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
2 changes: 1 addition & 1 deletion .claude/skills/verify-berthd/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The maintained list of what a person can do in the app, and how to prove each on

| Feature | File | Scenario |
| --- | --- | --- |
| 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` |
| Sidebar places: Pinned, More, Hidden, counts on rows (Home's waiting, Review's, a plugin's); the arrangement kept across a reload and an agent restart | [sidebar-nav.md](sidebar-nav.md) | `plugin-nav-badges`; `sidebar-layout-reload` (the layout across a reload, and before it loads); `../scripts/sidebar-layout-restart.sh` (a real agent restarting as the app starts); `app/e2e/sidebar-layout-load.spec.ts` asserts it |
| 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 |
Expand Down
9 changes: 7 additions & 2 deletions .claude/skills/verify-berthd/features/sidebar-nav.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The top of the sidebar lists the app's places (Home, Review, Worktrees, Automati
- `nav-pinned`: pinned places as rows, in the stored order.
- `nav-more`: More, one row and a menu of the rest; highlighted, naming the place, while you're on one of its places.
- `nav-customize`: the Customize sidebar sheet (drag, or Pin / Move to More / Hide buttons, Reset to default); right-click a row for the same.
- `nav-layout-load`: the arrangement is read from the laptop agent (`/v1/app/sidebar`, `~/.berth/app/sidebar.json`) and outlives a reload. Until it is read the default shows and nothing can change it: More's **Customize sidebar…** is disabled with a note (`nav-not-loaded`), and any other change (a row's right-click, a drag, ⌘K's Customize sidebar…) is refused with the same note in a toast. A failed read is tried again (1 s, doubling to 10 s, and at once when the agent reconnects) until it arrives, then the stored arrangement replaces the default. A missing or empty file is the default, with Customize open. A change keeps the place of pinned or hidden ids that aren't available (a plugin off or still loading).
- `nav-counts`: a count on a row. Home shows agents waiting for you (amber), Review shows what's to review (quiet), and a plugin's sidebar item shows its `useCount` (amber, or quiet with `urgent: false`; none for 0, undefined or a hook that throws). An amber count under More also shows on More's row.

## How to get to it (user POV)
Expand All @@ -18,12 +19,16 @@ The top of the sidebar lists the app's places (Home, Review, Worktrees, Automati
## Driving it with drive.mjs

- `node .claude/skills/verify-berthd/scripts/drive.mjs plugin-nav-badges --port $PORT` loads a test plugin with five sidebar items (counts 3, quiet 2, 4 from `useStorage`, 0, and one that throws). It checks More's row shows 3, the More menu shows `Waiting3`, `Quiet2`, `Stored4` and no count on Zero or Boom, pins four of them through Customize, then checks the rows: Waiting `3` amber (`text-warning-foreground`), Quiet `2` not amber, Zero and Boom no `nav-badge`, More's row now `4`, Home's own badge still there, and Waiting still opens its screen (`aria-current="page"`).
- Hooks: `nav-<id>` on each row's button (`nav-home`, `nav-review`, `nav-plugin:<plugin>:<item>`), `nav-more` on More's button, `nav-badge` on a row's count. A row's count is `page.locator("li", { has: page.getByTestId("nav-<id>") }).getByTestId("nav-badge")`.
- `node .claude/skills/verify-berthd/scripts/drive.mjs sidebar-layout-reload --port $PORT` (`nav-layout-load`, mock): pins Kits through Customize, reloads and finds it still pinned; then opens the app with `failapp=sidebar` (the mock fails that document until `window.__mockApp.recover("sidebar")`), checks the default shows, Customize is disabled with the note in More's menu, Hide on a row's right-click is refused with the note, and the stored document didn't change; recovers, and checks Kits comes back and Customize opens.
- `.claude/skills/verify-berthd/scripts/sidebar-layout-restart.sh $PORT` (`nav-layout-load`, a real agent): seeds `sidebar.json` (Kits pinned, Automations hidden) for a laptop agent of this checkout with an empty box (`scripts/agent-box.sh`, ports 9467/9468 and 7546), drives `sidebar-layout-restart-live`, which stops the agent as the app asks for the layout and starts it 2 s later, and compares `sidebar.json`'s sha256 before and after. For a before, serve main's build on another port and run it against that: it stays on the default.
- Hooks: `nav-<id>` on each row's button (`nav-home`, `nav-review`, `nav-plugin:<plugin>:<item>`), `nav-more` on More's button, `nav-badge` on a row's count, `nav-customize` on More's Customize sidebar…, `nav-not-loaded` on its note. A row's count is `page.locator("li", { has: page.getByTestId("nav-<id>") }).getByTestId("nav-badge")`.
- Pin through the sheet: `getByRole("dialog", { name: "Customize sidebar" })`, then the `li` holding the label's exact text, its `Pin` button.

## Gotchas

- New places, a plugin's included, land under More until pinned, so a plugin's count first shows on More's row, and only the first amber one.
- While you're on a place under More, More's row names it instead of showing a count.
- The mock saves the layout in memory only; a reload starts from the default.
- The mock keeps the layout in the tab's session storage, so a reload or a `goto` in the same page keeps it; a new browser context starts from the default.
- Scenarios that look for `nav-<id>` rows need `params: { sidebar: "list" }`: the default layout is the rail.
- A fresh laptop agent with no box shows onboarding instead of the sidebar; `agent-box.sh` pairs an empty box for that reason.
- Counts in the More menu are plain text after the label: the menu item's text is `Waiting3`.
100 changes: 100 additions & 0 deletions .claude/skills/verify-berthd/scripts/agent-box.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# agent-box.sh up|env|stop|start|restart [SECONDS]|down: a laptop agent of
# this checkout's build with one empty box paired ("devr", so the app is
# past onboarding), on their own state and ports (agent UI 127.0.0.1:9468,
# proxy 9467, box 7546; never 1377-1379), for what the app reads from the
# agent itself, such as its /v1/app documents. No agent runs on the box.
# `up` builds both into the state folder; `stop`, `start` and `restart 2`
# (2 seconds between) take the laptop agent down and up again, the box
# untouched; `env` prints what drive.mjs needs; `down` stops both and
# deletes the state.
#
# VERIFY_AGENT_DIR=/tmp/va-NNNN the state (default /tmp/verify-berthd-agent)
# VERIFY_AGENT_BIN=<berth> another build of the laptop agent, say main's
#
# The app's documents are in $VERIFY_AGENT_DIR/user/app/<key>.json: seed one
# before `up` (sidebar-layout-restart.sh does). The box's HOME is in the
# state folder too, so it never touches the real one's agent hooks.
set -euo pipefail
root=$(git -C "$(dirname "$0")" rev-parse --show-toplevel)
dir=${VERIFY_AGENT_DIR:-/tmp/verify-berthd-agent}
ui=127.0.0.1:9468
addr=127.0.0.1:7546
bin=${VERIFY_AGENT_BIN:-$dir/bin/berth}
lap_env=(BERTH_HOME="$dir/home" BERTH_USER_DIR="$dir/user" BERTH_UI_ADDR="$ui" BERTH_PROXY_ADDR=127.0.0.1:9467)
box_env=(HOME="$dir/box-home" BERTH_HOME="$dir/box-home" BERTH_USER_DIR="$dir/box-user" TMUX_TMPDIR="$dir/tmux")
box() { env "${box_env[@]}" "$dir/bin/berthd" "$@"; }

# ours: the agent listening on the UI port with this state, no other.
ours() {
for pid in $(ss -ltnpH "sport = :${ui##*:}" 2>/dev/null | grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u); do
tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | grep -qx "BERTH_HOME=$dir/home" && echo "$pid"
done
return 0
}
start() {
mkdir -p "$dir"/{home,user}
env "${lap_env[@]}" setsid nohup "$bin" agent >>"$dir/agent.log" 2>&1 </dev/null &
for _ in $(seq 120); do
curl -s -o /dev/null "http://$ui/" && return
sleep 0.25
done
echo "the agent isn't answering on $ui after 30 s: $dir/agent.log" >&2
exit 1
}
stop() {
local pids
pids=$(ours)
[[ -z $pids ]] && return
kill $pids
for _ in $(seq 100); do
[[ -z $(ours) ]] && ! ss -ltnH "sport = :${ui##*:}" | grep -q . && return
sleep 0.1
done
echo "the agent ($pids) is still running after 10 s" >&2
exit 1
}

case ${1:-} in
up)
for p in 7546 9467 9468; do
if ss -ltn | grep -q ":$p "; then echo "port $p is taken: is an agent up already? ($0 down)" >&2; exit 1; fi
done
mkdir -p "$dir"/{home,user,box-home,box-user,tmux,bin} && chmod 700 "$dir/tmux"
(cd "$root" && go build -o "$dir/bin/" ./cmd/berth ./cmd/berthd)
env "${box_env[@]}" setsid nohup "$dir/bin/berthd" serve --listen "$addr" >"$dir/berthd.log" 2>&1 </dev/null &
box_pid=$!
echo $box_pid >"$dir/berthd.pid"
for _ in $(seq 120); do
grep -q "berthd serving" "$dir/berthd.log" && break
kill -0 $box_pid 2>/dev/null || { echo "berthd exited before it served: $dir/berthd.log" >&2; exit 1; }
sleep 0.5
done
grep -q "berthd serving" "$dir/berthd.log" || { echo "the box isn't serving: $dir/berthd.log" >&2; exit 1; }
start
env "${lap_env[@]}" "$bin" pair "$(box pair --address "$addr" --json | jq -r .link)" --name devr >/dev/null
echo "up: agent http://$ui with box devr ($addr), state $dir"
;;
start) start ;;
stop) stop ;;
restart)
stop
sleep "${2:-0}"
start
;;
env)
# VERIFY_BERTH_TOKEN is a secret: export it, don't print it anywhere else.
echo "export VERIFY_BERTH_TOKEN=$(env "${lap_env[@]}" "$bin" ui-token | jq -r .token) VERIFY_AGENT_DIR=$dir"
;;
down)
stop
[[ -f $dir/berthd.pid ]] && kill "$(cat "$dir/berthd.pid")" 2>/dev/null || true
env TMUX_TMPDIR="$dir/tmux" tmux -L berth kill-server 2>/dev/null || true
rm -rf "$dir"
echo "down: $dir removed"
;;
*)
echo "usage: agent-box.sh up|env|stop|start|restart [SECONDS]|down" >&2
exit 2
;;
esac
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// The sidebar's arrangement outlives a reload, and a layout that doesn't
// load leaves the default showing with Customize blocked until it arrives
// (lib/nav.ts). The mock keeps the document across reloads, and
// ?failapp=sidebar fails it until __mockApp.recover("sidebar").
// features/sidebar-nav.md.

// The list sidebar, where the places are rows (nav-<id>).
export const params = { sidebar: "list" };

export async function run({ page, shot, step, expect }) {
const sidebar = page.getByTestId("sidebar");
const stored = () => page.evaluate(() => window.__mockApp.doc("sidebar"));

step("pin Kits in Customize sidebar");
await expect(sidebar.getByTestId("nav-kits")).toHaveCount(0);
await page.getByTestId("nav-more").click();
await page.getByTestId("nav-customize").click();
const sheet = page.getByRole("dialog", { name: "Customize sidebar" });
await sheet.locator("li", { has: page.getByText("Kits", { exact: true }) }).getByRole("button", { name: "Pin" }).click();
await sheet.getByRole("button", { name: "Done" }).click();
await expect(sheet).toBeHidden();
await expect(sidebar.getByTestId("nav-kits")).toBeVisible();
await shot("1-pinned");

step("reload: Kits is still pinned");
await page.reload();
await expect(sidebar.getByTestId("nav-home")).toBeVisible({ timeout: 20_000 });
await expect(sidebar.getByTestId("nav-kits")).toBeVisible();
await shot("2-after-reload");
const saved = await stored();

step("start again while the agent can't answer for the layout: the default shows");
const url = new URL(page.url());
url.searchParams.set("failapp", "sidebar");
await page.goto(url.href);
await expect(sidebar.getByTestId("nav-automations")).toBeVisible({ timeout: 20_000 });
await expect(sidebar.getByTestId("nav-kits")).toHaveCount(0);

step("More's Customize sidebar… is blocked, with the note");
await page.getByTestId("nav-more").click();
await expect(page.getByTestId("nav-customize")).toHaveAttribute("aria-disabled", "true");
await expect(page.getByTestId("nav-not-loaded")).toBeVisible();
await shot("3-customize-blocked");
await page.keyboard.press("Escape");

step("Hide on a row's right-click is refused with the same note, and Review stays");
await sidebar.getByTestId("nav-review").click({ button: "right" });
await page.getByRole("menuitem", { name: "Hide" }).click();
await expect(page.getByText("Your saved layout hasn't loaded yet").last()).toBeVisible();
await expect(sidebar.getByTestId("nav-review")).toBeVisible();
await shot("4-change-refused");
if (JSON.stringify(await stored()) !== JSON.stringify(saved)) throw new Error("the stored layout changed while it hadn't loaded");

step("the agent answers again: the stored layout replaces the default, and Customize opens");
await page.evaluate(() => window.__mockApp.recover("sidebar"));
await expect(sidebar.getByTestId("nav-kits")).toBeVisible({ timeout: 20_000 });
await page.getByTestId("nav-more").click();
await expect(page.getByTestId("nav-not-loaded")).toHaveCount(0);
await page.getByTestId("nav-customize").click();
await expect(sheet).toBeVisible();
await shot("5-recovered");
if (JSON.stringify(await stored()) !== JSON.stringify(saved)) throw new Error("the stored layout changed");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// A real laptop agent restarts just as the app asks it for the sidebar's
// layout, as the first launch of an updated Mac app restarts its agent.
// The app keeps trying, and the saved layout replaces the default once the
// agent is back; nothing is written meanwhile. Run it through
// ../sidebar-layout-restart.sh, which seeds the layout (Kits pinned,
// Automations hidden), starts the agent (../agent-box.sh) and compares
// sidebar.json before and after. features/sidebar-nav.md.
import { execFileSync, spawn } from "node:child_process";
import { fileURLToPath } from "node:url";

export const live = true;
// The requests that meet the stopped agent.
export const allowErrors = [/ERR_CONNECTION_REFUSED/, /Failed to load resource/, /Failed to fetch/, /NetworkError/];

// The list sidebar, where the places are rows (nav-<id>).
export const params = { sidebar: "list" };

const agentBox = fileURLToPath(new URL("../agent-box.sh", import.meta.url));

export async function run({ page, shot, step, expect }) {
const sidebar = page.getByTestId("sidebar");

step("the saved layout shows: Kits pinned, Automations hidden");
await expect(sidebar.getByTestId("nav-kits")).toBeVisible({ timeout: 20_000 });
await expect(sidebar.getByTestId("nav-automations")).toHaveCount(0);
await shot("1-saved-layout");

step("start the app again; as it asks for the layout, the agent stops, and starts 2 s later");
let restarted = false;
let back;
await page.route(/\/v1\/app\/sidebar(?:\?|$)/, async (route) => {
if (!restarted && route.request().method() === "GET") {
restarted = true;
execFileSync(agentBox, ["stop"], { stdio: "inherit" });
back = new Promise((resolve, reject) => {
setTimeout(() => {
const p = spawn(agentBox, ["start"], { stdio: "inherit" });
p.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`agent-box.sh start: exit ${code}`))));
}, 2000);
});
}
await route.continue().catch(() => {});
});
await page.reload();
await expect(sidebar.getByTestId("nav-home")).toBeVisible({ timeout: 30_000 });
await expect.poll(() => restarted, { timeout: 30_000 }).toBe(true);
await back;
step("the agent is back: Kits pinned and Automations hidden again");
await expect(sidebar.getByTestId("nav-kits")).toBeVisible({ timeout: 30_000 });
await expect(sidebar.getByTestId("nav-automations")).toHaveCount(0);
await shot("2-after-restart");

step("Customize sidebar opens, with Kits under Pinned and Automations under Hidden");
await page.getByTestId("nav-more").click();
await page.getByTestId("nav-customize").click();
const sheet = page.getByRole("dialog", { name: "Customize sidebar" });
await expect(sheet).toBeVisible();
await expect(sheet.locator("section", { has: page.getByRole("heading", { name: "Pinned" }) })).toContainText("Kits");
await expect(sheet.locator("section", { has: page.getByRole("heading", { name: "Hidden" }) })).toContainText("Automations");
await shot("3-customize");
await sheet.getByRole("button", { name: "Done" }).click();
}
31 changes: 31 additions & 0 deletions .claude/skills/verify-berthd/scripts/sidebar-layout-restart.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# sidebar-layout-restart.sh PORT: does the sidebar's saved layout survive
# the laptop agent restarting while the app starts?
#
# Seeds a layout (Kits pinned, Automations hidden) in a throwaway agent's
# state, starts a real agent of this checkout (agent-box.sh), drives the
# app served on PORT through scenarios/sidebar-layout-restart-live.mjs
# (which restarts the agent as the app asks for the layout), then checks
# sidebar.json is byte for byte what was seeded. Prints the evidence folder
# and one JSON line; exit 1 if the scenario or the comparison failed.
#
# VERIFY_AGENT_DIR (default /tmp/vb-sidebar-layout) is deleted at the end.
# OUT=<dir> for the evidence (drive.mjs --out).
set -euo pipefail
port=${1:?usage: sidebar-layout-restart.sh PORT}
here=$(cd "$(dirname "$0")" && pwd)
export VERIFY_AGENT_DIR=${VERIFY_AGENT_DIR:-/tmp/vb-sidebar-layout}
file=$VERIFY_AGENT_DIR/user/app/sidebar.json
trap '"$here/agent-box.sh" down >/dev/null' EXIT

rm -rf "$VERIFY_AGENT_DIR" && mkdir -p "$(dirname "$file")"
printf '%s' '{"version":2,"pinned":["home","kits","review","worktrees"],"more":[],"hidden":["automations"]}' >"$file"
before=$(sha256sum "$file" | cut -d' ' -f1)
"$here/agent-box.sh" up
eval "$("$here/agent-box.sh" env)"
ok=true
node "$here/drive.mjs" sidebar-layout-restart-live --port "$port" --agent http://127.0.0.1:9468 ${OUT:+--out "$OUT"} || ok=false
after=$(sha256sum "$file" | cut -d' ' -f1)
[[ $before == "$after" ]] || ok=false
printf '{"ok":%s,"sidebar_json_before":"%s","sidebar_json_after":"%s"}\n' "$ok" "$before" "$after"
$ok
2 changes: 1 addition & 1 deletion app/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { type BrowserContext, expect, type Locator, type Page, test as base } fr
//
// Stable hooks, so tests never click text that moves: data-testid where
// nothing else was stable (worktree-row with data-worktree="box/name",
// nav-<id>, nav-more, nav-badge, pane with data-pane-kind, chat, chat-item with data-kind,
// nav-<id>, nav-more, nav-badge, nav-customize, nav-not-loaded, pane with data-pane-kind, chat, chat-item with data-kind,
// composer, question-form, queued-reply, artifacts-chip/-popover,
// attachment-chip, panel with data-panel, browser-pane with data-mode,
// chat-background, header-picture with data-picture, header-picture-settings, settings-nav-<id>, settings-<id>, theme-option with
Expand Down
Loading