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 @@ -18,6 +18,7 @@ The maintained list of what a person can do in the app, and how to prove each on
| Plugins: built-in and your own, what they add (screens, sidebar items, widgets, panels) | [plugins.md](plugins.md) | `plugin-nav-badges` (sidebar items) |
| 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 |
| 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 |

Expand Down
27 changes: 27 additions & 0 deletions .claude/skills/verify-berthd/features/chat-turn-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Chat: a live turn, and a turn that ended while its work runs on

The chat's foot and its reply box say whether the agent is in a turn. In a live turn (`agent_state: running`) the last row is the agent's spinner word (its own, or a harbour word such as "Making way…" when its screen shows none) with a ticking timer, the reply box queues what you type ("Claude is working: it reads this at its next step", **Queue**), and **Stop** sits under it. A finished turn that turn check read as work still running (`agent_state: finished`, `unfinished: true`: a delegated task, a build, a deploy, a watcher) is between turns: the last row is an hourglass and **Waiting on its work**, with the Agent Dashboard card's tooltip, no spinner word and no timer; the reply box shows **Send** and starts a new turn, and there is no Stop. Everywhere else (the sidebar, the board's counts) that session still counts as running (`lib/derive.ts` `onItsWork`, `chatState`, `replyMode`; `conversation-pane.tsx`; `conversation-view.tsx`).

## Sub-features

- `live-turn`: the spinner word and timer, **Queue** and **Stop** for a running agent.
- `on-its-work`: the hourglass row, its tooltip ("Its turn ended while its work runs on: …"), **Send**, no Stop, for a finished + unfinished one.
- `new-turn`: a prompt sent then starts a turn, and the chat is a live turn's again (the box resets `unfinished` on the turn's start).

## How to get to it (user POV)

- Open a worktree's agent and switch the pane to **Conversation**.
- On the mock: `devl/order-export`'s tab **Add tests for the export job** is on its work; `gpu/judge-v2` is mid-turn.
- On a real box: a turn that ends saying its build, deploy or background task is still running, with turn check on.

## Driving it with drive.mjs

- On the mock: `scenarios/chat-turn-state.mjs` opens both and asserts the literal row text, the tooltip, and the buttons (**Send** and no **Stop …** on its work; **Stop Claude** and **Queue** in a live turn). `app/e2e/chat.spec.ts` ("a turn that ended while its work runs on …") asserts the same.
- On a real box: `scenarios/chat-turn-state-live.mjs` (`export const live = true`), on a dev box of this checkout with turn check on (the commands in [chat-ask-card.md](chat-ask-card.md)). It has Claude Code start `sleep 600` in the background and end its turn, waits for the box to list the session `finished` with `unfinished: true`, and asserts the same as the mock; then a foreground `sleep 45` for a live turn (`agent_state: running`, the timer, **Stop Claude**, **Queue**). Second view: the box's `sessions` through the agent (`call`).
- Turn check's verdicts themselves are Go's: `TestTurnCheckReplays` in `internal/box/turncheck_test.go` replays recorded AI Gateway answers (`internal/box/testdata/turncheck/`), each tied to its exact request by a sha256. A changed prompt fails it until it is recorded again, which asks the gateway: `BERTH_TURNCHECK_RECORD_KEY_FILE=<key file> go test ./internal/box -run TestTurnCheckReplays`.

## Gotchas

- The tab strip and the sidebar still draw a spinner for a session on its work; giving them the hourglass is a separate change.
- On a real box the hourglass needs the box's turn check to say `working` at 0.65 or more. A reply that only reports another session's progress and offers to follow it up is `done`, so it ends as a plain finished turn.
- A helper (subagent) left running in the background keeps using tools after the agent's Stop. Claude Code marks those hooks with `agent_id`, and the box ignores them while no turn is open, so they don't bring back the live turn. One that asks for a permission then still makes the session wait, and its tool running once that is granted ends the wait.
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// The chat's foot and composer on a real box (features/chat-turn-state.md):
// a turn Claude Code ends while a background task it started still runs,
// which turn check reads as working (finished + unfinished: the hourglass
// and Send), then a live turn (the spinner word, its timer, Stop and
// Queue), each read back from the box's own session list.
//
// On a dev box of this checkout (../dev-box.sh), with turn check on for it
// (features/chat-ask-card.md copies the settings in):
//
// node drive.mjs chat-turn-state-live --port $PORT --agent http://127.0.0.1:9378
//
// VERIFY_SESSION names the session (default "ask", dev-box.sh's): Claude
// Code at its prompt. It types two prompts there.

export const live = true;
export const params = { view: "conversation", sidebar: "list" };

const WORKTREE = process.env.VERIFY_WORKTREE ?? "devq/acme-shop";
const BOX = WORKTREE.split("/")[0];
const SESSION = process.env.VERIFY_SESSION ?? "ask";
const ON_ITS_WORK = "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, call }) {
const session = async () => (await call(`/v1/boxes/${BOX}/api/sessions`)).find((s) => s.name === SESSION) ?? {};
const pane = page.locator("[data-testid=pane]:visible");
const chat = pane.locator("[data-testid=chat]");
const composer = pane.locator("[data-testid=composer]");
const reply = composer.getByRole("textbox", { name: "Reply" });
const send = async (text) => {
await reply.fill(text);
await reply.press("Enter");
await expect(reply).toHaveValue("", { timeout: 20_000 });
};

await page.locator(`[data-testid=worktree-row][data-worktree="${WORKTREE}"]`).click({ position: { x: 32, y: 12 } });
await expect(reply).toBeVisible({ timeout: 30_000 });

step("a turn that ends while a background task it started still runs");
await send(
"Start `sleep 600` as a background task (run_in_background), don't wait for it, and end your turn right away with exactly: The background sleep is still running; I'll report back when it finishes.",
);
await expect.poll(async () => { const s = await session(); return `${s.agent_state}/${!!s.unfinished}`; }, { timeout: 180_000, intervals: [2_000], message: "turn check read it as working" }).toBe("finished/true");
const onWork = chat.locator("[data-kind=on-its-work]");
await expect(onWork).toHaveText("Waiting on its work", { timeout: 15_000 });
await expect(chat.locator("[data-kind=thinking]")).toHaveCount(0);
await expect(composer.getByRole("button", { name: "Send", exact: true })).toBeVisible();
await expect(composer.getByRole("button", { name: "Queue", exact: true })).toHaveCount(0);
await expect(pane.getByRole("button", { name: /^Stop / })).toHaveCount(0);
await onWork.getByText("Waiting on its work").hover();
await expect(page.locator("[data-slot=tooltip-popup]")).toHaveText(ON_ITS_WORK);
await page.waitForTimeout(300); // its fade-in, for the picture
await shot("live-on-its-work");
await page.mouse.move(10, 890);

step("a live turn: a new prompt starts one");
await send("Run `sleep 45` in the foreground (not in the background), then reply: done.");
await expect.poll(async () => (await session()).agent_state, { timeout: 60_000, intervals: [1_000] }).toBe("running");
const thinking = chat.locator("[data-kind=thinking]");
await expect(thinking).toBeVisible({ timeout: 15_000 });
await expect(chat.locator("[data-kind=on-its-work]")).toHaveCount(0);
await expect(pane.getByRole("button", { name: "Stop Claude" })).toBeVisible();
await expect(composer.getByRole("button", { name: "Queue", exact: true })).toBeVisible();
await page.waitForTimeout(3_000); // the timer ticks
await expect(thinking).toHaveText(/· \d+(s|m \d\ds)/);
step(`live footer reads "${(await thinking.innerText()).replace(/\s+/g, " ").trim()}"`);
await shot("live-turn-real");
await expect.poll(async () => (await session()).agent_state, { timeout: 120_000, intervals: [2_000] }).toBe("finished");
}
55 changes: 55 additions & 0 deletions .claude/skills/verify-berthd/scripts/scenarios/chat-turn-state.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// The chat's foot and composer for a turn that ended while its work runs on,
// and for a live turn (features/chat-turn-state.md), on the mock:
//
// node drive.mjs chat-turn-state --port $PORT
//
// order-export's "Add tests for the export job" finished a turn that turn
// check read as working (unfinished): an hourglass, "Waiting on its work"
// with the dashboard card's tooltip, no spinner word or timer, and Send.
// judge-v2's agent is mid-turn: the spinner word with its timer, Stop and
// Queue.

const ON_ITS_WORK = "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 }) {
await page.goto(page.url().replace(/\?.*/, "?mock=1&sidebar=list&view=conversation"));
await expect(page.getByTestId("nav-home")).toBeVisible();
const chat = page.locator("[data-testid=pane]:visible [data-testid=chat]");
const pane = page.locator("[data-testid=pane]:visible");
const composer = pane.locator("[data-testid=composer]");
const open = async (worktree, tab) => {
await page.locator(`[data-testid=worktree-row][data-worktree="${worktree}"]`).click({ position: { x: 32, y: 12 } });
if (tab) await page.locator("[data-tab-strip] [data-tab]", { hasText: tab }).click();
await expect(chat.locator("[data-testid=chat-item]").first()).toBeVisible();
};

step("open the turn that ended while its work runs on (devl/order-export, Add tests for the export job)");
await open("devl/order-export", "Add tests for the export job");
const onWork = chat.locator("[data-kind=on-its-work]");
await expect(onWork).toHaveText("Waiting on its work");
await expect(onWork.locator("svg.lucide-hourglass")).toBeVisible();
await expect(chat.locator("[data-kind=thinking]")).toHaveCount(0);
await expect(composer.getByRole("button", { name: "Send", exact: true })).toBeVisible();
await expect(composer.getByRole("button", { name: "Queue", exact: true })).toHaveCount(0);
await expect(pane.getByRole("button", { name: /^Stop / })).toHaveCount(0);
await expect(composer.getByRole("textbox", { name: "Reply" })).toHaveAttribute("placeholder", "Reply, or ask for something else");
step("hover the hourglass for its tooltip");
await onWork.getByText("Waiting on its work").hover();
await expect(page.locator("[data-slot=tooltip-popup]")).toHaveText(ON_ITS_WORK);
await page.waitForTimeout(300); // its fade-in, for the picture
await shot("on-its-work");
await page.mouse.move(10, 890);

step("open a live turn (gpu/judge-v2)");
await open("gpu/judge-v2");
const thinking = chat.locator("[data-kind=thinking]");
await expect(thinking).toBeVisible();
// Its word, then the timer: "Making way… · 12s".
await expect(thinking).toHaveText(/…\s*· \d+(s|m \d\ds)/);
await expect(chat.locator("[data-kind=on-its-work]")).toHaveCount(0);
await expect(pane.getByRole("button", { name: "Stop Claude" })).toBeVisible();
await expect(composer.getByRole("button", { name: "Queue", exact: true })).toBeVisible();
await expect(composer.getByRole("textbox", { name: "Reply" })).toHaveAttribute("placeholder", "Claude is working: it reads this at its next step");
step(`live footer reads "${(await thinking.innerText()).replace(/\s+/g, " ").trim()}"`);
await shot("live-turn");
}
19 changes: 19 additions & 0 deletions app/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,25 @@ test("a reply sent while the agent works waits as pending", async ({ app }) => {
await expect(pending.getByRole("button", { name: "Send now" })).toBeEnabled();
});

test("a turn that ended while its work runs on shows the hourglass and takes a new turn", async ({ app }) => {
mockOnly();
// Turn check read its finished turn as work still running (unfinished).
await app.openWorktree("devl/order-export");
await app.page.locator("[data-tab-strip] [data-tab]", { hasText: "Add tests for the export job" }).click();
const onWork = app.chat.locator("[data-kind=on-its-work]");
await expect(onWork).toHaveText("Waiting on its work");
await expect(app.chat.locator("[data-kind=thinking]")).toHaveCount(0);
await expect(app.composer.getByRole("button", { name: "Send", exact: true })).toBeVisible();
await expect(app.panes.getByRole("button", { name: /^Stop / })).toHaveCount(0);
await onWork.getByText("Waiting on its work").hover();
await expect(app.page.locator("[data-slot=tooltip-popup]")).toContainText("Its turn ended while its work runs on");
// A live turn keeps its spinner, Stop and Queue.
await app.openWorktree("gpu/judge-v2");
await expect(app.chat.locator("[data-kind=thinking]")).toBeVisible();
await expect(app.panes.getByRole("button", { name: "Stop Claude" })).toBeVisible();
await expect(app.composer.getByRole("button", { name: "Queue", exact: true })).toBeVisible();
});

// selectWords selects an element's words, as a triple click does.
async function selectWords(el: import("@playwright/test").Locator) {
await el.scrollIntoViewIfNeeded();
Expand Down
17 changes: 11 additions & 6 deletions app/src/components/conversation/conversation-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { joinDraft, listenForQuotes, type QuoteFill } from "@/lib/chat-quote";
import { canPickPast } from "@/lib/commands";
import { resumeWords } from "@/lib/resume-words";
import { keyOf, useConversations } from "@/lib/conversation-store";
import { agentLabel, agentOf, atQuestion, firstPrompt, guessAgent, sessionState, worktreeOf } from "@/lib/derive";
import { agentLabel, agentOf, atQuestion, chatState, firstPrompt, guessAgent, onItsWork, replyMode, worktreeOf } from "@/lib/derive";
import type { NextStep } from "@/lib/errors";
import { errorMessage } from "@/lib/format";
import { useNotifications } from "@/lib/notifications";
Expand Down Expand Up @@ -86,7 +86,10 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS
const away = !!boxStatus && boxStatus.state !== "online";
// Gone: the box lists its sessions and this one isn't among them.
const gone = !away && !!listed && !s;
const state = gone ? "exited" : s ? sessionState(s, stats) : undefined;
// A session on its work is between turns here (chatState): a message
// starts a new one.
const state = gone ? "exited" : s ? chatState(s, stats) : undefined;
const parked = !gone && onItsWork(s);
const [attempt, setAttempt] = useState(0);
const feed = useTranscriptFeed(box, session, s?.dir, visible && !mock && !away, attempt);
// Turn check's wait asks in the agent's last message, which the reply
Expand Down Expand Up @@ -156,7 +159,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS
useEffect(() => {
if (!mock || !s || useConversations.getState().items[key]) return;
const wt = worktreeOf(locations, s);
const state = sessionState(s, stats);
const state = chatState(s, stats);
void mockConversation().then((m) => {
if (!useConversations.getState().items[key]) m.seedTranscript(box, session, state, wt ? (wt.worktree.main ? wt.location.name : wt.worktree.name) : session);
});
Expand All @@ -173,7 +176,8 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS
// What the box does not send, from the session's state: thinking while it
// works, its question while it waits.
const shown = useMemo(() => {
if (mock) return withDraft(items, drafts.draft).items;
const onWork: TranscriptItem[] = parked ? [{ kind: "on-its-work", id: "live:on-its-work" }] : [];
if (mock) return [...withDraft(items, drafts.draft).items, ...onWork];
const sentNow: TranscriptItem[] = [];
// Started with a prompt it hasn't read yet, as it waits at a question
// of its own (whether to trust its folder): the prompt shows as sent.
Expand Down Expand Up @@ -207,6 +211,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS
// Started with a prompt, it is about to work, not waiting for a first
// task: say so until its own record arrives.
else if (prompted && !items.length && (state === "ready" || state === "idle")) out.push({ kind: "thinking", id: "live:starting", since: Date.parse(s?.created ?? "") || Date.now() });
out.push(...onWork);
const decided = answered && answered.at === s?.state_since ? answered.key : undefined;
// A form of questions draws itself (its question item); one the chat
// can't read from the record (an older box) only its screen answers.
Expand All @@ -223,7 +228,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS
out.push({ kind: "ask", id: "live:ask", tool: "Question", detail: s?.ask?.message || ask.detail, choices: ask.choices, decided });
}
return out;
}, [mock, items, state, s?.state_since, s?.ask, s?.created, startedWith, ask, answered, sent, prompted, onScreen, last, formAsk, drafts.draft, drafts.status, drafts.supported]);
}, [mock, items, state, parked, s?.state_since, s?.ask, s?.created, startedWith, ask, answered, sent, prompted, onScreen, last, formAsk, drafts.draft, drafts.status, drafts.supported]);

const edits = useMemo<EditActions | undefined>(() => {
if (!client || (!canDiff && !mock)) return undefined;
Expand Down Expand Up @@ -597,7 +602,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS
comments={reviewKey ? toSend.length : 0}
live={live.show}
terminal={!!onShowTerminal}
mode={state === "running" ? "queue" : state === "waiting" ? "answer" : "send"}
mode={replyMode(state)}
blocked={(asking && atMenu) || live.show}
hint={live.show ? (liveWhy ?? `${who} is showing its own screen: answer it above`) : wantsWords ? `Tell ${who} what to change, then press Enter` : answerable ? `Answer ${who}'s ${openQ?.questions.length === 1 ? "question" : "questions"} above` : undefined}
actions={actions}
Expand Down
Loading