@opengeni/react 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/index.d.ts +1035 -14
  2. package/dist/index.js +6867 -1884
  3. package/dist/index.js.map +1 -1
  4. package/package.json +65 -2
  5. package/src/client.ts +21 -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/message-timeline.tsx +70 -196
  11. package/src/components/pierre-diff.tsx +140 -0
  12. package/src/components/pierre-file.tsx +142 -0
  13. package/src/components/sandbox-files.tsx +509 -0
  14. package/src/components/sandbox-terminal.tsx +425 -0
  15. package/src/components/workspace-dock.tsx +247 -0
  16. package/src/hooks/use-desktop-stream.ts +214 -0
  17. package/src/hooks/use-sandbox-files.ts +670 -0
  18. package/src/hooks/use-sandbox-git.ts +105 -0
  19. package/src/hooks/use-sandbox-terminal.ts +226 -0
  20. package/src/hooks/use-session-capabilities.ts +415 -0
  21. package/src/hooks/use-terminal-stream.ts +207 -0
  22. package/src/index.ts +111 -2
  23. package/src/lib/cn.ts +20 -1
  24. package/src/lib/git-patch.ts +37 -0
  25. package/src/lib/use-theme-type.ts +40 -0
  26. package/src/lib/xterm-theme.ts +34 -0
  27. package/src/timeline/activity-rail.tsx +207 -0
  28. package/src/timeline/disclosure-context.tsx +34 -0
  29. package/src/timeline/index.ts +85 -0
  30. package/src/timeline/parsers.ts +248 -0
  31. package/src/{timeline.ts → timeline/projection.ts} +59 -134
  32. package/src/timeline/registry.ts +96 -0
  33. package/src/timeline/screenshot-lightbox.tsx +152 -0
  34. package/src/timeline/shared.tsx +481 -0
  35. package/src/timeline/tool-diff.tsx +91 -0
  36. package/src/timeline/tool-renderers.tsx +882 -0
  37. package/src/timeline/turn-summary.tsx +125 -0
  38. package/src/timeline/types.ts +131 -0
  39. package/src/types/external.d.ts +7 -0
  40. package/styles/index.css +72 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/react",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "React hooks and styled components for OpenGeni: live session streaming, chat composer, message timeline, session status, and fleet views — token-themed (CSS variables), dark-first, built on Tailwind v4 + Radix + Motion.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -40,27 +40,90 @@
40
40
  "demo:build": "vite build demo"
41
41
  },
42
42
  "dependencies": {
43
- "@opengeni/sdk": "^0.2.0",
43
+ "@opengeni/sdk": "^0.4.0",
44
44
  "clsx": "^2.1.1",
45
45
  "lucide-react": "^1.8.0",
46
46
  "motion": "^12.0.0",
47
47
  "radix-ui": "^1.4.3",
48
48
  "react-markdown": "^10.1.0",
49
+ "react-resizable-panels": "^4.11.2",
49
50
  "remark-gfm": "^4.0.1",
50
51
  "tailwind-merge": "^3.5.0"
51
52
  },
52
53
  "peerDependencies": {
54
+ "@codemirror/lang-css": "^6.0.0",
55
+ "@codemirror/lang-html": "^6.0.0",
56
+ "@codemirror/lang-javascript": "^6.0.0",
57
+ "@codemirror/lang-json": "^6.0.0",
58
+ "@codemirror/lang-markdown": "^6.0.0",
59
+ "@codemirror/lang-python": "^6.0.0",
60
+ "@novnc/novnc": "^1.7.0",
61
+ "@pierre/diffs": "^1.2.11",
62
+ "@uiw/react-codemirror": "^4.23.0",
63
+ "@xterm/addon-fit": "^0.11.0",
64
+ "@xterm/addon-web-links": "^0.12.0",
65
+ "@xterm/xterm": "^6.0.0",
53
66
  "react": "^18.0.0 || ^19.0.0",
54
67
  "react-dom": "^18.0.0 || ^19.0.0"
55
68
  },
69
+ "peerDependenciesMeta": {
70
+ "@codemirror/lang-css": {
71
+ "optional": true
72
+ },
73
+ "@codemirror/lang-html": {
74
+ "optional": true
75
+ },
76
+ "@codemirror/lang-javascript": {
77
+ "optional": true
78
+ },
79
+ "@codemirror/lang-json": {
80
+ "optional": true
81
+ },
82
+ "@codemirror/lang-markdown": {
83
+ "optional": true
84
+ },
85
+ "@codemirror/lang-python": {
86
+ "optional": true
87
+ },
88
+ "@novnc/novnc": {
89
+ "optional": true
90
+ },
91
+ "@pierre/diffs": {
92
+ "optional": true
93
+ },
94
+ "@uiw/react-codemirror": {
95
+ "optional": true
96
+ },
97
+ "@xterm/addon-fit": {
98
+ "optional": true
99
+ },
100
+ "@xterm/addon-web-links": {
101
+ "optional": true
102
+ },
103
+ "@xterm/xterm": {
104
+ "optional": true
105
+ }
106
+ },
56
107
  "devDependencies": {
108
+ "@codemirror/lang-css": "^6.3.1",
109
+ "@codemirror/lang-html": "^6.4.11",
110
+ "@codemirror/lang-javascript": "^6.2.5",
111
+ "@codemirror/lang-json": "^6.0.2",
112
+ "@codemirror/lang-markdown": "^6.5.0",
113
+ "@codemirror/lang-python": "^6.2.1",
57
114
  "@fontsource-variable/inter": "^5.2.8",
58
115
  "@fontsource-variable/jetbrains-mono": "^5.2.8",
59
116
  "@happy-dom/global-registrator": "^20.10.2",
117
+ "@novnc/novnc": "^1.7.0",
118
+ "@pierre/diffs": "^1.2.11",
60
119
  "@tailwindcss/vite": "^4.2.4",
120
+ "@uiw/react-codemirror": "^4.25.10",
61
121
  "@types/react": "^19.2.14",
62
122
  "@types/react-dom": "^19.2.3",
63
123
  "@vitejs/plugin-react": "^6.0.1",
124
+ "@xterm/addon-fit": "^0.11.0",
125
+ "@xterm/addon-web-links": "^0.12.0",
126
+ "@xterm/xterm": "^6.0.0",
64
127
  "react": "^19.2.5",
65
128
  "react-dom": "^19.2.5",
66
129
  "tailwindcss": "^4.2.4",
package/src/client.ts CHANGED
@@ -51,4 +51,25 @@ export type SessionClientLike = Pick<
51
51
  | "createWorkspace"
52
52
  | "updateWorkspace"
53
53
  | "getBillingUsage"
54
+ // Stream surfacing (Phase 5): capability negotiation + viewer lifecycle
55
+ | "getClientConfig"
56
+ | "getStreamCapabilities"
57
+ | "acknowledgeStream"
58
+ | "attachViewer"
59
+ | "heartbeatViewer"
60
+ | "detachViewer"
61
+ // Channel-A structured services (terminal-as-events feed via fs/git/terminal)
62
+ | "fsList"
63
+ | "fsRead"
64
+ | "fsWrite"
65
+ | "fsDelete"
66
+ | "fsMove"
67
+ | "fsMkdir"
68
+ | "gitStatus"
69
+ | "gitDiff"
70
+ | "terminalExec"
71
+ | "terminalPtyOpen"
72
+ | "terminalPtyWrite"
73
+ | "terminalPtyResize"
74
+ | "terminalPtyClose"
54
75
  >;
@@ -0,0 +1,398 @@
1
+ import { Loader2Icon, SaveIcon } from "lucide-react";
2
+ import {
3
+ type ComponentType,
4
+ type CSSProperties,
5
+ type ReactNode,
6
+ lazy,
7
+ Suspense,
8
+ useCallback,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from "react";
14
+ import { cn } from "../lib/cn";
15
+
16
+ // --- Lazy CodeMirror surface ----------------------------------------------
17
+ //
18
+ // CodeMirror 6 (via @uiw/react-codemirror) plus the per-language grammars are a
19
+ // heavy bundle (lezer parsers, the view layer). We keep them OFF the critical
20
+ // path and out of any SSR bundle by lazy-importing only when a file is actually
21
+ // EDITED. The read-only viewer stays Pierre's Shiki `File`; this is the
22
+ // editable complement, mounted on demand.
23
+
24
+ /** The subset of `@uiw/react-codemirror`'s props we drive. */
25
+ type ReactCodeMirrorComponent = ComponentType<{
26
+ value?: string;
27
+ height?: string;
28
+ theme?: "light" | "dark" | "none" | unknown;
29
+ editable?: boolean;
30
+ readOnly?: boolean;
31
+ basicSetup?: boolean | Record<string, boolean>;
32
+ extensions?: unknown[];
33
+ onChange?: ((value: string) => void) | undefined;
34
+ className?: string;
35
+ style?: CSSProperties;
36
+ }>;
37
+
38
+ // Resolved lazily on first edit: the editor component + the keymap/Prec helpers
39
+ // (both re-exported from @uiw/react-codemirror, which re-exports @codemirror/view
40
+ // and @codemirror/state) + the chosen language extension.
41
+ type EditorBundle = {
42
+ Editor: ReactCodeMirrorComponent;
43
+ saveKeymapExtension: (onSave: () => void) => unknown;
44
+ languageExtension: unknown | null;
45
+ };
46
+
47
+ /** Language grammar loaders, keyed by the extension class we infer from the path. */
48
+ const LANGUAGE_LOADERS: Record<string, () => Promise<unknown>> = {
49
+ javascript: async () =>
50
+ (await import("@codemirror/lang-javascript")).javascript({ jsx: true, typescript: true }),
51
+ json: async () => (await import("@codemirror/lang-json")).json(),
52
+ python: async () => (await import("@codemirror/lang-python")).python(),
53
+ markdown: async () => (await import("@codemirror/lang-markdown")).markdown(),
54
+ css: async () => (await import("@codemirror/lang-css")).css(),
55
+ html: async () => (await import("@codemirror/lang-html")).html(),
56
+ };
57
+
58
+ /** Map a filename to a grammar key (or null for plain text — still fully editable). */
59
+ export function languageForPath(path: string): keyof typeof LANGUAGE_LOADERS | null {
60
+ const name = path.split("/").pop() ?? path;
61
+ const ext = name.includes(".") ? (name.split(".").pop() ?? "").toLowerCase() : "";
62
+ switch (ext) {
63
+ case "js":
64
+ case "jsx":
65
+ case "ts":
66
+ case "tsx":
67
+ case "mjs":
68
+ case "cjs":
69
+ return "javascript";
70
+ case "json":
71
+ case "jsonc":
72
+ return "json";
73
+ case "py":
74
+ case "pyi":
75
+ return "python";
76
+ case "md":
77
+ case "markdown":
78
+ case "mdx":
79
+ return "markdown";
80
+ case "css":
81
+ case "scss":
82
+ case "less":
83
+ return "css";
84
+ case "html":
85
+ case "htm":
86
+ case "xml":
87
+ case "svg":
88
+ case "vue":
89
+ return "html";
90
+ default:
91
+ return null;
92
+ }
93
+ }
94
+
95
+ const LazyCodeMirror = lazy(async () => {
96
+ const mod = (await import("@uiw/react-codemirror")) as unknown as {
97
+ default: ReactCodeMirrorComponent;
98
+ };
99
+ return { default: mod.default };
100
+ });
101
+
102
+ export type CodeEditorProps = {
103
+ /** Workspace-relative path — drives language inference (and the save target upstream). */
104
+ path: string;
105
+ /** The decoded text contents to seed the editor with. */
106
+ initialContents: string;
107
+ /** Persist the current buffer. Resolves when the write lands; rejects to surface an error. */
108
+ onSave: (contents: string) => Promise<unknown>;
109
+ /** Read-only mode (e.g. a truncated/too-large file shown for reference only). */
110
+ readOnly?: boolean | undefined;
111
+ themeType?: "dark" | "light" | undefined;
112
+ /** Rendered while the (lazy) CodeMirror bundle loads. */
113
+ loading?: ReactNode | undefined;
114
+ /** Rendered if `@uiw/react-codemirror` is not installed / fails to import. */
115
+ fallback?: ReactNode | undefined;
116
+ className?: string | undefined;
117
+ };
118
+
119
+ /**
120
+ * The EDITABLE single-file pane: CodeMirror 6 with a per-language grammar chosen
121
+ * from the filename, og-* themed, with dirty tracking and a save path wired to
122
+ * `Cmd/Ctrl+S` *and* an explicit Save button. The viewer (Pierre `File`) stays
123
+ * the read-only complement — this is only mounted when the user opts to edit.
124
+ *
125
+ * Save semantics: the buffer is "dirty" the moment it diverges from the last
126
+ * saved baseline; a successful `onSave` clears dirty and re-baselines. A failed
127
+ * save keeps the buffer dirty (nothing is lost) and surfaces the error inline.
128
+ * `readOnly` suppresses every mutation path so a truncated/binary file can never
129
+ * be saved back (which would corrupt it by writing the truncated prefix).
130
+ */
131
+ export function CodeEditor({
132
+ path,
133
+ initialContents,
134
+ onSave,
135
+ readOnly = false,
136
+ themeType = "dark",
137
+ loading,
138
+ fallback,
139
+ className,
140
+ }: CodeEditorProps) {
141
+ const [failed, setFailed] = useState(false);
142
+ const [bundle, setBundle] = useState<EditorBundle | null>(null);
143
+
144
+ // The live buffer + the baseline it's compared against for dirtiness. The
145
+ // baseline resets whenever a *new* file is loaded (path/initialContents change)
146
+ // or after a successful save.
147
+ const [value, setValue] = useState(initialContents);
148
+ const [baseline, setBaseline] = useState(initialContents);
149
+ const [saving, setSaving] = useState(false);
150
+ const [saveError, setSaveError] = useState<Error | null>(null);
151
+ const [savedTick, setSavedTick] = useState(false);
152
+
153
+ const dirty = value !== baseline;
154
+
155
+ // Reload the buffer when the file identity changes. Guard on initialContents
156
+ // too so an external refresh of the SAME path (e.g. fs.changed re-read) reseeds
157
+ // a clean buffer — but only when the user hasn't got unsaved edits in flight.
158
+ const lastSeed = useRef<{ path: string; contents: string }>({ path, contents: initialContents });
159
+ useEffect(() => {
160
+ const seedChanged =
161
+ lastSeed.current.path !== path || lastSeed.current.contents !== initialContents;
162
+ if (!seedChanged) return;
163
+ lastSeed.current = { path, contents: initialContents };
164
+ setValue(initialContents);
165
+ setBaseline(initialContents);
166
+ setSaveError(null);
167
+ setSavedTick(false);
168
+ }, [path, initialContents]);
169
+
170
+ // Resolve the lazy bundle (editor + keymap/Prec helpers + language grammar)
171
+ // once, re-resolving the grammar when the file's language class changes.
172
+ const langKey = useMemo(() => languageForPath(path), [path]);
173
+ useEffect(() => {
174
+ let cancelled = false;
175
+ void (async () => {
176
+ try {
177
+ const cmMod = (await import("@uiw/react-codemirror")) as unknown as {
178
+ default: ReactCodeMirrorComponent;
179
+ keymap: { of: (binds: unknown[]) => unknown };
180
+ Prec: { highest: (ext: unknown) => unknown };
181
+ };
182
+ const languageExtension = langKey ? await LANGUAGE_LOADERS[langKey]?.() : null;
183
+ if (cancelled) return;
184
+ // A high-precedence Cmd/Ctrl-S keymap that calls back into the latest
185
+ // save handler (kept fresh via a ref) and swallows the browser's
186
+ // "save page" default.
187
+ const saveKeymapExtension = (run: () => void) =>
188
+ cmMod.Prec.highest(
189
+ cmMod.keymap.of([
190
+ {
191
+ key: "Mod-s",
192
+ preventDefault: true,
193
+ run: () => {
194
+ run();
195
+ return true;
196
+ },
197
+ },
198
+ ]),
199
+ );
200
+ setBundle({
201
+ Editor: cmMod.default,
202
+ saveKeymapExtension,
203
+ languageExtension: languageExtension ?? null,
204
+ });
205
+ } catch {
206
+ if (!cancelled) setFailed(true);
207
+ }
208
+ })();
209
+ return () => {
210
+ cancelled = true;
211
+ };
212
+ }, [langKey]);
213
+
214
+ // Keep the freshest save closure reachable from the (stable) keymap binding.
215
+ const saveRef = useRef<() => void>(() => {});
216
+
217
+ const save = useCallback(async () => {
218
+ if (readOnly) return;
219
+ // Snapshot the buffer at call time so an in-flight edit can't race the write.
220
+ const snapshot = value;
221
+ if (snapshot === baseline) return; // nothing to persist
222
+ setSaving(true);
223
+ setSaveError(null);
224
+ setSavedTick(false);
225
+ try {
226
+ await onSave(snapshot);
227
+ setBaseline(snapshot);
228
+ lastSeed.current = { path, contents: snapshot };
229
+ setSavedTick(true);
230
+ } catch (cause) {
231
+ setSaveError(cause instanceof Error ? cause : new Error(String(cause)));
232
+ } finally {
233
+ setSaving(false);
234
+ }
235
+ }, [readOnly, value, baseline, onSave, path]);
236
+
237
+ saveRef.current = () => {
238
+ void save();
239
+ };
240
+
241
+ // A brief "Saved" affordance after a successful write, cleared on the next edit.
242
+ useEffect(() => {
243
+ if (!savedTick) return;
244
+ const t = setTimeout(() => setSavedTick(false), 1800);
245
+ return () => clearTimeout(t);
246
+ }, [savedTick]);
247
+
248
+ const fileName = path.split("/").filter(Boolean).pop() ?? path;
249
+
250
+ const cmExtensions = useMemo(() => {
251
+ if (!bundle) return [] as unknown[];
252
+ const exts: unknown[] = [bundle.saveKeymapExtension(() => saveRef.current())];
253
+ if (bundle.languageExtension) exts.push(bundle.languageExtension);
254
+ return exts;
255
+ }, [bundle]);
256
+
257
+ if (failed) {
258
+ return (
259
+ <div className={cn("flex h-full min-h-0 flex-col", className)}>
260
+ {fallback ?? (
261
+ <PlainTextarea
262
+ value={value}
263
+ readOnly={readOnly}
264
+ onChange={(next) => {
265
+ setValue(next);
266
+ setSavedTick(false);
267
+ }}
268
+ />
269
+ )}
270
+ </div>
271
+ );
272
+ }
273
+
274
+ return (
275
+ <div
276
+ className={cn("flex h-full min-h-0 flex-col", className)}
277
+ data-opengeni-code-editor
278
+ style={editorVars}
279
+ >
280
+ {/* Save bar: dirty indicator + status + the explicit Save button. */}
281
+ <div className="flex shrink-0 items-center gap-2 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))] px-2 py-1">
282
+ <span
283
+ className={cn(
284
+ "size-1.5 shrink-0 rounded-full transition-colors",
285
+ readOnly
286
+ ? "bg-transparent"
287
+ : dirty
288
+ ? "bg-[color:var(--og-color-status-running,var(--color-warning,#d29922))]"
289
+ : "bg-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]",
290
+ )}
291
+ title={readOnly ? "read-only" : dirty ? "unsaved changes" : "saved"}
292
+ />
293
+ <span className="truncate font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[11px] text-[color:var(--og-color-fg-muted,var(--color-fg-muted,#aaa))]">
294
+ {fileName}
295
+ {dirty && !readOnly ? " •" : ""}
296
+ </span>
297
+ <div className="ml-auto flex shrink-0 items-center gap-2">
298
+ {saveError && (
299
+ <span
300
+ className="max-w-[220px] truncate text-[10px] text-[color:var(--og-color-danger,var(--color-danger,#f85149))]"
301
+ title={saveError.message}
302
+ >
303
+ {saveError.message}
304
+ </span>
305
+ )}
306
+ {!saveError && savedTick && (
307
+ <span className="text-[10px] text-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]">
308
+ Saved
309
+ </span>
310
+ )}
311
+ {readOnly ? (
312
+ <span className="text-[10px] uppercase tracking-wide text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
313
+ Read-only
314
+ </span>
315
+ ) : (
316
+ <button
317
+ type="button"
318
+ onClick={() => void save()}
319
+ disabled={saving || !dirty}
320
+ className={cn(
321
+ "flex items-center gap-1 rounded-[var(--og-radius-sm,4px)] border border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] px-1.5 py-0.5 text-[10px]",
322
+ saving || !dirty
323
+ ? "cursor-default text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))] opacity-60"
324
+ : "text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))] hover:bg-[color:var(--og-color-accent-soft,var(--color-surface-2,#222))]",
325
+ )}
326
+ title="Save (⌘/Ctrl+S)"
327
+ >
328
+ {saving ? (
329
+ <Loader2Icon className="size-3 animate-spin" />
330
+ ) : (
331
+ <SaveIcon className="size-3" />
332
+ )}
333
+ Save
334
+ </button>
335
+ )}
336
+ </div>
337
+ </div>
338
+
339
+ {/* The editor surface. */}
340
+ <div className="min-h-0 flex-1 overflow-auto">
341
+ <Suspense fallback={loading ?? <EditorSkeleton />}>
342
+ <LazyCodeMirror
343
+ value={value}
344
+ theme={themeType === "light" ? "light" : "dark"}
345
+ editable={!readOnly}
346
+ readOnly={readOnly}
347
+ basicSetup={true}
348
+ extensions={cmExtensions}
349
+ height="100%"
350
+ className="og-cm-editor min-h-full text-[12.5px]"
351
+ onChange={
352
+ readOnly
353
+ ? undefined
354
+ : (next: string) => {
355
+ setValue(next);
356
+ setSavedTick(false);
357
+ }
358
+ }
359
+ />
360
+ </Suspense>
361
+ </div>
362
+ </div>
363
+ );
364
+ }
365
+
366
+ /** og-* themed CSS-var overrides handed to CodeMirror's container. */
367
+ const editorVars = {
368
+ "--og-cm-bg": "var(--og-color-bg, #0d0d0d)",
369
+ fontFamily: "var(--og-font-mono, var(--font-mono, monospace))",
370
+ } as CSSProperties;
371
+
372
+ function PlainTextarea({
373
+ value,
374
+ readOnly,
375
+ onChange,
376
+ }: {
377
+ value: string;
378
+ readOnly: boolean;
379
+ onChange: (value: string) => void;
380
+ }) {
381
+ return (
382
+ <textarea
383
+ value={value}
384
+ readOnly={readOnly}
385
+ spellCheck={false}
386
+ onChange={(e) => onChange(e.target.value)}
387
+ className="h-full w-full resize-none bg-transparent 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))] outline-none"
388
+ />
389
+ );
390
+ }
391
+
392
+ function EditorSkeleton() {
393
+ return (
394
+ <div className="p-3 text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
395
+ Loading editor…
396
+ </div>
397
+ );
398
+ }