@opengeni/react 0.3.0 → 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
|
@@ -1,34 +1,32 @@
|
|
|
1
1
|
import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
|
|
2
2
|
import {
|
|
3
3
|
ArrowDownIcon,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
ArrowRightIcon,
|
|
5
|
+
CheckIcon,
|
|
6
|
+
PauseIcon,
|
|
7
|
+
PencilLineIcon,
|
|
8
|
+
PlayIcon,
|
|
8
9
|
TargetIcon,
|
|
9
10
|
TriangleAlertIcon,
|
|
10
|
-
WrenchIcon,
|
|
11
11
|
} from "lucide-react";
|
|
12
|
+
import type { ComponentType } from "react";
|
|
12
13
|
import { AnimatePresence, motion } from "motion/react";
|
|
13
14
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
14
|
-
import { Collapsible } from "radix-ui";
|
|
15
15
|
import { cn } from "../lib/cn";
|
|
16
|
-
import { formatRelativeTime,
|
|
16
|
+
import { formatRelativeTime, truncate } from "../lib/format";
|
|
17
17
|
import { Markdown } from "./markdown";
|
|
18
18
|
import {
|
|
19
|
+
ActivityRail,
|
|
19
20
|
buildTimeline,
|
|
20
|
-
|
|
21
|
+
defaultToolRegistry,
|
|
21
22
|
groupTimeline,
|
|
22
|
-
|
|
23
|
+
LightboxProvider,
|
|
23
24
|
type AgentMessageItem,
|
|
24
25
|
type GoalItem,
|
|
25
26
|
type NoticeItem,
|
|
26
|
-
type ReasoningItem,
|
|
27
|
-
type SandboxItem,
|
|
28
27
|
type TimelineItem,
|
|
29
|
-
type
|
|
28
|
+
type ToolRegistry,
|
|
30
29
|
type UserMessageItem,
|
|
31
|
-
type WorkerItem,
|
|
32
30
|
} from "../timeline";
|
|
33
31
|
import { SESSION_STATUS_META, StatusDot } from "./session-status";
|
|
34
32
|
|
|
@@ -43,6 +41,12 @@ export type MessageTimelineProps = {
|
|
|
43
41
|
renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
44
42
|
/** Drill into a spawned worker session. */
|
|
45
43
|
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* The tool-renderer registry that resolves how each tool call is drawn.
|
|
46
|
+
* Defaults to {@link defaultToolRegistry}; pass a registry from
|
|
47
|
+
* `createDefaultToolRegistry({ entries })` to add custom tool renderers.
|
|
48
|
+
*/
|
|
49
|
+
toolRegistry?: ToolRegistry | undefined;
|
|
46
50
|
/** Follow new events when pinned to the bottom. Defaults to true. */
|
|
47
51
|
autoFollow?: boolean | undefined;
|
|
48
52
|
emptyState?: ReactNode | undefined;
|
|
@@ -61,6 +65,7 @@ export function MessageTimeline({
|
|
|
61
65
|
status,
|
|
62
66
|
renderMessageText,
|
|
63
67
|
onOpenSession,
|
|
68
|
+
toolRegistry = defaultToolRegistry,
|
|
64
69
|
autoFollow = true,
|
|
65
70
|
emptyState,
|
|
66
71
|
className,
|
|
@@ -91,15 +96,16 @@ export function MessageTimeline({
|
|
|
91
96
|
};
|
|
92
97
|
|
|
93
98
|
return (
|
|
94
|
-
<
|
|
95
|
-
|
|
99
|
+
<LightboxProvider>
|
|
100
|
+
<div className={cn("og-root relative flex min-h-0 flex-col", className)}>
|
|
101
|
+
<div ref={scrollRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 sm:px-6">
|
|
96
102
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
|
|
97
103
|
{groups.length === 0 && !working
|
|
98
104
|
? (emptyState ?? <p className="py-10 text-center text-sm text-og-fg-subtle">No activity yet.</p>)
|
|
99
105
|
: null}
|
|
100
106
|
{groups.map((group) =>
|
|
101
107
|
group.kind === "activity" ? (
|
|
102
|
-
<
|
|
108
|
+
<ActivityRail key={group.id} items={group.items} onOpenSession={onOpenSession} toolRegistry={toolRegistry} />
|
|
103
109
|
) : (
|
|
104
110
|
<TimelineRow key={group.item.id} item={group.item} renderMessageText={renderMessageText} />
|
|
105
111
|
),
|
|
@@ -139,12 +145,18 @@ export function MessageTimeline({
|
|
|
139
145
|
) : null}
|
|
140
146
|
</AnimatePresence>
|
|
141
147
|
</div>
|
|
148
|
+
</LightboxProvider>
|
|
142
149
|
);
|
|
143
150
|
}
|
|
144
151
|
|
|
145
152
|
/* --- single rows ------------------------------------------------------------ */
|
|
146
153
|
|
|
147
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Render one non-activity timeline item (chat message, status divider, goal
|
|
156
|
+
* landmark, notice). Exported so the component demo draws the EXACT same rows as
|
|
157
|
+
* the live app — no forked bubble/goal markup.
|
|
158
|
+
*/
|
|
159
|
+
export function TimelineRow({
|
|
148
160
|
item,
|
|
149
161
|
renderMessageText,
|
|
150
162
|
}: {
|
|
@@ -176,7 +188,7 @@ function UserMessageRow({
|
|
|
176
188
|
}) {
|
|
177
189
|
return (
|
|
178
190
|
<div className="animate-og-enter flex justify-end">
|
|
179
|
-
<div className="max-w-[85%] min-w-0 rounded-og-lg rounded-br-og-xs border border-og-border bg-og-surface-2 px-4 py-2.5 text-
|
|
191
|
+
<div className="max-w-[85%] min-w-0 rounded-og-lg rounded-br-og-xs border border-og-border bg-og-surface-2 px-4 py-2.5 text-og-md leading-6 text-og-fg">
|
|
180
192
|
{renderMessageText ? renderMessageText(item.text, item) : <Markdown>{item.text}</Markdown>}
|
|
181
193
|
</div>
|
|
182
194
|
</div>
|
|
@@ -194,7 +206,7 @@ function AgentMessageRow({
|
|
|
194
206
|
<span className="ml-0.5 inline-block h-[1.1em] w-[2px] translate-y-[3px] animate-og-blink rounded-full bg-og-accent" aria-hidden />
|
|
195
207
|
) : null;
|
|
196
208
|
return (
|
|
197
|
-
<div className="animate-og-enter min-w-0 text-
|
|
209
|
+
<div className="animate-og-enter min-w-0 text-og-md leading-7 text-og-fg">
|
|
198
210
|
{renderMessageText ? (
|
|
199
211
|
<>
|
|
200
212
|
{renderMessageText(item.text, item)}
|
|
@@ -216,7 +228,7 @@ function AgentMessageRow({
|
|
|
216
228
|
function SessionStatusRow({ item }: { item: { status: SessionStatus; occurredAt: string } }) {
|
|
217
229
|
const meta = SESSION_STATUS_META[item.status];
|
|
218
230
|
return (
|
|
219
|
-
<div className="animate-og-enter flex items-center gap-3 text-
|
|
231
|
+
<div className="animate-og-enter flex items-center gap-3 text-og-xs text-og-fg-subtle" role="status">
|
|
220
232
|
<span className="h-px flex-1 bg-og-border" />
|
|
221
233
|
<span className="inline-flex items-center gap-1.5">
|
|
222
234
|
<StatusDot status={item.status} className="size-1" />
|
|
@@ -227,23 +239,47 @@ function SessionStatusRow({ item }: { item: { status: SessionStatus; occurredAt:
|
|
|
227
239
|
);
|
|
228
240
|
}
|
|
229
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The per-action presentation of a goal landmark pill. Each of the six goal
|
|
244
|
+
* actions reads distinctly, but the palette stays quiet — color is spent only on
|
|
245
|
+
* the two states that genuinely earn it, the rest are neutral pills set apart by
|
|
246
|
+
* their glyph alone:
|
|
247
|
+
*
|
|
248
|
+
* completed success green (status-idle) check — the only "done" hue
|
|
249
|
+
* paused attention waiting-tinted pause — a held goal asks to resume
|
|
250
|
+
* set a landmark a quiet accent target — opening a fresh goal
|
|
251
|
+
* resumed forward neutral play — motion picking back up
|
|
252
|
+
* updated a revision neutral pencil — the goal text changed
|
|
253
|
+
* continuation steady on neutral arrow — still tracking the same goal
|
|
254
|
+
*
|
|
255
|
+
* The pill class is the established badge convention (`text-X border-X/30
|
|
256
|
+
* bg-X/10`); neutral actions reuse the surface/border tokens so a clean run of
|
|
257
|
+
* landmarks stays calm rather than a row of colored chips.
|
|
258
|
+
*/
|
|
259
|
+
type GoalMeta = { label: string; pill: string; icon: ComponentType<{ className?: string }> };
|
|
260
|
+
|
|
261
|
+
const NEUTRAL_PILL = "border-og-border bg-og-surface-1 text-og-fg-muted";
|
|
262
|
+
|
|
263
|
+
const GOAL_META: Record<GoalItem["action"], GoalMeta> = {
|
|
264
|
+
set: { label: "Goal set", pill: "border-og-accent/30 bg-og-accent/10 text-og-accent", icon: TargetIcon },
|
|
265
|
+
updated: { label: "Goal updated", pill: NEUTRAL_PILL, icon: PencilLineIcon },
|
|
266
|
+
completed: { label: "Goal completed", pill: "border-og-status-idle/30 bg-og-status-idle/10 text-og-status-idle", icon: CheckIcon },
|
|
267
|
+
paused: { label: "Goal paused", pill: "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting", icon: PauseIcon },
|
|
268
|
+
resumed: { label: "Goal resumed", pill: NEUTRAL_PILL, icon: PlayIcon },
|
|
269
|
+
continuation: { label: "Continuing toward the goal", pill: NEUTRAL_PILL, icon: ArrowRightIcon },
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* A goal landmark pill. Resolves its label, accent/tone, and glyph from
|
|
274
|
+
* {@link GOAL_META} so all six actions are visually distinguishable while the
|
|
275
|
+
* palette stays restrained — see that table for the per-action rationale.
|
|
276
|
+
*/
|
|
230
277
|
function GoalRow({ item }: { item: GoalItem }) {
|
|
231
|
-
const label =
|
|
232
|
-
item.action === "set"
|
|
233
|
-
? "Goal set"
|
|
234
|
-
: item.action === "updated"
|
|
235
|
-
? "Goal updated"
|
|
236
|
-
: item.action === "completed"
|
|
237
|
-
? "Goal completed"
|
|
238
|
-
: item.action === "paused"
|
|
239
|
-
? "Goal paused"
|
|
240
|
-
: item.action === "resumed"
|
|
241
|
-
? "Goal resumed"
|
|
242
|
-
: "Continuing toward the goal";
|
|
278
|
+
const { label, pill, icon: Icon } = GOAL_META[item.action];
|
|
243
279
|
return (
|
|
244
280
|
<div className="animate-og-enter flex justify-center">
|
|
245
|
-
<span className="inline-flex max-w-full items-center gap-1.5 rounded-full border
|
|
246
|
-
<
|
|
281
|
+
<span className={cn("inline-flex max-w-full items-center gap-1.5 rounded-full border px-3 py-1 text-og-sm", pill)}>
|
|
282
|
+
<Icon className="size-3.5 shrink-0" />
|
|
247
283
|
<span className="truncate">
|
|
248
284
|
{label}
|
|
249
285
|
{item.text ? `: ${truncate(item.text, 90)}` : ""}
|
|
@@ -268,165 +304,3 @@ function NoticeRow({ item }: { item: NoticeItem }) {
|
|
|
268
304
|
);
|
|
269
305
|
}
|
|
270
306
|
|
|
271
|
-
/* --- activity cluster -------------------------------------------------------- */
|
|
272
|
-
|
|
273
|
-
type ActivityItem = ReasoningItem | ToolCallItem | WorkerItem | SandboxItem;
|
|
274
|
-
|
|
275
|
-
function ActivityCluster({
|
|
276
|
-
items,
|
|
277
|
-
onOpenSession,
|
|
278
|
-
}: {
|
|
279
|
-
items: ActivityItem[];
|
|
280
|
-
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
281
|
-
}) {
|
|
282
|
-
return (
|
|
283
|
-
<div className="animate-og-enter flex flex-col gap-1.5 border-l-2 border-og-border pl-3 sm:pl-4">
|
|
284
|
-
{items.map((item) => {
|
|
285
|
-
switch (item.kind) {
|
|
286
|
-
case "reasoning":
|
|
287
|
-
return <ReasoningRow key={item.id} item={item} />;
|
|
288
|
-
case "tool-call":
|
|
289
|
-
return <ToolCallRow key={item.id} item={item} />;
|
|
290
|
-
case "worker":
|
|
291
|
-
return <WorkerRow key={item.id} item={item} onOpenSession={onOpenSession} />;
|
|
292
|
-
case "sandbox":
|
|
293
|
-
return <SandboxRow key={item.id} item={item} />;
|
|
294
|
-
}
|
|
295
|
-
})}
|
|
296
|
-
</div>
|
|
297
|
-
);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
function ActivityDisclosure({
|
|
301
|
-
icon,
|
|
302
|
-
title,
|
|
303
|
-
running,
|
|
304
|
-
failed,
|
|
305
|
-
preview,
|
|
306
|
-
children,
|
|
307
|
-
}: {
|
|
308
|
-
icon: ReactNode;
|
|
309
|
-
title: string;
|
|
310
|
-
running: boolean;
|
|
311
|
-
failed?: boolean | undefined;
|
|
312
|
-
preview?: string | undefined;
|
|
313
|
-
children?: ReactNode;
|
|
314
|
-
}) {
|
|
315
|
-
const [open, setOpen] = useState(false);
|
|
316
|
-
return (
|
|
317
|
-
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
|
318
|
-
<Collapsible.Trigger
|
|
319
|
-
className={cn(
|
|
320
|
-
"group flex w-full min-w-0 items-center gap-2 rounded-og-sm px-1.5 py-1 text-left text-[13px]",
|
|
321
|
-
"text-og-fg-muted transition-colors duration-150 hover:bg-og-surface-1 hover:text-og-fg",
|
|
322
|
-
)}
|
|
323
|
-
>
|
|
324
|
-
<ChevronRightIcon className="size-3.5 shrink-0 text-og-fg-subtle transition-transform duration-150 group-data-[state=open]:rotate-90" />
|
|
325
|
-
<span className={cn("shrink-0", failed ? "text-og-status-failed" : running ? "text-og-status-running" : "text-og-fg-subtle")}>{icon}</span>
|
|
326
|
-
<span className={cn("shrink-0 font-medium", running && "og-shimmer-text", failed && "text-og-status-failed")}>{title}</span>
|
|
327
|
-
{preview ? <span className="min-w-0 flex-1 truncate font-og-mono text-xs text-og-fg-subtle">{preview}</span> : null}
|
|
328
|
-
{running ? <span className="ml-auto size-1.5 shrink-0 animate-og-pulse rounded-full bg-og-status-running" /> : null}
|
|
329
|
-
</Collapsible.Trigger>
|
|
330
|
-
<Collapsible.Content className="overflow-hidden">
|
|
331
|
-
<div className="mt-1 mb-1.5 ml-7 flex flex-col gap-2">{children}</div>
|
|
332
|
-
</Collapsible.Content>
|
|
333
|
-
</Collapsible.Root>
|
|
334
|
-
);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function PayloadBlock({ label, value }: { label: string; value: unknown }) {
|
|
338
|
-
const text = stringifyPayload(value);
|
|
339
|
-
if (!text) {
|
|
340
|
-
return null;
|
|
341
|
-
}
|
|
342
|
-
return (
|
|
343
|
-
<div className="min-w-0">
|
|
344
|
-
<p className="mb-1 text-[10px] font-medium uppercase tracking-[0.08em] text-og-fg-subtle">{label}</p>
|
|
345
|
-
<pre className="max-h-64 overflow-auto rounded-og-sm border border-og-border bg-og-bg/60 p-2.5 font-og-mono text-xs leading-5 text-og-fg-muted">
|
|
346
|
-
{text}
|
|
347
|
-
</pre>
|
|
348
|
-
</div>
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function ReasoningRow({ item }: { item: ReasoningItem }) {
|
|
353
|
-
return (
|
|
354
|
-
<ActivityDisclosure
|
|
355
|
-
icon={<BrainIcon className="size-3.5" />}
|
|
356
|
-
title={item.streaming ? "Thinking" : "Thought"}
|
|
357
|
-
running={item.streaming}
|
|
358
|
-
preview={truncate(item.text, 110)}
|
|
359
|
-
>
|
|
360
|
-
<p className="whitespace-pre-wrap text-[13px] leading-6 text-og-fg-muted">{item.text}</p>
|
|
361
|
-
</ActivityDisclosure>
|
|
362
|
-
);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function ToolCallRow({ item }: { item: ToolCallItem }) {
|
|
366
|
-
return (
|
|
367
|
-
<ActivityDisclosure
|
|
368
|
-
icon={<WrenchIcon className="size-3.5" />}
|
|
369
|
-
title={toolDisplayName(item.name)}
|
|
370
|
-
running={item.status === "running"}
|
|
371
|
-
preview={compactPayloadPreview(item.arguments)}
|
|
372
|
-
>
|
|
373
|
-
<PayloadBlock label="Arguments" value={item.arguments} />
|
|
374
|
-
{item.status === "complete" ? <PayloadBlock label="Output" value={item.output} /> : null}
|
|
375
|
-
</ActivityDisclosure>
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function SandboxRow({ item }: { item: SandboxItem }) {
|
|
380
|
-
return (
|
|
381
|
-
<ActivityDisclosure
|
|
382
|
-
icon={<SquareTerminalIcon className="size-3.5" />}
|
|
383
|
-
title={toolDisplayName(item.name)}
|
|
384
|
-
running={item.status === "running"}
|
|
385
|
-
failed={item.status === "failed"}
|
|
386
|
-
preview={item.command ?? undefined}
|
|
387
|
-
>
|
|
388
|
-
{item.command ? <PayloadBlock label="Command" value={item.command} /> : null}
|
|
389
|
-
{item.output ? <PayloadBlock label="Output" value={item.output} /> : null}
|
|
390
|
-
</ActivityDisclosure>
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/** Spawned/messaged worker sessions get a first-class card, not a tool row. */
|
|
395
|
-
function WorkerRow({ item, onOpenSession }: { item: WorkerItem; onOpenSession?: ((sessionId: string) => void) | undefined }) {
|
|
396
|
-
const running = item.status === "running";
|
|
397
|
-
const title = item.action === "spawn" ? (running ? "Spawning worker" : "Worker spawned") : running ? "Messaging worker" : "Worker messaged";
|
|
398
|
-
return (
|
|
399
|
-
<div className="my-0.5 flex items-start gap-3 rounded-og-md border border-og-border bg-og-surface-1 p-3 shadow-og-sm">
|
|
400
|
-
<span
|
|
401
|
-
className={cn(
|
|
402
|
-
"mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
|
|
403
|
-
"bg-og-accent-soft text-og-accent",
|
|
404
|
-
)}
|
|
405
|
-
>
|
|
406
|
-
<BotIcon className="size-4" />
|
|
407
|
-
</span>
|
|
408
|
-
<div className="min-w-0 flex-1">
|
|
409
|
-
<div className="flex items-center gap-2">
|
|
410
|
-
<span className={cn("text-[13px] font-medium", running ? "og-shimmer-text" : "text-og-fg")}>{title}</span>
|
|
411
|
-
{running ? <span className="size-1.5 animate-og-pulse rounded-full bg-og-status-running" /> : null}
|
|
412
|
-
</div>
|
|
413
|
-
{item.prompt ? <p className="mt-0.5 truncate text-xs text-og-fg-muted">{truncate(item.prompt, 140)}</p> : null}
|
|
414
|
-
{item.workerSessionId ? (
|
|
415
|
-
<p className="mt-1 font-og-mono text-[11px] text-og-fg-subtle">{item.workerSessionId.slice(0, 8)}</p>
|
|
416
|
-
) : null}
|
|
417
|
-
</div>
|
|
418
|
-
{item.workerSessionId && onOpenSession ? (
|
|
419
|
-
<button
|
|
420
|
-
type="button"
|
|
421
|
-
onClick={() => item.workerSessionId && onOpenSession(item.workerSessionId)}
|
|
422
|
-
className={cn(
|
|
423
|
-
"shrink-0 self-center rounded-og-sm border border-og-border px-2.5 py-1 text-xs font-medium text-og-fg-muted",
|
|
424
|
-
"transition-colors duration-150 hover:border-og-border-strong hover:text-og-fg",
|
|
425
|
-
)}
|
|
426
|
-
>
|
|
427
|
-
Open session
|
|
428
|
-
</button>
|
|
429
|
-
) : null}
|
|
430
|
-
</div>
|
|
431
|
-
);
|
|
432
|
-
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { GitFileDiff } from "@opengeni/sdk";
|
|
2
|
+
import {
|
|
3
|
+
type ComponentType,
|
|
4
|
+
type CSSProperties,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
lazy,
|
|
7
|
+
Suspense,
|
|
8
|
+
useEffect,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { cn } from "../lib/cn";
|
|
12
|
+
import { gitFileDiffToPatch } from "../lib/git-patch";
|
|
13
|
+
|
|
14
|
+
/** Pierre `PatchDiff` props subset we drive. */
|
|
15
|
+
type PatchDiffComponent = ComponentType<{
|
|
16
|
+
patch: string;
|
|
17
|
+
options?: {
|
|
18
|
+
theme?: string | { dark: string; light: string };
|
|
19
|
+
themeType?: "dark" | "light";
|
|
20
|
+
diffStyle?: "unified" | "split";
|
|
21
|
+
overflow?: "scroll" | "wrap";
|
|
22
|
+
stickyHeader?: boolean;
|
|
23
|
+
};
|
|
24
|
+
disableWorkerPool?: boolean;
|
|
25
|
+
className?: string;
|
|
26
|
+
}>;
|
|
27
|
+
|
|
28
|
+
export type PierreDiffProps = {
|
|
29
|
+
diff: GitFileDiff[];
|
|
30
|
+
layout?: "unified" | "split" | undefined;
|
|
31
|
+
themeType?: "dark" | "light" | undefined;
|
|
32
|
+
/** Shiki bundled theme names (dark/light) — derived from the host palette. */
|
|
33
|
+
theme?: { dark: string; light: string } | undefined;
|
|
34
|
+
/** Disable Pierre's worker pool if its worker bundling fights the host bundler. */
|
|
35
|
+
disableWorkerPool?: boolean | undefined;
|
|
36
|
+
/** Rendered while the (lazy) Pierre bundle loads. */
|
|
37
|
+
loading?: ReactNode | undefined;
|
|
38
|
+
/** Rendered if `@pierre/diffs/react` is not installed / fails to import. */
|
|
39
|
+
fallback?: ReactNode | undefined;
|
|
40
|
+
className?: string | undefined;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Lazy-load `@pierre/diffs/react` so Shiki + the worker pool stay off the
|
|
44
|
+
// critical path (and out of an SSR bundle) until a diff is actually shown. The
|
|
45
|
+
// dynamic specifier is static so the bundler can resolve + chunk it. If the
|
|
46
|
+
// optional peer is absent the import rejects and we render `fallback`.
|
|
47
|
+
const LazyPatchDiff = lazy(async () => {
|
|
48
|
+
const mod = (await import("@pierre/diffs/react")) as unknown as {
|
|
49
|
+
PatchDiff: PatchDiffComponent;
|
|
50
|
+
};
|
|
51
|
+
return { default: mod.PatchDiff };
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The Pierre-backed diff: Shiki-highlighted, virtualized, unified/split. Renders
|
|
56
|
+
* one `PatchDiff` per changed file (a reconstructed unified patch from the
|
|
57
|
+
* `GitFileDiff` hunks). This sits behind `DiffView`'s `fallback` seam so a host
|
|
58
|
+
* that lacks `@pierre/diffs` keeps the hand-rolled renderer.
|
|
59
|
+
*/
|
|
60
|
+
export function PierreDiff({
|
|
61
|
+
diff,
|
|
62
|
+
layout = "unified",
|
|
63
|
+
themeType,
|
|
64
|
+
theme,
|
|
65
|
+
disableWorkerPool,
|
|
66
|
+
loading,
|
|
67
|
+
fallback,
|
|
68
|
+
className,
|
|
69
|
+
}: PierreDiffProps) {
|
|
70
|
+
const [failed, setFailed] = useState(false);
|
|
71
|
+
|
|
72
|
+
// Probe the import once so a hard failure (peer missing) shows `fallback`
|
|
73
|
+
// rather than a Suspense boundary that never resolves.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
let cancelled = false;
|
|
76
|
+
void import("@pierre/diffs/react").catch(() => {
|
|
77
|
+
if (!cancelled) setFailed(true);
|
|
78
|
+
});
|
|
79
|
+
return () => {
|
|
80
|
+
cancelled = true;
|
|
81
|
+
};
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
if (failed && fallback !== undefined) {
|
|
85
|
+
return <div className={className}>{fallback}</div>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Default to the dark theme: the host UI is dark-first, and Pierre's own
|
|
89
|
+
// auto-detection otherwise lands on the light Shiki theme (a white diff pane
|
|
90
|
+
// inside a dark dock). Callers pass `themeType="light"` to opt into light.
|
|
91
|
+
const options = {
|
|
92
|
+
diffStyle: layout,
|
|
93
|
+
overflow: "scroll" as const,
|
|
94
|
+
stickyHeader: true,
|
|
95
|
+
...(theme ? { theme } : { theme: { dark: "github-dark", light: "github-light" } }),
|
|
96
|
+
themeType: themeType ?? "dark",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Pierre renders inside a shadow DOM, so host CSS can't reach it — but it reads
|
|
100
|
+
// a set of `--diffs-*-override` custom properties through the shadow boundary.
|
|
101
|
+
// Pin the diff's own base background to the dock surface (Pierre's dark default
|
|
102
|
+
// is pure #000, which reads as a seam against our #0d0d0d panel) and quiet the
|
|
103
|
+
// hunk-separator slab so the collapsed-context row isn't a heavy gray bar.
|
|
104
|
+
const pierreVars = {
|
|
105
|
+
"--diffs-dark-bg": "var(--og-color-bg, #0d0d0d)",
|
|
106
|
+
"--diffs-light-bg": "var(--og-color-bg, #ffffff)",
|
|
107
|
+
"--diffs-bg-buffer-override": "var(--og-color-surface-1, #161616)",
|
|
108
|
+
"--diffs-bg-separator-override": "var(--og-color-surface-1, #161616)",
|
|
109
|
+
"--diffs-font-size": "12.5px",
|
|
110
|
+
"--diffs-line-height": "20px",
|
|
111
|
+
} as CSSProperties;
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
className={cn("min-w-0", className)}
|
|
116
|
+
data-opengeni-pierre-diff
|
|
117
|
+
style={pierreVars}
|
|
118
|
+
>
|
|
119
|
+
<Suspense fallback={loading ?? <DiffSkeleton />}>
|
|
120
|
+
{diff.map((file) => (
|
|
121
|
+
<div key={file.path} className="mb-2">
|
|
122
|
+
<LazyPatchDiff
|
|
123
|
+
patch={gitFileDiffToPatch(file)}
|
|
124
|
+
options={options}
|
|
125
|
+
{...(disableWorkerPool !== undefined ? { disableWorkerPool } : {})}
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
))}
|
|
129
|
+
</Suspense>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function DiffSkeleton() {
|
|
135
|
+
return (
|
|
136
|
+
<div className="p-3 text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
|
|
137
|
+
Loading diff…
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ComponentType,
|
|
3
|
+
type CSSProperties,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
lazy,
|
|
6
|
+
Suspense,
|
|
7
|
+
useEffect,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { cn } from "../lib/cn";
|
|
11
|
+
|
|
12
|
+
/** Pierre `File` props subset we drive — the single-file, syntax-highlighted view
|
|
13
|
+
* (Shiki), the read counterpart of `PatchDiff`. */
|
|
14
|
+
type FileComponent = ComponentType<{
|
|
15
|
+
file: { name: string; contents: string; lang?: string };
|
|
16
|
+
options?: {
|
|
17
|
+
theme?: string | { dark: string; light: string };
|
|
18
|
+
themeType?: "dark" | "light";
|
|
19
|
+
overflow?: "scroll" | "wrap";
|
|
20
|
+
stickyHeader?: boolean;
|
|
21
|
+
showLineNumbers?: boolean;
|
|
22
|
+
};
|
|
23
|
+
disableWorkerPool?: boolean;
|
|
24
|
+
className?: string;
|
|
25
|
+
}>;
|
|
26
|
+
|
|
27
|
+
export type PierreFileProps = {
|
|
28
|
+
/** Workspace-relative path (used for the header + language inference). */
|
|
29
|
+
path: string;
|
|
30
|
+
/** The decoded text contents. */
|
|
31
|
+
contents: string;
|
|
32
|
+
themeType?: "dark" | "light" | undefined;
|
|
33
|
+
/** Shiki bundled theme names (dark/light) — derived from the host palette. */
|
|
34
|
+
theme?: { dark: string; light: string } | undefined;
|
|
35
|
+
/** Disable Pierre's worker pool if its worker bundling fights the host bundler. */
|
|
36
|
+
disableWorkerPool?: boolean | undefined;
|
|
37
|
+
/** Rendered while the (lazy) Pierre bundle loads. */
|
|
38
|
+
loading?: ReactNode | undefined;
|
|
39
|
+
/** Rendered if `@pierre/diffs/react` is not installed / fails to import. */
|
|
40
|
+
fallback?: ReactNode | undefined;
|
|
41
|
+
className?: string | undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Lazy-load `@pierre/diffs/react`'s `File` so Shiki + the worker pool stay off the
|
|
45
|
+
// critical path (and out of an SSR bundle) until a file is actually viewed.
|
|
46
|
+
const LazyFile = lazy(async () => {
|
|
47
|
+
const mod = (await import("@pierre/diffs/react")) as unknown as { File: FileComponent };
|
|
48
|
+
return { default: mod.File };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The Pierre-backed single-file VIEWER: Shiki-highlighted, language inferred from
|
|
53
|
+
* the filename — the read complement of `PierreDiff`. Wired to `fs.read` so
|
|
54
|
+
* clicking any file in the tree shows its contents (NOT a diff; no repo needed).
|
|
55
|
+
* Falls back to a plain `<pre>` when `@pierre/diffs` is absent / fails to import.
|
|
56
|
+
*/
|
|
57
|
+
export function PierreFile({
|
|
58
|
+
path,
|
|
59
|
+
contents,
|
|
60
|
+
themeType,
|
|
61
|
+
theme,
|
|
62
|
+
disableWorkerPool,
|
|
63
|
+
loading,
|
|
64
|
+
fallback,
|
|
65
|
+
className,
|
|
66
|
+
}: PierreFileProps) {
|
|
67
|
+
const [failed, setFailed] = useState(false);
|
|
68
|
+
|
|
69
|
+
// Probe the import once so a hard failure (peer missing) shows `fallback`
|
|
70
|
+
// rather than a Suspense boundary that never resolves.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
let cancelled = false;
|
|
73
|
+
void import("@pierre/diffs/react").catch(() => {
|
|
74
|
+
if (!cancelled) setFailed(true);
|
|
75
|
+
});
|
|
76
|
+
return () => {
|
|
77
|
+
cancelled = true;
|
|
78
|
+
};
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
const name = path.split("/").filter(Boolean).pop() ?? path;
|
|
82
|
+
|
|
83
|
+
if (failed) {
|
|
84
|
+
return (
|
|
85
|
+
<div className={className}>
|
|
86
|
+
{fallback ?? <PlainFile name={name} contents={contents} />}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const options = {
|
|
92
|
+
overflow: "scroll" as const,
|
|
93
|
+
stickyHeader: true,
|
|
94
|
+
showLineNumbers: true,
|
|
95
|
+
...(theme ? { theme } : { theme: { dark: "github-dark", light: "github-light" } }),
|
|
96
|
+
themeType: themeType ?? "dark",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Same shadow-DOM override vars as PierreDiff: pin the base background to the
|
|
100
|
+
// dock surface so the viewer reads as part of the panel, not a black seam.
|
|
101
|
+
const pierreVars = {
|
|
102
|
+
"--diffs-dark-bg": "var(--og-color-bg, #0d0d0d)",
|
|
103
|
+
"--diffs-light-bg": "var(--og-color-bg, #ffffff)",
|
|
104
|
+
"--diffs-bg-buffer-override": "var(--og-color-surface-1, #161616)",
|
|
105
|
+
"--diffs-bg-separator-override": "var(--og-color-surface-1, #161616)",
|
|
106
|
+
"--diffs-font-size": "12.5px",
|
|
107
|
+
"--diffs-line-height": "20px",
|
|
108
|
+
} as CSSProperties;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className={cn("min-w-0", className)} data-opengeni-pierre-file style={pierreVars}>
|
|
112
|
+
<Suspense fallback={loading ?? <FileSkeleton />}>
|
|
113
|
+
{/* `cacheKey` keys Pierre's worker-pool highlight cache on path+size so a
|
|
114
|
+
re-select of the same file is instant but an edited file re-highlights. */}
|
|
115
|
+
<LazyFile
|
|
116
|
+
file={{ name, contents }}
|
|
117
|
+
options={options}
|
|
118
|
+
{...(disableWorkerPool !== undefined ? { disableWorkerPool } : {})}
|
|
119
|
+
/>
|
|
120
|
+
</Suspense>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function PlainFile({ name, contents }: { name: string; contents: string }) {
|
|
126
|
+
return (
|
|
127
|
+
<pre
|
|
128
|
+
className="overflow-auto whitespace-pre p-2 font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[12px] leading-[18px] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
|
|
129
|
+
data-file={name}
|
|
130
|
+
>
|
|
131
|
+
{contents}
|
|
132
|
+
</pre>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function FileSkeleton() {
|
|
137
|
+
return (
|
|
138
|
+
<div className="p-3 text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
|
|
139
|
+
Loading file…
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|