@opengeni/react 0.3.1 → 0.5.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 (42) hide show
  1. package/dist/index.d.ts +1055 -27
  2. package/dist/index.js +6993 -1954
  3. package/dist/index.js.map +1 -1
  4. package/package.json +65 -2
  5. package/src/client.ts +22 -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/fleet-tile.tsx +5 -0
  11. package/src/components/message-timeline.tsx +70 -196
  12. package/src/components/pierre-diff.tsx +140 -0
  13. package/src/components/pierre-file.tsx +142 -0
  14. package/src/components/sandbox-files.tsx +509 -0
  15. package/src/components/sandbox-terminal.tsx +425 -0
  16. package/src/components/workspace-dock.tsx +247 -0
  17. package/src/hooks/use-desktop-stream.ts +214 -0
  18. package/src/hooks/use-sandbox-files.ts +670 -0
  19. package/src/hooks/use-sandbox-git.ts +105 -0
  20. package/src/hooks/use-sandbox-terminal.ts +226 -0
  21. package/src/hooks/use-session-capabilities.ts +415 -0
  22. package/src/hooks/use-session.ts +80 -12
  23. package/src/hooks/use-terminal-stream.ts +207 -0
  24. package/src/index.ts +112 -3
  25. package/src/lib/cn.ts +20 -1
  26. package/src/lib/git-patch.ts +43 -0
  27. package/src/lib/use-theme-type.ts +40 -0
  28. package/src/lib/xterm-theme.ts +34 -0
  29. package/src/timeline/activity-rail.tsx +207 -0
  30. package/src/timeline/disclosure-context.tsx +34 -0
  31. package/src/timeline/index.ts +85 -0
  32. package/src/timeline/parsers.ts +253 -0
  33. package/src/{timeline.ts → timeline/projection.ts} +59 -134
  34. package/src/timeline/registry.ts +96 -0
  35. package/src/timeline/screenshot-lightbox.tsx +152 -0
  36. package/src/timeline/shared.tsx +481 -0
  37. package/src/timeline/tool-diff.tsx +91 -0
  38. package/src/timeline/tool-renderers.tsx +882 -0
  39. package/src/timeline/turn-summary.tsx +125 -0
  40. package/src/timeline/types.ts +131 -0
  41. package/src/types/external.d.ts +7 -0
  42. package/styles/index.css +72 -0
@@ -1,5 +1,16 @@
1
- import type { ResourceRef, SessionEvent, SessionStatus, ToolRef } from "@opengeni/sdk";
2
- import { stringifyPayload, tryParseJson } from "./lib/format";
1
+ import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
2
+ import { tryParseJson } from "../lib/format";
3
+ import type {
4
+ AgentMessageItem,
5
+ ActivityItem,
6
+ GoalItem,
7
+ SandboxItem,
8
+ SessionStatusItem,
9
+ TimelineGroup,
10
+ TimelineItem,
11
+ ToolCallItem,
12
+ WorkerItem,
13
+ } from "./types";
3
14
 
4
15
  /* ----------------------------------------------------------------------------
5
16
  Timeline projection
@@ -15,112 +26,6 @@ import { stringifyPayload, tryParseJson } from "./lib/format";
15
26
  memoized, unit-tested, and re-run incrementally as new events stream in.
16
27
  -------------------------------------------------------------------------- */
17
28
 
18
- export type UserMessageItem = {
19
- kind: "user-message";
20
- id: string;
21
- text: string;
22
- /** Resources attached to this message (file uploads, repositories). */
23
- resources: ResourceRef[];
24
- /** Tools requested for the turn this message starts. */
25
- tools: ToolRef[];
26
- occurredAt: string;
27
- };
28
-
29
- export type AgentMessageItem = {
30
- kind: "agent-message";
31
- id: string;
32
- turnId: string | null;
33
- text: string;
34
- /** Still receiving deltas (no completed/turn-end seen yet). */
35
- streaming: boolean;
36
- occurredAt: string;
37
- };
38
-
39
- export type ReasoningItem = {
40
- kind: "reasoning";
41
- id: string;
42
- turnId: string | null;
43
- text: string;
44
- streaming: boolean;
45
- occurredAt: string;
46
- };
47
-
48
- export type ToolCallItem = {
49
- kind: "tool-call";
50
- id: string;
51
- turnId: string | null;
52
- callId: string | null;
53
- name: string;
54
- arguments: unknown;
55
- output: unknown;
56
- status: "running" | "complete";
57
- occurredAt: string;
58
- };
59
-
60
- /**
61
- * An orchestration call against another session — the manager spawning or
62
- * messaging a worker. Rendered as a first-class "worker" row, not a generic
63
- * tool call.
64
- */
65
- export type WorkerItem = {
66
- kind: "worker";
67
- id: string;
68
- turnId: string | null;
69
- callId: string | null;
70
- action: "spawn" | "message";
71
- /** The worker's initial message / the message sent to it, when parseable. */
72
- prompt: string | null;
73
- /** The target/spawned worker session id, when parseable from args/output. */
74
- workerSessionId: string | null;
75
- status: "running" | "complete";
76
- occurredAt: string;
77
- };
78
-
79
- export type SandboxItem = {
80
- kind: "sandbox";
81
- id: string;
82
- turnId: string | null;
83
- name: string;
84
- command: string | null;
85
- output: string;
86
- status: "running" | "complete" | "failed";
87
- occurredAt: string;
88
- };
89
-
90
- export type SessionStatusItem = {
91
- kind: "session-status";
92
- id: string;
93
- status: SessionStatus;
94
- occurredAt: string;
95
- };
96
-
97
- export type GoalItem = {
98
- kind: "goal";
99
- id: string;
100
- action: "set" | "updated" | "completed" | "paused" | "resumed" | "continuation";
101
- text: string | null;
102
- occurredAt: string;
103
- };
104
-
105
- export type NoticeItem = {
106
- kind: "notice";
107
- id: string;
108
- tone: "waiting" | "cancelled" | "failed";
109
- text: string;
110
- occurredAt: string;
111
- };
112
-
113
- export type TimelineItem =
114
- | UserMessageItem
115
- | AgentMessageItem
116
- | ReasoningItem
117
- | ToolCallItem
118
- | WorkerItem
119
- | SandboxItem
120
- | SessionStatusItem
121
- | GoalItem
122
- | NoticeItem;
123
-
124
29
  /** Tool names on the first-party OpenGeni MCP server that operate on sessions. */
125
30
  const WORKER_SPAWN_TOOL = "session_create";
126
31
  const WORKER_MESSAGE_TOOL = "session_send_message";
@@ -139,7 +44,7 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
139
44
  }
140
45
  };
141
46
 
142
- const finalizeOpen = (turnId?: string | null): void => {
47
+ const finalizeOpen = (turnId?: string | null, disposition: "complete" | "failed" | "cancelled" = "complete"): void => {
143
48
  for (const item of items) {
144
49
  if (turnId !== undefined && "turnId" in item && item.turnId && turnId && item.turnId !== turnId) {
145
50
  continue;
@@ -148,10 +53,10 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
148
53
  item.streaming = false;
149
54
  }
150
55
  if ((item.kind === "tool-call" || item.kind === "worker") && item.status === "running") {
151
- item.status = "complete";
56
+ item.status = disposition;
152
57
  }
153
58
  if (item.kind === "sandbox" && item.status === "running") {
154
- item.status = "complete";
59
+ item.status = disposition;
155
60
  }
156
61
  }
157
62
  };
@@ -276,6 +181,9 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
276
181
  name,
277
182
  arguments: args,
278
183
  output: undefined,
184
+ // The provider-native item drives the per-tool renderers (apply_patch
185
+ // operation, computer_call action, web_search providerData, …).
186
+ raw: payload.raw,
279
187
  status: "running",
280
188
  occurredAt: event.occurredAt,
281
189
  });
@@ -289,11 +197,15 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
289
197
  break;
290
198
  }
291
199
  if (target.kind === "worker") {
292
- target.status = "complete";
200
+ // A worker spawn/message that returns an error flag (or an MCP
201
+ // isError result) settles to "failed" too, so WorkerRow surfaces it.
202
+ target.status = isErrorOutput(payload) ? "failed" : "complete";
293
203
  target.workerSessionId = target.workerSessionId ?? extractSessionRef(payload.output);
294
204
  break;
295
205
  }
296
- target.status = "complete";
206
+ // An output carrying an explicit error flag (or an MCP isError result)
207
+ // settles the tool to "failed" so the renderer can surface it loudly.
208
+ target.status = isErrorOutput(payload) ? "failed" : "complete";
297
209
  target.output = payload.output;
298
210
  break;
299
211
  }
@@ -375,7 +287,7 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
375
287
  }
376
288
 
377
289
  case "turn.failed": {
378
- finalizeOpen(turnId);
290
+ finalizeOpen(turnId, "failed");
379
291
  items.push({
380
292
  kind: "notice",
381
293
  id: event.id,
@@ -387,7 +299,7 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
387
299
  }
388
300
 
389
301
  case "turn.cancelled": {
390
- finalizeOpen(turnId);
302
+ finalizeOpen(turnId, "cancelled");
391
303
  items.push({
392
304
  kind: "notice",
393
305
  id: event.id,
@@ -442,22 +354,32 @@ export function sessionStatusFromEvents(events: SessionEvent[]): SessionStatus |
442
354
  sandbox) cluster into one collapsible block between chat messages.
443
355
  -------------------------------------------------------------------------- */
444
356
 
445
- export type TimelineGroup =
446
- | { kind: "item"; item: TimelineItem }
447
- | { kind: "activity"; id: string; items: (ReasoningItem | ToolCallItem | WorkerItem | SandboxItem)[] };
448
-
449
- const ACTIVITY_KINDS = new Set(["reasoning", "tool-call", "worker", "sandbox"]);
357
+ /**
358
+ * Whether an item clusters into an activity block. A `switch` (not a stringly-
359
+ * typed set) so adding an {@link ActivityItem} kind is a compile-time prompt to
360
+ * decide its grouping — and it narrows `item` to `ActivityItem` with no cast.
361
+ */
362
+ function isActivityItem(item: TimelineItem): item is ActivityItem {
363
+ switch (item.kind) {
364
+ case "reasoning":
365
+ case "tool-call":
366
+ case "worker":
367
+ case "sandbox":
368
+ return true;
369
+ default:
370
+ return false;
371
+ }
372
+ }
450
373
 
451
374
  export function groupTimeline(items: TimelineItem[]): TimelineGroup[] {
452
375
  const groups: TimelineGroup[] = [];
453
376
  for (const item of items) {
454
- if (ACTIVITY_KINDS.has(item.kind)) {
377
+ if (isActivityItem(item)) {
455
378
  const open = groups[groups.length - 1];
456
- const activity = item as ReasoningItem | ToolCallItem | WorkerItem | SandboxItem;
457
379
  if (open?.kind === "activity") {
458
- open.items.push(activity);
380
+ open.items.push(item);
459
381
  } else {
460
- groups.push({ kind: "activity", id: `activity-${item.id}`, items: [activity] });
382
+ groups.push({ kind: "activity", id: `activity-${item.id}`, items: [item] });
461
383
  }
462
384
  continue;
463
385
  }
@@ -475,11 +397,11 @@ function asRecord(value: unknown): Record<string, unknown> {
475
397
  const SESSION_STATUSES: readonly SessionStatus[] = ["queued", "running", "idle", "requires_action", "failed", "cancelled"];
476
398
 
477
399
  /** Keep only entries that match the wire shapes; user payloads are untyped. */
478
- function resourceRefs(value: unknown): ResourceRef[] {
400
+ function resourceRefs(value: unknown): import("@opengeni/sdk").ResourceRef[] {
479
401
  if (!Array.isArray(value)) {
480
402
  return [];
481
403
  }
482
- return value.filter((entry): entry is ResourceRef => {
404
+ return value.filter((entry): entry is import("@opengeni/sdk").ResourceRef => {
483
405
  const record = asRecord(entry);
484
406
  if (record.kind === "repository") {
485
407
  return typeof record.uri === "string" && typeof record.ref === "string";
@@ -488,11 +410,11 @@ function resourceRefs(value: unknown): ResourceRef[] {
488
410
  });
489
411
  }
490
412
 
491
- function toolRefs(value: unknown): ToolRef[] {
413
+ function toolRefs(value: unknown): import("@opengeni/sdk").ToolRef[] {
492
414
  if (!Array.isArray(value)) {
493
415
  return [];
494
416
  }
495
- return value.filter((entry): entry is ToolRef => {
417
+ return value.filter((entry): entry is import("@opengeni/sdk").ToolRef => {
496
418
  const record = asRecord(entry);
497
419
  return record.kind === "mcp" && typeof record.id === "string";
498
420
  });
@@ -502,6 +424,15 @@ function isSessionStatus(value: unknown): value is SessionStatus {
502
424
  return typeof value === "string" && (SESSION_STATUSES as readonly string[]).includes(value);
503
425
  }
504
426
 
427
+ /** Does this tool output represent an error (explicit flag or MCP `isError`)? */
428
+ function isErrorOutput(payload: Record<string, unknown>): boolean {
429
+ if (payload.error === true || payload.failed === true) {
430
+ return true;
431
+ }
432
+ const output = payload.output;
433
+ return !!output && typeof output === "object" && (output as { isError?: unknown }).isError === true;
434
+ }
435
+
505
436
  function findOpenCall(items: TimelineItem[], callId: string | null): ToolCallItem | WorkerItem | undefined {
506
437
  const reversed = [...items].reverse();
507
438
  const isCall = (item: TimelineItem): item is ToolCallItem | WorkerItem => item.kind === "tool-call" || item.kind === "worker";
@@ -624,9 +555,3 @@ function looksLikeId(value: string): boolean {
624
555
  export function toolDisplayName(name: string): string {
625
556
  return name.replace(/[_-]+/g, " ").trim();
626
557
  }
627
-
628
- /** Compact, single-line preview of tool arguments/outputs for collapsed rows. */
629
- export function compactPayloadPreview(value: unknown, maxLength = 120): string {
630
- const text = stringifyPayload(value).replace(/\s+/g, " ").trim();
631
- return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
632
- }
@@ -0,0 +1,96 @@
1
+ import type { ComponentType } from "react";
2
+ import type { ToolCallItem } from "./types";
3
+
4
+ /* ----------------------------------------------------------------------------
5
+ Tool renderer registry
6
+
7
+ The extension point. A `ToolRenderer` is a React component fed one projected
8
+ `ToolCallItem`; the registry resolves which renderer handles a given call,
9
+ keyed on the tool `name` and (secondarily) its provider-native `raw.type`.
10
+
11
+ Resolution order (most → least specific):
12
+ 1. exact match on `raw.type` (e.g. "apply_patch_call", "computer_call")
13
+ 2. exact match on the tool `name` (e.g. "exec_command", "web_search_call")
14
+ 3. the registry's generic fallback
15
+
16
+ A consumer extends the defaults without forking by passing overrides to
17
+ `createToolRegistry` — e.g. a custom renderer for their own MCP tool, or a
18
+ replacement for a built-in one. The registry is immutable and fully typed.
19
+ -------------------------------------------------------------------------- */
20
+
21
+ export type ToolRendererProps = {
22
+ item: ToolCallItem;
23
+ };
24
+
25
+ export type ToolRenderer = ComponentType<ToolRendererProps>;
26
+
27
+ /** A registry entry: which key it matches and the component that renders it. */
28
+ export type ToolRegistryEntry =
29
+ | { match: "rawType"; type: string; render: ToolRenderer }
30
+ | { match: "name"; name: string; render: ToolRenderer };
31
+
32
+ export type ToolRegistry = {
33
+ /** Resolve the renderer for a call (never null — falls back to generic). */
34
+ resolve: (item: ToolCallItem) => ToolRenderer;
35
+ /** The generic fallback renderer. */
36
+ fallback: ToolRenderer;
37
+ };
38
+
39
+ export type CreateToolRegistryOptions = {
40
+ /**
41
+ * Entries that take precedence over the built-ins. Earlier entries win, so a
42
+ * consumer can shadow a default renderer for the same key.
43
+ */
44
+ entries?: ToolRegistryEntry[] | undefined;
45
+ /** Replace the generic fallback used for unmatched tools. */
46
+ fallback?: ToolRenderer | undefined;
47
+ };
48
+
49
+ /** The `raw.type` of a projected tool call, when the provider item carries one. */
50
+ export function rawTypeOf(item: ToolCallItem): string | null {
51
+ const raw = item.raw;
52
+ if (raw && typeof raw === "object" && typeof (raw as { type?: unknown }).type === "string") {
53
+ return (raw as { type: string }).type;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Build a tool registry from a set of entries and a fallback. The returned
60
+ * registry resolves in priority order: `raw.type` entries first, then `name`
61
+ * entries, then the fallback. Consumer `entries` are consulted before the
62
+ * built-in `baseEntries`, so they shadow defaults cleanly.
63
+ */
64
+ export function createToolRegistry(
65
+ baseEntries: ToolRegistryEntry[],
66
+ baseFallback: ToolRenderer,
67
+ options: CreateToolRegistryOptions = {},
68
+ ): ToolRegistry {
69
+ const entries = [...(options.entries ?? []), ...baseEntries];
70
+ const fallback = options.fallback ?? baseFallback;
71
+
72
+ const byRawType = new Map<string, ToolRenderer>();
73
+ const byName = new Map<string, ToolRenderer>();
74
+ for (const entry of entries) {
75
+ if (entry.match === "rawType") {
76
+ if (!byRawType.has(entry.type)) {
77
+ byRawType.set(entry.type, entry.render);
78
+ }
79
+ } else if (!byName.has(entry.name)) {
80
+ byName.set(entry.name, entry.render);
81
+ }
82
+ }
83
+
84
+ const resolve = (item: ToolCallItem): ToolRenderer => {
85
+ const rawType = rawTypeOf(item);
86
+ if (rawType) {
87
+ const byType = byRawType.get(rawType);
88
+ if (byType) {
89
+ return byType;
90
+ }
91
+ }
92
+ return byName.get(item.name) ?? fallback;
93
+ };
94
+
95
+ return { resolve, fallback };
96
+ }
@@ -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
+ }