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..24cf4906b --- /dev/null +++ b/app/e2e/many-tabs.spec.ts @@ -0,0 +1,82 @@ +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 }) => { + // 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. + 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)} />) : []), 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