@opengeni/react 0.13.0 → 0.15.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 (41) hide show
  1. package/README.md +19 -13
  2. package/dist/chunk-TOJR776I.js +2280 -0
  3. package/dist/chunk-TOJR776I.js.map +1 -0
  4. package/dist/index.d.ts +314 -255
  5. package/dist/index.js +5862 -4404
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-CnlMb7E-.d.ts → machines-BpdwuQcD.d.ts} +130 -10
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +23 -1
  10. package/package.json +5 -2
  11. package/src/client.ts +10 -1
  12. package/src/components/chat-composer.tsx +309 -57
  13. package/src/components/machine-card.tsx +81 -15
  14. package/src/components/machine-health-pill.tsx +68 -0
  15. package/src/components/machine-metrics.tsx +10 -24
  16. package/src/components/machines/health.ts +146 -0
  17. package/src/components/machines/machine-detail.tsx +220 -0
  18. package/src/components/machines/metric-history-chart.tsx +298 -0
  19. package/src/components/machines/metric-sparkline.tsx +76 -0
  20. package/src/components/machines/series.ts +113 -0
  21. package/src/components/machines-dashboard.tsx +13 -1
  22. package/src/components/queue-surface.tsx +578 -0
  23. package/src/components/sandbox-files.tsx +94 -9
  24. package/src/components/sandbox-workspace.tsx +186 -52
  25. package/src/components/session-status.tsx +0 -6
  26. package/src/components/workbench-changes.tsx +64 -20
  27. package/src/components/workspace-dock.tsx +146 -55
  28. package/src/hooks/use-composer.ts +369 -39
  29. package/src/hooks/use-session-control.ts +6 -7
  30. package/src/hooks/use-session-events.ts +3 -2
  31. package/src/hooks/use-session-lineage.ts +15 -6
  32. package/src/hooks/use-session.ts +10 -2
  33. package/src/hooks/use-turn-queue.ts +175 -47
  34. package/src/index.ts +13 -7
  35. package/src/machines.ts +16 -0
  36. package/src/provider.tsx +192 -5
  37. package/src/timeline/parsers.ts +43 -6
  38. package/src/timeline/projection.ts +24 -2
  39. package/styles/index.css +22 -0
  40. package/dist/chunk-NFYVQWIB.js +0 -1377
  41. package/dist/chunk-NFYVQWIB.js.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import type { FsReadResponse } from "@opengeni/sdk";
2
+ import { FileCode2Icon, FileWarningIcon, LoaderCircleIcon } from "lucide-react";
2
3
  import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
3
4
  import { cn } from "../lib/cn";
4
5
  import { useThemeType } from "../lib/use-theme-type";
@@ -27,6 +28,14 @@ export type SandboxFilesProps = {
27
28
  * dock warms the box on this so the save lands fast; opening/reading never fires
28
29
  * it. Browsing the tree/diff must not warm a box. */
29
30
  onEditIntent?: (() => void) | undefined;
31
+ /** A guarded diff path routed here by the parent workspace. */
32
+ requestedPath?: string | undefined;
33
+ /** Identity for one guarded-file request. Increment this when the same path is
34
+ * deliberately requested again; it also lets a pending request be consumed
35
+ * without overriding later manual tree navigation. Defaults to the path. */
36
+ requestedPathRequestId?: string | number | undefined;
37
+ /** False while the parent is waking a cold sandbox for `requestedPath`. */
38
+ requestedPathReady?: boolean | undefined;
30
39
  themeType?: "dark" | "light" | undefined;
31
40
  className?: string | undefined;
32
41
  };
@@ -45,6 +54,9 @@ export function SandboxFiles({
45
54
  usePierre = true,
46
55
  editable = true,
47
56
  onEditIntent,
57
+ requestedPath,
58
+ requestedPathRequestId,
59
+ requestedPathReady = true,
48
60
  themeType,
49
61
  className,
50
62
  }: SandboxFilesProps) {
@@ -52,6 +64,28 @@ export function SandboxFiles({
52
64
  // View vs Edit for the selected file. Resets to View on every new selection so
53
65
  // opening a file never lands you in a stale dirty editor for a different path.
54
66
  const [editMode, setEditMode] = useState(false);
67
+ const pendingRequestRef = useRef<string | number | null>(null);
68
+ const handledRequestRef = useRef<string | number | null>(null);
69
+ const requestKey = requestedPath ? (requestedPathRequestId ?? requestedPath) : null;
70
+
71
+ useEffect(() => {
72
+ if (!requestedPath || requestKey === null) {
73
+ pendingRequestRef.current = null;
74
+ handledRequestRef.current = null;
75
+ return;
76
+ }
77
+ if (handledRequestRef.current === requestKey) {
78
+ return;
79
+ }
80
+ if (!requestedPathReady) {
81
+ pendingRequestRef.current = requestKey;
82
+ return;
83
+ }
84
+ handledRequestRef.current = requestKey;
85
+ pendingRequestRef.current = null;
86
+ setSelected(requestedPath);
87
+ setEditMode(false);
88
+ }, [requestKey, requestedPath, requestedPathReady]);
55
89
 
56
90
  // Side-by-side (tree left, viewer right) once the surface is wide enough;
57
91
  // stacked (tree over viewer) on a narrow dock. Tracked off the container so it
@@ -80,8 +114,14 @@ export function SandboxFiles({
80
114
  const fileView = useFileView(viewPath, files.readFile);
81
115
 
82
116
  // Selecting a (different) file always returns to View — never drop the user into
83
- // an editor whose buffer belongs to the previously-selected path.
117
+ // an editor whose buffer belongs to the previously-selected path. Manual
118
+ // navigation also consumes a pending guarded-file request: a late cold→warm
119
+ // transition must never pull the user away from the file they chose meanwhile.
84
120
  const selectFile = useCallback((path: string) => {
121
+ if (pendingRequestRef.current !== null) {
122
+ handledRequestRef.current = pendingRequestRef.current;
123
+ pendingRequestRef.current = null;
124
+ }
85
125
  setSelected(path);
86
126
  setEditMode(false);
87
127
  }, []);
@@ -101,7 +141,15 @@ export function SandboxFiles({
101
141
  const showEditor = canEdit && editMode;
102
142
 
103
143
  if (!fileSystemAvailable) {
104
- return <Notice className={className}>This sandbox does not expose a file system.</Notice>;
144
+ return (
145
+ <Notice
146
+ className={className}
147
+ icon={<FileWarningIcon className="size-5" aria-hidden />}
148
+ title="Files unavailable"
149
+ >
150
+ This sandbox does not expose a file system.
151
+ </Notice>
152
+ );
105
153
  }
106
154
 
107
155
  return (
@@ -138,7 +186,10 @@ export function SandboxFiles({
138
186
  )}
139
187
  >
140
188
  <div className="flex shrink-0 items-center justify-between gap-2 border-b border-og-border bg-og-surface-1 px-2 py-1">
141
- <span className="min-w-0 truncate font-og-mono text-og-xs text-og-fg-muted">
189
+ <span
190
+ data-opengeni-selected-file
191
+ className="min-w-0 truncate font-og-mono text-og-xs text-og-fg-muted"
192
+ >
142
193
  {selected ?? "No file selected"}
143
194
  </span>
144
195
  {/* View/Edit toggle — only for a real, fully-loaded text file the editor
@@ -206,9 +257,23 @@ export function SandboxFiles({
206
257
  ) : (
207
258
  <Notice>Loading {viewPath}…</Notice>
208
259
  )
260
+ ) : // Nothing selected — the tree shows the whole workspace; pick a file.
261
+ requestedPath && !requestedPathReady ? (
262
+ <Notice
263
+ icon={
264
+ <LoaderCircleIcon
265
+ className="size-5 animate-spin motion-reduce:animate-none"
266
+ aria-hidden
267
+ />
268
+ }
269
+ title="Waking sandbox"
270
+ >
271
+ Opening {requestedPath} when the live workspace is ready…
272
+ </Notice>
209
273
  ) : (
210
- // Nothing selected the tree shows the whole workspace; pick a file.
211
- <Notice>Select a file in the tree to view it.</Notice>
274
+ <Notice icon={<FileCode2Icon className="size-5" aria-hidden />} title="Choose a file">
275
+ Select a file in the tree to preview it.
276
+ </Notice>
212
277
  )}
213
278
  </div>
214
279
  </div>
@@ -238,7 +303,9 @@ function GitHeader({ git, dirtyCount }: { git: UseSandboxGitResult; dirtyCount:
238
303
  </span>
239
304
  )}
240
305
  {dirty && (
241
- <span className="ml-auto shrink-0 text-og-xs text-og-fg-subtle">{dirtyCount} changed</span>
306
+ <span data-contrast-audited className="ml-auto shrink-0 text-og-xs text-og-fg-subtle">
307
+ {dirtyCount} changed
308
+ </span>
242
309
  )}
243
310
  </div>
244
311
  );
@@ -261,7 +328,7 @@ function Segmented({
261
328
  type="button"
262
329
  onClick={() => onChange(opt.value)}
263
330
  className={cn(
264
- "min-h-7 rounded-og-xs px-1.5 py-0.5 text-og-xs max-[1023px]:min-h-11 pointer-coarse:min-h-11",
331
+ "min-h-7 rounded-og-xs px-1.5 py-0.5 text-og-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:min-h-11 pointer-coarse:min-h-11",
265
332
  opt.value === value
266
333
  ? "bg-og-accent-soft text-og-fg"
267
334
  : "text-og-fg-subtle hover:text-og-fg",
@@ -375,7 +442,17 @@ function decodeBase64Utf8(b64: string): string {
375
442
  return b64;
376
443
  }
377
444
 
378
- function Notice({ children, className }: { children: ReactNode; className?: string | undefined }) {
445
+ function Notice({
446
+ children,
447
+ className,
448
+ icon,
449
+ title,
450
+ }: {
451
+ children: ReactNode;
452
+ className?: string | undefined;
453
+ icon?: ReactNode | undefined;
454
+ title?: string | undefined;
455
+ }) {
379
456
  return (
380
457
  <div
381
458
  className={cn(
@@ -383,7 +460,15 @@ function Notice({ children, className }: { children: ReactNode; className?: stri
383
460
  className,
384
461
  )}
385
462
  >
386
- {children}
463
+ <div className="flex max-w-sm flex-col items-center gap-2.5">
464
+ {icon ? (
465
+ <span className="grid size-10 place-items-center rounded-og-lg border border-og-border bg-og-surface-1 text-og-fg-muted shadow-sm">
466
+ {icon}
467
+ </span>
468
+ ) : null}
469
+ {title ? <p className="font-medium text-og-fg">{title}</p> : null}
470
+ <div className="leading-5">{children}</div>
471
+ </div>
387
472
  </div>
388
473
  );
389
474
  }
@@ -16,7 +16,14 @@
16
16
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
17
17
  import { Popover } from "radix-ui";
18
18
  import type { SessionEvent } from "@opengeni/sdk";
19
- import { CpuIcon, LaptopIcon, RefreshCwIcon } from "lucide-react";
19
+ import {
20
+ CircleCheckIcon,
21
+ CpuIcon,
22
+ LaptopIcon,
23
+ LoaderCircleIcon,
24
+ RefreshCwIcon,
25
+ TriangleAlertIcon,
26
+ } from "lucide-react";
20
27
 
21
28
  import { type ClientOverride, useOpenGeni } from "../provider";
22
29
  import { cn } from "../lib/cn";
@@ -62,11 +69,9 @@ function captureDegradedMessage(reason: string): string {
62
69
  * "changes exist → Changes, else Files" needs zero machine round-trips. A host
63
70
  * `override` (e.g. a landing "run" tab) wins when supplied.
64
71
  *
65
- * NOTE: the dock no longer uses this for its default tab — `<SandboxWorkspace>`
66
- * now derives the default from its OWN capture fetch (`useWorkspaceCapture`'s
67
- * `fileCount`), so a pure embedder needs no events-at-mount contract (Refinement
68
- * 2). This remains exported as a standalone helper for hosts that already hold
69
- * the event log and want the same decision without the capture fetch.
72
+ * `<SandboxWorkspace>` uses this only as an optional early hint after its own
73
+ * capture GET reports no durable capture. A pure embedder needs no events-at-mount
74
+ * contract: it falls through to authoritative live Git instead.
70
75
  */
71
76
  export function initialWorkspaceTab(
72
77
  events: SessionEvent[] | undefined,
@@ -131,22 +136,28 @@ export type UseSandboxWorkspaceTabsOptions = ClientOverride & {
131
136
  sessionId: string;
132
137
  /** Live event log (usually `useSessionEvents().events`). */
133
138
  events: SessionEvent[];
134
- /** Override the capture-driven default tab (e.g. a host landing tab id). When
135
- * omitted the workbench picks Changes-vs-Files from its own capture fetch. */
139
+ /** Override the source-driven default tab (e.g. a host landing tab id). When
140
+ * omitted the workbench picks Changes-vs-Files from capture or live Git. */
136
141
  initialTab?: string | null | undefined;
137
142
  /** Host-routed notifications (mutation errors, desktop-consent failures). The
138
143
  * package never imports a toast library — the host decides how to surface. */
139
144
  onNotify?: ((notification: WorkspaceNotification) => void) | undefined;
145
+ /** File requested by a Changes guard. The Files surface defers the read until
146
+ * the sandbox is live, then reveals this path. */
147
+ requestedFilePath?: string | null | undefined;
148
+ /** Unique identity for `requestedFilePath`, including repeated requests for the
149
+ * same path. */
150
+ requestedFileRequestId?: string | number | null | undefined;
151
+ /** Route a guarded diff into the host's Files tab. */
152
+ onOpenFile?: ((path: string) => void) | undefined;
140
153
  };
141
154
 
142
155
  export type UseSandboxWorkspaceTabsResult = {
143
156
  /** Changes | Files | Terminal | Desktop (capability-gated where noted). */
144
157
  tabs: WorkspaceTab[];
145
- /** The capture-driven default tab: Changes when the session has changes, else
146
- * Files (a host `initialTab` overrides). null during the brief window before the
147
- * capture GET first resolves (and no host override was given) — consumers render
148
- * their dock's own first-tab fallback until it latches, which it does ONCE, so it
149
- * never causes a post-render switch. */
158
+ /** The source-driven default tab: Changes when the first authoritative capture
159
+ * or live Git result has changes, else Files (a host `initialTab` overrides).
160
+ * null while that source resolves; the choice latches once. */
150
161
  defaultTab: string | null;
151
162
  /** The machine-state model for the dock-header chip. */
152
163
  machine: WorkspaceMachine;
@@ -156,7 +167,7 @@ type SessionWarmIntents = {
156
167
  sessionId: string;
157
168
  watchDesktop: boolean;
158
169
  warmTerminal: boolean;
159
- warmEdit: boolean;
170
+ warmFiles: boolean;
160
171
  };
161
172
 
162
173
  function emptyWarmIntents(sessionId: string): SessionWarmIntents {
@@ -164,7 +175,7 @@ function emptyWarmIntents(sessionId: string): SessionWarmIntents {
164
175
  sessionId,
165
176
  watchDesktop: false,
166
177
  warmTerminal: false,
167
- warmEdit: false,
178
+ warmFiles: false,
168
179
  };
169
180
  }
170
181
 
@@ -177,20 +188,21 @@ export function useSandboxWorkspaceTabs(
177
188
  options: UseSandboxWorkspaceTabsOptions,
178
189
  ): UseSandboxWorkspaceTabsResult {
179
190
  const { client, workspaceId } = useOpenGeni(options);
180
- const { sessionId, events, onNotify } = options;
191
+ const { sessionId, events, onNotify, requestedFilePath, requestedFileRequestId, onOpenFile } =
192
+ options;
181
193
  const initialTab = options.initialTab ?? null;
182
194
 
183
- // The three — and only three — box-warming INTENTS, each off by default and each
195
+ // The three box-warming INTENTS, each off by default and each
184
196
  // flipped true by a genuine user action (never on mount, never on a passive
185
197
  // capture glance): desktop watch consent, terminal engagement (`onActivate`), and
186
- // the first wake-on-edit keystroke in the Files editor. Browsing capture-served
198
+ // a deliberate live-file open/edit in Files. Browsing capture-served
187
199
  // Changes/Files warms nothing — that is the whole point of Refinement 1.
188
200
  const [storedWarmIntents, setStoredWarmIntents] = useState<SessionWarmIntents>(() =>
189
201
  emptyWarmIntents(sessionId),
190
202
  );
191
203
  const warmIntents =
192
204
  storedWarmIntents.sessionId === sessionId ? storedWarmIntents : emptyWarmIntents(sessionId);
193
- const { watchDesktop, warmTerminal, warmEdit } = warmIntents;
205
+ const { watchDesktop, warmTerminal, warmFiles } = warmIntents;
194
206
  const requestWarmIntent = useCallback(
195
207
  (intent: Exclude<keyof SessionWarmIntents, "sessionId">) => {
196
208
  setStoredWarmIntents((previous) => {
@@ -217,9 +229,9 @@ export function useSandboxWorkspaceTabs(
217
229
  events,
218
230
  attachDesktop: watchDesktop,
219
231
  attachTerminal: warmTerminal,
220
- // Edit intent only — NOT "the Files tab is open". A cold edit warms the box
221
- // (that is the wake); a cold glance at the tree/diff does not.
222
- attachFiles: warmEdit,
232
+ // Explicit live-file intent only — NOT "the Files tab is open". A cold edit
233
+ // or guarded-file open wakes the box; a glance at the tree/diff does not.
234
+ attachFiles: warmFiles,
223
235
  });
224
236
  const capabilities = caps.capabilities;
225
237
  const liveness = capabilities?.liveness;
@@ -363,18 +375,18 @@ export function useSandboxWorkspaceTabs(
363
375
  capabilitiesState: caps.state,
364
376
  activeMachineState: activeMachine?.state ?? null,
365
377
  activeIsSelfhosted: activeMachine?.kind === "selfhosted",
366
- wantsWarm: warmTerminal || watchDesktop || warmEdit,
378
+ wantsWarm: warmTerminal || watchDesktop || warmFiles,
367
379
  capturedAt: captureState.capturedAt,
368
380
  });
369
381
 
370
- // The pre-paint default tab, decided from the workbench's OWN capture fetch — no
371
- // embedder events-at-mount contract (Refinement 2). A host `initialTab` wins
372
- // immediately; otherwise the default stays null until the capture GET first
373
- // resolves, then latches Changes (changes exist) or Files, ONCE live data can
374
- // never switch it afterward. Committing at first-resolve before the tab body's
375
- // first CONTENT paint (both bodies show a connecting/loading state until the
376
- // capture lands) means the first real content is the correct tab, no switch. A
377
- // pure embedder that never preloads events now gets the right default for free.
382
+ // The pre-paint default tab is decided from the first AUTHORITATIVE workspace
383
+ // source. A capture wins immediately on the cold/offline fast path; a warm box
384
+ // waits for live Git so a prior snapshot can never outrank the current tree.
385
+ // When the GET says no capture exists, `fileCount: 0` does NOT itself mean the
386
+ // working tree is clean. A captured-revision announce can
387
+ // resolve Changes earlier, but a pure embedder with no event preload still gets
388
+ // the correct live default. The choice latches once so later edits never steal
389
+ // the user's current tab.
378
390
  const defaultTabRef = useRef<{ sessionId: string; value: string | null }>({
379
391
  sessionId,
380
392
  value: null,
@@ -382,12 +394,42 @@ export function useSandboxWorkspaceTabs(
382
394
  if (defaultTabRef.current.sessionId !== sessionId) {
383
395
  defaultTabRef.current = { sessionId, value: null };
384
396
  }
397
+ const liveWorkspaceExpected = liveness === "warm" || liveness === "draining";
398
+ const captureIsAuthoritative =
399
+ !liveWorkspaceExpected && (liveness !== undefined || caps.error !== null);
400
+ const captureUnavailable = captureState.fileCount === 0 || captureState.error !== null;
385
401
  if (defaultTabRef.current.value === null) {
386
402
  if (initialTab) {
387
403
  defaultTabRef.current.value = initialTab;
388
- } else if (captureState.fileCount !== null) {
404
+ } else if (git.source === "live") {
405
+ defaultTabRef.current.value =
406
+ git.diff.length > 0 ? WORKBENCH_TAB_CHANGES : WORKBENCH_TAB_FILES;
407
+ } else if (
408
+ captureIsAuthoritative &&
409
+ captureState.fileCount !== null &&
410
+ captureState.available
411
+ ) {
412
+ defaultTabRef.current.value =
413
+ captureState.fileCount > 0 ? WORKBENCH_TAB_CHANGES : WORKBENCH_TAB_FILES;
414
+ } else if (
415
+ captureIsAuthoritative &&
416
+ captureUnavailable &&
417
+ initialWorkspaceTab(events) === WORKBENCH_TAB_CHANGES
418
+ ) {
419
+ defaultTabRef.current.value = WORKBENCH_TAB_CHANGES;
420
+ } else if (captureState.available && git.error && captureState.fileCount !== null) {
421
+ // A warm live read failed but the durable capture is intact: retain an
422
+ // immediate, deterministic review surface instead of hanging unresolved.
389
423
  defaultTabRef.current.value =
390
424
  captureState.fileCount > 0 ? WORKBENCH_TAB_CHANGES : WORKBENCH_TAB_FILES;
425
+ } else if (captureUnavailable && caps.error) {
426
+ // The Changes surface owns the truthful sandbox-unavailable state + retry.
427
+ // Files would misleadingly describe this as a missing filesystem.
428
+ defaultTabRef.current.value = WORKBENCH_TAB_CHANGES;
429
+ } else if (captureUnavailable && git.error) {
430
+ // No capture and live Git failed: Files is the least surprising fallback
431
+ // and preserves the established no-capture degraded behavior.
432
+ defaultTabRef.current.value = WORKBENCH_TAB_FILES;
391
433
  }
392
434
  }
393
435
  // null only during the brief pre-first-resolve window (no host override yet).
@@ -411,6 +453,14 @@ export function useSandboxWorkspaceTabs(
411
453
  capabilitiesState={caps.state}
412
454
  capabilitiesError={caps.error}
413
455
  onRetry={caps.renegotiate}
456
+ {...(onOpenFile
457
+ ? {
458
+ onOpenFile: (path: string) => {
459
+ requestWarmIntent("warmFiles");
460
+ onOpenFile(path);
461
+ },
462
+ }
463
+ : {})}
414
464
  />
415
465
  ),
416
466
  });
@@ -421,14 +471,24 @@ export function useSandboxWorkspaceTabs(
421
471
  label: "Files",
422
472
  content: (
423
473
  <SandboxFiles
474
+ key={sessionId}
424
475
  files={files}
425
476
  git={git}
426
477
  stagedGit={stagedGit}
427
478
  fileSystemAvailable={fileSystemOn || captureAvailable}
428
479
  editable={filesEditable}
429
- // The first keystroke in the editor is the wake-on-edit INTENT: warm the
430
- // box (even cold) so the write lands fast. Opening/reading files does not.
431
- onEditIntent={() => requestWarmIntent("warmEdit")}
480
+ {...(requestedFilePath
481
+ ? {
482
+ requestedPath: requestedFilePath,
483
+ ...(requestedFileRequestId !== null && requestedFileRequestId !== undefined
484
+ ? { requestedPathRequestId: requestedFileRequestId }
485
+ : {}),
486
+ requestedPathReady: liveness === "warm" || liveness === "draining",
487
+ }
488
+ : {})}
489
+ // Ordinary capture browsing does not warm a box. The first edit — or an
490
+ // explicit guarded-file open from Changes — is deliberate live-file intent.
491
+ onEditIntent={() => requestWarmIntent("warmFiles")}
432
492
  className="h-full"
433
493
  />
434
494
  ),
@@ -488,6 +548,11 @@ export function useSandboxWorkspaceTabs(
488
548
  dirtyCount,
489
549
  watchDesktop,
490
550
  warmTerminal,
551
+ requestedFilePath,
552
+ requestedFileRequestId,
553
+ onOpenFile,
554
+ liveness,
555
+ sessionId,
491
556
  files,
492
557
  git,
493
558
  stagedGit,
@@ -524,7 +589,7 @@ export type SandboxWorkspaceProps = ClientOverride & {
524
589
  /** Host tabs injected AFTER the workbench tabs (e.g. a "Debug" tab). */
525
590
  trailingTabs?: WorkspaceTab[] | undefined;
526
591
  /** Override the pre-paint default tab (e.g. a host landing tab id). When
527
- * omitted the workbench decides Changes-vs-Files from local capture stats. */
592
+ * omitted the workbench decides from capture, then live Git when no capture exists. */
528
593
  initialTab?: string | undefined;
529
594
  /** Host-routed notifications (no toast dependency in the package). */
530
595
  onNotify?: ((notification: WorkspaceNotification) => void) | undefined;
@@ -566,6 +631,25 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
566
631
  className,
567
632
  } = props;
568
633
 
634
+ const [storedSelection, setStoredSelection] = useState<{
635
+ sessionId: string;
636
+ tab: string;
637
+ } | null>(null);
638
+ const [requestedFile, setRequestedFile] = useState<{
639
+ sessionId: string;
640
+ path: string;
641
+ requestId: number;
642
+ } | null>(null);
643
+ const nextFileRequestId = useRef(0);
644
+ const openFile = useCallback(
645
+ (path: string) => {
646
+ nextFileRequestId.current += 1;
647
+ setRequestedFile({ sessionId, path, requestId: nextFileRequestId.current });
648
+ setStoredSelection({ sessionId, tab: WORKBENCH_TAB_FILES });
649
+ },
650
+ [sessionId],
651
+ );
652
+
569
653
  const {
570
654
  tabs: workbenchTabs,
571
655
  machine,
@@ -577,18 +661,17 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
577
661
  events,
578
662
  ...(initialTab ? { initialTab } : {}),
579
663
  ...(onNotify ? { onNotify } : {}),
664
+ requestedFilePath: requestedFile?.sessionId === sessionId ? requestedFile.path : null,
665
+ requestedFileRequestId: requestedFile?.sessionId === sessionId ? requestedFile.requestId : null,
666
+ onOpenFile: openFile,
580
667
  });
581
668
 
582
- // A user's tab click wins forever; before that we follow the capture-driven
669
+ // A user's tab click wins forever; before that we follow the source-driven
583
670
  // default. While it is still resolving (null, pure-embedder pre-first-resolve)
584
671
  // we pass no controlled tab, so the dock renders its own first-tab fallback
585
672
  // (Changes) — whose body is a connecting/loading state until the capture lands,
586
673
  // so committing the real default at first-resolve produces no CONTENT switch.
587
674
  const tabs: WorkspaceTab[] = [...(leadingTabs ?? []), ...workbenchTabs, ...(trailingTabs ?? [])];
588
- const [storedSelection, setStoredSelection] = useState<{
589
- sessionId: string;
590
- tab: string;
591
- } | null>(null);
592
675
  const selectedTab = storedSelection?.sessionId === sessionId ? storedSelection.tab : null;
593
676
  const activeTab = selectedTab ?? defaultTab ?? tabs[0]?.id;
594
677
  const selectTab = useCallback(
@@ -645,7 +728,7 @@ function MachineKindIcon({
645
728
 
646
729
  function chipDotClass(state: MachineChip["state"]): string {
647
730
  if (state === "live") return "bg-og-status-running";
648
- if (state === "waking") return "bg-og-status-idle animate-pulse";
731
+ if (state === "waking") return "bg-og-status-idle animate-pulse motion-reduce:animate-none";
649
732
  return "bg-og-fg-subtle";
650
733
  }
651
734
 
@@ -673,7 +756,7 @@ function MachineStateChip({
673
756
  <button
674
757
  type="button"
675
758
  aria-label={`Machine: ${chip.label}`}
676
- className="inline-flex min-h-7 items-center gap-1.5 rounded-og-sm px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:bg-og-surface-2 hover:text-og-fg max-[1023px]:min-h-11 pointer-coarse:min-h-11"
759
+ className="inline-flex min-h-7 items-center gap-1.5 rounded-og-sm px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:bg-og-surface-2 hover:text-og-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:min-h-11 pointer-coarse:min-h-11"
677
760
  >
678
761
  <span
679
762
  className={cn("size-1.5 shrink-0 rounded-full", chipDotClass(chip.state))}
@@ -740,7 +823,7 @@ function DockActionButton({ onClick, children }: { onClick: () => void; children
740
823
  <button
741
824
  type="button"
742
825
  onClick={onClick}
743
- className="inline-flex items-center gap-1.5 rounded-og-sm border border-og-border px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg pointer-coarse:min-h-9"
826
+ className="inline-flex items-center gap-1.5 rounded-og-sm border border-og-border px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:min-h-11 pointer-coarse:min-h-11"
744
827
  >
745
828
  {children}
746
829
  </button>
@@ -760,6 +843,7 @@ function ChangesTabBody({
760
843
  capabilitiesState,
761
844
  capabilitiesError,
762
845
  onRetry,
846
+ onOpenFile,
763
847
  }: {
764
848
  git: UseSandboxGitResult;
765
849
  captureAvailable: boolean;
@@ -767,6 +851,7 @@ function ChangesTabBody({
767
851
  capabilitiesState: string;
768
852
  capabilitiesError: Error | null;
769
853
  onRetry: () => void;
854
+ onOpenFile?: ((path: string) => void) | undefined;
770
855
  }) {
771
856
  const diff = git.diff;
772
857
 
@@ -777,13 +862,14 @@ function ChangesTabBody({
777
862
  source={git.source}
778
863
  capturedAt={git.capturedAt}
779
864
  captureRevision={captureRevision}
865
+ {...(onOpenFile ? { onOpenFile } : {})}
780
866
  />
781
867
  );
782
868
  }
783
869
 
784
870
  if (capabilitiesError && !captureAvailable) {
785
871
  return (
786
- <CenteredState>
872
+ <CenteredState icon={<TriangleAlertIcon className="size-5" aria-hidden />} tone="danger">
787
873
  <p className="text-og-sm font-medium text-og-fg">Sandbox unavailable</p>
788
874
  <p className="text-og-sm leading-5 text-og-fg-muted">
789
875
  {capabilitiesError.message || "Couldn't reach the sandbox for this session."}
@@ -798,26 +884,74 @@ function ChangesTabBody({
798
884
 
799
885
  if ((capabilitiesState === "negotiating" || capabilitiesState === "cold") && !captureAvailable) {
800
886
  return (
801
- <CenteredState>
802
- <p className="text-og-sm text-og-fg-muted">Connecting sandbox…</p>
887
+ <CenteredState
888
+ icon={
889
+ <LoaderCircleIcon
890
+ className="size-5 animate-spin motion-reduce:animate-none"
891
+ aria-hidden
892
+ />
893
+ }
894
+ >
895
+ <p className="text-og-sm font-medium text-og-fg">Connecting workspace</p>
896
+ <p className="text-og-sm leading-5 text-og-fg-subtle">
897
+ Looking for the latest files and changes…
898
+ </p>
899
+ </CenteredState>
900
+ );
901
+ }
902
+
903
+ if (git.loading && git.source === null) {
904
+ return (
905
+ <CenteredState
906
+ icon={
907
+ <LoaderCircleIcon
908
+ className="size-5 animate-spin motion-reduce:animate-none"
909
+ aria-hidden
910
+ />
911
+ }
912
+ >
913
+ <p className="text-og-sm font-medium text-og-fg">Loading workspace</p>
914
+ <p className="text-og-sm leading-5 text-og-fg-subtle">Reading the current working tree…</p>
803
915
  </CenteredState>
804
916
  );
805
917
  }
806
918
 
807
919
  return (
808
- <CenteredState>
809
- <p className="text-og-sm font-medium text-og-fg">No changes yet</p>
920
+ <CenteredState icon={<CircleCheckIcon className="size-5" aria-hidden />} tone="success">
921
+ <p className="text-og-sm font-medium text-og-fg">Working tree is clean</p>
810
922
  <p className="text-og-sm leading-5 text-og-fg-subtle">
811
- File edits from this session's turns show up here.
923
+ File edits from future turns will appear here.
812
924
  </p>
813
925
  </CenteredState>
814
926
  );
815
927
  }
816
928
 
817
- function CenteredState({ children }: { children: ReactNode }) {
929
+ function CenteredState({
930
+ children,
931
+ icon,
932
+ tone = "neutral",
933
+ }: {
934
+ children: ReactNode;
935
+ icon?: ReactNode | undefined;
936
+ tone?: "neutral" | "success" | "danger";
937
+ }) {
818
938
  return (
819
939
  <div className="grid h-full place-items-center p-6 text-center">
820
- <div className="flex max-w-sm flex-col items-center gap-2.5">{children}</div>
940
+ <div className="flex max-w-sm flex-col items-center gap-2.5">
941
+ {icon ? (
942
+ <span
943
+ className={cn(
944
+ "grid size-10 place-items-center rounded-og-lg border bg-og-surface-1 shadow-sm",
945
+ tone === "success" && "border-og-status-idle/30 text-og-status-idle",
946
+ tone === "danger" && "border-og-status-failed/30 text-og-status-failed",
947
+ tone === "neutral" && "border-og-border text-og-fg-muted",
948
+ )}
949
+ >
950
+ {icon}
951
+ </span>
952
+ ) : null}
953
+ {children}
954
+ </div>
821
955
  </div>
822
956
  );
823
957
  }
@@ -35,12 +35,6 @@ export const SESSION_STATUS_META: Record<SessionStatusValue, SessionStatusMeta>
35
35
  badgeClassName: "text-og-status-waiting border-og-status-waiting/35 bg-og-status-waiting/10",
36
36
  pulse: true,
37
37
  },
38
- paused: {
39
- label: "Paused",
40
- dotClassName: "bg-og-status-cancelled",
41
- badgeClassName: "text-og-fg-subtle border-og-border bg-og-status-cancelled/10",
42
- pulse: false,
43
- },
44
38
  idle: {
45
39
  label: "Idle",
46
40
  dotClassName: "bg-og-status-idle",