Skip to content

Agents strip stops reacting to scroll: drill-down will not close, pinned click will not scroll up #145

Description

@0101

Symptoms

The Overview Agents strip stops reacting to dashboard scroll:

  • An open .overview-breakdown panel no longer closes when the strip pins.
  • The selected group keeps its .overview-item-selected background indefinitely.
  • Clicking a group from the pinned strip no longer scrolls the dashboard to the top, so the panel
    opens below the fold and the click looks like it did nothing.

Both symptoms always appear together — they share one input. Model.OverviewAgentsStuck freezes at
false, so the two reducer arms that depend on it (SetOverviewAgentsStuck, SelectOverviewGroup
in App.fs) go quiet. The reducers themselves are correct.

It presents as intermittent, which makes it easy to dismiss: a reload or a canvas dock change can
make it start working again without anything being fixed.

Cause

OverviewBand.observePinnedState resolves .dashboard, .overview-agents-stick-sentinel and
.overview-agents-band once, inside a single requestAnimationFrame, and holds those element
references for the lifetime of the subscription. Its Elmish key ["overview-sticky"] never changes,
so it never re-attaches. That yields two independent defects.

A — Attach race (majority of fresh loads — see correction below)

If the frame runs before React has committed the band, all three querySelector calls miss,
createPinnedObservers falls through to | _ -> [], and nothing retries. The strip is glued from
page load with no interaction at all.

The rate below was measured through a response interceptor and is optimistic; without
interception it is 16/20 loads broken. See the correction comment for the latency dependency.

Measured against the production bundle, 12 fresh loads, no clicking, canvas pane closed:

run  3: observed=[]  sentinelInDom=true  scrollClosesDrilldown=false
run  6: observed=[]  sentinelInDom=true  scrollClosesDrilldown=false
run  8: observed=[]  sentinelInDom=true  scrollClosesDrilldown=false
run 10: observed=[]  sentinelInDom=true  scrollClosesDrilldown=false

attached on 8/12 fresh loads

This only affects the path where the Overview panel is already open at first load — the persisted
production default — not the toggle-it-open path. It did not surface under the Vite dev server.

B — Slot binding (canvas docked left or top)

.app-layout has two unkeyed, same-typed div children whose order depends on
Canvas.CanvasPosition: Left/Top render [canvas; dashboard], Right/Bottom render
[dashboard; canvas]. React reconciles by index, so crossing between those groups re-renders each
existing DOM node with the other subtree instead of moving it. The captured reference therefore
tracks layout slot #0, which is the dashboard only while the canvas is docked right or bottom.

Tracing one page through all four positions — a single IntersectionObserver is ever constructed,
and the class of the node it watches alternates:

1 after load        : observed=["overview-agents-stick-sentinel"]
2 after canvas open : observed=["overview-agents-stick-sentinel"]
3 after dock Left   : observed=["canvas-tab-bar"]                  <- watching canvas chrome
3 after dock Top    : observed=["canvas-tab-bar"]
3 after dock Right  : observed=["overview-agents-stick-sentinel"]  <- repaired by accident
3 after dock Bottom : observed=["overview-agents-stick-sentinel"]
Dock change Slot #0 holds Agents strip
Right to Bottom (or Left to Top) unchanged works
Right to Left canvas broken
Right to Top canvas broken
Bottom to Left canvas broken
Left to Right (dock back) dashboard repairs itself

Since the default is Right and Right/Bottom never reorder, many sessions never hit B.

The same stale-or-absent capture also disables the ResizeObserver that recomputes
--overview-agents-items-shift, so compact-strip circle centring can drift.

Verification method

npm run build in a clean checkout produced dist/assets/index-BGreE3HJ.js — the same filename and
content hash as the deployed wwwroot bundle at commit 4e6da04. That bundle was served on an
isolated port; the production instance was never touched. So this is not a dev-server artifact.

Suggested fix

Two changes; the first is the one that matters.

  1. Stop capturing, re-resolve per event (fixes A and B). Replace the one-shot
    requestAnimationFrame plus captured handles with a capture-phase scroll listener on
    document that re-queries .dashboard and the sentinel on each event, reuses the existing pure
    isPastStickyBoundary, and dispatches only on a flip. Precedent already in the tree:
    Navigation.withDashboardOnNextFrame re-queries on every call, which is why the scroll effects
    never rotted while the observer did. Open question: where setCircleShift's recompute lives once
    the ResizeObserver capture goes away.
  2. Key the two .app-layout children (fixes B only): prop.key "dashboard" on the dashboard
    div and prop.key "canvas" on the pane root. A/B-verified end to end. Also stops the whole
    canvas-pane subtree — including its doc iframe — being destroyed and rebuilt on every dock
    change, which currently defeats the "morph the iframe in place without swapping its src" design.

Shipping only 2 leaves roughly a third of loads still glued.

Verification for a fix

  • 12 fresh loads must all close the drill-down on scroll (currently 8/12).
  • Every dock transition must keep working, not just those leaving the dashboard in slot #0.
  • Must be exercised against the built bundle served from wwwroot, not the Vite dev server.
  • Fixture mode hard-codes OverviewPanelOpen = false and CanvasPosition = Right
    (WorktreeApi.fs), so an E2E test has to toggle Overview on and click a dock button.

OverviewBandE2ETests already covers this behaviour and passes — it never changes the dock position
and happens to win the attach race.

Prevention

Two rules added to .github/instructions/client-mvu.instructions.md in #144: resolve DOM handles
when a callback runs rather than at attach time, and key a subscription by every input its attached
resource depends on.

Repro scripts

Both run against a fixture-mode server plus the built bundle on an isolated port, with
IntersectionObserver.observe instrumented. Requires playwright.

Attach-rate probe (defect A)
import { chromium } from 'playwright';

const url = 'http://localhost:5311/';

const instrumentation = () => {
  window.__observed = [];
  const Orig = window.IntersectionObserver;
  class Wrapped extends Orig {
    observe(el) { window.__observed.push(el); super.observe(el); }
  }
  window.IntersectionObserver = Wrapped;
};

const browser = await chromium.launch();
let attached = 0;
const runs = 12;

for (let i = 0; i < runs; i++) {
  const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
  await page.addInitScript(instrumentation);
  // Fixture mode hard-codes OverviewPanelOpen=false; production persists it open.
  await page.route('**/IWorktreeApi/**', async route => {
    try {
      const res = await route.fetch();
      const body = (await res.text()).replace(/"OverviewPanelOpen":\s*false/g, '"OverviewPanelOpen":true');
      await route.fulfill({ response: res, body });
    } catch { /* page closed */ }
  });
  await page.goto(url, { waitUntil: 'domcontentloaded' });
  await page.locator('.overview-agents-band').waitFor({ timeout: 20000 });
  await page.waitForTimeout(1500);

  const r = await page.evaluate(() => ({
    observed: window.__observed.map(e => e.className || '(no class)'),
    liveSentinel: !!document.querySelector('.overview-agents-stick-sentinel'),
  }));

  await page.locator('.overview-agents-band .overview-item').first().click();
  await page.waitForTimeout(300);
  const box = await page.locator('.dashboard').boundingBox();
  await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
  await page.mouse.wheel(0, 250);
  await page.waitForTimeout(600);
  const closes = await page.evaluate(() =>
    document.querySelector('.dashboard').scrollTop > 20 && !document.querySelector('.overview-breakdown'));

  if (r.observed.length) attached++;
  console.log(`run ${String(i + 1).padStart(2)}: observed=${JSON.stringify(r.observed)} scrollClosesDrilldown=${closes}`);
  await page.unrouteAll({ behavior: 'ignoreErrors' });
  await page.close();
}

console.log(`\nattached on ${attached}/${runs} fresh loads`);
await browser.close();
Dock-position trace (defect B)
import { chromium } from 'playwright';

const url = 'http://localhost:5311/';

const instrumentation = () => {
  window.__observed = [];
  window.__ctors = 0;
  const Orig = window.IntersectionObserver;
  class Wrapped extends Orig {
    constructor(cb, opts) { super(cb, opts); window.__ctors++; }
    observe(el) { window.__observed.push(el); super.observe(el); }
  }
  window.IntersectionObserver = Wrapped;
};

const snap = label => `${label}: ` + JSON.stringify({
  ctors: window.__ctors,
  observed: window.__observed.map(e => e.className),
  liveSentinel: !!document.querySelector('.overview-agents-stick-sentinel'),
});

const POS = { Left: 0, Right: 1, Top: 2, Bottom: 3 };

const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.addInitScript(instrumentation);
await page.route('**/IWorktreeApi/**', async route => {
  try {
    const res = await route.fetch();
    const body = (await res.text()).replace(/"OverviewPanelOpen":\s*false/g, '"OverviewPanelOpen":true');
    await route.fulfill({ response: res, body });
  } catch { /* page closed */ }
});

await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.locator('.overview-agents-band').waitFor({ timeout: 20000 });
await page.waitForTimeout(700);
console.log(await page.evaluate(snap, '1 after load        '));

await page.locator('.header-controls .ctrl-btn', { hasText: 'Canvas' }).click();
await page.locator('.canvas-tab-bar').waitFor({ timeout: 5000 });
await page.waitForTimeout(600);
console.log(await page.evaluate(snap, '2 after canvas open '));

for (const pos of ['Left', 'Top', 'Right', 'Bottom']) {
  await page.locator('.canvas-pos-group .canvas-pos-btn').nth(POS[pos]).click();
  await page.waitForTimeout(800);
  console.log(await page.evaluate(snap, `3 after dock ${pos.padEnd(7)}`));
}

await browser.close();
Harness setup
npm run build
New-Item -ItemType Directory -Force -Path wwwroot | Out-Null
Copy-Item -Path dist\* -Destination wwwroot -Recurse -Force

$env:TREEMON_CONFIG_DIR = "<scratch dir>"   # keep the real config untouched
dotnet run --project src\Server -- "<repo root>" --port 5311 --canvas-port 5312 `
  --test-fixtures "src\Tests\fixtures\overview-band.json"

Uses isolated ports so it never touches the production instance on 5000.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions