feat(ai-chat): show tool-call arguments in the action-log card (#392)
For tools without a friendly label (esp. external MCP tools like
Search_web_search) the card showed a generic "Ran tool <name>" 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 <Text> (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) <noreply@anthropic.com>
This commit is contained in:
@@ -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) —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div className={classes.toolCard}>
|
||||
@@ -57,6 +68,12 @@ export default function ToolCallCard({
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{inputSummary && (
|
||||
<Text size="xs" c="dimmed" mt={2} lineClamp={2}>
|
||||
{inputSummary}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{state === "error" && part.errorText && (
|
||||
<Text size="xs" c="red" mt={2}>
|
||||
{part.errorText}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 `<Text>`), 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<string, unknown>;
|
||||
|
||||
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
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user