authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 09:06:24-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 12:06:24+00:00
logdf4ae529eacfff8db38f2f4b9b767d3717446c8a
treebde280da1966d73d87694779367dca7639f13155
parent1d013c7ca1edc843bd3b5e23fd6573aeeaf9b11d
signature Signed by PGP key B5690EEEBB952194

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>

4 files changed, 191 insertions(+), 76 deletions(-)

apps/mobile/src/features/threads/thread-work-log.tsx+57-14
......@@ -64,7 +64,7 @@ import {
6464 type ThreadFeedActivity,
6565 workEntryRowLabel,
6666} from "../../lib/threadActivity";
67import { turnItemOutputText } from "@t3tools/client-runtime/work-log/item-detail";
67import { toolCallLines, turnItemOutputText } from "@t3tools/client-runtime/work-log/item-detail";
6868import { useTurnItemDetail } from "../../state/queries";
6969import {
7070 resolveThreadWorkGroupInitialScroll,
......@@ -929,23 +929,41 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
929929 const fetchedItem = fetchedDetail.data?.item ?? null;
930930 // Reads keep their path list; the fetched file contents show as output.
931931 const isRead = toolGroupAction(row.workEntry) === "read";
932 // Tool calls show the call in the foreground and the result muted below it.
933 const shownItem = fetchedItem ?? row.projectedItem.item;
934 const call =
935 expanded && !isRead && shownItem.type === "command_execution"
936 ? toolCallLines({ command: shownItem.input })
937 : expanded && !isRead && shownItem.type === "dynamic_tool"
938 ? toolCallLines({ args: shownItem.input })
939 : expanded && shownItem.type === "file_search"
940 ? toolCallLines({ args: { pattern: shownItem.pattern } })
941 : expanded && shownItem.type === "web_search"
942 ? toolCallLines({ args: { query: shownItem.patterns?.join(", ") } })
943 : null;
944 const failedExitCode =
945 call && shownItem.type === "command_execution" && shownItem.exitCode
946 ? shownItem.exitCode
947 : null;
932948 const fullDetail =
933 expanded && !reasoning
949 expanded && !reasoning && !call
934950 ? fetchedItem && !isRead
935951 ? formatItemFullDetail(row.projectedItem, fetchedItem)
936952 : row.getFullDetail()
937953 : null;
938954 const fetchedOutput = !expanded
939955 ? null
940 : fetchedItem
941 ? (turnItemOutputText(fetchedItem) ?? "No output.")
942 : fetchedDetail.error
943 ? `Couldn't load output: ${fetchedDetail.error}`
944 : row.fetchesDetail
945 ? fetchedDetail.data
946 ? "Output is no longer available."
947 : "Loading output…"
948 : null;
956 : shownItem.type === "file_search" || shownItem.type === "web_search"
957 ? turnItemOutputText(shownItem)
958 : fetchedItem
959 ? (turnItemOutputText(fetchedItem) ?? "No output.")
960 : fetchedDetail.error
961 ? `Couldn't load output: ${fetchedDetail.error}`
962 : row.fetchesDetail
963 ? fetchedDetail.data
964 ? "Output is no longer available."
965 : "Loading output…"
966 : null;
949967 const viewedImagePath = workEntryViewedImagePath(row.workEntry);
950968 const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);
951969 const previewText = workEntryRowLabel(row.workEntry);
......@@ -1091,6 +1109,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
10911109 {expanded &&
10921110 (reasoning ||
10931111 fullDetail ||
1112 call ||
10941113 fetchedOutput ||
10951114 viewedImagePath ||
10961115 row.workEntry.questionAnswer) ? (
......@@ -1120,19 +1139,43 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
11201139 >
11211140 {reasoning ? (
11221141 props.renderReasoning(reasoning.text)
1123 ) : (
1142 ) : call ? (
1143 [
1144 call.command,
1145 ...(call.args ?? []).map(([key, value]) => `${key} ${value}`),
1146 call.argsText,
1147 ]
1148 .filter((line): line is string => Boolean(line))
1149 .map((line, index) => (
1150 <Text
1151 key={`${index}:${line}`}
1152 selectable
1153 className="font-mono text-2xs leading-normal text-foreground"
1154 >
1155 {line}
1156 </Text>
1157 ))
1158 ) : fullDetail ? (
11241159 <Text selectable className="font-mono text-2xs leading-normal text-foreground-muted">
11251160 {fullDetail}
11261161 </Text>
1127 )}
1162 ) : null}
11281163 {fetchedOutput ? (
11291164 <Text
11301165 selectable
1131 className="mt-1.5 font-mono text-2xs leading-normal text-foreground-muted"
1166 className={cn(
1167 "font-mono text-2xs leading-normal text-foreground-muted",
1168 (!call || call.command || call.args || call.argsText) && "mt-1.5",
1169 )}
11321170 >
11331171 {fetchedOutput}
11341172 </Text>
11351173 ) : null}
1174 {failedExitCode !== null ? (
1175 <Text className="mt-1.5 font-mono text-2xs leading-normal text-danger-foreground">
1176 exit {failedExitCode}
1177 </Text>
1178 ) : null}
11361179 </ScrollView>
11371180 </Animated.View>
11381181 ) : null}
apps/web/src/components/chat/V2ItemInspector.tsx+60-57
......@@ -5,15 +5,16 @@ import type {
55 ThreadId,
66} from "@t3tools/contracts";
77import {
8 formatToolValue,
8 toolCallLines,
99 turnItemDetailRevision,
1010 turnItemNeedsDetailFetch,
1111 turnItemOutputText,
1212} from "@t3tools/client-runtime/work-log/item-detail";
1313import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react";
14import { memo, type ReactNode, Suspense, use, useMemo } from "react";
14import { memo, Suspense, use, useMemo } from "react";
1515
1616import { useTheme } from "../../hooks/useTheme";
17import { cn } from "../../lib/utils";
1718import { resolveDiffThemeName } from "../../lib/diffRendering";
1819import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting";
1920import { useTurnItemDetail } from "../../state/queries";
......@@ -55,6 +56,9 @@ function JsonTokens({ text }: { readonly text: string }) {
5556 ]);
5657}
5758
59const monoClassName =
60 "font-mono text-(length:--font-size-code,var(--text-2xs)) leading-relaxed whitespace-pre-wrap break-words select-text";
61
5862function StructuredValue({
5963 value,
6064 highlightJson = false,
......@@ -74,7 +78,7 @@ function StructuredValue({
7478 }, [highlightJson, text]);
7579 if (!text) return null;
7680 return (
77 <pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border/50 bg-background/60 p-2 font-mono text-2xs leading-relaxed text-muted-foreground select-text">
81 <pre className={cn("max-h-80 overflow-auto text-muted-foreground", monoClassName)}>
7882 {isJson ? (
7983 <RenderErrorBoundary fallback={text}>
8084 <Suspense fallback={text}>
......@@ -88,14 +92,6 @@ function StructuredValue({
8892 );
8993}
9094
91function SectionLabel({ children }: { readonly children: ReactNode }) {
92 return (
93 <p className="mb-1 text-3xs font-medium tracking-wide uppercase text-muted-foreground">
94 {children}
95 </p>
96 );
97}
98
9995/**
10096 * The item behind a projected row, with the output the timeline withheld
10197 * fetched while the row is open.
......@@ -121,7 +117,7 @@ function useFetchedTurnItem(
121117 return {
122118 item,
123119 output: {
124 text: turnItemOutputText(item),
120 output: turnItemOutputText(item),
125121 pending: item === wireItem && detail.isPending,
126122 error:
127123 item !== wireItem
......@@ -134,29 +130,23 @@ function useFetchedTurnItem(
134130 };
135131}
136132
137/** Output the timeline withheld, fetched while the row is open. */
138function ToolOutput(props: {
139 readonly text: string | null;
133interface ToolOutputState {
134 readonly output: string | null;
140135 readonly pending: boolean;
141136 readonly error: string | null;
142137 readonly empty: boolean;
143}) {
144 const body = props.text ? (
145 <StructuredValue value={props.text} />
138}
139
140function ToolOutput(props: ToolOutputState) {
141 return props.output ? (
142 <div className="max-h-80 overflow-auto text-muted-foreground">{props.output}</div>
146143 ) : props.pending ? (
147 <p className="text-muted-foreground">Loading output…</p>
144 <div className="text-muted-foreground italic">Loading output…</div>
148145 ) : props.error ? (
149 <p className="text-destructive">Couldn&apos;t load output: {props.error}</p>
146 <div className="text-destructive">Couldn&apos;t load output: {props.error}</div>
150147 ) : props.empty ? (
151 <p className="text-muted-foreground">No output.</p>
148 <div className="text-muted-foreground italic">No output.</div>
152149 ) : null;
153 if (body === null) return null;
154 return (
155 <div>
156 <SectionLabel>Output</SectionLabel>
157 {body}
158 </div>
159 );
160150}
161151
162152/** Fetched output for rows that show their own plain text instead of the inspector. */
......@@ -166,16 +156,47 @@ export function FetchedToolOutput(props: {
166156}) {
167157 const { output } = useFetchedTurnItem(props.projectedItem, props.environmentId);
168158 return (
169 <div className="mt-2 text-xs">
159 <div className={cn("mt-1.5", monoClassName)}>
170160 <ToolOutput {...output} />
171161 </div>
172162 );
173163}
174164
165/** A tool call's body: the call itself in the foreground, its result muted below. */
166function ToolCallBody(
167 props: ToolOutputState & {
168 readonly command?: string;
169 readonly args?: unknown;
170 readonly exitCode?: number | undefined;
171 },
172) {
173 const call = toolCallLines({ command: props.command, args: props.args });
174 return (
175 <div className={cn("space-y-1.5", monoClassName)}>
176 {call.command ? <div className="text-foreground/85">{call.command}</div> : null}
177 {call.args ? (
178 <div className="text-foreground/85">
179 {call.args.map(([key, value]) => (
180 <div key={key}>
181 <span className="text-muted-foreground">{key} </span>
182 {value}
183 </div>
184 ))}
185 </div>
186 ) : null}
187 {call.argsText ? <StructuredValue value={call.argsText} highlightJson /> : null}
188 <ToolOutput {...props} />
189 {props.exitCode !== undefined && props.exitCode !== 0 ? (
190 <div className="text-destructive">exit {props.exitCode}</div>
191 ) : null}
192 </div>
193 );
194}
195
175196export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspectorProps) {
176197 const fetched = useFetchedTurnItem(props.projectedItem, props.environmentId);
177198 const item = fetched.item;
178 const output = <ToolOutput {...fetched.output} />;
199 const outputState = fetched.output;
179200 const support = useV2ItemSupport({
180201 environmentId: props.environmentId,
181202 sourceThreadId: props.projectedItem.sourceThreadId,
......@@ -198,15 +219,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
198219 ) : null}
199220
200221 {item.type === "command_execution" ? (
201 <div className="space-y-2">
202 <StructuredValue value={item.input} highlightJson />
203 {output}
204 {item.exitCode !== undefined ? (
205 <p className={item.exitCode === 0 ? "text-success" : "text-destructive"}>
206 Process exited with code {item.exitCode}
207 </p>
208 ) : null}
209 </div>
222 <ToolCallBody command={item.input} exitCode={item.exitCode} {...outputState} />
210223 ) : null}
211224
212225 {item.type === "file_change" ? (
......@@ -250,16 +263,16 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
250263 </div>
251264 ) : null}
252265
253 {item.type === "file_search" && item.pattern?.trim() && !item.results?.length ? (
254 <StructuredValue value={item.pattern} />
266 {item.type === "file_search" && item.pattern?.trim() ? (
267 <div className={cn("text-foreground/85", monoClassName)}>{item.pattern}</div>
255268 ) : null}
256269
257 {item.type === "web_search" && item.patterns?.length && !item.results?.length ? (
258 <StructuredValue value={item.patterns.join("\n")} />
270 {item.type === "web_search" && item.patterns?.length ? (
271 <div className={cn("text-foreground/85", monoClassName)}>{item.patterns.join("\n")}</div>
259272 ) : null}
260273
261274 {item.type === "file_search" && item.results?.length ? (
262 <ul className="space-y-1 rounded-md border border-border/45 p-2">
275 <ul className="space-y-1">
263276 {item.results.map((result) => (
264277 <li key={JSON.stringify(result)}>
265278 <span className="font-mono text-foreground/80">
......@@ -276,7 +289,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
276289 ) : null}
277290
278291 {item.type === "web_search" && item.results?.length ? (
279 <ul className="space-y-1.5 rounded-md border border-border/45 p-2">
292 <ul className="space-y-1.5">
280293 {item.results.map((result) => {
281294 const safeHref = resolveExternalWebLinkHref(result.url);
282295 return (
......@@ -303,17 +316,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
303316 </ul>
304317 ) : null}
305318
306 {item.type === "dynamic_tool" ? (
307 <div className="space-y-2">
308 {formatToolValue(item.input) ? (
309 <div>
310 <SectionLabel>Input</SectionLabel>
311 <StructuredValue value={formatToolValue(item.input)} highlightJson />
312 </div>
313 ) : null}
314 {output}
315 </div>
316 ) : null}
319 {item.type === "dynamic_tool" ? <ToolCallBody args={item.input} {...outputState} /> : null}
317320
318321 {item.type === "approval_request" ? <StructuredValue value={item.prompt} /> : null}
319322 {item.type === "user_input_request" ? (
......@@ -368,7 +371,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
368371 ) : null}
369372
370373 {item.type === "handoff" ? (
371 <div className="space-y-1 rounded-md border border-border/45 p-2 text-muted-foreground">
374 <div className="space-y-1 text-muted-foreground">
372375 <p>
373376 {item.fromProviderInstanceIds.join(", ")} → {item.toProviderInstanceId}
374377 </p>
apps/web/src/components/chat/WorkLog.tsx+1-1
......@@ -123,7 +123,7 @@ export function WorkLogDetails({
123123 kind === "text"
124124 ? "ms-7 flex max-h-96 flex-col gap-3 overflow-auto px-0.5 py-1 select-text"
125125 : kind === "panel"
126 ? "mt-1 rounded-md bg-muted/40 px-3 py-2"
126 ? "mt-0.5 mb-1.5"
127127 : "mt-1",
128128 )}
129129 onClick={(event) => event.stopPropagation()}
packages/client-runtime/src/work-log/itemDetail.ts+73-4
......@@ -61,7 +61,7 @@ function prettyJsonText(text: string): string {
6161}
6262
6363/** Formats a tool input or output for display: text blocks as text, the rest as JSON. */
64export function formatToolValue(value: unknown): string | null {
64function formatToolValue(value: unknown): string | null {
6565 if (value === undefined || value === null) return null;
6666 const text = textFromBlocks(value, 0);
6767 if (text !== null) return text.trim() ? prettyJsonText(text) : null;
......@@ -75,6 +75,48 @@ export function formatToolValue(value: unknown): string | null {
7575 return json;
7676}
7777
78/**
79 * The call a tool row's body shows above its result: the full command, the
80 * arguments as `key value` pairs, or formatted text when they are not flat.
81 */
82export function toolCallLines(input: {
83 readonly command?: string | undefined;
84 readonly args?: unknown;
85}): {
86 readonly command: string | null;
87 readonly args: ReadonlyArray<readonly [string, string]> | null;
88 readonly argsText: string | null;
89} {
90 if (input.command !== undefined) {
91 // The row title truncates to its width, so the body always has the full command.
92 const command = input.command.trim();
93 return { command: command || null, args: null, argsText: null };
94 }
95 const args = input.args;
96 if (isRecord(args) && !isSummarizedValue(args)) {
97 const entries = Object.entries(args).flatMap(
98 ([key, value]): Array<readonly [string, string]> => {
99 if (value === undefined) return [];
100 // An empty string or null can be the point of a call (a clear or reset), so show it.
101 return [
102 [
103 key,
104 typeof value === "string" && value !== ""
105 ? value
106 : (JSON.stringify(value) ?? String(value)),
107 ],
108 ];
109 },
110 );
111 return { command: null, args: entries.length > 0 ? entries : null, argsText: null };
112 }
113 return { command: null, args: null, argsText: formatToolValue(args) };
114}
115
116function toolCallHasLines(lines: ReturnType<typeof toolCallLines>): boolean {
117 return lines.command !== null || lines.args !== null || lines.argsText !== null;
118}
119
78120const LIVE_TURN_ITEM_STATUSES: ReadonlySet<OrchestrationV2TurnItem["status"]> = new Set([
79121 "idle",
80122 "pending",
......@@ -126,6 +168,33 @@ export function turnItemOutputText(item: OrchestrationV2TurnItem): string | null
126168 return item.output?.trim() ? commandOutputText(item.output) || null : null;
127169 case "dynamic_tool":
128170 return item.outputOmitted === true ? null : formatToolValue(item.output);
171 case "file_search":
172 return item.results?.length
173 ? item.results
174 .map((result) =>
175 [
176 `${result.fileName}${result.line === undefined ? "" : `:${result.line}`}`,
177 result.preview?.trim(),
178 ]
179 .filter(Boolean)
180 .join("\n"),
181 )
182 .join("\n")
183 : null;
184 case "web_search":
185 return item.results?.length
186 ? item.results
187 .map((result) =>
188 [
189 result.title?.trim() || result.url,
190 result.title ? result.url : undefined,
191 result.snippet?.trim(),
192 ]
193 .filter(Boolean)
194 .join("\n"),
195 )
196 .join("\n\n")
197 : null;
129198 default:
130199 return null;
131200 }
......@@ -141,10 +210,10 @@ export function turnItemHasDetail(item: OrchestrationV2TurnItem): boolean {
141210 return item.text.trim().length > 0;
142211 case "command_execution":
143212 return (
144 item.input.trim().length > 0 ||
213 toolCallLines({ command: item.input }).command !== null ||
145214 item.outputOmitted === true ||
146215 Boolean(item.output?.trim()) ||
147 item.exitCode !== undefined
216 (item.exitCode !== undefined && item.exitCode !== 0)
148217 );
149218 case "file_change":
150219 case "checkpoint":
......@@ -156,7 +225,7 @@ export function turnItemHasDetail(item: OrchestrationV2TurnItem): boolean {
156225 case "web_search":
157226 return (item.results?.length ?? 0) > 0 || (item.patterns?.length ?? 0) > 0;
158227 case "dynamic_tool":
159 return item.outputOmitted === true || formatToolValue(item.input) !== null;
228 return item.outputOmitted === true || toolCallHasLines(toolCallLines({ args: item.input }));
160229 case "approval_request":
161230 return Boolean(item.prompt?.trim());
162231 case "user_input_request":