@opengeni/react 0.12.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +20 -14
  2. package/dist/chunk-TOJR776I.js +2280 -0
  3. package/dist/chunk-TOJR776I.js.map +1 -0
  4. package/dist/index.d.ts +1040 -363
  5. package/dist/index.js +10938 -6281
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BeZ3bD3t.d.ts → machines-BpdwuQcD.d.ts} +134 -14
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +23 -1
  10. package/package.json +20 -12
  11. package/src/approvals.ts +16 -4
  12. package/src/client.ts +41 -5
  13. package/src/commands/index.ts +1 -7
  14. package/src/commands/registry.ts +46 -12
  15. package/src/components/chat-composer.tsx +417 -69
  16. package/src/components/code-editor.tsx +28 -36
  17. package/src/components/command-palette.tsx +26 -5
  18. package/src/components/desktop-viewer.tsx +29 -16
  19. package/src/components/diff-view.tsx +27 -189
  20. package/src/components/enrollment-consent.tsx +28 -10
  21. package/src/components/enrollment-device-flow.tsx +8 -5
  22. package/src/components/file-browser.tsx +190 -56
  23. package/src/components/fleet-tile.tsx +13 -4
  24. package/src/components/machine-card.tsx +93 -15
  25. package/src/components/machine-dock-bar.tsx +20 -8
  26. package/src/components/machine-health-pill.tsx +68 -0
  27. package/src/components/machine-metrics.tsx +28 -19
  28. package/src/components/machine-status-pill.tsx +26 -5
  29. package/src/components/machines/health.ts +146 -0
  30. package/src/components/machines/machine-detail.tsx +220 -0
  31. package/src/components/machines/metric-history-chart.tsx +298 -0
  32. package/src/components/machines/metric-sparkline.tsx +76 -0
  33. package/src/components/machines/series.ts +113 -0
  34. package/src/components/machines-dashboard.tsx +21 -3
  35. package/src/components/markdown.tsx +39 -9
  36. package/src/components/message-timeline.tsx +633 -109
  37. package/src/components/pierre-diff.tsx +83 -15
  38. package/src/components/pierre-file.tsx +10 -9
  39. package/src/components/queue-surface.tsx +578 -0
  40. package/src/components/sandbox-files.tsx +193 -225
  41. package/src/components/sandbox-terminal.tsx +256 -88
  42. package/src/components/sandbox-workspace.tsx +957 -0
  43. package/src/components/session-status.tsx +22 -2
  44. package/src/components/workbench-changes.tsx +585 -0
  45. package/src/components/workspace-dock.tsx +213 -59
  46. package/src/hooks/internal.ts +5 -2
  47. package/src/hooks/use-available-models.ts +7 -2
  48. package/src/hooks/use-billing-usage.ts +4 -1
  49. package/src/hooks/use-codex-accounts.ts +74 -44
  50. package/src/hooks/use-composer.ts +414 -63
  51. package/src/hooks/use-environments.ts +92 -47
  52. package/src/hooks/use-file-attachments.ts +101 -55
  53. package/src/hooks/use-goal.ts +52 -16
  54. package/src/hooks/use-machine-chip.ts +134 -0
  55. package/src/hooks/use-machines.ts +34 -16
  56. package/src/hooks/use-packs.ts +24 -19
  57. package/src/hooks/use-relay-frame-stream.ts +5 -2
  58. package/src/hooks/use-rigs.ts +335 -0
  59. package/src/hooks/use-sandbox-files.ts +213 -39
  60. package/src/hooks/use-sandbox-git.ts +188 -11
  61. package/src/hooks/use-sandbox-terminal.ts +18 -14
  62. package/src/hooks/use-scheduled-tasks.ts +10 -2
  63. package/src/hooks/use-session-capabilities.ts +77 -20
  64. package/src/hooks/use-session-control.ts +50 -23
  65. package/src/hooks/use-session-events.ts +50 -21
  66. package/src/hooks/use-session-lineage.ts +128 -0
  67. package/src/hooks/use-session.ts +53 -30
  68. package/src/hooks/use-slash-commands.ts +6 -4
  69. package/src/hooks/use-turn-queue.ts +202 -147
  70. package/src/hooks/use-windowed-sections.ts +204 -0
  71. package/src/hooks/use-workspace-capture.ts +233 -0
  72. package/src/hooks/use-workspace-edit.ts +231 -0
  73. package/src/hooks/use-workspace-sessions.ts +48 -5
  74. package/src/hooks/use-workspaces.ts +18 -15
  75. package/src/index.ts +133 -23
  76. package/src/lib/format.ts +3 -3
  77. package/src/lib/xterm-renderer.ts +65 -0
  78. package/src/lib/xterm-theme.ts +224 -18
  79. package/src/machines.ts +29 -3
  80. package/src/provider.tsx +195 -6
  81. package/src/timeline/activity-rail.tsx +213 -54
  82. package/src/timeline/disclosure-context.tsx +12 -2
  83. package/src/timeline/index.ts +21 -2
  84. package/src/timeline/parsers.ts +87 -16
  85. package/src/timeline/projection.ts +412 -87
  86. package/src/timeline/shared.tsx +111 -24
  87. package/src/timeline/tool-diff.tsx +24 -20
  88. package/src/timeline/tool-renderers.tsx +98 -21
  89. package/src/timeline/turn-summary.tsx +76 -23
  90. package/src/timeline/types.ts +96 -12
  91. package/styles/index.css +22 -0
  92. package/styles/tokens.css +2 -2
  93. package/dist/chunk-5TIXRCSI.js +0 -1291
  94. package/dist/chunk-5TIXRCSI.js.map +0 -1
@@ -1,4 +1,13 @@
1
- import { type ReactNode, useCallback, useEffect, useLayoutEffect, useState } from "react";
1
+ import {
2
+ type KeyboardEvent as ReactKeyboardEvent,
3
+ type ReactNode,
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useLayoutEffect,
8
+ useRef,
9
+ useState,
10
+ } from "react";
2
11
  import {
3
12
  ChevronsLeftRightIcon,
4
13
  Maximize2Icon,
@@ -6,13 +15,7 @@ import {
6
15
  PanelRightCloseIcon,
7
16
  XIcon,
8
17
  } from "lucide-react";
9
- import {
10
- Group,
11
- Panel,
12
- Separator,
13
- useDefaultLayout,
14
- usePanelRef,
15
- } from "react-resizable-panels";
18
+ import { Group, Panel, Separator, useDefaultLayout, usePanelRef } from "react-resizable-panels";
16
19
  import { cn } from "../lib/cn";
17
20
 
18
21
  export type WorkspaceTab = {
@@ -34,6 +37,13 @@ export type WorkspaceDockProps = {
34
37
  /** Controlled collapsed state for hosts that expose their own dock toggle. */
35
38
  collapsed?: boolean | undefined;
36
39
  onCollapsedChange?: ((collapsed: boolean) => void) | undefined;
40
+ /** A status accessory pinned to the right of the tab strip, left of the
41
+ * maximize/collapse controls (e.g. the machine-state chip). Renders in both
42
+ * the docked header and the full-screen overlay header. */
43
+ headerAccessory?: ReactNode | undefined;
44
+ /** Optional host navigation shown at the start of the phone overlay header.
45
+ * It is intentionally absent from desktop dock chrome. */
46
+ mobileLeadingControl?: ReactNode | undefined;
37
47
  /** Persisted layout id (localStorage key) for react-resizable-panels. */
38
48
  autoSaveId?: string | undefined;
39
49
  /** Default dock width as a percent of the session area. */
@@ -94,6 +104,8 @@ export function WorkspaceDock({
94
104
  onActiveTabChange,
95
105
  collapsed: collapsedProp,
96
106
  onCollapsedChange,
107
+ headerAccessory,
108
+ mobileLeadingControl,
97
109
  autoSaveId = "og.session.dock",
98
110
  defaultSize = 34,
99
111
  minSize = 22,
@@ -101,6 +113,9 @@ export function WorkspaceDock({
101
113
  className,
102
114
  }: WorkspaceDockProps) {
103
115
  const narrow = useIsNarrow(DOCK_OVERLAY_BREAKPOINT);
116
+ const rootRef = useRef<HTMLDivElement | null>(null);
117
+ const reopenRef = useRef<HTMLButtonElement | null>(null);
118
+ const returnFocusRef = useRef<HTMLElement | null>(null);
104
119
  const dockPanelRef = usePanelRef();
105
120
  const [internalCollapsed, setInternalCollapsed] = useState(false);
106
121
  const [maximized, setMaximized] = useState(false);
@@ -112,6 +127,7 @@ export function WorkspaceDock({
112
127
  // bug). Standalone (uncontrolled) usage keeps the built-in collapse button
113
128
  // and the thin re-open rail as its only affordances.
114
129
  const hostControlled = collapsedProp !== undefined;
130
+ const previousCollapsedRef = useRef(collapsed);
115
131
 
116
132
  // Persisted layout (width split) keyed by autoSaveId.
117
133
  const { defaultLayout, onLayoutChanged } = useDefaultLayout({
@@ -120,9 +136,11 @@ export function WorkspaceDock({
120
136
  id: autoSaveId,
121
137
  } as Parameters<typeof useDefaultLayout>[0]);
122
138
 
123
- const current = activeTab ?? internalTab;
124
- const tabIds = tabs.map((tab) => tab.id).join("\u0000");
139
+ const requestedTab = activeTab ?? internalTab;
140
+ const tabIds = tabs.map((tab) => tab.id);
125
141
  const firstTabId = tabs[0]?.id ?? "";
142
+ const requestedTabIsValid = tabIds.includes(requestedTab);
143
+ const current = requestedTabIsValid ? requestedTab : firstTabId;
126
144
  const setTab = useCallback(
127
145
  (id: string) => {
128
146
  setInternalTab(id);
@@ -150,14 +168,52 @@ export function WorkspaceDock({
150
168
  }
151
169
  }, [collapsedProp, dockPanelRef]);
152
170
 
171
+ // Treat the narrow overlay like a real modal: remember the external opener,
172
+ // move focus into the selected tab, and restore it on close. The standalone
173
+ // desktop dock instead focuses its newly-visible reopen rail after collapse.
174
+ useEffect(() => {
175
+ const previous = previousCollapsedRef.current;
176
+ previousCollapsedRef.current = collapsed;
177
+ if (previous === collapsed) return;
178
+
179
+ if (!collapsed) {
180
+ const active = document.activeElement;
181
+ const workspaceSurface = rootRef.current?.querySelector<HTMLElement>(
182
+ narrow ? '[role="dialog"]' : "[data-workspace-surface]",
183
+ );
184
+ if (active instanceof HTMLElement && !workspaceSurface?.contains(active)) {
185
+ returnFocusRef.current = active;
186
+ }
187
+ if (narrow) {
188
+ const frame = requestAnimationFrame(() => {
189
+ rootRef.current
190
+ ?.querySelector<HTMLElement>('[role="dialog"] [role="tab"][aria-selected="true"]')
191
+ ?.focus();
192
+ });
193
+ return () => cancelAnimationFrame(frame);
194
+ }
195
+ return;
196
+ }
197
+
198
+ const frame = requestAnimationFrame(() => {
199
+ if (!hostControlled) {
200
+ reopenRef.current?.focus();
201
+ return;
202
+ }
203
+ const target = returnFocusRef.current;
204
+ if (target?.isConnected) target.focus();
205
+ });
206
+ return () => cancelAnimationFrame(frame);
207
+ }, [collapsed, hostControlled, narrow]);
208
+
153
209
  // Keep the active tab valid if the available tabs change.
154
210
  useEffect(() => {
155
- if (firstTabId && !tabs.some((t) => t.id === current)) {
211
+ if (firstTabId && !requestedTabIsValid) {
156
212
  setTab(firstTabId);
157
213
  }
158
214
  // Depend on tab identity, not the tab content objects. Session live events
159
215
  // rebuild tab JSX frequently; only id changes can invalidate the active tab.
160
- }, [tabIds, firstTabId, current, setTab]);
216
+ }, [firstTabId, requestedTabIsValid, setTab]);
161
217
 
162
218
  const collapse = useCallback(() => {
163
219
  dockPanelRef.current?.collapse();
@@ -186,31 +242,45 @@ export function WorkspaceDock({
186
242
  // driven by the same `collapsed` contract (collapsed → hidden).
187
243
  if (narrow) {
188
244
  return (
189
- <div className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
245
+ <div ref={rootRef} className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
190
246
  <div className="min-h-0 min-w-0 flex-1">{primary}</div>
191
- {!collapsed && (
192
- <div
193
- role="dialog"
194
- aria-modal="true"
195
- aria-label="Workspace"
196
- className="fixed inset-0 z-40 flex flex-col bg-og-bg"
197
- style={{
198
- paddingTop: "env(safe-area-inset-top)",
199
- paddingBottom: "env(safe-area-inset-bottom)",
200
- }}
247
+ <div
248
+ role="dialog"
249
+ aria-modal="true"
250
+ aria-label="Workspace"
251
+ className="fixed inset-0 z-40 flex flex-col bg-og-bg"
252
+ hidden={collapsed}
253
+ style={{
254
+ paddingTop: "env(safe-area-inset-top)",
255
+ paddingBottom: "env(safe-area-inset-bottom)",
256
+ }}
257
+ >
258
+ <DockChrome
259
+ tabs={tabs}
260
+ current={current}
261
+ onTab={setTab}
262
+ leading={mobileLeadingControl}
263
+ accessory={headerAccessory}
264
+ controls={
265
+ <ChromeButton onClick={collapse} title="Close workspace" label="Close workspace">
266
+ <XIcon className="size-4" />
267
+ </ChromeButton>
268
+ }
269
+ />
270
+ </div>
271
+ {collapsed && !hostControlled ? (
272
+ <button
273
+ ref={reopenRef}
274
+ type="button"
275
+ onClick={expand}
276
+ title="Open workspace"
277
+ aria-label="Open workspace"
278
+ className="absolute right-3 top-3 z-30 inline-flex size-11 items-center justify-center rounded-og-md border border-og-border bg-og-surface-1 text-og-fg-muted shadow-lg transition-colors hover:border-og-border-strong hover:text-og-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent"
279
+ style={{ marginTop: "env(safe-area-inset-top)" }}
201
280
  >
202
- <DockChrome
203
- tabs={tabs}
204
- current={current}
205
- onTab={setTab}
206
- controls={
207
- <ChromeButton onClick={collapse} title="Close workspace" label="Close workspace">
208
- <XIcon className="size-4" />
209
- </ChromeButton>
210
- }
211
- />
212
- </div>
213
- )}
281
+ <ChevronsLeftRightIcon className="size-4" aria-hidden />
282
+ </button>
283
+ ) : null}
214
284
  </div>
215
285
  );
216
286
  }
@@ -220,6 +290,7 @@ export function WorkspaceDock({
220
290
  tabs={tabs}
221
291
  current={current}
222
292
  onTab={setTab}
293
+ accessory={headerAccessory}
223
294
  controls={
224
295
  <>
225
296
  <ChromeButton
@@ -227,7 +298,11 @@ export function WorkspaceDock({
227
298
  title={maximized ? "Restore (Esc)" : "Maximize"}
228
299
  label={maximized ? "Restore dock" : "Maximize dock"}
229
300
  >
230
- {maximized ? <Minimize2Icon className="size-3.5" /> : <Maximize2Icon className="size-3.5" />}
301
+ {maximized ? (
302
+ <Minimize2Icon className="size-3.5" />
303
+ ) : (
304
+ <Maximize2Icon className="size-3.5" />
305
+ )}
231
306
  </ChromeButton>
232
307
  {hostControlled ? null : (
233
308
  <ChromeButton onClick={collapse} title="Collapse" label="Collapse dock">
@@ -240,7 +315,7 @@ export function WorkspaceDock({
240
315
  );
241
316
 
242
317
  return (
243
- <div className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
318
+ <div ref={rootRef} className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
244
319
  <Group
245
320
  orientation="horizontal"
246
321
  className="min-h-0 flex-1"
@@ -275,13 +350,19 @@ export function WorkspaceDock({
275
350
  }}
276
351
  className="min-h-0 min-w-0"
277
352
  >
278
- {/* Hidden behind the overlay while maximized (avoids double-mounting
279
- the surfaces). */}
280
- {!collapsed && !maximized && (
281
- <div className="flex h-full min-h-0 min-w-0 flex-col border-l border-og-border bg-og-bg">
282
- {dockChrome}
283
- </div>
284
- )}
353
+ {/* One persistent mount across normal, collapsed, and maximized modes:
354
+ layout changes must never destroy an editor buffer or terminal view. */}
355
+ <div
356
+ data-workspace-surface
357
+ aria-hidden={collapsed ? true : undefined}
358
+ className={cn(
359
+ "flex h-full min-h-0 min-w-0 flex-col bg-og-bg",
360
+ maximized ? "fixed inset-0 z-40" : "border-l border-og-border",
361
+ collapsed && "invisible pointer-events-none",
362
+ )}
363
+ >
364
+ {dockChrome}
365
+ </div>
285
366
  </Panel>
286
367
  </Group>
287
368
 
@@ -289,6 +370,7 @@ export function WorkspaceDock({
289
370
  when the host controls collapse — its own toggle is the one way in. */}
290
371
  {collapsed && !maximized && !hostControlled && (
291
372
  <button
373
+ ref={reopenRef}
292
374
  type="button"
293
375
  onClick={expand}
294
376
  title="Open workspace"
@@ -297,13 +379,6 @@ export function WorkspaceDock({
297
379
  <ChevronsLeftRightIcon className="size-3.5" />
298
380
  </button>
299
381
  )}
300
-
301
- {/* Maximize overlay: full-workspace surface above everything. */}
302
- {maximized && (
303
- <div className="fixed inset-0 z-40 flex flex-col bg-og-bg">
304
- {dockChrome}
305
- </div>
306
- )}
307
382
  </div>
308
383
  );
309
384
  }
@@ -326,7 +401,7 @@ function ChromeButton({
326
401
  onClick={onClick}
327
402
  title={title}
328
403
  aria-label={label}
329
- className="inline-flex items-center justify-center rounded-og-sm p-1 transition-colors hover:bg-og-surface-2 hover:text-og-fg pointer-coarse:size-10"
404
+ className="inline-flex size-7 items-center justify-center rounded-og-sm p-1 transition-colors hover:bg-og-surface-2 hover:text-og-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:size-11 pointer-coarse:size-11"
330
405
  >
331
406
  {children}
332
407
  </button>
@@ -337,34 +412,93 @@ function DockChrome({
337
412
  tabs,
338
413
  current,
339
414
  onTab,
415
+ leading,
416
+ accessory,
340
417
  controls,
341
418
  }: {
342
419
  tabs: WorkspaceTab[];
343
420
  current: string;
344
421
  onTab: (id: string) => void;
422
+ /** Host navigation at the start of mobile overlay chrome. */
423
+ leading?: ReactNode | undefined;
424
+ /** A status accessory (machine chip) between the tab strip and the controls. */
425
+ accessory?: ReactNode | undefined;
345
426
  /** Right-aligned chrome controls (maximize / collapse, or the overlay close). */
346
427
  controls: ReactNode;
347
428
  }) {
348
429
  const active = tabs.find((t) => t.id === current) ?? tabs[0];
430
+ const tabsetId = useId();
431
+ const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
432
+ const [visitedTabs, setVisitedTabs] = useState<Set<string>>(() => new Set());
433
+ const activeId = active?.id ?? "";
434
+
435
+ useEffect(() => {
436
+ if (!activeId) return;
437
+ setVisitedTabs((previous) => {
438
+ if (previous.has(activeId)) return previous;
439
+ const next = new Set(previous);
440
+ next.add(activeId);
441
+ return next;
442
+ });
443
+ }, [activeId]);
444
+
445
+ const activateTab = (index: number) => {
446
+ const tab = tabs[index];
447
+ if (!tab) {
448
+ return;
449
+ }
450
+ onTab(tab.id);
451
+ tabRefs.current[index]?.focus();
452
+ };
453
+
454
+ const onTabKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
455
+ if (tabs.length === 0) return;
456
+ let nextIndex: number | null = null;
457
+ if (event.key === "ArrowRight") nextIndex = (index + 1) % tabs.length;
458
+ else if (event.key === "ArrowLeft") nextIndex = (index - 1 + tabs.length) % tabs.length;
459
+ else if (event.key === "Home") nextIndex = 0;
460
+ else if (event.key === "End") nextIndex = tabs.length - 1;
461
+ if (nextIndex === null) return;
462
+ event.preventDefault();
463
+ activateTab(nextIndex);
464
+ };
465
+
349
466
  return (
350
467
  <>
351
- <div className="flex shrink-0 items-center gap-2 border-b border-og-border px-1.5 py-1">
468
+ <div className="grid shrink-0 grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center gap-x-2 border-b border-og-border px-1.5 py-1 max-[1023px]:grid-cols-[auto_minmax(0,1fr)_auto] max-[1023px]:gap-y-0 max-[1023px]:px-2 max-[1023px]:pb-1 max-[1023px]:pt-0">
469
+ <div className="flex min-w-0 shrink-0 items-center max-[1023px]:min-h-11">
470
+ {leading ?? (
471
+ <span className="hidden truncate px-1 text-og-sm font-semibold text-og-fg max-[1023px]:inline">
472
+ Workspace
473
+ </span>
474
+ )}
475
+ </div>
352
476
  {/* The tab list scrolls horizontally when it can't fit — it must never
353
477
  grow into or overlap the chrome controls (they stay shrink-0). The
354
- scrollbar is hidden to keep the strip calm. */}
478
+ scrollbar is hidden to keep the strip calm. On the narrow overlay it
479
+ owns a full second row, so status/close chrome can never squeeze the
480
+ canonical workspace navigation out of view. */}
355
481
  <div
356
- className="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
482
+ className="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden max-[1023px]:col-span-3 max-[1023px]:row-start-2 max-[1023px]:w-full"
357
483
  role="tablist"
484
+ aria-orientation="horizontal"
358
485
  >
359
- {tabs.map((tab) => (
486
+ {tabs.map((tab, index) => (
360
487
  <button
361
488
  key={tab.id}
489
+ ref={(node) => {
490
+ tabRefs.current[index] = node;
491
+ }}
492
+ id={`${tabsetId}-tab-${index}`}
362
493
  type="button"
363
494
  role="tab"
364
495
  aria-selected={tab.id === current}
496
+ aria-controls={`${tabsetId}-panel-${index}`}
497
+ tabIndex={tab.id === current ? 0 : -1}
365
498
  onClick={() => onTab(tab.id)}
499
+ onKeyDown={(event) => onTabKeyDown(event, index)}
366
500
  className={cn(
367
- "flex shrink-0 items-center gap-1 rounded-og-sm px-2 py-1 text-og-xs font-medium transition-colors pointer-coarse:min-h-10",
501
+ "flex min-h-7 shrink-0 items-center justify-center gap-1 rounded-og-sm px-2 py-1 text-og-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:min-h-11 max-[1023px]:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11",
368
502
  tab.id === current
369
503
  ? "bg-og-accent-soft text-og-fg"
370
504
  : "text-og-fg-subtle hover:text-og-fg",
@@ -375,10 +509,30 @@ function DockChrome({
375
509
  </button>
376
510
  ))}
377
511
  </div>
512
+ <div className="flex min-w-0 shrink-0 items-center justify-self-end">{accessory}</div>
378
513
  <div className="flex shrink-0 items-center gap-0.5 text-og-fg-subtle">{controls}</div>
379
514
  </div>
380
- <div className="min-h-0 min-w-0 flex-1 overflow-hidden" role="tabpanel">
381
- {active?.content}
515
+ <div className="min-h-0 min-w-0 flex-1 overflow-hidden">
516
+ {tabs.length > 0 ? (
517
+ tabs.map((tab, index) => {
518
+ const selected = tab.id === activeId;
519
+ const shouldMount = selected || visitedTabs.has(tab.id);
520
+ return (
521
+ <div
522
+ key={tab.id}
523
+ id={`${tabsetId}-panel-${index}`}
524
+ aria-labelledby={`${tabsetId}-tab-${index}`}
525
+ className="h-full min-h-0 min-w-0 overflow-hidden"
526
+ hidden={!selected}
527
+ role="tabpanel"
528
+ >
529
+ {shouldMount ? tab.content : null}
530
+ </div>
531
+ );
532
+ })
533
+ ) : (
534
+ <div className="h-full min-h-0 min-w-0" aria-label="Workspace" role="tabpanel" />
535
+ )}
382
536
  </div>
383
537
  </>
384
538
  );
@@ -13,7 +13,10 @@ export type AsyncListState<T> = {
13
13
  * Shared fetch + optional polling loop for the list/read hooks. Stale
14
14
  * responses (superseded by a newer load or an unmount) are dropped.
15
15
  */
16
- export function usePolledValue<T>(load: () => Promise<T>, options: { pollIntervalMs?: number | undefined; enabled?: boolean | undefined } = {}): AsyncListState<T> {
16
+ export function usePolledValue<T>(
17
+ load: () => Promise<T>,
18
+ options: { pollIntervalMs?: number | undefined; enabled?: boolean | undefined } = {},
19
+ ): AsyncListState<T> {
17
20
  const enabled = options.enabled ?? true;
18
21
  const pollIntervalMs = options.pollIntervalMs;
19
22
  const [data, setData] = useState<T | null>(null);
@@ -95,7 +98,7 @@ export function useMutationRunner(): MutationState & {
95
98
  mounted.current = false;
96
99
  };
97
100
  }, []);
98
- const run = useCallback(async <T,>(operation: () => Promise<T>): Promise<T | null> => {
101
+ const run = useCallback(async <T>(operation: () => Promise<T>): Promise<T | null> => {
99
102
  inFlight.current += 1;
100
103
  if (mounted.current) {
101
104
  setMutating(true);
@@ -25,10 +25,15 @@ export type UseAvailableModelsResult = {
25
25
  * provider-grouped `models` plus the `defaultModel` the picker should preselect.
26
26
  * Deployment-scoped, so it only needs the client (no workspace).
27
27
  */
28
- export function useAvailableModels(options: UseAvailableModelsOptions = {}): UseAvailableModelsResult {
28
+ export function useAvailableModels(
29
+ options: UseAvailableModelsOptions = {},
30
+ ): UseAvailableModelsResult {
29
31
  const client = useOpenGeniClient(options);
30
32
  const load = useCallback(async () => await client.getClientConfig(), [client]);
31
- const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
33
+ const state = usePolledValue(load, {
34
+ pollIntervalMs: options.pollIntervalMs,
35
+ enabled: options.enabled,
36
+ });
32
37
  return {
33
38
  models: state.data?.models ?? [],
34
39
  defaultModel: state.data?.defaultModel ?? null,
@@ -40,7 +40,10 @@ export function useBillingUsage(options: UseBillingUsageOptions = {}): UseBillin
40
40
  }),
41
41
  [client, accountId, workspaceId],
42
42
  );
43
- const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
43
+ const state = usePolledValue(load, {
44
+ pollIntervalMs: options.pollIntervalMs,
45
+ enabled: options.enabled,
46
+ });
44
47
  return {
45
48
  balance: state.data?.balance ?? null,
46
49
  usage: state.data?.usage ?? [],
@@ -1,7 +1,17 @@
1
1
  import { useCallback, useState } from "react";
2
- import type { CodexAccount, CodexAccountsResponse, CodexRotationSettings, SessionEvent } from "@opengeni/sdk";
2
+ import type {
3
+ CodexAccount,
4
+ CodexAccountsResponse,
5
+ CodexRotationSettings,
6
+ SessionEvent,
7
+ } from "@opengeni/sdk";
3
8
  import { useOpenGeni, type ClientOverride } from "../provider";
4
- import { useMutationRunner, usePolledValue, useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
9
+ import {
10
+ useMutationRunner,
11
+ usePolledValue,
12
+ useSessionEventTrigger,
13
+ type SessionEventFeedOptions,
14
+ } from "./internal";
5
15
 
6
16
  /** Events that change which Codex account a session runs on (or just ran). */
7
17
  export function isCodexAccountEvent(event: Pick<SessionEvent, "type">): boolean {
@@ -17,19 +27,27 @@ export function isCodexAccountEvent(event: Pick<SessionEvent, "type">): boolean
17
27
  */
18
28
  export type CodexAccountsClientLike = {
19
29
  listCodexAccounts: (workspaceId: string) => Promise<CodexAccountsResponse>;
20
- getSession?: (workspaceId: string, sessionId: string) => Promise<{ codexPinnedCredentialId?: string | null; codexLastCredentialId?: string | null }>;
21
- pinSessionCodexAccount?: (workspaceId: string, sessionId: string, target: string) => Promise<{ pinned: string }>;
30
+ getSession?: (
31
+ workspaceId: string,
32
+ sessionId: string,
33
+ ) => Promise<{ codexPinnedCredentialId?: string | null; codexLastCredentialId?: string | null }>;
34
+ pinSessionCodexAccount?: (
35
+ workspaceId: string,
36
+ sessionId: string,
37
+ target: string,
38
+ ) => Promise<{ pinned: string }>;
22
39
  /** Optional (absent ⇒ the card hides live refresh): batched live /wham/usage refresh. */
23
40
  refreshCodexUsage?: (workspaceId: string) => Promise<{ usage: Record<string, unknown> }>;
24
41
  };
25
42
 
26
- export type UseCodexAccountsOptions = ClientOverride & SessionEventFeedOptions & {
27
- pollIntervalMs?: number | undefined;
28
- /** Scope to a session so the hook resolves the pin + the effective account. */
29
- sessionId?: string | undefined;
30
- /** Override the client with one implementing `CodexAccountsClientLike`. */
31
- codexClient?: CodexAccountsClientLike | undefined;
32
- };
43
+ export type UseCodexAccountsOptions = ClientOverride &
44
+ SessionEventFeedOptions & {
45
+ pollIntervalMs?: number | undefined;
46
+ /** Scope to a session so the hook resolves the pin + the effective account. */
47
+ sessionId?: string | undefined;
48
+ /** Override the client with one implementing `CodexAccountsClientLike`. */
49
+ codexClient?: CodexAccountsClientLike | undefined;
50
+ };
33
51
 
34
52
  export type UseCodexAccountsResult = {
35
53
  accounts: CodexAccount[];
@@ -60,7 +78,11 @@ export type UseCodexAccountsResult = {
60
78
  mutationError: Error | null;
61
79
  };
62
80
 
63
- const EMPTY_SETTINGS: CodexRotationSettings = { rotationEnabled: false, rotationStrategy: "most_remaining", activeCredentialId: null };
81
+ const EMPTY_SETTINGS: CodexRotationSettings = {
82
+ rotationEnabled: false,
83
+ rotationStrategy: "most_remaining",
84
+ activeCredentialId: null,
85
+ };
64
86
 
65
87
  type CodexAccountsState = {
66
88
  accounts: CodexAccount[];
@@ -87,15 +109,16 @@ const EMPTY_STATE: CodexAccountsState = {
87
109
  */
88
110
  export function useCodexAccounts(options: UseCodexAccountsOptions = {}): UseCodexAccountsResult {
89
111
  const { client, workspaceId } = useOpenGeni(options);
90
- const codexClient = (options.codexClient ?? (client as unknown as CodexAccountsClientLike));
112
+ const codexClient = options.codexClient ?? (client as unknown as CodexAccountsClientLike);
91
113
  const sessionId = options.sessionId;
92
114
  const sharedEvents = options.events;
93
115
 
94
116
  const load = useCallback(async (): Promise<CodexAccountsState> => {
95
117
  const accountsP = codexClient.listCodexAccounts(workspaceId);
96
- const sessionP = sessionId && codexClient.getSession
97
- ? codexClient.getSession(workspaceId, sessionId).catch(() => null)
98
- : Promise.resolve(null);
118
+ const sessionP =
119
+ sessionId && codexClient.getSession
120
+ ? codexClient.getSession(workspaceId, sessionId).catch(() => null)
121
+ : Promise.resolve(null);
99
122
  const [acc, session] = await Promise.all([accountsP, sessionP]);
100
123
  return {
101
124
  accounts: acc.accounts,
@@ -106,9 +129,16 @@ export function useCodexAccounts(options: UseCodexAccountsOptions = {}): UseCode
106
129
  };
107
130
  }, [codexClient, workspaceId, sessionId]);
108
131
 
109
- const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
110
- const mutation = useMutationRunner();
111
- const usageMutation = useMutationRunner();
132
+ const {
133
+ data: loadedData,
134
+ loading,
135
+ refresh,
136
+ } = usePolledValue(load, {
137
+ pollIntervalMs: options.pollIntervalMs,
138
+ enabled: options.enabled,
139
+ });
140
+ const { run: runMutation, mutating: pinning, mutationError } = useMutationRunner();
141
+ const { run: runUsageMutation, mutating: refreshingUsage } = useMutationRunner();
112
142
  const [pinningTarget, setPinningTarget] = useState<string | null>(null);
113
143
 
114
144
  // Live flip: a manual switch (P1) or a failover (P3) emits codex.account.switched;
@@ -118,8 +148,11 @@ export function useCodexAccounts(options: UseCodexAccountsOptions = {}): UseCode
118
148
  workspaceId,
119
149
  sessionId,
120
150
  isCodexAccountEvent,
121
- () => void state.refresh(),
122
- { enabled: options.enabled ?? true, ...(sharedEvents !== undefined ? { events: sharedEvents } : {}) },
151
+ () => void refresh(),
152
+ {
153
+ enabled: options.enabled ?? true,
154
+ ...(sharedEvents !== undefined ? { events: sharedEvents } : {}),
155
+ },
123
156
  );
124
157
 
125
158
  const pin = useCallback(
@@ -128,36 +161,33 @@ export function useCodexAccounts(options: UseCodexAccountsOptions = {}): UseCode
128
161
  return false;
129
162
  }
130
163
  setPinningTarget(target);
131
- const result = await mutation.run(async () => {
164
+ const result = await runMutation(async () => {
132
165
  await codexClient.pinSessionCodexAccount!(workspaceId, sessionId, target);
133
166
  return true;
134
167
  });
135
168
  setPinningTarget(null);
136
- if (result) await state.refresh();
169
+ if (result) await refresh();
137
170
  return result === true;
138
171
  },
139
- [codexClient, workspaceId, sessionId, mutation.run, state.refresh],
172
+ [codexClient, workspaceId, sessionId, runMutation, refresh],
140
173
  );
141
174
 
142
175
  // Live usage refresh: hit the batched provider read, then re-read cached
143
176
  // metadata so the fresh windows land on `accounts`. The provider read writes the
144
177
  // cache columns server-side; state.refresh() pulls them back.
145
- const refreshUsage = useCallback(
146
- async (): Promise<boolean> => {
147
- if (!codexClient.refreshCodexUsage) {
148
- return false;
149
- }
150
- const result = await usageMutation.run(async () => {
151
- await codexClient.refreshCodexUsage!(workspaceId);
152
- return true;
153
- });
154
- if (result) await state.refresh();
155
- return result === true;
156
- },
157
- [codexClient, workspaceId, usageMutation.run, state.refresh],
158
- );
178
+ const refreshUsage = useCallback(async (): Promise<boolean> => {
179
+ if (!codexClient.refreshCodexUsage) {
180
+ return false;
181
+ }
182
+ const result = await runUsageMutation(async () => {
183
+ await codexClient.refreshCodexUsage!(workspaceId);
184
+ return true;
185
+ });
186
+ if (result) await refresh();
187
+ return result === true;
188
+ }, [codexClient, workspaceId, runUsageMutation, refresh]);
159
189
 
160
- const data = state.data ?? EMPTY_STATE;
190
+ const data = loadedData ?? EMPTY_STATE;
161
191
  const effectiveAccountId = data.pinnedAccountId ?? data.activeAccountId;
162
192
 
163
193
  return {
@@ -167,13 +197,13 @@ export function useCodexAccounts(options: UseCodexAccountsOptions = {}): UseCode
167
197
  effectiveAccountId,
168
198
  lastAccountId: data.lastAccountId,
169
199
  settings: data.settings,
170
- loading: state.loading,
171
- refresh: state.refresh,
200
+ loading,
201
+ refresh,
172
202
  refreshUsage,
173
- refreshingUsage: usageMutation.mutating,
203
+ refreshingUsage,
174
204
  pin,
175
- pinning: mutation.mutating,
205
+ pinning,
176
206
  pinningTarget,
177
- mutationError: mutation.mutationError,
207
+ mutationError,
178
208
  };
179
209
  }