Skip to content
Source
Sign inSign up
Open History to see the last commit

V2ItemInspector.tsx

416 lines15.0 KB

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