Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8eee741
fix(app): archiving a worktree no longer holds its dialog open (BRA-2…
Stener1 Oct 21, 2026
693fc3d
feat(app): bulk Stop and Archive carry on in a background-jobs tray
Stener1 Oct 22, 2026
c87daa0
test(verify): the background-jobs tray in the stop and archive scenarios
Stener1 Oct 22, 2026
15de7ba
test(verify): bulk Stop and Archive… on a real box, read back from git
Stener1 Oct 22, 2026
e87e3cb
refactor(app): one home for archive wording, tidier tray
Stener1 Oct 22, 2026
35fd68f
fix(app): a queued archive looks again before it goes, and can still …
Stener1 Oct 22, 2026
5af7aa1
fix(app): Stop the rest pressed while an archive looks for agents hol…
Stener1 Oct 22, 2026
2c3e9e7
feat(box): a bulk Stop runs on the box, so closing the laptop doesn't…
Stener1 Oct 27, 2026
eee2172
fix(box): a stop run resumes an archive cut short by berthd stopping,…
Stener1 Oct 27, 2026
d908ec4
fix(app): a stop run is kept for a relaunch as soon as its box takes …
Stener1 Oct 27, 2026
b5c8519
fix(box): a box's stop runs archive one worktree at a time, and no ag…
Stener1 Oct 27, 2026
c1b6a4f
fix(app): a stop run's start is asked until its box answers, and Arch…
Stener1 Oct 27, 2026
f91e5c9
fix(app): the review's small ones: runPool takes every item, one plur…
Stener1 Oct 27, 2026
0b75013
docs: stop runs and Archive… as the box does them, in the changelog, …
Stener1 Oct 27, 2026
13d0b1a
test(verify): the mock's stop runs archive in their box's line, and t…
Stener1 Oct 27, 2026
5f91faa
test(verify): a bulk Stop whose box goes away as Stop is pressed, on …
Stener1 Oct 27, 2026
61cd29b
fix(app): Stop the rest outlives a relaunch, and an archive script th…
Stener1 Oct 27, 2026
1960b34
fix(box): an archive that fails says why, and one stopped halfway can…
Stener1 Oct 29, 2026
cb07a00
test(verify): a removal git doesn't finish in time, on a real box
Stener1 Oct 29, 2026
edc22d9
fix(box): a half-removed worktree reads as one from an older listing too
Stener1 Oct 29, 2026
2401b1d
fix(box): a half-removed worktree that lost its .gitignore files can …
Stener1 Oct 29, 2026
0f096fc
test(verify): the interrupted removal also loses its .gitignore, as a…
Stener1 Oct 29, 2026
db2bb93
test(verify): the interrupted-removal scenario removes its stand-in g…
Stener1 Oct 29, 2026
c468a35
fix(box): a half-removed worktree that will be kept tears nothing dow…
Stener1 Oct 29, 2026
306e3c7
fix: the review's small ones: tests that can't pass vacuously or hang…
Stener1 Oct 29, 2026
1fbd0a0
fix(box): a stop run that finishes a gone review worktree drops its r…
Stener1 Oct 29, 2026
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
3 changes: 2 additions & 1 deletion .claude/skills/verify-berthd/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ The maintained list of what a person can do in the app, and how to prove each on
| Sidebar places: Pinned, More, Hidden, counts on rows (Home's waiting, Review's, a plugin's) | [sidebar-nav.md](sidebar-nav.md) | `plugin-nav-badges` |
| Back and forward: the window strip's arrows, ⌘[ and ⌘], the mouse's buttons; archiving or removing the worktree in front goes back, not Home; the dashboard keeps its scroll | [navigation-history.md](navigation-history.md) | `navigation-history`; `app/e2e/back-forward.spec.ts` asserts it |
| Shortcuts in the keyboard's layout (⌘Å and ⌘¨ for Back and Forward on Norwegian) in tooltips, the Keyboard shortcuts sheet, Settings and ⌘K; the sheet's descriptions fit their lines | [navigation-history.md](navigation-history.md) (`history-layout`) | `keyboard-layout`, `keyboard-layout-us`; `app/e2e/back-forward.spec.ts` asserts it |
| Stopping agents from the Agent Dashboard: one, the picked ones, a column, Clean up; archiving their worktrees where it's safe | [dashboard-stop.md](dashboard-stop.md) | `dashboard-stop-archive`; `app/e2e/dashboard-stop-archive.spec.ts` asserts it |
| Stopping agents from the Agent Dashboard: one, the picked ones, a column, Clean up; archiving their worktrees where it's safe; the run in the background-jobs tray (progress, fold, Stop the rest, report, fade); a box with runs doing it itself, through the laptop sleeping and the app relaunching | [dashboard-stop.md](dashboard-stop.md) | `dashboard-stop-archive`; `dashboard-stop-live`, `dashboard-stop-lid`, `dashboard-stop-unanswered` and `dashboard-stop-interrupted-live` (a real box, `--agent`); `app/e2e/dashboard-stop-archive.spec.ts` asserts it |
| Plugins: built-in and your own, what they add (screens, sidebar items, widgets, panels) | [plugins.md](plugins.md) | `plugin-nav-badges` (sidebar items); `plugin-attach-live` (`api.attach`, a real box, `--agent`) |
| Archiving and removing a worktree: Archive's dialog closes at once, Archiving… until the box is done (and in the tray), one at a time per box, the toasts; Remove worktree; a removal that failed says why, and one stopped halfway is finished by archiving it again | [worktree-archive.md](worktree-archive.md) | `worktree-archive`; `dashboard-stop-interrupted-live` (a real box, `--agent`); `app/e2e/worktree-remove.spec.ts` |
| Home's widget grid | [home-widgets.md](home-widgets.md) | none yet; `app/e2e/home-widgets.spec.ts` asserts it |
| Questions in chat: an agent's form of questions as a card, answered on a real box; kept with Retry and Copy when it can't be | [chat-questions.md](chat-questions.md) | `chat-questions-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` on the mock |
| Chat's foot and reply box: a live turn's spinner, timer, Stop and Queue; a turn that ended while its work runs on shows an hourglass and Send | [chat-turn-state.md](chat-turn-state.md) | `chat-turn-state`; `chat-turn-state-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` asserts it |
Expand Down
19 changes: 14 additions & 5 deletions .claude/skills/verify-berthd/features/dashboard-stop.md

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions .claude/skills/verify-berthd/features/worktree-archive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Archiving and removing a worktree

A worktree that is done goes one of two ways. **Archive…** asks first, then stops its sessions and services, runs the repo's archive script if it has one, and removes the folder; the branch stays, except a review worktree's own `review/` branch, which goes with it. **Remove worktree** asks nothing and removes it; the branch stays too, with the same exception. Both go through one queue (`app/src/lib/remove-worktree.ts`): the worktree is marked at once (`lib/removing.ts`), and each box takes one removal at a time, so archiving several in a row never waits on a dialog. On a box with runs, Archive… is a one-worktree `stop` run there (`archiveOnBox`, as the board's bulk Stop: `features/dashboard-stop.md`): its sessions stop, then it's archived where it's safe, in the box's own line (`archiveTurn`), and it finishes while the laptop sleeps or the app is closed. Elsewhere, and for Remove, the app sends it in the box's line (`inTurn`), which it holds until the box says the worktree went or the script failed (`archiveEnded`).

## Sub-features

- `archive-confirm`: the dialog names the worktree, its branch ("Branch … stays") and the archive script, if any. It closes as soon as Archive is pressed, before the box answers.
- `archive-mark`: from that moment the row is struck through and says **Archiving…** (`[data-leaving]`, `LeavingRow` in `components/sidebar/projects.tsx`); zen's switcher says the same, and the worktree's launcher says "Archiving NAME…". It can't be opened meanwhile.
- `archive-tray`: the archive is also a one-row job in the background-jobs tray (`queueRemoval(…, { kind: "archive" })`, `components/jobs-tray.tsx`): **Archiving NAME**, its row `Waiting for BOX` until its turn (or until the box answers), then `Archiving…` (and `Archiving: its archive script runs, then it goes` while the app waits on a script). Once the worktree is gone it reads **Archived NAME** and fades; kept, it stays as a report **Kept NAME** / `Kept: uncommitted changes` (or the box's other reason, such as `Kept: another agent is still running there`) until dismissed. Only Archive… from the sidebar, zen and the launcher uses the tray; Remove and the Worktrees table don't.
- `archive-done`: without a script the box answers once the folder is gone, then a toast **Archived NAME** / "Branch … is kept." With a script the box answers at once: a toast **Archiving NAME** / "Its archive script is running on the box…", and **Archived NAME** when `worktree.removed` arrives. On a box with runs it is one toast, **Archived NAME**, once the box's run has the worktree gone; kept there, **Couldn't archive NAME** says why.
- `archive-dirty`: uncommitted work makes git refuse. The worktree comes back as it was, with a warning toast **Couldn't archive NAME** / "NAME has uncommitted changes, so it was left as it is. Commit them first, or use Remove worktree to discard them."
- `archive-queue`: archives (and removals) on one box go one after another; each row keeps its mark until its turn is done, archive script included. Another box's don't wait.
- `remove`: Remove worktree, the dashboard's Stop and remove worktree, and Delete in the Worktrees table: no dialog, **Removing…**, and a dirty one gives **Kept NAME** with **Remove anyway**.
- `archive-interrupted`: git has 10 minutes to remove a worktree (`BERTH_WORKTREE_REMOVE_TIMEOUT` on the box sets another limit). One it doesn't finish in time is stopped, and the row says so (`git worktree remove timed out after … and was stopped; the folder may be partly removed, and archiving it again finishes it`). A removal stopped halfway leaves a folder with no `.git`, which git lists as prunable and the sidebar still shows. Archive… on it removes what's left and git's record of that one worktree (never `git worktree prune`, which would drop every half-removed one's), keeps the branch, and reads **Archived NAME**. It does so only when git, asked through the record's HEAD and index, sees nothing but files already deleted (any `.gitignore` the removal deleted is put back as committed first, so `node_modules` doesn't read as new); a changed or new file keeps it as `Kept: uncommitted changes`. So does one whose folder is gone altogether, which isn't listed. A folder that still has a `.git` is never deleted by hand.
- Bulk Archive in the Worktrees table (`views/worktrees/archive-dialog.tsx`) is separate: its dialog stays open on purpose, with each row's outcome (`use-bulk.ts`). On a box with runs, its eligible rows (and Stop sessions') are one `stop` run there (`startOnBoxes`, as the board's bulk Stop): the worktree's sessions stop first, then it's archived where it's safe, and a row the box keeps reads `Kept: …` (skipped). A row with uncommitted changes and every row of a box without runs go one after another from the app, as before; a row already on its way out is skipped (`already on its way out`) and left to that removal.

## How to get to it (user POV)

- Right-click a worktree in the sidebar, or its ⋯: **Archive…** or **Remove worktree**. Zen's worktree menu has the same items.
- A worktree with no tabs shows its launcher: **Archive this worktree…**.
- Worktrees (sidebar): select rows, then **Archive N…** or **Delete** in the bar at the bottom.

## Driving it with drive.mjs

- `node .claude/skills/verify-berthd/scripts/drive.mjs worktree-archive --port $PORT` opens the mock with `slowremove=4000` (a box takes 4 s to remove a worktree with no archive script, as a big `node_modules` does) and the list sidebar. It archives `gpu/ci-flake` and fails if the dialog stays open over 1 s, checks its row and its tray job say Archiving…, archives `gpu/judge-v2` straight after (both marked; judge-v2 waits its turn, `Waiting for gpu` in the tray), waits for **Archived ci-flake** with "Branch me/ci-flake is kept." at about 4 s and **Archived judge-v2** at about 8 s, then archives `devl/qa-deck` (script `pnpm db:drop`: **Archiving qa-deck**, then **Archived qa-deck**) and `devl/checkout-fix` (uncommitted: **Couldn't archive checkout-fix**, row still there, and a **Kept checkout-fix** report in the tray, then dismissed). Each step logs how long the dialog stayed open.
- Hooks: `[data-testid=worktree-row][data-worktree="BOX/NAME"]` for a row, `[data-leaving]` with the name's text for its leaving state, `getByRole("alertdialog")` for the dialog and its exact **Archive** button, `[data-slot=toast-viewport] > *` with the title's text for a toast.
- `app/e2e/worktree-remove.spec.ts` asserts the same on the mock: Remove (no dialog, Remove anyway), Archive (dialog gone in 1 s with `slowremove`, Archiving…, the toast) and Delete in the table.

## Gotchas

- `?slowremove=MS` slows only a worktree with no archive script; one with a script (devl's shop) answers at once and finishes 1.5 s later, as on a box. `checkout-fix` always refuses without force.
- The default sidebar is the rail, which shows one project at a time; the scenario sets `sidebar=list` to see devl's and gpu's worktrees together, as the e2e specs do.
- A worktree already marked is ignored by a second Archive or Remove until its mark goes.
- `dashboard-stop-interrupted-live` (see [dashboard-stop.md](dashboard-stop.md)) drives `archive-interrupted` on a real box, with a git that hangs for one worktree.
- The real box's slow part (deleting a big folder inside `git worktree remove`) isn't in the mock; a real box is needed to time it (`../scripts/dev-box.sh`). `dashboard-stop-live` (see [dashboard-stop.md](dashboard-stop.md)) archives `verify-solo` from the sidebar on one and checks git agrees.
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
// The Agent Dashboard's bulk Stop, with "Archive their worktrees where it's
// safe" ticked (views/dashboard/stop.tsx): a clean worktree with an archive
// script archives in the background, one without archives at once, a dirty
// one is kept, and one with another agent still in it is kept.
// safe" ticked (views/dashboard/stop.tsx): the dialog closes at once and
// the run goes on in the background-jobs tray (components/jobs-tray.tsx).
// With ?noruns every box is one from before runs, so this is the app's own
// loop, the fallback (boxes with runs: dashboard-stop-lid on a real box, and
// the e2e). A clean worktree with an archive script archives in the
// background, one without archives (slowly: ?slowremove), a dirty one is
// kept, and one with another agent still in it is kept; the tray reports
// those two. Then a
// clean run fades, and Stop the rest leaves the unsent ones (?slowstop).
// features/dashboard-stop.md.

export const params = { slowremove: "4000", slowstop: "1500", noruns: "1" };

const picks = [
// devl / search-perf: shop has an archive script, so it runs first.
"Speed up product search",
// gpu / ci-flake: no archive script, so it goes at once.
// gpu / ci-flake: no archive script, so it goes after gpu's 4 s.
"Move acme billing to the new ledger",
// devl / checkout-fix: uncommitted work, so git keeps it.
"Fix checkout webhook retries",
Expand All @@ -18,48 +26,116 @@ const picks = [
export async function run({ page, shot, step, expect }) {
const dialog = page.getByRole("alertdialog");
const archiveBox = dialog.getByRole("checkbox", { name: /Archive their worktrees where it's safe/ });
const row = (place) => dialog.getByRole("group", { name: "Agents to stop" }).getByRole("listitem").filter({ hasText: place });
const tray = page.getByRole("region", { name: "Background jobs" });
const job = (title) => tray.getByRole("group", { name: title });
const jobRow = (title, label) => job(title).getByRole("listitem").filter({ hasText: label });
const toast = (text) => page.locator("[data-slot=toast-viewport] > *", { hasText: text });
// The Worktrees table lists every box's; the sidebar shows one project.
const listed = (name) => page.getByRole("checkbox", { name: `Select ${name}`, exact: true });
const board = async () => {
await page.keyboard.press("Meta+j");
await expect(page.getByRole("heading", { name: "Agent Dashboard" })).toBeVisible();
};
const stopPicked = async (titles) => {
// Still picking after the last run, or not yet.
const select = page.getByRole("button", { name: "Select", exact: true });
if (await select.isVisible()) await select.click();
for (const t of titles) await page.locator("article[role=button]", { hasText: t }).getByText(t).click();
await page.getByRole("button", { name: `Stop ${titles.length}…` }).click();
await expect(dialog).toBeVisible();
};
const confirmStop = async (n, label) => {
const t0 = Date.now();
await dialog.getByRole("button", { name: `Stop ${n} agent${n === 1 ? "" : "s"}` }).click();
const closed = dialog.waitFor({ state: "hidden", timeout: 15_000 }).then(() => Date.now() - t0);
await page.waitForTimeout(300);
await shot(label);
const ms = await closed;
step(`the dialog closed ${ms} ms after Stop was pressed`);
if (ms > 1000) throw new Error(`the dialog stayed open ${ms} ms, waiting on the boxes`);
};

step("open the Agent Dashboard, Select, and pick four agents");
await page.keyboard.press("Meta+j");
await expect(page.getByRole("heading", { name: "Agent Dashboard" })).toBeVisible();
await page.getByRole("button", { name: "Select", exact: true }).click();
for (const t of picks) await page.locator("article[role=button]", { hasText: t }).getByText(t).click();
await page.getByRole("button", { name: `Stop ${picks.length}…` }).click();
await board();
await stopPicked(picks);
await expect(archiveBox).not.toBeChecked();
await expect(dialog).toContainText("its worktree and files are kept");
await shot("1-stop-dialog-unticked");
await shot("01-stop-dialog-unticked");

step("tick 'Archive their worktrees where it's safe': the copy says so");
await archiveBox.click();
await expect(archiveBox).toBeChecked();
await expect(dialog).toContainText("then its worktree is archived where it's safe");
await shot("2-stop-dialog-ticked");
await shot("02-stop-dialog-ticked");

step("Stop 4 agents: the dialog goes at once, the tray takes over");
await confirmStop(picks.length, "03-just-after-stop");
const first = job("Stopping 4 agents");
await expect(first).toContainText(/Stopping 4 agents · [0-3]\/4/);

step("mid-run: the tray lists each by box, ci-flake Archiving… on gpu");
await expect(jobRow("Stopping 4 agents", "ci-flake")).toContainText("Archiving…");
await expect(first).toContainText("devl");
await expect(first).toContainText("gpu");
await shot("04-tray-mid-run");

step("Stop 4 agents: kept work holds the dialog open, each row with its outcome");
await dialog.getByRole("button", { name: `Stop ${picks.length} agents` }).click();
await expect(dialog.getByRole("heading")).toHaveText("Stopped 4 agents · archiving 2 worktrees");
await expect(row("search-perf")).toContainText("Stopped · Archiving: its archive script runs, then it goes");
await expect(row("ci-flake")).toContainText("Stopped · Archived; its branch is kept");
await expect(row("checkout-fix")).toContainText("Stopped · Kept: uncommitted changes");
await expect(row("order-export")).toContainText("Stopped · Kept: another agent is still running there");
await shot("3-stop-dialog-done");
step("fold it: one line with the count and a spinner");
await first.getByRole("button", { name: "Fold" }).click();
const pill = tray.getByRole("button", { expanded: false });
await expect(pill).toContainText(/Stopping 4 agents · [0-3]\/4/);
await shot("05-tray-folded");

step("Done, then the Worktrees table: the archived ones are gone, the kept ones stay");
await dialog.getByRole("button", { name: "Done" }).click();
step("go elsewhere while it runs: the Worktrees view opens, the tray stays");
await page.getByTestId("nav-worktrees").click();
await expect(listed("checkout-fix")).toBeVisible();
await expect(tray).toBeVisible();
await shot("06-elsewhere-while-running");
await pill.click();

step("it ends as a report of what was kept, each saying why");
await expect(first).toContainText("4 stopped, 2 archived, 2 kept", { timeout: 15_000 });
await expect(first.getByRole("listitem")).toHaveCount(2);
await expect(jobRow("Stopping 4 agents", "checkout-fix")).toContainText("Stopped · Kept: uncommitted changes");
await expect(jobRow("Stopping 4 agents", "order-export")).toContainText("Stopped · Kept: another agent is still running there");
await shot("07-tray-report");

step("in the Worktrees table the archived ones are gone, the kept ones stay");
await expect(listed("checkout-fix")).toBeVisible();
await expect(listed("order-export")).toBeVisible();
await expect(listed("ci-flake")).toHaveCount(0, { timeout: 10_000 });
await expect(listed("search-perf")).toHaveCount(0, { timeout: 10_000 });
await shot("4-worktrees-after");

step("dismiss the report");
await first.getByRole("button", { name: "Dismiss" }).click();
await expect(tray).toHaveCount(0);

step("the choice is remembered: Clean up… opens with the box ticked");
await page.keyboard.press("Meta+j");
await board();
await page.getByRole("button", { name: "Clean up…" }).click();
await expect(archiveBox).toBeChecked();
await expect(dialog).toContainText("Their worktrees are archived where it's safe");
await shot("5-cleanup-remembers");
await shot("08-cleanup-remembers");
await dialog.getByRole("button", { name: "Cancel" }).click();

step("a clean run (archive unticked): the toast, a one-line summary, then it fades");
await stopPicked(["Tune the judge prompt"]);
await archiveBox.click();
await expect(archiveBox).not.toBeChecked();
await confirmStop(1, "09-clean-just-after-stop");
await expect(toast("Stopped 1 agent")).toContainText("Their worktrees are kept.", { timeout: 10_000 });
await expect(job("Stopping 1 agent")).toContainText("1 stopped");
await shot("10-clean-summary");
await expect(tray).toHaveCount(0, { timeout: 15_000 });
step("the clean run's summary faded on its own");
await shot("11-clean-faded");

step("Stop the rest: four picked, three in flight, the fourth never sent");
await stopPicked(["Add tests for the export job", "Rerun the evals once the vendor replies", "Fix the cart badge after a refund", "Plan the checkout release"]);
await confirmStop(4, "12-four-just-after-stop");
const rest = job("Stopping 4 agents");
await expect(rest.getByRole("listitem").filter({ hasText: "Stopping…" })).toHaveCount(3);
await rest.getByRole("button", { name: "Stop the rest" }).click();
await expect(rest).toContainText("3 stopped, 1 not stopped", { timeout: 10_000 });
await expect(rest.getByRole("listitem").filter({ hasText: "Not stopped" })).toHaveCount(1);
await shot("13-stop-the-rest-report");
}
Loading