diff --git a/client/src/components/conversation/ConversationView.tsx b/client/src/components/conversation/ConversationView.tsx index 2a66da56..d02deb12 100644 --- a/client/src/components/conversation/ConversationView.tsx +++ b/client/src/components/conversation/ConversationView.tsx @@ -97,6 +97,8 @@ interface ConversationViewProps { waiting?: boolean; /** Shows the pane's terminal, where that question is answered. */ onOpenTerminal?: () => void; + /** Bumped by the chat's Stop: the live line ends with the turn on screen. */ + stopped?: number; /** The newest reply's prompt size (input + cache) and model: how full the * context is. */ onContext?: (tokens: number, model: string | null) => void; @@ -113,6 +115,7 @@ export function ConversationView({ follow = 0, waiting = false, onOpenTerminal, + stopped = 0, onContext, onNotStarted, }: ConversationViewProps) { @@ -558,7 +561,11 @@ export function ConversationView({ {t("conversation.notStartedBody")}

{onOpenTerminal && ( - )} @@ -578,6 +585,7 @@ export function ConversationView({ live={live} waiting={waiting} onOpenTerminal={onOpenTerminal} + stopped={stopped} /> )} diff --git a/client/src/components/conversation/MessageList.tsx b/client/src/components/conversation/MessageList.tsx index 09e4b22e..d0e21a8d 100644 --- a/client/src/components/conversation/MessageList.tsx +++ b/client/src/components/conversation/MessageList.tsx @@ -156,6 +156,9 @@ interface MessageListProps { waiting?: boolean; /** Shows the pane's terminal, where that question is answered. */ onOpenTerminal?: () => void; + /** Bumped by the chat's Stop: the turn on screen is over, even when the + * transcript never says so. */ + stopped?: number; } /** Build a map from tool_use id → tool_result for matching */ @@ -611,15 +614,24 @@ export function MessageList({ live = false, waiting = false, onOpenTerminal, + stopped = 0, }: MessageListProps) { const { t } = useTranslation("sessions"); const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]); + const keys = useMemo(() => messageKeys(messages), [messages]); + const lastKey = keys[keys.length - 1] ?? null; + // The newest line when Stop was pressed. Esc before the model wrote anything + // puts the prompt back in Claude Code's input box and writes no + // "[Request interrupted…]" line (issue #74), so the transcript still ends on + // the prompt: no live line until a newer one arrives. + const [stop, setStop] = useState<{ n: number; on: string | null }>({ n: stopped, on: null }); + if (stop.n !== stopped) setStop({ n: stopped, on: lastKey }); + const halted = stop.on !== null && stop.on === lastKey; const pending = useMemo( - () => (live ? workingOn(messages, toolResultMap) : null), - [live, messages, toolResultMap] + () => (live && !halted ? workingOn(messages, toolResultMap) : null), + [live, halted, messages, toolResultMap] ); const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]); - const keys = useMemo(() => messageKeys(messages), [messages]); const items = useMemo(() => chatItems(messages, keys), [messages, keys]); if (loading) { diff --git a/client/src/components/conversation/__tests__/MessageList.sender.test.tsx b/client/src/components/conversation/__tests__/MessageList.sender.test.tsx index 45d8a6c0..1ae20186 100644 --- a/client/src/components/conversation/__tests__/MessageList.sender.test.tsx +++ b/client/src/components/conversation/__tests__/MessageList.sender.test.tsx @@ -182,6 +182,29 @@ describe("MessageList — sender attribution", () => { expect(screen.queryByTestId("chat-running-step")).toBeNull(); }); + // Issue #74: Esc before the model wrote anything takes the prompt back into + // Claude Code's input box and writes NO "[Request interrupted…]" line — the + // transcript still ends on the prompt, which read as "Thinking…" for 15 min. + it("drops Thinking… once Stop was pressed, until the transcript moves on", () => { + const prompt = msg({ + sender: "user", + timestamp: new Date(Date.now() - 14_000).toISOString(), + content: [{ type: "text", text: "Phần đề xuất trong báo cáo" }], + }); + const { rerender } = render(); + expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking"); + rerender(); + expect(screen.queryByTestId("chat-running-step")).toBeNull(); + // The next prompt is a new turn: its Thinking… shows. + const next = msg({ + sender: "user", + timestamp: new Date().toISOString(), + content: [{ type: "text", text: "again" }], + }); + rerender(); + expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking"); + }); + it("counts a turn's output once per API call, since its prompt", () => { const at = "2026-06-26T08:19:00.000Z"; const line = (cache: number, out: number) => diff --git a/client/src/components/run/PaneChat.tsx b/client/src/components/run/PaneChat.tsx index 9d76e203..e8516ce4 100644 --- a/client/src/components/run/PaneChat.tsx +++ b/client/src/components/run/PaneChat.tsx @@ -12,7 +12,12 @@ * `/model ` / `/effort ` — and the permission mode, which only * Shift+Tab changes in a running session, so the server steps it and reads * every step back off the pane (`POST /api/run/:id/permission-mode`); it sits - * in the box beside `+`. A permission prompt or a menu still needs the terminal. + * in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a + * permission or trust prompt, Codex's approval — is read off the terminal + * underneath (`readRunMenu`) and shown in the prompt box's place as buttons + * that press its keys there (`PaneMenuCard`); its "Type something" row opens + * a field in the card, whose text is typed there as the answer + * (`answerRunMenu`). * It also has Claude Desktop's activity panel (`ChatOutline`: progress, * outputs, what the session used), open at first in a pane spanning the * grid's full width or height (`panelOpen`) and collapsed into a file button @@ -49,7 +54,8 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; -import { ArrowUp, FileText, Square } from "lucide-react"; +import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react"; +import { isSubmitEnter } from "../../lib/keys"; import { ConversationView } from "../conversation/ConversationView"; import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup"; import { ChatAddMenu } from "./ChatAddMenu"; @@ -59,7 +65,14 @@ import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } f import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview"; import { Select } from "../Select"; import { Divider, showTerminal } from "./PaneGrid"; -import { interruptRun, submitToRun } from "../../lib/panePaste"; +import { + answerRunMenu, + chooseRunOption, + interruptRun, + readRunMenu, + submitToRun, + type PaneMenu, +} from "../../lib/panePaste"; import { useLiveRefresh } from "../../hooks/useLiveRefresh"; import { openInChanges } from "../../lib/openInChanges"; import { toast } from "../../lib/toast"; @@ -184,6 +197,108 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission ); } +/** The menu the CLI asks with, as buttons: a pick presses its key in the + * pane; its "Type something" row opens a field right there instead — + * pressing that row's digit again would type the digit into the CLI's own. */ +function PaneMenuCard({ + menu, + onChoose, + onAnswer, + onCancel, +}: { + menu: PaneMenu; + onChoose: (index: number) => void; + onAnswer: (text: string) => void; + onCancel: () => void; +}) { + const { t } = useTranslation("run"); + const [typing, setTyping] = useState(false); + const [draft, setDraft] = useState(""); + const submit = () => draft.trim() && onAnswer(draft); + return ( +
+ {menu.question && ( +

+ {menu.question} +

+ )} +
+ {menu.options.map((o, i) => + typing && i === menu.typeAt ? ( +
+ {o.n} + setDraft(e.target.value)} + onKeyDown={(e) => { + if (isSubmitEnter(e)) { + e.preventDefault(); + submit(); + } else if (e.key === "Escape") setTyping(false); + }} + placeholder={t("chat.menu.placeholder")} + aria-label={t("chat.menu.placeholder")} + className="min-w-0 flex-1 bg-transparent py-0.5 text-sm text-fg-primary outline-none placeholder:text-fg-muted" + /> + +
+ ) : ( + + ) + )} +
+
+ +
+
+ ); +} + export function PaneChat({ run, sessionId, @@ -247,6 +362,8 @@ export function PaneChat({ // waits on the user from a Notification (a permission prompt) to the next hook. const [working, setWorking] = useState(false); const [waiting, setWaiting] = useState(false); + // Stop presses, for the conversation's live line (issue #74). + const [stops, setStops] = useState(0); const readWorking = useCallback( () => api.agents @@ -275,14 +392,41 @@ export function PaneChat({ (msg.data as { session_id?: string } | undefined)?.session_id === sessionId, }); - // A hidden tab says when this session waits on the user or its turn ended. - const was = useRef({ working: false, waiting: false }); + // The menu the terminal underneath shows now. AskUserQuestion fires no + // Notification, so this — not `waiting` alone — says the CLI asks. + // ponytail: polls the xterm buffer; subscribe to its onRender if it ever shows in a profile. + const [menu, setMenu] = useState(null); useEffect(() => { - if (waiting) setTabBadge(runId, "waiting"); + const read = () => + setMenu((prev) => { + const next = readRunMenu(runId); + return JSON.stringify(next) === JSON.stringify(prev) ? prev : next; + }); + read(); + const timer = window.setInterval(read, 700); + return () => window.clearInterval(timer); + }, [runId]); + const asking = waiting || menu !== null; + // Gone until the pane redraws: no second press on a stale menu. + const choose = (index: number) => { + if (menu && chooseRunOption(runId, menu, index)) setMenu(null); + else toast.error(t("chat.notSent")); + }; + const answer = (reply: string) => { + if (menu && answerRunMenu(runId, menu, reply)) { + setMenu(null); + setFollow((n) => n + 1); + } else toast.error(t("chat.notSent")); + }; + + // A hidden tab says when this session waits on the user or its turn ended. + const was = useRef({ working: false, asking: false }); + useEffect(() => { + if (asking) setTabBadge(runId, "waiting"); else if (working) setTabBadge(runId, null); - else if (was.current.working || was.current.waiting) setTabBadge(runId, "done"); - was.current = { working, waiting }; - }, [runId, working, waiting]); + else if (was.current.working || was.current.asking) setTabBadge(runId, "done"); + was.current = { working, asking }; + }, [runId, working, asking]); useEffect(() => () => setTabBadge(runId, null), [runId]); const [context, setContext] = useState<{ tokens: number; model: string | null }>({ tokens: 0, @@ -339,7 +483,8 @@ export function PaneChat({ fill live follow={follow} - waiting={waiting} + waiting={asking} + stopped={stops} onOpenTerminal={() => showTerminal(runId, sessionId)} onContext={onContext} onNotStarted={setNotStarted} @@ -366,7 +511,24 @@ export function PaneChat({
-
+ {menu && ( + { + if (interruptRun(runId)) setMenu(null); + else toast.error(t("chat.notSent")); + }} + /> + )} + {/* In the card's place while the CLI asks: hidden, never unmounted, + so a draft or an upload in flight survives the question. */} +
{ // An interrupted turn fires no Stop hook: clear it here. - if (interruptRun(runId)) setWorking(false); - else toast.error(t("chat.notStopped")); + if (interruptRun(runId)) { + setWorking(false); + setStops((n) => n + 1); + } else toast.error(t("chat.notStopped")); }} aria-label={t("chat.stop")} title={t("chat.stop")} diff --git a/client/src/components/run/__tests__/PaneChat.test.tsx b/client/src/components/run/__tests__/PaneChat.test.tsx index c5d8b421..dca8e8d8 100644 --- a/client/src/components/run/__tests__/PaneChat.test.tsx +++ b/client/src/components/run/__tests__/PaneChat.test.tsx @@ -7,7 +7,9 @@ * activity panel, collapsed into a file button with the output count, whose * files open a preview in its place; a file in a turn's "Edited N files" card * opens in the Workspace's Changes panel when it is in the pane's folder; and - * Stop in Send's place while the main agent works, pressing Esc in the pane. + * Stop in Send's place while the main agent works, pressing Esc in the pane; + * and the menu the CLI asks with, shown as buttons that press its keys, a + * message sent meanwhile typed as its "Type something" answer. * @author Nguyễn Ngọc Trí Vĩ */ @@ -16,7 +18,14 @@ import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { PaneChat, contextWindow } from "../PaneChat"; import { api } from "../../../lib/api"; -import { interruptRun, submitToRun } from "../../../lib/panePaste"; +import { + answerRunMenu, + chooseRunOption, + interruptRun, + readRunMenu, + submitToRun, + type PaneMenu, +} from "../../../lib/panePaste"; import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges"; vi.mock("../../conversation/ConversationView", () => ({ @@ -41,6 +50,9 @@ vi.mock("../../../lib/confirm", () => ({ confirmDialog })); vi.mock("../../../lib/panePaste", () => ({ submitToRun: vi.fn(() => true), interruptRun: vi.fn(() => true), + readRunMenu: vi.fn(() => null), + chooseRunOption: vi.fn(() => true), + answerRunMenu: vi.fn(() => true), })); vi.mock("../../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); @@ -412,3 +424,51 @@ describe("PaneChat — the unsent prompt", () => { expect(confirmDialog).not.toHaveBeenCalled(); }); }); + +describe("PaneChat — a menu the CLI asks with", () => { + const MENU: PaneMenu = { + question: "Which color?", + options: [ + { n: 1, label: "Red", hint: "warm", checked: null }, + { n: 2, label: "Type something.", hint: "", checked: null }, + ], + cursor: 0, + multi: false, + typeAt: 1, + }; + + it("shows the question as buttons in the box's place, and a pick presses its key", async () => { + vi.mocked(readRunMenu).mockReturnValue(MENU); + try { + renderChat(); + const card = await screen.findByTestId("pane-chat-menu"); + expect(card).toHaveTextContent("Which color?"); + expect(card).toHaveTextContent("warm"); + // The prompt box is hidden (kept mounted) while the CLI asks. + expect(screen.getByPlaceholderText("Write a message…").closest(".hidden")).not.toBeNull(); + fireEvent.click(screen.getByText("Red")); + expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", MENU, 0); + } finally { + vi.mocked(readRunMenu).mockReturnValue(null); + } + }); + + it("types the answer in the card's own field, never pressing that row's key", async () => { + vi.mocked(readRunMenu).mockReturnValue(MENU); + vi.mocked(chooseRunOption).mockClear(); + vi.mocked(submitToRun).mockClear(); + try { + renderChat(); + fireEvent.click(await screen.findByText("Type something.")); + expect(chooseRunOption).not.toHaveBeenCalled(); + const field = screen.getByTestId("pane-chat-menu-input"); + expect(field).toHaveFocus(); + fireEvent.change(field, { target: { value: "purple" } }); + fireEvent.keyDown(field, { key: "Enter" }); + expect(answerRunMenu).toHaveBeenCalledWith("agenthub-lane-1", MENU, "purple"); + expect(submitToRun).not.toHaveBeenCalled(); + } finally { + vi.mocked(readRunMenu).mockReturnValue(null); + } + }); +}); diff --git a/client/src/i18n/locales/en/run.json b/client/src/i18n/locales/en/run.json index fdfe19fe..65d8426c 100644 --- a/client/src/i18n/locales/en/run.json +++ b/client/src/i18n/locales/en/run.json @@ -127,6 +127,10 @@ "tab": { "waiting": "Waiting", "done": "Done" + }, + "menu": { + "placeholder": "Type your answer…", + "cancel": "Cancel (Esc)" } }, "interrupted": { diff --git a/client/src/i18n/locales/vi/run.json b/client/src/i18n/locales/vi/run.json index 3f3143e7..54fe5ca5 100644 --- a/client/src/i18n/locales/vi/run.json +++ b/client/src/i18n/locales/vi/run.json @@ -127,6 +127,10 @@ "tab": { "waiting": "Đang chờ", "done": "Xong" + }, + "menu": { + "placeholder": "Nhập câu trả lời của bạn…", + "cancel": "Huỷ (Esc)" } }, "interrupted": { diff --git a/client/src/lib/__tests__/panePaste.test.ts b/client/src/lib/__tests__/panePaste.test.ts index 2adc1c55..20a383d5 100644 --- a/client/src/lib/__tests__/panePaste.test.ts +++ b/client/src/lib/__tests__/panePaste.test.ts @@ -6,16 +6,22 @@ * is not on screen (a hidden console behind a full-width Changes panel); and * `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane; * and the input box read off a real `claude`/`codex` screen (`parsePromptInput`) - * and moved out of it (`takeRunInput`) when the pane switches to Chat. + * and moved out of it (`takeRunInput`) when the pane switches to Chat; and + * the menu a CLI asks with, read off real Claude Code 2.1.296 / Codex 0.161 + * screens (`parsePaneMenu`) and answered with the keys verified there. * @author Nguyễn Ngọc Trí Vĩ */ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { interruptRun, notePaneFocus, parsePromptInput, + answerRunMenu, + chooseRunOption, + parsePaneMenu, pasteToLane, + readRunMenu, registerPane, takeRunInput, } from "../panePaste"; @@ -122,4 +128,186 @@ describe("panePaste", () => { off(); expect(takeRunInput("agenthub-lane-7")).toBe(""); }); + + it("reads an AskUserQuestion menu: question, options, hints, cursor", () => { + const m = parsePaneMenu(ASK); + expect(m).toEqual({ + question: "Which color do you like: red, green, or blue?", + options: [ + { n: 1, label: "Red", hint: "Crisp red", checked: null }, + { n: 2, label: "Green", hint: "", checked: null }, + { n: 3, label: "Type something.", hint: "", checked: null }, + { n: 4, label: "Chat about this", hint: "", checked: null }, + ], + cursor: 0, + multi: false, + typeAt: 2, + }); + }); + + it("reads a multiSelect question with its unnumbered Submit row", () => { + const m = parsePaneMenu(MULTI)!; + expect(m.multi).toBe(true); + expect(m.options.map((o) => [o.n, o.label, o.checked])).toEqual([ + [1, "Nuts", true], + [2, "Honey", false], + [3, "Type something", false], + [null, "Submit", null], + [4, "Chat about this", null], + ]); + expect(m.cursor).toBe(1); + expect(m.typeAt).toBe(2); + }); + + // The "Type something" row once something is typed in it: its label is the + // text. It is still the row right above "Chat about this". + it("finds Type something by its place once text is typed in it, and replaces that text", () => { + vi.useFakeTimers(); + const keys: string[] = []; + const typed = ASK.map((r) => + r === "❯ 1. Red" ? " 1. Red" : r === " 3. Type something." ? "❯ 3. 44444" : r + ); + const m = parsePaneMenu(typed)!; + expect(m.typeAt).toBe(2); + expect(m.cursor).toBe(2); + const off = registerPane( + "agenthub-lane-9", + () => {}, + () => true, + (d) => keys.push(d) + ); + expect(answerRunMenu("agenthub-lane-9", m, "teal")).toBe(true); + vi.runAllTimers(); + expect(keys).toEqual(["\x15", "\x1b[200~teal\x1b[201~", "\r"]); + off(); + vi.useRealTimers(); + }); + + it("reads Claude's and Codex's permission prompts", () => { + const c = parsePaneMenu(PERMISSION)!; + expect(c.question).toBe("Do you want to proceed?"); + expect(c.options.map((o) => o.label)).toEqual(["Yes", "Yes, and always allow", "No"]); + const x = parsePaneMenu(CODEX_APPROVAL)!; + expect(x.question).toBe("Would you like to run the following command?\n$ touch probe.txt"); + expect(x.options).toHaveLength(3); + expect(x.cursor).toBe(0); + }); + + it("reads a menu with no footer: the login method at first start", () => { + const m = parsePaneMenu([ + " Select login method:", + "", + " ❯ 1. Claude account with subscription · Pro, Max, Team, or Enterprise", + "", + " 2. Anthropic Console account · API usage billing", + "", + ])!; + expect(m.question).toBe("Select login method:"); + expect(m.options.map((o) => o.n)).toEqual([1, 2]); + }); + + it("finds no menu in a reply's numbered list or a typed `1.`", () => { + // No cursor on the list. + expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull(); + // A numbered list typed into the input box (under its rule), mid-turn. + expect(parsePaneMenu([RULE, "❯ 1. fix it", RULE, " esc to interrupt"])).toBeNull(); + expect(parsePaneMenu([RULE, "❯\u00a01. a", " 2. b", RULE, " esc to interrupt"])).toBeNull(); + expect(parsePaneMenu(CLAUDE)).toBeNull(); + }); + + it("answers by digit, walks to Submit, and types into Type something", () => { + vi.useFakeTimers(); + const keys: string[] = []; + let rows = ASK; + const off = registerPane( + "agenthub-lane-8", + () => {}, + () => true, + (d) => keys.push(d), + () => ({ rows, cols: 80 }) + ); + const ask = readRunMenu("agenthub-lane-8")!; + expect(chooseRunOption("agenthub-lane-8", ask, 1)).toBe(true); + expect(keys).toEqual(["2"]); + keys.length = 0; + // Single select: walk to the row, paste, Enter. + expect(answerRunMenu("agenthub-lane-8", ask, "purple")).toBe(true); + vi.runAllTimers(); + expect(keys).toEqual(["\x1b[B", "\x1b[B", "\x1b[200~purple\x1b[201~", "\r"]); + keys.length = 0; + rows = MULTI; + const multi = readRunMenu("agenthub-lane-8")!; + // From Honey (cursor 1) to Submit (3): two Downs, then Enter. + expect(chooseRunOption("agenthub-lane-8", multi, 3)).toBe(true); + vi.runAllTimers(); + expect(keys).toEqual(["\x1b[B", "\x1b[B", "\r"]); + keys.length = 0; + // multiSelect: the typed text ticks the row; no Enter, Submit still to pick. + expect(answerRunMenu("agenthub-lane-8", multi, "salt")).toBe(true); + vi.runAllTimers(); + expect(keys).toEqual(["\x1b[B", "\x1b[200~salt\x1b[201~"]); + off(); + vi.useRealTimers(); + expect(readRunMenu("agenthub-lane-8")).toBeNull(); + }); }); + +// Real screens (descriptions trimmed), bottom of a 120-column pane. +const ASK = [ + "❯ Use the AskUserQuestion tool to ask me which color I like.", + RULE, + " ☐ Color", + "", + "Which color do you like: red, green, or blue?", + "", + "❯ 1. Red", + " Crisp red", + " 2. Green", + " Green", + " 3. Type something.", + RULE, + " 4. Chat about this", + "", + "Enter to select · ↑/↓ to navigate · Esc to cancel", + "", +]; +const MULTI = [ + RULE, + "← ☒ Fruit ☐ Toppings ✔ Submit →", + "", + "Which toppings do you want?", + "", + " 1. [✔] Nuts", + " Crunchy, adds texture.", + "❯ 2. [ ] Honey", + " 3. [ ] Type something", + " Submit", + RULE, + " 4. Chat about this", + "", + "Enter to select · Tab/Arrow keys to navigate · Esc to cancel", +]; +const PERMISSION = [ + " Bash command", + " touch probe-file.txt", + "╌".repeat(60), + " Do you want to proceed?", + " ❯ 1. Yes", + " 2. Yes, and always allow", + " 3. No", + "", + " Esc to cancel · Tab to amend", +]; +const CODEX_APPROVAL = [ + "• Running touch probe.txt", + "", + " Would you like to run the following command?", + " $ touch probe.txt", + "", + "› 1. Yes, proceed (y)", + " 2. Yes, and don't ask again for commands that start with `touch probe.txt` (p)", + " 3. No, and tell Codex what to do differently", + "", + // "Press enter to confirm or esc to cancel", its dim cells left out. + " enteresc", +]; diff --git a/client/src/lib/panePaste.ts b/client/src/lib/panePaste.ts index f7d7e60a..a0124cb3 100644 --- a/client/src/lib/panePaste.ts +++ b/client/src/lib/panePaste.ts @@ -14,6 +14,9 @@ * terminal moves the unsent prompt across (`takeRunInput` reads the TUI's * input box off the screen and empties it; the chat's draft is pasted back * without Enter), so the half-typed text is never left behind on the other side. + * The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads + * it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the + * arrows and Enter, or type into its "Type something" row). * @author Nguyễn Ngọc Trí Vĩ */ @@ -151,3 +154,141 @@ export function takeRunInput(runId: string): string { p!.key!("\x1b[3~".repeat(n) + "\x7f".repeat(n)); return text; } + +/** One choice of a menu the CLI shows: `n` its number (null for Claude's + * unnumbered Submit row), `checked` a multiSelect box's state. */ +export type PaneMenuOption = { + n: number | null; + label: string; + hint: string; + checked: boolean | null; +}; +/** A menu the CLI asks with (AskUserQuestion, a permission or trust prompt, + * Codex's approval): `cursor` the row its `❯`/`›` is on, `typeAt` the + * "Type something" row (-1: none). */ +export type PaneMenu = { + question: string; + options: PaneMenuOption[]; + cursor: number; + multi: boolean; + typeAt: number; +}; + +const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/; +const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/; +const RULE_ROW = /^ *[─╌]{3,} *$/; +const CHECK = /^\[([ ✔])\] *(.*)$/; +const PLACEHOLDER = /^Type something\.?$/; + +/** The numbered menu at the bottom of `rows`, or null. A menu has two + * numbered rows or more from 1, the cursor on one of them, and no rule right + * above its first row — a reply's numbered list has no cursor, and a list + * typed into Claude's input box sits under the box's rule. */ +export function parsePaneMenu(rows: string[]): PaneMenu | null { + const r = rows.map((l) => l.trimEnd()); + let end = r.length - 1; + while (end >= 0 && !r[end]) end--; + const options: PaneMenuOption[] = []; + let cursor = -1; + let hint: string[] = []; + let top = -1; + // Bottom up: a row under an option that is no option is its hint. + for (let i = end; i >= 0 && end - i < 40; i--) { + const row = r[i] ?? ""; + const m = OPTION.exec(row); + const s = m ? null : SUBMIT.exec(row); + if (m || s) { + const text = m ? (m[3] ?? "") : "Submit"; + const c = m ? CHECK.exec(text) : null; + const label = c ? (c[2] ?? "") : text; + const h = hint.join(" "); + options.unshift({ + n: m ? Number(m[2]) : null, + label, + hint: h === label ? "" : h, + checked: c ? c[1] === "✔" : null, + }); + if (m?.[1] || s?.[1]) cursor = options.length; + hint = []; + if (m?.[2] === "1") { + top = i; + break; + } + } else if (row && !RULE_ROW.test(row) && options.length) { + hint.unshift(row.trim()); + } else if (row && !RULE_ROW.test(row)) { + hint = []; // the footer and anything under the last option + } + } + const numbered = options.filter((o) => o.n !== null); + if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? "")) return null; + if (numbered.some((o, i) => o.n !== i + 1)) return null; + // The question: the block of text right above the first option. + let q = top - 1; + while (q >= 0 && !r[q]) q--; + const lines: string[] = []; + for (; q >= 0 && r[q] && !RULE_ROW.test(r[q]!) && lines.length < 4; q--) + lines.unshift(r[q]!.trim()); + return { + question: lines.join("\n"), + options, + cursor: options.length - cursor, + multi: options.some((o) => o.checked !== null), + typeAt: typeRow(options), + }; +} + +/** The "Type something" row. Text typed into it replaces its label, and it is + * still the last numbered row above "Chat about this". */ +function typeRow(options: PaneMenuOption[]): number { + const at = options.findIndex((o) => PLACEHOLDER.test(o.label)); + if (at >= 0) return at; + for (let i = options.findIndex((o) => o.label === "Chat about this") - 1; i >= 0; i--) + if (options[i]!.n !== null) return i; + return -1; +} + +/** The menu `runId`'s visible pane shows now, or null. */ +export function readRunMenu(runId: string): PaneMenu | null { + const p = panes.get(runId); + const screen = p?.visible() && p.key ? p.screen?.() : null; + return screen ? parsePaneMenu(screen.rows) : null; +} + +// One key per write, a beat apart: a TUI reading two arrows in one chunk may +// take them as one key. +function pressKeys(key: (data: string) => void, keys: string[]) { + keys.forEach((k, i) => setTimeout(() => key(k), i * 40)); +} + +/** The arrow presses from the menu's cursor to row `to`. */ +const walk = (menu: PaneMenu, to: number) => + Array(Math.abs(to - menu.cursor)).fill(to > menu.cursor ? "\x1b[B" : "\x1b[A"); + +/** Picks `menu`'s option `index` in `runId`'s pane: its digit — which picks + * it, or ticks its box in a multiSelect — else (Submit) the arrows to it + * and Enter. False when the pane is not visible or not attached. */ +export function chooseRunOption(runId: string, menu: PaneMenu, index: number): boolean { + const p = panes.get(runId); + const opt = menu.options[index]; + if (!p?.visible() || !p.key || !opt) return false; + if (opt.n !== null && opt.n < 10) p.key(String(opt.n)); + else pressKeys(p.key, [...walk(menu, index), "\r"]); + return true; +} + +/** Answers `menu` with typed text: the arrows to its "Type something" row, + * Ctrl+U over what is typed there already (one key clears it, however long + * it is), the text as a bracketed + * paste, and Enter — none in a multiSelect, where the text only ticks its + * row and Submit is still to pick. */ +export function answerRunMenu(runId: string, menu: PaneMenu, text: string): boolean { + const p = panes.get(runId); + const row = menu.options[menu.typeAt]; + if (!p?.visible() || !p.key || !row) return false; + const keys = walk(menu, menu.typeAt); + if (!PLACEHOLDER.test(row.label)) keys.push("\x15"); + keys.push(`\x1b[200~${text}\x1b[201~`); + pressKeys(p.key, menu.multi ? keys : [...keys, "\r"]); + return true; +} diff --git a/docs/LANES.md b/docs/LANES.md index 5723ffcd..d7a64337 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -394,7 +394,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page - **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=&run=`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split in the tab on screen (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=&session=` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet). - **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Proxy) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace"). - **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Proxy, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from. -- **Chat view of a live pane** — each Claude or Codex SESSION shows either a chat or its terminal, its own choice: the chat/terminal button left of the pane's split buttons (`ChatToggle` in `client/src/components/run/PaneGrid.tsx`) switches it, and the choice is kept in this browser's localStorage by session id (`agenthub.chatUi.sessions`, the newest 200; `useSessionChat` in `client/src/lib/chatUi.ts`). **Settings → Appearance → Chat UI** (`ChatUiSettings.tsx`, saved per ACCOUNT on the server: `GET/PUT /api/me/prefs`, key `agenthub.chatUi`) is only the default — written onto a session the first time it is shown, so changing it later leaves every session already seen as it is. The chat needs the session id, so a pane shows it only once its session is known (`client/src/components/run/LaneConsolePane.tsx`). Chat (`client/src/components/run/PaneChat.tsx`) shows the session's conversation the way Claude Desktop does — the shared `ConversationView` in its `fill` layout: one centred column, your messages as bubbles, the reply as plain prose, every run of tool calls folded into one line named by what it did, in Claude Desktop's fixed order ("Ran 3 commands, edited a file, used a tool ›", `workLabel`) — a thinking block that carries text is the model's narration (Claude Code ≥ 2.1.280 stores real thinking empty) and reads as prose between those lines, as Claude Desktop shows it — that opens onto a framed list of its steps — each titled by its own description, the call in a capped scroll box, its output one click further — and a round ↓ back to the end — above a prompt box that grows with its text up to 40% of the screen, then scrolls. The conversation stays at its end while you are there: a new line, the running step, an image loading or the pane shown again after being hidden keeps the newest in sight (a `ResizeObserver` in `ConversationView`), until you scroll up; sending a message takes it back to the end. It lies OVER the terminal, which stays mounted and attached (the console never unmounts a terminal); a message is sent through that terminal as a bracketed paste and Enter (`submitToRun` in `client/src/lib/panePaste.ts`), exactly as if typed, so no new route or server write exists for it. On a Windows dashboard (psmux) the attach (`server/lib/pty-attach.js`) holds any input that follows a paste until 700 ms after it (`PASTE_HOLD_MS`): psmux delays a paste ~350 ms and wrote an Enter sent meanwhile INSIDE it (psmux#766), so the prompt sat in Claude's composer as `[Pasted text #1]` instead of being sent. While the session's main agent is `working` (its `UserPromptSubmit` hook seen, no `Stop` yet — `GET /api/agents?session_id=&status=working`, re-read on `agent_updated`), an empty box shows **Stop** in Send's place, like Claude Desktop: it presses Esc in the pane (`interruptRun` in the same file), the key `claude` and `codex` stop a turn with; typing a message brings Send back (Claude Code queues it). A Codex pane without AgentHub's hooks never shows it. An image attached in the box reaches the transcript as its saved path; the user's turn shows the image itself (`GET /api/run/drop/:name`) — any other attached file as a chip (name · EXT) that opens its preview like an output — and opens it full size over the whole page (`ImageLightbox`, rendered into `document.body`, so nothing stacked over a pane covers it). The box carries Claude Desktop's controls: a `+` menu (`ChatAddMenu.tsx`, also in the workspace home's prompt box) — Upload or add files; what already exists, attached like an upload (a chip, its path sent with the prompt): **Project files** (a search of the box's folder, folders too — `GET /api/run/files?dirs=1` — sent as `@path`), **Context files** (the workspace's own, `GET /api/lanes/:id/sources`, sent as `@`) and **Recent uploads** (files uploaded through a prompt box in the last day, kept in this browser's localStorage `agenthub.prompt.recentUploads` — the server prunes an upload after a day); a file or folder dragged onto anywhere in a prompt box (not only its text area) attaches the same way — from the Changes panel's Files tree, its search, a Changes-list row, or a workspace's Context files and Folder list; a row from an extra folder's section (`data-drag-root`) and a context file or folder carry their absolute path, since they lie outside the pane's folder (`client/src/lib/promptAttach.ts`); Skills (yours and the project's), Plugins → a plugin → the skills and commands it ships, each put at the front of the message as `/` / `/:`, and Manage links to the Claude config page — and, for a Claude pane, the Model/Effort chip, a change typed into the pane as `/model ` / `/effort `, and the permission mode beside `+` in the box. A menu opened in a split pane (a `Select`, the Model chip) flips up or down by the room inside ITS pane (`data-menu-bounds` on the pane, `menuRoom` in `client/src/components/Select.tsx`), never the window's — a menu running past the pane's edge is painted over by the pane below. A running session changes mode only by Shift+Tab, so the server steps it and reads each step back off the pane (`POST /api/run/:id/permission-mode`, `server/lib/pane-mode.js`): it lands on exactly the mode picked, presses nothing while a menu hides the mode line, and returns to where it began when the mode is not in that session's cycle (Bypass only when the session was started with it). The mode belongs to that session only. A permission prompt or a menu still needs the terminal — switch that session to its terminal to answer it. A live pane's chat ends, until its turn is over, with what the model is doing now and — like the terminal's status line — how long the turn has run since your prompt and the output tokens it has made so far, e.g. `Thinking… (2m 8s · ↑ 3.5K tokens)` (`workingOn` and `turnStats` in `MessageList.tsx`; Claude Code repeats a call's usage on each of its lines, so consecutive lines with the same prompt size count once), the words lit by a moving shimmer (`.text-shimmer`, stopped under reduced motion) and the token count rolling up rather than jumping. It names every call of the turn still waiting for its result — the oldest first and **+N more** for the rest, a click listing them all, since Claude Code writes parallel calls on separate lines and their results arrive in any order — by its description (Claude Code's Bash `description`) or tool and target, with that call's own time once it has run 30 s; else **Thinking…**. A line with no new transcript line for 3 minutes turns amber (the step may be stuck). While the CLI asks you something — the main agent awaiting input on a `Notification` hook (a permission prompt; `awaiting_reason = 'notification'`, read with the Stop button's agent poll) — it reads **Waiting for your approval: ** in amber with an **Open terminal** button (`showTerminal` in `PaneGrid.tsx`, the chat/terminal toggle's own switch: the unsent prompt goes into the CLI's input box); the answer itself is still given in the terminal. Before the session has started — no session row yet, because Claude Code runs no hook until its folder is trusted — the chat says so instead of the empty-transcript text, with the same **Open terminal** button (`notStarted` in `ConversationView.tsx`), and Send and every control typed into the pane (`/model`, `/effort`) ask first (`confirmDialog`, **Send anyway**): an Enter there would answer the trust prompt, whose preselected choice is **No, exit** (issue #73). It asks rather than refuses because the row appears only with the session's first hook, and a hard lock would strand the chat if none came right after the answer. A turn that ended (`end_turn`) closes with **✓ Done in 2m 8s · ↑ 3.5K tokens** — no cost: pricing one turn would need a second cost formula. The prompt box shows how full the context is (`ContextMeter` in `PaneChat.tsx`): the newest reply's prompt size (input + cache) against the model's window — 200K for Claude's 4.5 line and older, 1M for every model since (`contextWindow`, from LiteLLM's list; none for another vendor) — amber from 75 %, red from 90 %, the numbers on hover. While the browser tab is hidden its title is marked **● Waiting** while a chat pane's session waits on you and **✓ Done** once a turn ended (`client/src/lib/tabBadge.ts`); showing the tab drops the Done mark. Thinking is shown (after your prompt, after a tool result, or after narration whose `stop_reason` is `tool_use`). The transcript route passes each assistant line's `stop_reason`; `end_turn` ends the turn, so does an interrupt, and a turn with no new line for 15 minutes and no call running is treated as dead. The chat re-reads the transcript every 3 s while the page is visible. Each message's hover line shows ↓ the whole prompt it was answered from (input + cache read + cache write; the split on hover) and ↑ its output. Each assistant turn that changed files ends, like Claude Desktop's, with an **Edited N files** card (`EditedFiles` in `client/src/components/conversation/MessageList.tsx`): one row per file with the lines it added and removed over the turn — the `diff` the transcript route reads off Claude Code's `structuredPatch` — and, in a live pane's chat, a click on a row opens that file in the workspace's Changes panel when it lies in the pane's folder, else its preview (beside the chat, or over it when the pane is narrow). It has no Undo: reverting a file is a write the dashboard does not take from a click. The conversation column and the prompt box are `max(48rem, 70%)` of the pane wide — never narrower than Claude's fixed 48rem, wider on a large screen. Every chat pane, split or not, has Claude Desktop's activity panel (`client/src/components/run/ChatOutline.tsx`, `GET /api/sessions/:id/outline`, re-read every 5 s while the tab is visible). It starts open in a pane that spans the grid's full width or height (`spanning` in `ConsoleArea`, `client/src/pages/Workspace.tsx`, from `layoutRects`) and collapsed into a file button with the number of outputs at the chat's top right in a quarter-sized one (e.g. four panes at once); a split or a close that changes the pane's shape brings that default back. Whether it sits beside the conversation or floats over it is decided by the PANE's width, not the window's (a CSS container query on `PaneChat`'s root, 40rem): beside it, a splitter on its left edge (`Divider` from `PaneGrid.tsx`, arrow keys too) drags the panel — and a file preview in its place — between 20% and 70% of the pane, remembered per browser (`localStorage` `agenthub.chat.panelWidth`, one fraction for every pane). Open, it shows **Progress** — the session's `TaskCreate`/`TaskUpdate` (or `TodoWrite`) plan, only when there is one: how many steps are done of how many (`3/7`) over a bar, done items struck through and the step in progress highlighted; **Outputs** — files it wrote or edited (`Write`/`Edit`/`MultiEdit`/`NotebookEdit`), newest first, each with its extension tag (a file made by a Bash command is not listed); and **Used in this session** — **Uploads** (files attached through the box), **Files read**, **Memory** (Claude Code memory files), **Skills** and **Connectors** (MCP servers). A click on an output opens its preview in the panel's place (`FilePreview` in `client/src/components/run/ChatFilePreview.tsx`: name · EXT, Copy path, Close back to the panel); Uploads, Files read and Memory open their list with a preview beside it (`FileListModal`), and Skills opens the same dialog over the session's skills, each previewed from its SKILL.md (`SkillListModal`: a plugin's `plugin:name` from `GET /api/cc-config/plugins`, else the user's or the project's skill; a built-in skill has no file and shows by name); Connectors opens the Claude config page. Every row but Connectors also drags onto the prompt box (`dragAttachments`/`dragSkill` in `client/src/lib/promptAttach.ts`): an output, or a whole Uploads/Files read/Memory group, attaches its files like the `+` menu does (an upload by its bare path, anything else as `@`); a skill becomes the message's leading `/` — the Skills row only when the session used one skill (a prompt takes one command), else drag it from its list. A row dragged out of one of those list dialogs closes the dialog, so the box is reachable. A preview reads only through routes that already guard it — a file under the pane's folder through `GET /api/lanes/:id/git/file` (an image through `/git/raw`), a memory file under `~/.claude` through `GET /api/cc-config/file`, an attached image or text file (`.txt`, `.md`, `.csv`, `.log`, `.json`… — a long paste is one) through `GET /api/run/drop/:name` — and shows just the path for anything else (an attached spreadsheet, a file outside the workspace, one since deleted). A Windows path (`C:\…\.claude\…`) is matched like a POSIX one. The ended-session replay and the Analyst page's conversation use the same layout. The chat's unsent prompt and its attachments are kept per session in this browser (`agenthub.chat.draft.`, `client/src/lib/chatDraft.ts`), so switching the pane to its terminal and back, or reloading the page, leaves them as typed — an attached image keeps its path, not its preview; a send clears it. Switching carries the unsent prompt across: to the terminal, the draft (its files as paths) is pasted into the CLI's input box without Enter and the draft is cleared; to the chat, the text in the CLI's input box is read off the screen (`parsePromptInput` in `client/src/lib/panePaste.ts`: Claude's `❯` box between its `─` rules, Codex's last `›` row up to its footer; a soft-wrapped row is joined back) into the draft and deleted there with Delete/Backspace — never Ctrl+C or Esc, which would stop a working turn. A box holding `[Pasted text #N]` or `[Image #N]` is left as it is, since the screen does not show what it stands for. A row of a turn's **Edited N files** card drags into the prompt box as an `@path` attachment (its header drags every file in the card); dropped elsewhere it is the plain path. +- **Chat view of a live pane** — each Claude or Codex SESSION shows either a chat or its terminal, its own choice: the chat/terminal button left of the pane's split buttons (`ChatToggle` in `client/src/components/run/PaneGrid.tsx`) switches it, and the choice is kept in this browser's localStorage by session id (`agenthub.chatUi.sessions`, the newest 200; `useSessionChat` in `client/src/lib/chatUi.ts`). **Settings → Appearance → Chat UI** (`ChatUiSettings.tsx`, saved per ACCOUNT on the server: `GET/PUT /api/me/prefs`, key `agenthub.chatUi`) is only the default — written onto a session the first time it is shown, so changing it later leaves every session already seen as it is. The chat needs the session id, so a pane shows it only once its session is known (`client/src/components/run/LaneConsolePane.tsx`). Chat (`client/src/components/run/PaneChat.tsx`) shows the session's conversation the way Claude Desktop does — the shared `ConversationView` in its `fill` layout: one centred column, your messages as bubbles, the reply as plain prose, every run of tool calls folded into one line named by what it did, in Claude Desktop's fixed order ("Ran 3 commands, edited a file, used a tool ›", `workLabel`) — a thinking block that carries text is the model's narration (Claude Code ≥ 2.1.280 stores real thinking empty) and reads as prose between those lines, as Claude Desktop shows it — that opens onto a framed list of its steps — each titled by its own description, the call in a capped scroll box, its output one click further — and a round ↓ back to the end — above a prompt box that grows with its text up to 40% of the screen, then scrolls. The conversation stays at its end while you are there: a new line, the running step, an image loading or the pane shown again after being hidden keeps the newest in sight (a `ResizeObserver` in `ConversationView`), until you scroll up; sending a message takes it back to the end. It lies OVER the terminal, which stays mounted and attached (the console never unmounts a terminal); a message is sent through that terminal as a bracketed paste and Enter (`submitToRun` in `client/src/lib/panePaste.ts`), exactly as if typed, so no new route or server write exists for it. On a Windows dashboard (psmux) the attach (`server/lib/pty-attach.js`) holds any input that follows a paste until 700 ms after it (`PASTE_HOLD_MS`): psmux delays a paste ~350 ms and wrote an Enter sent meanwhile INSIDE it (psmux#766), so the prompt sat in Claude's composer as `[Pasted text #1]` instead of being sent. While the session's main agent is `working` (its `UserPromptSubmit` hook seen, no `Stop` yet — `GET /api/agents?session_id=&status=working`, re-read on `agent_updated`), an empty box shows **Stop** in Send's place, like Claude Desktop: it presses Esc in the pane (`interruptRun` in the same file), the key `claude` and `codex` stop a turn with; typing a message brings Send back (Claude Code queues it). Stop also ends the conversation's live line (`stopped` on `ConversationView`/`MessageList`) until the transcript gains a newer line: an Esc before the model wrote anything puts the prompt back in Claude Code's input box and writes no `[Request interrupted by user]` line, so the transcript still ends on the prompt and would read as **Thinking…** for 15 minutes (issue #74). An Esc pressed in the terminal itself is not seen by the chat. A Codex pane without AgentHub's hooks never shows it. An image attached in the box reaches the transcript as its saved path; the user's turn shows the image itself (`GET /api/run/drop/:name`) — any other attached file as a chip (name · EXT) that opens its preview like an output — and opens it full size over the whole page (`ImageLightbox`, rendered into `document.body`, so nothing stacked over a pane covers it). The box carries Claude Desktop's controls: a `+` menu (`ChatAddMenu.tsx`, also in the workspace home's prompt box) — Upload or add files; what already exists, attached like an upload (a chip, its path sent with the prompt): **Project files** (a search of the box's folder, folders too — `GET /api/run/files?dirs=1` — sent as `@path`), **Context files** (the workspace's own, `GET /api/lanes/:id/sources`, sent as `@`) and **Recent uploads** (files uploaded through a prompt box in the last day, kept in this browser's localStorage `agenthub.prompt.recentUploads` — the server prunes an upload after a day); a file or folder dragged onto anywhere in a prompt box (not only its text area) attaches the same way — from the Changes panel's Files tree, its search, a Changes-list row, or a workspace's Context files and Folder list; a row from an extra folder's section (`data-drag-root`) and a context file or folder carry their absolute path, since they lie outside the pane's folder (`client/src/lib/promptAttach.ts`); Skills (yours and the project's), Plugins → a plugin → the skills and commands it ships, each put at the front of the message as `/` / `/:`, and Manage links to the Claude config page — and, for a Claude pane, the Model/Effort chip, a change typed into the pane as `/model ` / `/effort `, and the permission mode beside `+` in the box. A menu opened in a split pane (a `Select`, the Model chip) flips up or down by the room inside ITS pane (`data-menu-bounds` on the pane, `menuRoom` in `client/src/components/Select.tsx`), never the window's — a menu running past the pane's edge is painted over by the pane below. A running session changes mode only by Shift+Tab, so the server steps it and reads each step back off the pane (`POST /api/run/:id/permission-mode`, `server/lib/pane-mode.js`): it lands on exactly the mode picked, presses nothing while a menu hides the mode line, and returns to where it began when the mode is not in that session's cycle (Bypass only when the session was started with it). The mode belongs to that session only. A menu the CLI asks with — AskUserQuestion (one question or several, multiSelect too), a permission or trust prompt, Codex's approval, a first-start login menu — is answered from the chat: it is read off the terminal underneath every 700 ms (`parsePaneMenu`/`readRunMenu` in `client/src/lib/panePaste.ts`: two numbered rows or more from 1, the `❯`/`›` cursor on one of them, no `─` rule right above the first — a reply's numbered list has no cursor, a list typed into Claude's input box sits under its rule) and shown in the prompt box's place as a card of buttons (`PaneMenuCard` in `PaneChat.tsx`, with each option's description and a multiSelect box's state; the box is hidden with CSS, never unmounted, so its draft survives the question); a pick presses its digit in the pane (which picks it, or ticks its box), Claude's unnumbered **Submit** row is reached with the arrows and Enter, **Cancel (Esc)** presses Esc, and the **Type something** row opens a field inside the card whose text, on Enter, is typed there as the answer (`answerRunMenu`: the arrows to that row, Ctrl+U over any text already in it, the text as a bracketed paste, Enter — none in a multiSelect, where Submit is still to pick). That row presses no key: a second press of its digit would type the digit into the CLI's own field. Once text is typed in that row its label is the text, so it is found as the last numbered row above **Chat about this**. Like Send, it only presses keys in the pane, so no new route exists for it. A menu with no numbers (the first-start theme picker) still needs the terminal. A live pane's chat ends, until its turn is over, with what the model is doing now and — like the terminal's status line — how long the turn has run since your prompt and the output tokens it has made so far, e.g. `Thinking… (2m 8s · ↑ 3.5K tokens)` (`workingOn` and `turnStats` in `MessageList.tsx`; Claude Code repeats a call's usage on each of its lines, so consecutive lines with the same prompt size count once), the words lit by a moving shimmer (`.text-shimmer`, stopped under reduced motion) and the token count rolling up rather than jumping. It names every call of the turn still waiting for its result — the oldest first and **+N more** for the rest, a click listing them all, since Claude Code writes parallel calls on separate lines and their results arrive in any order — by its description (Claude Code's Bash `description`) or tool and target, with that call's own time once it has run 30 s; else **Thinking…**. A line with no new transcript line for 3 minutes turns amber (the step may be stuck). While the CLI asks you something — the main agent awaiting input on a `Notification` hook (a permission prompt; `awaiting_reason = 'notification'`, read with the Stop button's agent poll) — it reads **Waiting for your approval: ** in amber with an **Open terminal** button (`showTerminal` in `PaneGrid.tsx`, the chat/terminal toggle's own switch: the unsent prompt goes into the CLI's input box); the menu card above answers it from the chat. AskUserQuestion fires no `Notification`, so a menu read off the pane counts as waiting too — for this line and the hidden tab's **● Waiting**. Before the session has started — no session row yet, because Claude Code runs no hook until its folder is trusted — the chat says so instead of the empty-transcript text, with the same **Open terminal** button (`notStarted` in `ConversationView.tsx`), and Send and every control typed into the pane (`/model`, `/effort`) ask first (`confirmDialog`, **Send anyway**): an Enter there would answer the trust prompt, whose preselected choice is **No, exit** (issue #73). It asks rather than refuses because the row appears only with the session's first hook, and a hard lock would strand the chat if none came right after the answer. A turn that ended (`end_turn`) closes with **✓ Done in 2m 8s · ↑ 3.5K tokens** — no cost: pricing one turn would need a second cost formula. The prompt box shows how full the context is (`ContextMeter` in `PaneChat.tsx`): the newest reply's prompt size (input + cache) against the model's window — 200K for Claude's 4.5 line and older, 1M for every model since (`contextWindow`, from LiteLLM's list; none for another vendor) — amber from 75 %, red from 90 %, the numbers on hover. While the browser tab is hidden its title is marked **● Waiting** while a chat pane's session waits on you and **✓ Done** once a turn ended (`client/src/lib/tabBadge.ts`); showing the tab drops the Done mark. Thinking is shown (after your prompt, after a tool result, or after narration whose `stop_reason` is `tool_use`). The transcript route passes each assistant line's `stop_reason`; `end_turn` ends the turn, so does an interrupt, and a turn with no new line for 15 minutes and no call running is treated as dead. The chat re-reads the transcript every 3 s while the page is visible. Each message's hover line shows ↓ the whole prompt it was answered from (input + cache read + cache write; the split on hover) and ↑ its output. Each assistant turn that changed files ends, like Claude Desktop's, with an **Edited N files** card (`EditedFiles` in `client/src/components/conversation/MessageList.tsx`): one row per file with the lines it added and removed over the turn — the `diff` the transcript route reads off Claude Code's `structuredPatch` — and, in a live pane's chat, a click on a row opens that file in the workspace's Changes panel when it lies in the pane's folder, else its preview (beside the chat, or over it when the pane is narrow). It has no Undo: reverting a file is a write the dashboard does not take from a click. The conversation column and the prompt box are `max(48rem, 70%)` of the pane wide — never narrower than Claude's fixed 48rem, wider on a large screen. Every chat pane, split or not, has Claude Desktop's activity panel (`client/src/components/run/ChatOutline.tsx`, `GET /api/sessions/:id/outline`, re-read every 5 s while the tab is visible). It starts open in a pane that spans the grid's full width or height (`spanning` in `ConsoleArea`, `client/src/pages/Workspace.tsx`, from `layoutRects`) and collapsed into a file button with the number of outputs at the chat's top right in a quarter-sized one (e.g. four panes at once); a split or a close that changes the pane's shape brings that default back. Whether it sits beside the conversation or floats over it is decided by the PANE's width, not the window's (a CSS container query on `PaneChat`'s root, 40rem): beside it, a splitter on its left edge (`Divider` from `PaneGrid.tsx`, arrow keys too) drags the panel — and a file preview in its place — between 20% and 70% of the pane, remembered per browser (`localStorage` `agenthub.chat.panelWidth`, one fraction for every pane). Open, it shows **Progress** — the session's `TaskCreate`/`TaskUpdate` (or `TodoWrite`) plan, only when there is one: how many steps are done of how many (`3/7`) over a bar, done items struck through and the step in progress highlighted; **Outputs** — files it wrote or edited (`Write`/`Edit`/`MultiEdit`/`NotebookEdit`), newest first, each with its extension tag (a file made by a Bash command is not listed); and **Used in this session** — **Uploads** (files attached through the box), **Files read**, **Memory** (Claude Code memory files), **Skills** and **Connectors** (MCP servers). A click on an output opens its preview in the panel's place (`FilePreview` in `client/src/components/run/ChatFilePreview.tsx`: name · EXT, Copy path, Close back to the panel); Uploads, Files read and Memory open their list with a preview beside it (`FileListModal`), and Skills opens the same dialog over the session's skills, each previewed from its SKILL.md (`SkillListModal`: a plugin's `plugin:name` from `GET /api/cc-config/plugins`, else the user's or the project's skill; a built-in skill has no file and shows by name); Connectors opens the Claude config page. Every row but Connectors also drags onto the prompt box (`dragAttachments`/`dragSkill` in `client/src/lib/promptAttach.ts`): an output, or a whole Uploads/Files read/Memory group, attaches its files like the `+` menu does (an upload by its bare path, anything else as `@`); a skill becomes the message's leading `/` — the Skills row only when the session used one skill (a prompt takes one command), else drag it from its list. A row dragged out of one of those list dialogs closes the dialog, so the box is reachable. A preview reads only through routes that already guard it — a file under the pane's folder through `GET /api/lanes/:id/git/file` (an image through `/git/raw`), a memory file under `~/.claude` through `GET /api/cc-config/file`, an attached image or text file (`.txt`, `.md`, `.csv`, `.log`, `.json`… — a long paste is one) through `GET /api/run/drop/:name` — and shows just the path for anything else (an attached spreadsheet, a file outside the workspace, one since deleted). A Windows path (`C:\…\.claude\…`) is matched like a POSIX one. The ended-session replay and the Analyst page's conversation use the same layout. The chat's unsent prompt and its attachments are kept per session in this browser (`agenthub.chat.draft.`, `client/src/lib/chatDraft.ts`), so switching the pane to its terminal and back, or reloading the page, leaves them as typed — an attached image keeps its path, not its preview; a send clears it. Switching carries the unsent prompt across: to the terminal, the draft (its files as paths) is pasted into the CLI's input box without Enter and the draft is cleared; to the chat, the text in the CLI's input box is read off the screen (`parsePromptInput` in `client/src/lib/panePaste.ts`: Claude's `❯` box between its `─` rules, Codex's last `›` row up to its footer; a soft-wrapped row is joined back) into the draft and deleted there with Delete/Backspace — never Ctrl+C or Esc, which would stop a working turn. A box holding `[Pasted text #N]` or `[Image #N]` is left as it is, since the screen does not show what it stands for. A row of a turn's **Edited N files** card drags into the prompt box as an `@path` attachment (its header drags every file in the card); dropped elsewhere it is the plain path. - **The workspace home** — `/workspace?lane=` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Proxy** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt, after the workspace owner's own **My agent instructions** from Settings → AI tools — one markdown text per account (typed, or read from a local `.md`/`.txt` with **Choose file**, saved only on Save), shared by every workspace they own), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. Each extra folder is also its own repo in the **Changes** panel, stacked under the workspace folder's like VS Code's Source Control: once the workspace has one, every repo is a section with a foldable header, each a whole panel — Changes, Files, Review, History, commit and push — on its own folder (every git route with `?source=`, resolved from the `lane_sources` row; `ENOSOURCE` otherwise, never the workspace folder); the open sections share the height, and a folded one stays mounted. A chat's request to open a file unfolds the workspace folder's section. Right-clicking a section header offers **Close repository** (extra folders only — the workspace folder never closes) and **Close other repositories**; closing REMOVES the folder from the workspace (`DELETE /api/lanes/:id/sources/:sourceId`, the folder stays on disk), asking first only if that section holds an unsaved edit. The panel's bottom fold, **Workspace context** (folded by default, kept in `agenthub.workspace.contextOpen`), is the home's context column made compact (`client/src/components/lanes/WorkspaceContext.tsx`, the same component): Instructions, Memory, Context files and Folders are edited from the session view without going back to the home, and a folder added or removed there joins or leaves the stack above at once. A plain-text paste over 5,000 characters into any prompt box (the home, a chat, a finished session's reply) is attached as `pasted-text.txt` like a pasted file (`PASTE_AS_FILE_CHARS` in `client/src/components/run/RunSetup.tsx`): the prompt carries its path, never the text (#68). The home HIDES the session view with CSS — the console never unmounts behind it. - **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=`, so CLAUDE CODE makes `/.claude/worktrees/` on branch `worktree-`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed. - **Resume interrupted sessions** — when a session's pane dies under it (the machine shut down, tmux was killed, the CLI crashed), its run is recorded `interrupted` instead of `abandoned`, and every page of the dashboard opens the **Resume interrupted sessions?** dialog (`client/src/components/lanes/InterruptedSessionsPrompt.tsx`, from `GET /api/run/interrupted`). Each session is listed with its workspace, tool, name or first prompt and when it stopped, all ticked: **Resume** starts each ticked one in its workspace's first free tab (`POST /api/lanes/:id/start {slot: "auto", resumeSessionId}`, with the run's model, effort and permission mode — never `bypassPermissions`) and opens the first; **Don't resume** (also ×, Escape, the backdrop) resumes none. Either answer dismisses the whole list (`POST /api/run/interrupted/dismiss`), so it is asked once; a resume that failed stays listed. What counts as interrupted: a pane the dashboard did not close (Stop, ×, Quit are `killed`), not a Terminal tab, with a known session — and for Claude, not one the user ended themselves (`SessionEnd` reason `prompt_input_exit`/`logout`). Codex and other tools report no such reason, so their every vanished pane counts; a Codex pane without its opt-in hooks has no known session and is never listed. A session resumed by any other path drops off the list. A run younger than 20 s is never judged dead (a tmux list is a cached answer, and another dashboard on the same database may not have listed a brand-new pane yet). In server mode each user is offered only their own workspaces' sessions.