@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.
- package/dist/index.d.ts +1035 -14
- package/dist/index.js +6867 -1884
- package/dist/index.js.map +1 -1
- package/package.json +65 -2
- package/src/client.ts +21 -0
- package/src/components/code-editor.tsx +398 -0
- package/src/components/desktop-viewer.tsx +647 -0
- package/src/components/diff-view.tsx +230 -0
- package/src/components/file-browser.tsx +838 -0
- package/src/components/message-timeline.tsx +70 -196
- package/src/components/pierre-diff.tsx +140 -0
- package/src/components/pierre-file.tsx +142 -0
- package/src/components/sandbox-files.tsx +509 -0
- package/src/components/sandbox-terminal.tsx +425 -0
- package/src/components/workspace-dock.tsx +247 -0
- package/src/hooks/use-desktop-stream.ts +214 -0
- package/src/hooks/use-sandbox-files.ts +670 -0
- package/src/hooks/use-sandbox-git.ts +105 -0
- package/src/hooks/use-sandbox-terminal.ts +226 -0
- package/src/hooks/use-session-capabilities.ts +415 -0
- package/src/hooks/use-terminal-stream.ts +207 -0
- package/src/index.ts +111 -2
- package/src/lib/cn.ts +20 -1
- package/src/lib/git-patch.ts +37 -0
- package/src/lib/use-theme-type.ts +40 -0
- package/src/lib/xterm-theme.ts +34 -0
- package/src/timeline/activity-rail.tsx +207 -0
- package/src/timeline/disclosure-context.tsx +34 -0
- package/src/timeline/index.ts +85 -0
- package/src/timeline/parsers.ts +248 -0
- package/src/{timeline.ts → timeline/projection.ts} +59 -134
- package/src/timeline/registry.ts +96 -0
- package/src/timeline/screenshot-lightbox.tsx +152 -0
- package/src/timeline/shared.tsx +481 -0
- package/src/timeline/tool-diff.tsx +91 -0
- package/src/timeline/tool-renderers.tsx +882 -0
- package/src/timeline/turn-summary.tsx +125 -0
- package/src/timeline/types.ts +131 -0
- package/src/types/external.d.ts +7 -0
- 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
|
+
}
|