1import type {
2 EnvironmentId,
3 OrchestrationV2ProjectedTurnItem,
4 RunId,
5 ThreadId,
6} from "@t3tools/contracts";
7import {
8 toolCallLines,
9 turnItemDetailRevision,
10 turnItemNeedsDetailFetch,
11 turnItemOutputImages,
12 turnItemOutputText,
13} from "@t3tools/client-runtime/work-log/item-detail";
14import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
15import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity";
16import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react";
17import { memo, Suspense, use, useMemo } from "react";
18
19import { useTheme } from "../../hooks/useTheme";
20import { cn } from "../../lib/utils";
21import { resolveDiffThemeName } from "../../lib/diffRendering";
22import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting";
23import { useTurnItemDetail } from "../../state/queries";
24import { useV2ItemSupport } from "../../state/v2ItemSupport";
25import { formatWorkspaceRelativePath } from "../../filePathDisplay";
26import { Button } from "../ui/button";
27import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
28import { RenderErrorBoundary } from "../RenderErrorBoundary";
29import { resolveExternalWebLinkHref } from "./externalLinkContextMenu";
30import type { ExpandedImagePreview } from "./ExpandedImagePreview";
31import { ShellCommandBlock } from "./ShellCommandBlock";
32
33interface V2ItemInspectorProps {
34 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
35 readonly environmentId: EnvironmentId;
36 readonly cwd?: string | undefined;
37 readonly workspaceRoot?: string | undefined;
38 readonly onOpenThread: (threadId: ThreadId) => void;
39 readonly onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
40 readonly onRollbackCheckpoint?: (input: {
41 readonly checkpointId: string;
42 readonly scopeId: string;
43 }) => void;
44 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
45}
46
47function SyntaxTokens({
48 text,
49 language,
50}: {
51 readonly text: string;
52 readonly language: "json" | "typescript";
53}) {
54 const { resolvedTheme } = useTheme();
55 const highlighter = use(getSyntaxHighlighterPromise(language));
56 const { tokens } = useMemo(
57 () =>
58 highlighter.codeToTokens(text, {
59 lang: language,
60 theme: resolveDiffThemeName(resolvedTheme),
61 }),
62 [highlighter, text, language, resolvedTheme],
63 );
64 return tokens.flatMap((line, lineIndex) => [
65 lineIndex > 0 ? "\n" : "",
66 ...line.map((token) => (
67 <span key={token.offset} style={{ color: token.color }}>
68 {token.content}
69 </span>
70 )),
71 ]);
72}
73
74const monoClassName =
75 "font-mono text-(length:--font-size-code,var(--text-2xs)) leading-relaxed whitespace-pre-wrap break-words select-text";
76
77function StructuredValue({
78 value,
79 highlightJson = false,
80 language,
81}: {
82 readonly value: unknown;
83 readonly highlightJson?: boolean;
84 readonly language?: "typescript";
85}) {
86 const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
87 const isJson = useMemo(() => {
88 if (!highlightJson || !text) return false;
89 try {
90 JSON.parse(text);
91 return true;
92 } catch {
93 return false;
94 }
95 }, [highlightJson, text]);
96 if (!text) return null;
97 return (
98 <pre className={cn("max-h-80 overflow-auto text-muted-foreground", monoClassName)}>
99 {language || isJson ? (
100 <RenderErrorBoundary fallback={text}>
101 <Suspense fallback={text}>
102 <SyntaxTokens text={text} language={language ?? "json"} />
103 </Suspense>
104 </RenderErrorBoundary>
105 ) : (
106 text
107 )}
108 </pre>
109 );
110}
111
112/**
113 * The item behind a projected row, with the output the timeline withheld
114 * fetched while the row is open.
115 */
116function useFetchedTurnItem(
117 projectedItem: OrchestrationV2ProjectedTurnItem,
118 environmentId: EnvironmentId,
119) {
120 const wireItem = projectedItem.item;
121 const fetches = turnItemNeedsDetailFetch(wireItem);
122 const detail = useTurnItemDetail(
123 fetches
124 ? {
125 environmentId,
126 threadId: projectedItem.sourceThreadId,
127 itemId: projectedItem.sourceItemId,
128 revision: turnItemDetailRevision(wireItem),
129 }
130 : null,
131 );
132 const fetchedItem = detail.data?.item;
133 const item = fetchedItem?.type === wireItem.type ? fetchedItem : wireItem;
134 return {
135 item,
136 output: {
137 output: turnItemOutputText(item),
138 images: turnItemOutputImages(item),
139 environmentId,
140 pending: item === wireItem && detail.isPending,
141 error:
142 item !== wireItem
143 ? null
144 : detail.data?.item === null
145 ? "Output is no longer available."
146 : detail.error,
147 empty: fetches && item !== wireItem,
148 },
149 };
150}
151
152interface ToolOutputState {
153 readonly output: string | null;
154 readonly images: ReturnType<typeof turnItemOutputImages>;
155 readonly environmentId: EnvironmentId;
156 readonly pending: boolean;
157 readonly error: string | null;
158 readonly empty: boolean;
159 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
160}
161
162function ToolOutput(props: ToolOutputState) {
163 const images = props.images.map((resource) => (
164 <ChatMarkdownAssetImage
165 key={resource.index}
166 environmentId={props.environmentId}
167 resource={resource}
168 alt="Tool output image"
169 maxHeightRem={16}
170 onImageExpand={props.onImageExpand}
171 />
172 ));
173 const text = props.output ? (
174 <div className="max-h-80 overflow-auto text-muted-foreground">{props.output}</div>
175 ) : props.pending ? (
176 <div className="text-muted-foreground italic">Loading output…</div>
177 ) : props.error ? (
178 <div className="text-destructive">Couldn&apos;t load output: {props.error}</div>
179 ) : props.empty && images.length === 0 ? (
180 <div className="text-muted-foreground italic">No output.</div>
181 ) : null;
182 return (
183 <>
184 {images}
185 {text}
186 </>
187 );
188}
189
190/** Fetched output for rows that show their own plain text instead of the inspector. */
191export function FetchedToolOutput(props: {
192 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
193 readonly environmentId: EnvironmentId;
194 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
195}) {
196 const { output } = useFetchedTurnItem(props.projectedItem, props.environmentId);
197 return (
198 <div className={cn("mt-1.5 space-y-1.5", monoClassName)}>
199 <ToolOutput {...output} onImageExpand={props.onImageExpand} />
200 </div>
201 );
202}
203
204/** A tool call's body: the call itself in the foreground, its result muted below. */
205function ToolCallBody(
206 props: ToolOutputState & {
207 readonly command?: string;
208 readonly args?: unknown;
209 readonly toolName?: string | null;
210 readonly exitCode?: number | undefined;
211 },
212) {
213 const call = toolCallLines({ command: props.command, args: props.args });
214 return (
215 <div className={cn("space-y-1.5", monoClassName)}>
216 {call.command ? <ShellCommandBlock command={call.command} /> : null}
217 {call.args ? (
218 <div className="text-foreground/85">
219 {call.args.map(([key, value]) => (
220 <div key={key}>
221 <span className="text-muted-foreground">{key} </span>
222 {key === "code" && isJavaScriptReplTool(props.toolName) ? (
223 <StructuredValue value={value} language="typescript" />
224 ) : (
225 value
226 )}
227 </div>
228 ))}
229 </div>
230 ) : null}
231 {call.argsText ? <StructuredValue value={call.argsText} highlightJson /> : null}
232 <ToolOutput {...props} />
233 {props.exitCode !== undefined && props.exitCode !== 0 ? (
234 <div className="text-destructive">exit {props.exitCode}</div>
235 ) : null}
236 </div>
237 );
238}
239
240export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspectorProps) {
241 const fetched = useFetchedTurnItem(props.projectedItem, props.environmentId);
242 const item = fetched.item;
243 const outputState = fetched.output;
244 const support = useV2ItemSupport({
245 environmentId: props.environmentId,
246 sourceThreadId: props.projectedItem.sourceThreadId,
247 sourceItemId: props.projectedItem.sourceItemId,
248 });
249 return (
250 <div className="space-y-2 text-xs" data-v2-item-inspector={item.type}>
251 {item.type === "reasoning" && item.text ? (
252 <div className="rounded-md border border-border/45 bg-muted/15 p-2 italic text-muted-foreground">
253 <ChatMarkdown
254 text={item.text}
255 cwd={props.cwd}
256 threadRef={{
257 environmentId: props.environmentId,
258 threadId: props.projectedItem.sourceThreadId,
259 }}
260 lineBreaks
261 />
262 </div>
263 ) : null}
264
265 {item.type === "command_execution" ? (
266 <ToolCallBody
267 command={item.input}
268 exitCode={item.exitCode}
269 {...outputState}
270 onImageExpand={props.onImageExpand}
271 />
272 ) : null}
273
274 {item.type === "file_change" ? (
275 <div className="space-y-2">
276 <div className="flex flex-wrap items-center gap-2">
277 <span className="font-mono text-muted-foreground">
278 {formatWorkspaceRelativePath(item.fileName, props.workspaceRoot)}
279 </span>
280 {item.additions !== undefined || item.deletions !== undefined ? (
281 <span>
282 <span className="text-success">+{formatDiffCount(item.additions ?? 0)}</span>{" "}
283 <span className="text-destructive">-{formatDiffCount(item.deletions ?? 0)}</span>
284 </span>
285 ) : null}
286 {item.runId !== null ? (
287 <Button
288 size="xs"
289 variant="outline"
290 onClick={() => props.onOpenTurnDiff(item.runId!, item.fileName)}
291 >
292 Open diff
293 </Button>
294 ) : null}
295 </div>
296 {item.status === "failed" && item.diffStr?.trim() ? (
297 <StructuredValue value={item.diffStr} />
298 ) : null}
299 {item.changes !== undefined && item.changes.length > 0 ? (
300 <ul className="space-y-1 font-mono text-muted-foreground">
301 {item.changes.map((change, index) => (
302 <li key={`${change.operation}:${change.path}:${index}`}>
303 {change.operation} {change.oldPath ? `${change.oldPath} → ` : ""}
304 {formatWorkspaceRelativePath(change.path, props.workspaceRoot)}
305 {change.fileType || change.mimeType
306 ? ` (${[change.fileType, change.mimeType].filter(Boolean).join(", ")})`
307 : ""}
308 </li>
309 ))}
310 </ul>
311 ) : null}
312 </div>
313 ) : null}
314
315 {item.type === "file_search" && item.pattern?.trim() ? (
316 <div className={cn("text-foreground/85", monoClassName)}>{item.pattern}</div>
317 ) : null}
318
319 {item.type === "web_search" && item.patterns?.length ? (
320 <div className={cn("text-foreground/85", monoClassName)}>{item.patterns.join("\n")}</div>
321 ) : null}
322
323 {item.type === "file_search" && item.results?.length ? (
324 <ul className="space-y-1">
325 {item.results.map((result) => (
326 <li key={JSON.stringify(result)}>
327 <span className="font-mono text-foreground/80">
328 {formatWorkspaceRelativePath(result.fileName, props.workspaceRoot)}
329 {result.line === undefined ? "" : `:${result.line}`}
330 {result.column === undefined ? "" : `:${result.column}`}
331 </span>
332 {result.preview ? (
333 <p className="mt-0.5 whitespace-pre-wrap text-muted-foreground">{result.preview}</p>
334 ) : null}
335 </li>
336 ))}
337 </ul>
338 ) : null}
339
340 {item.type === "web_search" && item.results?.length ? (
341 <ul className="space-y-1.5">
342 {item.results.map((result) => {
343 const safeHref = resolveExternalWebLinkHref(result.url);
344 return (
345 <li key={JSON.stringify(result)}>
346 {safeHref ? (
347 <a
348 href={safeHref}
349 target="_blank"
350 rel="noreferrer"
351 className="inline-flex items-center gap-1 font-medium text-foreground hover:underline"
352 >
353 {result.title ?? result.url}
354 <ExternalLinkIcon className="size-3" />
355 </a>
356 ) : (
357 <p className="font-medium text-foreground">
358 {result.title ?? result.url ?? "Search result"}
359 </p>
360 )}
361 {result.snippet ? <p className="text-muted-foreground">{result.snippet}</p> : null}
362 </li>
363 );
364 })}
365 </ul>
366 ) : null}
367
368 {item.type === "dynamic_tool" ? (
369 <ToolCallBody
370 toolName={item.toolName}
371 args={item.input}
372 {...outputState}
373 onImageExpand={props.onImageExpand}
374 />
375 ) : null}
376
377 {item.type === "approval_request" ? <StructuredValue value={item.prompt} /> : null}
378 {item.type === "user_input_request" ? (
379 <StructuredValue value={item.questions.map((question) => question.question).join("\n\n")} />
380 ) : null}
381 {item.type === "notification" ? <StructuredValue value={item.detail} /> : null}
382 {item.type === "system_notice" ? <StructuredValue value={item.message} /> : null}
383 {item.type === "error" ? <StructuredValue value={item.failure.message} /> : null}
384 {item.type === "proposed_plan" ? <StructuredValue value={item.markdown} /> : null}
385 {item.type === "todo_list" ? (
386 <StructuredValue
387 value={item.steps
388 .map((step) => `${step.status === "completed" ? "✓" : "○"} ${step.text}`)
389 .join("\n")}
390 />
391 ) : null}
392
393 {item.type === "checkpoint" ? (
394 <div className="flex flex-wrap items-center gap-2">
395 <span className="text-muted-foreground">
396 {support.checkpoint?.status ?? item.status} · {item.files.length} files
397 </span>
398 {props.onRollbackCheckpoint && support.checkpoint?.status === "ready" ? (
399 <Button
400 size="xs"
401 variant="outline"
402 onClick={() =>
403 props.onRollbackCheckpoint?.({
404 checkpointId: item.checkpointId,
405 scopeId: item.scopeId,
406 })
407 }
408 >
409 <RotateCcwIcon className="size-3" />
410 Roll back
411 </Button>
412 ) : null}
413 </div>
414 ) : null}
415
416 {item.type === "fork" ? (
417 <Button size="xs" variant="outline" onClick={() => props.onOpenThread(item.targetThreadId)}>
418 <GitBranchIcon className="size-3" />
419 Open fork
420 </Button>
421 ) : null}
422
423 {item.type === "subagent" && item.childThreadId !== null ? (
424 <Button size="xs" variant="outline" onClick={() => props.onOpenThread(item.childThreadId!)}>
425 Open subagent thread
426 </Button>
427 ) : null}
428
429 {item.type === "handoff" ? (
430 <div className="space-y-1 text-muted-foreground">
431 <p>
432 {item.fromProviderInstanceIds.join(", ")} → {item.toProviderInstanceId}
433 </p>
434 <p>
435 {item.strategy.replaceAll("_", " ")} · {support.contextHandoff?.status ?? item.status}
436 </p>
437 {support.contextTransfer ? (
438 <p>
439 Transfer {support.contextTransfer.type.replaceAll("_", " ")} ·{" "}
440 {support.contextTransfer.status}
441 </p>
442 ) : null}
443 </div>
444 ) : null}
445 </div>
446 );
447});