@opengeni/react 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/index.d.ts +1035 -14
  2. package/dist/index.js +6867 -1884
  3. package/dist/index.js.map +1 -1
  4. package/package.json +65 -2
  5. package/src/client.ts +21 -0
  6. package/src/components/code-editor.tsx +398 -0
  7. package/src/components/desktop-viewer.tsx +647 -0
  8. package/src/components/diff-view.tsx +230 -0
  9. package/src/components/file-browser.tsx +838 -0
  10. package/src/components/message-timeline.tsx +70 -196
  11. package/src/components/pierre-diff.tsx +140 -0
  12. package/src/components/pierre-file.tsx +142 -0
  13. package/src/components/sandbox-files.tsx +509 -0
  14. package/src/components/sandbox-terminal.tsx +425 -0
  15. package/src/components/workspace-dock.tsx +247 -0
  16. package/src/hooks/use-desktop-stream.ts +214 -0
  17. package/src/hooks/use-sandbox-files.ts +670 -0
  18. package/src/hooks/use-sandbox-git.ts +105 -0
  19. package/src/hooks/use-sandbox-terminal.ts +226 -0
  20. package/src/hooks/use-session-capabilities.ts +415 -0
  21. package/src/hooks/use-terminal-stream.ts +207 -0
  22. package/src/index.ts +111 -2
  23. package/src/lib/cn.ts +20 -1
  24. package/src/lib/git-patch.ts +37 -0
  25. package/src/lib/use-theme-type.ts +40 -0
  26. package/src/lib/xterm-theme.ts +34 -0
  27. package/src/timeline/activity-rail.tsx +207 -0
  28. package/src/timeline/disclosure-context.tsx +34 -0
  29. package/src/timeline/index.ts +85 -0
  30. package/src/timeline/parsers.ts +248 -0
  31. package/src/{timeline.ts → timeline/projection.ts} +59 -134
  32. package/src/timeline/registry.ts +96 -0
  33. package/src/timeline/screenshot-lightbox.tsx +152 -0
  34. package/src/timeline/shared.tsx +481 -0
  35. package/src/timeline/tool-diff.tsx +91 -0
  36. package/src/timeline/tool-renderers.tsx +882 -0
  37. package/src/timeline/turn-summary.tsx +125 -0
  38. package/src/timeline/types.ts +131 -0
  39. package/src/types/external.d.ts +7 -0
  40. package/styles/index.css +72 -0
@@ -0,0 +1,882 @@
1
+ import type { GitFileDiff } from "@opengeni/sdk";
2
+ import {
3
+ CameraIcon,
4
+ CameraOffIcon,
5
+ FileDiffIcon,
6
+ GlobeIcon,
7
+ ImageIcon,
8
+ KeyboardIcon,
9
+ KeyRoundIcon,
10
+ LockIcon,
11
+ MousePointer2Icon,
12
+ PlugIcon,
13
+ SearchIcon,
14
+ TerminalIcon,
15
+ WrenchIcon,
16
+ } from "lucide-react";
17
+ import type { ReactNode } from "react";
18
+ import { stringifyPayload } from "../lib/format";
19
+ import {
20
+ applyPatchOps,
21
+ controlCaret,
22
+ execTruncated,
23
+ isExecSessionLostBanner,
24
+ looksBinary,
25
+ parseExecBannerSessionId,
26
+ parseToolArgs,
27
+ redactSecrets,
28
+ sandboxCommandExitCode,
29
+ stripExecBanner,
30
+ tailPeek,
31
+ unwrapMcpOutput,
32
+ v4aToGitFileDiff,
33
+ type ApplyPatchOperation,
34
+ } from "./parsers";
35
+ import { createToolRegistry, type ToolRegistry, type ToolRegistryEntry, type ToolRendererProps } from "./registry";
36
+ import {
37
+ BodyNote,
38
+ MediaEmpty,
39
+ MediaSkeleton,
40
+ PayloadBlock,
41
+ ScreenshotFigure,
42
+ TermBlock,
43
+ Thumbnail,
44
+ ActivityDisclosure,
45
+ type DisclosureChip,
46
+ } from "./shared";
47
+ import { RawPatch, ToolDiff } from "./tool-diff";
48
+ import { toolDisplayName } from "./projection";
49
+
50
+ /* ----------------------------------------------------------------------------
51
+ Per-tool renderers
52
+
53
+ Each renderer takes one projected `ToolCallItem` and returns an `ActivityDisclosure`
54
+ tuned for that tool's real wire shape. The defaults below populate the
55
+ registry; the mapping is registered at the bottom of the file.
56
+
57
+ Restraint is the rule: compact title + one quiet preview, secondary detail
58
+ only on expand. No loud right-side badges — at most a single settle chip.
59
+ -------------------------------------------------------------------------- */
60
+
61
+ const ICON_SIZE = "size-3.5";
62
+
63
+ /**
64
+ * The single in-flight locus for a running row: a pulse dot immediately left of
65
+ * the status word, riding the preview line — NOT a detached gutter badge. The
66
+ * title already shimmers; this keeps the live signal in one place the eye reads
67
+ * left-to-right.
68
+ */
69
+ function RunningPreview({ children }: { children: ReactNode }) {
70
+ return (
71
+ <span className="inline-flex items-center gap-1.5">
72
+ <span className="size-1.5 shrink-0 animate-og-pulse rounded-full bg-og-status-running" />
73
+ <span className="min-w-0 truncate">{children}</span>
74
+ </span>
75
+ );
76
+ }
77
+
78
+ /* ---- exec_command ---------------------------------------------------------- */
79
+
80
+ function ExecRenderer({ item }: ToolRendererProps) {
81
+ const args = parseToolArgs(item.arguments);
82
+ const cmd = typeof args.cmd === "string" ? args.cmd : "";
83
+ const workdir = typeof args.workdir === "string" ? args.workdir : null;
84
+ const running = item.status === "running";
85
+ const out = item.output;
86
+ const title = `$ ${cmd}`;
87
+
88
+ // No output event ever arrived (item.output stays undefined from creation):
89
+ // the turn failed before the output insert — most likely a NUL byte in the
90
+ // command output prevented storage. Surface the specific explanation.
91
+ // (Cancelled items bypass this: a cancellation is not a NUL-storage failure.)
92
+ if (item.status === "failed" && out === undefined) {
93
+ return (
94
+ <ActivityDisclosure
95
+ icon={<TerminalIcon className={ICON_SIZE} />}
96
+ iconTone="failed"
97
+ title={title}
98
+ titleMono
99
+ chip={{ tone: "bad", text: "failed" }}
100
+ preview="output lost — NUL byte could not be stored"
101
+ >
102
+ <BodyNote tone="error">
103
+ output contained a NUL byte and could not be stored; the turn failed on this tool&apos;s output insert — no output
104
+ event ever arrived.
105
+ </BodyNote>
106
+ </ActivityDisclosure>
107
+ );
108
+ }
109
+
110
+ // An output event arrived but the tool still failed (error:true / MCP isError)
111
+ // and the output is empty — show a generic failure rather than claiming NUL.
112
+ // (Cancelled items bypass this: a cancellation is not a tool-call failure.)
113
+ if (item.status === "failed" && (out == null || out === "")) {
114
+ return (
115
+ <ActivityDisclosure
116
+ icon={<TerminalIcon className={ICON_SIZE} />}
117
+ iconTone="failed"
118
+ title={title}
119
+ titleMono
120
+ chip={{ tone: "bad", text: "failed" }}
121
+ preview="tool call failed"
122
+ >
123
+ <BodyNote tone="error">the tool call failed with no output.</BodyNote>
124
+ </ActivityDisclosure>
125
+ );
126
+ }
127
+
128
+ if (running) {
129
+ const streamed = typeof out === "string" ? stripExecBanner(out) : "";
130
+ return (
131
+ <ActivityDisclosure
132
+ icon={<TerminalIcon className={ICON_SIZE} />}
133
+ iconTone="running"
134
+ title={title}
135
+ titleMono
136
+ running
137
+ preview={<RunningPreview>{streamed ? `${streamed.split("\n").length} lines` : "running…"}</RunningPreview>}
138
+ >
139
+ {/* The row title is already `$ ${cmd}`; the TermBlock header drops the
140
+ command (command={null}) so it never repeats above the output. */}
141
+ <TermBlock command={null} workdir={workdir} output={streamed} live />
142
+ </ActivityDisclosure>
143
+ );
144
+ }
145
+
146
+ const text = typeof out === "string" ? out : stringifyPayload(out);
147
+ const stripped = stripExecBanner(text);
148
+ const bgSession = parseExecBannerSessionId(text);
149
+ const exitCode = sandboxCommandExitCode(text);
150
+ const binary = looksBinary(stripped);
151
+
152
+ // Color is spent on the exception only: a clean exit (0) earns NO chip — the
153
+ // absence of a red token is the success signal. Background sessions surface a
154
+ // muted id; a non-zero exit is the one red token.
155
+ let chip: DisclosureChip | undefined;
156
+ let iconTone: "accent" | "failed" | "muted" = "muted";
157
+ if (bgSession != null) {
158
+ chip = { tone: "muted", text: `session ${bgSession}` };
159
+ } else if (exitCode != null && exitCode !== 0) {
160
+ chip = { tone: "bad", text: `exit ${exitCode}` };
161
+ iconTone = "failed";
162
+ }
163
+
164
+ const preview = binary ? "binary output" : tailPeek(stripped) || "(no output)";
165
+ const truncated = execTruncated(text);
166
+ // Hand TermBlock the FULL stripped output; it owns the tail/show-more slicing.
167
+ const body = binary ? "(binary output suppressed)" : stripped;
168
+
169
+ return (
170
+ <ActivityDisclosure
171
+ icon={<TerminalIcon className={ICON_SIZE} />}
172
+ iconTone={iconTone}
173
+ title={title}
174
+ titleMono
175
+ {...(chip ? { chip } : {})}
176
+ failed={item.status === "failed"}
177
+ cancelled={item.status === "cancelled"}
178
+ preview={truncated ? `⋯ truncated · ${preview}` : preview}
179
+ >
180
+ <TermBlock command={null} workdir={workdir} output={body} />
181
+ {bgSession != null ? (
182
+ <BodyNote>↳ session {bgSession} — a later write_stdin can target this PTY.</BodyNote>
183
+ ) : null}
184
+ </ActivityDisclosure>
185
+ );
186
+ }
187
+
188
+ /* ---- write_stdin ----------------------------------------------------------- */
189
+
190
+ function WriteStdinRenderer({ item }: ToolRendererProps) {
191
+ const args = parseToolArgs(item.arguments);
192
+ const sessionId = typeof args.session_id === "string" || typeof args.session_id === "number" ? args.session_id : undefined;
193
+ const running = item.status === "running";
194
+ const text = typeof item.output === "string" ? item.output : stringifyPayload(item.output);
195
+ const lost = isExecSessionLostBanner(text);
196
+ const keys = controlCaret(typeof args.chars === "string" ? args.chars : "");
197
+ const exitCode = sandboxCommandExitCode(text);
198
+ const stripped = stripExecBanner(text);
199
+
200
+ if (running) {
201
+ return (
202
+ <ActivityDisclosure
203
+ icon={<KeyboardIcon className={ICON_SIZE} />}
204
+ iconTone="running"
205
+ title={`session ${sessionId} ← ${keys || "∅"}`}
206
+ titleMono
207
+ running
208
+ preview={<RunningPreview>sending…</RunningPreview>}
209
+ >
210
+ <BodyNote>sending input to session {sessionId}…</BodyNote>
211
+ </ActivityDisclosure>
212
+ );
213
+ }
214
+
215
+ // Success (exit 0 or a quiet ack) earns no chip; only a lost PTY / non-zero
216
+ // exit gets the one red token.
217
+ let chip: DisclosureChip | undefined;
218
+ if (lost) {
219
+ chip = { tone: "bad", text: "lost" };
220
+ } else if (exitCode != null && exitCode !== 0) {
221
+ chip = { tone: "bad", text: `exit ${exitCode}` };
222
+ }
223
+
224
+ return (
225
+ <ActivityDisclosure
226
+ icon={<KeyboardIcon className={ICON_SIZE} />}
227
+ iconTone={lost ? "failed" : "muted"}
228
+ title={`session ${sessionId} ← ${keys || "∅"}`}
229
+ titleMono
230
+ {...(chip ? { chip } : {})}
231
+ failed={item.status === "failed"}
232
+ cancelled={item.status === "cancelled"}
233
+ preview={lost ? `session ${sessionId} PTY vanished` : tailPeek(stripped) || "sent"}
234
+ >
235
+ {lost ? (
236
+ <BodyNote tone="error">{stripped || text}</BodyNote>
237
+ ) : (
238
+ <TermBlock command={`write_stdin → session ${sessionId}`} output={stripped} />
239
+ )}
240
+ </ActivityDisclosure>
241
+ );
242
+ }
243
+
244
+ /* ---- apply_patch ----------------------------------------------------------- */
245
+
246
+ function verbForOp(op: ApplyPatchOperation | undefined): string {
247
+ if (!op) {
248
+ return "Edited";
249
+ }
250
+ return op.type === "create_file" ? "Created" : op.type === "delete_file" ? "Deleted" : op.moveTo ? "Renamed" : "Edited";
251
+ }
252
+
253
+ function basename(path: string): string {
254
+ const parts = path.split("/").filter(Boolean);
255
+ return parts.length ? parts[parts.length - 1]! : path;
256
+ }
257
+
258
+ function dirname(path: string): string {
259
+ const idx = path.lastIndexOf("/");
260
+ return idx >= 0 ? path.slice(0, idx + 1) : "";
261
+ }
262
+
263
+ /**
264
+ * The collapsed-row path preview. Diff magnitude is rendered as a SINGLE muted
265
+ * "+N −M" glyph pair — the saturated add/del green/red is reserved exclusively
266
+ * for the expanded DiffView gutter, so the one-line rail stays a calm, single
267
+ * hue (the file path) with no competing colored numerics.
268
+ */
269
+ function PathPreview({ path, add, del }: { path: string; add?: number | undefined; del?: number | undefined }) {
270
+ return (
271
+ <span className="inline-flex items-center gap-2 truncate font-og-mono">
272
+ <span className="truncate">
273
+ <span className="text-og-fg-subtle">{dirname(path)}</span>
274
+ <span className="text-og-fg-muted">{basename(path)}</span>
275
+ </span>
276
+ {add != null || del != null ? (
277
+ <span className="shrink-0 text-og-fg-subtle">
278
+ {add != null ? `+${add}` : ""}
279
+ {add != null && del != null ? " " : ""}
280
+ {del != null ? `−${del}` : ""}
281
+ </span>
282
+ ) : null}
283
+ </span>
284
+ );
285
+ }
286
+
287
+ function ApplyPatchRenderer({ item }: ToolRendererProps) {
288
+ const ops = applyPatchOps(item.raw);
289
+ const failed = item.status === "failed";
290
+ const cancelled = item.status === "cancelled";
291
+ const running = item.status === "running";
292
+ const firstOp = ops[0];
293
+
294
+ if (running) {
295
+ // Show the patch structure from the arguments (available immediately on
296
+ // creation), but mark the row clearly as in-progress — not applied yet.
297
+ const fileCount = ops.length;
298
+ const titleVerb = firstOp ? `Applying ${basename(firstOp.path)}` : "Applying patch";
299
+ return (
300
+ <ActivityDisclosure
301
+ icon={<FileDiffIcon className={ICON_SIZE} />}
302
+ iconTone="running"
303
+ title={fileCount > 1 ? `Applying ${fileCount} files` : titleVerb}
304
+ running
305
+ preview={<RunningPreview>{fileCount > 1 ? `${fileCount} files` : firstOp ? firstOp.path : "applying…"}</RunningPreview>}
306
+ >
307
+ {ops.map((op) => {
308
+ const file = safeParseOp(op);
309
+ return file ? (
310
+ <ToolDiff key={op.path} files={[file]} />
311
+ ) : (
312
+ <div key={op.path}>
313
+ <p className="mb-1 font-og-mono text-og-xs text-og-fg-muted">{op.path}</p>
314
+ <RawPatch diff={op.diff ?? ""} />
315
+ </div>
316
+ );
317
+ })}
318
+ </ActivityDisclosure>
319
+ );
320
+ }
321
+
322
+ if (failed) {
323
+ return (
324
+ <ActivityDisclosure
325
+ icon={<FileDiffIcon className={ICON_SIZE} />}
326
+ iconTone="failed"
327
+ title={firstOp ? `${verbForOp(firstOp)} ${basename(firstOp.path)}` : "apply_patch"}
328
+ chip={{ tone: "bad", text: "failed" }}
329
+ preview={typeof item.output === "string" ? item.output : "patch failed"}
330
+ >
331
+ <PayloadBlock label="Error" value={item.output} failed />
332
+ </ActivityDisclosure>
333
+ );
334
+ }
335
+
336
+ // multi-file edit — magnitude stays a single muted glyph; the per-file
337
+ // green/red lives only inside the expanded DiffView gutter.
338
+ if (ops.length > 1) {
339
+ // Parse every op: successfully parsed ones go into ToolDiff; malformed ops
340
+ // fall back to a RawPatch display (mirroring the single-op fallback path).
341
+ // The count in the title/preview equals ops.length so it is always truthful
342
+ // regardless of how many ops parsed successfully.
343
+ const parsed = ops.map((op) => safeParseOp(op));
344
+ const goodFiles = parsed.filter((f): f is GitFileDiff => f !== null);
345
+ const add = goodFiles.reduce((n, f) => n + f.additions, 0);
346
+ const del = goodFiles.reduce((n, f) => n + f.deletions, 0);
347
+ return (
348
+ <ActivityDisclosure
349
+ icon={<FileDiffIcon className={ICON_SIZE} />}
350
+ iconTone="accent"
351
+ title={`Edited ${ops.length} files`}
352
+ cancelled={cancelled}
353
+ preview={
354
+ <span className="inline-flex items-center gap-2 font-og-mono">
355
+ <span className="text-og-fg-muted">{ops.length} files</span>
356
+ <span className="text-og-fg-subtle">
357
+ +{add} −{del}
358
+ </span>
359
+ </span>
360
+ }
361
+ >
362
+ {ops.map((op, index) => {
363
+ const file = parsed[index];
364
+ return file ? (
365
+ <ToolDiff key={op.path} files={[file]} />
366
+ ) : (
367
+ <div key={op.path}>
368
+ <p className="mb-1 font-og-mono text-og-xs text-og-fg-muted">{op.path}</p>
369
+ <RawPatch diff={op.diff ?? ""} />
370
+ </div>
371
+ );
372
+ })}
373
+ </ActivityDisclosure>
374
+ );
375
+ }
376
+
377
+ // single op
378
+ if (!firstOp) {
379
+ return <GenericRenderer item={item} />;
380
+ }
381
+ if (firstOp.type === "delete_file") {
382
+ return (
383
+ <ActivityDisclosure
384
+ icon={<FileDiffIcon className={ICON_SIZE} />}
385
+ iconTone="failed"
386
+ title={`Deleted ${basename(firstOp.path)}`}
387
+ cancelled={cancelled}
388
+ preview={<PathPreview path={firstOp.path} />}
389
+ >
390
+ <BodyNote>File deleted — no diff to show.</BodyNote>
391
+ </ActivityDisclosure>
392
+ );
393
+ }
394
+
395
+ const file = safeParseOp(firstOp);
396
+ if (!file) {
397
+ return (
398
+ <ActivityDisclosure
399
+ icon={<FileDiffIcon className={ICON_SIZE} />}
400
+ iconTone="accent"
401
+ title={`${verbForOp(firstOp)} ${basename(firstOp.path)}`}
402
+ cancelled={cancelled}
403
+ preview={
404
+ <span className="inline-flex items-center gap-2 font-og-mono">
405
+ <span className="text-og-fg-muted">{basename(firstOp.path)}</span>
406
+ <span className="text-og-fg-subtle">malformed V4A</span>
407
+ </span>
408
+ }
409
+ >
410
+ <RawPatch diff={firstOp.diff ?? ""} />
411
+ </ActivityDisclosure>
412
+ );
413
+ }
414
+
415
+ // The collapsed row shows verb + basename (title) and a muted "+N −M"
416
+ // (preview); on expand the preview is hidden and the DiffView header carries
417
+ // the path + churn — so the filename/stat never appears twice at once.
418
+ return (
419
+ <ActivityDisclosure
420
+ icon={<FileDiffIcon className={ICON_SIZE} />}
421
+ iconTone="accent"
422
+ title={`${verbForOp(firstOp)} ${basename(file.path)}`}
423
+ cancelled={cancelled}
424
+ preview={<PathPreview path={file.path} add={file.additions} del={file.deletions} />}
425
+ >
426
+ <ToolDiff files={[file]} />
427
+ </ActivityDisclosure>
428
+ );
429
+ }
430
+
431
+ function safeParseOp(op: ApplyPatchOperation): GitFileDiff | null {
432
+ try {
433
+ return v4aToGitFileDiff(op);
434
+ } catch {
435
+ return null;
436
+ }
437
+ }
438
+
439
+ /* ---- computer_call --------------------------------------------------------- */
440
+
441
+ type ComputerAction = {
442
+ type?: string;
443
+ x?: number;
444
+ y?: number;
445
+ text?: string;
446
+ keys?: string[];
447
+ button?: string;
448
+ };
449
+
450
+ function computerVerb(action: ComputerAction | undefined): string {
451
+ if (!action || !action.type) {
452
+ return "Acted";
453
+ }
454
+ switch (action.type) {
455
+ case "screenshot":
456
+ return "Screenshot";
457
+ case "click":
458
+ return `Clicked (${action.x}, ${action.y})`;
459
+ case "double_click":
460
+ return `Double-clicked (${action.x}, ${action.y})`;
461
+ case "move":
462
+ return `Moved (${action.x}, ${action.y})`;
463
+ case "scroll":
464
+ return "Scrolled";
465
+ case "type": {
466
+ const t = action.text ?? "";
467
+ return `Typed “${t.slice(0, 28)}${t.length > 28 ? "…" : ""}”`;
468
+ }
469
+ case "keypress":
470
+ return `Pressed ${(action.keys ?? []).join("+")}`;
471
+ case "drag":
472
+ return "Dragged";
473
+ case "wait":
474
+ return "Waited";
475
+ default:
476
+ return action.type;
477
+ }
478
+ }
479
+
480
+ function ComputerCallRenderer({ item }: ToolRendererProps) {
481
+ const raw = (item.raw ?? {}) as {
482
+ action?: ComputerAction;
483
+ actions?: ComputerAction[];
484
+ providerData?: { approvalStatus?: string };
485
+ };
486
+ const action = raw.action;
487
+ const actions = raw.actions ?? (action ? [action] : []);
488
+ const verb = computerVerb(action);
489
+ const out = item.output;
490
+ const running = item.status === "running";
491
+ const rejected = raw.providerData?.approvalStatus === "rejected";
492
+ const readOnly = typeof out === "string" && out.includes("read-only");
493
+ const isImage = typeof out === "string" && out.startsWith("data:image");
494
+ const empty = out === "" || out == null;
495
+ const batched = actions.length > 1 ? actions.map((a) => computerVerb(a)).join(" · ") : null;
496
+ // Fold the batched-action count into the title (one media affordance per row),
497
+ // rather than a separate "+N more" mono label competing beside the thumbnail.
498
+ const countSuffix = actions.length > 1 ? ` ·${actions.length}` : "";
499
+ const isShot = action?.type === "screenshot";
500
+
501
+ if (running) {
502
+ return (
503
+ <ActivityDisclosure
504
+ icon={isShot ? <CameraIcon className={ICON_SIZE} /> : <MousePointer2Icon className={ICON_SIZE} />}
505
+ iconTone="running"
506
+ title={verb}
507
+ running
508
+ media={<MediaSkeleton />}
509
+ >
510
+ <BodyNote>capturing frame…</BodyNote>
511
+ </ActivityDisclosure>
512
+ );
513
+ }
514
+
515
+ if (readOnly) {
516
+ return (
517
+ <ActivityDisclosure
518
+ icon={<MousePointer2Icon className={ICON_SIZE} />}
519
+ iconTone="failed"
520
+ title={verb}
521
+ chip={{ tone: "bad", text: "read-only" }}
522
+ preview="write actions disabled"
523
+ >
524
+ <BodyNote tone="error">computer-use is read-only — write actions are disabled.</BodyNote>
525
+ </ActivityDisclosure>
526
+ );
527
+ }
528
+
529
+ if (rejected) {
530
+ return (
531
+ <ActivityDisclosure
532
+ icon={<LockIcon className={ICON_SIZE} />}
533
+ iconTone="muted"
534
+ title={verb}
535
+ preview="approval rejected — this action did not run"
536
+ >
537
+ <BodyNote>approval rejected — this action did not run.</BodyNote>
538
+ </ActivityDisclosure>
539
+ );
540
+ }
541
+
542
+ const isFailed = item.status === "failed";
543
+ const isCancelled = item.status === "cancelled";
544
+
545
+ if (isImage && typeof out === "string") {
546
+ const caption = `computer_call · ${verb}${actions.length > 1 ? ` (+${actions.length - 1} more)` : ""}`;
547
+ return (
548
+ <ActivityDisclosure
549
+ icon={isShot ? <CameraIcon className={ICON_SIZE} /> : <MousePointer2Icon className={ICON_SIZE} />}
550
+ iconTone={isFailed ? "failed" : "accent"}
551
+ title={`${verb}${countSuffix}`}
552
+ failed={isFailed}
553
+ cancelled={isCancelled}
554
+ media={<Thumbnail src={out} caption={caption} />}
555
+ >
556
+ <ScreenshotFigure src={out} caption={caption} />
557
+ {batched ? <BodyNote>batched: {batched}</BodyNote> : null}
558
+ </ActivityDisclosure>
559
+ );
560
+ }
561
+
562
+ if (empty) {
563
+ return (
564
+ <ActivityDisclosure
565
+ icon={<CameraOffIcon className={ICON_SIZE} />}
566
+ iconTone={isFailed ? "failed" : "muted"}
567
+ title={verb}
568
+ failed={isFailed}
569
+ cancelled={isCancelled}
570
+ media={<MediaEmpty />}
571
+ >
572
+ <BodyNote>{isFailed ? "computer_call failed — no image returned." : isCancelled ? "computer_call interrupted — no image returned." : "(no image) — the session returned an empty screenshot."}</BodyNote>
573
+ </ActivityDisclosure>
574
+ );
575
+ }
576
+
577
+ // a non-screenshot action whose output is not an image (click/keypress)
578
+ return (
579
+ <ActivityDisclosure
580
+ icon={<MousePointer2Icon className={ICON_SIZE} />}
581
+ iconTone={isFailed ? "failed" : "accent"}
582
+ title={verb}
583
+ failed={isFailed}
584
+ cancelled={isCancelled}
585
+ preview={batched ?? undefined}
586
+ expandable={batched != null}
587
+ >
588
+ {batched ? <BodyNote>{batched}</BodyNote> : null}
589
+ </ActivityDisclosure>
590
+ );
591
+ }
592
+
593
+ /* ---- web_search ------------------------------------------------------------ */
594
+
595
+ type WebSearchResult = { title: string; domain: string; snippet: string };
596
+
597
+ function WebSearchRenderer({ item }: ToolRendererProps) {
598
+ const raw = (item.raw ?? {}) as { providerData?: { action?: { query?: string; queries?: string[] } } };
599
+ const action = raw.providerData?.action ?? {};
600
+ const query = action.query ?? "(query unavailable)";
601
+ const queries = action.queries ?? [];
602
+ const variants = queries.length > 1 ? ` +${queries.length - 1} variants` : "";
603
+ const running = item.status === "running";
604
+ // web_search may surface a results array on the output when the host enriches it.
605
+ // Filter out null/undefined/non-object entries before casting: host-provided
606
+ // data is untrusted and a null element would throw on result.title access.
607
+ const rawResults = (item.output as { results?: unknown } | undefined)?.results;
608
+ const results = Array.isArray(rawResults)
609
+ ? (rawResults as unknown[]).filter((r): r is WebSearchResult => !!r && typeof r === "object")
610
+ : undefined;
611
+
612
+ if (running) {
613
+ return (
614
+ <ActivityDisclosure
615
+ icon={<SearchIcon className={ICON_SIZE} />}
616
+ iconTone="running"
617
+ title="Searching the web"
618
+ running
619
+ preview={<RunningPreview>{`${query}${variants}`}</RunningPreview>}
620
+ >
621
+ <BodyNote>searching… results fold into the model context (no output event).</BodyNote>
622
+ </ActivityDisclosure>
623
+ );
624
+ }
625
+
626
+ return (
627
+ <ActivityDisclosure
628
+ icon={<SearchIcon className={ICON_SIZE} />}
629
+ iconTone="muted"
630
+ title="Searched the web"
631
+ preview={`${query}${variants}`}
632
+ failed={item.status === "failed"}
633
+ cancelled={item.status === "cancelled"}
634
+ >
635
+ {results && results.length ? (
636
+ <ul className="flex flex-col gap-2">
637
+ {results.map((result, index) => (
638
+ <li key={index} className="flex gap-2.5">
639
+ <GlobeIcon className="mt-0.5 size-3.5 shrink-0 text-og-fg-subtle" />
640
+ <div className="min-w-0">
641
+ <p className="truncate text-og-base text-og-fg">
642
+ {result.title} <span className="text-og-fg-subtle">{result.domain}</span>
643
+ </p>
644
+ <p className="text-og-sm leading-5 text-og-fg-muted">{result.snippet}</p>
645
+ </div>
646
+ </li>
647
+ ))}
648
+ </ul>
649
+ ) : (
650
+ <BodyNote>results folded into model context — no list available.</BodyNote>
651
+ )}
652
+ </ActivityDisclosure>
653
+ );
654
+ }
655
+
656
+ /* ---- view_image ------------------------------------------------------------ */
657
+
658
+ const VIEW_IMAGE_ERRORS = ["was not found", "is not a file", "exceeded the allowed size", "is not a supported image", "unable to read image"];
659
+
660
+ function ViewImageRenderer({ item }: ToolRendererProps) {
661
+ const args = parseToolArgs(item.arguments);
662
+ const path = typeof args.path === "string" ? args.path : "";
663
+ const out = item.output;
664
+ const text = typeof out === "string" ? out : "";
665
+
666
+ if (item.status === "running") {
667
+ return (
668
+ <ActivityDisclosure
669
+ icon={<ImageIcon className={ICON_SIZE} />}
670
+ iconTone="running"
671
+ title={`View ${basename(path)}`}
672
+ running
673
+ preview={<RunningPreview>reading…</RunningPreview>}
674
+ media={<MediaSkeleton />}
675
+ >
676
+ <BodyNote>reading image…</BodyNote>
677
+ </ActivityDisclosure>
678
+ );
679
+ }
680
+
681
+ const viewFailed = item.status === "failed";
682
+ const viewCancelled = item.status === "cancelled";
683
+
684
+ const errMatch = VIEW_IMAGE_ERRORS.find((p) => text.includes(p));
685
+ if (errMatch) {
686
+ const tooBig = text.includes("exceeded the allowed size");
687
+ return (
688
+ <ActivityDisclosure
689
+ icon={<ImageIcon className={ICON_SIZE} />}
690
+ iconTone="failed"
691
+ title={`View ${basename(path)}`}
692
+ chip={{ tone: "bad", text: tooBig ? "too large" : "error" }}
693
+ preview={text}
694
+ >
695
+ <BodyNote tone="error">{text}</BodyNote>
696
+ </ActivityDisclosure>
697
+ );
698
+ }
699
+ if (text.startsWith("OpenAI file reference:")) {
700
+ return (
701
+ <ActivityDisclosure
702
+ icon={<ImageIcon className={ICON_SIZE} />}
703
+ iconTone={viewFailed ? "failed" : "muted"}
704
+ title={`Viewed ${basename(path)}`}
705
+ failed={viewFailed}
706
+ cancelled={viewCancelled}
707
+ preview={path}
708
+ >
709
+ <BodyNote>{text}</BodyNote>
710
+ </ActivityDisclosure>
711
+ );
712
+ }
713
+ if (text.includes("No image data")) {
714
+ return (
715
+ <ActivityDisclosure
716
+ icon={<ImageIcon className={ICON_SIZE} />}
717
+ iconTone={viewFailed ? "failed" : "muted"}
718
+ title={`Viewed ${basename(path)}`}
719
+ failed={viewFailed}
720
+ cancelled={viewCancelled}
721
+ preview="(no image)"
722
+ >
723
+ <BodyNote>{viewFailed ? "view_image failed — no image data returned." : viewCancelled ? "view_image interrupted." : "(no image) — the sandbox session returned no image data."}</BodyNote>
724
+ </ActivityDisclosure>
725
+ );
726
+ }
727
+ if (text.startsWith("data:")) {
728
+ return (
729
+ <ActivityDisclosure
730
+ icon={<ImageIcon className={ICON_SIZE} />}
731
+ iconTone={viewFailed ? "failed" : "accent"}
732
+ title={`Viewed ${basename(path)}`}
733
+ failed={viewFailed}
734
+ cancelled={viewCancelled}
735
+ media={<Thumbnail src={text} caption={path} alt={path} />}
736
+ >
737
+ <ScreenshotFigure src={text} caption={path} alt={path} />
738
+ </ActivityDisclosure>
739
+ );
740
+ }
741
+ return <GenericRenderer item={item} />;
742
+ }
743
+
744
+ /* ---- environment_set_variable (secret-safe, write-only) -------------------- */
745
+
746
+ function SecretSetRenderer({ item }: ToolRendererProps) {
747
+ const args = parseToolArgs(item.arguments);
748
+ const name = typeof args.name === "string" ? args.name : "variable";
749
+
750
+ if (item.status === "running") {
751
+ return (
752
+ <ActivityDisclosure
753
+ icon={<KeyRoundIcon className={ICON_SIZE} />}
754
+ iconTone="running"
755
+ title={`Set ${name}`}
756
+ running
757
+ preview={<RunningPreview>setting…</RunningPreview>}
758
+ >
759
+ <PayloadBlock label="Arguments" value={redactSecrets(args)} />
760
+ </ActivityDisclosure>
761
+ );
762
+ }
763
+
764
+ if (item.status === "failed") {
765
+ const errorText = typeof item.output === "string" ? item.output : null;
766
+ return (
767
+ <ActivityDisclosure
768
+ icon={<KeyRoundIcon className={ICON_SIZE} />}
769
+ iconTone="failed"
770
+ title={`Set ${name}`}
771
+ failed
772
+ preview={errorText ?? "variable write failed"}
773
+ >
774
+ <PayloadBlock label="Arguments" value={redactSecrets(args)} />
775
+ {errorText ? <PayloadBlock label="Error" value={errorText} failed /> : <BodyNote tone="error">the tool call failed with no output.</BodyNote>}
776
+ </ActivityDisclosure>
777
+ );
778
+ }
779
+
780
+ return (
781
+ <ActivityDisclosure
782
+ icon={<KeyRoundIcon className={ICON_SIZE} />}
783
+ iconTone="muted"
784
+ title={`Set ${name}`}
785
+ cancelled={item.status === "cancelled"}
786
+ preview="value write-only · never returned"
787
+ >
788
+ <PayloadBlock label="Arguments" value={redactSecrets(args)} />
789
+ <BodyNote>the value is a secret — redacted in every view; the API never returns it.</BodyNote>
790
+ </ActivityDisclosure>
791
+ );
792
+ }
793
+
794
+ /* ---- generic fallback (first-party MCP, external MCP, unknown) ------------- */
795
+
796
+ function GenericRenderer({ item }: ToolRendererProps) {
797
+ const running = item.status === "running";
798
+ const args = redactSecrets(parseToolArgs(item.arguments));
799
+ const display = toolDisplayName(item.name);
800
+
801
+ if (running) {
802
+ return (
803
+ <ActivityDisclosure
804
+ icon={<PlugIcon className={ICON_SIZE} />}
805
+ iconTone="running"
806
+ title={display}
807
+ running
808
+ preview={<RunningPreview>{compactArgs(args) || "running…"}</RunningPreview>}
809
+ >
810
+ <PayloadBlock label="Arguments" value={args} />
811
+ </ActivityDisclosure>
812
+ );
813
+ }
814
+
815
+ const { text: outText, isError } = unwrapMcpOutput(item.output);
816
+ // Cancelled is NOT an error — a user-cancelled tool should not surface the red
817
+ // error chip even if the output payload carries an isError flag (the error may be
818
+ // a consequence of the cancellation, not the tool's own failure).
819
+ if ((isError || item.status === "failed") && item.status !== "cancelled") {
820
+ return (
821
+ <ActivityDisclosure
822
+ icon={<WrenchIcon className={ICON_SIZE} />}
823
+ iconTone="failed"
824
+ title={display}
825
+ chip={{ tone: "bad", text: "error" }}
826
+ preview={outText.slice(0, 80)}
827
+ >
828
+ <PayloadBlock label="Arguments" value={args} />
829
+ <PayloadBlock label="Error" value={outText} failed />
830
+ </ActivityDisclosure>
831
+ );
832
+ }
833
+
834
+ return (
835
+ <ActivityDisclosure
836
+ icon={<WrenchIcon className={ICON_SIZE} />}
837
+ iconTone="muted"
838
+ title={display}
839
+ cancelled={item.status === "cancelled"}
840
+ preview={compactArgs(args)}
841
+ >
842
+ <PayloadBlock label="Arguments" value={args} />
843
+ <PayloadBlock label="Result" value={outText} />
844
+ </ActivityDisclosure>
845
+ );
846
+ }
847
+
848
+ function compactArgs(args: unknown): string {
849
+ const text = stringifyPayload(args).replace(/\s+/g, " ").trim();
850
+ return text === "{}" ? "" : text.length > 90 ? `${text.slice(0, 89)}…` : text;
851
+ }
852
+
853
+ /* ---- the default registry -------------------------------------------------- */
854
+
855
+ const BASE_ENTRIES: ToolRegistryEntry[] = [
856
+ // Provider-native items carry `raw.type` on the wire — this is their source of
857
+ // truth and is consulted first by the registry.
858
+ { match: "rawType", type: "apply_patch_call", render: ApplyPatchRenderer },
859
+ { match: "rawType", type: "computer_call", render: ComputerCallRenderer },
860
+ { match: "rawType", type: "hosted_tool_call", render: WebSearchRenderer },
861
+ // First-party sandbox + MCP tools resolve by name. `apply_patch_call` /
862
+ // `computer_call` are intentionally repeated by name as a fallback only for
863
+ // first-party replays that omit `raw` (the rawType entries above win whenever
864
+ // `raw.type` is present, which is the live-wire case).
865
+ { match: "name", name: "exec_command", render: ExecRenderer },
866
+ { match: "name", name: "write_stdin", render: WriteStdinRenderer },
867
+ { match: "name", name: "apply_patch_call", render: ApplyPatchRenderer },
868
+ { match: "name", name: "computer_call", render: ComputerCallRenderer },
869
+ { match: "name", name: "web_search_call", render: WebSearchRenderer },
870
+ { match: "name", name: "view_image", render: ViewImageRenderer },
871
+ { match: "name", name: "environment_set_variable", render: SecretSetRenderer },
872
+ ];
873
+
874
+ /** The built-in tool renderer registry: every first-party tool plus a fallback. */
875
+ export const defaultToolRegistry: ToolRegistry = createToolRegistry(BASE_ENTRIES, GenericRenderer);
876
+
877
+ /** Build a registry that extends the built-ins with consumer entries/fallback. */
878
+ export function createDefaultToolRegistry(
879
+ options: Parameters<typeof createToolRegistry>[2] = {},
880
+ ): ToolRegistry {
881
+ return createToolRegistry(BASE_ENTRIES, GenericRenderer, options);
882
+ }