From cb445f09660314dcf8ec757c6b50ba38ef68fa8a Mon Sep 17 00:00:00 2001 From: agent_coder Date: Mon, 6 Jul 2026 21:28:24 +0300 Subject: [PATCH] feat(ai-chat): show tool-call arguments in the action-log card (#392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For tools without a friendly label (esp. external MCP tools like Search_web_search) the card showed a generic "Ran tool " with no sense of what was searched. Add a compact one-line, dimmed summary of the call's arguments under the label, pulled from part.input. New pure toolInputSummary(part): picks the first present "primary" field (query/q/searchQuery/url/urls/title/name/text/prompt), collapses whitespace, clamps to ~140 chars; arrays render "first (+N)". It returns undefined during input-streaming (the input grows while state is fixed and messageSignature doesn't track input, so a live summary would freeze) — the state flip to input-available re-renders the row with the final value, so message-signature.ts is left untouched. The value renders ONLY through Mantine (React-escaped) — no markdown/HTML, no XSS. A showInput prop (default true) is threaded MessageList -> MessageItem (+memo) -> ToolCallCard; the public share widget passes showInput={false} so an anonymous reader never sees the agent's raw query text (mirrors showCitations). No JSON fallback when no primary field is present. closes #392 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/components/message-item.tsx | 10 ++ .../ai-chat/components/message-list.tsx | 9 ++ .../ai-chat/components/tool-call-card.tsx | 17 ++++ .../ai-chat/utils/tool-parts.test.tsx | 97 +++++++++++++++++++ .../src/features/ai-chat/utils/tool-parts.tsx | 63 ++++++++++++ .../share/components/share-ai-widget.tsx | 3 + 6 files changed, 199 insertions(+) diff --git a/apps/client/src/features/ai-chat/components/message-item.tsx b/apps/client/src/features/ai-chat/components/message-item.tsx index 4e645d8a..130b15dc 100644 --- a/apps/client/src/features/ai-chat/components/message-item.tsx +++ b/apps/client/src/features/ai-chat/components/message-item.tsx @@ -40,6 +40,13 @@ interface MessageItemProps { * Defaults to true (internal chat). The public share passes false. */ showCitations?: boolean; + /** + * Forwarded to ToolCallCard: whether tool cards render the one-line summary of + * a call's arguments (e.g. the search query). Defaults to true (internal + * chat). The public share passes false so an anonymous reader doesn't see the + * agent's raw query/argument text. + */ + showInput?: boolean; /** * Neutralize internal/relative markdown links in the rendered answer (drop * their href so they become inert text). Defaults to false (internal chat, @@ -117,6 +124,7 @@ const MarkdownPart = memo(function MarkdownPart({ function MessageItem({ message, showCitations = true, + showInput = true, neutralizeInternalLinks = false, assistantName, turnStreaming = false, @@ -210,6 +218,7 @@ function MessageItem({ key={index} part={part as unknown as ToolUiPart} showCitations={showCitations} + showInput={showInput} /> ); } @@ -274,6 +283,7 @@ export function arePropsEqual( return ( prev.signature === next.signature && prev.showCitations === next.showCitations && + prev.showInput === next.showInput && prev.neutralizeInternalLinks === next.neutralizeInternalLinks && prev.assistantName === next.assistantName && // The turn-end flip re-renders every row once (cheap, terminal event) — diff --git a/apps/client/src/features/ai-chat/components/message-list.tsx b/apps/client/src/features/ai-chat/components/message-list.tsx index 25435aa5..d5c9c377 100644 --- a/apps/client/src/features/ai-chat/components/message-list.tsx +++ b/apps/client/src/features/ai-chat/components/message-list.tsx @@ -25,6 +25,13 @@ interface MessageListProps { * false because an anonymous reader cannot open the linked internal pages. */ showCitations?: boolean; + /** + * Forwarded to MessageItem -> ToolCallCard: whether tool cards render the + * one-line summary of a call's arguments (e.g. the search query). Defaults to + * true (internal chat). The public share passes false so an anonymous reader + * doesn't see the agent's raw query/argument text. + */ + showInput?: boolean; /** * Forwarded to MessageItem: neutralize internal/relative markdown links in * the rendered answers (drop their href so they render as inert text). @@ -119,6 +126,7 @@ export default function MessageList({ isStreaming, emptyState, showCitations = true, + showInput = true, neutralizeInternalLinks = false, assistantName, }: MessageListProps) { @@ -208,6 +216,7 @@ export default function MessageList({ message={message} signature={messageSignature(message)} showCitations={showCitations} + showInput={showInput} neutralizeInternalLinks={neutralizeInternalLinks} assistantName={assistantName} // Turn-level liveness, gated to the TAIL row: only the tail message diff --git a/apps/client/src/features/ai-chat/components/tool-call-card.tsx b/apps/client/src/features/ai-chat/components/tool-call-card.tsx index d337bd1f..30efb19f 100644 --- a/apps/client/src/features/ai-chat/components/tool-call-card.tsx +++ b/apps/client/src/features/ai-chat/components/tool-call-card.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { getToolName, toolCitations, + toolInputSummary, toolLabelKey, toolRunState, ToolUiPart, @@ -21,6 +22,14 @@ interface ToolCallCardProps { * (the action log itself) while dropping the unusable links. */ showCitations?: boolean; + /** + * Whether to render the one-line summary of the call's arguments (e.g. the + * search query) under the label. Defaults to true (the internal chat). The + * public share passes false: an anonymous reader should not see the agent's + * raw query/argument text. Conservative and reversible — it only suppresses + * the extra summary line, leaving the card (the action log) intact. + */ + showInput?: boolean; } /** @@ -31,12 +40,14 @@ interface ToolCallCardProps { export default function ToolCallCard({ part, showCitations = true, + showInput = true, }: ToolCallCardProps) { const { t } = useTranslation(); const toolName = getToolName(part); const state = toolRunState(part.state); const { key, values } = toolLabelKey(toolName); const citations = showCitations ? toolCitations(part) : []; + const inputSummary = showInput ? toolInputSummary(part) : undefined; return (
@@ -57,6 +68,12 @@ export default function ToolCallCard({ + {inputSummary && ( + + {inputSummary} + + )} + {state === "error" && part.errorText && ( {part.errorText} diff --git a/apps/client/src/features/ai-chat/utils/tool-parts.test.tsx b/apps/client/src/features/ai-chat/utils/tool-parts.test.tsx index f3c3bd4c..e5b4375f 100644 --- a/apps/client/src/features/ai-chat/utils/tool-parts.test.tsx +++ b/apps/client/src/features/ai-chat/utils/tool-parts.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { toolCitations, + toolInputSummary, toolRunState, type ToolUiPart, } from "./tool-parts"; @@ -77,6 +78,102 @@ describe("toolCitations", () => { }); }); +describe("toolInputSummary", () => { + it("returns the primary `query` string", () => { + const part: ToolUiPart = { + type: "tool-Search_web_search", + state: "input-available", + input: { query: "hello world" }, + }; + expect(toolInputSummary(part)).toBe("hello world"); + }); + + it("summarizes a primary array field with a (+N) suffix", () => { + // `urls` is an external MCP read_pages-style list; the first element plus a + // count of the rest. + const part: ToolUiPart = { + type: "tool-read_pages", + state: "input-available", + input: { urls: ["a", "b", "c"] }, + }; + expect(toolInputSummary(part)).toBe("a (+2)"); + }); + + it("omits the (+N) suffix for a single-element array", () => { + const part: ToolUiPart = { + type: "tool-read_pages", + state: "input-available", + input: { urls: ["only"] }, + }; + expect(toolInputSummary(part)).toBe("only"); + }); + + it("falls back to `title` for a page op with no query", () => { + const part: ToolUiPart = { + type: "tool-createPage", + state: "input-available", + input: { pageId: "x", title: "My Page" }, + }; + expect(toolInputSummary(part)).toBe("My Page"); + }); + + it("clamps a long value to ~140 chars with an ellipsis", () => { + const long = "a".repeat(300); + const part: ToolUiPart = { + type: "tool-Search_web_search", + state: "input-available", + input: { query: long }, + }; + const out = toolInputSummary(part)!; + expect(out.endsWith("…")).toBe(true); + expect(out.length).toBeLessThanOrEqual(141); + }); + + it("collapses newlines and repeated spaces to single spaces", () => { + const part: ToolUiPart = { + type: "tool-Search_web_search", + state: "input-available", + input: { query: " foo\n\n bar baz " }, + }; + expect(toolInputSummary(part)).toBe("foo bar baz"); + }); + + it("returns undefined with no input", () => { + expect( + toolInputSummary({ type: "tool-x", state: "input-available" }), + ).toBeUndefined(); + }); + + it("returns undefined for an empty object input", () => { + expect( + toolInputSummary({ + type: "tool-x", + state: "input-available", + input: {}, + }), + ).toBeUndefined(); + }); + + it("returns undefined for a non-object input", () => { + expect( + toolInputSummary({ + type: "tool-x", + state: "input-available", + input: "just a string", + }), + ).toBeUndefined(); + }); + + it("returns undefined while the input is still streaming (even with a full input)", () => { + const part: ToolUiPart = { + type: "tool-Search_web_search", + state: "input-streaming", + input: { query: "hello world" }, + }; + expect(toolInputSummary(part)).toBeUndefined(); + }); +}); + describe("toolRunState", () => { it('maps "output-error" to error', () => { expect(toolRunState("output-error")).toBe("error"); diff --git a/apps/client/src/features/ai-chat/utils/tool-parts.tsx b/apps/client/src/features/ai-chat/utils/tool-parts.tsx index be972050..c1a3fa7e 100644 --- a/apps/client/src/features/ai-chat/utils/tool-parts.tsx +++ b/apps/client/src/features/ai-chat/utils/tool-parts.tsx @@ -97,6 +97,69 @@ function asString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } +/** Collapse runs of whitespace/newlines to a single space and trim. */ +function collapse(s: string): string { + return s.replace(/\s+/g, " ").trim(); +} + +/** Truncate to ~140 chars, appending an ellipsis when it overflows. */ +function clamp(s: string): string { + const MAX = 140; + return s.length > MAX ? s.slice(0, MAX).trimEnd() + "…" : s; +} + +/** + * Priority "primary" argument fields, in order. The first present one supplies + * the summary. `urls` is included (external MCP `read_pages`-style tools take a + * list of URLs) and is handled as an array; `url` covers the single-URL form. + */ +const PRIMARY_INPUT_FIELDS = [ + "query", + "q", + "searchQuery", + "url", + "urls", + "title", + "name", + "text", + "prompt", +] as const; + +/** + * A short, PLAIN-TEXT one-line summary of a tool call's arguments (e.g. the + * search query), or undefined when no recognizable primary field is present. + * Rendered under the tool label so tools without a friendly name (external MCP + * tools like `Search_web_search`) still show WHAT was requested, not just a + * generic "Ran tool {{name}}". The returned string is plain text and MUST be + * rendered React-escaped (Mantine ``), never as markdown/HTML. + * + * Streaming gate: while `state === "input-streaming"` the `input` object grows + * chunk by chunk but `messageSignature` deliberately does NOT track `input`, so + * a live summary computed here would freeze at its first captured value and go + * stale. We therefore return undefined until the state flips to + * `input-available` (input finalized) — that state change IS tracked by the + * signature, so the row re-renders and shows the complete summary. Do NOT add + * `input` to `message-signature.ts` to work around this. + */ +export function toolInputSummary(part: ToolUiPart): string | undefined { + if (part.state === "input-streaming") return undefined; + if (!part.input || typeof part.input !== "object") return undefined; + const input = part.input as Record; + + for (const field of PRIMARY_INPUT_FIELDS) { + const value = input[field]; + if (typeof value === "string" && value.length > 0) { + return clamp(collapse(value)); + } + if (Array.isArray(value) && value.length > 0) { + const first = collapse(String(value[0])); + if (first.length === 0) continue; + return clamp(first + (value.length > 1 ? ` (+${value.length - 1})` : "")); + } + } + return undefined; +} + /** * Resolve the page citation(s) a tool part references, from its input/output. * Only output-available parts (the tool returned) yield citations. Search diff --git a/apps/client/src/features/share/components/share-ai-widget.tsx b/apps/client/src/features/share/components/share-ai-widget.tsx index b5c285da..59b2efb5 100644 --- a/apps/client/src/features/share/components/share-ai-widget.tsx +++ b/apps/client/src/features/share/components/share-ai-widget.tsx @@ -165,6 +165,9 @@ export default function ShareAiWidget({ isStreaming={isStreaming} assistantName={assistantName} showCitations={false} + // Anonymous reader: suppress the tool-argument summary line so the + // agent's raw query/argument text isn't shown on the public share. + showInput={false} // Anonymous reader: neutralize internal/relative links in the // assistant's markdown so internal UUIDs/auth-gated routes don't // leak as clickable links (external http(s) links are kept).