Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/verify-berthd/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions .claude/skills/verify-berthd/features/workspace-tabs.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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");
}
82 changes: 82 additions & 0 deletions app/e2e/many-tabs.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
38 changes: 29 additions & 9 deletions app/src/components/workspace/pane-layer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(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<string>()).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);
Expand Down Expand Up @@ -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" }}
>
<PaneGuard wsKey={key} tab={tab.id} pane={leaf.id}>
<Pane wsKey={key} tab={tab.id} pane={leaf} visible={on} focused={tab.focus === leaf.id} split={false} mixed compare={{ tab: tab.id, pane: leaf.id, side: i as 0 | 1, sync: c.sync }} />
</PaneGuard>
{wake(leaf.id, on) && (
<PaneGuard wsKey={key} tab={tab.id} pane={leaf.id}>
<Pane wsKey={key} tab={tab.id} pane={leaf} visible={on} focused={tab.focus === leaf.id} split={false} mixed compare={{ tab: tab.id, pane: leaf.id, side: i as 0 | 1, sync: c.sync }} />
</PaneGuard>
)}
</div>
);
}),
...(c.parked ?? []).map((leaf) => (
<div key={leaf.id} className="absolute inset-0 overflow-hidden" style={{ display: "none" }}>
<PaneGuard wsKey={key} tab={tab.id} pane={leaf.id}>
<Pane wsKey={key} tab={tab.id} pane={leaf} visible={false} focused={false} split={false} mixed />
</PaneGuard>
{wake(leaf.id, false) && (
<PaneGuard wsKey={key} tab={tab.id} pane={leaf.id}>
<Pane wsKey={key} tab={tab.id} pane={leaf} visible={false} focused={false} split={false} mixed />
</PaneGuard>
)}
</div>
)),
];
Expand All @@ -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" }}
>
<PaneGuard wsKey={key} tab={tab.id} pane={leaf.id}>
<Pane wsKey={key} tab={tab.id} pane={leaf} visible={visible} focused={tab.focus === leaf.id} split={split} mixed={several} />
</PaneGuard>
{wake(leaf.id, visible) && (
<PaneGuard wsKey={key} tab={tab.id} pane={leaf.id}>
<Pane wsKey={key} tab={tab.id} pane={leaf} visible={visible} focused={tab.focus === leaf.id} split={split} mixed={several} />
</PaneGuard>
)}
</div>
)),
...(visible ? dividers.map((d) => <DividerHandle key={d.id} d={d} area={area} onRatio={(r) => resizeSplit(key, tab.id, d.id, r)} />) : []),
Expand Down
5 changes: 5 additions & 0 deletions docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down