@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.
- package/dist/index.d.ts +1055 -27
- package/dist/index.js +6993 -1954
- package/dist/index.js.map +1 -1
- package/package.json +65 -2
- package/src/client.ts +22 -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/fleet-tile.tsx +5 -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-session.ts +80 -12
- package/src/hooks/use-terminal-stream.ts +207 -0
- package/src/index.ts +112 -3
- package/src/lib/cn.ts +20 -1
- package/src/lib/git-patch.ts +43 -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 +253 -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
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
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 =
|
|
56
|
+
item.status = disposition;
|
|
152
57
|
}
|
|
153
58
|
if (item.kind === "sandbox" && item.status === "running") {
|
|
154
|
-
item.status =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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 (
|
|
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(
|
|
380
|
+
open.items.push(item);
|
|
459
381
|
} else {
|
|
460
|
-
groups.push({ kind: "activity", id: `activity-${item.id}`, items: [
|
|
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
|
+
}
|