@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
@@ -15,6 +15,11 @@ export type FleetTileProps = {
15
15
 
16
16
  /** Best-effort display title for a session. */
17
17
  export function sessionDisplayTitle(session: Session): string {
18
+ // A set title (agent-generated or user-renamed) wins over metadata + the
19
+ // initial-message fallback.
20
+ if (typeof session.title === "string" && session.title.trim().length > 0) {
21
+ return session.title;
22
+ }
18
23
  for (const key of ["title", "name"] as const) {
19
24
  const value = session.metadata[key];
20
25
  if (typeof value === "string" && value.trim().length > 0) {
@@ -1,34 +1,32 @@
1
1
  import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
2
2
  import {
3
3
  ArrowDownIcon,
4
- BotIcon,
5
- BrainIcon,
6
- ChevronRightIcon,
7
- SquareTerminalIcon,
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, stringifyPayload, truncate } from "../lib/format";
16
+ import { formatRelativeTime, truncate } from "../lib/format";
17
17
  import { Markdown } from "./markdown";
18
18
  import {
19
+ ActivityRail,
19
20
  buildTimeline,
20
- compactPayloadPreview,
21
+ defaultToolRegistry,
21
22
  groupTimeline,
22
- toolDisplayName,
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 ToolCallItem,
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
- <div className={cn("og-root relative min-h-0", className)}>
95
- <div ref={scrollRef} onScroll={onScroll} className="h-full overflow-y-auto overscroll-contain px-4 py-6 sm:px-6">
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
- <ActivityCluster key={group.id} items={group.items} onOpenSession={onOpenSession} />
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
- function TimelineRow({
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-[15px] leading-6 text-og-fg">
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-[15px] leading-7 text-og-fg">
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-[11px] text-og-fg-subtle" role="status">
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 border-og-border bg-og-surface-1 px-3 py-1 text-xs text-og-fg-muted">
246
- <TargetIcon className="size-3.5 shrink-0 text-og-accent" />
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
+ }