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) => (
+
+
{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":