@opengeni/react 0.13.0 → 0.20.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 (113) hide show
  1. package/README.md +129 -23
  2. package/dist/chunk-6BXBGJ3B.js +1956 -0
  3. package/dist/chunk-6BXBGJ3B.js.map +1 -0
  4. package/dist/chunk-6UT5OC3P.js +2133 -0
  5. package/dist/chunk-6UT5OC3P.js.map +1 -0
  6. package/dist/chunk-EBTT3MYN.js +92 -0
  7. package/dist/chunk-EBTT3MYN.js.map +1 -0
  8. package/dist/chunk-HHFA4AFX.js +425 -0
  9. package/dist/chunk-HHFA4AFX.js.map +1 -0
  10. package/dist/chunk-LAZ2A743.js +2393 -0
  11. package/dist/chunk-LAZ2A743.js.map +1 -0
  12. package/dist/chunk-MRSECXRP.js +563 -0
  13. package/dist/chunk-MRSECXRP.js.map +1 -0
  14. package/dist/chunk-TK7G6XLT.js +18 -0
  15. package/dist/chunk-TK7G6XLT.js.map +1 -0
  16. package/dist/composer-DvUaa5ki.d.ts +585 -0
  17. package/dist/composer.d.ts +6 -0
  18. package/dist/composer.js +56 -0
  19. package/dist/composer.js.map +1 -0
  20. package/dist/index.d.ts +642 -1080
  21. package/dist/index.js +7672 -8005
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +25 -1
  25. package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
  26. package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
  27. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
  28. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
  29. package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
  30. package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
  31. package/dist/session-Gd4iVPYw.d.ts +425 -0
  32. package/dist/session-context-DIFFlXKc.d.ts +52 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +50 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-BCfm4mzX.d.ts +112 -0
  37. package/package.json +23 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +50 -1
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +104 -751
  42. package/src/components/code-editor.tsx +104 -26
  43. package/src/components/command-palette.tsx +8 -2
  44. package/src/components/composer-transcription-control.tsx +211 -0
  45. package/src/components/composer.tsx +1536 -0
  46. package/src/components/desktop-viewer.tsx +3 -3
  47. package/src/components/enrollment-consent.tsx +2 -2
  48. package/src/components/file-browser.tsx +367 -29
  49. package/src/components/human-input-form.tsx +417 -0
  50. package/src/components/machine-card.tsx +82 -16
  51. package/src/components/machine-health-pill.tsx +68 -0
  52. package/src/components/machine-metrics.tsx +10 -24
  53. package/src/components/machines/health.ts +146 -0
  54. package/src/components/machines/machine-detail.tsx +220 -0
  55. package/src/components/machines/metric-history-chart.tsx +298 -0
  56. package/src/components/machines/metric-sparkline.tsx +76 -0
  57. package/src/components/machines/series.ts +113 -0
  58. package/src/components/machines-dashboard.tsx +16 -4
  59. package/src/components/message-timeline.tsx +15 -7
  60. package/src/components/model-picker.tsx +12 -3
  61. package/src/components/pierre-diff.tsx +32 -6
  62. package/src/components/queue-surface-implementation.tsx +1041 -0
  63. package/src/components/queue-surface-state.tsx +94 -0
  64. package/src/components/queue-surface.tsx +60 -0
  65. package/src/components/sandbox-files.tsx +230 -21
  66. package/src/components/sandbox-terminal.tsx +8 -2
  67. package/src/components/sandbox-workspace.tsx +489 -135
  68. package/src/components/session-status.tsx +0 -6
  69. package/src/components/workbench-changes.tsx +145 -50
  70. package/src/components/workspace-dock.tsx +329 -68
  71. package/src/composer.ts +60 -0
  72. package/src/hooks/internal.ts +115 -34
  73. package/src/hooks/use-composer.ts +635 -74
  74. package/src/hooks/use-human-input.ts +131 -0
  75. package/src/hooks/use-machine-chip.ts +2 -2
  76. package/src/hooks/use-machines.ts +27 -11
  77. package/src/hooks/use-sandbox-files.ts +310 -62
  78. package/src/hooks/use-sandbox-git.ts +154 -40
  79. package/src/hooks/use-sandbox-terminal.ts +28 -6
  80. package/src/hooks/use-session-capabilities.ts +38 -9
  81. package/src/hooks/use-session-control.ts +43 -15
  82. package/src/hooks/use-session-events.ts +523 -48
  83. package/src/hooks/use-session-lineage.ts +15 -6
  84. package/src/hooks/use-session.ts +10 -2
  85. package/src/hooks/use-slash-commands.ts +38 -38
  86. package/src/hooks/use-transcription.ts +757 -0
  87. package/src/hooks/use-turn-queue.ts +273 -77
  88. package/src/hooks/use-windowed-sections.ts +2 -2
  89. package/src/hooks/use-workspace-capture.ts +146 -40
  90. package/src/hooks/use-workspace-edit.ts +50 -14
  91. package/src/hooks/use-workspace-sessions.ts +3 -0
  92. package/src/human-input.ts +72 -0
  93. package/src/index.ts +88 -12
  94. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  95. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  96. package/src/lib/use-portal-token-style.ts +51 -0
  97. package/src/lib/use-unicode-fonts.ts +57 -0
  98. package/src/machines.ts +16 -0
  99. package/src/provider.tsx +163 -41
  100. package/src/session-context.ts +135 -0
  101. package/src/session.ts +79 -0
  102. package/src/timeline/parsers.ts +78 -6
  103. package/src/timeline/projection.ts +36 -6
  104. package/src/timeline/tool-renderers.tsx +41 -0
  105. package/src/timeline/turn-summary.tsx +2 -2
  106. package/src/timeline/types.ts +16 -8
  107. package/styles/index.css +78 -0
  108. package/styles/index.d.ts +2 -0
  109. package/styles/tokens.css +11 -6
  110. package/styles/tokens.d.ts +2 -0
  111. package/dist/chunk-NFYVQWIB.js +0 -1377
  112. package/dist/chunk-NFYVQWIB.js.map +0 -1
  113. package/dist/machines-CnlMb7E-.d.ts +0 -329
@@ -15,7 +15,14 @@ import {
15
15
  PanelRightCloseIcon,
16
16
  XIcon,
17
17
  } from "lucide-react";
18
- import { Group, Panel, Separator, useDefaultLayout, usePanelRef } from "react-resizable-panels";
18
+ import {
19
+ Group,
20
+ Panel,
21
+ Separator,
22
+ useDefaultLayout,
23
+ usePanelRef,
24
+ type LayoutStorage,
25
+ } from "react-resizable-panels";
19
26
  import { cn } from "../lib/cn";
20
27
 
21
28
  export type WorkspaceTab = {
@@ -53,6 +60,24 @@ export type WorkspaceDockProps = {
53
60
  className?: string | undefined;
54
61
  };
55
62
 
63
+ const SERVER_LAYOUT_STORAGE: LayoutStorage = {
64
+ getItem: () => null,
65
+ setItem: () => {},
66
+ };
67
+
68
+ /** `useDefaultLayout` defaults to the ambient `localStorage` identifier when
69
+ * storage is undefined, which throws during SSR. Access can also throw in a
70
+ * browser that blocks storage (sandboxed frames/private policy), so both cases
71
+ * use a stable, non-persisting implementation instead. */
72
+ function getLayoutStorage(): LayoutStorage {
73
+ if (typeof window === "undefined") return SERVER_LAYOUT_STORAGE;
74
+ try {
75
+ return window.localStorage;
76
+ } catch {
77
+ return SERVER_LAYOUT_STORAGE;
78
+ }
79
+ }
80
+
56
81
  /**
57
82
  * The resizable / collapsible / maximizable right-hand Workspace dock. Replaces
58
83
  * a fixed grid column: drag the separator to set width, collapse to a thin rail
@@ -113,6 +138,10 @@ export function WorkspaceDock({
113
138
  className,
114
139
  }: WorkspaceDockProps) {
115
140
  const narrow = useIsNarrow(DOCK_OVERLAY_BREAKPOINT);
141
+ const rootRef = useRef<HTMLDivElement | null>(null);
142
+ const reopenRef = useRef<HTMLButtonElement | null>(null);
143
+ const returnFocusRef = useRef<HTMLElement | null>(null);
144
+ const initialNarrowFocusRef = useRef(false);
116
145
  const dockPanelRef = usePanelRef();
117
146
  const [internalCollapsed, setInternalCollapsed] = useState(false);
118
147
  const [maximized, setMaximized] = useState(false);
@@ -124,13 +153,14 @@ export function WorkspaceDock({
124
153
  // bug). Standalone (uncontrolled) usage keeps the built-in collapse button
125
154
  // and the thin re-open rail as its only affordances.
126
155
  const hostControlled = collapsedProp !== undefined;
156
+ const previousCollapsedRef = useRef(collapsed);
127
157
 
128
158
  // Persisted layout (width split) keyed by autoSaveId.
129
159
  const { defaultLayout, onLayoutChanged } = useDefaultLayout({
130
160
  panelIds: ["primary", "dock"],
131
- storage: typeof window !== "undefined" ? window.localStorage : undefined,
161
+ storage: getLayoutStorage(),
132
162
  id: autoSaveId,
133
- } as Parameters<typeof useDefaultLayout>[0]);
163
+ });
134
164
 
135
165
  const requestedTab = activeTab ?? internalTab;
136
166
  const tabIds = tabs.map((tab) => tab.id);
@@ -164,6 +194,61 @@ export function WorkspaceDock({
164
194
  }
165
195
  }, [collapsedProp, dockPanelRef]);
166
196
 
197
+ // Treat the narrow overlay like a real modal: remember the external opener,
198
+ // move focus into the selected tab, and restore it on close. The standalone
199
+ // desktop dock instead focuses its newly-visible reopen rail after collapse.
200
+ useEffect(() => {
201
+ const previous = previousCollapsedRef.current;
202
+ previousCollapsedRef.current = collapsed;
203
+ if (previous === collapsed) return;
204
+
205
+ if (!collapsed) {
206
+ const active = document.activeElement;
207
+ const workspaceSurface = rootRef.current?.querySelector<HTMLElement>(
208
+ narrow ? '[role="dialog"]' : "[data-workspace-surface]",
209
+ );
210
+ if (active instanceof HTMLElement && !workspaceSurface?.contains(active)) {
211
+ returnFocusRef.current = active;
212
+ }
213
+ if (narrow) {
214
+ const frame = requestAnimationFrame(() => {
215
+ rootRef.current
216
+ ?.querySelector<HTMLElement>('[role="dialog"] [role="tab"][aria-selected="true"]')
217
+ ?.focus();
218
+ });
219
+ return () => cancelAnimationFrame(frame);
220
+ }
221
+ return;
222
+ }
223
+
224
+ const frame = requestAnimationFrame(() => {
225
+ if (!hostControlled) {
226
+ reopenRef.current?.focus();
227
+ return;
228
+ }
229
+ const target = returnFocusRef.current;
230
+ if (target?.isConnected) target.focus();
231
+ });
232
+ return () => cancelAnimationFrame(frame);
233
+ }, [collapsed, hostControlled, narrow]);
234
+
235
+ // On the first narrow render there is no collapsed-state transition to drive
236
+ // focus into the modal: the overlay can already be open at mount. Place focus
237
+ // on its selected tab once, just as we do when an existing overlay is opened.
238
+ useEffect(() => {
239
+ if (!narrow || collapsed || initialNarrowFocusRef.current) return;
240
+ initialNarrowFocusRef.current = true;
241
+ const active = document.activeElement;
242
+ const workspaceSurface = rootRef.current?.querySelector<HTMLElement>('[role="dialog"]');
243
+ if (active instanceof HTMLElement && !workspaceSurface?.contains(active)) {
244
+ returnFocusRef.current = active;
245
+ }
246
+ const frame = requestAnimationFrame(() => {
247
+ workspaceSurface?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')?.focus();
248
+ });
249
+ return () => cancelAnimationFrame(frame);
250
+ }, [collapsed, narrow]);
251
+
167
252
  // Keep the active tab valid if the available tabs change.
168
253
  useEffect(() => {
169
254
  if (firstTabId && !requestedTabIsValid) {
@@ -182,14 +267,55 @@ export function WorkspaceDock({
182
267
  setCollapsed(false);
183
268
  }, [dockPanelRef, setCollapsed]);
184
269
 
185
- // Esc restores from maximize (desktop) and closes the mobile overlay.
270
+ // Esc restores from maximize (desktop) and closes the mobile overlay. Tab is
271
+ // fenced inside either full-screen surface: both visually cover the host, so
272
+ // keyboard focus must never move into invisible content behind them.
186
273
  useEffect(() => {
187
274
  const overlayOpen = maximized || (narrow && !collapsed);
188
275
  if (!overlayOpen) return;
189
276
  const onKey = (event: KeyboardEvent) => {
190
- if (event.key !== "Escape") return;
191
- if (maximized) setMaximized(false);
192
- else collapse();
277
+ if (event.key === "Escape") {
278
+ if (maximized) setMaximized(false);
279
+ else collapse();
280
+ return;
281
+ }
282
+ if (event.key !== "Tab") return;
283
+ const surface = rootRef.current?.querySelector<HTMLElement>(
284
+ narrow ? '[role="dialog"]' : "[data-workspace-surface]",
285
+ );
286
+ if (!surface) return;
287
+ const focusable = Array.from(surface.querySelectorAll<HTMLElement>("*")).filter(
288
+ (element) =>
289
+ element.tabIndex >= 0 &&
290
+ !element.hasAttribute("disabled") &&
291
+ element.getClientRects().length > 0 &&
292
+ getComputedStyle(element).visibility !== "hidden",
293
+ );
294
+ const first = focusable[0];
295
+ const last = focusable.at(-1);
296
+ const active = document.activeElement;
297
+ if (!first || !last) {
298
+ event.preventDefault();
299
+ surface.focus();
300
+ return;
301
+ }
302
+ if (!surface.contains(active)) {
303
+ event.preventDefault();
304
+ (event.shiftKey ? last : first).focus();
305
+ return;
306
+ }
307
+ if ((event.shiftKey && active === first) || (!event.shiftKey && active === last)) {
308
+ event.preventDefault();
309
+ (event.shiftKey ? last : first).focus();
310
+ return;
311
+ }
312
+ // Focus can emerge from a shadow-DOM editor/highlighter host at a point
313
+ // the light-DOM order cannot predict. Repair any escape after the browser
314
+ // performs its default Tab move.
315
+ requestAnimationFrame(() => {
316
+ const next = document.activeElement;
317
+ if (!surface.contains(next)) (event.shiftKey ? last : first).focus();
318
+ });
193
319
  };
194
320
  window.addEventListener("keydown", onKey);
195
321
  return () => window.removeEventListener("keydown", onKey);
@@ -200,33 +326,53 @@ export function WorkspaceDock({
200
326
  // driven by the same `collapsed` contract (collapsed → hidden).
201
327
  if (narrow) {
202
328
  return (
203
- <div className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
204
- <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
- }}
329
+ <div ref={rootRef} className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
330
+ <div
331
+ data-workspace-primary
332
+ aria-hidden={!collapsed ? true : undefined}
333
+ className="min-h-0 min-w-0 flex-1"
334
+ inert={!collapsed ? true : undefined}
335
+ >
336
+ {primary}
337
+ </div>
338
+ <div
339
+ data-workspace-surface
340
+ role="dialog"
341
+ aria-modal="true"
342
+ aria-label="Workspace"
343
+ className="fixed inset-0 z-40 flex flex-col bg-og-bg"
344
+ hidden={collapsed}
345
+ style={{
346
+ paddingTop: "env(safe-area-inset-top)",
347
+ paddingBottom: "env(safe-area-inset-bottom)",
348
+ }}
349
+ >
350
+ <DockChrome
351
+ tabs={tabs}
352
+ current={current}
353
+ onTab={setTab}
354
+ leading={mobileLeadingControl}
355
+ accessory={headerAccessory}
356
+ controls={
357
+ <ChromeButton onClick={collapse} title="Close workspace" label="Close workspace">
358
+ <XIcon className="size-4" />
359
+ </ChromeButton>
360
+ }
361
+ />
362
+ </div>
363
+ {collapsed && !hostControlled ? (
364
+ <button
365
+ ref={reopenRef}
366
+ type="button"
367
+ onClick={expand}
368
+ title="Open workspace"
369
+ aria-label="Open workspace"
370
+ 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"
371
+ style={{ marginTop: "env(safe-area-inset-top)" }}
215
372
  >
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
- )}
373
+ <ChevronsLeftRightIcon className="size-4" aria-hidden />
374
+ </button>
375
+ ) : null}
230
376
  </div>
231
377
  );
232
378
  }
@@ -261,7 +407,7 @@ export function WorkspaceDock({
261
407
  );
262
408
 
263
409
  return (
264
- <div className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
410
+ <div ref={rootRef} className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
265
411
  <Group
266
412
  orientation="horizontal"
267
413
  className="min-h-0 flex-1"
@@ -269,12 +415,19 @@ export function WorkspaceDock({
269
415
  onLayoutChanged={onLayoutChanged}
270
416
  >
271
417
  <Panel id="primary" minSize="30%" className="min-h-0 min-w-0">
272
- {primary}
418
+ <div
419
+ data-workspace-primary
420
+ aria-hidden={maximized ? true : undefined}
421
+ className="h-full min-h-0 min-w-0"
422
+ inert={maximized ? true : undefined}
423
+ >
424
+ {primary}
425
+ </div>
273
426
  </Panel>
274
427
 
275
428
  {!collapsed && (
276
- <Separator className="group relative w-1.5 shrink-0 outline-none">
277
- <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-og-border transition-colors group-hover:bg-og-accent group-data-[separator-state=dragging]:bg-og-accent" />
429
+ <Separator className="group relative z-10 w-1.5 shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent max-[1023px]:-mx-[19px] max-[1023px]:w-11 pointer-coarse:-mx-[19px] pointer-coarse:w-11">
430
+ <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-og-border transition-colors group-hover:bg-og-accent group-focus-visible:bg-og-accent group-data-[separator-state=dragging]:bg-og-accent" />
278
431
  </Separator>
279
432
  )}
280
433
 
@@ -296,13 +449,22 @@ export function WorkspaceDock({
296
449
  }}
297
450
  className="min-h-0 min-w-0"
298
451
  >
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
- )}
452
+ {/* One persistent mount across normal, collapsed, and maximized modes:
453
+ layout changes must never destroy an editor buffer or terminal view. */}
454
+ <div
455
+ data-workspace-surface
456
+ role={maximized ? "dialog" : undefined}
457
+ aria-modal={maximized ? true : undefined}
458
+ aria-label={maximized ? "Workspace" : undefined}
459
+ aria-hidden={collapsed ? true : undefined}
460
+ className={cn(
461
+ "flex h-full min-h-0 min-w-0 flex-col bg-og-bg",
462
+ maximized ? "fixed inset-0 z-40" : "border-l border-og-border",
463
+ collapsed && "invisible pointer-events-none",
464
+ )}
465
+ >
466
+ {dockChrome}
467
+ </div>
306
468
  </Panel>
307
469
  </Group>
308
470
 
@@ -310,6 +472,7 @@ export function WorkspaceDock({
310
472
  when the host controls collapse — its own toggle is the one way in. */}
311
473
  {collapsed && !maximized && !hostControlled && (
312
474
  <button
475
+ ref={reopenRef}
313
476
  type="button"
314
477
  onClick={expand}
315
478
  title="Open workspace"
@@ -318,9 +481,6 @@ export function WorkspaceDock({
318
481
  <ChevronsLeftRightIcon className="size-3.5" />
319
482
  </button>
320
483
  )}
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
484
  </div>
325
485
  );
326
486
  }
@@ -343,7 +503,7 @@ function ChromeButton({
343
503
  onClick={onClick}
344
504
  title={title}
345
505
  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"
506
+ 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
507
  >
348
508
  {children}
349
509
  </button>
@@ -369,14 +529,82 @@ function DockChrome({
369
529
  controls: ReactNode;
370
530
  }) {
371
531
  const active = tabs.find((t) => t.id === current) ?? tabs[0];
372
- const activeIndex = tabs.findIndex((tab) => tab.id === active?.id);
373
532
  const tabsetId = useId();
374
533
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
375
- const panelId = `${tabsetId}-panel`;
534
+ const tabListRef = useRef<HTMLDivElement | null>(null);
535
+ const [visitedTabs, setVisitedTabs] = useState<Set<string>>(() => new Set());
536
+ const [tabOverflow, setTabOverflow] = useState({ start: false, end: false });
537
+ const activeId = active?.id ?? "";
538
+ const activeTabIndex = tabs.findIndex((tab) => tab.id === activeId);
539
+ const keepActiveTabVisible = useCallback(() => {
540
+ const selected = activeTabIndex >= 0 ? tabRefs.current[activeTabIndex] : null;
541
+ selected?.scrollIntoView({ block: "nearest", inline: "nearest" });
542
+ }, [activeTabIndex]);
543
+
544
+ useEffect(() => {
545
+ if (!activeId) return;
546
+ setVisitedTabs((previous) => {
547
+ if (previous.has(activeId)) return previous;
548
+ const next = new Set(previous);
549
+ next.add(activeId);
550
+ return next;
551
+ });
552
+ }, [activeId]);
553
+
554
+ // A controlled/initial tab can become active without receiving focus (for
555
+ // example, a host routing a guarded diff into Files). Native scrolling only
556
+ // follows focus, so keep the selected tab inside the horizontally scrollable
557
+ // strip even when state — rather than a click or arrow key — selected it.
558
+ useDockLayoutEffect(() => {
559
+ keepActiveTabVisible();
560
+ }, [activeId, keepActiveTabVisible]);
561
+
562
+ // Hidden scrollbars keep the chrome quiet, but without an edge cue a clipped
563
+ // host label reads like corrupted copy. Fade only the edge(s) with more tabs,
564
+ // and recompute after scrolling, resizing, or late font metrics.
565
+ useDockLayoutEffect(() => {
566
+ const list = tabListRef.current;
567
+ if (!list) return;
568
+ const update = () => {
569
+ const max = Math.max(0, list.scrollWidth - list.clientWidth);
570
+ const next = { start: list.scrollLeft > 1, end: list.scrollLeft < max - 1 };
571
+ setTabOverflow((previous) =>
572
+ previous.start === next.start && previous.end === next.end ? previous : next,
573
+ );
574
+ };
575
+ update();
576
+ list.addEventListener("scroll", update, { passive: true });
577
+ const observer =
578
+ typeof ResizeObserver === "undefined"
579
+ ? null
580
+ : new ResizeObserver(() => {
581
+ keepActiveTabVisible();
582
+ update();
583
+ });
584
+ observer?.observe(list);
585
+ for (const tab of tabRefs.current) {
586
+ if (tab) observer?.observe(tab);
587
+ }
588
+ return () => {
589
+ list.removeEventListener("scroll", update);
590
+ observer?.disconnect();
591
+ };
592
+ }, [activeId, keepActiveTabVisible, tabs.length]);
593
+
594
+ const tabMask =
595
+ tabOverflow.start && tabOverflow.end
596
+ ? "linear-gradient(to right, transparent 0, black 14px, black calc(100% - 14px), transparent 100%)"
597
+ : tabOverflow.start
598
+ ? "linear-gradient(to right, transparent 0, black 14px, black 100%)"
599
+ : tabOverflow.end
600
+ ? "linear-gradient(to right, black 0, black calc(100% - 14px), transparent 100%)"
601
+ : undefined;
376
602
 
377
603
  const activateTab = (index: number) => {
378
604
  const tab = tabs[index];
379
- if (!tab) return;
605
+ if (!tab) {
606
+ return;
607
+ }
380
608
  onTab(tab.id);
381
609
  tabRefs.current[index]?.focus();
382
610
  };
@@ -395,15 +623,31 @@ function DockChrome({
395
623
 
396
624
  return (
397
625
  <>
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}
626
+ <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 min-[640px]:max-[1023px]:grid-cols-[auto_minmax(0,1fr)_auto_auto]">
627
+ <div className="flex min-w-0 shrink-0 items-center max-[1023px]:min-h-11 min-[640px]:max-[1023px]:col-start-1 min-[640px]:max-[1023px]:row-start-1">
628
+ {leading ?? (
629
+ <span className="hidden truncate px-1 text-og-sm font-semibold text-og-fg max-[1023px]:inline">
630
+ Workspace
631
+ </span>
632
+ )}
633
+ </div>
400
634
  {/* The tab list scrolls horizontally when it can't fit — it must never
401
635
  grow into or overlap the chrome controls (they stay shrink-0). The
402
- scrollbar is hidden to keep the strip calm. */}
636
+ scrollbar is hidden to keep the strip calm. On the narrow overlay it
637
+ owns a full second row, so status/close chrome can never squeeze the
638
+ canonical workspace navigation out of view. Tablets have enough
639
+ width to retain the denser single-row chrome, with this same strip
640
+ absorbing any unusually long host-injected labels. */}
403
641
  <div
404
- className="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
642
+ ref={tabListRef}
643
+ 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 min-[640px]:max-[1023px]:col-span-1 min-[640px]:max-[1023px]:col-start-2 min-[640px]:max-[1023px]:row-start-1"
405
644
  role="tablist"
406
645
  aria-orientation="horizontal"
646
+ style={{
647
+ maskImage: tabMask,
648
+ WebkitMaskImage: tabMask,
649
+ scrollPaddingInline: "16px",
650
+ }}
407
651
  >
408
652
  {tabs.map((tab, index) => (
409
653
  <button
@@ -415,33 +659,50 @@ function DockChrome({
415
659
  type="button"
416
660
  role="tab"
417
661
  aria-selected={tab.id === current}
418
- aria-controls={panelId}
662
+ aria-controls={`${tabsetId}-panel-${index}`}
419
663
  tabIndex={tab.id === current ? 0 : -1}
420
664
  onClick={() => onTab(tab.id)}
421
665
  onKeyDown={(event) => onTabKeyDown(event, index)}
422
666
  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",
667
+ "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
668
  tab.id === current
425
669
  ? "bg-og-accent-soft text-og-fg"
426
670
  : "text-og-fg-subtle hover:text-og-fg",
427
671
  )}
428
672
  >
429
- <span>{tab.label}</span>
673
+ <span data-contrast-audited>{tab.label}</span>
430
674
  {tab.badge}
431
675
  </button>
432
676
  ))}
433
677
  </div>
434
- {accessory ? <div className="flex shrink-0 items-center">{accessory}</div> : null}
435
- <div className="flex shrink-0 items-center gap-0.5 text-og-fg-subtle">{controls}</div>
678
+ <div className="flex min-w-0 shrink-0 items-center justify-self-end min-[640px]:max-[1023px]:col-start-3 min-[640px]:max-[1023px]:row-start-1">
679
+ {accessory}
680
+ </div>
681
+ <div className="flex shrink-0 items-center gap-0.5 text-og-fg-subtle min-[640px]:max-[1023px]:col-start-4 min-[640px]:max-[1023px]:row-start-1">
682
+ {controls}
683
+ </div>
436
684
  </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}
685
+ <div className="min-h-0 min-w-0 flex-1 overflow-hidden">
686
+ {tabs.length > 0 ? (
687
+ tabs.map((tab, index) => {
688
+ const selected = tab.id === activeId;
689
+ const shouldMount = selected || visitedTabs.has(tab.id);
690
+ return (
691
+ <div
692
+ key={tab.id}
693
+ id={`${tabsetId}-panel-${index}`}
694
+ aria-labelledby={`${tabsetId}-tab-${index}`}
695
+ className="h-full min-h-0 min-w-0 overflow-hidden"
696
+ hidden={!selected}
697
+ role="tabpanel"
698
+ >
699
+ {shouldMount ? tab.content : null}
700
+ </div>
701
+ );
702
+ })
703
+ ) : (
704
+ <div className="h-full min-h-0 min-w-0" aria-label="Workspace" role="tabpanel" />
705
+ )}
445
706
  </div>
446
707
  </>
447
708
  );
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Advanced chat-composer framework. Import this subpath as a namespace:
3
+ *
4
+ * `import * as Composer from "@opengeni/react/composer"`.
5
+ */
6
+ export {
7
+ Actions,
8
+ AttachButton,
9
+ Attachments,
10
+ CommandPalette,
11
+ Confirmation,
12
+ Controls,
13
+ Footer,
14
+ Frame,
15
+ Help,
16
+ Hint,
17
+ Input,
18
+ ModelPicker,
19
+ OPEN_WORKSTREAM_CONTROL_EVENT,
20
+ PauseButton,
21
+ PausedState,
22
+ RestoredResources,
23
+ Root,
24
+ SendButton,
25
+ Status,
26
+ Surface,
27
+ defaultChatComposerMessages,
28
+ useChatComposer,
29
+ useChatComposerController,
30
+ } from "./components/composer";
31
+ export type {
32
+ ChatComposerController,
33
+ ChatComposerContextValue,
34
+ ChatComposerMessages,
35
+ ComposerActionsProps,
36
+ ComposerAttachButtonProps,
37
+ ComposerCommandPaletteProps,
38
+ ComposerControlLinks,
39
+ ComposerControlState,
40
+ ComposerControlsProps,
41
+ ComposerDelivery,
42
+ ComposerDraftState,
43
+ ComposerFooterProps,
44
+ ComposerFrameProps,
45
+ ComposerHintProps,
46
+ ComposerInputProps,
47
+ ComposerModelPickerProps,
48
+ ComposerPauseButtonProps,
49
+ ComposerRootProps,
50
+ ComposerSendButtonProps,
51
+ ComposerSubmitBlocker,
52
+ ComposerSubmitMode,
53
+ ComposerSurfaceProps,
54
+ UseChatComposerControllerOptions,
55
+ } from "./components/composer";
56
+ export { ComposerTranscriptionControl } from "./components/composer-transcription-control";
57
+ export type {
58
+ ComposerTranscriptionControlProps,
59
+ ComposerTranscriptionMessages,
60
+ } from "./components/composer-transcription-control";