@opengeni/react 1.0.1 → 2.2.0-canary.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 (174) hide show
  1. package/README.md +33 -10
  2. package/dist/artifacts-spreadsheet.js +1 -1
  3. package/dist/artifacts.js +1 -1
  4. package/dist/browser-viewer-GXUICIAG.js +14 -0
  5. package/dist/{chunk-R3AZOAB5.js → chunk-4MDLEHXL.js} +9 -115
  6. package/dist/chunk-4MDLEHXL.js.map +1 -0
  7. package/dist/{chunk-Y7OEDUJ5.js → chunk-5RZN3DD5.js} +1014 -420
  8. package/dist/chunk-5RZN3DD5.js.map +1 -0
  9. package/dist/chunk-5YK2ZDIG.js +116 -0
  10. package/dist/chunk-5YK2ZDIG.js.map +1 -0
  11. package/dist/chunk-H5YGYNXS.js +329 -0
  12. package/dist/chunk-H5YGYNXS.js.map +1 -0
  13. package/dist/{chunk-7WCLAEFV.js → chunk-ICA6R6TW.js} +2 -2
  14. package/dist/{chunk-7WCLAEFV.js.map → chunk-ICA6R6TW.js.map} +1 -1
  15. package/dist/{chunk-IGC6A2ZI.js → chunk-JOAVA2TW.js} +589 -217
  16. package/dist/chunk-JOAVA2TW.js.map +1 -0
  17. package/dist/{chunk-5UQPNLIO.js → chunk-LTCHJ4MS.js} +9 -9
  18. package/dist/chunk-LTCHJ4MS.js.map +1 -0
  19. package/dist/{chunk-C5ZPSSS5.js → chunk-NEXLLTP4.js} +2 -1
  20. package/dist/{chunk-C5ZPSSS5.js.map → chunk-NEXLLTP4.js.map} +1 -1
  21. package/dist/chunk-QGBJ6GHG.js +2300 -0
  22. package/dist/chunk-QGBJ6GHG.js.map +1 -0
  23. package/dist/{chunk-B6WPZSTT.js → chunk-UOLJWT5Y.js} +78 -16
  24. package/dist/chunk-UOLJWT5Y.js.map +1 -0
  25. package/dist/{chunk-MKPIAGVL.js → chunk-W6NFJXSA.js} +8 -19
  26. package/dist/chunk-W6NFJXSA.js.map +1 -0
  27. package/dist/{chunk-XBZWOM2K.js → chunk-X4A66EWZ.js} +8 -3
  28. package/dist/chunk-X4A66EWZ.js.map +1 -0
  29. package/dist/chunk-X4JAFD5R.js +3907 -0
  30. package/dist/chunk-X4JAFD5R.js.map +1 -0
  31. package/dist/{chunk-6KLX2RH5.js → chunk-XSA2QAKP.js} +334 -19
  32. package/dist/chunk-XSA2QAKP.js.map +1 -0
  33. package/dist/{chunk-HA46CYQK.js → chunk-XXWH2JLC.js} +792 -206
  34. package/dist/chunk-XXWH2JLC.js.map +1 -0
  35. package/dist/client.d.ts +2 -2
  36. package/dist/components/browser-viewer.d.ts +7 -1
  37. package/dist/components/computer-viewer.d.ts +4 -1
  38. package/dist/components/enrollment-consent.d.ts +6 -3
  39. package/dist/components/file-browser.d.ts +7 -1
  40. package/dist/components/human-input-form.d.ts +1 -1
  41. package/dist/components/human-input-surface.d.ts +1 -1
  42. package/dist/components/machine-card.d.ts +4 -1
  43. package/dist/components/machines/machine-detail.d.ts +4 -1
  44. package/dist/components/machines/machine-operation-policy.d.ts +11 -0
  45. package/dist/components/machines-dashboard.d.ts +3 -1
  46. package/dist/components/markdown.d.ts +20 -1
  47. package/dist/components/message-timeline.d.ts +1 -1
  48. package/dist/components/model-policy-picker.d.ts +2 -0
  49. package/dist/components/pierre-file.d.ts +1 -1
  50. package/dist/components/sandbox-files.d.ts +8 -2
  51. package/dist/components/sandbox-workspace.d.ts +33 -3
  52. package/dist/components/session-chrome.d.ts +15 -3
  53. package/dist/components/workspace-dock.d.ts +6 -2
  54. package/dist/composer.js +5 -5
  55. package/dist/computer-viewer-ZOP4Q7A7.js +16 -0
  56. package/dist/computer-viewer-ZOP4Q7A7.js.map +1 -0
  57. package/dist/hooks/channel-cache.d.ts +23 -0
  58. package/dist/hooks/shared-machines-list.d.ts +14 -0
  59. package/dist/hooks/use-attached-browsers.d.ts +2 -1
  60. package/dist/hooks/use-channels.d.ts +1 -0
  61. package/dist/hooks/use-composer.d.ts +37 -9
  62. package/dist/hooks/use-machine-chip.d.ts +3 -3
  63. package/dist/hooks/use-machines.d.ts +19 -1
  64. package/dist/hooks/use-sandbox-files.d.ts +1 -0
  65. package/dist/hooks/use-session-background-commands.d.ts +14 -0
  66. package/dist/hooks/use-turn-queue.d.ts +12 -0
  67. package/dist/hooks/use-workspace-sessions.d.ts +1 -0
  68. package/dist/index.d.ts +7 -5
  69. package/dist/index.js +1096 -623
  70. package/dist/index.js.map +1 -1
  71. package/dist/interaction.js +29 -181
  72. package/dist/interaction.js.map +1 -1
  73. package/dist/lib/interaction-errors.d.ts +3 -0
  74. package/dist/machines.d.ts +4 -2
  75. package/dist/machines.js +10 -2
  76. package/dist/model-policy.d.ts +1 -1
  77. package/dist/model-policy.js +1 -1
  78. package/dist/platform-activity-row-BJHUHXAT.js +169 -0
  79. package/dist/platform-activity-row-BJHUHXAT.js.map +1 -0
  80. package/dist/{queue-surface-implementation-2KVJTYXH.js → queue-surface-implementation-BSKUWHRI.js} +76 -16
  81. package/dist/queue-surface-implementation-BSKUWHRI.js.map +1 -0
  82. package/dist/realtime/realtime-control.d.ts +6 -3
  83. package/dist/realtime.js +36 -14
  84. package/dist/realtime.js.map +1 -1
  85. package/dist/session-ui.d.ts +1 -1
  86. package/dist/session-ui.js +6 -2
  87. package/dist/session.d.ts +2 -2
  88. package/dist/session.js +4 -4
  89. package/dist/timeline/index.d.ts +1 -1
  90. package/dist/timeline/platform-activity-row.d.ts +15 -0
  91. package/dist/timeline/screenshot-lightbox.d.ts +1 -1
  92. package/dist/timeline/shared.d.ts +4 -2
  93. package/dist/timeline/types.d.ts +48 -5
  94. package/dist/types/machines.d.ts +2 -2
  95. package/package.json +3 -3
  96. package/src/client.ts +12 -1
  97. package/src/components/artifacts/editable-spreadsheet.tsx +1 -1
  98. package/src/components/browser-viewer.tsx +361 -58
  99. package/src/components/chat-composer.tsx +1 -1
  100. package/src/components/composer.tsx +6 -6
  101. package/src/components/computer-viewer.tsx +368 -115
  102. package/src/components/enrollment-consent.tsx +64 -6
  103. package/src/components/file-browser.tsx +117 -2
  104. package/src/components/human-input-form.tsx +91 -55
  105. package/src/components/human-input-surface.tsx +1 -1
  106. package/src/components/machine-card.tsx +111 -0
  107. package/src/components/machine-input-display.ts +15 -0
  108. package/src/components/machines/machine-detail.tsx +47 -0
  109. package/src/components/machines/machine-operation-policy.tsx +179 -0
  110. package/src/components/machines-dashboard.tsx +6 -0
  111. package/src/components/markdown.tsx +174 -20
  112. package/src/components/message-timeline.tsx +162 -17
  113. package/src/components/model-policy-picker.tsx +9 -17
  114. package/src/components/pierre-file.tsx +1 -1
  115. package/src/components/queue-surface-implementation.tsx +82 -16
  116. package/src/components/sandbox-files.tsx +135 -19
  117. package/src/components/sandbox-workspace.tsx +204 -92
  118. package/src/components/session-chrome.tsx +415 -208
  119. package/src/components/session-status.tsx +1 -1
  120. package/src/components/user-message-body.tsx +168 -48
  121. package/src/components/workspace-dock.tsx +141 -175
  122. package/src/hooks/channel-cache.ts +104 -0
  123. package/src/hooks/shared-machines-list.ts +239 -0
  124. package/src/hooks/use-attached-browsers.ts +5 -1
  125. package/src/hooks/use-browser-frame-stream.ts +6 -1
  126. package/src/hooks/use-browser-session.ts +6 -4
  127. package/src/hooks/use-browser-sessions.ts +7 -1
  128. package/src/hooks/use-channels.ts +32 -10
  129. package/src/hooks/use-composer.ts +760 -291
  130. package/src/hooks/use-computer-frame-stream.ts +14 -9
  131. package/src/hooks/use-computer-session.ts +16 -14
  132. package/src/hooks/use-computer-sessions.ts +8 -1
  133. package/src/hooks/use-last-started-turn-policy.ts +1 -1
  134. package/src/hooks/use-machine-chip.ts +23 -6
  135. package/src/hooks/use-machines.ts +104 -6
  136. package/src/hooks/use-sandbox-files.ts +195 -140
  137. package/src/hooks/use-sandbox-git.ts +58 -29
  138. package/src/hooks/use-session-background-commands.ts +56 -0
  139. package/src/hooks/use-session-capabilities.ts +18 -0
  140. package/src/hooks/use-turn-queue.ts +111 -12
  141. package/src/hooks/use-workspace-sessions.ts +5 -1
  142. package/src/index.ts +26 -4
  143. package/src/lib/interaction-errors.ts +18 -0
  144. package/src/machines.ts +9 -1
  145. package/src/model-policy.ts +10 -2
  146. package/src/realtime/realtime-control.tsx +53 -17
  147. package/src/session-context.ts +1 -0
  148. package/src/session-ui.ts +6 -1
  149. package/src/session.ts +9 -1
  150. package/src/timeline/activity-rail.tsx +20 -75
  151. package/src/timeline/index.ts +4 -0
  152. package/src/timeline/platform-activity-row.tsx +201 -0
  153. package/src/timeline/projection.ts +476 -23
  154. package/src/timeline/screenshot-lightbox.tsx +48 -14
  155. package/src/timeline/shared.tsx +8 -2
  156. package/src/timeline/tool-renderers.tsx +104 -6
  157. package/src/timeline/turn-summary.tsx +6 -1
  158. package/src/timeline/types.ts +75 -2
  159. package/src/types/machines.ts +10 -0
  160. package/styles/compiled.css +115 -104
  161. package/dist/chunk-5UQPNLIO.js.map +0 -1
  162. package/dist/chunk-6KLX2RH5.js.map +0 -1
  163. package/dist/chunk-B6WPZSTT.js.map +0 -1
  164. package/dist/chunk-HA46CYQK.js.map +0 -1
  165. package/dist/chunk-IGC6A2ZI.js.map +0 -1
  166. package/dist/chunk-LLOLUHVG.js +0 -5937
  167. package/dist/chunk-LLOLUHVG.js.map +0 -1
  168. package/dist/chunk-MKPIAGVL.js.map +0 -1
  169. package/dist/chunk-R3AZOAB5.js.map +0 -1
  170. package/dist/chunk-XBZWOM2K.js.map +0 -1
  171. package/dist/chunk-Y7OEDUJ5.js.map +0 -1
  172. package/dist/interactive-workbench-viewers-TOHBXJEE.js +0 -15
  173. package/dist/queue-surface-implementation-2KVJTYXH.js.map +0 -1
  174. /package/dist/{interactive-workbench-viewers-TOHBXJEE.js.map → browser-viewer-GXUICIAG.js.map} +0 -0
@@ -21,6 +21,8 @@ import {
21
21
  Separator,
22
22
  useDefaultLayout,
23
23
  usePanelRef,
24
+ type Layout,
25
+ type LayoutChangedMeta,
24
26
  type LayoutStorage,
25
27
  } from "react-resizable-panels";
26
28
  import { cn } from "../lib/cn";
@@ -28,6 +30,10 @@ import { cn } from "../lib/cn";
28
30
  export type WorkspaceTab = {
29
31
  id: string;
30
32
  label: ReactNode;
33
+ /** Optional icon used by the compact activity rail. */
34
+ icon?: ReactNode | undefined;
35
+ /** Accessible name when `label` is not plain text. */
36
+ ariaLabel?: string | undefined;
31
37
  /** Rendered as the active surface. */
32
38
  content: ReactNode;
33
39
  /** A small badge after the label (e.g. dirty count, live pill). */
@@ -44,9 +50,9 @@ export type WorkspaceDockProps = {
44
50
  /** Controlled collapsed state for hosts that expose their own dock toggle. */
45
51
  collapsed?: boolean | undefined;
46
52
  onCollapsedChange?: ((collapsed: boolean) => void) | undefined;
47
- /** Keep the built-in collapse control when the host controls collapsed state. */
53
+ /** Keep the panel-local Hide workspace control when the host controls collapsed state. */
48
54
  showCollapseControl?: boolean | undefined;
49
- /** A status accessory pinned to the right of the tab strip, left of the
55
+ /** A status accessory pinned to the right of the workspace header, left of the
50
56
  * maximize/collapse controls (e.g. the machine-state chip). Renders in both
51
57
  * the docked header and the full-screen overlay header. */
52
58
  headerAccessory?: ReactNode | undefined;
@@ -131,7 +137,7 @@ export function WorkspaceDock({
131
137
  onActiveTabChange,
132
138
  collapsed: collapsedProp,
133
139
  onCollapsedChange,
134
- showCollapseControl = false,
140
+ showCollapseControl = true,
135
141
  headerAccessory,
136
142
  mobileLeadingControl,
137
143
  autoSaveId = "og.session.dock",
@@ -150,11 +156,9 @@ export function WorkspaceDock({
150
156
  const [maximized, setMaximized] = useState(false);
151
157
  const [internalTab, setInternalTab] = useState(tabs[0]?.id ?? "");
152
158
  const collapsed = collapsedProp ?? internalCollapsed;
153
- // When the host supplies `collapsed` it owns the open/close affordance (e.g.
154
- // the app header's panel toggle) the dock must not offer a SECOND
155
- // open/close control (duplicate buttons with near-identical icons read as a
156
- // bug). Standalone (uncontrolled) usage keeps the built-in collapse button
157
- // and the thin re-open rail as its only affordances.
159
+ // The host header remains the stable reopen action. The open panel also owns
160
+ // a local Hide workspace action so a user never has to hunt elsewhere to
161
+ // dismiss it; standalone usage keeps the thin reopen rail as well.
158
162
  const hostControlled = collapsedProp !== undefined;
159
163
  const previousCollapsedRef = useRef(collapsed);
160
164
 
@@ -164,6 +168,25 @@ export function WorkspaceDock({
164
168
  storage: getLayoutStorage(),
165
169
  id: autoSaveId,
166
170
  });
171
+ const expandedDefaultLayout =
172
+ defaultLayout && (defaultLayout.dock ?? 0) > 1 ? defaultLayout : undefined;
173
+ const lastExpandedSizeRef = useRef(expandedDefaultLayout?.dock ?? defaultSize);
174
+ const layoutIdentityRef = useRef(autoSaveId);
175
+ if (layoutIdentityRef.current !== autoSaveId) {
176
+ layoutIdentityRef.current = autoSaveId;
177
+ lastExpandedSizeRef.current = expandedDefaultLayout?.dock ?? defaultSize;
178
+ }
179
+ const persistExpandedLayout = useCallback(
180
+ (layout: Layout, meta: LayoutChangedMeta) => {
181
+ // Both supported panel-library versions report 100/0 when the host
182
+ // collapses the dock. Do not replace the last user-selected width with it.
183
+ const dockSize = layout.dock ?? 0;
184
+ if (dockSize <= 1) return;
185
+ lastExpandedSizeRef.current = dockSize;
186
+ onLayoutChanged(layout, meta);
187
+ },
188
+ [onLayoutChanged],
189
+ );
167
190
 
168
191
  const requestedTab = activeTab ?? internalTab;
169
192
  const tabIds = tabs.map((tab) => tab.id);
@@ -193,7 +216,7 @@ export function WorkspaceDock({
193
216
  dockPanelRef.current?.collapse();
194
217
  setMaximized(false);
195
218
  } else {
196
- dockPanelRef.current?.expand();
219
+ dockPanelRef.current?.resize(`${lastExpandedSizeRef.current}%`);
197
220
  }
198
221
  }, [collapsedProp, dockPanelRef]);
199
222
 
@@ -230,7 +253,18 @@ export function WorkspaceDock({
230
253
  return;
231
254
  }
232
255
  const target = returnFocusRef.current;
233
- if (target?.isConnected) target.focus();
256
+ if (target?.isConnected) {
257
+ target.focus();
258
+ return;
259
+ }
260
+ // A controlled desktop dock can be restored open on the initial render,
261
+ // so there may be no opener to remember. Never leave focus parked on the
262
+ // now-hidden workspace chrome; fall back to the host's primary surface.
263
+ rootRef.current
264
+ ?.querySelector<HTMLElement>(
265
+ '[data-workspace-primary] button:not([disabled]), [data-workspace-primary] a[href], [data-workspace-primary] input:not([disabled]), [data-workspace-primary] textarea:not([disabled]), [data-workspace-primary] select:not([disabled]), [data-workspace-primary] [tabindex]:not([tabindex="-1"])',
266
+ )
267
+ ?.focus();
234
268
  });
235
269
  return () => cancelAnimationFrame(frame);
236
270
  }, [collapsed, hostControlled, narrow]);
@@ -362,7 +396,7 @@ export function WorkspaceDock({
362
396
  leading={mobileLeadingControl}
363
397
  accessory={headerAccessory}
364
398
  controls={
365
- <ChromeButton onClick={collapse} title="Close workspace" label="Close workspace">
399
+ <ChromeButton onClick={collapse} title="Hide workspace" label="Hide workspace">
366
400
  <XIcon className="size-4" />
367
401
  </ChromeButton>
368
402
  }
@@ -405,7 +439,7 @@ export function WorkspaceDock({
405
439
  )}
406
440
  </ChromeButton>
407
441
  {hostControlled && !showCollapseControl ? null : (
408
- <ChromeButton onClick={collapse} title="Collapse" label="Collapse dock">
442
+ <ChromeButton onClick={collapse} title="Hide workspace" label="Hide workspace">
409
443
  <PanelRightCloseIcon className="size-3.5" />
410
444
  </ChromeButton>
411
445
  )}
@@ -419,8 +453,8 @@ export function WorkspaceDock({
419
453
  <Group
420
454
  orientation="horizontal"
421
455
  className="min-h-0 flex-1"
422
- {...(defaultLayout ? { defaultLayout } : {})}
423
- onLayoutChanged={onLayoutChanged}
456
+ {...(expandedDefaultLayout ? { defaultLayout: expandedDefaultLayout } : {})}
457
+ onLayoutChanged={persistExpandedLayout}
424
458
  >
425
459
  <Panel id="primary" minSize="30%" className="min-h-0 min-w-0">
426
460
  <div
@@ -477,7 +511,7 @@ export function WorkspaceDock({
477
511
  </Group>
478
512
 
479
513
  {/* Collapsed rail: the standalone fallback re-open affordance. Hidden
480
- when the host controls collapse — its own toggle is the one way in. */}
514
+ when the host controls collapse — its header remains the stable way in. */}
481
515
  {collapsed && !maximized && !hostControlled && (
482
516
  <button
483
517
  ref={reopenRef}
@@ -541,24 +575,19 @@ function DockChrome({
541
575
  const active = tabs.find((t) => t.id === current) ?? tabs[0];
542
576
  const tabsetId = useId();
543
577
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
544
- const tabListRef = useRef<HTMLDivElement | null>(null);
545
578
  const [visitedTabs, setVisitedTabs] = useState<Set<string>>(() => new Set());
546
- const [tabOverflow, setTabOverflow] = useState({ start: false, end: false });
547
579
  const activeId = active?.id ?? "";
548
- const activeTabIndex = tabs.findIndex((tab) => tab.id === activeId);
549
580
 
550
- // A desktop dock can be much narrower than the viewport after the user drags
551
- // its separator. Viewport breakpoints therefore cannot protect the tab strip:
552
- // the status + controls used to squeeze it to zero width, leaving visible but
553
- // unclickable ARIA tabs. At narrow *dock* widths, give status/controls their
554
- // own row and the horizontally scrollable tabs the complete second row.
581
+ // Dock width, not viewport width, chooses rail density. A narrow split keeps
582
+ // the icon-first activity rail usable without stealing the panel's content
583
+ // width; overlays and maximized views retain readable labels.
555
584
  useDockLayoutEffect(() => {
556
585
  const node = chromeRef.current;
557
586
  if (!node) return;
558
587
  const update = () => {
559
588
  const desktop =
560
589
  typeof window.matchMedia !== "function" || window.matchMedia("(min-width: 1024px)").matches;
561
- const next = desktop && node.getBoundingClientRect().width < 420;
590
+ const next = desktop && node.getBoundingClientRect().width < 440;
562
591
  setCompact((previous) => (previous === next ? previous : next));
563
592
  };
564
593
  update();
@@ -571,11 +600,6 @@ function DockChrome({
571
600
  return () => observer.disconnect();
572
601
  }, []);
573
602
 
574
- const keepActiveTabVisible = useCallback(() => {
575
- const selected = activeTabIndex >= 0 ? tabRefs.current[activeTabIndex] : null;
576
- selected?.scrollIntoView({ block: "nearest", inline: "nearest" });
577
- }, [activeTabIndex]);
578
-
579
603
  useEffect(() => {
580
604
  if (!activeId) return;
581
605
  setVisitedTabs((previous) => {
@@ -586,55 +610,6 @@ function DockChrome({
586
610
  });
587
611
  }, [activeId]);
588
612
 
589
- // A controlled/initial tab can become active without receiving focus (for
590
- // example, a host routing a guarded diff into Files). Native scrolling only
591
- // follows focus, so keep the selected tab inside the horizontally scrollable
592
- // strip even when state — rather than a click or arrow key — selected it.
593
- useDockLayoutEffect(() => {
594
- keepActiveTabVisible();
595
- }, [activeId, keepActiveTabVisible]);
596
-
597
- // Hidden scrollbars keep the chrome quiet, but without an edge cue a clipped
598
- // host label reads like corrupted copy. Fade only the edge(s) with more tabs,
599
- // and recompute after scrolling, resizing, or late font metrics.
600
- useDockLayoutEffect(() => {
601
- const list = tabListRef.current;
602
- if (!list) return;
603
- const update = () => {
604
- const max = Math.max(0, list.scrollWidth - list.clientWidth);
605
- const next = { start: list.scrollLeft > 1, end: list.scrollLeft < max - 1 };
606
- setTabOverflow((previous) =>
607
- previous.start === next.start && previous.end === next.end ? previous : next,
608
- );
609
- };
610
- update();
611
- list.addEventListener("scroll", update, { passive: true });
612
- const observer =
613
- typeof ResizeObserver === "undefined"
614
- ? null
615
- : new ResizeObserver(() => {
616
- keepActiveTabVisible();
617
- update();
618
- });
619
- observer?.observe(list);
620
- for (const tab of tabRefs.current) {
621
- if (tab) observer?.observe(tab);
622
- }
623
- return () => {
624
- list.removeEventListener("scroll", update);
625
- observer?.disconnect();
626
- };
627
- }, [activeId, keepActiveTabVisible, tabs.length]);
628
-
629
- const tabMask =
630
- tabOverflow.start && tabOverflow.end
631
- ? "linear-gradient(to right, transparent 0, black 14px, black calc(100% - 14px), transparent 100%)"
632
- : tabOverflow.start
633
- ? "linear-gradient(to right, transparent 0, black 14px, black 100%)"
634
- : tabOverflow.end
635
- ? "linear-gradient(to right, black 0, black calc(100% - 14px), transparent 100%)"
636
- : undefined;
637
-
638
613
  const activateTab = (index: number) => {
639
614
  const tab = tabs[index];
640
615
  if (!tab) {
@@ -647,8 +622,8 @@ function DockChrome({
647
622
  const onTabKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
648
623
  if (tabs.length === 0) return;
649
624
  let nextIndex: number | null = null;
650
- if (event.key === "ArrowRight") nextIndex = (index + 1) % tabs.length;
651
- else if (event.key === "ArrowLeft") nextIndex = (index - 1 + tabs.length) % tabs.length;
625
+ if (event.key === "ArrowDown") nextIndex = (index + 1) % tabs.length;
626
+ else if (event.key === "ArrowUp") nextIndex = (index - 1 + tabs.length) % tabs.length;
652
627
  else if (event.key === "Home") nextIndex = 0;
653
628
  else if (event.key === "End") nextIndex = tabs.length - 1;
654
629
  if (nextIndex === null) return;
@@ -657,115 +632,106 @@ function DockChrome({
657
632
  };
658
633
 
659
634
  return (
660
- <>
661
- <div
662
- ref={chromeRef}
663
- data-dock-chrome
664
- data-compact={compact ? "true" : undefined}
665
- className={cn(
666
- "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]",
667
- compact && "gap-y-1",
668
- )}
669
- style={compact ? { gridTemplateColumns: "minmax(0, 1fr) auto" } : undefined}
670
- >
671
- <div
672
- className={cn(
673
- "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",
674
- compact && "hidden",
675
- )}
676
- >
635
+ <div
636
+ ref={chromeRef}
637
+ data-dock-chrome
638
+ data-compact={compact ? "true" : undefined}
639
+ className="flex h-full min-h-0 min-w-0 flex-col"
640
+ >
641
+ <div className="flex min-h-10 shrink-0 items-center gap-2 border-b border-og-border px-2 max-[1023px]:min-h-12">
642
+ <div className="flex min-w-0 flex-1 items-center gap-2">
677
643
  {leading ?? (
678
- <span className="hidden truncate px-1 text-og-sm font-semibold text-og-fg max-[1023px]:inline">
644
+ <span className="hidden truncate text-og-sm font-semibold text-og-fg max-[1023px]:inline">
679
645
  Workspace
680
646
  </span>
681
647
  )}
682
648
  </div>
683
- {/* The tab list scrolls horizontally when it can't fit — it must never
684
- grow into or overlap the chrome controls (they stay shrink-0). The
685
- scrollbar is hidden to keep the strip calm. On the narrow overlay it
686
- owns a full second row, so status/close chrome can never squeeze the
687
- canonical workspace navigation out of view. Tablets have enough
688
- width to retain the denser single-row chrome, with this same strip
689
- absorbing any unusually long host-injected labels. */}
649
+ <div className="min-w-0 shrink text-og-fg-muted">{accessory}</div>
650
+ <div className="flex shrink-0 items-center gap-0.5 text-og-fg-subtle">{controls}</div>
651
+ </div>
652
+ <div className="flex min-h-0 min-w-0 flex-1">
690
653
  <div
691
- ref={tabListRef}
692
654
  className={cn(
693
- "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",
694
- compact && "col-span-2 col-start-1 row-start-2 w-full",
655
+ "flex shrink-0 flex-col gap-1 overflow-x-hidden overflow-y-auto border-r border-og-border bg-og-surface-1/45 p-1.5",
656
+ compact ? "w-12" : "w-32 max-[1023px]:w-36",
695
657
  )}
696
658
  role="tablist"
697
- aria-orientation="horizontal"
698
- style={{
699
- maskImage: tabMask,
700
- WebkitMaskImage: tabMask,
701
- scrollPaddingInline: "16px",
702
- }}
703
- >
704
- {tabs.map((tab, index) => (
705
- <button
706
- key={tab.id}
707
- ref={(node) => {
708
- tabRefs.current[index] = node;
709
- }}
710
- id={`${tabsetId}-tab-${index}`}
711
- type="button"
712
- role="tab"
713
- aria-selected={tab.id === current}
714
- aria-controls={`${tabsetId}-panel-${index}`}
715
- tabIndex={tab.id === current ? 0 : -1}
716
- onClick={() => onTab(tab.id)}
717
- onKeyDown={(event) => onTabKeyDown(event, index)}
718
- className={cn(
719
- "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-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",
720
- tab.id === current
721
- ? "bg-og-accent-soft text-og-fg"
722
- : "text-og-fg-subtle hover:text-og-fg",
723
- )}
724
- >
725
- <span data-contrast-audited>{tab.label}</span>
726
- {tab.badge}
727
- </button>
728
- ))}
729
- </div>
730
- <div
731
- className={cn(
732
- "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",
733
- compact && "col-start-1 row-start-1 justify-self-start",
734
- )}
735
- >
736
- {accessory}
737
- </div>
738
- <div
739
- className={cn(
740
- "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",
741
- compact && "col-start-2 row-start-1",
742
- )}
659
+ aria-label="Workspace panels"
660
+ aria-orientation="vertical"
743
661
  >
744
- {controls}
745
- </div>
746
- </div>
747
- <div className="min-h-0 min-w-0 flex-1 overflow-hidden">
748
- {tabs.length > 0 ? (
749
- tabs.map((tab, index) => {
750
- const selected = tab.id === activeId;
751
- const shouldMount = selected || visitedTabs.has(tab.id);
662
+ {tabs.map((tab, index) => {
663
+ const tabName = tab.ariaLabel ?? (typeof tab.label === "string" ? tab.label : tab.id);
752
664
  return (
753
- <div
665
+ <button
754
666
  key={tab.id}
755
- id={`${tabsetId}-panel-${index}`}
756
- aria-labelledby={`${tabsetId}-tab-${index}`}
757
- className="h-full min-h-0 min-w-0 overflow-hidden"
758
- hidden={!selected}
759
- role="tabpanel"
667
+ ref={(node) => {
668
+ tabRefs.current[index] = node;
669
+ }}
670
+ id={`${tabsetId}-tab-${index}`}
671
+ type="button"
672
+ role="tab"
673
+ aria-label={compact && tab.icon ? tabName : undefined}
674
+ title={compact && tab.icon ? tabName : undefined}
675
+ aria-selected={tab.id === current}
676
+ aria-controls={`${tabsetId}-panel-${index}`}
677
+ tabIndex={tab.id === current ? 0 : -1}
678
+ onClick={() => onTab(tab.id)}
679
+ onKeyDown={(event) => onTabKeyDown(event, index)}
680
+ className={cn(
681
+ "group relative flex min-h-9 w-full items-center gap-2 rounded-og-md px-2 text-left text-og-xs font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:min-h-11 pointer-coarse:min-h-11",
682
+ compact && "justify-center px-1",
683
+ tab.id === current
684
+ ? "bg-og-accent-soft text-og-fg shadow-[inset_2px_0_0_var(--og-color-accent)]"
685
+ : "text-og-fg-subtle hover:bg-og-surface-2 hover:text-og-fg",
686
+ )}
760
687
  >
761
- {shouldMount ? tab.content : null}
762
- </div>
688
+ {tab.icon ? (
689
+ <span
690
+ className="flex size-4 shrink-0 items-center justify-center [&>svg]:size-4"
691
+ aria-hidden
692
+ >
693
+ {tab.icon}
694
+ </span>
695
+ ) : null}
696
+ {!compact || !tab.icon ? (
697
+ <span data-contrast-audited className="min-w-0 flex-1 truncate">
698
+ {tab.label}
699
+ </span>
700
+ ) : null}
701
+ {tab.badge ? (
702
+ <span
703
+ className={cn("shrink-0", compact && "absolute -right-0.5 -top-0.5 scale-90")}
704
+ >
705
+ {tab.badge}
706
+ </span>
707
+ ) : null}
708
+ </button>
763
709
  );
764
- })
765
- ) : (
766
- <div className="h-full min-h-0 min-w-0" aria-label="Workspace" role="tabpanel" />
767
- )}
710
+ })}
711
+ </div>
712
+ <div className="min-h-0 min-w-0 flex-1 overflow-hidden">
713
+ {tabs.length > 0 ? (
714
+ tabs.map((tab, index) => {
715
+ const selected = tab.id === activeId;
716
+ const shouldMount = selected || visitedTabs.has(tab.id);
717
+ return (
718
+ <div
719
+ key={tab.id}
720
+ id={`${tabsetId}-panel-${index}`}
721
+ aria-labelledby={`${tabsetId}-tab-${index}`}
722
+ className="h-full min-h-0 min-w-0 overflow-hidden"
723
+ hidden={!selected}
724
+ role="tabpanel"
725
+ >
726
+ {shouldMount ? tab.content : null}
727
+ </div>
728
+ );
729
+ })
730
+ ) : (
731
+ <div className="h-full min-h-0 min-w-0" aria-label="Workspace" role="tabpanel" />
732
+ )}
733
+ </div>
768
734
  </div>
769
- </>
735
+ </div>
770
736
  );
771
737
  }
@@ -0,0 +1,104 @@
1
+ import type { Channel } from "@opengeni/sdk";
2
+ import type { SessionClientLike } from "../client";
3
+
4
+ type PendingChannel = { channel: Channel; committedAfterLoad: number };
5
+
6
+ export type ChannelCacheSnapshot = Readonly<{
7
+ channels: readonly Channel[];
8
+ ready: boolean;
9
+ }>;
10
+
11
+ export type ChannelCacheStore = Readonly<{
12
+ getSnapshot: () => ChannelCacheSnapshot;
13
+ subscribe: (listener: () => void) => () => void;
14
+ beginLoad: () => number;
15
+ acceptLoad: (ticket: number, channels: readonly Channel[]) => void;
16
+ publishCreated: (channel: Channel) => void;
17
+ }>;
18
+
19
+ const EMPTY_CHANNELS: readonly Channel[] = [];
20
+ const EMPTY_SNAPSHOT: ChannelCacheSnapshot = {
21
+ channels: EMPTY_CHANNELS,
22
+ ready: false,
23
+ };
24
+ const channelCaches = new WeakMap<object, Map<string, ChannelCacheStore>>();
25
+
26
+ function compareChannels(left: Channel, right: Channel): number {
27
+ return (
28
+ Number(right.pinned) - Number(left.pinned) ||
29
+ left.sortOrder - right.sortOrder ||
30
+ left.id.localeCompare(right.id)
31
+ );
32
+ }
33
+
34
+ function orderedChannels(channels: readonly Channel[]): Channel[] {
35
+ return [...channels].sort(compareChannels);
36
+ }
37
+
38
+ function upsertChannel(channels: readonly Channel[], channel: Channel): Channel[] {
39
+ const next = channels.filter((candidate) => candidate.id !== channel.id);
40
+ next.push(channel);
41
+ return orderedChannels(next);
42
+ }
43
+
44
+ /**
45
+ * One client/workspace cache shared by every useChannels consumer.
46
+ *
47
+ * List reads are ticketed so an older request cannot erase a newer result.
48
+ * A successful create remains layered over the latest server snapshot until a
49
+ * list request that started after that success reconciles it. Requests already
50
+ * in flight cannot make the project disappear between create and session start.
51
+ */
52
+ export function createChannelCacheStore(): ChannelCacheStore {
53
+ let base: readonly Channel[] | null = null;
54
+ let snapshot = EMPTY_SNAPSHOT;
55
+ let nextLoadTicket = 0;
56
+ let acceptedLoadTicket = 0;
57
+ const pending = new Map<string, PendingChannel>();
58
+ const listeners = new Set<() => void>();
59
+
60
+ const publish = () => {
61
+ let channels = base ?? EMPTY_CHANNELS;
62
+ for (const { channel } of pending.values()) {
63
+ channels = upsertChannel(channels, channel);
64
+ }
65
+ snapshot = { channels, ready: base !== null || pending.size > 0 };
66
+ for (const listener of listeners) listener();
67
+ };
68
+
69
+ return {
70
+ getSnapshot: () => snapshot,
71
+ subscribe: (listener) => {
72
+ listeners.add(listener);
73
+ return () => listeners.delete(listener);
74
+ },
75
+ beginLoad: () => ++nextLoadTicket,
76
+ acceptLoad: (ticket, channels) => {
77
+ if (ticket < acceptedLoadTicket) return;
78
+ acceptedLoadTicket = ticket;
79
+ base = orderedChannels(channels);
80
+ for (const [id, created] of pending) {
81
+ if (ticket > created.committedAfterLoad) pending.delete(id);
82
+ }
83
+ publish();
84
+ },
85
+ publishCreated: (channel) => {
86
+ pending.set(channel.id, { channel, committedAfterLoad: nextLoadTicket });
87
+ publish();
88
+ },
89
+ };
90
+ }
91
+
92
+ export function channelCacheFor(client: SessionClientLike, workspaceId: string): ChannelCacheStore {
93
+ let byWorkspace = channelCaches.get(client);
94
+ if (!byWorkspace) {
95
+ byWorkspace = new Map();
96
+ channelCaches.set(client, byWorkspace);
97
+ }
98
+ let cache = byWorkspace.get(workspaceId);
99
+ if (!cache) {
100
+ cache = createChannelCacheStore();
101
+ byWorkspace.set(workspaceId, cache);
102
+ }
103
+ return cache;
104
+ }