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).