@opengeni/react 0.15.0 → 0.23.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 (103) hide show
  1. package/README.md +110 -10
  2. package/dist/{chunk-TOJR776I.js → chunk-6XUS5VFM.js} +45 -369
  3. package/dist/chunk-6XUS5VFM.js.map +1 -0
  4. package/dist/chunk-CATATY77.js +2393 -0
  5. package/dist/chunk-CATATY77.js.map +1 -0
  6. package/dist/chunk-I3BJZIG5.js +439 -0
  7. package/dist/chunk-I3BJZIG5.js.map +1 -0
  8. package/dist/chunk-TK7G6XLT.js +18 -0
  9. package/dist/chunk-TK7G6XLT.js.map +1 -0
  10. package/dist/chunk-UAHPKLB6.js +2237 -0
  11. package/dist/chunk-UAHPKLB6.js.map +1 -0
  12. package/dist/chunk-UQS7OQDE.js +591 -0
  13. package/dist/chunk-UQS7OQDE.js.map +1 -0
  14. package/dist/chunk-YDNWMKXO.js +102 -0
  15. package/dist/chunk-YDNWMKXO.js.map +1 -0
  16. package/dist/composer-CVm5uoUI.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 +1288 -1785
  21. package/dist/index.js +7140 -8817
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +3 -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-3UW3MIIR.js +890 -0
  30. package/dist/queue-surface-implementation-3UW3MIIR.js.map +1 -0
  31. package/dist/session-Db00TRwl.d.ts +446 -0
  32. package/dist/session-context-rgrfElMl.d.ts +58 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +54 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-By1wn3MM.d.ts +99 -0
  37. package/package.json +20 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +44 -0
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +94 -993
  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 +1 -1
  51. package/src/components/machines-dashboard.tsx +3 -3
  52. package/src/components/message-timeline.tsx +205 -25
  53. package/src/components/model-picker.tsx +12 -3
  54. package/src/components/pierre-diff.tsx +32 -6
  55. package/src/components/queue-draft-policy.ts +18 -0
  56. package/src/components/queue-surface-implementation.tsx +1037 -0
  57. package/src/components/queue-surface-state.tsx +94 -0
  58. package/src/components/queue-surface.tsx +42 -559
  59. package/src/components/sandbox-files.tsx +138 -14
  60. package/src/components/sandbox-terminal.tsx +8 -2
  61. package/src/components/sandbox-workspace.tsx +339 -119
  62. package/src/components/workbench-changes.tsx +84 -33
  63. package/src/components/workspace-dock.tsx +188 -18
  64. package/src/composer.ts +60 -0
  65. package/src/hooks/internal.ts +115 -34
  66. package/src/hooks/use-composer.ts +385 -104
  67. package/src/hooks/use-human-input.ts +131 -0
  68. package/src/hooks/use-machine-chip.ts +2 -2
  69. package/src/hooks/use-machines.ts +27 -11
  70. package/src/hooks/use-sandbox-files.ts +310 -62
  71. package/src/hooks/use-sandbox-git.ts +154 -40
  72. package/src/hooks/use-sandbox-terminal.ts +28 -6
  73. package/src/hooks/use-session-capabilities.ts +38 -9
  74. package/src/hooks/use-session-control.ts +37 -8
  75. package/src/hooks/use-session-events.ts +522 -48
  76. package/src/hooks/use-session-mcp-approval-policy.ts +161 -0
  77. package/src/hooks/use-slash-commands.ts +38 -38
  78. package/src/hooks/use-transcription.ts +757 -0
  79. package/src/hooks/use-turn-queue.ts +134 -66
  80. package/src/hooks/use-windowed-sections.ts +2 -2
  81. package/src/hooks/use-workspace-capture.ts +146 -40
  82. package/src/hooks/use-workspace-edit.ts +50 -14
  83. package/src/hooks/use-workspace-sessions.ts +3 -0
  84. package/src/human-input.ts +72 -0
  85. package/src/index.ts +85 -6
  86. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  87. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  88. package/src/lib/use-portal-token-style.ts +51 -0
  89. package/src/lib/use-unicode-fonts.ts +57 -0
  90. package/src/provider.tsx +4 -69
  91. package/src/session-context.ts +160 -0
  92. package/src/session.ts +89 -0
  93. package/src/timeline/parsers.ts +35 -0
  94. package/src/timeline/projection.ts +12 -4
  95. package/src/timeline/tool-renderers.tsx +41 -0
  96. package/src/timeline/turn-summary.tsx +2 -2
  97. package/src/timeline/types.ts +10 -9
  98. package/styles/index.css +56 -0
  99. package/styles/index.d.ts +2 -0
  100. package/styles/tokens.css +11 -6
  101. package/styles/tokens.d.ts +2 -0
  102. package/dist/chunk-TOJR776I.js.map +0 -1
  103. package/dist/machines-BpdwuQcD.d.ts +0 -449
@@ -12,25 +12,28 @@ import {
12
12
  } from "react";
13
13
  import { VList } from "virtua";
14
14
  import { cn } from "../lib/cn";
15
+ import { useUnicodeFallbackFonts } from "../lib/use-unicode-fonts";
15
16
  import { useThemeType } from "../lib/use-theme-type";
16
17
  import { formatAsOf } from "../hooks/use-machine-chip";
18
+ import type { SandboxGitFileDiff } from "../hooks/use-sandbox-git";
17
19
  import { useWindowedSections } from "../hooks/use-windowed-sections";
18
20
  import { PierreDiff } from "./pierre-diff";
19
21
 
20
22
  /* ----------------------------------------------------------------------------
21
23
  Changes tab — a PR-review surface.
22
24
 
23
- A file rail (status glyph, ±counts, grouped by top-level dir when the change
24
- set is large) on the left; a stacked, WINDOWED diff pane on the right. Only
25
+ A file rail (status glyph, ±counts, grouped by repository when metadata is
26
+ available, otherwise by top-level directory for a large legacy input) on the
27
+ left; a stacked, WINDOWED diff pane on the right. Only
25
28
  the file sections inside the visible window ± overscan mount a Pierre/Shiki
26
29
  highlighter (the one renderer), so a 40- or 400-file change set never mounts N
27
- highlighters at once (dossier §10.7, D2). A per-file >guard (binary / diff too
30
+ highlighters at once (D2). A per-file size guard (binary / diff too
28
31
  large) degrades to an "open live" affordance instead of a diff body.
29
32
 
30
- `git.diff` is a single flat scope, so "group by repo then directory" here means
31
- "group by top-level directory" which is exactly the repo dir in a multi-repo
32
- workspace and the top folder otherwise. Grouping only kicks in past the
33
- threshold; a small change set stays a flat list.
33
+ Workspace-wide Git results carry an explicit `repoRoot`, so grouping never
34
+ guesses repository ownership from a directory name. Multi-repo results group
35
+ at every size; legacy unscoped inputs group by top-level directory only past
36
+ the threshold.
34
37
  -------------------------------------------------------------------------- */
35
38
 
36
39
  /** Group the rail (and reorder the pane) once the change set is larger than this. */
@@ -67,9 +70,16 @@ const STATUS_LETTER: Record<GitFileDiff["status"], string> = {
67
70
  typechange: "T",
68
71
  };
69
72
 
73
+ function compactFileLabel(file: SandboxGitFileDiff): string {
74
+ const slash = file.path.lastIndexOf("/");
75
+ const basename = slash >= 0 ? file.path.slice(slash + 1) : file.path;
76
+ const parent = slash >= 0 ? file.path.slice(0, slash + 1) : "";
77
+ return `${STATUS_LETTER[file.status]} · ${basename}${parent ? ` — ${parent}` : ""}`;
78
+ }
79
+
70
80
  export type WorkbenchChangesProps = {
71
81
  /** The changed files (`useSandboxGit().diff`). Assumed non-empty by the caller. */
72
- diff: GitFileDiff[];
82
+ diff: SandboxGitFileDiff[];
73
83
  /** Which source the diff came from — drives the "live" vs "as of turn" badge. */
74
84
  source: "live" | "capture" | null;
75
85
  /** When the served capture was taken (ISO), when `source === "capture"`. */
@@ -84,28 +94,41 @@ export type WorkbenchChangesProps = {
84
94
 
85
95
  type RailRow =
86
96
  | { kind: "group"; label: string; count: number }
87
- | { kind: "file"; file: GitFileDiff; index: number };
97
+ | { kind: "file"; file: SandboxGitFileDiff; index: number };
88
98
 
89
99
  /** Order the files and build the rail rows (grouped past the threshold). The
90
100
  * returned `orderedFiles` drives BOTH the rail and the diff pane so a rail row's
91
101
  * `index` addresses the matching pane section. Exported for tests. */
92
- export function buildRail(files: GitFileDiff[]): { orderedFiles: GitFileDiff[]; rows: RailRow[] } {
93
- if (files.length <= GROUP_THRESHOLD) {
102
+ export function buildRail(files: SandboxGitFileDiff[]): {
103
+ orderedFiles: SandboxGitFileDiff[];
104
+ rows: RailRow[];
105
+ grouped: boolean;
106
+ } {
107
+ const roots = new Set(
108
+ files.flatMap((file) => (file.repoRoot === undefined ? [] : [file.repoRoot])),
109
+ );
110
+ const groupByRepo = roots.size > 1;
111
+ if (!groupByRepo && files.length <= GROUP_THRESHOLD) {
94
112
  return {
95
113
  orderedFiles: files,
96
114
  rows: files.map((file, index) => ({ kind: "file", file, index })),
115
+ grouped: false,
97
116
  };
98
117
  }
99
- const groups = new Map<string, GitFileDiff[]>();
118
+ const groups = new Map<string, SandboxGitFileDiff[]>();
100
119
  for (const file of files) {
101
120
  const slash = file.path.indexOf("/");
102
- const key = slash > 0 ? file.path.slice(0, slash) : "(root)";
121
+ const key = groupByRepo
122
+ ? file.repoRoot || "(workspace)"
123
+ : slash > 0
124
+ ? file.path.slice(0, slash)
125
+ : "(root)";
103
126
  const bucket = groups.get(key);
104
127
  if (bucket) bucket.push(file);
105
128
  else groups.set(key, [file]);
106
129
  }
107
130
  const labels = [...groups.keys()].sort((a, b) => a.localeCompare(b));
108
- const orderedFiles: GitFileDiff[] = [];
131
+ const orderedFiles: SandboxGitFileDiff[] = [];
109
132
  const rows: RailRow[] = [];
110
133
  for (const label of labels) {
111
134
  const bucket = (groups.get(label) ?? []).slice().sort((a, b) => a.path.localeCompare(b.path));
@@ -115,7 +138,7 @@ export function buildRail(files: GitFileDiff[]): { orderedFiles: GitFileDiff[];
115
138
  orderedFiles.push(file);
116
139
  }
117
140
  }
118
- return { orderedFiles, rows };
141
+ return { orderedFiles, rows, grouped: true };
119
142
  }
120
143
 
121
144
  /** A file's diff body is "too large to inline" when it's binary or the diff guard
@@ -150,8 +173,13 @@ export function WorkbenchChanges({
150
173
  const [activePath, setActivePath] = useState<string | null>(null);
151
174
  const pickerId = useId();
152
175
 
153
- const { orderedFiles, rows } = useMemo(() => buildRail(diff), [diff]);
154
- const grouped = diff.length > GROUP_THRESHOLD;
176
+ const unicodeFontPaths = useMemo(
177
+ () => diff.flatMap((file) => (file.oldPath ? [file.path, file.oldPath] : [file.path])),
178
+ [diff],
179
+ );
180
+ useUnicodeFallbackFonts(unicodeFontPaths);
181
+
182
+ const { orderedFiles, rows, grouped } = useMemo(() => buildRail(diff), [diff]);
155
183
  const selectedIndex = orderedFiles.findIndex((file) => file.path === activePath);
156
184
  const activeIndex = selectedIndex >= 0 ? selectedIndex : 0;
157
185
 
@@ -251,10 +279,20 @@ export function WorkbenchChanges({
251
279
  compact && source === "capture" && "flex-col items-stretch gap-1.5 py-2",
252
280
  )}
253
281
  >
254
- <span data-contrast-audited className="min-w-0 text-og-xs text-og-fg-muted">
282
+ <span
283
+ role="status"
284
+ aria-atomic="true"
285
+ aria-live="polite"
286
+ data-contrast-audited
287
+ className="min-w-0 text-og-xs text-og-fg-muted"
288
+ >
255
289
  {diff.length} {diff.length === 1 ? "file" : "files"} changed
256
- <span className="ml-2 text-og-status-idle">+{additions}</span>
257
- <span className="ml-1 text-og-status-failed">−{deletions}</span>
290
+ <span data-contrast-audited className="ml-2 text-og-status-idle">
291
+ +{additions}
292
+ </span>
293
+ <span data-contrast-audited className="ml-1 text-og-status-failed">
294
+ −{deletions}
295
+ </span>
258
296
  </span>
259
297
  <div
260
298
  className={cn(
@@ -281,12 +319,13 @@ export function WorkbenchChanges({
281
319
  const index = Number(event.currentTarget.value);
282
320
  setActivePath(orderedFiles[index]?.path ?? null);
283
321
  }}
322
+ title={activeFile?.path}
284
323
  data-compact-file-picker
285
324
  className="h-11 w-full rounded-og-md border border-og-border bg-og-bg px-3 font-og-mono text-og-sm text-og-fg outline-none transition-colors focus:border-og-accent focus:ring-2 focus:ring-og-accent-soft"
286
325
  >
287
326
  {orderedFiles.map((file, index) => (
288
327
  <option key={file.path} value={index}>
289
- {STATUS_LETTER[file.status]} · {file.path}
328
+ {compactFileLabel(file)}
290
329
  </option>
291
330
  ))}
292
331
  </select>
@@ -316,7 +355,7 @@ export function WorkbenchChanges({
316
355
  <div
317
356
  key={`g:${row.label}`}
318
357
  data-rail-group
319
- className="flex items-center gap-1.5 px-2 pb-0.5 pt-2 text-2xs font-medium uppercase tracking-wide text-og-fg-subtle"
358
+ className="flex items-center gap-1.5 px-2 pb-0.5 pt-2 text-og-xs font-medium uppercase tracking-wide text-og-fg-subtle"
320
359
  >
321
360
  <span className="min-w-0 truncate">{row.label}</span>
322
361
  <span className="shrink-0">{row.count}</span>
@@ -387,7 +426,7 @@ function SourceBadge({
387
426
  if (source === "capture" && capturedAt) {
388
427
  return (
389
428
  <span
390
- className="inline-flex items-center gap-1 rounded-og-xs border border-og-border px-1.5 py-px text-2xs text-og-fg-muted"
429
+ className="inline-flex items-center gap-1 rounded-og-xs border border-og-border px-1.5 py-px text-og-xs text-og-fg-muted"
391
430
  title={new Date(capturedAt).toLocaleString()}
392
431
  >
393
432
  <HistoryIcon className="size-3 shrink-0 text-og-status-running" aria-hidden />
@@ -396,7 +435,7 @@ function SourceBadge({
396
435
  );
397
436
  }
398
437
  return (
399
- <span className="rounded-og-xs bg-og-surface-2 px-1.5 py-px text-2xs text-og-fg-subtle">
438
+ <span className="rounded-og-xs bg-og-surface-2 px-1.5 py-px text-og-xs text-og-fg-subtle">
400
439
  {label}
401
440
  </span>
402
441
  );
@@ -419,14 +458,21 @@ function RailFileRow({
419
458
  active,
420
459
  onClick,
421
460
  }: {
422
- file: GitFileDiff;
461
+ file: SandboxGitFileDiff;
423
462
  grouped: boolean;
424
463
  active: boolean;
425
464
  onClick: () => void;
426
465
  }) {
427
- // In a grouped rail the top-level dir is the group header, so show the path
428
- // beneath it; otherwise show the whole path.
429
- const shown = grouped ? file.path.slice(file.path.indexOf("/") + 1) || file.path : file.path;
466
+ // A real repository group strips the exact repo root. Legacy top-directory
467
+ // grouping strips only that first segment.
468
+ const repoPrefix = file.repoRoot ? `${file.repoRoot}/` : null;
469
+ const shown = grouped
470
+ ? file.repoRoot !== undefined
471
+ ? repoPrefix && file.path.startsWith(repoPrefix)
472
+ ? file.path.slice(repoPrefix.length)
473
+ : file.path
474
+ : file.path.slice(file.path.indexOf("/") + 1) || file.path
475
+ : file.path;
430
476
  return (
431
477
  <button
432
478
  type="button"
@@ -441,14 +487,19 @@ function RailFileRow({
441
487
  )}
442
488
  >
443
489
  <span
490
+ data-contrast-audited
444
491
  className={cn("w-3 shrink-0 text-center font-og-mono text-og-xs", STATUS_TINT[file.status])}
445
492
  >
446
493
  {STATUS_LETTER[file.status]}
447
494
  </span>
448
495
  <span className="min-w-0 flex-1 truncate">{shown}</span>
449
- <span className="ml-auto flex shrink-0 items-center gap-1 pl-1 font-og-mono text-2xs">
450
- <span className="text-og-status-idle">+{file.additions}</span>
451
- <span className="text-og-status-failed">−{file.deletions}</span>
496
+ <span className="ml-auto flex shrink-0 items-center gap-1 pl-1 font-og-mono text-og-xs">
497
+ <span data-contrast-audited className="text-og-status-idle">
498
+ +{file.additions}
499
+ </span>
500
+ <span data-contrast-audited className="text-og-status-failed">
501
+ −{file.deletions}
502
+ </span>
452
503
  </span>
453
504
  </button>
454
505
  );
@@ -512,7 +563,7 @@ function DiffSection({
512
563
  <span className="min-w-0 truncate font-og-mono text-og-xs">
513
564
  {renamed ? `${file.oldPath} → ${file.path}` : file.path}
514
565
  </span>
515
- <span className="flex shrink-0 items-center gap-2 font-og-mono text-2xs">
566
+ <span className="flex shrink-0 items-center gap-2 font-og-mono text-og-xs">
516
567
  <span className="text-og-status-idle">+{file.additions}</span>
517
568
  <span className="text-og-status-failed">−{file.deletions}</span>
518
569
  </span>
@@ -571,7 +622,7 @@ function LayoutToggle({
571
622
  type="button"
572
623
  onClick={() => onChange(value)}
573
624
  className={cn(
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",
625
+ "min-h-7 rounded-og-xs px-1.5 py-0.5 text-og-xs capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11",
575
626
  layout === value
576
627
  ? "bg-og-accent-soft text-og-fg"
577
628
  : "text-og-fg-subtle hover:text-og-fg",
@@ -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
@@ -116,6 +141,7 @@ export function WorkspaceDock({
116
141
  const rootRef = useRef<HTMLDivElement | null>(null);
117
142
  const reopenRef = useRef<HTMLButtonElement | null>(null);
118
143
  const returnFocusRef = useRef<HTMLElement | null>(null);
144
+ const initialNarrowFocusRef = useRef(false);
119
145
  const dockPanelRef = usePanelRef();
120
146
  const [internalCollapsed, setInternalCollapsed] = useState(false);
121
147
  const [maximized, setMaximized] = useState(false);
@@ -132,9 +158,9 @@ export function WorkspaceDock({
132
158
  // Persisted layout (width split) keyed by autoSaveId.
133
159
  const { defaultLayout, onLayoutChanged } = useDefaultLayout({
134
160
  panelIds: ["primary", "dock"],
135
- storage: typeof window !== "undefined" ? window.localStorage : undefined,
161
+ storage: getLayoutStorage(),
136
162
  id: autoSaveId,
137
- } as Parameters<typeof useDefaultLayout>[0]);
163
+ });
138
164
 
139
165
  const requestedTab = activeTab ?? internalTab;
140
166
  const tabIds = tabs.map((tab) => tab.id);
@@ -206,6 +232,23 @@ export function WorkspaceDock({
206
232
  return () => cancelAnimationFrame(frame);
207
233
  }, [collapsed, hostControlled, narrow]);
208
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
+
209
252
  // Keep the active tab valid if the available tabs change.
210
253
  useEffect(() => {
211
254
  if (firstTabId && !requestedTabIsValid) {
@@ -224,14 +267,55 @@ export function WorkspaceDock({
224
267
  setCollapsed(false);
225
268
  }, [dockPanelRef, setCollapsed]);
226
269
 
227
- // 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.
228
273
  useEffect(() => {
229
274
  const overlayOpen = maximized || (narrow && !collapsed);
230
275
  if (!overlayOpen) return;
231
276
  const onKey = (event: KeyboardEvent) => {
232
- if (event.key !== "Escape") return;
233
- if (maximized) setMaximized(false);
234
- 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
+ });
235
319
  };
236
320
  window.addEventListener("keydown", onKey);
237
321
  return () => window.removeEventListener("keydown", onKey);
@@ -243,8 +327,16 @@ export function WorkspaceDock({
243
327
  if (narrow) {
244
328
  return (
245
329
  <div ref={rootRef} className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
246
- <div className="min-h-0 min-w-0 flex-1">{primary}</div>
247
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
248
340
  role="dialog"
249
341
  aria-modal="true"
250
342
  aria-label="Workspace"
@@ -323,12 +415,19 @@ export function WorkspaceDock({
323
415
  onLayoutChanged={onLayoutChanged}
324
416
  >
325
417
  <Panel id="primary" minSize="30%" className="min-h-0 min-w-0">
326
- {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>
327
426
  </Panel>
328
427
 
329
428
  {!collapsed && (
330
- <Separator className="group relative w-1.5 shrink-0 outline-none">
331
- <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" />
332
431
  </Separator>
333
432
  )}
334
433
 
@@ -354,6 +453,9 @@ export function WorkspaceDock({
354
453
  layout changes must never destroy an editor buffer or terminal view. */}
355
454
  <div
356
455
  data-workspace-surface
456
+ role={maximized ? "dialog" : undefined}
457
+ aria-modal={maximized ? true : undefined}
458
+ aria-label={maximized ? "Workspace" : undefined}
357
459
  aria-hidden={collapsed ? true : undefined}
358
460
  className={cn(
359
461
  "flex h-full min-h-0 min-w-0 flex-col bg-og-bg",
@@ -429,8 +531,15 @@ function DockChrome({
429
531
  const active = tabs.find((t) => t.id === current) ?? tabs[0];
430
532
  const tabsetId = useId();
431
533
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
534
+ const tabListRef = useRef<HTMLDivElement | null>(null);
432
535
  const [visitedTabs, setVisitedTabs] = useState<Set<string>>(() => new Set());
536
+ const [tabOverflow, setTabOverflow] = useState({ start: false, end: false });
433
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]);
434
543
 
435
544
  useEffect(() => {
436
545
  if (!activeId) return;
@@ -442,6 +551,55 @@ function DockChrome({
442
551
  });
443
552
  }, [activeId]);
444
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;
602
+
445
603
  const activateTab = (index: number) => {
446
604
  const tab = tabs[index];
447
605
  if (!tab) {
@@ -465,8 +623,8 @@ function DockChrome({
465
623
 
466
624
  return (
467
625
  <>
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">
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">
470
628
  {leading ?? (
471
629
  <span className="hidden truncate px-1 text-og-sm font-semibold text-og-fg max-[1023px]:inline">
472
630
  Workspace
@@ -477,11 +635,19 @@ function DockChrome({
477
635
  grow into or overlap the chrome controls (they stay shrink-0). The
478
636
  scrollbar is hidden to keep the strip calm. On the narrow overlay it
479
637
  owns a full second row, so status/close chrome can never squeeze the
480
- canonical workspace navigation out of view. */}
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. */}
481
641
  <div
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"
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"
483
644
  role="tablist"
484
645
  aria-orientation="horizontal"
646
+ style={{
647
+ maskImage: tabMask,
648
+ WebkitMaskImage: tabMask,
649
+ scrollPaddingInline: "16px",
650
+ }}
485
651
  >
486
652
  {tabs.map((tab, index) => (
487
653
  <button
@@ -504,13 +670,17 @@ function DockChrome({
504
670
  : "text-og-fg-subtle hover:text-og-fg",
505
671
  )}
506
672
  >
507
- <span>{tab.label}</span>
673
+ <span data-contrast-audited>{tab.label}</span>
508
674
  {tab.badge}
509
675
  </button>
510
676
  ))}
511
677
  </div>
512
- <div className="flex min-w-0 shrink-0 items-center justify-self-end">{accessory}</div>
513
- <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>
514
684
  </div>
515
685
  <div className="min-h-0 min-w-0 flex-1 overflow-hidden">
516
686
  {tabs.length > 0 ? (
@@ -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";