From 7d03467065660ffebcf778727a1dcbc64b9c6ebf Mon Sep 17 00:00:00 2001 From: Stener1 Date: Fri, 6 Nov 2026 15:09:14 +0000 Subject: [PATCH 1/2] fix(app): a saved tab's pane mounts when it first shows, not all at launch Every session started in an open worktree gets a tab, and the tab outlives the session. A main checkout that agents start in collects hundreds. At launch PaneLayer mounted every one of them, each with its terminal and its chat: on a save of ~650 tabs the Mac app's window reached 8-9 GB within a minute and stopped answering. A pane's content now mounts the first time it shows and stays mounted after, as before. Co-Authored-By: Claude Opus 5.5 --- .../skills/verify-berthd/features/README.md | 1 + .../verify-berthd/features/workspace-tabs.md | 23 ++++++ .../scripts/scenarios/many-tabs-launch.mjs | 66 +++++++++++++++ app/e2e/many-tabs.spec.ts | 80 +++++++++++++++++++ app/src/components/workspace/pane-layer.tsx | 38 ++++++--- 5 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 .claude/skills/verify-berthd/features/workspace-tabs.md create mode 100644 .claude/skills/verify-berthd/scripts/scenarios/many-tabs-launch.mjs create mode 100644 app/e2e/many-tabs.spec.ts diff --git a/.claude/skills/verify-berthd/features/README.md b/.claude/skills/verify-berthd/features/README.md index 11a07f637..a94ce9a94 100644 --- a/.claude/skills/verify-berthd/features/README.md +++ b/.claude/skills/verify-berthd/features/README.md @@ -24,6 +24,7 @@ The maintained list of what a person can do in the app, and how to prove each on | The board's cards for agents in chat: what they ask with its answers, their reply's last lines, the prompt they started with; never the host's frames or "stdio" | [chat-board-cards.md](chat-board-cards.md) | `chat-board-cards`; `app/e2e/dashboard-chat-cards.spec.ts` asserts it | | Worktrees changed with git outside Shipyard: added, committed in, switched, locked, removed; a new remote; a quiet box runs no git for them | [worktree-git.md](worktree-git.md) | `worktree-git-live` (a real box, `--agent`); `TestListingLocationsAgainRunsNoGitUntilSomethingChanges` | | Turn check's "working": an hourglass with why in the sidebar, the tab and the rail's card, not a live turn's spinner; a handoff reads as done | [turn-check-indicator.md](turn-check-indicator.md) | `turn-check-indicator`; `app/e2e/sidebar-turn-check.spec.ts` asserts it, `TestTurnCheckLive` asks the real provider | +| A worktree's saved tabs: restored on launch; a pane mounts when its tab first shows and stays, so hundreds of saved tabs don't load at once | [workspace-tabs.md](workspace-tabs.md) | `many-tabs-launch`; `app/e2e/many-tabs.spec.ts` asserts it | | Upgrading a box: sessions keep running, and nothing the old build started stays a zombie under berthd | [box-upgrade.md](box-upgrade.md) | `../scripts/upgrade-zombies.sh FROM TO` (a real box, Linux); `go test ./internal/childreap` | ## Proof and skip reporting diff --git a/.claude/skills/verify-berthd/features/workspace-tabs.md b/.claude/skills/verify-berthd/features/workspace-tabs.md new file mode 100644 index 000000000..f0fe75d8b --- /dev/null +++ b/.claude/skills/verify-berthd/features/workspace-tabs.md @@ -0,0 +1,23 @@ +# A worktree's saved tabs + +A worktree's tabs are saved (`berth.workspaces` in local storage, `app/src/lib/workspaces.ts`) and come back on the next launch. Every session started in a worktree that is open gets a tab of its own (`reconcile`), and the tab stays after the session ends, so it can be started again. A worktree that every agent starts in, a repo's main checkout, collects hundreds of them. + +## Sub-features + +- `tabs-restore`: the tabs, their order, the active one and each pane's view (chat or terminal) are as they were left. +- `tabs-mount-when-shown`: a pane's content (its terminal, its chat) mounts the first time its tab shows, then stays mounted while the window is open, so going back to it is instant and its terminal keeps its screen and connection (`components/workspace/pane-layer.tsx`). A launch draws only the tab in front, however many are saved; a pane never shown is an empty box (`[data-testid="pane"]` with no children). + +## How to get to it (user POV) + +- Have many tabs in one worktree (a main checkout where agents are started), quit and launch again: the window draws at once and stays responsive. +- Click an old tab: it draws; click back: the first is as it was. + +## Driving it with drive.mjs + +- `node .claude/skills/verify-berthd/scripts/drive.mjs many-tabs-launch --port $PORT` seeds a save of `checkout-fix` with 300 tabs of ended sessions and its agent's tab in front (the scenario's `init`), then asserts one pane drawn at launch, two after opening "Old task 7", still two after going back. It logs the page's DOM nodes at launch and after. +- `app/e2e/many-tabs.spec.ts` asserts the same, and that the first pane's element is the same one when it shows again (not remounted). + +## Gotchas + +- Memory itself can't be read on the mock: the panes of ended sessions there draw little. On a real box with a save of ~650 tabs, before this the Mac app's window (WKWebView) reached 8–9 GB within a minute; Playwright's WebKit on Linux reproduced it (0.8 GB → 6.1 GB in 150 s, DOM nodes 22k → 59k and climbing); with it, ~1.0 GB and ~10k nodes, flat. That needs a person's save and a real box, so it stays out of the scenario. +- The tab strip still draws every tab: hundreds of them is a long strip. diff --git a/.claude/skills/verify-berthd/scripts/scenarios/many-tabs-launch.mjs b/.claude/skills/verify-berthd/scripts/scenarios/many-tabs-launch.mjs new file mode 100644 index 000000000..3e74b2774 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/many-tabs-launch.mjs @@ -0,0 +1,66 @@ +// A launch on a worktree with hundreds of saved tabs (features/workspace-tabs.md): +// only the tab showing has its pane drawn; another draws when it is opened and +// stays drawn after. Before, every saved tab's pane mounted at launch, each +// with its terminal and chat, and the window grew to gigabytes. + +const A = "devl:/home/me/work/shop-checkout-fix"; +const OLD = 300; + +export const initArg = { + current: A, + shown: [A], + folded: [], + tones: {}, + recentUrls: [], + spaces: { + [A]: { + ref: { box: "devl", location: "shop", worktree: "checkout-fix", path: "/home/me/work/shop-checkout-fix" }, + tabs: [ + ...Array.from({ length: OLD }, (_, i) => ({ + id: `old-${i}`, + root: { kind: "leaf", id: `old-pane-${i}`, content: { kind: "terminal", box: "devl", session: `old-task-${i}`, agent: "claude", command: "claude", title: `Old task ${i}`, view: "conversation" } }, + focus: `old-pane-${i}`, + })), + { id: "agent-tab", root: { kind: "leaf", id: "own", content: { kind: "terminal", box: "devl", session: "checkout-fix-claude", agent: "claude", command: "claude", view: "terminal" } }, focus: "own" }, + ], + hidden: [], + known: ["checkout-fix-claude", "svc-shop-checkout-fix-web", ...Array.from({ length: OLD }, (_, i) => `old-task-${i}`)], + visitedAt: 1, + active: "agent-tab", + }, + }, +}; + +// Seeded once, as a save left by an earlier run; a reload keeps what the app saved. +export const init = (state) => { + if (sessionStorage.getItem("verify.seeded")) return; + sessionStorage.setItem("verify.seeded", "1"); + localStorage.setItem("berth.prefs", JSON.stringify({ whatsNewSeen: "999.0.0" })); + localStorage.setItem("berth.workspaces", JSON.stringify(state)); +}; + +export async function run({ page, shot, step, expect }) { + const drawn = page.locator('[data-testid="pane"]:has(> *)'); + const nodes = () => page.evaluate(() => document.getElementsByTagName("*").length); + + step(`launch on checkout-fix with ${OLD} saved tabs of ended sessions and its agent's tab in front`); + await expect(page.getByRole("tab", { name: /Fix checkout webhook retries/ })).toHaveAttribute("aria-selected", "true"); + await expect(page.getByRole("tab", { name: /Old task 299\b/ })).toHaveCount(1); + expect(await page.locator('[data-testid="pane"]').count()).toBeGreaterThan(OLD); + await expect(drawn).toHaveCount(1); + step(`one pane drawn of ${await page.locator('[data-testid="pane"]').count()}; ${await nodes()} DOM nodes`); + await shot("1-launch"); + + step("open 'Old task 7': its pane draws, the agent's stays drawn behind it"); + await page.getByRole("tab", { name: /Old task 7\b/ }).click(); + await expect(page.getByRole("tab", { name: /Old task 7\b/ })).toHaveAttribute("aria-selected", "true"); + await expect(drawn).toHaveCount(2); + await shot("2-old-task"); + + step("back to the agent's tab: nothing more is drawn, nothing redrawn"); + await page.getByRole("tab", { name: /Fix checkout webhook retries/ }).click(); + await expect(page.getByRole("tab", { name: /Fix checkout webhook retries/ })).toHaveAttribute("aria-selected", "true"); + await expect(drawn).toHaveCount(2); + step(`${await nodes()} DOM nodes`); + await shot("3-back"); +} diff --git a/app/e2e/many-tabs.spec.ts b/app/e2e/many-tabs.spec.ts new file mode 100644 index 000000000..306560492 --- /dev/null +++ b/app/e2e/many-tabs.spec.ts @@ -0,0 +1,80 @@ +import { type App, expect, mockOnly, test } from "./fixtures"; + +// A worktree that every agent starts in collects a tab for each session, and +// they outlive the sessions: hundreds of them, saved. Launching on that used +// to mount every tab's pane at once, each with its terminal and chat, which +// took the window to gigabytes and stalled it before anything drew. A pane +// mounts the first time it shows, and stays mounted from then on. + +const A = "devl:/home/me/work/shop-checkout-fix"; +const OLD = 150; + +const tab = (i: number) => ({ + id: `old-${i}`, + root: { kind: "leaf", id: `old-pane-${i}`, content: { kind: "terminal", box: "devl", session: `old-task-${i}`, agent: "claude", command: "claude", title: `Old task ${i}`, view: "conversation" } }, + focus: `old-pane-${i}`, +}); + +const saved = { + current: A, + shown: [A], + folded: [], + tones: {}, + recentUrls: [], + spaces: { + [A]: { + ref: { box: "devl", location: "shop", worktree: "checkout-fix", path: "/home/me/work/shop-checkout-fix" }, + tabs: [ + ...Array.from({ length: OLD }, (_, i) => tab(i)), + { id: "agent-tab", root: { kind: "leaf", id: "own", content: { kind: "terminal", box: "devl", session: "checkout-fix-claude", agent: "claude", command: "claude", view: "terminal" } }, focus: "own" }, + ], + hidden: [], + known: ["checkout-fix-claude", "svc-shop-checkout-fix-web", ...Array.from({ length: OLD }, (_, i) => `old-task-${i}`)], + visitedAt: 1, + active: "agent-tab", + }, + }, +}; + +async function openWith(app: App, ws: unknown) { + await app.context.addInitScript((state) => { + if (sessionStorage.getItem("e2e.seeded")) return; + sessionStorage.setItem("e2e.seeded", "1"); + localStorage.setItem("berth.prefs", JSON.stringify({ whatsNewSeen: "999.0.0" })); + localStorage.setItem("berth.workspaces", JSON.stringify(state)); + }, ws); + await app.open(); +} + +// Panes whose content is mounted: a pane not yet shown is an empty box. +const drawn = (app: App) => app.page.locator('[data-testid="pane"]:has(> *)'); + +test.beforeEach(() => mockOnly("seeds a save of the mock's worktrees")); + +test("a worktree with many saved tabs mounts only the one showing, and a tab once shown stays mounted", async ({ app }) => { + await openWith(app, saved); + await expect(app.page.getByRole("region", { name: "Claude Code, checkout-fix" })).toBeVisible(); + // Every tab is in the strip, but only the active one has its pane drawn. + await expect(app.page.getByRole("tab", { name: /Old task 7\b/ })).toHaveCount(1); + expect(await app.page.locator('[data-testid="pane"]').count()).toBeGreaterThan(OLD); + await expect(drawn(app)).toHaveCount(1); + + // The active tab's own content, held on to: the same element when it + // shows again means it stayed mounted (a remount draws a new one). + const own = await drawn(app).locator('[role="region"]').first().elementHandle(); + if (!own) throw new Error("the active tab's pane drew nothing"); + + await app.page.getByRole("tab", { name: /Old task 7\b/ }).click(); + await expect(app.page.getByRole("tab", { name: /Old task 7\b/ })).toHaveAttribute("aria-selected", "true"); + await expect(drawn(app)).toHaveCount(2); + expect(await own.isVisible()).toBe(false); + + await app.page.getByRole("tab", { name: /Old task 8\b/ }).click(); + await expect(drawn(app)).toHaveCount(3); + + await app.page.getByRole("tab", { name: /Old task 7\b/ }).click(); + await app.page.getByRole("tab", { name: /Fix checkout webhook retries/ }).click(); + await expect.poll(() => own.isVisible()).toBe(true); + expect(await own.evaluate((el) => el.isConnected)).toBe(true); + await expect(drawn(app)).toHaveCount(3); +}); diff --git a/app/src/components/workspace/pane-layer.tsx b/app/src/components/workspace/pane-layer.tsx index d498f5c23..fc663c850 100644 --- a/app/src/components/workspace/pane-layer.tsx +++ b/app/src/components/workspace/pane-layer.tsx @@ -20,8 +20,22 @@ const pct = (n: number) => `${n * 100}%`; // hidden, moved to another tab, or its worktree switched away from: React // never remounts it. They are sorted by id, so a pane moving between tabs // never moves in the DOM either (a moved frame would reload). +// +// A pane's content mounts the first time it shows, and stays from then on. +// A workspace can hold hundreds of tabs (every session started in a +// worktree gets one, and they outlive the session): mounting them all at +// launch, each with its terminal and chat, took the window to gigabytes +// and stalled it for minutes before the first one drew. export function PaneLayer({ showing }: { showing: boolean }) { const area = useRef(null); + // Panes shown at least once since launch, by id. Only ever grows, so + // adding to it while rendering gives the same answer however often React + // renders. + const woken = useRef(new Set()).current; + const wake = (id: string, on: boolean) => { + if (on) woken.add(id); + return woken.has(id); + }; const current = useWorkspaces((s) => s.current); const mounted = useWorkspaces((s) => s.mounted); const spaces = useWorkspaces((s) => s.spaces); @@ -69,17 +83,21 @@ export function PaneLayer({ showing }: { showing: boolean }) { className={cn("absolute overflow-hidden", r && r.x > 0 && "border-l", r && r.y > 0 && "border-t")} style={{ left: pct(r?.x ?? 0), width: pct(r?.w ?? 1), top: `calc(${COMPARE_BAR}px + (100% - ${COMPARE_BAR}px) * ${r?.y ?? 0})`, height: `calc((100% - ${COMPARE_BAR}px) * ${r?.h ?? 1})`, display: on ? "block" : "none" }} > - - - + {wake(leaf.id, on) && ( + + + + )} ); }), ...(c.parked ?? []).map((leaf) => (
- - - + {wake(leaf.id, false) && ( + + + + )}
)), ]; @@ -98,9 +116,11 @@ export function PaneLayer({ showing }: { showing: boolean }) { className={cn("absolute overflow-hidden", rect.x > 0 && "border-l", rect.y > 0 && "border-t")} style={{ left: pct(rect.x), top: pct(rect.y), width: pct(rect.w), height: pct(rect.h), display: visible ? "block" : "none" }} > - - - + {wake(leaf.id, visible) && ( + + + + )} )), ...(visible ? dividers.map((d) => resizeSplit(key, tab.id, d.id, r)} />) : []), From 2b040e0133d60debdb3c568193e568f43ace9177 Mon Sep 17 00:00:00 2001 From: Stener1 Date: Fri, 6 Nov 2026 21:28:38 +0000 Subject: [PATCH 2/2] test(app): the many-tabs launch gets the slow-test budget, and the fix its changelog line Seeding 150 tabs takes 12-36 s on a loaded runner, which timed out at the default 30 s in 2 of 5 runs with every assertion passing. Co-Authored-By: Claude Opus 5.5 --- app/e2e/many-tabs.spec.ts | 2 ++ docs/changelog.mdx | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/app/e2e/many-tabs.spec.ts b/app/e2e/many-tabs.spec.ts index 306560492..24cf4906b 100644 --- a/app/e2e/many-tabs.spec.ts +++ b/app/e2e/many-tabs.spec.ts @@ -52,6 +52,8 @@ const drawn = (app: App) => app.page.locator('[data-testid="pane"]:has(> *)'); test.beforeEach(() => mockOnly("seeds a save of the mock's worktrees")); test("a worktree with many saved tabs mounts only the one showing, and a tab once shown stays mounted", async ({ app }) => { + // 150 tabs in the strip: 12–36 s on a loaded runner, past the default 30. + test.slow(); await openWith(app, saved); await expect(app.page.getByRole("region", { name: "Claude Code, checkout-fix" })).toBeVisible(); // Every tab is in the strip, but only the active one has its pane drawn. diff --git a/docs/changelog.mdx b/docs/changelog.mdx index cc4ef4e5a..629dc81d1 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -10,6 +10,11 @@ upgrades with `berth upgrade BOX`, or by running the again. ## Unreleased +- **A worktree with hundreds of tabs opens at once.** Every session started + in a worktree leaves a tab, and the app drew every one of them, with its + terminal and chat, as it started: the Mac app's window could reach + gigabytes and stop answering. A tab's pane is now drawn the first time you + open it, and stays as it was when you go back. - **Quiet projects are grayed out.** On the rail and in the two-column sidebar, a project with no agent or shell open in it is grayed out, so the ones you're using stand out. Switching to one of those opens New