@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,152 @@
1
+ import { XIcon } from "lucide-react";
2
+ import { AnimatePresence, motion } from "motion/react";
3
+ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
4
+ import { Dialog } from "radix-ui";
5
+ import { cn } from "../lib/cn";
6
+
7
+ /* ----------------------------------------------------------------------------
8
+ Screenshot lightbox
9
+
10
+ A single, app-level lightbox the computer_call / view_image renderers open by
11
+ `src`. Built on Radix Dialog so it is focus-trapped, ESC-closable, and
12
+ scroll-locked — fixing the v1 mockup's broken expand (an absolutely-positioned
13
+ <img> that overflowed its row). The image is centered, constrained to the
14
+ viewport (`max-w/max-h` + `object-contain`), and sits on a dimmed backdrop.
15
+
16
+ Consumers render `<LightboxProvider>` once near the timeline; renderers call
17
+ `useLightbox().open(src)`.
18
+ -------------------------------------------------------------------------- */
19
+
20
+ type LightboxController = {
21
+ open: (src: string, caption?: string) => void;
22
+ };
23
+
24
+ const LightboxContext = createContext<LightboxController | null>(null);
25
+
26
+ /** Open the app-level screenshot lightbox. No-op outside a `LightboxProvider`. */
27
+ export function useLightbox(): LightboxController {
28
+ return useContext(LightboxContext) ?? NOOP;
29
+ }
30
+
31
+ /**
32
+ * The lightbox controller when one is mounted, or `null` outside a
33
+ * `LightboxProvider`. Lets a media primitive degrade to a non-interactive image
34
+ * (rather than a dead "Expand" button that announces an action it cannot do).
35
+ */
36
+ export function useLightboxOptional(): LightboxController | null {
37
+ return useContext(LightboxContext);
38
+ }
39
+
40
+ const NOOP: LightboxController = { open: () => {} };
41
+
42
+ /**
43
+ * The app-level screenshot lightbox. Render once near the timeline; renderers
44
+ * call `useLightbox().open(src)`.
45
+ *
46
+ * Idempotent by design: when an ancestor `LightboxProvider` already exists (e.g.
47
+ * a `MessageTimeline` mounted inside an app that already wraps its shell), this
48
+ * one becomes a pass-through and does NOT mount a second focus-trapping Dialog.
49
+ * That keeps `MessageTimeline` self-sufficient (it owns its own provider) while
50
+ * composing cleanly when nested.
51
+ */
52
+ export function LightboxProvider({ children }: { children: ReactNode }) {
53
+ const ancestor = useContext(LightboxContext);
54
+ if (ancestor) {
55
+ return <>{children}</>;
56
+ }
57
+ return <LightboxRoot>{children}</LightboxRoot>;
58
+ }
59
+
60
+ function LightboxRoot({ children }: { children: ReactNode }) {
61
+ const [state, setState] = useState<{ src: string; caption?: string } | null>(null);
62
+
63
+ const open = useCallback((src: string, caption?: string) => {
64
+ setState(caption ? { src, caption } : { src });
65
+ }, []);
66
+
67
+ const controller = useMemo<LightboxController>(() => ({ open }), [open]);
68
+
69
+ return (
70
+ <LightboxContext.Provider value={controller}>
71
+ {children}
72
+ <Dialog.Root open={state !== null} onOpenChange={(next) => !next && setState(null)}>
73
+ <AnimatePresence>
74
+ {state !== null ? (
75
+ <Dialog.Portal forceMount>
76
+ <Dialog.Overlay asChild forceMount>
77
+ <motion.div
78
+ initial={{ opacity: 0 }}
79
+ animate={{ opacity: 1 }}
80
+ exit={{ opacity: 0 }}
81
+ transition={{ duration: 0.15 }}
82
+ className="og-root fixed inset-0 z-50 bg-black/90 backdrop-blur-md"
83
+ />
84
+ </Dialog.Overlay>
85
+ <Dialog.Content
86
+ asChild
87
+ forceMount
88
+ aria-label="Screenshot"
89
+ onOpenAutoFocus={(event) => event.preventDefault()}
90
+ >
91
+ <motion.div
92
+ initial={{ opacity: 0, scale: 0.98 }}
93
+ animate={{ opacity: 1, scale: 1 }}
94
+ exit={{ opacity: 0, scale: 0.98 }}
95
+ transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
96
+ // Content is a full-inset centering wrapper layered above the
97
+ // Overlay, so Radix's own click-outside (which fires on the
98
+ // Overlay) can never see a backdrop click — every click lands on
99
+ // Content. We make Content's own backdrop dismiss: a click whose
100
+ // target is the wrapper itself (not the figure) closes it, so the
101
+ // figcaption's "click outside to close" affordance is real.
102
+ onClick={(event) => {
103
+ if (event.target === event.currentTarget) {
104
+ setState(null);
105
+ }
106
+ }}
107
+ className="og-root fixed inset-0 z-50 flex items-center justify-center p-6 sm:p-12"
108
+ >
109
+ <Dialog.Title className="sr-only">Screenshot</Dialog.Title>
110
+ {/* The figure is one self-contained object: image, caption, and
111
+ its own close control. The close button anchors to a wrapper
112
+ sized to the IMAGE (w-fit), so it hugs the real top-right
113
+ corner regardless of aspect ratio — never floating into the
114
+ empty space of a wide figure column. */}
115
+ <figure className="m-0 flex max-h-full max-w-5xl flex-col items-center gap-3">
116
+ <div className="relative flex min-h-0 w-fit max-w-full">
117
+ {/* A plain <img>: this SDK is framework-agnostic, with no host Image component. */}
118
+ <img
119
+ src={state.src}
120
+ alt={state.caption ?? "Screenshot"}
121
+ className="min-h-0 max-h-[82vh] w-auto max-w-full rounded-og-md border border-white/10 object-contain shadow-og-lg"
122
+ />
123
+ <Dialog.Close
124
+ className={cn(
125
+ "absolute -right-3 -top-3 inline-flex size-9 items-center justify-center rounded-full",
126
+ "border border-white/15 bg-black/60 text-white/70 backdrop-blur",
127
+ "transition-colors hover:border-white/30 hover:text-white",
128
+ )}
129
+ aria-label="Close"
130
+ >
131
+ <XIcon className="size-4" />
132
+ </Dialog.Close>
133
+ </div>
134
+ {state.caption ? (
135
+ <figcaption className="max-w-2xl text-center font-og-mono text-og-xs text-white/55">
136
+ {state.caption}
137
+ </figcaption>
138
+ ) : (
139
+ <figcaption className="font-og-mono text-[10px] uppercase tracking-[0.1em] text-white/35">
140
+ Esc or click outside to close
141
+ </figcaption>
142
+ )}
143
+ </figure>
144
+ </motion.div>
145
+ </Dialog.Content>
146
+ </Dialog.Portal>
147
+ ) : null}
148
+ </AnimatePresence>
149
+ </Dialog.Root>
150
+ </LightboxContext.Provider>
151
+ );
152
+ }
@@ -0,0 +1,481 @@
1
+ import { CameraIcon, CameraOffIcon, ChevronRightIcon } from "lucide-react";
2
+ import { useState, type ReactNode } from "react";
3
+ import { Collapsible } from "radix-ui";
4
+ import { cn } from "../lib/cn";
5
+ import { stringifyPayload } from "../lib/format";
6
+ import { useForcedDefaultOpen } from "./disclosure-context";
7
+ import { useLightboxOptional } from "./screenshot-lightbox";
8
+
9
+ /* ----------------------------------------------------------------------------
10
+ Shared timeline primitives
11
+
12
+ The restraint layer. One disclosure shape every tool renderer reuses, so the
13
+ rail reads as a calm, aligned column: a chevron, a tinted icon, a title, an
14
+ optional muted preview, and — at most — ONE quiet right-aligned signal
15
+ (a settle chip). Compact by default; the body only mounts when expanded.
16
+
17
+ CHIP DOCTRINE (closed set — do not extend):
18
+ The right gutter carries at most ONE terse status token per row, and COLOR is
19
+ spent only on the exception. Success is the default, so it never earns a hue:
20
+ a settled-ok chip is bare muted text (or nothing). The colored dot is reserved
21
+ for failure alone. In-flight state is NOT a gutter chip — the shimmering title
22
+ carries it, so a running row has a clean right edge (no detached pulse badge).
23
+ There is no bordered/filled pill. Anything narrative (a session id, "approval
24
+ rejected", "malformed V4A") belongs in the muted preview line, never the gutter.
25
+
26
+ ok a settled success quiet muted text ("0", "done") — no dot
27
+ bad a settled failure red dot + red text ("exit 6") — the one hue
28
+ muted quiet metadata subtle text ("session 3")
29
+ -------------------------------------------------------------------------- */
30
+
31
+ /**
32
+ * A subtle settle signal — see the CHIP DOCTRINE above. The closed tone set.
33
+ * `"interrupted"` is a calm neutral tone for cancelled items — no dot, same
34
+ * quiet weight as `"muted"`, but semantically distinct from metadata.
35
+ */
36
+ export type DisclosureChip = {
37
+ tone: "ok" | "bad" | "muted" | "interrupted";
38
+ text: string;
39
+ };
40
+
41
+ export type ActivityDisclosureProps = {
42
+ icon: ReactNode;
43
+ /** Icon tint. Defaults to the muted foreground; renderers pass accent/failed. */
44
+ iconTone?: "accent" | "failed" | "running" | "muted" | undefined;
45
+ title: ReactNode;
46
+ /** Render the title in the mono face (commands, paths). */
47
+ titleMono?: boolean | undefined;
48
+ /** Shimmer the title while the tool is in-flight. */
49
+ running?: boolean | undefined;
50
+ /**
51
+ * Quiet single-line secondary text (truncated). It is detail-on-demand: hidden
52
+ * when a media preview is set, AND hidden once the row is expanded (the body
53
+ * then owns the detail), so a stat/path never appears twice at once.
54
+ */
55
+ preview?: ReactNode | undefined;
56
+ /** A small inline media preview (a screenshot thumbnail) shown in place of `preview`. */
57
+ media?: ReactNode | undefined;
58
+ /** At most one quiet settle chip, right-aligned to the gutter. */
59
+ chip?: DisclosureChip | undefined;
60
+ /**
61
+ * When true the row carries the standard failure affordance: the icon is tinted
62
+ * red and a "failed" bad-chip appears in the right gutter (unless an explicit
63
+ * `chip` is already supplied — the caller's chip wins). Output is still visible
64
+ * on expand; this is a quiet status signal, not a blocking banner.
65
+ *
66
+ * Renderers should pass `failed={item.status === "failed"}` on their settled
67
+ * (non-running) paths so any tool with a failed status shows a consistent
68
+ * affordance without each renderer having to duplicate the logic.
69
+ */
70
+ failed?: boolean | undefined;
71
+ /**
72
+ * When true the row carries a calm "interrupted" affordance: the icon stays
73
+ * muted (no red) and a quiet "interrupted" chip appears in the right gutter
74
+ * (unless an explicit `chip` is already supplied — the caller's chip wins).
75
+ * This is the cancelled-status analogue of `failed`, but deliberately calm
76
+ * and neutral — it is NOT an error; the user chose to stop.
77
+ *
78
+ * Renderers should pass `cancelled={item.status === "cancelled"}` so any
79
+ * in-flight item that was interrupted on turn.cancelled reads consistently.
80
+ * `cancelled` is ignored when `failed` is also true (failure takes precedence).
81
+ */
82
+ cancelled?: boolean | undefined;
83
+ /** When false the row is a static line (no expand affordance). */
84
+ expandable?: boolean | undefined;
85
+ children?: ReactNode | undefined;
86
+ };
87
+
88
+ const ICON_TONE: Record<NonNullable<ActivityDisclosureProps["iconTone"]>, string> = {
89
+ accent: "text-og-accent",
90
+ failed: "text-og-status-failed",
91
+ running: "text-og-status-running",
92
+ muted: "text-og-fg-subtle",
93
+ };
94
+
95
+ /**
96
+ * The one disclosure row shape every activity row reuses (tool calls, reasoning,
97
+ * sandbox ops): a chevron, a tinted icon, a title, an optional muted preview or
98
+ * inline media, and at most one right-gutter settle chip. Compact by default;
99
+ * the body mounts only when expanded.
100
+ */
101
+ export function ActivityDisclosure({
102
+ icon,
103
+ iconTone: iconToneProp = "muted",
104
+ title,
105
+ titleMono,
106
+ running,
107
+ preview,
108
+ media,
109
+ chip: chipProp,
110
+ failed,
111
+ cancelled,
112
+ expandable = true,
113
+ children,
114
+ }: ActivityDisclosureProps) {
115
+ // `failed` takes precedence over `cancelled` when both are set (shouldn't happen, but be safe).
116
+ // When `failed` is set the icon goes red and a "failed" chip appears in the
117
+ // gutter — unless the caller already supplied an explicit chip (their chip wins,
118
+ // e.g. an exit-code chip that is more informative than a bare "failed" label).
119
+ // When `cancelled` is set (and not failed) the icon stays muted and a calm
120
+ // "interrupted" chip appears — no red, just a quiet neutral signal.
121
+ const iconTone = failed && iconToneProp === "muted" ? "failed" : iconToneProp;
122
+ const chip =
123
+ chipProp ??
124
+ (failed
125
+ ? ({ tone: "bad", text: "failed" } satisfies DisclosureChip)
126
+ : cancelled
127
+ ? ({ tone: "interrupted", text: "interrupted" } satisfies DisclosureChip)
128
+ : undefined);
129
+ // An ancestor may seed the initial open state (screenshot instrumentation);
130
+ // absent in normal app usage, where the row starts collapsed.
131
+ const forcedDefaultOpen = useForcedDefaultOpen();
132
+ const [open, setOpen] = useState(forcedDefaultOpen ?? false);
133
+ const hasBody = expandable && children != null;
134
+
135
+ // The preview is detail-on-demand: it is suppressed once the row is open so a
136
+ // path/stat shown in the body never also sits in the collapsed row.
137
+ const previewVisible = preview != null && !open;
138
+
139
+ // ONE full-row layout for EVERY tool — media or not — so the hit target always
140
+ // equals the visible row. The chevron → icon → title lead; a flex spacer (or
141
+ // the preview) fills the middle; the right gutter (ml-auto) carries the chip OR
142
+ // the media thumbnail. There is no media-vs-non-media fork: the screenshot card
143
+ // toggles from anywhere on the row, exactly like every other row.
144
+ //
145
+ // The row is the single Collapsible.Trigger (via `asChild` onto a div), so it
146
+ // is the toggle surface. A div — not a native <button> — so the interactive
147
+ // media thumbnail (itself a <button>) is valid nested DOM; that thumbnail calls
148
+ // stopPropagation so activating it never toggles the row.
149
+ //
150
+ // Radix forwards aria-expanded / aria-controls / data-state and a click handler
151
+ // onto the asChild child, but it does NOT synthesize the button role, tab stop,
152
+ // or Enter/Space activation for a non-button element. We add those ourselves
153
+ // (role/tabIndex/onKeyDown) so the row is a fully keyboard-operable button to
154
+ // AT and the keyboard, matching its native-<button> siblings (TurnSummary).
155
+ const rowClass = cn(
156
+ "group/disclosure flex w-full min-w-0 items-center gap-2 rounded-og-sm px-1.5 py-1.5 text-left text-og-base",
157
+ "text-og-fg-muted transition-colors duration-150",
158
+ );
159
+ // The chevron rotates to point down when open; it tracks `data-state` on this
160
+ // same row (the Trigger), so the affordance never freezes.
161
+ const inner = (
162
+ <>
163
+ {hasBody ? (
164
+ <ChevronRightIcon className="size-3.5 shrink-0 text-og-fg-subtle transition-transform duration-150 ease-og-in-out group-data-[state=open]/disclosure:rotate-90" />
165
+ ) : (
166
+ <span className="size-3.5 shrink-0" />
167
+ )}
168
+ <span className={cn("shrink-0", ICON_TONE[iconTone])}>{icon}</span>
169
+ <span
170
+ className={cn(
171
+ "min-w-0 shrink truncate text-og-base font-medium",
172
+ titleMono && "font-og-mono text-og-sm font-normal",
173
+ running && "og-shimmer-text",
174
+ )}
175
+ >
176
+ {title}
177
+ </span>
178
+ {previewVisible && !media ? (
179
+ <span className="min-w-0 flex-1 truncate text-og-sm text-og-fg-subtle">{preview}</span>
180
+ ) : (
181
+ <span className="flex-1" />
182
+ )}
183
+ {/* The right gutter carries at most ONE signal: the media thumbnail, else a
184
+ terse settle chip (hidden once expanded — the body owns the detail). */}
185
+ {media ? (
186
+ <span className="ml-auto flex shrink-0 items-center gap-2 pl-2">{media}</span>
187
+ ) : chip && !open ? (
188
+ <span className="ml-auto shrink-0 pl-2">
189
+ <Chip chip={chip} />
190
+ </span>
191
+ ) : null}
192
+ </>
193
+ );
194
+
195
+ // A `data-status` attribute on the root lets tests (and AT) detect the item's
196
+ // settled state regardless of whether the chip slot is occupied by media.
197
+ const dataStatus = cancelled ? "cancelled" : failed ? "failed" : undefined;
198
+
199
+ if (!hasBody) {
200
+ return <div className={cn(rowClass, "cursor-default")} data-status={dataStatus}>{inner}</div>;
201
+ }
202
+
203
+ return (
204
+ <Collapsible.Root open={open} onOpenChange={setOpen}>
205
+ <Collapsible.Trigger asChild>
206
+ <div
207
+ role="button"
208
+ tabIndex={0}
209
+ // Space/Enter activate a native button; Radix doesn't add this for a
210
+ // non-button asChild child, so we toggle here. preventDefault on Space
211
+ // stops the page from scrolling.
212
+ onKeyDown={(event) => {
213
+ if (event.key === "Enter" || event.key === " ") {
214
+ event.preventDefault();
215
+ setOpen((prev) => !prev);
216
+ }
217
+ }}
218
+ data-status={dataStatus}
219
+ className={cn(
220
+ rowClass,
221
+ "cursor-pointer outline-none hover:bg-og-surface-1 hover:text-og-fg",
222
+ "focus-visible:ring-2 focus-visible:ring-og-accent focus-visible:ring-offset-0",
223
+ )}
224
+ >
225
+ {inner}
226
+ </div>
227
+ </Collapsible.Trigger>
228
+ <Collapsible.Content className="overflow-hidden data-[state=closed]:animate-og-collapse data-[state=open]:animate-og-expand">
229
+ <div className="mb-2 ml-7 mt-1.5 flex flex-col gap-2">{children}</div>
230
+ </Collapsible.Content>
231
+ </Collapsible.Root>
232
+ );
233
+ }
234
+
235
+ /**
236
+ * The right-gutter settle chip. Color is spent only on the exception: a failure
237
+ * is a red dot + red text (the one colored token in a healthy run); success and
238
+ * metadata are bare muted text with no dot. No box, no fill (see the CHIP
239
+ * DOCTRINE above). A `bad` chip with empty text is a lone dot (no trailing void).
240
+ */
241
+ function Chip({ chip }: { chip: DisclosureChip }) {
242
+ const base = "inline-flex items-center font-og-mono text-og-xs leading-none";
243
+ const withText = chip.text ? "gap-1.5" : "gap-0";
244
+ if (chip.tone === "bad") {
245
+ return (
246
+ <span className={cn(base, withText, "text-og-status-failed")}>
247
+ <span className="size-1.5 rounded-full bg-og-status-failed" />
248
+ {chip.text}
249
+ </span>
250
+ );
251
+ }
252
+ // "interrupted" is a calm cancelled signal: same quiet weight as muted, no
253
+ // dot, no red — just a slightly more prominent subtle text so "interrupted"
254
+ // reads at a glance without demanding attention the way a failure does.
255
+ if (chip.tone === "interrupted") {
256
+ return <span className={cn(base, "text-og-fg-subtle og-cancelled-chip")}>{chip.text}</span>;
257
+ }
258
+ // ok and muted are the same quiet weight — success never earns a hue.
259
+ return <span className={cn(base, "text-og-fg-subtle")}>{chip.text}</span>;
260
+ }
261
+
262
+ /* --- terminal output block (exec / write_stdin) ---------------------------- */
263
+
264
+ export function TermBlock({
265
+ command,
266
+ workdir,
267
+ output,
268
+ live,
269
+ tailLines = 12,
270
+ }: {
271
+ /**
272
+ * The command shown in the prompt header. Pass `null` when the row title
273
+ * already carries it (e.g. an exec row titled `$ cmd`): the header then drops
274
+ * the command — and the whole prompt line if there is no workdir either — so
275
+ * the command never reads twice, stacked, above the output.
276
+ */
277
+ command: string | null;
278
+ workdir?: string | null | undefined;
279
+ /** The FULL output. TermBlock owns the tail/full slicing internally. */
280
+ output: string;
281
+ live?: boolean | undefined;
282
+ /**
283
+ * When the output exceeds the tail window, only the last `tailLines` are shown
284
+ * with a "show full output" toggle. The component holds the full text, so the
285
+ * toggle reveals the rest (never a dead affordance). Defaults to 12.
286
+ */
287
+ tailLines?: number | undefined;
288
+ }) {
289
+ const [full, setFull] = useState(false);
290
+ const empty = output.trim() === "";
291
+ const lines = output.split("\n");
292
+ const big = lines.length > tailLines + 4;
293
+ const shown = full || !big ? output : lines.slice(-tailLines).join("\n");
294
+ const showMore = big && !full;
295
+ const showHeader = command != null || workdir != null;
296
+
297
+ return (
298
+ <div className="overflow-hidden rounded-og-sm border border-og-border bg-og-bg/70">
299
+ {showHeader ? (
300
+ <div className="flex items-center gap-2 border-b border-og-border/70 px-2.5 py-1.5">
301
+ <span className="select-none text-og-status-idle">$</span>
302
+ {command != null ? (
303
+ <span className="min-w-0 flex-1 truncate font-og-mono text-og-sm text-og-fg-muted">{command}</span>
304
+ ) : (
305
+ <span className="flex-1" />
306
+ )}
307
+ {workdir ? <span className="shrink-0 font-og-mono text-og-xs text-og-fg-subtle">{workdir}</span> : null}
308
+ </div>
309
+ ) : null}
310
+ {empty ? (
311
+ <p className="px-2.5 py-2 font-og-mono text-og-xs italic text-og-fg-subtle">(no output)</p>
312
+ ) : (
313
+ <pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all px-2.5 py-2 font-og-mono text-og-xs leading-5 text-og-fg-muted">
314
+ {shown}
315
+ {live ? <span className="ml-px inline-block h-[1em] w-[2px] translate-y-[2px] animate-og-blink bg-og-accent align-middle" /> : null}
316
+ </pre>
317
+ )}
318
+ {showMore ? (
319
+ <button
320
+ type="button"
321
+ onClick={() => setFull(true)}
322
+ className="w-full border-t border-og-border/70 px-2.5 py-1.5 text-left text-og-xs text-og-fg-subtle transition-colors hover:text-og-fg"
323
+ >
324
+ show full output ({lines.length} lines)
325
+ </button>
326
+ ) : null}
327
+ </div>
328
+ );
329
+ }
330
+
331
+ /* --- generic payload block -------------------------------------------------- */
332
+
333
+ export function PayloadBlock({ label, value, failed }: { label: string; value: unknown; failed?: boolean | undefined }) {
334
+ const text = typeof value === "string" ? value : stringifyPayload(value);
335
+ if (!text || text.trim() === "") {
336
+ return null;
337
+ }
338
+ return (
339
+ <div className="min-w-0">
340
+ <p className="mb-1 text-og-xs font-medium uppercase tracking-[0.08em] text-og-fg-subtle">{label}</p>
341
+ <pre
342
+ className={cn(
343
+ "max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-og-sm border p-2.5 font-og-mono text-og-xs leading-5",
344
+ failed
345
+ ? "border-og-status-failed/30 bg-og-status-failed/5 text-og-status-failed"
346
+ : "border-og-border bg-og-bg/60 text-og-fg-muted",
347
+ )}
348
+ >
349
+ {text}
350
+ </pre>
351
+ </div>
352
+ );
353
+ }
354
+
355
+ /** A quiet inline note inside an expanded body (lost output, empty frame, …). */
356
+ export function BodyNote({ children, tone }: { children: ReactNode; tone?: "error" | "muted" | undefined }) {
357
+ if (tone === "error") {
358
+ return (
359
+ <div className="rounded-og-sm border border-og-status-failed/30 bg-og-status-failed/5 px-2.5 py-2 font-og-mono text-og-xs leading-5 text-og-status-failed">
360
+ {children}
361
+ </div>
362
+ );
363
+ }
364
+ return <p className="px-0.5 text-og-sm italic leading-5 text-og-fg-subtle">{children}</p>;
365
+ }
366
+
367
+ /* --- screenshot thumbnail + media states ------------------------------------ */
368
+
369
+ /**
370
+ * The shared inline-media footprint, so thumb / skeleton / empty align. Sized to
371
+ * the row's line height (~28px) so a media row never out-weighs a text row and
372
+ * the single-column rhythm holds.
373
+ */
374
+ const MEDIA_BOX = "h-7 w-[52px] shrink-0 rounded-og-xs border border-og-border";
375
+
376
+ /**
377
+ * A loading screenshot placeholder. A faint camera glyph over a shimmering box,
378
+ * so a still frame of the running state reads unambiguously as "capturing" — not
379
+ * a broken thumbnail.
380
+ */
381
+ export function MediaSkeleton() {
382
+ return (
383
+ <span className={cn(MEDIA_BOX, "relative inline-flex items-center justify-center overflow-hidden bg-og-surface-2")}>
384
+ <span className="absolute inset-0 animate-og-pulse bg-og-surface-3/50" />
385
+ <CameraIcon className="relative size-3.5 text-og-fg-subtle" />
386
+ </span>
387
+ );
388
+ }
389
+
390
+ /** A standardized "tool ran, produced no image" placeholder in the media slot. */
391
+ export function MediaEmpty() {
392
+ return (
393
+ <span className={cn(MEDIA_BOX, "inline-flex items-center justify-center bg-og-bg")}>
394
+ <CameraOffIcon className="size-3.5 text-og-fg-subtle" />
395
+ </span>
396
+ );
397
+ }
398
+
399
+ /**
400
+ * A small inline screenshot thumbnail that opens the app lightbox on click.
401
+ *
402
+ * Requires a `LightboxProvider` ancestor for the click-to-expand affordance.
403
+ * Outside one it degrades to a plain, non-interactive image — never a dead
404
+ * "Expand" button that announces an action it cannot perform.
405
+ */
406
+ export function Thumbnail({ src, caption, alt = "screenshot" }: { src: string; caption?: string | undefined; alt?: string }) {
407
+ const lightbox = useLightboxOptional();
408
+ const [failed, setFailed] = useState(false);
409
+ if (failed) {
410
+ return <MediaEmpty />;
411
+ }
412
+ // A plain <img> (not a framework Image): this is a framework-agnostic SDK, so
413
+ // the host's image component is unavailable and unwanted here.
414
+ const img = (
415
+ <img
416
+ src={src}
417
+ alt={alt}
418
+ onError={() => setFailed(true)}
419
+ className="h-full w-full object-cover transition-opacity group-hover/thumb:opacity-80"
420
+ />
421
+ );
422
+ if (!lightbox) {
423
+ return <span className={cn(MEDIA_BOX, "inline-flex overflow-hidden bg-og-bg")}>{img}</span>;
424
+ }
425
+ // The thumbnail is a real, independently-focusable button nested inside the
426
+ // row's disclosure trigger. It stops both pointer AND keyboard activation from
427
+ // bubbling, so opening the lightbox never also toggles the row.
428
+ return (
429
+ <button
430
+ type="button"
431
+ onClick={(event) => {
432
+ event.stopPropagation();
433
+ lightbox.open(src, caption);
434
+ }}
435
+ onKeyDown={(event) => {
436
+ if (event.key === "Enter" || event.key === " ") {
437
+ event.stopPropagation();
438
+ }
439
+ }}
440
+ className={cn(
441
+ MEDIA_BOX,
442
+ "group/thumb relative inline-flex overflow-hidden bg-og-bg outline-none",
443
+ "focus-visible:ring-2 focus-visible:ring-og-accent",
444
+ )}
445
+ aria-label="Expand screenshot"
446
+ >
447
+ {img}
448
+ </button>
449
+ );
450
+ }
451
+
452
+ /**
453
+ * The expanded screenshot inside a tool body: a contained, clickable preview
454
+ * (opens the lightbox) with a quiet caption. Constrained height + object-contain
455
+ * so it never breaks the row layout. Like {@link Thumbnail}, it degrades to a
456
+ * plain image outside a `LightboxProvider`.
457
+ */
458
+ export function ScreenshotFigure({ src, caption, alt = "screenshot" }: { src: string; caption?: string | undefined; alt?: string }) {
459
+ const lightbox = useLightboxOptional();
460
+ const [failed, setFailed] = useState(false);
461
+ const surface = "block w-full overflow-hidden rounded-og-md border border-og-border bg-og-bg";
462
+ // A plain <img>, like {@link Thumbnail} — a framework-agnostic SDK has no host
463
+ // Image component to defer to.
464
+ const img = <img src={src} alt={alt} onError={() => setFailed(true)} className="max-h-80 w-full object-contain" />;
465
+ return (
466
+ <figure className="m-0 min-w-0">
467
+ {failed ? (
468
+ <div className="rounded-og-md border border-og-border bg-og-bg px-3 py-6 text-center font-og-mono text-og-xs text-og-fg-subtle">
469
+ image unavailable
470
+ </div>
471
+ ) : lightbox ? (
472
+ <button type="button" onClick={() => lightbox.open(src, caption)} className={surface} aria-label="Expand screenshot">
473
+ {img}
474
+ </button>
475
+ ) : (
476
+ <div className={surface}>{img}</div>
477
+ )}
478
+ {caption ? <figcaption className="mt-1.5 font-og-mono text-og-xs text-og-fg-subtle">{caption}</figcaption> : null}
479
+ </figure>
480
+ );
481
+ }