From 7ff9f77f40842d85109d9a4ef466d67ddf4bedc6 Mon Sep 17 00:00:00 2001 From: buenosair Date: Tue, 1 Sep 2026 14:14:52 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20unified=20tool=20cards=20(glyph=20+=20a?= =?UTF-8?q?rg,=20call+result=20in=20one=20card,=20=E2=9C=93/=E2=9C=97=20st?= =?UTF-8?q?atus),=20client-side=20message=20queue=20(queue=20while=20busy,?= =?UTF-8?q?=20flush=20on=20settle,=20removable=20pending=20bubbles),=20mob?= =?UTF-8?q?ile=20chat-header=20wrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/ChatStream.test.tsx | 90 ++++++++++++++++++++++++++----- web/src/ChatStream.tsx | 95 ++++++++++++++++++++++++--------- web/src/ChatView.test.tsx | 51 ++++++++++++++++++ web/src/ChatView.tsx | 102 ++++++++++++++++++++++++++---------- web/src/index.css | 52 ++++++++++++++++++ 5 files changed, 325 insertions(+), 65 deletions(-) diff --git a/web/src/ChatStream.test.tsx b/web/src/ChatStream.test.tsx index 00178a7..41e0fd6 100644 --- a/web/src/ChatStream.test.tsx +++ b/web/src/ChatStream.test.tsx @@ -38,6 +38,8 @@ const tool = (p: Partial): ToolState => ({ function stream(p: { messages: ChatMessage[]; busy: boolean; + queued?: string[]; + unqueue?: (index: number) => void; hasOlder?: boolean; loadingOlder?: boolean; onOlder?: () => void; @@ -50,6 +52,8 @@ function stream(p: { messages={p.messages} tools={new Map()} busy={p.busy} + queued={p.queued ?? []} + unqueue={p.unqueue ?? (() => undefined)} hasOlder={p.hasOlder ?? false} loadingOlder={p.loadingOlder ?? false} onLoadOlder={p.onOlder ?? (() => undefined)} @@ -151,10 +155,10 @@ describe("Bubble", () => { tools={tools} />, ); - expect(screen.getByText("🛠 bash")).toBeInTheDocument(); + expect(screen.getByText("$ ls -la")).toBeInTheDocument(); expect(screen.getByText("finished")).toBeInTheDocument(); - const summary = screen.getByText("🛠 bash").closest("summary") as HTMLElement; + const summary = screen.getByText("$ ls -la").closest("summary") as HTMLElement; const card = summary.closest("details") as HTMLDetailsElement; expect(card.open).toBe(false); await userEvent.click(summary); @@ -182,9 +186,9 @@ describe("Bubble", () => { tools={tools} />, ); - expect(screen.getByText("working…")).toBeInTheDocument(); - expect(screen.getByText("error")).toBeInTheDocument(); - expect(screen.getByText("done")).toBeInTheDocument(); + expect(screen.getByText("⋯")).toBeInTheDocument(); + expect(screen.getByText("✗")).toBeInTheDocument(); + expect(screen.getByText("✓")).toBeInTheDocument(); }); it("tool call with no matching state renders no card", () => { @@ -781,7 +785,7 @@ describe("pretty tool args", () => { tools={new Map([["t1", tool('{"command":"ls -la","foo":1}')]])} />, ); - expect(document.querySelector(".tool-summary")?.textContent).toBe("ls -la"); + expect(document.querySelector(".tool-label")?.textContent).toBe("$ ls -la"); expect(document.querySelector(".tool-args")?.textContent).not.toContain( '"command"', ); @@ -809,8 +813,8 @@ describe("pretty tool args", () => { tools={new Map([["t1", tool('{"path":"src/main.ts"}')]])} />, ); - expect(document.querySelector(".tool-summary")?.textContent).toBe( - "src/main.ts", + expect(document.querySelector(".tool-label")?.textContent).toBe( + "$ src/main.ts", ); expect(document.querySelector(".tool-args .arg-k")).toBeNull(); }); @@ -849,8 +853,7 @@ describe("tool summary + diff", () => { tools={new Map([["t1", tool('{"command":"ls -la","timeout":30}')]])} />, ); - expect(screen.getByText("🛠 bash")).toBeInTheDocument(); - expect(document.querySelector(".tool-summary")?.textContent).toBe("ls -la"); + expect(screen.getByText("$ ls -la")).toBeInTheDocument(); }); it("read summary shows the path", () => { @@ -862,8 +865,8 @@ describe("tool summary + diff", () => { tools={new Map([["t1", tool('{"path":"src/main.ts"}', "read")]])} />, ); - expect(document.querySelector(".tool-summary")?.textContent).toBe( - "src/main.ts", + expect(document.querySelector(".tool-label")?.textContent).toBe( + "📄 src/main.ts", ); }); @@ -878,7 +881,7 @@ describe("tool summary + diff", () => { tools={new Map([["t1", tool(`{"command":"${long}"}`)]])} />, ); - const summary = document.querySelector(".tool-summary"); + const summary = document.querySelector(".tool-label"); expect(summary?.textContent?.endsWith("…")).toBe(true); }); @@ -970,3 +973,64 @@ describe("lineDiff", () => { expect(d.filter((l) => l.kind === "add")).toHaveLength(1); }); }); + +describe("unified tool card", () => { + const tool = (args: string): ToolState => ({ + id: "t1", + name: "bash", + args, + running: false, + isError: false, + preview: "", + }); + + it("toolResult bubble is skipped when its tool card exists", () => { + const { container } = render( + <> + + + , + ); + expect(container.querySelectorAll(".tool-card")).toHaveLength(1); + expect(container.textContent).not.toContain("result"); + }); + + it("toolResult without tool state still renders a result card", () => { + render( + , + ); + expect(screen.getAllByText("orphan output").length).toBeGreaterThan(0); + }); + + it("queued messages render as removable pending bubbles", async () => { + const unqueue = vi.fn(); + const { rerender } = render( + stream({ + messages: [msg({ role: "user", text: "hi" })], + busy: true, + queued: ["next msg"], + unqueue, + }), + ); + expect(screen.getByText("next msg")).toBeInTheDocument(); + await userEvent.click(screen.getByLabelText("Remove queued message")); + expect(unqueue).toHaveBeenCalledWith(0); + + rerender( + stream({ messages: [msg({ role: "user", text: "hi" })], busy: true, queued: [], unqueue }), + ); + expect(screen.queryByText("next msg")).toBeNull(); + }); +}); diff --git a/web/src/ChatStream.tsx b/web/src/ChatStream.tsx index 980014e..761bc01 100644 --- a/web/src/ChatStream.tsx +++ b/web/src/ChatStream.tsx @@ -143,6 +143,16 @@ function oneLine(text: string): string { return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}…` : flat; } +/** true when the standalone result bubble is skipped because its ToolCard + * (which carries the output preview) is already rendered */ +function resultSkipped(m: ChatMessage, tools: Map): boolean { + return ( + m.role === "toolResult" && + m.toolCallId !== null && + tools.has(m.toolCallId) + ); +} + function CopyButton({ text }: { text: string }): React.ReactNode { const [copied, setCopied] = useState(false); return ( @@ -362,25 +372,34 @@ function DiffBlock({ lines }: { lines: DiffLine[] }): React.ReactNode { ); } +// Well-known tools get a compact glyph + their primary arg instead of +// the raw tool name (bash → "$ ls -la", read → "📄 src/main.ts", …). +const TOOL_ICON: Record = { + bash: "$", + read: "📄", + edit: "✎", + write: "📝", +}; + function ToolCard({ tool }: { tool: ToolState }) { - const status: string = tool.running - ? "running…" - : tool.isError - ? "error" - : "done"; + const status: string = tool.running ? "⋯" : tool.isError ? "✗" : "✓"; const statusClass: string = tool.isError ? "tool-status-err" - : "tool-status-ok"; + : tool.running + ? "tool-status-run" + : "tool-status-ok"; const summary: string = oneLine(toolSummaryText(tool.args)); + const icon: string | undefined = TOOL_ICON[tool.name]; + const label: string = + icon === undefined ? tool.name : `${icon} ${summary}`.trim(); + const rest: string = icon === undefined ? summary : ""; const diffBlocks: DiffLine[][] | null = toolDiffBlocks(tool.name, tool.args); return (
- 🛠 {tool.name} - {summary.length > 0 && {summary}} - - {tool.running ? "working…" : status} - + {label} + {rest.length > 0 && {rest}} + {status}
{diffBlocks === null ? ( @@ -451,6 +470,9 @@ export function Bubble({ if (t !== undefined) msgTools.push(t); } } + // the ToolCard above already carries this result (output preview): skip + // the duplicate standalone "result" bubble when the tool state exists + if (resultSkipped(msg, tools)) return null; const copyable: boolean = msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0); const tsTitle: string = msg.ts > 0 ? new Date(msg.ts).toISOString() : ""; @@ -506,6 +528,10 @@ interface Props { messages: ChatMessage[]; tools: Map; busy: boolean; + /** messages queued client-side while the agent runs; sent on settle */ + queued: string[]; + /** drop a queued message before it is sent (the âś• on a queued bubble) */ + unqueue: (index: number) => void; /** an older page exists beyond the loaded window (B1) */ hasOlder: boolean; loadingOlder: boolean; @@ -521,6 +547,8 @@ export default function ChatStream({ messages, tools, busy, + queued, + unqueue, hasOlder, loadingOlder, onLoadOlder, @@ -574,9 +602,11 @@ export default function ChatStream({ const q: string = query.trim().toLowerCase(); if (q.length === 0) return []; return messages - .filter((m) => searchHaystack(m).includes(q)) + .filter( + (m) => !resultSkipped(m, tools) && searchHaystack(m).includes(q), + ) .map((m) => m.key); - }, [messages, query]); + }, [messages, tools, query]); // live events can shrink the match set under the cursor useEffect(() => { @@ -627,19 +657,34 @@ export default function ChatStream({
)} - {messages.map((m) => ( -
{ - if (el === null) rowRefs.current.delete(m.key); - else rowRefs.current.set(m.key, el); - }} - > - + {messages.map((m) => ( +
{ + if (el === null) rowRefs.current.delete(m.key); + else rowRefs.current.set(m.key, el); + }} + > + +
+ ))} + {queued.map((text, i) => ( +
+
+
{text}
+
- ))} - {showTyping && } +
+ ))} + {showTyping && }
{searchOpen && ( diff --git a/web/src/ChatView.test.tsx b/web/src/ChatView.test.tsx index 4daac5b..5b7b7ae 100644 --- a/web/src/ChatView.test.tsx +++ b/web/src/ChatView.test.tsx @@ -1718,3 +1718,54 @@ describe("document title", () => { await waitFor(() => expect(document.title).toContain("bash")); }); }); + +describe("ChatView message queue", () => { + it("Enter while busy queues instead of sending; flushed on settle", async () => { + const fetchMock = mockFetchJson((_url, init) => + init?.method === "POST" ? { ok: true } : [], + ); + renderChat(makeStore()); + const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement; + const promptCalls = (): number => + fetchMock.mock.calls.filter(([u]) => + String(u).endsWith("/api/sessions/s1/prompt"), + ).length; + + push([ev("agent_start")]); // busy + await userEvent.type(ta, "queued hello"); + fireEvent.keyDown(ta, { key: "Enter" }); + + // not sent yet; visible as a pending bubble + expect(promptCalls()).toBe(0); + expect(await screen.findByText("queued hello")).toBeInTheDocument(); + + // settled → flush + push([ev("agent_settled")]); + await vi.waitFor(() => expect(promptCalls()).toBe(1)); + }); + + it("queued bubble can be removed before it is sent", async () => { + const fetchMock = mockFetchJson((_url, init) => + init?.method === "POST" ? { ok: true } : [], + ); + renderChat(makeStore()); + const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement; + const promptCalls = (): number => + fetchMock.mock.calls.filter(([u]) => + String(u).endsWith("/api/sessions/s1/prompt"), + ).length; + + push([ev("agent_start")]); + await userEvent.type(ta, "do not send"); + fireEvent.keyDown(ta, { key: "Enter" }); + expect(await screen.findByText("do not send")).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText("Remove queued message")); + + push([ev("agent_settled")]); + await vi.waitFor(() => + expect(screen.queryByText("do not send")).toBeNull(), + ); + expect(promptCalls()).toBe(0); + }); +}); diff --git a/web/src/ChatView.tsx b/web/src/ChatView.tsx index 3331a5c..5aa5ffd 100644 --- a/web/src/ChatView.tsx +++ b/web/src/ChatView.tsx @@ -86,6 +86,8 @@ export default function ChatView({ store, pushToast }: Props) { const [loadError, setLoadError] = useState(""); const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); + // messages waiting for the current run to settle (sent one per idle window) + const [queued, setQueued] = useState([]); const [tasksOpen, setTasksOpen] = useState(false); const [hasOlder, setHasOlder] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); @@ -166,6 +168,7 @@ export default function ChatView({ store, pushToast }: Props) { // leak into the new one setDraft(""); setSending(false); + setQueued([]); setSearchOpen(false); setMenuOpen(false); sentRef.current = []; @@ -227,13 +230,13 @@ export default function ChatView({ store, pushToast }: Props) { .filter((t) => t.running) .map((t) => t.name)[0]; const titleClaim: string | null = - !busy - ? label - : streaming + busy + ? streaming ? `${label} · writing…` - : toolNow !== undefined - ? `${label} · ${toolNow}` - : `${label} · working…`; + : toolNow === undefined + ? `${label} · working…` + : `${label} · ${toolNow}` + : label; useTitle(titleClaim); const minSeq: number = useMemo( @@ -312,30 +315,74 @@ export default function ChatView({ store, pushToast }: Props) { }; }, [menuOpen]); - const send = async (): Promise => { - const text = draft.trim(); - if (text.length === 0 || sending) return; - setDraft(""); - setSending(true); - const hist = sentRef.current; - hist.push(text); - if (hist.length > SENT_HISTORY_MAX) hist.shift(); + const postMessage = async (text: string): Promise => { try { await fetchJson(Route.SessionPrompt(sessionId), { method: "POST", body: JSON.stringify({ message: text }), }); + return true; } catch (err) { - // the message never reached the session: put it back (S6) - setDraft(text); if (err instanceof ApiError && err.status === 409) pushToast("session offline"); else pushToast(errMessage(err)); + return false; + } + }; + + const rememberSent = (text: string): void => { + const hist = sentRef.current; + hist.push(text); + if (hist.length > SENT_HISTORY_MAX) hist.shift(); + }; + + const send = async (): Promise => { + const text = draft.trim(); + if (text.length === 0 || sending) return; + setDraft(""); + // agent busy → queue; flushed when the run settles + if (busy) { + setQueued((q) => [...q, text]); + return; + } + setSending(true); + rememberSent(text); + try { + // the message never reached the session: put it back (S6) + if (!(await postMessage(text))) setDraft(text); } finally { setSending(false); } }; + const unqueue = (index: number): void => { + setQueued((q) => q.filter((_, n) => n !== index)); + }; + + // drain the queue: one message per idle window — the gate only reopens + // when the busy window actually opens (the POST resolving before the + // agent_start event would otherwise fire the next message back-to-back) + const flushingRef = useRef(false); + useEffect(() => { + if (busy) { + flushingRef.current = false; + return; + } + if (sending || queued.length === 0 || flushingRef.current) return; + const text: string | undefined = queued[0]; + if (text === undefined) return; + flushingRef.current = true; + setQueued((q) => q.slice(1)); + rememberSent(text); + void (async () => { + // the message never reached the session: put it back (S6) + if (!(await postMessage(text))) { + flushingRef.current = false; + setDraft(text); + } + })(); + }, [busy, sending, queued, postMessage]); + const abort = async (): Promise => { try { await fetchJson(Route.SessionAbort(sessionId), { method: "POST" }); @@ -663,6 +710,8 @@ export default function ChatView({ store, pushToast }: Props) { messages={chat.messages} tools={chat.tools} busy={busy} + queued={queued} + unqueue={unqueue} hasOlder={hasOlder} loadingOlder={loadingOlder} onLoadOlder={() => void loadOlder()} @@ -682,7 +731,7 @@ export default function ChatView({ store, pushToast }: Props) { onChange={(e) => setDraft(e.target.value)} onKeyDown={onKeyDown} /> - {busy ? ( + {busy && ( - ) : ( - )} + diff --git a/web/src/index.css b/web/src/index.css index 38905ec..d23af4c 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -688,6 +688,15 @@ a:hover { color: var(--text); flex-shrink: 0; } +.bubble .tool-card summary .tool-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--mono); + font-size: 12px; + color: var(--text); +} .bubble .tool-card summary .tool-summary { flex: 1; min-width: 40px; @@ -719,6 +728,9 @@ a:hover { color: var(--danger); font-weight: 600; } +.tool-status-run { + color: var(--text-faint); +} details.thinking { margin-bottom: 10px; @@ -1378,6 +1390,26 @@ mark.hit { } @media (max-width: 700px) { + .chat-header { + flex-wrap: wrap; + gap: 6px 8px; + padding: 8px 12px; + min-height: 0; + } + .chat-header .title { + flex: 1 1 100%; + min-width: 0; + } + .chat-header .model-chip span:first-child { + display: inline-block; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; + } + .chat-header .usage-chip { + display: none; + } .sidebar { position: fixed; inset: 0 auto 0 0; @@ -1525,3 +1557,23 @@ mark.hit { .diff-ctx { color: var(--text-faint); } + +/* ---------- queued (pending) messages ---------- */ + +.queued-bubble { + display: flex; + align-items: flex-start; + gap: 8px; + opacity: 0.7; + border-style: dashed; +} +.queued-line { + flex: 1; + min-width: 0; + white-space: pre-wrap; + word-break: break-word; +} +.queued-remove { + flex-shrink: 0; + padding: 2px 6px; +}