@opengeni/react 0.3.1 → 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
|
@@ -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
|
+
}
|