From 0f8877f7502f806ed4ff5e1e8c8c8a522ed3dcf1 Mon Sep 17 00:00:00 2001 From: alice Date: Sat, 8 Aug 2026 22:20:13 +0000 Subject: [PATCH] fix(board): hold scroll across live re-renders, and order project lanes by recency (ready-6ec) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TWO things a reader hit while just looking at the board: 1. THE VIEW JUMPED ON SCROLL. Every live fold (setItems) and board-state reconcile (setBoards) does render()'s replaceChildren(), which empties #app — and the PAGE is the scroll container here (the shell grows with content, so nothing scrolls internally). Emptying it clamps the page scroll to 0, so a reader scrolled halfway down was yanked to the top again and again as the first-minute stream of updates arrived. Capture the page scroll (and the panes, in case the layout is ever made pane-scrolling) before the rebuild and restore it after; refocus with preventScroll so it cannot fight. Real-browser check: page scroll 700 -> 700 across a live fold. 2. PROJECT LANES WERE ORDERED BY GATE COUNT, so a project stale for a week floated to the top just because it carried gates (enterpriseaiframework). Gates now live in their own banner at the very top, surfaced regardless of lane order, so the lanes answer the question a scroller actually has: order by MOST-RECENT activity, ties broken by name for a total (stable) order. Full board suite 1007 passing; tsc clean; scroll-hold verified in a real browser. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/board/src/board/render.test.ts | 34 +++++++++++++++++ web/board/src/board/render.ts | 59 ++++++++++++++++++++++++++---- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/web/board/src/board/render.test.ts b/web/board/src/board/render.test.ts index c6c16f1..0a43fcc 100644 --- a/web/board/src/board/render.test.ts +++ b/web/board/src/board/render.test.ts @@ -256,6 +256,40 @@ describe("the gate rail is a stack of self-contained cards", () => { }); }); +describe("project lanes and scroll stability", () => { + const DAY = 86400 * 1e9; + + it("orders project lanes by MOST-RECENT activity, not by gate count", () => { + const now = Date.now() * 1e6; + const items = [ + // "stale" project: touched a week ago, and carries a gate — which used to + // float it to the top under the old gated-first order. + makeItem({ id: "s1", project: "stale", status: "waiting", waitingType: "gate", gateMsgId: "m", gate: "design", updatedAt: now - 8 * DAY }), + // "fresh" project: touched yesterday, no gate. + makeItem({ id: "f1", project: "fresh", status: "active", updatedAt: now - 1 * DAY }), + ]; + ws = mountBoardWorkspace(container, items, { + boards: [{ coord: "stale", title: "stale" }, { coord: "fresh", title: "fresh" }], + }); + const laneNames = [...container.querySelectorAll(".swimlane .lane-name")].map((n) => n.textContent); + expect(laneNames.indexOf("fresh")).toBeLessThan(laneNames.indexOf("stale")); + expect(laneNames[0]).toBe("fresh"); + }); + + it("preserves the page scroll position across a live re-render", () => { + const items = Array.from({ length: 30 }, (_, i) => + makeItem({ id: `i${i}`, status: "active", project: "ready", updatedAt: Date.now() * 1e6 - i * 1000 }), + ); + ws = mountBoardWorkspace(container, items, { boards: [{ coord: "ready", title: "ready" }] }); + const scroller = document.scrollingElement ?? document.documentElement; + scroller.scrollTop = 250; + // a live fold arrives — the board rebuilds (replaceChildren), but the reader + // stays where they scrolled to instead of being yanked to the top. + ws.setItems([...items, makeItem({ id: "new", status: "active", project: "ready" })]); + expect((document.scrollingElement ?? document.documentElement).scrollTop).toBe(250); + }); +}); + describe("board identity: names, never coordinates", () => { const COORD = "30301:a9f766ae56bbf466d2d361e5b1788b7cd689fd8e3b418e35b002b313f478db25:dontguess"; diff --git a/web/board/src/board/render.ts b/web/board/src/board/render.ts index 5d0d961..cdf17f6 100644 --- a/web/board/src/board/render.ts +++ b/web/board/src/board/render.ts @@ -365,9 +365,11 @@ export class BoardWorkspace { */ setItems(items: Item[]): void { const focus = this.captureFocusedField(); + const scroll = this.captureScroll(); this.items = items; this.render(); this.restoreFocusedField(focus); + this.restoreScroll(scroll); } /** @@ -385,11 +387,13 @@ export class BoardWorkspace { */ setBoards(boards: BoardRef[]): void { const focus = this.captureFocusedField(); + const scroll = this.captureScroll(); this.boards = boards; this.boardTitles = new Map(boards.map((b) => [b.coord, b.title])); this.boardByCoord = new Map(boards.map((b) => [b.coord, b])); this.render(); this.restoreFocusedField(focus); + this.restoreScroll(scroll); } /** @@ -401,9 +405,44 @@ export class BoardWorkspace { */ setNotice(notice: string | undefined): void { const focus = this.captureFocusedField(); + const scroll = this.captureScroll(); this.notice = notice; this.render(); this.restoreFocusedField(focus); + this.restoreScroll(scroll); + } + + /** + * The board re-renders on every live fold (setItems) and every board-state + * reconcile (setBoards), each of which does render()'s replaceChildren() — a + * fresh .board-center / .left-tree whose scrollTop is 0. During the first + * minute those calls arrive in a stream, so a reader scrolled halfway down the + * board was yanked back to the top again and again (ready-...). Capturing the + * two independent scroll positions before the rebuild and restoring them after + * keeps the view where the reader put it — the page updates under them without + * moving what they are looking at. + */ + private scroller(): Element { + return document.scrollingElement ?? document.documentElement; + } + + private captureScroll(): { page: number; center: number; tree: number } { + // The page itself is the scroll container (the shell grows with content), so + // that is the one that must survive replaceChildren — the panes are captured + // too so the fix still holds if the layout is ever made pane-scrolling. + return { + page: this.scroller().scrollTop, + center: this.container.querySelector(".board-center")?.scrollTop ?? 0, + tree: this.container.querySelector(".left-tree")?.scrollTop ?? 0, + }; + } + + private restoreScroll(s: { page: number; center: number; tree: number }): void { + this.scroller().scrollTop = s.page; + const center = this.container.querySelector(".board-center"); + const tree = this.container.querySelector(".left-tree"); + if (center) center.scrollTop = s.center; + if (tree) tree.scrollTop = s.tree; } private captureFocusedField(): FocusedField | undefined { @@ -440,7 +479,9 @@ export class BoardWorkspace { const next = [...scope.querySelectorAll("input")].find((n) => n.classList.contains(f.cls)); if (!next) continue; next.value = f.value; - next.focus(); + // preventScroll: refocusing must not scroll the field into view — that + // would fight restoreScroll and yank the board (ready-...). + next.focus({ preventScroll: true }); if (f.start !== null && f.end !== null) next.setSelectionRange(f.start, f.end); return; } @@ -925,7 +966,7 @@ export class BoardWorkspace { this.setQuery(find.value); const next = this.container.querySelector("input.find"); if (next) { - next.focus(); + next.focus({ preventScroll: true }); if (caret !== null) next.setSelectionRange(caret, caret); } }); @@ -1409,12 +1450,16 @@ export class BoardWorkspace { list.push(item); groups.set(key, list); } - // Most gated first, then largest — the prototype's project-lane order: - // the lane that needs a human decision should not be below the fold. - const gateCount = (key: string): number => - items.filter((i) => this.projectKeyOf(i) === key && gatesFilter()(i)).length; + // MOST RECENTLY ACTIVE FIRST (ready-...). The prototype led with the most- + // gated project so a decision was never below the fold — but gates now live + // in their own banner at the very top, surfaced regardless of lane order, so + // the lanes are free to answer the question a reader actually has scrolling + // the board: "what have I touched lately?". A project stale for a week no + // longer floats to the top just because it carries gates. Ties break on + // name so the order is total (stable across re-renders). + const lastActive = (its: Item[]): number => its.reduce((m, i) => Math.max(m, i.updatedAt), 0); return [...groups.entries()] - .sort(([a, ia], [b, ib]) => gateCount(b) - gateCount(a) || ib.length - ia.length || a.localeCompare(b)) + .sort(([a, ia], [b, ib]) => lastActive(ib) - lastActive(ia) || a.localeCompare(b)) .map(([key, its]) => ({ key, label: this.projectNameOf(key), items: its })); } // epic