fix: v2.7.5 — the chat shows each AskUserQuestion with the user's answer

- 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.
This commit is contained in:
2026-10-13 10:46:56 +07:00
parent 7cb4f64781
commit b0dd5af22f
20 changed files with 320 additions and 31 deletions
+1
View File
@@ -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__/<name>.test.js`, and in `client/` `NODE_ENV=test npx vitest related <changed files> --run` (or `vitest run <test file>`). 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
+2 -1
View File
@@ -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__/<name>.test.js`; in `client/` `NODE_ENV=test npx vitest related <changed files> --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
@@ -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 (
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
);
case "ask":
return (
<AskCard
key={item.key}
block={item.block}
result={item.block.id ? (toolResultMap.get(item.block.id) ?? null) : null}
/>
);
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 (
<div
data-testid="chat-ask"
className="space-y-2 rounded-xl border border-border px-3 py-2 text-sm"
>
{questions.map((q, i) => {
const answer = result?.answers?.[q];
return (
<div key={i}>
<div className="flex items-start gap-1.5 text-fg-secondary">
<MessageCircleQuestion className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<span>{q}</span>
</div>
{answer && (
<div className="ml-5 mt-0.5 flex items-start gap-1.5 font-medium text-fg-primary">
<CornerDownRight className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
<span className="whitespace-pre-wrap">{answer}</span>
</div>
)}
</div>
);
})}
{result?.is_error && (
<div className="text-xs text-fg-muted">{t("conversation.askDeclined")}</div>
)}
</div>
);
}
function TurnDone({ ms, output }: { ms: number; output: number }) {
const { t } = useTranslation("sessions");
return (
@@ -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ĩ <vinnt@smartgift.vn>
*/
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<string, unknown>) =>
({
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(
<MessageList
loading={false}
messages={[
user("go"),
ask("q1", ["Commit?", "Push?"]),
result("q1", { answers: { "Commit?": "Yes", "Push?": "only after review" } }),
]}
/>
);
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(
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />
);
const card = screen.getByTestId("chat-ask");
expect(within(card).getByText("Commit?")).toBeTruthy();
expect(within(card).queryByText(/declined/i)).toBeNull();
rerender(
<MessageList
loading={false}
messages={[user("go"), ask("q1", ["Commit?"]), result("q1", { is_error: true })]}
/>
);
expect(within(screen.getByTestId("chat-ask")).getByText(/declined/i)).toBeTruthy();
});
});
@@ -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)}`);
@@ -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 : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
}
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
<Routes>
<Route path="*" element={<HomeUntilRun />} />
</Routes>
</MemoryRouter>
);
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");
+23 -5
View File
@@ -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<EffortLevel>(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<string | null>(null);
@@ -355,6 +371,8 @@ export function PaneChat({
}, []);
const [list, setList] = useState<OutlineList | null>(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);
+15 -5
View File
@@ -40,7 +40,15 @@
* here takes the lead back (`resize` with `drive: true`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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(() => {
@@ -13,7 +13,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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) => (
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
</MemoryRouter>
);
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(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
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 = () => (
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
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(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
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");
@@ -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(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
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", () => {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+3
View File
@@ -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<string, string>;
}
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.7.4 (rebuild client to match)
UI build v2.7.5 (rebuild client to match)
</span>
</p>
</div>
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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ĩ
+2 -2
View File
@@ -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": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.7.4",
"version": "2.7.5",
"bin": {
"agenthub": "bin/agenthub.js"
},
+20 -1
View File
@@ -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);
});
});
+13
View File
@@ -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;