From b0dd5af22fd4e60816cfae6e68299d4314d5d35a Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 13 Oct 2026 10:46:56 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20v2.7.5=20=E2=80=94=20the=20chat=20shows?= =?UTF-8?q?=20each=20AskUserQuestion=20with=20the=20user's=20answer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chat: an AskUserQuestion is its own card (AskCard in MessageList.tsx), not part of the collapsed work line — each question with the option picked or the text typed, "declined" when refused. The transcript route passes the answers from Claude Code's toolUseResult (askAnswers, beside diff). - Chat: the activity panel keeps the user's open/close per session for the tab, and opens by default only once the session has an output. - Workspace home: the sent prompt's draft is dropped even though opening the session unmounts the home. - Terminal: Ctrl+V with text on the clipboard pastes the text; only an empty clipboard still sends ^V. - AGENTS.md / CLAUDE.md: run only the tests a change touches while iterating, the full suite once before committing. --- AGENTS.md | 1 + CLAUDE.md | 3 +- .../components/conversation/MessageList.tsx | 64 +++++++++++++- .../__tests__/MessageList.ask.test.tsx | 85 +++++++++++++++++++ client/src/components/lanes/WorkspaceHome.tsx | 7 ++ .../lanes/__tests__/WorkspaceHome.test.tsx | 23 ++++- client/src/components/run/PaneChat.tsx | 28 ++++-- client/src/components/run/TerminalView.tsx | 20 +++-- .../run/__tests__/PaneChat.test.tsx | 47 ++++++++-- .../run/__tests__/TerminalView.test.tsx | 22 +++-- client/src/i18n/locales/en/sessions.json | 1 + client/src/i18n/locales/vi/sessions.json | 1 + client/src/lib/types.ts | 3 + .../screens.snapshot.test.tsx.snap | 2 +- docs/LANES.md | 2 +- openapi.yaml | 2 +- package-lock.json | 4 +- package.json | 2 +- server/__tests__/transcript-sender.test.js | 21 ++++- server/routes/sessions.js | 13 +++ 20 files changed, 320 insertions(+), 31 deletions(-) create mode 100644 client/src/components/conversation/__tests__/MessageList.ask.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 1e2232cc..fba97429 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ make while you work here as Codex. ## Validation expectations - Backend changes: run `npm run test:server` when possible. - Frontend changes: run `npm run test:client` when possible. +- Full suites take 5–6 minutes, so while iterating run only what the change touches: `npm run test:server -- server/__tests__/.test.js`, and in `client/` `NODE_ENV=test npx vitest related --run` (or `vitest run `). Run the full suite once before committing, and always when the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib) — the per-screen snapshots catch that. - If any check is skipped, report it explicitly. ## Safety expectations diff --git a/CLAUDE.md b/CLAUDE.md index 14a2814b..4474283e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.7.4 (2026-10-12) — Changes hides a staged change undone on disk (#72); chat says when the session has not started (#73); a member's missing folder is made as them, bypass offered only once accepted._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.7.5 (2026-10-13) — the chat shows each AskUserQuestion with the user's answer; while iterating, run only the tests a change touches._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -96,6 +96,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti ## Testing and verification policy - Backend changes: run `npm run test:server` before finishing. - Frontend changes: run `npm run test:client` when relevant. This includes per-screen render snapshots (`client/src/pages/__tests__/screens.snapshot.test.tsx`). If a UI change is intentional, review the snapshot diff and regenerate baselines with `cd client && npx vitest run -u`; never blindly update snapshots to make tests pass. +- Full suites take 5–6 minutes — while iterating run only what the change touches (`npm run test:server -- server/__tests__/.test.js`; in `client/` `NODE_ENV=test npx vitest related --run`), then the full suite once before committing, and always when touching something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib). - If you cannot run a verification step, state exactly what was not run and why. ## Change guidelines by area diff --git a/client/src/components/conversation/MessageList.tsx b/client/src/components/conversation/MessageList.tsx index d0e21a8d..95ecc972 100644 --- a/client/src/components/conversation/MessageList.tsx +++ b/client/src/components/conversation/MessageList.tsx @@ -15,7 +15,9 @@ * files ends with Claude Desktop's "Edited N files" card: every file with the * lines added and removed (the tool results' `diff`); a click opens the file * when the caller can (`onOpenFile`), and a row (or the card's header, for - * every file in it) drags into a prompt box as an `@path` attachment. A thinking block that carries text is + * every file in it) drags into a prompt box as an `@path` attachment. An + * AskUserQuestion is its own card (`AskCard`), not part of the collapsed work + * line: each question with the answer the user picked or typed. A thinking block that carries text is * the model's narration to the user (Claude Code ≥ 2.1.280 stores real * thinking empty) and reads as prose, like Claude Desktop shows it; a live * chat (`live`) ends, until its turn is over, with what the model is doing @@ -95,6 +97,8 @@ import { Check, Hand, SquareTerminal, + MessageCircleQuestion, + CornerDownRight, } from "lucide-react"; import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types"; @@ -461,6 +465,7 @@ type Item = | { kind: "user"; key: string; msg: TranscriptMessage } | { kind: "text"; key: string; msg: TranscriptMessage; text: string } | { kind: "work"; key: string; blocks: TranscriptContent[] } + | { kind: "ask"; key: string; block: TranscriptContent } | { kind: "edits"; key: string; blocks: TranscriptContent[] } | { kind: "done"; key: string; ms: number; output: number }; @@ -522,7 +527,10 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] msg.content.forEach((block, b) => { const last = items[items.length - 1]; if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block); - if (block.type === "tool_use") { + // A question to the user is a turn of the conversation, not hidden work. + if (block.type === "tool_use" && block.name === "AskUserQuestion") { + items.push({ kind: "ask", key: `${key}:${b}`, block }); + } else if (block.type === "tool_use") { if (last?.kind === "work") last.blocks.push(block); else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] }); } else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) { @@ -669,6 +677,14 @@ export function MessageList({ return ( ); + case "ask": + return ( + + ); case "edits": // The card closes a finished turn; one still working has none yet. if (pending && item === items[items.length - 1]) return null; @@ -935,6 +951,50 @@ function RunningStep({ } /** Under a turn that ended: how long it took and the output it made. */ +/** An AskUserQuestion: each question with the answer the user picked or + * typed (the result's `answers`), "declined" when they refused it, no answer + * yet while the CLI still asks. */ +function AskCard({ + block, + result, +}: { + block: TranscriptContent; + result: TranscriptContent | null; +}) { + const { t } = useTranslation("sessions"); + const input = block.input as { questions?: unknown } | undefined; + const questions = (Array.isArray(input?.questions) ? input.questions : []) + .map((q) => (q && typeof q === "object" ? (q as { question?: unknown }).question : null)) + .filter((q): q is string => typeof q === "string"); + return ( +
+ {questions.map((q, i) => { + const answer = result?.answers?.[q]; + return ( +
+
+ + {q} +
+ {answer && ( +
+ + {answer} +
+ )} +
+ ); + })} + {result?.is_error && ( +
{t("conversation.askDeclined")}
+ )} +
+ ); +} + function TurnDone({ ms, output }: { ms: number; output: number }) { const { t } = useTranslation("sessions"); return ( diff --git a/client/src/components/conversation/__tests__/MessageList.ask.test.tsx b/client/src/components/conversation/__tests__/MessageList.ask.test.tsx new file mode 100644 index 00000000..b7b7a630 --- /dev/null +++ b/client/src/components/conversation/__tests__/MessageList.ask.test.tsx @@ -0,0 +1,85 @@ +/** + * @file MessageList.ask.test.tsx + * @description An AskUserQuestion call is not folded into the collapsed work + * line: the chat shows it as its own card — each question with the answer the + * user picked or typed, "declined" when they refused it, nothing yet while the + * CLI still waits. + * @author Nguyễn Ngọc Trí Vĩ + */ +import { describe, it, expect } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { MessageList } from "../MessageList"; +import type { TranscriptMessage } from "../../../lib/types"; + +const at = "2026-10-13T08:00:00.000Z"; +const user = (text: string): TranscriptMessage => + ({ + type: "user", + sender: "user", + timestamp: at, + content: [{ type: "text", text }], + }) as TranscriptMessage; +const ask = (id: string, questions: string[]): TranscriptMessage => + ({ + type: "assistant", + sender: "assistant", + timestamp: at, + content: [ + { type: "tool_use", id: `${id}r`, name: "Read", input: { file_path: "/w/a.ts" } }, + { + type: "tool_use", + id, + name: "AskUserQuestion", + input: { + questions: questions.map((question) => ({ + question, + header: "Pick", + options: [{ label: "Yes" }, { label: "No" }], + })), + }, + }, + ], + }) as TranscriptMessage; +const result = (id: string, extra: Record) => + ({ + type: "user", + sender: "tool", + timestamp: at, + content: [{ type: "tool_result", id, output: "x", ...extra }], + }) as TranscriptMessage; + +describe("an AskUserQuestion card", () => { + it("shows each question with the user's answer, outside the work line", () => { + render( + + ); + const card = screen.getByTestId("chat-ask"); + expect(within(card).getByText("Commit?")).toBeTruthy(); + expect(within(card).getByText("Yes")).toBeTruthy(); + expect(within(card).getByText("only after review")).toBeTruthy(); + expect(within(screen.getByTestId("chat-tools")).queryByText(/Commit\?/)).toBeNull(); + }); + + it("says declined when refused, and shows no answer while still asked", () => { + const { rerender } = render( + + ); + const card = screen.getByTestId("chat-ask"); + expect(within(card).getByText("Commit?")).toBeTruthy(); + expect(within(card).queryByText(/declined/i)).toBeNull(); + rerender( + + ); + expect(within(screen.getByTestId("chat-ask")).getByText(/declined/i)).toBeTruthy(); + }); +}); diff --git a/client/src/components/lanes/WorkspaceHome.tsx b/client/src/components/lanes/WorkspaceHome.tsx index ade44cc2..e8b3bfd7 100644 --- a/client/src/components/lanes/WorkspaceHome.tsx +++ b/client/src/components/lanes/WorkspaceHome.tsx @@ -277,6 +277,13 @@ function HomeBody({ ...(claude && inWorktree && project?.root ? { worktree: true } : {}), }); setPrompt(""); + // Here, not only through the effect: the navigate below unmounts this + // home in the same batch, so the effect never sees the empty prompt. + try { + localStorage.removeItem(draftKey); + } catch { + /* storage unavailable: nothing was kept */ + } images.forEach((i) => i.url && URL.revokeObjectURL(i.url)); setImages([]); navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`); diff --git a/client/src/components/lanes/__tests__/WorkspaceHome.test.tsx b/client/src/components/lanes/__tests__/WorkspaceHome.test.tsx index bf8faa1f..2656ab61 100644 --- a/client/src/components/lanes/__tests__/WorkspaceHome.test.tsx +++ b/client/src/components/lanes/__tests__/WorkspaceHome.test.tsx @@ -14,7 +14,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { MemoryRouter, Route, Routes, useLocation, useSearchParams } from "react-router-dom"; import type { Lane, Session } from "../../../lib/types"; import type { LaneSourcesView, RunHandle } from "../../../lib/api"; @@ -241,6 +241,27 @@ describe("WorkspaceHome", () => { await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull()); }); + it("drops the sent prompt's draft even though opening the session unmounts the home", async () => { + // As the Workspace page: `&run=` replaces the home with the session view. + function HomeUntilRun() { + const [params] = useSearchParams(); + return params.get("run") ? null : undefined} />; + } + const user = userEvent.setup(); + render( + + + } /> + + + ); + await screen.findByTestId("workspace-recent-s-live"); + await user.type(screen.getByLabelText("How can I help you today?"), "sent once"); + await user.click(screen.getByTestId("workspace-home-start")); + await waitFor(() => expect(screen.queryByTestId("workspace-home-start")).toBeNull()); + expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull(); + }); + it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => { renderHome(); await screen.findByTestId("workspace-recent-s-live"); diff --git a/client/src/components/run/PaneChat.tsx b/client/src/components/run/PaneChat.tsx index e8516ce4..418b5e49 100644 --- a/client/src/components/run/PaneChat.tsx +++ b/client/src/components/run/PaneChat.tsx @@ -20,8 +20,10 @@ * (`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 - * with the output count in a quarter-sized one; it sits beside the + * grid's full width or height (`panelOpen`) once the session has an output, + * else collapsed into a file button with the output count; the user's own + * open/close is kept per session for the tab (sessionStorage), so the chat + * leaving for the terminal and back keeps it; it sits beside the * conversation when the pane is wide and over it when narrow (a CSS container * query on the pane, not the window), dragged wider or narrower by its * splitter there (a fraction of the pane, remembered per browser). A file @@ -92,6 +94,11 @@ const MODES: [PermissionMode, string][] = [ const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m); const SIDE_KEY = "agenthub.chat.panelWidth"; const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100; +const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`; +const readOutlineChoice = (sessionId: string) => { + const v = sessionStorage.getItem(outlineKey(sessionId)); + return v === null ? null : v === "1"; +}; const storedSide = () => { const f = Number(localStorage.getItem(SIDE_KEY)); return f ? clampSide(f) : null; @@ -328,11 +335,20 @@ export function PaneChat({ const [model, setModel] = useState(run.model ?? ""); const [effort, setEffort] = useState(run.effort ?? ""); const pickFiles = useRef<(() => void) | null>(null); - const [outlineOpen, setOutlineOpen] = useState(panelOpen); + // The user's own open/close, per session for this tab: it outlives the chat + // leaving for the terminal (an unmount). Unset, the default rules. + const [outlineChoice, setOutlineChoice] = useState(() => readOutlineChoice(sessionId)); // A split or a close changes the pane's shape: its default comes back. - useEffect(() => setOutlineOpen(panelOpen), [panelOpen]); + const shape = useRef(panelOpen); + useEffect(() => { + if (shape.current === panelOpen) return; + shape.current = panelOpen; + sessionStorage.removeItem(outlineKey(sessionId)); + setOutlineChoice(null); + }, [panelOpen, sessionId]); const openOutline = (open: boolean) => { - setOutlineOpen(open); + sessionStorage.setItem(outlineKey(sessionId), open ? "1" : "0"); + setOutlineChoice(open); setPreview(null); }; const [preview, setPreview] = useState(null); @@ -355,6 +371,8 @@ export function PaneChat({ }, []); const [list, setList] = useState(null); const activity = useSessionOutline(sessionId); + // Open by default only in a spanning pane, and only once there is an output. + const outlineOpen = outlineChoice ?? (panelOpen && activity.outputs.length > 0); const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId }; const text = promptWithImages(prompt, images); diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index 8f7fc01e..9619d03f 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -40,7 +40,15 @@ * here takes the lead back (`resize` with `drive: true`). * @author Nguyễn Ngọc Trí Vĩ */ -import { useEffect, useId, useMemo, useRef, useState, type ClipboardEvent, type DragEvent } from "react"; +import { + useEffect, + useId, + useMemo, + useRef, + useState, + type ClipboardEvent, + type DragEvent, +} from "react"; import { useTranslation } from "react-i18next"; import type { Terminal } from "@xterm/xterm"; import type { CanvasAddon } from "@xterm/addon-canvas"; @@ -200,13 +208,14 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter // A pasted image (a screenshot) goes the way of a drop: xterm only reads // text/plain and would paste nothing. Capture phase, because xterm's own - // paste listener stops the event. A Ctrl+V with no file in it is the pane's - // ^V again (vim's block select, readline's quoted insert), not a text paste. + // paste listener stops the event. Text is xterm's to paste, Ctrl+V's too; + // only a Ctrl+V with nothing on the clipboard is the pane's ^V again + // (vim's block select, readline's quoted insert). const onPaste = (e: ClipboardEvent) => { const ctrlV = ctrlVRef.current; ctrlVRef.current = false; const files = Array.from(e.clipboardData?.files ?? []); - if (files.length === 0 && !ctrlV) return; + if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return; e.preventDefault(); e.stopPropagation(); if (files.length > 0) void attachFiles(files); @@ -354,7 +363,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter } // Ctrl+V: xterm would send ^V, and claude would then read the // SERVER's clipboard, never this browser's. Let the browser paste - // instead (onPaste); a browser that fires none still sends ^V. + // instead (onPaste: a file's path, or the text); a browser that fires + // no paste, or one with nothing on the clipboard, still sends ^V. if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "v") { ctrlVRef.current = true; setTimeout(() => { diff --git a/client/src/components/run/__tests__/PaneChat.test.tsx b/client/src/components/run/__tests__/PaneChat.test.tsx index dca8e8d8..2835668f 100644 --- a/client/src/components/run/__tests__/PaneChat.test.tsx +++ b/client/src/components/run/__tests__/PaneChat.test.tsx @@ -13,7 +13,7 @@ * @author Nguyễn Ngọc Trí Vĩ */ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { PaneChat, contextWindow } from "../PaneChat"; @@ -148,6 +148,7 @@ beforeEach(() => { vi.mocked(submitToRun).mockClear(); // A prompt typed in one test is a saved draft in the next (`lib/chatDraft`). localStorage.removeItem("agenthub.chat.draft.s1"); + sessionStorage.clear(); }); describe("PaneChat — the + menu", () => { @@ -313,27 +314,63 @@ describe("PaneChat — the activity panel", () => { expect(screen.getByTestId("chat-outline")).toBeTruthy(); }); - it("starts open in a pane spanning the grid, and follows the pane when it is split", () => { + it("starts open in a pane spanning the grid, and follows the pane when it is split", async () => { const pane = (panelOpen: boolean) => ( ); const { rerender } = render(pane(true)); - expect(screen.getByTestId("chat-outline")).toBeTruthy(); + await screen.findByTestId("chat-outline"); rerender(pane(false)); expect(screen.queryByTestId("chat-outline")).toBeNull(); expect(screen.getByTestId("chat-outline-show")).toBeTruthy(); }); - it("resizes from its splitter and remembers the width", () => { + it("starts closed while the session has no output, even in a spanning pane", async () => { + vi.mocked(api.sessions.outline).mockResolvedValueOnce({ + progress: [], + outputs: [], + files: [], + uploads: [], + memory: [], + skills: [], + mcp: [], + } as never); + render( + + + + ); + await waitFor(() => expect(api.sessions.outline).toHaveBeenCalled()); + expect(screen.queryByTestId("chat-outline")).toBeNull(); + expect(screen.getByTestId("chat-outline-show").textContent).toBe("0"); + }); + + it("keeps the user's open or close across the chat leaving for the terminal", async () => { + const pane = () => ( + + + + ); + const first = render(pane()); + fireEvent.click( + within(await screen.findByTestId("chat-outline")).getByRole("button", { name: "Hide side panel" }) + ); + first.unmount(); + render(pane()); + await waitFor(() => expect(screen.getByTestId("chat-outline-show").textContent).toBe("2")); + expect(screen.queryByTestId("chat-outline")).toBeNull(); + }); + + it("resizes from its splitter and remembers the width", async () => { localStorage.removeItem("agenthub.chat.panelWidth"); render( ); - const splitter = screen.getByRole("separator", { name: "Resize side panel" }); + const splitter = await screen.findByRole("separator", { name: "Resize side panel" }); expect(splitter.getAttribute("aria-valuenow")).toBe("70"); fireEvent.keyDown(splitter, { key: "ArrowLeft" }); expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35"); diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index bd437e14..6207d966 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -527,18 +527,30 @@ describe("TerminalView", () => { }); // Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then - // reads the SERVER's clipboard, not the browser's. - it("lets Ctrl+V reach the browser's paste, and still sends ^V when it holds no file", () => { + // reads the SERVER's clipboard, not the browser's. Text on the clipboard is + // pasted like any browser paste: sending ^V for it pasted nothing. + it("lets Ctrl+V reach the browser's paste: text is pasted, ^V only for an empty clipboard", () => { + vi.useFakeTimers(); inputMock.mockClear(); - pasteMock.mockClear(); render(); const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean; expect(handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }))).toBe(false); + // Not cancelled: xterm's own paste listener takes the text. + expect( + fireEvent.paste(screen.getByTestId("terminal-view"), { + clipboardData: { files: [], getData: () => "some text" }, + }) + ).toBe(true); + vi.advanceTimersByTime(1); + expect(inputMock).not.toHaveBeenCalled(); + handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true })); fireEvent.paste(screen.getByTestId("terminal-view"), { - clipboardData: { files: [], getData: () => "some text" }, + clipboardData: { files: [], getData: () => "" }, }); + vi.advanceTimersByTime(1); + expect(inputMock).toHaveBeenCalledTimes(1); expect(inputMock).toHaveBeenCalledWith("\x16", true); - expect(pasteMock).not.toHaveBeenCalled(); + vi.useRealTimers(); }); it("sends ^V on Ctrl+V when the browser fires no paste at all", () => { diff --git a/client/src/i18n/locales/en/sessions.json b/client/src/i18n/locales/en/sessions.json index eef43635..d1aa68f7 100644 --- a/client/src/i18n/locales/en/sessions.json +++ b/client/src/i18n/locales/en/sessions.json @@ -209,6 +209,7 @@ "moreSteps_one": "+{{count}} more", "moreSteps_other": "+{{count}} more", "quiet": "no new activity for {{time}}", + "askDeclined": "Declined — no answer given", "turnDone": "Done in {{time}}" }, "overview": { diff --git a/client/src/i18n/locales/vi/sessions.json b/client/src/i18n/locales/vi/sessions.json index 74454d8c..62e1078c 100644 --- a/client/src/i18n/locales/vi/sessions.json +++ b/client/src/i18n/locales/vi/sessions.json @@ -209,6 +209,7 @@ "moreSteps_one": "+{{count}} việc nữa", "moreSteps_other": "+{{count}} việc nữa", "quiet": "không có hoạt động mới trong {{time}}", + "askDeclined": "Đã từ chối — không trả lời", "turnDone": "Xong trong {{time}}" }, "overview": { diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 02700fdf..9b21ad3e 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -3213,6 +3213,9 @@ export interface TranscriptContent { /** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and * removed (from Claude Code's `structuredPatch`). */ diff?: { added: number; removed: number }; + /** Present on an AskUserQuestion "tool_result": question text → the option + * the user picked, or what they typed. */ + answers?: Record; } /** Who actually sent a transcript message. A JSONL `type:"user"` line can be the diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index f4172b6f..4f20c3ed 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = ` - UI build v2.7.4 (rebuild client to match) + UI build v2.7.5 (rebuild client to match)

diff --git a/docs/LANES.md b/docs/LANES.md index d7a64337..11be58a4 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). 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. +- **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. An AskUserQuestion is not folded into the collapsed work line: it is its own card (`AskCard`) showing each question with the option the user picked or what they typed — the `answers` the transcript route reads off Claude Code's `toolUseResult` —, "declined" when they refused it. 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`) once the session has an output, and otherwise — no output yet, or a quarter-sized pane (e.g. four panes at once) — collapsed into a file button with the number of outputs at the chat's top right. The user's own open or close is kept per session for the browser tab (`sessionStorage` `agenthub.chat.panel.`), so switching the pane to its terminal and back keeps it; a split or a close that changes the pane's shape drops it and brings the 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. diff --git a/openapi.yaml b/openapi.yaml index 1fc6826e..02e98a71 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3,7 +3,7 @@ openapi: 3.0.3 info: title: Agent Dashboard for Claude Code API - version: 2.7.4 + version: 2.7.5 description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. contact: name: Nguyễn Ngọc Trí Vĩ diff --git a/package-lock.json b/package-lock.json index e5b4dd71..30d71dc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agenthub", - "version": "2.7.4", + "version": "2.7.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agenthub", - "version": "2.7.4", + "version": "2.7.5", "hasInstallScript": true, "license": "UNLICENSED", "dependencies": { diff --git a/package.json b/package.json index 84935495..98f96c26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agenthub", - "version": "2.7.4", + "version": "2.7.5", "bin": { "agenthub": "bin/agenthub.js" }, diff --git a/server/__tests__/transcript-sender.test.js b/server/__tests__/transcript-sender.test.js index 5768ba1c..634b85d0 100644 --- a/server/__tests__/transcript-sender.test.js +++ b/server/__tests__/transcript-sender.test.js @@ -18,7 +18,7 @@ const fs = require("node:fs"); const TEST_DB = path.join(os.tmpdir(), `transcript-sender-${Date.now()}-${process.pid}.db`); process.env.DASHBOARD_DB_PATH = TEST_DB; -const { classifyTranscriptSender, patchCounts } = require("../routes/sessions"); +const { classifyTranscriptSender, patchCounts, askAnswers } = require("../routes/sessions"); const { db } = require("../db"); after(() => { @@ -168,3 +168,22 @@ describe("patchCounts — the +/- lines of a Write/Edit result", () => { assert.equal(patchCounts(undefined), null); }); }); + +describe("askAnswers — what the user picked or typed for an AskUserQuestion", () => { + it("returns each question's answer", () => { + const r = { + questions: [{ question: "Commit?", options: [{ label: "Yes" }, { label: "No" }] }], + answers: { "Commit?": "Yes", "Notes?": "typed text" }, + annotations: {}, + }; + assert.deepEqual(askAnswers(r), { "Commit?": "Yes", "Notes?": "typed text" }); + }); + + it("drops a non-string answer and answers null for a result that is no question", () => { + assert.deepEqual(askAnswers({ answers: { a: "x", b: 3 } }), { a: "x" }); + assert.equal(askAnswers({ answers: {} }), null); + assert.equal(askAnswers({ answers: ["x"] }), null); + assert.equal(askAnswers({ stdout: "ok" }), null); + assert.equal(askAnswers("text"), null); + }); +}); diff --git a/server/routes/sessions.js b/server/routes/sessions.js index 6476c69e..1da1cf50 100644 --- a/server/routes/sessions.js +++ b/server/routes/sessions.js @@ -1029,6 +1029,7 @@ router.get("/:id/transcript", async (req, res) => { } else if (block.type === "tool_result") { // A line carries one tool result; an edit's +/- counts ride on it. const diff = patchCounts(entry.toolUseResult); + const answers = askAnswers(entry.toolUseResult); content.push({ type: "tool_result", id: block.tool_use_id || null, @@ -1040,6 +1041,7 @@ router.get("/:id/transcript", async (req, res) => { ), is_error: !!block.is_error, ...(diff ? { diff } : {}), + ...(answers ? { answers } : {}), }); } } @@ -1330,6 +1332,16 @@ function patchCounts(r) { return null; } +/** An AskUserQuestion result's answers (`toolUseResult.answers`): question + * text → the option picked, or what the user typed. Null for any other result. */ +function askAnswers(r) { + if (!r || typeof r !== "object" || !r.answers || typeof r.answers !== "object") return null; + if (Array.isArray(r.answers)) return null; + const out = {}; + for (const [q, a] of Object.entries(r.answers)) if (typeof a === "string") out[q] = a; + return Object.keys(out).length ? out : null; +} + // A conversation turn — a prompt, an answer — is shown whole: it is bounded by // the model's output and what the user typed. Only tool payloads and thinking // (the parts that can run to megabytes) keep the 10 KB cap. This one guards @@ -1353,3 +1365,4 @@ module.exports.sessionStats = sessionStats; // Exported for unit tests — sender attribution is correctness-critical. module.exports.classifyTranscriptSender = classifyTranscriptSender; module.exports.patchCounts = patchCounts; +module.exports.askAnswers = askAnswers;