Skip to content
Merged
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 @@ -19,6 +19,7 @@ The maintained list of what a person can do in the app, and how to prove each on
| 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 and board: no question card or answers but the options the agent's screen shows; the mode menu while turn check waits | [chat-ask-card.md](chat-ask-card.md) | `chat-ask-card` (a real box, `--agent`); `app/e2e/ask-card.spec.ts` on a stand-in box |
| 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 |

## Proof and skip reporting

Expand Down
30 changes: 30 additions & 0 deletions .claude/skills/verify-berthd/features/turn-check-indicator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Turn check's "working": an hourglass, not a live turn's spinner

Turn check (`internal/box/turncheck.go`) reads each finished turn. When it reads the work as still running without the agent (a delegated task the agent waits on, a build, a deploy, a watcher), the box marks the session `unfinished` and it counts as working (`sessionState()` in `app/src/lib/derive.ts`), so it sits in the dashboard's Working column. A handoff to a worker or session the agent won't follow ("you can close this session", "I turned off its notifications to this session") is done, not working.

## Sub-features

- `sidebar`: a worktree row whose running agents all ended their turn on their work (`allOnItsWork`) shows an hourglass in its status lane instead of the blue arc. Its tip: "Its turn ended while its work runs on: a delegated task, a build, a deploy or a watcher. It picks up by itself." A row with any live turn keeps the arc. The same goes for a folded project's header, a folded fork's chip, and Labs' two-column rows.
- `tab`: the tab (and the pane's header) of such a session shows the hourglass instead of the spinner, and its tooltip adds the same line.
- `rail`: the rail's hover card for the agent says the same line under its state.
- `dashboard`: the card says "Waiting on its work" with an hourglass (`views/dashboard/agent-card.tsx`), in the Working column. Unchanged.
- A new turn clears `unfinished` on the box (`internal/box/turns.go`), so the spinner comes back while it runs.

## How to get to it (user POV)

- Look at the sidebar: the mock's `devl / order-export` ("Add tests for the export job", `order-export-claude-3` with `unfinished: true`) has the hourglass, `devl / qa-deck` (a live Codex turn) the spinner. Hover the hourglass. Click the row and look at the tab.

## Driving it with drive.mjs

- `node .claude/skills/verify-berthd/scripts/drive.mjs turn-check-indicator --port $PORT` checks the sidebar rows and the tip, opens order-export and checks its tab and tooltip, then opens qa-deck and checks its spinner.
- `app/e2e/sidebar-turn-check.spec.ts` asserts the same; `app/e2e/dashboard-turn-check.spec.ts` the dashboard's columns.
- The verdict itself: `go test ./internal/box -run TestTurnCheckLive -v` with `BERTH_TURNCHECK_LIVE_KEY_FILE` (a box's `turncheck.key`) and `BERTH_TURNCHECK_LIVE_MODEL` asks the real provider about the fixtures (a handoff is done, a delegated task it waits on, a deploy and a background re-check are working, a scheduled check-in is parked). It skips without them, so CI never calls a provider. Models drift: run it a few times.

- The verdict on a real box, the way a real turn reaches it: `scripts/turn-check-replay.sh NAME TITLE PROMPT_FILE REPLY_FILE` makes a throwaway location and a stand-in `claude` session, writes the turn as its transcript, fires the UserPromptSubmit and Stop hooks through `berthd hook`, prints how the box then reads the session (`unfinished=True` is working, `parked=True` parked, neither done), and removes it all. It uses the berthd on PATH (`BERTHD=` for another, such as a `dev-box.sh` build) and the box's own turn check settings, so the turn goes to that provider.

## Gotchas

- An agent running in a chat-mode (structured) session has `BERTH_DRIVER=structured` set, and `berthd hook` does nothing under it. The replay script unsets it for its hooks.

- The drive opens with `?sidebar=list`. The default rail layout shows one project at a time.
- The sidebar's tips come from one shared tooltip (`RowLayer`), so they read as `[data-slot=tooltip-popup]`, not role `tooltip`. They fade in, so wait for opacity 1 before a screenshot.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// A finished turn turn check read as working (its work runs on: a delegated
// task it waits on, a build, a deploy) wears an hourglass with why in its
// tip, in the sidebar and on its tab; a turn still going keeps the spinner.
// features/turn-check-indicator.md.

export const params = { sidebar: "list" };

const WHY = "Its turn ended while its work runs on: a delegated task, a build, a deploy or a watcher. It picks up by itself.";

export async function run({ page, shot, step, expect }) {
const row = (w) => page.locator(`[data-testid=worktree-row][data-worktree="${w}"]`);
const tip = page.locator("[data-slot=tooltip-popup]").filter({ hasText: WHY });

step("the sidebar: order-export (turn ended, work runs on) shows the hourglass, qa-deck (a live turn) the spinner");
await expect(row("devl/order-export").getByRole("img", { name: "Waiting on its work" })).toBeVisible();
await expect(row("devl/order-export").getByRole("img", { name: "Working" })).toHaveCount(0);
await expect(row("devl/qa-deck").getByRole("img", { name: "Working" })).toBeVisible();
await expect(row("devl/qa-deck").getByTestId("on-its-work")).toHaveCount(0);

step("hover the hourglass: its tip says why");
await row("devl/order-export").getByTestId("on-its-work").hover();
await expect(tip).toBeVisible();
await expect(tip).toHaveCSS("opacity", "1");
await shot("1-sidebar-hourglass-tip");

step("open order-export: the tab of 'Add tests for the export job' shows the hourglass, and its tip says why");
await row("devl/order-export").click({ position: { x: 32, y: 12 } });
const tab = page.getByRole("tab", { name: /Add tests for the export job/ });
await expect(tab.getByRole("img", { name: "Waiting on its work" })).toBeVisible();
await expect(tab.getByRole("img", { name: "Working" })).toHaveCount(0);
await tab.hover();
await expect(tip).toBeVisible();
await expect(tip).toHaveCSS("opacity", "1");
await shot("2-tab-hourglass-tip");

step("open qa-deck: its live turn's tab keeps the spinner");
await page.mouse.move(900, 500);
await row("devl/qa-deck").click({ position: { x: 32, y: 12 } });
const live = page.getByRole("tab", { name: /Build the QA deck for the release/ });
await expect(live.getByRole("img", { name: "Working" })).toBeVisible();
await expect(live.getByTestId("on-its-work")).toHaveCount(0);
await shot("3-tab-live-spinner");
}
83 changes: 83 additions & 0 deletions .claude/skills/verify-berthd/scripts/turn-check-replay.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Replays one finished Claude Code turn through this box's own turn check,
# the way a real turn reaches it: a throwaway location and a session whose
# command is a stand-in `claude`, a transcript holding the turn, and the
# UserPromptSubmit and Stop hooks through `berthd hook`. It prints how the
# box then reads the session, and cleans everything up.
#
# turn-check-replay.sh NAME TITLE PROMPT_FILE REPLY_FILE
#
# NAME is short ([a-z0-9-]); TITLE is the session's title (turn check sends
# it as the objective); the files hold the turn's last prompt and reply.
# BERTHD picks the berthd (default: the one on PATH, the installed box).
# Turn check must be on for this box (Settings -> Agents). It sends the turn
# to the provider set there, as any turn on this box does.
#
# Prints one line: NAME agent_state=... unfinished=... parked=... (what
# turn check made of it: unfinished is "working", parked is "parked",
# neither is done, a waiting agent_state is "needs you").
set -euo pipefail

name=$1 title=$2 prompt_file=$3 reply_file=$4
BERTHD=${BERTHD:-berthd}
[[ $name =~ ^[a-z0-9-]+$ ]] || { echo "NAME must be [a-z0-9-]" >&2; exit 2; }

root=$(mktemp -d /tmp/turn-check-replay.XXXXXX)
repo=$root/repo
bin=$root/bin
loc=replay-$name
sess=replay-$name
id=$(python3 -c 'import uuid; print(uuid.uuid4())')
proj=$HOME/.claude/projects/$(printf '%s' "$repo" | sed 's/[^a-zA-Z0-9]/-/g')

cleanup() {
"$BERTHD" session kill "$sess" >/dev/null 2>&1 || true
"$BERTHD" location rm "$loc" >/dev/null 2>&1 || true
rm -rf "$proj" "$root"
}
trap cleanup EXIT

mkdir -p "$repo" "$bin"
git -C "$repo" init -q
git -C "$repo" -c user.name=replay -c user.email=replay@localhost commit -q --allow-empty -m replay
# The box knows an agent by its command's name; this one only waits.
printf '#!/bin/sh\nexec sleep 1800\n' > "$bin/claude"
chmod +x "$bin/claude"

"$BERTHD" location add "$loc" "$repo" >/dev/null
"$BERTHD" session new "$loc" --name "$sess" --title "$title" -- "$bin/claude" >/dev/null
sleep 1

transcript=$proj/$id.jsonl
python3 - "$transcript" "$prompt_file" "$reply_file" <<'EOF'
import datetime, json, os, sys
os.makedirs(os.path.dirname(sys.argv[1]), exist_ok=True)
path, prompt, reply = sys.argv[1], open(sys.argv[2]).read(), open(sys.argv[3]).read()
at = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
with open(path, "w") as f:
f.write(json.dumps({"type": "user", "timestamp": at, "message": {"role": "user", "content": prompt}}) + "\n")
f.write(json.dumps({"type": "assistant", "timestamp": at, "message": {"role": "assistant", "content": [{"type": "text", "text": reply}]}}) + "\n")
EOF

hook() {
python3 -c 'import json, sys; print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2], "cwd": sys.argv[3], "hook_event_name": sys.argv[4], "prompt": sys.argv[5]}))' \
"$id" "$transcript" "$repo" "$1" "${2:-}" | env -u BERTH_DRIVER BERTH_SESSION="$sess" "$BERTHD" hook claude "$1" >/dev/null
}
hook UserPromptSubmit "$(head -c 200 "$prompt_file")"
sleep 1
hook Stop

# Turn check settles, then asks (up to 10 s); read the session once it has
# had the time.
sleep 15
"$BERTHD" sessions --json | python3 -c '
import json, sys
name = sys.argv[1]
data = json.load(sys.stdin)
for s in data if isinstance(data, list) else data.get("sessions", []):
if s.get("name") == name:
print(name, "agent_state=%s unfinished=%s parked=%s" % (s.get("agent_state"), bool(s.get("unfinished")), bool(s.get("parked"))))
break
else:
print(f"{name}: not listed"); sys.exit(1)
' "$sess"
40 changes: 40 additions & 0 deletions app/e2e/sidebar-turn-check.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { expect, mockOnly, test } from "./fixtures";

const WHY = "Its turn ended while its work runs on: a delegated task, a build, a deploy or a watcher. It picks up by itself.";

// A finished turn turn check read as working ("the build runs, I'll check
// it when it reports back") still counts as working, but the sidebar and
// its tab draw it with an hourglass that says why, not the spinner of a
// turn still going; a turn still going keeps the spinner.
test("an agent waiting on its own work wears an hourglass in the sidebar and its tab, a live turn the spinner", async ({ app }) => {
mockOnly("the unfinished agent is a mock fixture");
await app.open({ prefs: { labs: false, labsChosen: true, version: 3 } });
const { page } = app;

// order-export: its turn ended, its work runs on.
const waiting = app.worktree("devl/order-export");
await expect(waiting.getByRole("img", { name: "Waiting on its work" })).toBeVisible();
await expect(waiting.getByRole("img", { name: "Working" })).toHaveCount(0);
await waiting.getByTestId("on-its-work").hover();
await expect(page.locator("[data-slot=tooltip-popup]").filter({ hasText: WHY })).toBeVisible();
// qa-deck: a turn still going.
const live = app.worktree("devl/qa-deck");
await expect(live.getByRole("img", { name: "Working" })).toBeVisible();
await expect(live.getByTestId("on-its-work")).toHaveCount(0);
await page.mouse.move(800, 600);
await page.screenshot({ path: test.info().outputPath("sidebar.png") });

await app.openWorktree("devl/order-export");
const tab = page.getByRole("tab", { name: /Add tests for the export job/ });
await expect(tab.getByRole("img", { name: "Waiting on its work" })).toBeVisible();
await expect(tab.getByRole("img", { name: "Working" })).toHaveCount(0);
await tab.hover();
await expect(page.locator("[data-slot=tooltip-popup]").filter({ hasText: WHY })).toBeVisible();
await page.screenshot({ path: test.info().outputPath("tab-on-its-work.png") });

await app.openWorktree("devl/qa-deck");
const liveTab = page.getByRole("tab", { name: /Build the QA deck for the release/ });
await expect(liveTab.getByRole("img", { name: "Working" })).toBeVisible();
await expect(liveTab.getByTestId("on-its-work")).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath("tab-live.png") });
});
38 changes: 34 additions & 4 deletions app/src/components/agent-glyph.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { AsteriskIcon, CheckIcon, HexagonIcon, SparkleIcon, SquareTerminalIcon, TerminalIcon } from "lucide-react";
import type { ReactNode } from "react";
import { AsteriskIcon, CheckIcon, HexagonIcon, HourglassIcon, SparkleIcon, SquareTerminalIcon, TerminalIcon } from "lucide-react";

import { Tip } from "@/components/tip";
import type { SessionState } from "@/lib/derive";
import type { Session } from "@/lib/api";
import { ON_ITS_WORK, onItsWork, type SessionState } from "@/lib/derive";
import { useOutdated } from "@/lib/outdated";
import { BOX_WORDS, type BoxState, boxState, boxWhy, sessionWord } from "@/lib/state-model";
import { useStore } from "@/lib/store";
Expand Down Expand Up @@ -45,9 +47,14 @@ export function stateText(state: SessionState) {
// reduced motion the spinner stands still as a broken ring and the dot does
// not ping. The sidebar, the rail, tabs, panes, zen, the dashboard and the
// palette all draw states with it, so they agree. It is named for screen
// readers; the row or card around it says it in words.
export function StateGlyph({ state, className }: { state: SessionState; className?: string }) {
// readers; the row or card around it says it in words. Given its session,
// one whose turn ended while its work runs on (onItsWork) wears an
// hourglass that says so, not the spinner of a turn still going. Inside
// another tooltip's trigger (a tab, a pane's header) it takes noTip, and
// that tooltip says why.
export function StateGlyph({ state, session, noTip, className }: { state: SessionState; session?: Session; noTip?: boolean; className?: string }) {
const box = cn("inline-flex size-3.5 shrink-0 items-center justify-center", className);
if (state === "running" && onItsWork(session)) return <WorkGlyph className={box} noTip={noTip} />;
switch (state) {
case "running":
return (
Expand Down Expand Up @@ -87,6 +94,29 @@ export function StateGlyph({ state, className }: { state: SessionState; classNam
}
}

// WorkGlyph is a turn that ended while its work runs on: an hourglass, with
// why in its tip unless noTip.
export function WorkGlyph({ className, noTip }: { className?: string; noTip?: boolean }) {
const glyph = (
<span data-testid="on-its-work" className={cn("inline-flex shrink-0 items-center justify-center text-muted-foreground", className)} role="img" aria-label="Waiting on its work">
<HourglassIcon className="size-full max-h-3 max-w-3" aria-hidden />
</span>
);
return noTip ? glyph : <Tip label={ON_ITS_WORK}>{glyph}</Tip>;
}

// withWork is a tooltip's label, and when its session's glyph is the
// hourglass drawn with noTip, why.
export function withWork(label: string, state: SessionState | undefined, session: Session | undefined): ReactNode {
if (state !== "running" || !onItsWork(session)) return label;
return (
<span className="flex max-w-72 flex-col gap-0.5">
<span>{label}</span>
<span>{ON_ITS_WORK}</span>
</span>
);
}

// StatusDot is a box's state (lib/state-model.ts): green online, faded
// green when its link is slow, green with
// a blue ring when outdated, red when unreachable, a grey ring while
Expand Down
14 changes: 11 additions & 3 deletions app/src/components/sidebar/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,14 +169,17 @@ export function ProjectsColumn({ prefs, update, picked }: { prefs: SidebarPrefs;
function ProjectColumnRow({ project: p, selected }: { project: Project; selected: boolean }) {
// Only its own boxes' data: agents changing on another box leave it be.
const memberData = useStore(useShallow((s) => p.members.map((m) => s.boxes[m.box.name])));
const state = mostUrgent(p.members.map((m, i) => (m.box.state === "online" ? summary((memberData[i]?.sessions ?? []).filter((s) => inWorktrees(m.loc, s)), memberData[i]).state : undefined)));
const sums = p.members.map((m, i) => (m.box.state === "online" ? summary((memberData[i]?.sessions ?? []).filter((s) => inWorktrees(m.loc, s)), memberData[i]) : undefined));
const state = mostUrgent(sums.map((x) => x?.state));
const onWork = state === "running" && sums.every((x) => x?.state !== "running" || x.onWork);
const online = p.members.some((m) => m.box.state === "online");
const def = p.members.find((m) => m.box.name === p.defaultBox) ?? p.members[0];
return (
<ColumnRow
name={p.name}
selected={selected}
state={state}
onWork={onWork}
away={online ? undefined : p.members.map((m) => m.box)}
tip={<PlaceTip name={`${p.name}${p.slug ? ` (${p.slug})` : ""}`} lines={[...p.members.map((m) => `${m.box.name}: ${m.loc.path}`), ...p.members.filter((m) => m.box.state !== "online").map((m) => `${m.box.name} is ${awayText(m.box)}`)]} />}
items={() => projectGroupActions(p)}
Expand All @@ -193,12 +196,14 @@ function RepoColumnRow({ repo, selected }: { repo: Repo; selected: boolean }) {
const { box, loc } = repo;
const data = useStore((s) => s.boxes[box.name]);
const online = box.state === "online";
const state = online ? mostUrgent([summary((data?.sessions ?? []).filter((s) => inWorktrees(loc, s)), data).state]) : undefined;
const sum = online ? summary((data?.sessions ?? []).filter((s) => inWorktrees(loc, s)), data) : undefined;
const state = mostUrgent([sum?.state]);
return (
<ColumnRow
name={loc.name}
selected={selected}
state={state}
onWork={sum?.onWork}
away={online ? undefined : [box]}
tip={<PlaceTip name={`${loc.name} on ${box.name}`} lines={[loc.path, ...(online ? [] : [`${box.name} is ${awayText(box)}`])]} />}
items={() => projectActions(box.name, loc)}
Expand All @@ -218,6 +223,7 @@ function ColumnRow({
name,
selected,
state,
onWork,
away,
tip,
items,
Expand All @@ -230,6 +236,8 @@ function ColumnRow({
name: string;
selected: boolean;
state?: SessionState;
// Running with no turn going: only work its agents wait on.
onWork?: boolean;
away?: BoxStatus[];
tip: ReactNode;
items: () => Action[];
Expand Down Expand Up @@ -265,7 +273,7 @@ function ColumnRow({
<span className="min-w-0 truncate">{name}</span>
{marks}
<span className="ml-auto" />
{away ? <AwayWord boxes={away} /> : state ? <TaskGlyph state={state} small /> : null}
{away ? <AwayWord boxes={away} /> : state ? <TaskGlyph state={state} small onWork={onWork} /> : null}
</SidebarMenuButton>
</Tip>
<RowOverlay selected={selected}>
Expand Down
Loading