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.
- 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.
- 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.
Symptoms
The Overview Agents strip stops reacting to dashboard scroll:
.overview-breakdownpanel no longer closes when the strip pins..overview-item-selectedbackground indefinitely.opens below the fold and the click looks like it did nothing.
Both symptoms always appear together — they share one input.
Model.OverviewAgentsStuckfreezes atfalse, so the two reducer arms that depend on it (SetOverviewAgentsStuck,SelectOverviewGroupin
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.observePinnedStateresolves.dashboard,.overview-agents-stick-sentineland.overview-agents-bandonce, inside a singlerequestAnimationFrame, and holds those elementreferences 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
querySelectorcalls miss,createPinnedObserversfalls through to| _ -> [], and nothing retries. The strip is glued frompage 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:
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-layouthas two unkeyed, same-typeddivchildren whose order depends onCanvas.CanvasPosition:Left/Toprender[canvas; dashboard],Right/Bottomrender[dashboard; canvas]. React reconciles by index, so crossing between those groups re-renders eachexisting 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
IntersectionObserveris ever constructed,and the class of the node it watches alternates:
Since the default is
RightandRight/Bottomnever reorder, many sessions never hit B.The same stale-or-absent capture also disables the
ResizeObserverthat recomputes--overview-agents-items-shift, so compact-strip circle centring can drift.Verification method
npm run buildin a clean checkout produceddist/assets/index-BGreE3HJ.js— the same filename andcontent hash as the deployed
wwwrootbundle at commit 4e6da04. That bundle was served on anisolated 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.
requestAnimationFrameplus captured handles with a capture-phasescrolllistener ondocumentthat re-queries.dashboardand the sentinel on each event, reuses the existing pureisPastStickyBoundary, and dispatches only on a flip. Precedent already in the tree:Navigation.withDashboardOnNextFramere-queries on every call, which is why the scroll effectsnever rotted while the observer did. Open question: where
setCircleShift's recompute lives oncethe
ResizeObservercapture goes away..app-layoutchildren (fixes B only):prop.key "dashboard"on the dashboarddivandprop.key "canvas"on the pane root. A/B-verified end to end. Also stops the wholecanvas-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
wwwroot, not the Vite dev server.OverviewPanelOpen = falseandCanvasPosition = Right(
WorktreeApi.fs), so an E2E test has to toggle Overview on and click a dock button.OverviewBandE2ETestsalready covers this behaviour and passes — it never changes the dock positionand happens to win the attach race.
Prevention
Two rules added to
.github/instructions/client-mvu.instructions.mdin #144: resolve DOM handleswhen 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.observeinstrumented. Requiresplaywright.Attach-rate probe (defect A)
Dock-position trace (defect B)
Harness setup
Uses isolated ports so it never touches the production instance on 5000.