From df4ae529eacfff8db38f2f4b9b767d3717446c8a Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 4 Oct 2026 09:06:24 -0300 Subject: [PATCH] feat(clients): tool calls show the call above a muted result, without cards (#15506) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../src/features/threads/thread-work-log.tsx | 71 ++++++++--- .../src/components/chat/V2ItemInspector.tsx | 117 +++++++++--------- apps/web/src/components/chat/WorkLog.tsx | 2 +- .../client-runtime/src/work-log/itemDetail.ts | 77 +++++++++++- 4 files changed, 191 insertions(+), 76 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 3fed0c1a71543a0cebf4afa003131cc427840448..dd2ebeb76ff69da7132ca9f54a76d923e2b6a58d 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -64,7 +64,7 @@ import { type ThreadFeedActivity, workEntryRowLabel, } from "../../lib/threadActivity"; -import { turnItemOutputText } from "@t3tools/client-runtime/work-log/item-detail"; +import { toolCallLines, turnItemOutputText } from "@t3tools/client-runtime/work-log/item-detail"; import { useTurnItemDetail } from "../../state/queries"; import { resolveThreadWorkGroupInitialScroll, @@ -929,23 +929,41 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( const fetchedItem = fetchedDetail.data?.item ?? null; // Reads keep their path list; the fetched file contents show as output. const isRead = toolGroupAction(row.workEntry) === "read"; + // Tool calls show the call in the foreground and the result muted below it. + const shownItem = fetchedItem ?? row.projectedItem.item; + const call = + expanded && !isRead && shownItem.type === "command_execution" + ? toolCallLines({ command: shownItem.input }) + : expanded && !isRead && shownItem.type === "dynamic_tool" + ? toolCallLines({ args: shownItem.input }) + : expanded && shownItem.type === "file_search" + ? toolCallLines({ args: { pattern: shownItem.pattern } }) + : expanded && shownItem.type === "web_search" + ? toolCallLines({ args: { query: shownItem.patterns?.join(", ") } }) + : null; + const failedExitCode = + call && shownItem.type === "command_execution" && shownItem.exitCode + ? shownItem.exitCode + : null; const fullDetail = - expanded && !reasoning + expanded && !reasoning && !call ? fetchedItem && !isRead ? formatItemFullDetail(row.projectedItem, fetchedItem) : row.getFullDetail() : null; const fetchedOutput = !expanded ? null - : fetchedItem - ? (turnItemOutputText(fetchedItem) ?? "No output.") - : fetchedDetail.error - ? `Couldn't load output: ${fetchedDetail.error}` - : row.fetchesDetail - ? fetchedDetail.data - ? "Output is no longer available." - : "Loading output…" - : null; + : shownItem.type === "file_search" || shownItem.type === "web_search" + ? turnItemOutputText(shownItem) + : fetchedItem + ? (turnItemOutputText(fetchedItem) ?? "No output.") + : fetchedDetail.error + ? `Couldn't load output: ${fetchedDetail.error}` + : row.fetchesDetail + ? fetchedDetail.data + ? "Output is no longer available." + : "Loading output…" + : null; const viewedImagePath = workEntryViewedImagePath(row.workEntry); const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); const previewText = workEntryRowLabel(row.workEntry); @@ -1091,6 +1109,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( {expanded && (reasoning || fullDetail || + call || fetchedOutput || viewedImagePath || row.workEntry.questionAnswer) ? ( @@ -1120,19 +1139,43 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( > {reasoning ? ( props.renderReasoning(reasoning.text) - ) : ( + ) : call ? ( + [ + call.command, + ...(call.args ?? []).map(([key, value]) => `${key} ${value}`), + call.argsText, + ] + .filter((line): line is string => Boolean(line)) + .map((line, index) => ( + + {line} + + )) + ) : fullDetail ? ( {fullDetail} - )} + ) : null} {fetchedOutput ? ( {fetchedOutput} ) : null} + {failedExitCode !== null ? ( + + exit {failedExitCode} + + ) : null} ) : null} diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index 4f33a7f5b1a72fba95bb563b9becdb377086c9b2..0b8275065549a4c18ac080300e6a718d190e0a4f 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -5,15 +5,16 @@ import type { ThreadId, } from "@t3tools/contracts"; import { - formatToolValue, + toolCallLines, turnItemDetailRevision, turnItemNeedsDetailFetch, turnItemOutputText, } from "@t3tools/client-runtime/work-log/item-detail"; import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react"; -import { memo, type ReactNode, Suspense, use, useMemo } from "react"; +import { memo, Suspense, use, useMemo } from "react"; import { useTheme } from "../../hooks/useTheme"; +import { cn } from "../../lib/utils"; import { resolveDiffThemeName } from "../../lib/diffRendering"; import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting"; import { useTurnItemDetail } from "../../state/queries"; @@ -55,6 +56,9 @@ function JsonTokens({ text }: { readonly text: string }) { ]); } +const monoClassName = + "font-mono text-(length:--font-size-code,var(--text-2xs)) leading-relaxed whitespace-pre-wrap break-words select-text"; + function StructuredValue({ value, highlightJson = false, @@ -74,7 +78,7 @@ function StructuredValue({ }, [highlightJson, text]); if (!text) return null; return ( -
+    
       {isJson ? (
         
           
@@ -88,14 +92,6 @@ function StructuredValue({
   );
 }
 
-function SectionLabel({ children }: { readonly children: ReactNode }) {
-  return (
-    

- {children} -

- ); -} - /** * The item behind a projected row, with the output the timeline withheld * fetched while the row is open. @@ -121,7 +117,7 @@ function useFetchedTurnItem( return { item, output: { - text: turnItemOutputText(item), + output: turnItemOutputText(item), pending: item === wireItem && detail.isPending, error: item !== wireItem @@ -134,29 +130,23 @@ function useFetchedTurnItem( }; } -/** Output the timeline withheld, fetched while the row is open. */ -function ToolOutput(props: { - readonly text: string | null; +interface ToolOutputState { + readonly output: string | null; readonly pending: boolean; readonly error: string | null; readonly empty: boolean; -}) { - const body = props.text ? ( - +} + +function ToolOutput(props: ToolOutputState) { + return props.output ? ( +
{props.output}
) : props.pending ? ( -

Loading output…

+
Loading output…
) : props.error ? ( -

Couldn't load output: {props.error}

+
Couldn't load output: {props.error}
) : props.empty ? ( -

No output.

+
No output.
) : null; - if (body === null) return null; - return ( -
- Output - {body} -
- ); } /** Fetched output for rows that show their own plain text instead of the inspector. */ @@ -166,16 +156,47 @@ export function FetchedToolOutput(props: { }) { const { output } = useFetchedTurnItem(props.projectedItem, props.environmentId); return ( -
+
); } +/** A tool call's body: the call itself in the foreground, its result muted below. */ +function ToolCallBody( + props: ToolOutputState & { + readonly command?: string; + readonly args?: unknown; + readonly exitCode?: number | undefined; + }, +) { + const call = toolCallLines({ command: props.command, args: props.args }); + return ( +
+ {call.command ?
{call.command}
: null} + {call.args ? ( +
+ {call.args.map(([key, value]) => ( +
+ {key} + {value} +
+ ))} +
+ ) : null} + {call.argsText ? : null} + + {props.exitCode !== undefined && props.exitCode !== 0 ? ( +
exit {props.exitCode}
+ ) : null} +
+ ); +} + export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspectorProps) { const fetched = useFetchedTurnItem(props.projectedItem, props.environmentId); const item = fetched.item; - const output = ; + const outputState = fetched.output; const support = useV2ItemSupport({ environmentId: props.environmentId, sourceThreadId: props.projectedItem.sourceThreadId, @@ -198,15 +219,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} {item.type === "command_execution" ? ( -
- - {output} - {item.exitCode !== undefined ? ( -

- Process exited with code {item.exitCode} -

- ) : null} -
+ ) : null} {item.type === "file_change" ? ( @@ -250,16 +263,16 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
) : null} - {item.type === "file_search" && item.pattern?.trim() && !item.results?.length ? ( - + {item.type === "file_search" && item.pattern?.trim() ? ( +
{item.pattern}
) : null} - {item.type === "web_search" && item.patterns?.length && !item.results?.length ? ( - + {item.type === "web_search" && item.patterns?.length ? ( +
{item.patterns.join("\n")}
) : null} {item.type === "file_search" && item.results?.length ? ( -
    +
      {item.results.map((result) => (
    • @@ -276,7 +289,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} {item.type === "web_search" && item.results?.length ? ( -
        +
          {item.results.map((result) => { const safeHref = resolveExternalWebLinkHref(result.url); return ( @@ -303,17 +316,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
        ) : null} - {item.type === "dynamic_tool" ? ( -
        - {formatToolValue(item.input) ? ( -
        - Input - -
        - ) : null} - {output} -
        - ) : null} + {item.type === "dynamic_tool" ? : null} {item.type === "approval_request" ? : null} {item.type === "user_input_request" ? ( @@ -368,7 +371,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} {item.type === "handoff" ? ( -
        +

        {item.fromProviderInstanceIds.join(", ")} → {item.toProviderInstanceId}

        diff --git a/apps/web/src/components/chat/WorkLog.tsx b/apps/web/src/components/chat/WorkLog.tsx index 5a0efd3550c3727895c10c4d407823073e4a1474..4358f30f4de36abf8a2ca0969ce2f81fd3ad9c7d 100644 --- a/apps/web/src/components/chat/WorkLog.tsx +++ b/apps/web/src/components/chat/WorkLog.tsx @@ -123,7 +123,7 @@ export function WorkLogDetails({ kind === "text" ? "ms-7 flex max-h-96 flex-col gap-3 overflow-auto px-0.5 py-1 select-text" : kind === "panel" - ? "mt-1 rounded-md bg-muted/40 px-3 py-2" + ? "mt-0.5 mb-1.5" : "mt-1", )} onClick={(event) => event.stopPropagation()} diff --git a/packages/client-runtime/src/work-log/itemDetail.ts b/packages/client-runtime/src/work-log/itemDetail.ts index c4c03291813ed1bbf501cb3e1d74bfc1cc86f16d..a138bed6f22c12b30ab210c7592218254bc252e9 100644 --- a/packages/client-runtime/src/work-log/itemDetail.ts +++ b/packages/client-runtime/src/work-log/itemDetail.ts @@ -61,7 +61,7 @@ function prettyJsonText(text: string): string { } /** Formats a tool input or output for display: text blocks as text, the rest as JSON. */ -export function formatToolValue(value: unknown): string | null { +function formatToolValue(value: unknown): string | null { if (value === undefined || value === null) return null; const text = textFromBlocks(value, 0); if (text !== null) return text.trim() ? prettyJsonText(text) : null; @@ -75,6 +75,48 @@ export function formatToolValue(value: unknown): string | null { return json; } +/** + * The call a tool row's body shows above its result: the full command, the + * arguments as `key value` pairs, or formatted text when they are not flat. + */ +export function toolCallLines(input: { + readonly command?: string | undefined; + readonly args?: unknown; +}): { + readonly command: string | null; + readonly args: ReadonlyArray | null; + readonly argsText: string | null; +} { + if (input.command !== undefined) { + // The row title truncates to its width, so the body always has the full command. + const command = input.command.trim(); + return { command: command || null, args: null, argsText: null }; + } + const args = input.args; + if (isRecord(args) && !isSummarizedValue(args)) { + const entries = Object.entries(args).flatMap( + ([key, value]): Array => { + if (value === undefined) return []; + // An empty string or null can be the point of a call (a clear or reset), so show it. + return [ + [ + key, + typeof value === "string" && value !== "" + ? value + : (JSON.stringify(value) ?? String(value)), + ], + ]; + }, + ); + return { command: null, args: entries.length > 0 ? entries : null, argsText: null }; + } + return { command: null, args: null, argsText: formatToolValue(args) }; +} + +function toolCallHasLines(lines: ReturnType): boolean { + return lines.command !== null || lines.args !== null || lines.argsText !== null; +} + const LIVE_TURN_ITEM_STATUSES: ReadonlySet = new Set([ "idle", "pending", @@ -126,6 +168,33 @@ export function turnItemOutputText(item: OrchestrationV2TurnItem): string | null return item.output?.trim() ? commandOutputText(item.output) || null : null; case "dynamic_tool": return item.outputOmitted === true ? null : formatToolValue(item.output); + case "file_search": + return item.results?.length + ? item.results + .map((result) => + [ + `${result.fileName}${result.line === undefined ? "" : `:${result.line}`}`, + result.preview?.trim(), + ] + .filter(Boolean) + .join("\n"), + ) + .join("\n") + : null; + case "web_search": + return item.results?.length + ? item.results + .map((result) => + [ + result.title?.trim() || result.url, + result.title ? result.url : undefined, + result.snippet?.trim(), + ] + .filter(Boolean) + .join("\n"), + ) + .join("\n\n") + : null; default: return null; } @@ -141,10 +210,10 @@ export function turnItemHasDetail(item: OrchestrationV2TurnItem): boolean { return item.text.trim().length > 0; case "command_execution": return ( - item.input.trim().length > 0 || + toolCallLines({ command: item.input }).command !== null || item.outputOmitted === true || Boolean(item.output?.trim()) || - item.exitCode !== undefined + (item.exitCode !== undefined && item.exitCode !== 0) ); case "file_change": case "checkpoint": @@ -156,7 +225,7 @@ export function turnItemHasDetail(item: OrchestrationV2TurnItem): boolean { case "web_search": return (item.results?.length ?? 0) > 0 || (item.patterns?.length ?? 0) > 0; case "dynamic_tool": - return item.outputOmitted === true || formatToolValue(item.input) !== null; + return item.outputOmitted === true || toolCallHasLines(toolCallLines({ args: item.input })); case "approval_request": return Boolean(item.prompt?.trim()); case "user_input_request": -- 2.54.0