@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,5 +1,5 @@
1
1
  import type { GitFileDiff } from "@opengeni/sdk";
2
- import { FileWarningIcon, HistoryIcon } from "lucide-react";
2
+ import { ArrowUpRightIcon, FileWarningIcon, HistoryIcon } from "lucide-react";
3
3
  import {
4
4
  type ReactNode,
5
5
  useCallback,
@@ -77,6 +77,8 @@ export type WorkbenchChangesProps = {
77
77
  /** The capture's turn revision, for the "as of turn N" badge. */
78
78
  captureRevision?: number | null | undefined;
79
79
  themeType?: "dark" | "light" | undefined;
80
+ /** Route a guarded binary/oversize file into the live Files surface. */
81
+ onOpenFile?: ((path: string) => void) | undefined;
80
82
  className?: string | undefined;
81
83
  };
82
84
 
@@ -137,6 +139,7 @@ export function WorkbenchChanges({
137
139
  capturedAt,
138
140
  captureRevision,
139
141
  themeType,
142
+ onOpenFile,
140
143
  className,
141
144
  }: WorkbenchChangesProps) {
142
145
  const rootRef = useRef<HTMLDivElement | null>(null);
@@ -211,7 +214,9 @@ export function WorkbenchChanges({
211
214
  // Track which section is at the top of the pane so the rail highlights it.
212
215
  const onPaneScroll = useCallback(() => {
213
216
  const el = windowed.scrollRef.current;
214
- if (!el) return;
217
+ if (!el) {
218
+ return;
219
+ }
215
220
  const top = el.scrollTop;
216
221
  const offsets = windowed.offsets;
217
222
  let idx = 0;
@@ -240,13 +245,23 @@ export function WorkbenchChanges({
240
245
  data-workbench-changes-layout={compact ? "compact" : "rail"}
241
246
  >
242
247
  {/* Summary + source badge + layout toggle. */}
243
- <div className="flex shrink-0 items-center justify-between gap-2 border-b border-og-border px-3 py-1.5">
244
- <span className="min-w-0 truncate text-og-xs text-og-fg-muted">
248
+ <div
249
+ className={cn(
250
+ "flex shrink-0 items-center justify-between gap-2 border-b border-og-border px-3 py-1.5",
251
+ compact && source === "capture" && "flex-col items-stretch gap-1.5 py-2",
252
+ )}
253
+ >
254
+ <span data-contrast-audited className="min-w-0 text-og-xs text-og-fg-muted">
245
255
  {diff.length} {diff.length === 1 ? "file" : "files"} changed
246
256
  <span className="ml-2 text-og-status-idle">+{additions}</span>
247
257
  <span className="ml-1 text-og-status-failed">−{deletions}</span>
248
258
  </span>
249
- <div className="flex shrink-0 items-center gap-2">
259
+ <div
260
+ className={cn(
261
+ "flex shrink-0 items-center gap-2",
262
+ compact && source === "capture" && "self-start",
263
+ )}
264
+ >
250
265
  {compact ? null : <LayoutToggle layout={layout} onChange={setLayout} />}
251
266
  <SourceBadge source={source} capturedAt={capturedAt} label={sourceBadge} />
252
267
  </div>
@@ -271,21 +286,26 @@ export function WorkbenchChanges({
271
286
  >
272
287
  {orderedFiles.map((file, index) => (
273
288
  <option key={file.path} value={index}>
274
- {STATUS_LETTER[file.status]} · {file.path} · +{file.additions} −{file.deletions}
289
+ {STATUS_LETTER[file.status]} · {file.path}
275
290
  </option>
276
291
  ))}
277
292
  </select>
278
293
  </div>
279
294
  <div className="min-h-0 min-w-0 flex-1 overflow-auto" data-opengeni-changes-pane>
280
295
  {activeFile ? (
281
- <DiffSection file={activeFile} layout="unified" themeType={resolvedTheme} />
296
+ <DiffSection
297
+ file={activeFile}
298
+ layout="unified"
299
+ themeType={resolvedTheme}
300
+ {...(onOpenFile ? { onOpenFile } : {})}
301
+ />
282
302
  ) : null}
283
303
  </div>
284
304
  </div>
285
305
  ) : (
286
306
  <div className="flex min-h-0 flex-1">
287
307
  {/* File rail — virtualized (virtua): a dense change set is fine. */}
288
- <div className="w-[240px] shrink-0 border-r border-og-border">
308
+ <div className="w-[clamp(12rem,28%,15rem)] shrink-0 border-r border-og-border bg-og-surface-1/35">
289
309
  <VList
290
310
  className="h-full"
291
311
  itemSize={coarsePointer ? 44 : 28}
@@ -332,7 +352,12 @@ export function WorkbenchChanges({
332
352
  top={windowed.offsets[index] ?? 0}
333
353
  onMeasure={windowed.measure}
334
354
  >
335
- <DiffSection file={file} layout={layout} themeType={resolvedTheme} />
355
+ <DiffSection
356
+ file={file}
357
+ layout={layout}
358
+ themeType={resolvedTheme}
359
+ {...(onOpenFile ? { onOpenFile } : {})}
360
+ />
336
361
  </MeasuredSection>
337
362
  );
338
363
  })}
@@ -377,13 +402,13 @@ function SourceBadge({
377
402
  );
378
403
  }
379
404
 
380
- /** "live" · "as of turn 7 · 14:32" · "as of 14:32" (no revision). */
405
+ /** "Live diff" · "as of turn 7 · 14:32" · "as of 14:32" (no revision). */
381
406
  function describeSource(
382
407
  source: "live" | "capture" | null,
383
408
  capturedAt: string | null,
384
409
  revision: number | null,
385
410
  ): string {
386
- if (source !== "capture" || !capturedAt) return "live";
411
+ if (source !== "capture" || !capturedAt) return "Live diff";
387
412
  const time = formatAsOf(capturedAt, Date.now());
388
413
  return revision !== null ? `as of turn ${revision} · ${time}` : `as of ${time}`;
389
414
  }
@@ -409,9 +434,10 @@ function RailFileRow({
409
434
  title={file.path}
410
435
  data-rail-file
411
436
  className={cn(
412
- "flex min-h-7 w-full items-center gap-1.5 truncate px-2 py-0.5 text-left text-og-sm hover:bg-og-surface-2 pointer-coarse:min-h-11",
437
+ "relative flex min-h-7 w-full items-center gap-1.5 truncate px-2 py-0.5 text-left text-og-sm transition-colors hover:bg-og-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent pointer-coarse:min-h-11",
413
438
  grouped && "pl-3",
414
- active && "bg-og-surface-2",
439
+ active &&
440
+ "bg-og-accent-soft text-og-fg before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-r-full before:bg-og-accent",
415
441
  )}
416
442
  >
417
443
  <span
@@ -468,10 +494,12 @@ function DiffSection({
468
494
  file,
469
495
  layout,
470
496
  themeType,
497
+ onOpenFile,
471
498
  }: {
472
499
  file: GitFileDiff;
473
500
  layout: "unified" | "split";
474
501
  themeType: "dark" | "light";
502
+ onOpenFile?: ((path: string) => void) | undefined;
475
503
  }) {
476
504
  // The guard files (binary / over-cap) get a minimal header + "open live" body
477
505
  // since Pierre has nothing to render; real diffs use Pierre's own sticky file
@@ -489,12 +517,12 @@ function DiffSection({
489
517
  <span className="text-og-status-failed">−{file.deletions}</span>
490
518
  </span>
491
519
  </header>
492
- <GuardBody file={file} />
520
+ <GuardBody file={file} {...(onOpenFile ? { onOpenFile } : {})} />
493
521
  </section>
494
522
  );
495
523
  }
496
524
  return (
497
- <section className="border-b border-og-border pb-2">
525
+ <section data-pierre-section className="border-b border-og-border pb-2">
498
526
  <PierreDiff diff={[file]} layout={layout} themeType={themeType} className="px-1" />
499
527
  </section>
500
528
  );
@@ -502,12 +530,28 @@ function DiffSection({
502
530
 
503
531
  /** The per-file guard: a binary file or an over-cap diff opens live rather than
504
532
  * inlining a body we don't (fully) have. */
505
- function GuardBody({ file }: { file: GitFileDiff }) {
533
+ function GuardBody({
534
+ file,
535
+ onOpenFile,
536
+ }: {
537
+ file: GitFileDiff;
538
+ onOpenFile?: ((path: string) => void) | undefined;
539
+ }) {
506
540
  const reason = file.isBinary ? "Binary file" : "Diff too large to show here";
507
541
  return (
508
- <div className="flex items-center gap-2 px-3 py-3 text-og-sm text-og-fg-subtle">
509
- <FileWarningIcon className="size-3.5 shrink-0" />
510
- <span className="min-w-0">{reason} — open it on the machine to view.</span>
542
+ <div className="flex flex-wrap items-center gap-2 px-3 py-3 text-og-sm text-og-fg-subtle">
543
+ <FileWarningIcon className="size-3.5 shrink-0" aria-hidden />
544
+ <span className="min-w-0 flex-1">{reason}.</span>
545
+ {onOpenFile ? (
546
+ <button
547
+ type="button"
548
+ onClick={() => onOpenFile(file.path)}
549
+ className="inline-flex min-h-8 shrink-0 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:bg-og-surface-2 hover:text-og-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:min-h-11"
550
+ >
551
+ Open in Files
552
+ <ArrowUpRightIcon className="size-3.5" aria-hidden />
553
+ </button>
554
+ ) : null}
511
555
  </div>
512
556
  );
513
557
  }
@@ -527,7 +571,7 @@ function LayoutToggle({
527
571
  type="button"
528
572
  onClick={() => onChange(value)}
529
573
  className={cn(
530
- "min-h-7 rounded-og-xs px-1.5 py-0.5 text-2xs capitalize pointer-coarse:min-h-11",
574
+ "min-h-7 rounded-og-xs px-1.5 py-0.5 text-2xs capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:min-h-11",
531
575
  layout === value
532
576
  ? "bg-og-accent-soft text-og-fg"
533
577
  : "text-og-fg-subtle hover:text-og-fg",
@@ -113,6 +113,9 @@ export function WorkspaceDock({
113
113
  className,
114
114
  }: WorkspaceDockProps) {
115
115
  const narrow = useIsNarrow(DOCK_OVERLAY_BREAKPOINT);
116
+ const rootRef = useRef<HTMLDivElement | null>(null);
117
+ const reopenRef = useRef<HTMLButtonElement | null>(null);
118
+ const returnFocusRef = useRef<HTMLElement | null>(null);
116
119
  const dockPanelRef = usePanelRef();
117
120
  const [internalCollapsed, setInternalCollapsed] = useState(false);
118
121
  const [maximized, setMaximized] = useState(false);
@@ -124,6 +127,7 @@ export function WorkspaceDock({
124
127
  // bug). Standalone (uncontrolled) usage keeps the built-in collapse button
125
128
  // and the thin re-open rail as its only affordances.
126
129
  const hostControlled = collapsedProp !== undefined;
130
+ const previousCollapsedRef = useRef(collapsed);
127
131
 
128
132
  // Persisted layout (width split) keyed by autoSaveId.
129
133
  const { defaultLayout, onLayoutChanged } = useDefaultLayout({
@@ -164,6 +168,44 @@ export function WorkspaceDock({
164
168
  }
165
169
  }, [collapsedProp, dockPanelRef]);
166
170
 
171
+ // Treat the narrow overlay like a real modal: remember the external opener,
172
+ // move focus into the selected tab, and restore it on close. The standalone
173
+ // desktop dock instead focuses its newly-visible reopen rail after collapse.
174
+ useEffect(() => {
175
+ const previous = previousCollapsedRef.current;
176
+ previousCollapsedRef.current = collapsed;
177
+ if (previous === collapsed) return;
178
+
179
+ if (!collapsed) {
180
+ const active = document.activeElement;
181
+ const workspaceSurface = rootRef.current?.querySelector<HTMLElement>(
182
+ narrow ? '[role="dialog"]' : "[data-workspace-surface]",
183
+ );
184
+ if (active instanceof HTMLElement && !workspaceSurface?.contains(active)) {
185
+ returnFocusRef.current = active;
186
+ }
187
+ if (narrow) {
188
+ const frame = requestAnimationFrame(() => {
189
+ rootRef.current
190
+ ?.querySelector<HTMLElement>('[role="dialog"] [role="tab"][aria-selected="true"]')
191
+ ?.focus();
192
+ });
193
+ return () => cancelAnimationFrame(frame);
194
+ }
195
+ return;
196
+ }
197
+
198
+ const frame = requestAnimationFrame(() => {
199
+ if (!hostControlled) {
200
+ reopenRef.current?.focus();
201
+ return;
202
+ }
203
+ const target = returnFocusRef.current;
204
+ if (target?.isConnected) target.focus();
205
+ });
206
+ return () => cancelAnimationFrame(frame);
207
+ }, [collapsed, hostControlled, narrow]);
208
+
167
209
  // Keep the active tab valid if the available tabs change.
168
210
  useEffect(() => {
169
211
  if (firstTabId && !requestedTabIsValid) {
@@ -200,33 +242,45 @@ export function WorkspaceDock({
200
242
  // driven by the same `collapsed` contract (collapsed → hidden).
201
243
  if (narrow) {
202
244
  return (
203
- <div className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
245
+ <div ref={rootRef} className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
204
246
  <div className="min-h-0 min-w-0 flex-1">{primary}</div>
205
- {!collapsed && (
206
- <div
207
- role="dialog"
208
- aria-modal="true"
209
- aria-label="Workspace"
210
- className="fixed inset-0 z-40 flex flex-col bg-og-bg"
211
- style={{
212
- paddingTop: "env(safe-area-inset-top)",
213
- paddingBottom: "env(safe-area-inset-bottom)",
214
- }}
247
+ <div
248
+ role="dialog"
249
+ aria-modal="true"
250
+ aria-label="Workspace"
251
+ className="fixed inset-0 z-40 flex flex-col bg-og-bg"
252
+ hidden={collapsed}
253
+ style={{
254
+ paddingTop: "env(safe-area-inset-top)",
255
+ paddingBottom: "env(safe-area-inset-bottom)",
256
+ }}
257
+ >
258
+ <DockChrome
259
+ tabs={tabs}
260
+ current={current}
261
+ onTab={setTab}
262
+ leading={mobileLeadingControl}
263
+ accessory={headerAccessory}
264
+ controls={
265
+ <ChromeButton onClick={collapse} title="Close workspace" label="Close workspace">
266
+ <XIcon className="size-4" />
267
+ </ChromeButton>
268
+ }
269
+ />
270
+ </div>
271
+ {collapsed && !hostControlled ? (
272
+ <button
273
+ ref={reopenRef}
274
+ type="button"
275
+ onClick={expand}
276
+ title="Open workspace"
277
+ aria-label="Open workspace"
278
+ className="absolute right-3 top-3 z-30 inline-flex size-11 items-center justify-center rounded-og-md border border-og-border bg-og-surface-1 text-og-fg-muted shadow-lg transition-colors hover:border-og-border-strong hover:text-og-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent"
279
+ style={{ marginTop: "env(safe-area-inset-top)" }}
215
280
  >
216
- <DockChrome
217
- tabs={tabs}
218
- current={current}
219
- onTab={setTab}
220
- leading={mobileLeadingControl}
221
- accessory={headerAccessory}
222
- controls={
223
- <ChromeButton onClick={collapse} title="Close workspace" label="Close workspace">
224
- <XIcon className="size-4" />
225
- </ChromeButton>
226
- }
227
- />
228
- </div>
229
- )}
281
+ <ChevronsLeftRightIcon className="size-4" aria-hidden />
282
+ </button>
283
+ ) : null}
230
284
  </div>
231
285
  );
232
286
  }
@@ -261,7 +315,7 @@ export function WorkspaceDock({
261
315
  );
262
316
 
263
317
  return (
264
- <div className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
318
+ <div ref={rootRef} className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
265
319
  <Group
266
320
  orientation="horizontal"
267
321
  className="min-h-0 flex-1"
@@ -296,13 +350,19 @@ export function WorkspaceDock({
296
350
  }}
297
351
  className="min-h-0 min-w-0"
298
352
  >
299
- {/* Hidden behind the overlay while maximized (avoids double-mounting
300
- the surfaces). */}
301
- {!collapsed && !maximized && (
302
- <div className="flex h-full min-h-0 min-w-0 flex-col border-l border-og-border bg-og-bg">
303
- {dockChrome}
304
- </div>
305
- )}
353
+ {/* One persistent mount across normal, collapsed, and maximized modes:
354
+ layout changes must never destroy an editor buffer or terminal view. */}
355
+ <div
356
+ data-workspace-surface
357
+ aria-hidden={collapsed ? true : undefined}
358
+ className={cn(
359
+ "flex h-full min-h-0 min-w-0 flex-col bg-og-bg",
360
+ maximized ? "fixed inset-0 z-40" : "border-l border-og-border",
361
+ collapsed && "invisible pointer-events-none",
362
+ )}
363
+ >
364
+ {dockChrome}
365
+ </div>
306
366
  </Panel>
307
367
  </Group>
308
368
 
@@ -310,6 +370,7 @@ export function WorkspaceDock({
310
370
  when the host controls collapse — its own toggle is the one way in. */}
311
371
  {collapsed && !maximized && !hostControlled && (
312
372
  <button
373
+ ref={reopenRef}
313
374
  type="button"
314
375
  onClick={expand}
315
376
  title="Open workspace"
@@ -318,9 +379,6 @@ export function WorkspaceDock({
318
379
  <ChevronsLeftRightIcon className="size-3.5" />
319
380
  </button>
320
381
  )}
321
-
322
- {/* Maximize overlay: full-workspace surface above everything. */}
323
- {maximized && <div className="fixed inset-0 z-40 flex flex-col bg-og-bg">{dockChrome}</div>}
324
382
  </div>
325
383
  );
326
384
  }
@@ -343,7 +401,7 @@ function ChromeButton({
343
401
  onClick={onClick}
344
402
  title={title}
345
403
  aria-label={label}
346
- className="inline-flex size-7 items-center justify-center rounded-og-sm p-1 transition-colors hover:bg-og-surface-2 hover:text-og-fg max-[1023px]:size-11 pointer-coarse:size-11"
404
+ className="inline-flex size-7 items-center justify-center rounded-og-sm p-1 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]:size-11 pointer-coarse:size-11"
347
405
  >
348
406
  {children}
349
407
  </button>
@@ -369,14 +427,26 @@ function DockChrome({
369
427
  controls: ReactNode;
370
428
  }) {
371
429
  const active = tabs.find((t) => t.id === current) ?? tabs[0];
372
- const activeIndex = tabs.findIndex((tab) => tab.id === active?.id);
373
430
  const tabsetId = useId();
374
431
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
375
- const panelId = `${tabsetId}-panel`;
432
+ const [visitedTabs, setVisitedTabs] = useState<Set<string>>(() => new Set());
433
+ const activeId = active?.id ?? "";
434
+
435
+ useEffect(() => {
436
+ if (!activeId) return;
437
+ setVisitedTabs((previous) => {
438
+ if (previous.has(activeId)) return previous;
439
+ const next = new Set(previous);
440
+ next.add(activeId);
441
+ return next;
442
+ });
443
+ }, [activeId]);
376
444
 
377
445
  const activateTab = (index: number) => {
378
446
  const tab = tabs[index];
379
- if (!tab) return;
447
+ if (!tab) {
448
+ return;
449
+ }
380
450
  onTab(tab.id);
381
451
  tabRefs.current[index]?.focus();
382
452
  };
@@ -395,13 +465,21 @@ function DockChrome({
395
465
 
396
466
  return (
397
467
  <>
398
- <div className="flex shrink-0 items-center gap-2 border-b border-og-border px-1.5 py-1">
399
- {leading ? <div className="flex shrink-0 items-center">{leading}</div> : null}
468
+ <div className="grid shrink-0 grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center gap-x-2 border-b border-og-border px-1.5 py-1 max-[1023px]:grid-cols-[auto_minmax(0,1fr)_auto] max-[1023px]:gap-y-0 max-[1023px]:px-2 max-[1023px]:pb-1 max-[1023px]:pt-0">
469
+ <div className="flex min-w-0 shrink-0 items-center max-[1023px]:min-h-11">
470
+ {leading ?? (
471
+ <span className="hidden truncate px-1 text-og-sm font-semibold text-og-fg max-[1023px]:inline">
472
+ Workspace
473
+ </span>
474
+ )}
475
+ </div>
400
476
  {/* The tab list scrolls horizontally when it can't fit — it must never
401
477
  grow into or overlap the chrome controls (they stay shrink-0). The
402
- scrollbar is hidden to keep the strip calm. */}
478
+ scrollbar is hidden to keep the strip calm. On the narrow overlay it
479
+ owns a full second row, so status/close chrome can never squeeze the
480
+ canonical workspace navigation out of view. */}
403
481
  <div
404
- className="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
482
+ className="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden max-[1023px]:col-span-3 max-[1023px]:row-start-2 max-[1023px]:w-full"
405
483
  role="tablist"
406
484
  aria-orientation="horizontal"
407
485
  >
@@ -415,12 +493,12 @@ function DockChrome({
415
493
  type="button"
416
494
  role="tab"
417
495
  aria-selected={tab.id === current}
418
- aria-controls={panelId}
496
+ aria-controls={`${tabsetId}-panel-${index}`}
419
497
  tabIndex={tab.id === current ? 0 : -1}
420
498
  onClick={() => onTab(tab.id)}
421
499
  onKeyDown={(event) => onTabKeyDown(event, index)}
422
500
  className={cn(
423
- "flex min-h-7 shrink-0 items-center gap-1 rounded-og-sm px-2 py-1 text-og-xs font-medium transition-colors max-[1023px]:min-h-11 max-[1023px]:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11",
501
+ "flex min-h-7 shrink-0 items-center justify-center gap-1 rounded-og-sm px-2 py-1 text-og-xs font-medium transition-colors 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",
424
502
  tab.id === current
425
503
  ? "bg-og-accent-soft text-og-fg"
426
504
  : "text-og-fg-subtle hover:text-og-fg",
@@ -431,17 +509,30 @@ function DockChrome({
431
509
  </button>
432
510
  ))}
433
511
  </div>
434
- {accessory ? <div className="flex shrink-0 items-center">{accessory}</div> : null}
512
+ <div className="flex min-w-0 shrink-0 items-center justify-self-end">{accessory}</div>
435
513
  <div className="flex shrink-0 items-center gap-0.5 text-og-fg-subtle">{controls}</div>
436
514
  </div>
437
- <div
438
- id={panelId}
439
- aria-label={activeIndex < 0 ? "Workspace" : undefined}
440
- aria-labelledby={activeIndex >= 0 ? `${tabsetId}-tab-${activeIndex}` : undefined}
441
- className="min-h-0 min-w-0 flex-1 overflow-hidden"
442
- role="tabpanel"
443
- >
444
- {active?.content}
515
+ <div className="min-h-0 min-w-0 flex-1 overflow-hidden">
516
+ {tabs.length > 0 ? (
517
+ tabs.map((tab, index) => {
518
+ const selected = tab.id === activeId;
519
+ const shouldMount = selected || visitedTabs.has(tab.id);
520
+ return (
521
+ <div
522
+ key={tab.id}
523
+ id={`${tabsetId}-panel-${index}`}
524
+ aria-labelledby={`${tabsetId}-tab-${index}`}
525
+ className="h-full min-h-0 min-w-0 overflow-hidden"
526
+ hidden={!selected}
527
+ role="tabpanel"
528
+ >
529
+ {shouldMount ? tab.content : null}
530
+ </div>
531
+ );
532
+ })
533
+ ) : (
534
+ <div className="h-full min-h-0 min-w-0" aria-label="Workspace" role="tabpanel" />
535
+ )}
445
536
  </div>
446
537
  </>
447
538
  );