@opengeni/react 0.45.0 → 0.48.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 (141) hide show
  1. package/README.md +68 -12
  2. package/dist/{chunk-U3CSCMWA.js → chunk-FK6VG4LL.js} +9 -13
  3. package/dist/chunk-FK6VG4LL.js.map +1 -0
  4. package/dist/{chunk-HFO4ERGQ.js → chunk-FWXGSNXT.js} +2 -2
  5. package/dist/{chunk-SRFUT2ZU.js → chunk-I4MPFDRP.js} +119 -63
  6. package/dist/chunk-I4MPFDRP.js.map +1 -0
  7. package/dist/{chunk-EB67J347.js → chunk-KZ73RL76.js} +111 -31
  8. package/dist/chunk-KZ73RL76.js.map +1 -0
  9. package/dist/{chunk-LWR4MXSS.js → chunk-LHLYWG5R.js} +10 -1
  10. package/dist/chunk-LHLYWG5R.js.map +1 -0
  11. package/dist/{chunk-L3EWO3UK.js → chunk-NJ7M2B6Z.js} +445 -84
  12. package/dist/chunk-NJ7M2B6Z.js.map +1 -0
  13. package/dist/{chunk-VCBFUSUZ.js → chunk-O4VXOBGF.js} +3 -3
  14. package/dist/{chunk-VCBFUSUZ.js.map → chunk-O4VXOBGF.js.map} +1 -1
  15. package/dist/{chunk-6EDV6YFE.js → chunk-OL6QVXGF.js} +121 -29
  16. package/dist/chunk-OL6QVXGF.js.map +1 -0
  17. package/dist/chunk-VZTQ73XT.js +86 -0
  18. package/dist/chunk-VZTQ73XT.js.map +1 -0
  19. package/dist/{chunk-EHSYZ4KP.js → chunk-WYA65Y3F.js} +16 -16
  20. package/dist/chunk-WYA65Y3F.js.map +1 -0
  21. package/dist/{chunk-FD72GJMK.js → chunk-XCNTXWBB.js} +274 -125
  22. package/dist/chunk-XCNTXWBB.js.map +1 -0
  23. package/dist/client.d.ts +1 -1
  24. package/dist/components/chat-composer.d.ts +7 -2
  25. package/dist/components/composer-transcription-control.d.ts +2 -0
  26. package/dist/components/composer.d.ts +14 -0
  27. package/dist/components/machines/machine-detail.d.ts +3 -1
  28. package/dist/components/machines-dashboard.d.ts +3 -3
  29. package/dist/components/message-timeline.d.ts +12 -2
  30. package/dist/components/sandbox-files.d.ts +2 -2
  31. package/dist/components/sandbox-terminal.d.ts +29 -0
  32. package/dist/components/sandbox-workspace.d.ts +5 -1
  33. package/dist/components/workspace-dock.d.ts +3 -1
  34. package/dist/composer.d.ts +2 -2
  35. package/dist/composer.js +6 -6
  36. package/dist/hooks/internal.d.ts +6 -0
  37. package/dist/hooks/use-codex-accounts.d.ts +1 -1
  38. package/dist/hooks/use-environments.d.ts +10 -4
  39. package/dist/hooks/use-machines.d.ts +7 -0
  40. package/dist/hooks/use-sandbox-files.d.ts +19 -0
  41. package/dist/hooks/use-sandbox-git.d.ts +7 -0
  42. package/dist/hooks/use-transcription.d.ts +2 -2
  43. package/dist/hooks/use-voice-input.d.ts +13 -4
  44. package/dist/index.d.ts +6 -5
  45. package/dist/index.js +904 -538
  46. package/dist/index.js.map +1 -1
  47. package/dist/lib/composer-responsive-context.d.ts +9 -0
  48. package/dist/lib/use-portal-token-style.d.ts +14 -3
  49. package/dist/machines.js +2 -2
  50. package/dist/{queue-surface-implementation-J4XJFKRW.js → queue-surface-implementation-U5JFG7HT.js} +19 -18
  51. package/dist/queue-surface-implementation-U5JFG7HT.js.map +1 -0
  52. package/dist/realtime/realtime-control.d.ts +9 -0
  53. package/dist/realtime.js +29 -32
  54. package/dist/realtime.js.map +1 -1
  55. package/dist/session-ui.js +5 -5
  56. package/dist/session.js +4 -4
  57. package/dist/timeline/activity-rail.d.ts +3 -2
  58. package/dist/timeline/index.d.ts +2 -2
  59. package/dist/timeline/parsers.d.ts +3 -3
  60. package/dist/timeline/registry.d.ts +3 -0
  61. package/dist/timeline/screenshot-lightbox.d.ts +1 -1
  62. package/dist/timeline/types.d.ts +4 -0
  63. package/dist/voice-recording-store.d.ts +8 -2
  64. package/package.json +20 -4
  65. package/src/client.ts +3 -0
  66. package/src/components/chat-composer.tsx +12 -2
  67. package/src/components/code-editor.tsx +1 -1
  68. package/src/components/command-palette.tsx +1 -1
  69. package/src/components/composer-transcription-control.tsx +41 -16
  70. package/src/components/composer.tsx +52 -26
  71. package/src/components/copy-button.tsx +2 -2
  72. package/src/components/desktop-viewer.tsx +2 -2
  73. package/src/components/file-browser.tsx +39 -17
  74. package/src/components/human-input-form.tsx +2 -2
  75. package/src/components/machine-card.tsx +1 -1
  76. package/src/components/machines/machine-detail.tsx +39 -15
  77. package/src/components/machines-dashboard.tsx +7 -6
  78. package/src/components/message-timeline.tsx +44 -11
  79. package/src/components/model-picker.tsx +2 -2
  80. package/src/components/model-policy-picker.tsx +16 -15
  81. package/src/components/queue-surface-implementation.tsx +20 -16
  82. package/src/components/queue-surface-state.tsx +2 -2
  83. package/src/components/sandbox-files.tsx +140 -60
  84. package/src/components/sandbox-terminal.tsx +24 -5
  85. package/src/components/sandbox-workspace.tsx +180 -142
  86. package/src/components/session-chrome.tsx +14 -14
  87. package/src/components/tooltip.tsx +13 -16
  88. package/src/components/user-message-body.tsx +1 -1
  89. package/src/components/workbench-changes.tsx +4 -4
  90. package/src/components/workspace-dock.tsx +13 -5
  91. package/src/composer.ts +3 -0
  92. package/src/hooks/internal.ts +132 -38
  93. package/src/hooks/use-codex-accounts.ts +4 -4
  94. package/src/hooks/use-environments.ts +24 -5
  95. package/src/hooks/use-file-attachments.ts +70 -32
  96. package/src/hooks/use-goal.ts +17 -5
  97. package/src/hooks/use-machines.ts +47 -1
  98. package/src/hooks/use-sandbox-files.ts +310 -59
  99. package/src/hooks/use-sandbox-git.ts +148 -41
  100. package/src/hooks/use-session-capabilities.ts +35 -24
  101. package/src/hooks/use-session-events.ts +18 -4
  102. package/src/hooks/use-transcription.ts +4 -14
  103. package/src/hooks/use-turn-queue.ts +17 -5
  104. package/src/hooks/use-voice-input.ts +481 -36
  105. package/src/hooks/use-windowed-sections.ts +9 -7
  106. package/src/index.ts +11 -6
  107. package/src/lib/composer-responsive-context.ts +15 -0
  108. package/src/lib/use-portal-token-style.ts +60 -15
  109. package/src/provider.tsx +52 -7
  110. package/src/realtime/dropdown-menu.tsx +15 -19
  111. package/src/realtime/realtime-control.tsx +18 -7
  112. package/src/timeline/activity-rail.tsx +15 -6
  113. package/src/timeline/index.ts +1 -1
  114. package/src/timeline/parsers.ts +33 -20
  115. package/src/timeline/projection.ts +23 -0
  116. package/src/timeline/registry.ts +7 -0
  117. package/src/timeline/screenshot-lightbox.tsx +15 -4
  118. package/src/timeline/shared.tsx +8 -8
  119. package/src/timeline/tool-renderers.tsx +166 -22
  120. package/src/timeline/turn-summary.tsx +6 -6
  121. package/src/timeline/types.ts +4 -0
  122. package/src/voice-recording-store.ts +15 -0
  123. package/styles/compiled.css +5065 -0
  124. package/styles/compiled.d.ts +2 -0
  125. package/styles/effective-tokens.css +27 -0
  126. package/styles/index.css +3 -0
  127. package/styles/responsive.css +196 -0
  128. package/styles/responsive.d.ts +2 -0
  129. package/styles/tokens.css +50 -17
  130. package/dist/chunk-6EDV6YFE.js.map +0 -1
  131. package/dist/chunk-EB67J347.js.map +0 -1
  132. package/dist/chunk-EHSYZ4KP.js.map +0 -1
  133. package/dist/chunk-FD72GJMK.js.map +0 -1
  134. package/dist/chunk-JB5725BI.js +0 -48
  135. package/dist/chunk-JB5725BI.js.map +0 -1
  136. package/dist/chunk-L3EWO3UK.js.map +0 -1
  137. package/dist/chunk-LWR4MXSS.js.map +0 -1
  138. package/dist/chunk-SRFUT2ZU.js.map +0 -1
  139. package/dist/chunk-U3CSCMWA.js.map +0 -1
  140. package/dist/queue-surface-implementation-J4XJFKRW.js.map +0 -1
  141. /package/dist/{chunk-HFO4ERGQ.js.map → chunk-FWXGSNXT.js.map} +0 -0
@@ -1,9 +1,11 @@
1
1
  import { FileCode2Icon, FileWarningIcon, LoaderCircleIcon } from "lucide-react";
2
2
  import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
3
+ import { Group, Panel, Separator } from "react-resizable-panels";
3
4
  import { cn } from "../lib/cn";
4
5
  import { useThemeType } from "../lib/use-theme-type";
5
6
  import {
6
7
  CapturedFileUnavailableError,
8
+ type SandboxFilesGitSummary,
7
9
  type UseSandboxFilesResult,
8
10
  } from "../hooks/use-sandbox-files";
9
11
  import type { UseSandboxGitResult } from "../hooks/use-sandbox-git";
@@ -14,8 +16,8 @@ import { PierreFile } from "./pierre-file";
14
16
  export type SandboxFilesProps = {
15
17
  /** From `useSandboxFiles(...)`. */
16
18
  files: UseSandboxFilesResult;
17
- /** From `useSandboxGit(...)` drives the branch/dirty header only. */
18
- git: UseSandboxGitResult;
19
+ /** Compatibility fallback for callers that have not yet adopted `files.gitSummary`. */
20
+ git?: UseSandboxGitResult | undefined;
19
21
  /** @deprecated Diffs live in the dedicated Changes tab now; accepted for
20
22
  * source-compat but unused (the Files surface is a browser + viewer). */
21
23
  stagedGit?: UseSandboxGitResult | undefined;
@@ -111,15 +113,29 @@ export function SandboxFiles({
111
113
  const [wide, setWide] = useState(false);
112
114
  useEffect(() => {
113
115
  const el = rootRef.current;
114
- if (!el || typeof ResizeObserver === "undefined") return;
115
- const observer = new ResizeObserver((entries) => {
116
- const w = entries[0]?.contentRect.width ?? 0;
117
- setWide(w >= 720);
118
- });
116
+ if (!el) return;
117
+ const update = () => setWide(el.getBoundingClientRect().width >= 720);
118
+ update();
119
+ if (typeof ResizeObserver === "undefined") return;
120
+ const observer = new ResizeObserver(update);
119
121
  observer.observe(el);
120
- return () => observer.disconnect();
122
+ // WorkspaceDock maximization changes its persistent surface from a sized
123
+ // panel child to `fixed inset-0`. Chromium can retain the child's observer
124
+ // size across that containing-block change even though its visible bounding
125
+ // box is now viewport-wide. Follow the surface mode as a deterministic
126
+ // second signal so the tree/viewer orientation matches what is painted.
127
+ const surface = el.closest("[data-workspace-surface]");
128
+ const surfaceObserver =
129
+ surface && typeof MutationObserver !== "undefined" ? new MutationObserver(update) : null;
130
+ surfaceObserver?.observe(surface!, {
131
+ attributes: true,
132
+ attributeFilter: ["class", "style"],
133
+ });
134
+ return () => {
135
+ observer.disconnect();
136
+ surfaceObserver?.disconnect();
137
+ };
121
138
  }, []);
122
-
123
139
  // Resolve the effective viewer theme from the host palette (the `data-og-theme`
124
140
  // attribute the demo/app sets), defaulting to dark, unless the caller forced one.
125
141
  const resolvedTheme = useThemeType(themeType);
@@ -128,7 +144,11 @@ export function SandboxFiles({
128
144
  // writable). Nothing is auto-selected — the pane waits for a tree click, so the
129
145
  // Files tab opens as a calm browser, not a diff.
130
146
  const viewPath = selected;
131
- const fileView = useFileView(viewPath, files.readFile, viewReloadRevision);
147
+ const fileView = useFileView(
148
+ viewPath,
149
+ files.readFile,
150
+ (files.contentRevision ?? 0) + viewReloadRevision,
151
+ );
132
152
 
133
153
  // Selecting a (different) file always returns to View — never drop the user into
134
154
  // an editor whose buffer belongs to the previously-selected path. Manual
@@ -165,19 +185,6 @@ export function SandboxFiles({
165
185
  (!liveWorkspaceReady || files.loading) &&
166
186
  captureFileUnavailable !== null;
167
187
 
168
- if (!fileSystemAvailable) {
169
- return (
170
- <Notice
171
- className={className}
172
- icon={<FileWarningIcon className="size-5" aria-hidden />}
173
- title="Files unavailable"
174
- announce="alert"
175
- >
176
- This sandbox does not expose a file system.
177
- </Notice>
178
- );
179
- }
180
-
181
188
  if (workspaceResting || workspaceWaking) {
182
189
  return (
183
190
  <div className={cn("h-full", className)} data-opengeni-workspace-resting>
@@ -208,19 +215,51 @@ export function SandboxFiles({
208
215
  );
209
216
  }
210
217
 
218
+ if (!fileSystemAvailable) {
219
+ return (
220
+ <Notice
221
+ className={className}
222
+ icon={<FileWarningIcon className="size-5" aria-hidden />}
223
+ title="Files unavailable"
224
+ announce="alert"
225
+ >
226
+ This sandbox does not expose a file system.
227
+ </Notice>
228
+ );
229
+ }
230
+
211
231
  return (
212
232
  <div ref={rootRef} className={cn("flex h-full min-h-0 min-w-0 flex-col", className)}>
213
233
  {/* Branch + dirty header (context; NOT the changed-files list). */}
214
- <GitHeader git={git} dirtyCount={git.diff.length} />
234
+ <GitHeader
235
+ loading={
236
+ Boolean(files.loading || files.gitLoading || files.source === null || git?.loading) &&
237
+ files.gitSummary === null
238
+ }
239
+ git={
240
+ files.gitSummary ?? {
241
+ branch: git?.branch ?? null,
242
+ isRepo: git?.isRepo ?? false,
243
+ repoCount: git?.repoCount ?? 0,
244
+ ahead: git?.ahead ?? 0,
245
+ behind: git?.behind ?? 0,
246
+ dirtyCount: git?.diff.length ?? 0,
247
+ }
248
+ }
249
+ />
215
250
 
216
- <div className={cn("flex min-h-0 flex-1", wide ? "flex-row" : "flex-col")}>
251
+ <Group
252
+ orientation={wide ? "horizontal" : "vertical"}
253
+ className={cn("min-h-0 flex-1", wide ? "flex-row" : "flex-col")}
254
+ >
217
255
  {/* Tree pane: the full lazy file tree. A fixed left column when wide, a top
218
256
  band when narrow. */}
219
- <div
220
- className={cn(
221
- "flex min-h-0 flex-col",
222
- wide ? "w-[280px] shrink-0 border-r border-og-border" : "flex-1",
223
- )}
257
+ <Panel
258
+ id="sandbox-files-tree"
259
+ defaultSize={wide ? "280px" : "40%"}
260
+ minSize={wide ? "180px" : "120px"}
261
+ maxSize={wide ? "60%" : "70%"}
262
+ className="flex min-h-0 min-w-0 flex-col"
224
263
  >
225
264
  <FileBrowser
226
265
  result={files}
@@ -230,16 +269,26 @@ export function SandboxFiles({
230
269
  emptyState="This directory is empty"
231
270
  className="min-w-0 flex-1"
232
271
  />
233
- </div>
272
+ </Panel>
273
+
274
+ <Separator
275
+ aria-label="Resize file tree"
276
+ className={cn(
277
+ "group relative z-10 shrink-0 cursor-col-resize outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent",
278
+ "w-px bg-og-border after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2",
279
+ "hover:bg-og-accent focus-visible:bg-og-accent data-[separator-state=dragging]:bg-og-accent",
280
+ !wide &&
281
+ "h-px w-full cursor-row-resize after:inset-x-0 after:inset-y-auto after:top-1/2 after:h-3 after:w-full after:-translate-x-0 after:-translate-y-1/2",
282
+ )}
283
+ />
234
284
 
235
285
  {/* Viewer pane: the selected file's contents (read-only) or the editor.
236
286
  Fills the remaining width when side-by-side, sits below the tree when
237
287
  stacked. */}
238
- <div
239
- className={cn(
240
- "flex min-h-0 min-w-0 flex-col",
241
- wide ? "flex-1" : "flex-[1.4] border-t border-og-border",
242
- )}
288
+ <Panel
289
+ id="sandbox-files-viewer"
290
+ minSize={wide ? "240px" : "160px"}
291
+ className={cn("flex min-h-0 min-w-0 flex-col", !wide && "border-t border-og-border")}
243
292
  >
244
293
  <div className="flex shrink-0 items-center justify-between gap-2 border-b border-og-border bg-og-surface-1 px-2 py-1">
245
294
  <span
@@ -371,8 +420,8 @@ export function SandboxFiles({
371
420
  </Notice>
372
421
  )}
373
422
  </div>
374
- </div>
375
- </div>
423
+ </Panel>
424
+ </Group>
376
425
  </div>
377
426
  );
378
427
  }
@@ -382,15 +431,30 @@ function WakeButton({ children, onClick }: { children: ReactNode; onClick: () =>
382
431
  <button
383
432
  type="button"
384
433
  onClick={onClick}
385
- className="mt-1 inline-flex min-h-11 items-center justify-center rounded-og-md bg-og-accent-deep px-3 py-2 text-og-sm font-medium text-og-accent-fg shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent focus-visible:ring-offset-2 focus-visible:ring-offset-og-bg"
434
+ className="mt-1 inline-flex min-h-11 items-center justify-center rounded-og-md bg-og-accent-deep px-3 py-2 text-og-sm font-medium text-og-accent-fg shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent focus-visible:ring-offset-2 focus-visible:ring-offset-og-bg"
386
435
  >
387
436
  {children}
388
437
  </button>
389
438
  );
390
439
  }
391
440
 
392
- function GitHeader({ git, dirtyCount }: { git: UseSandboxGitResult; dirtyCount: number }) {
393
- const dirty = dirtyCount > 0;
441
+ function GitHeader({ git, loading }: { git: SandboxFilesGitSummary; loading: boolean }) {
442
+ if (loading) {
443
+ return (
444
+ <div
445
+ className="flex shrink-0 items-center gap-2 border-b border-og-border bg-og-surface-1 px-2 py-1 text-og-sm text-og-fg-muted"
446
+ role="status"
447
+ aria-live="polite"
448
+ >
449
+ <LoaderCircleIcon
450
+ className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none"
451
+ aria-hidden
452
+ />
453
+ <span>Loading repository…</span>
454
+ </div>
455
+ );
456
+ }
457
+ const dirty = git.dirtyCount > 0;
394
458
  return (
395
459
  <div className="flex shrink-0 items-center gap-2 border-b border-og-border bg-og-surface-1 px-2 py-1 text-og-sm">
396
460
  <span
@@ -401,7 +465,7 @@ function GitHeader({ git, dirtyCount }: { git: UseSandboxGitResult; dirtyCount:
401
465
  )}
402
466
  />
403
467
  <span className="sr-only">
404
- {dirty ? `Working tree has ${dirtyCount} changed files` : "Working tree clean"}
468
+ {dirty ? `Uncommitted changes in ${git.dirtyCount} files` : "No uncommitted changes"}
405
469
  </span>
406
470
  <span className="truncate font-og-mono text-og-fg">
407
471
  {git.repoCount > 1
@@ -420,7 +484,7 @@ function GitHeader({ git, dirtyCount }: { git: UseSandboxGitResult; dirtyCount:
420
484
  data-contrast-audited
421
485
  className="ml-auto shrink-0 text-og-xs text-og-fg-subtle"
422
486
  >
423
- {dirtyCount} changed
487
+ {git.dirtyCount} changed
424
488
  </span>
425
489
  )}
426
490
  </div>
@@ -444,7 +508,7 @@ function Segmented({
444
508
  type="button"
445
509
  onClick={() => onChange(opt.value)}
446
510
  className={cn(
447
- "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 max-[1023px]:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11",
511
+ "min-h-7 rounded-og-xs px-1.5 py-0.5 text-og-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:min-h-11 max-[1023px]:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11",
448
512
  opt.value === value
449
513
  ? "bg-og-accent-soft text-og-fg"
450
514
  : "text-og-fg-subtle hover:text-og-fg",
@@ -480,6 +544,7 @@ function useFileView(
480
544
  readFile: UseSandboxFilesResult["readFile"],
481
545
  reloadRevision = 0,
482
546
  ): FileViewState {
547
+ const previousPathRef = useRef<string | null>(null);
483
548
  const [state, setState] = useState<FileViewState>({
484
549
  content: null,
485
550
  isBinary: false,
@@ -490,6 +555,7 @@ function useFileView(
490
555
  });
491
556
  useEffect(() => {
492
557
  if (!path) {
558
+ previousPathRef.current = null;
493
559
  setState({
494
560
  content: null,
495
561
  isBinary: false,
@@ -500,16 +566,25 @@ function useFileView(
500
566
  });
501
567
  return;
502
568
  }
569
+ const pathChanged = previousPathRef.current !== path;
570
+ previousPathRef.current = path;
503
571
  let cancelled = false;
504
572
  const abort = new AbortController();
505
- setState({
506
- content: null,
507
- isBinary: false,
508
- truncated: false,
509
- sizeBytes: null,
510
- loading: true,
511
- error: null,
512
- });
573
+ if (pathChanged) {
574
+ setState({
575
+ content: null,
576
+ isBinary: false,
577
+ truncated: false,
578
+ sizeBytes: null,
579
+ loading: true,
580
+ error: null,
581
+ });
582
+ } else {
583
+ // Keep the last successful preview visible while a remote Modal read
584
+ // revalidates it. Replacing useful content with a multi-second spinner
585
+ // makes a live workspace feel slower and hides the state being refreshed.
586
+ setState((previous) => ({ ...previous, error: null }));
587
+ }
513
588
  void readFile(path, { signal: abort.signal })
514
589
  .then((res) => {
515
590
  if (cancelled) return;
@@ -529,14 +604,19 @@ function useFileView(
529
604
  })
530
605
  .catch((cause) => {
531
606
  if (cancelled) return;
532
- setState({
533
- content: null,
534
- isBinary: false,
535
- truncated: false,
536
- sizeBytes: null,
537
- loading: false,
538
- error: cause instanceof Error ? cause : new Error(String(cause)),
539
- });
607
+ const error = cause instanceof Error ? cause : new Error(String(cause));
608
+ setState((previous) =>
609
+ pathChanged
610
+ ? {
611
+ content: null,
612
+ isBinary: false,
613
+ truncated: false,
614
+ sizeBytes: null,
615
+ loading: false,
616
+ error,
617
+ }
618
+ : { ...previous, loading: false, error },
619
+ );
540
620
  });
541
621
  return () => {
542
622
  cancelled = true;
@@ -110,6 +110,13 @@ type XtermLike = {
110
110
  };
111
111
  type FitAddonLike = { fit: () => void };
112
112
 
113
+ /** Clear the transient wake line and put the first live prompt at cell 1,1.
114
+ * `Terminal.clear()` removes scrollback but deliberately preserves the cursor,
115
+ * which lets the first PTY frame append to the old wake message. */
116
+ export function clearTerminalBootStatus(term: Pick<XtermLike, "write">): void {
117
+ term.write("\r\x1b[2K\x1b[2J\x1b[H");
118
+ }
119
+
113
120
  /** Debug seam (dev/evidence only, never a typed public API): a global hook the
114
121
  * screenshot harness reads to probe the settled renderer tier + resolved font
115
122
  * without mounting a real WebGL context in unit tests. */
@@ -215,7 +222,16 @@ export function SandboxTerminal({
215
222
  // PTY mode = a live ttyd socket drives the screen. Firehose mode = the
216
223
  // read-only projection (or the legacy HTTP-write fallback).
217
224
  const ptyMode = ptyWs && ptyStatus !== "closed";
218
- const interactive = !readOnly && (ptyMode || result.write !== null);
225
+ const interactive = !readOnly && (ptyMode ? ptyConnected : result.write !== null);
226
+ const terminalModeLabel = interactive
227
+ ? null
228
+ : readOnly
229
+ ? "read-only"
230
+ : ptyMode && ptyStatus === "connecting"
231
+ ? "connecting"
232
+ : ptyMode && ptyStatus === "error"
233
+ ? "connection error"
234
+ : "output only";
219
235
 
220
236
  // Boot-in-terminal: after the user focuses a not-yet-warm box, show styled
221
237
  // status lines INSIDE xterm instead of an overlay — but only when there is no
@@ -407,7 +423,7 @@ export function SandboxTerminal({
407
423
  if (!booting) {
408
424
  if (bootActiveRef.current) {
409
425
  bootActiveRef.current = false;
410
- if (!wroteFirehoseRef.current) term.clear();
426
+ if (!wroteFirehoseRef.current) clearTerminalBootStatus(term);
411
427
  }
412
428
  return;
413
429
  }
@@ -496,9 +512,12 @@ export function SandboxTerminal({
496
512
  <span className="truncate font-og-mono">pty: {shell ?? "shell"}</span>
497
513
  </span>
498
514
  <span className="flex shrink-0 items-center gap-2">
499
- {!interactive && (
500
- <span className="rounded-og-sm bg-og-surface-2 px-1.5 py-0.5 text-og-xs uppercase tracking-wide">
501
- read-only
515
+ {terminalModeLabel && (
516
+ <span
517
+ className="rounded-og-sm bg-og-surface-2 px-1.5 py-0.5 text-og-xs uppercase tracking-wide"
518
+ role="status"
519
+ >
520
+ {terminalModeLabel}
502
521
  </span>
503
522
  )}
504
523
  <button