@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
@@ -0,0 +1,425 @@
1
+ import type { TerminalCapability } from "@opengeni/sdk";
2
+ import { type ReactNode, useEffect, useRef, useState } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { useTerminalStream } from "../hooks/use-terminal-stream";
5
+ import type { UseSandboxTerminalResult } from "../hooks/use-sandbox-terminal";
6
+
7
+ /** A subset of xterm.js's ITheme — the tokens worth themeing from a host app. */
8
+ export type XtermTheme = {
9
+ background?: string;
10
+ foreground?: string;
11
+ cursor?: string;
12
+ cursorAccent?: string;
13
+ selectionBackground?: string;
14
+ };
15
+
16
+ export type SandboxTerminalProps = {
17
+ /** From `useSandboxTerminal(...)`. */
18
+ result: UseSandboxTerminalResult;
19
+ /**
20
+ * The negotiated Terminal capability cell. When it advertises `transport:
21
+ * "pty-ws"` + a live `url` (a warm box with a viewer attached), the terminal is
22
+ * driven by a REAL bidirectional PTY over the Modal tunnel (ttyd-over-websocket)
23
+ * INSTEAD of the broken ptyWrite-over-HTTP path: xterm input → the socket, the
24
+ * socket's output → xterm, xterm resize → the socket. On a cold box / no url
25
+ * (`transport: "sse-events"`) it stays on the read-only Channel-A firehose
26
+ * (`result.chunks`). Omit to force the legacy firehose-only behavior.
27
+ */
28
+ terminalCapability?: TerminalCapability | null | undefined;
29
+ theme?: XtermTheme | undefined;
30
+ fontFamily?: string | undefined;
31
+ fontSize?: number | undefined;
32
+ /**
33
+ * Force read-only even when the PTY accepts stdin. Default: interactive
34
+ * whenever a live pty-ws stream is connected OR `result.write !== null` (the box
35
+ * advertises an interactive PTY); otherwise the read-only agent firehose.
36
+ */
37
+ readOnly?: boolean | undefined;
38
+ /** Shown on the server / before xterm hydrates (SSR-safe placeholder). */
39
+ placeholder?: ReactNode | undefined;
40
+ /** Render the small status header (pty/shell + running dot + read-only pill). */
41
+ showHeader?: boolean | undefined;
42
+ /** Shell label for the header (e.g. `/bin/bash`). */
43
+ shell?: string | undefined;
44
+ /**
45
+ * Fired the first time the user engages the terminal surface (focus or click).
46
+ * The host wires this to warm the box for the REAL pty-ws terminal (the viewer
47
+ * attach), so a cold box upgrades from the read-only firehose to a live PTY ON
48
+ * INTERACT — never on mere mount (which would force a box spin-up and regress
49
+ * the firehose-only default).
50
+ */
51
+ onActivate?: (() => void) | undefined;
52
+ className?: string | undefined;
53
+ };
54
+
55
+ // The xterm.js handle the effect owns. `any`-free structural shape so we can
56
+ // drive it without a hard type dep on the lazily-imported lib.
57
+ type XtermLike = {
58
+ open: (el: HTMLElement) => void;
59
+ write: (data: string) => void;
60
+ clear: () => void;
61
+ onData: (cb: (data: string) => void) => { dispose: () => void };
62
+ onResize: (cb: (size: { cols: number; rows: number }) => void) => { dispose: () => void };
63
+ loadAddon: (addon: unknown) => void;
64
+ dispose: () => void;
65
+ cols: number;
66
+ rows: number;
67
+ options: { theme?: XtermTheme; disableStdin?: boolean };
68
+ };
69
+ type FitAddonLike = { fit: () => void };
70
+
71
+ /**
72
+ * An xterm.js terminal fed by the Channel-A event projection
73
+ * (`useSandboxTerminal`). xterm + the fit + web-links addons are lazy-imported
74
+ * inside an effect, so SSR renders the placeholder and the terminal mounts on
75
+ * hydration. Output chunks are written incrementally (tracking a written-cursor
76
+ * by chunk id so a re-render never re-writes). When `result.write` is non-null
77
+ * and not forced read-only, keystrokes pipe back through the PTY.
78
+ *
79
+ * Resizes are tracked with a `ResizeObserver` on the container (not just
80
+ * `window.resize`) so dragging the dock handle refits the grid instead of
81
+ * leaving the terminal mis-sized.
82
+ */
83
+ export function SandboxTerminal({
84
+ result,
85
+ terminalCapability,
86
+ theme,
87
+ fontFamily,
88
+ fontSize,
89
+ readOnly,
90
+ placeholder,
91
+ showHeader,
92
+ shell,
93
+ onActivate,
94
+ className,
95
+ }: SandboxTerminalProps) {
96
+ const containerRef = useRef<HTMLDivElement | null>(null);
97
+ const termRef = useRef<XtermLike | null>(null);
98
+ const fitRef = useRef<FitAddonLike | null>(null);
99
+ const writtenRef = useRef<Set<string>>(new Set());
100
+ const [ready, setReady] = useState(false);
101
+
102
+ // ── Interactive transport switch ─────────────────────────────────────────────
103
+ // The interactive terminal is a REAL bidirectional PTY (ttyd over the Modal
104
+ // tunnel) WHENEVER the Terminal cell advertises `transport: "pty-ws"` + a live
105
+ // `url`; the ttyd socket then OWNS the screen (input + output both ride it). On
106
+ // a cold box / sse-events cell, `useTerminalStream` stays idle and we fall back
107
+ // to the read-only Channel-A command-output firehose (`result.chunks`) — with
108
+ // the legacy `result.write` (ptyWrite-over-HTTP) as the only stdin path there.
109
+ const ptyWs = terminalCapability?.transport === "pty-ws" && Boolean(terminalCapability?.url);
110
+ // Output frames buffer here until xterm has mounted; the write effect drains it.
111
+ const ptyOutputQueueRef = useRef<string[]>([]);
112
+ const ptyStream = useTerminalStream({
113
+ capability: ptyWs
114
+ ? {
115
+ transport: terminalCapability!.transport,
116
+ url: terminalCapability!.url,
117
+ token: terminalCapability!.token,
118
+ }
119
+ : null,
120
+ onOutput: (data) => {
121
+ const term = termRef.current;
122
+ if (term) term.write(data);
123
+ else ptyOutputQueueRef.current.push(data);
124
+ },
125
+ });
126
+
127
+ // PTY mode = a live ttyd socket drives the screen. In PTY mode the screen is
128
+ // owned by the websocket (we must NOT replay the SSE firehose into it — that
129
+ // double-writes the agent's command output on top of the live PTY). Firehose
130
+ // mode = the read-only projection (or the legacy HTTP-write fallback).
131
+ const ptyMode = ptyWs && ptyStream.status !== "closed";
132
+ // Interactive (stdin enabled) when a live PTY socket is connected, OR (legacy
133
+ // fallback) the projection exposed an HTTP write fn. `readOnly` forces off.
134
+ const interactive = !readOnly && (ptyMode || result.write !== null);
135
+
136
+ // Mount xterm once (client-only). Re-mounts only when the interactive flag
137
+ // flips (stdin enable/disable is a construction param on xterm).
138
+ useEffect(() => {
139
+ if (typeof window === "undefined") return;
140
+ const el = containerRef.current;
141
+ if (!el) return;
142
+ let disposed = false;
143
+
144
+ // Wait for the container to have a real measured size before opening xterm.
145
+ // If `term.open()` runs while the panel is 0-wide (tab still mounting, dock
146
+ // mid-resize), xterm renders at its 80×24 fallback and the first frame's
147
+ // grid is rasterized into the canvas; a later fit reflows the text but the
148
+ // stale first paint lingers as a `]]]bbb` smear on the top row. Gating the
149
+ // open on a non-zero size removes that first wrong-width render entirely.
150
+ const waitForSize = (el: HTMLElement) =>
151
+ new Promise<void>((resolve) => {
152
+ if (el.clientWidth > 0 && el.clientHeight > 0) return resolve();
153
+ const ro = new ResizeObserver(() => {
154
+ if (el.clientWidth > 0 && el.clientHeight > 0) {
155
+ ro.disconnect();
156
+ resolve();
157
+ }
158
+ });
159
+ ro.observe(el);
160
+ // Safety: never hang if the observer somehow never fires.
161
+ setTimeout(() => {
162
+ ro.disconnect();
163
+ resolve();
164
+ }, 1000);
165
+ });
166
+
167
+ // Inject xterm's structural CSS before the first open so the helper-textarea
168
+ // ghost is clipped from frame one (no top-left input box flash).
169
+ ensureXtermBaseCss();
170
+
171
+ void (async () => {
172
+ const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
173
+ import("@xterm/xterm"),
174
+ import("@xterm/addon-fit"),
175
+ import("@xterm/addon-web-links"),
176
+ ]);
177
+ if (disposed) return;
178
+ await waitForSize(el);
179
+ if (disposed) return;
180
+ const term = new Terminal({
181
+ convertEol: true,
182
+ disableStdin: !interactive,
183
+ cursorBlink: interactive,
184
+ fontFamily: fontFamily ?? "var(--og-font-mono, var(--font-mono, monospace))",
185
+ fontSize: fontSize ?? 13,
186
+ lineHeight: 1.25,
187
+ // A little breathing room from the panel edge so output isn't flush to
188
+ // the border (matches a real terminal app's inner gutter).
189
+ ...({ padding: 8 } as Record<string, unknown>),
190
+ ...(theme ? { theme } : {}),
191
+ }) as unknown as XtermLike;
192
+ const fit = new FitAddon() as unknown as FitAddonLike;
193
+ term.loadAddon(fit);
194
+ // Clickable URLs in agent output (open in a new tab).
195
+ term.loadAddon(
196
+ new WebLinksAddon((_e: MouseEvent, uri: string) => window.open(uri, "_blank", "noopener,noreferrer")) as unknown,
197
+ );
198
+ term.open(el);
199
+ termRef.current = term;
200
+ fitRef.current = fit;
201
+ // Critical: the column count must be settled BEFORE any output is written,
202
+ // or seeded transcript lines reflow/garble against the default 80×24 grid
203
+ // (the trailing prompt's escape codes smear into `]]]bbb` artifacts). Fit
204
+ // once now, then again on the next frame after layout has measured the
205
+ // real container width, and only then unblock the write effect.
206
+ const settle = () => {
207
+ try {
208
+ fit.fit();
209
+ } catch {
210
+ // ignore fit before layout
211
+ }
212
+ };
213
+ settle();
214
+ requestAnimationFrame(() => {
215
+ if (disposed) return;
216
+ settle();
217
+ // Drain any ttyd OUTPUT that arrived before xterm finished mounting.
218
+ if (ptyOutputQueueRef.current.length > 0) {
219
+ for (const data of ptyOutputQueueRef.current) term.write(data);
220
+ ptyOutputQueueRef.current = [];
221
+ }
222
+ setReady(true);
223
+ });
224
+ })();
225
+
226
+ return () => {
227
+ disposed = true;
228
+ termRef.current?.dispose();
229
+ termRef.current = null;
230
+ fitRef.current = null;
231
+ writtenRef.current = new Set();
232
+ setReady(false);
233
+ };
234
+ // eslint-disable-next-line react-hooks/exhaustive-deps
235
+ }, [interactive]);
236
+
237
+ // Re-theme live (e.g. dark↔light flip) without re-mounting / losing scrollback.
238
+ useEffect(() => {
239
+ const term = termRef.current;
240
+ if (!ready || !term || !theme) return;
241
+ term.options.theme = theme;
242
+ }, [ready, theme]);
243
+
244
+ // On the firehose → PTY transition (the box warmed and the ttyd socket came
245
+ // up), clear the stale read-only transcript so the live shell starts on a clean
246
+ // screen instead of below the agent's projected command output.
247
+ const enteredPtyRef = useRef(false);
248
+ useEffect(() => {
249
+ const term = termRef.current;
250
+ if (!ready || !term) return;
251
+ if (ptyMode && !enteredPtyRef.current) {
252
+ enteredPtyRef.current = true;
253
+ term.clear();
254
+ } else if (!ptyMode) {
255
+ enteredPtyRef.current = false;
256
+ }
257
+ }, [ready, ptyMode]);
258
+
259
+ // Write new firehose chunks incrementally — but ONLY in firehose mode. In PTY
260
+ // mode the ttyd socket owns the screen; replaying the SSE command-output
261
+ // projection on top of it would double-render the agent's output.
262
+ useEffect(() => {
263
+ const term = termRef.current;
264
+ if (!ready || !term || ptyMode) return;
265
+ for (const chunk of result.chunks) {
266
+ if (writtenRef.current.has(chunk.id)) continue;
267
+ writtenRef.current.add(chunk.id);
268
+ term.write(chunk.text);
269
+ }
270
+ }, [ready, result.chunks, ptyMode]);
271
+
272
+ // Wire interactive input when allowed. PTY mode pipes keystrokes to the ttyd
273
+ // socket (the real PTY); firehose mode uses the legacy ptyWrite-over-HTTP fn.
274
+ useEffect(() => {
275
+ const term = termRef.current;
276
+ if (!ready || !term || !interactive) return;
277
+ const sink = ptyMode ? ptyStream.write : result.write;
278
+ if (!sink) return;
279
+ const sub = term.onData((data) => sink(data));
280
+ return () => sub.dispose();
281
+ }, [ready, interactive, ptyMode, ptyStream.write, result.write]);
282
+
283
+ // In PTY mode, tell ttyd the window size on the xterm resize event (the fit
284
+ // addon reflows the grid; ttyd resizes the remote PTY to match), and push the
285
+ // initial geometry once the socket is open.
286
+ useEffect(() => {
287
+ const term = termRef.current;
288
+ if (!ready || !term || !ptyMode) return;
289
+ ptyStream.resize(term.cols, term.rows);
290
+ const sub = term.onResize(({ cols, rows }) => ptyStream.resize(cols, rows));
291
+ return () => sub.dispose();
292
+ }, [ready, ptyMode, ptyStream.connected, ptyStream.resize]);
293
+
294
+ // Refit on container resize (dock drag) AND window resize. The fit addon
295
+ // mutates xterm's cols/rows → the onResize handler above forwards to ttyd.
296
+ useEffect(() => {
297
+ if (typeof window === "undefined") return;
298
+ const refit = () => {
299
+ try {
300
+ fitRef.current?.fit();
301
+ } catch {
302
+ // ignore
303
+ }
304
+ };
305
+ window.addEventListener("resize", refit);
306
+ let observer: ResizeObserver | null = null;
307
+ const el = containerRef.current;
308
+ if (el && typeof ResizeObserver !== "undefined") {
309
+ observer = new ResizeObserver(() => refit());
310
+ observer.observe(el);
311
+ }
312
+ return () => {
313
+ window.removeEventListener("resize", refit);
314
+ observer?.disconnect();
315
+ };
316
+ }, [ready]);
317
+
318
+ return (
319
+ <div className={cn("relative flex h-full w-full flex-col overflow-hidden", className)}>
320
+ {showHeader && (
321
+ <div className="flex shrink-0 items-center justify-between gap-2 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] px-2 py-1 text-[11px] text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
322
+ <span className="flex min-w-0 items-center gap-1.5">
323
+ <span
324
+ className={cn(
325
+ "size-1.5 shrink-0 rounded-full",
326
+ result.running
327
+ ? "bg-[color:var(--og-color-status-running,var(--color-status-running,#d29922))]"
328
+ : "bg-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]",
329
+ )}
330
+ />
331
+ <span className="truncate font-[family-name:var(--og-font-mono,var(--font-mono,monospace))]">
332
+ pty: {shell ?? "shell"}
333
+ </span>
334
+ </span>
335
+ <span className="flex shrink-0 items-center gap-2">
336
+ {!interactive && (
337
+ <span className="rounded-[var(--og-radius-sm,4px)] bg-[color:var(--og-color-surface-2,var(--color-surface-2,#161616))] px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
338
+ read-only
339
+ </span>
340
+ )}
341
+ <button
342
+ type="button"
343
+ onClick={() => {
344
+ termRef.current?.clear();
345
+ writtenRef.current = new Set();
346
+ }}
347
+ className="rounded-[var(--og-radius-sm,4px)] px-1.5 py-0.5 text-[10px] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
348
+ >
349
+ Clear
350
+ </button>
351
+ </span>
352
+ </div>
353
+ )}
354
+ {/* `onPointerDownCapture`/`onFocusCapture` fire on the FIRST user engagement
355
+ with the terminal surface (capture so they win even though xterm's own
356
+ listeners stop propagation). The host warms the box for pty-ws here. */}
357
+ <div
358
+ className="relative min-h-0 flex-1 bg-[color:var(--og-color-bg,var(--color-bg,#0d0d0d))] px-2 py-1.5"
359
+ onPointerDownCapture={onActivate}
360
+ onFocusCapture={onActivate}
361
+ >
362
+ {!ready && (placeholder ?? <TerminalPlaceholder />)}
363
+ <div ref={containerRef} className="h-full w-full" data-opengeni-terminal />
364
+ </div>
365
+ </div>
366
+ );
367
+ }
368
+
369
+ // xterm.js ships a stylesheet (`@xterm/xterm/css/xterm.css`) that is REQUIRED for
370
+ // correct layout — without it the `.xterm-helper-textarea` (an off-screen input
371
+ // xterm uses for IME/keystrokes) is NOT clipped and renders as a visible 1-line
372
+ // box in the top-left (the "weird input box" bug), and the viewport/screen aren't
373
+ // positioned. The host app never imports that CSS (no global stylesheet here), so
374
+ // we inject the structural rules once, idempotently (keyed by id), the first time
375
+ // any terminal mounts. Scoped under `.xterm` so it can't leak into host styles.
376
+ const XTERM_STYLE_ID = "opengeni-xterm-base-css";
377
+ const XTERM_BASE_CSS = `
378
+ .xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}
379
+ .xterm.focus,.xterm:focus{outline:none}
380
+ .xterm .xterm-helpers{position:absolute;top:0;z-index:5}
381
+ .xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}
382
+ .xterm .composition-view{background:#000;color:#FFF;display:none;position:absolute;white-space:nowrap;z-index:1}
383
+ .xterm .composition-view.active{display:block}
384
+ .xterm .xterm-viewport{background-color:transparent;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}
385
+ .xterm .xterm-screen{position:relative}
386
+ .xterm .xterm-screen canvas{position:absolute;left:0;top:0}
387
+ .xterm .xterm-scroll-area{visibility:hidden}
388
+ .xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}
389
+ .xterm.enable-mouse-events{cursor:default}
390
+ .xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}
391
+ .xterm.column-select.focus{cursor:crosshair}
392
+ .xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}
393
+ .xterm .xterm-accessibility-tree:not(.debug) *::selection{color:transparent}
394
+ .xterm .xterm-accessibility-tree{user-select:text;white-space:pre}
395
+ .xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
396
+ .xterm-dim{opacity:1!important}
397
+ .xterm-underline-1{text-decoration:underline}
398
+ .xterm-underline-2{text-decoration:double underline}
399
+ .xterm-underline-3{text-decoration:wavy underline}
400
+ .xterm-underline-4{text-decoration:dotted underline}
401
+ .xterm-underline-5{text-decoration:dashed underline}
402
+ .xterm-overline{text-decoration:overline}
403
+ .xterm-strikethrough{text-decoration:line-through}
404
+ .xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}
405
+ .xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}
406
+ .xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}
407
+ .xterm-decoration-top{z-index:2;position:relative}
408
+ `;
409
+
410
+ function ensureXtermBaseCss(): void {
411
+ if (typeof document === "undefined") return;
412
+ if (document.getElementById(XTERM_STYLE_ID)) return;
413
+ const style = document.createElement("style");
414
+ style.id = XTERM_STYLE_ID;
415
+ style.textContent = XTERM_BASE_CSS;
416
+ document.head.appendChild(style);
417
+ }
418
+
419
+ function TerminalPlaceholder() {
420
+ return (
421
+ <div className="absolute inset-0 flex items-center justify-center text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
422
+ Loading terminal…
423
+ </div>
424
+ );
425
+ }
@@ -0,0 +1,247 @@
1
+ import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
2
+ import {
3
+ ChevronsLeftRightIcon,
4
+ Maximize2Icon,
5
+ Minimize2Icon,
6
+ PanelRightCloseIcon,
7
+ } from "lucide-react";
8
+ import {
9
+ Group,
10
+ Panel,
11
+ Separator,
12
+ useDefaultLayout,
13
+ usePanelRef,
14
+ } from "react-resizable-panels";
15
+ import { cn } from "../lib/cn";
16
+
17
+ export type WorkspaceTab = {
18
+ id: string;
19
+ label: ReactNode;
20
+ /** Rendered as the active surface. */
21
+ content: ReactNode;
22
+ /** A small badge after the label (e.g. dirty count, live pill). */
23
+ badge?: ReactNode | undefined;
24
+ };
25
+
26
+ export type WorkspaceDockProps = {
27
+ /** The chat / primary pane shown beside the dock. */
28
+ primary: ReactNode;
29
+ tabs: WorkspaceTab[];
30
+ /** Controlled active tab. Falls back to the first tab. */
31
+ activeTab?: string | undefined;
32
+ onActiveTabChange?: ((id: string) => void) | undefined;
33
+ /** Persisted layout id (localStorage key) for react-resizable-panels. */
34
+ autoSaveId?: string | undefined;
35
+ /** Default dock width as a percent of the session area. */
36
+ defaultSize?: number | undefined;
37
+ minSize?: number | undefined;
38
+ maxSize?: number | undefined;
39
+ className?: string | undefined;
40
+ };
41
+
42
+ /**
43
+ * The resizable / collapsible / maximizable right-hand Workspace dock. Replaces
44
+ * a fixed grid column: drag the separator to set width, collapse to a thin rail
45
+ * that re-opens on click, and maximize to a full-workspace overlay (Esc /
46
+ * restore button returns). Layout persists via `useDefaultLayout` keyed on
47
+ * `autoSaveId`. Maximize is a mode ABOVE the Group (a `fixed inset-0` overlay) —
48
+ * pushing a Panel to ~100% still fights min sizes and leaves a chat sliver.
49
+ */
50
+ export function WorkspaceDock({
51
+ primary,
52
+ tabs,
53
+ activeTab,
54
+ onActiveTabChange,
55
+ autoSaveId = "og.session.dock",
56
+ defaultSize = 34,
57
+ minSize = 22,
58
+ maxSize = 70,
59
+ className,
60
+ }: WorkspaceDockProps) {
61
+ const dockPanelRef = usePanelRef();
62
+ const [collapsed, setCollapsed] = useState(false);
63
+ const [maximized, setMaximized] = useState(false);
64
+ const [internalTab, setInternalTab] = useState(tabs[0]?.id ?? "");
65
+
66
+ // Persisted layout (width split) keyed by autoSaveId.
67
+ const { defaultLayout, onLayoutChanged } = useDefaultLayout({
68
+ panelIds: ["primary", "dock"],
69
+ storage: typeof window !== "undefined" ? window.localStorage : undefined,
70
+ id: autoSaveId,
71
+ } as Parameters<typeof useDefaultLayout>[0]);
72
+
73
+ const current = activeTab ?? internalTab;
74
+ const setTab = useCallback(
75
+ (id: string) => {
76
+ setInternalTab(id);
77
+ onActiveTabChange?.(id);
78
+ },
79
+ [onActiveTabChange],
80
+ );
81
+
82
+ // Keep the active tab valid if the available tabs change.
83
+ useEffect(() => {
84
+ if (tabs.length > 0 && !tabs.some((t) => t.id === current)) {
85
+ setTab(tabs[0]!.id);
86
+ }
87
+ }, [tabs, current, setTab]);
88
+
89
+ // Esc restores from maximize.
90
+ useEffect(() => {
91
+ if (!maximized) return;
92
+ const onKey = (event: KeyboardEvent) => {
93
+ if (event.key === "Escape") setMaximized(false);
94
+ };
95
+ window.addEventListener("keydown", onKey);
96
+ return () => window.removeEventListener("keydown", onKey);
97
+ }, [maximized]);
98
+
99
+ const collapse = useCallback(() => {
100
+ dockPanelRef.current?.collapse();
101
+ setCollapsed(true);
102
+ }, [dockPanelRef]);
103
+ const expand = useCallback(() => {
104
+ dockPanelRef.current?.expand();
105
+ setCollapsed(false);
106
+ }, [dockPanelRef]);
107
+
108
+ const dockChrome = (
109
+ <DockChrome
110
+ tabs={tabs}
111
+ current={current}
112
+ onTab={setTab}
113
+ maximized={maximized}
114
+ onToggleMaximize={() => setMaximized((m) => !m)}
115
+ onCollapse={maximized ? () => setMaximized(false) : collapse}
116
+ />
117
+ );
118
+
119
+ return (
120
+ <div className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
121
+ <Group
122
+ orientation="horizontal"
123
+ className="min-h-0 flex-1"
124
+ {...(defaultLayout ? { defaultLayout } : {})}
125
+ onLayoutChanged={onLayoutChanged}
126
+ >
127
+ <Panel id="primary" minSize="30%" className="min-h-0 min-w-0">
128
+ {primary}
129
+ </Panel>
130
+
131
+ {!collapsed && (
132
+ <Separator className="group relative w-1.5 shrink-0 outline-none">
133
+ <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-[color:var(--og-color-border,var(--color-border,#2a2a2a))] transition-colors group-hover:bg-[color:var(--og-color-accent,var(--color-brand,#3b82f6))] group-data-[separator-state=dragging]:bg-[color:var(--og-color-accent,var(--color-brand,#3b82f6))]" />
134
+ </Separator>
135
+ )}
136
+
137
+ <Panel
138
+ id="dock"
139
+ panelRef={dockPanelRef}
140
+ collapsible
141
+ collapsedSize="0%"
142
+ defaultSize={`${defaultSize}%`}
143
+ minSize={`${minSize}%`}
144
+ maxSize={`${maxSize}%`}
145
+ onResize={(size) => {
146
+ // `asPercentage` is 0..100; treat a near-zero panel as collapsed.
147
+ const isCollapsed = size.asPercentage <= 1;
148
+ setCollapsed((prev) => (prev !== isCollapsed ? isCollapsed : prev));
149
+ }}
150
+ className="min-h-0 min-w-0"
151
+ >
152
+ {/* Hidden behind the overlay while maximized (avoids double-mounting
153
+ the surfaces). */}
154
+ {!maximized && (
155
+ <div className="flex h-full min-h-0 min-w-0 flex-col border-l border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-bg,var(--color-bg,#0d0d0d))]">
156
+ {dockChrome}
157
+ </div>
158
+ )}
159
+ </Panel>
160
+ </Group>
161
+
162
+ {/* Collapsed rail: a thin tab on the right edge that re-opens the dock. */}
163
+ {collapsed && !maximized && (
164
+ <button
165
+ type="button"
166
+ onClick={expand}
167
+ title="Open workspace"
168
+ className="absolute inset-y-0 right-0 flex w-6 shrink-0 items-center justify-center border-l border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))] text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
169
+ >
170
+ <ChevronsLeftRightIcon className="size-3.5" />
171
+ </button>
172
+ )}
173
+
174
+ {/* Maximize overlay: full-workspace surface above everything. */}
175
+ {maximized && (
176
+ <div className="fixed inset-0 z-40 flex flex-col bg-[color:var(--og-color-bg,var(--color-bg,#0d0d0d))]">
177
+ {dockChrome}
178
+ </div>
179
+ )}
180
+ </div>
181
+ );
182
+ }
183
+
184
+ function DockChrome({
185
+ tabs,
186
+ current,
187
+ onTab,
188
+ maximized,
189
+ onToggleMaximize,
190
+ onCollapse,
191
+ }: {
192
+ tabs: WorkspaceTab[];
193
+ current: string;
194
+ onTab: (id: string) => void;
195
+ maximized: boolean;
196
+ onToggleMaximize: () => void;
197
+ onCollapse: () => void;
198
+ }) {
199
+ const active = tabs.find((t) => t.id === current) ?? tabs[0];
200
+ return (
201
+ <>
202
+ <div className="flex shrink-0 items-center justify-between gap-2 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] px-1.5 py-1">
203
+ <div className="flex min-w-0 items-center gap-0.5" role="tablist">
204
+ {tabs.map((tab) => (
205
+ <button
206
+ key={tab.id}
207
+ type="button"
208
+ role="tab"
209
+ aria-selected={tab.id === current}
210
+ onClick={() => onTab(tab.id)}
211
+ className={cn(
212
+ "flex items-center gap-1 rounded-[var(--og-radius-sm,4px)] px-2 py-1 text-[11px] font-medium transition-colors",
213
+ tab.id === current
214
+ ? "bg-[color:var(--og-color-accent-soft,var(--color-surface-2,#222))] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
215
+ : "text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]",
216
+ )}
217
+ >
218
+ <span className="truncate">{tab.label}</span>
219
+ {tab.badge}
220
+ </button>
221
+ ))}
222
+ </div>
223
+ <div className="flex shrink-0 items-center gap-0.5 text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
224
+ <button
225
+ type="button"
226
+ onClick={onToggleMaximize}
227
+ title={maximized ? "Restore (Esc)" : "Maximize"}
228
+ className="rounded-[var(--og-radius-sm,4px)] p-1 hover:bg-[color:var(--og-color-surface-2,var(--color-surface-2,#222))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
229
+ >
230
+ {maximized ? <Minimize2Icon className="size-3.5" /> : <Maximize2Icon className="size-3.5" />}
231
+ </button>
232
+ <button
233
+ type="button"
234
+ onClick={onCollapse}
235
+ title={maximized ? "Restore" : "Collapse"}
236
+ className="rounded-[var(--og-radius-sm,4px)] p-1 hover:bg-[color:var(--og-color-surface-2,var(--color-surface-2,#222))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
237
+ >
238
+ <PanelRightCloseIcon className="size-3.5" />
239
+ </button>
240
+ </div>
241
+ </div>
242
+ <div className="min-h-0 min-w-0 flex-1 overflow-hidden" role="tabpanel">
243
+ {active?.content}
244
+ </div>
245
+ </>
246
+ );
247
+ }