@opengeni/react 0.11.0 → 0.13.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 (85) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-5C7RGAWA.js → chunk-NFYVQWIB.js} +170 -64
  3. package/dist/chunk-NFYVQWIB.js.map +1 -0
  4. package/dist/index.d.ts +791 -145
  5. package/dist/index.js +5447 -2213
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BeZ3bD3t.d.ts → machines-CnlMb7E-.d.ts} +5 -5
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +1 -1
  10. package/package.json +17 -12
  11. package/src/approvals.ts +16 -4
  12. package/src/client.ts +32 -5
  13. package/src/commands/index.ts +1 -7
  14. package/src/commands/registry.ts +46 -12
  15. package/src/components/chat-composer.tsx +135 -39
  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 +16 -4
  25. package/src/components/machine-dock-bar.tsx +20 -8
  26. package/src/components/machine-metrics.tsx +31 -8
  27. package/src/components/machine-status-pill.tsx +26 -5
  28. package/src/components/machines-dashboard.tsx +8 -2
  29. package/src/components/markdown.tsx +39 -9
  30. package/src/components/message-timeline.tsx +633 -109
  31. package/src/components/pierre-diff.tsx +83 -15
  32. package/src/components/pierre-file.tsx +10 -9
  33. package/src/components/sandbox-files.tsx +110 -227
  34. package/src/components/sandbox-terminal.tsx +256 -88
  35. package/src/components/sandbox-workspace.tsx +823 -0
  36. package/src/components/session-status.tsx +28 -2
  37. package/src/components/workbench-changes.tsx +541 -0
  38. package/src/components/workspace-dock.tsx +85 -22
  39. package/src/hooks/internal.ts +5 -2
  40. package/src/hooks/use-available-models.ts +7 -2
  41. package/src/hooks/use-billing-usage.ts +4 -1
  42. package/src/hooks/use-codex-accounts.ts +74 -44
  43. package/src/hooks/use-composer.ts +57 -36
  44. package/src/hooks/use-environments.ts +92 -47
  45. package/src/hooks/use-file-attachments.ts +101 -55
  46. package/src/hooks/use-goal.ts +52 -16
  47. package/src/hooks/use-machine-chip.ts +134 -0
  48. package/src/hooks/use-machines.ts +34 -16
  49. package/src/hooks/use-packs.ts +24 -19
  50. package/src/hooks/use-relay-frame-stream.ts +5 -2
  51. package/src/hooks/use-rigs.ts +335 -0
  52. package/src/hooks/use-sandbox-files.ts +213 -39
  53. package/src/hooks/use-sandbox-git.ts +188 -11
  54. package/src/hooks/use-sandbox-terminal.ts +18 -14
  55. package/src/hooks/use-scheduled-tasks.ts +10 -2
  56. package/src/hooks/use-session-capabilities.ts +77 -20
  57. package/src/hooks/use-session-control.ts +49 -21
  58. package/src/hooks/use-session-events.ts +48 -20
  59. package/src/hooks/use-session-lineage.ts +119 -0
  60. package/src/hooks/use-session.ts +43 -28
  61. package/src/hooks/use-slash-commands.ts +6 -4
  62. package/src/hooks/use-turn-queue.ts +74 -147
  63. package/src/hooks/use-windowed-sections.ts +204 -0
  64. package/src/hooks/use-workspace-capture.ts +233 -0
  65. package/src/hooks/use-workspace-edit.ts +231 -0
  66. package/src/hooks/use-workspace-sessions.ts +48 -5
  67. package/src/hooks/use-workspaces.ts +18 -15
  68. package/src/index.ts +128 -23
  69. package/src/lib/format.ts +44 -6
  70. package/src/lib/xterm-renderer.ts +65 -0
  71. package/src/lib/xterm-theme.ts +224 -18
  72. package/src/machines.ts +13 -3
  73. package/src/provider.tsx +3 -1
  74. package/src/timeline/activity-rail.tsx +213 -54
  75. package/src/timeline/disclosure-context.tsx +12 -2
  76. package/src/timeline/index.ts +21 -2
  77. package/src/timeline/parsers.ts +44 -10
  78. package/src/timeline/projection.ts +435 -84
  79. package/src/timeline/shared.tsx +111 -24
  80. package/src/timeline/tool-diff.tsx +24 -20
  81. package/src/timeline/tool-renderers.tsx +98 -21
  82. package/src/timeline/turn-summary.tsx +76 -23
  83. package/src/timeline/types.ts +96 -12
  84. package/styles/tokens.css +2 -2
  85. package/dist/chunk-5C7RGAWA.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,
@@ -120,9 +132,11 @@ export function WorkspaceDock({
120
132
  id: autoSaveId,
121
133
  } as Parameters<typeof useDefaultLayout>[0]);
122
134
 
123
- const current = activeTab ?? internalTab;
124
- const tabIds = tabs.map((tab) => tab.id).join("\u0000");
135
+ const requestedTab = activeTab ?? internalTab;
136
+ const tabIds = tabs.map((tab) => tab.id);
125
137
  const firstTabId = tabs[0]?.id ?? "";
138
+ const requestedTabIsValid = tabIds.includes(requestedTab);
139
+ const current = requestedTabIsValid ? requestedTab : firstTabId;
126
140
  const setTab = useCallback(
127
141
  (id: string) => {
128
142
  setInternalTab(id);
@@ -152,12 +166,12 @@ export function WorkspaceDock({
152
166
 
153
167
  // Keep the active tab valid if the available tabs change.
154
168
  useEffect(() => {
155
- if (firstTabId && !tabs.some((t) => t.id === current)) {
169
+ if (firstTabId && !requestedTabIsValid) {
156
170
  setTab(firstTabId);
157
171
  }
158
172
  // Depend on tab identity, not the tab content objects. Session live events
159
173
  // rebuild tab JSX frequently; only id changes can invalidate the active tab.
160
- }, [tabIds, firstTabId, current, setTab]);
174
+ }, [firstTabId, requestedTabIsValid, setTab]);
161
175
 
162
176
  const collapse = useCallback(() => {
163
177
  dockPanelRef.current?.collapse();
@@ -203,6 +217,8 @@ export function WorkspaceDock({
203
217
  tabs={tabs}
204
218
  current={current}
205
219
  onTab={setTab}
220
+ leading={mobileLeadingControl}
221
+ accessory={headerAccessory}
206
222
  controls={
207
223
  <ChromeButton onClick={collapse} title="Close workspace" label="Close workspace">
208
224
  <XIcon className="size-4" />
@@ -220,6 +236,7 @@ export function WorkspaceDock({
220
236
  tabs={tabs}
221
237
  current={current}
222
238
  onTab={setTab}
239
+ accessory={headerAccessory}
223
240
  controls={
224
241
  <>
225
242
  <ChromeButton
@@ -227,7 +244,11 @@ export function WorkspaceDock({
227
244
  title={maximized ? "Restore (Esc)" : "Maximize"}
228
245
  label={maximized ? "Restore dock" : "Maximize dock"}
229
246
  >
230
- {maximized ? <Minimize2Icon className="size-3.5" /> : <Maximize2Icon className="size-3.5" />}
247
+ {maximized ? (
248
+ <Minimize2Icon className="size-3.5" />
249
+ ) : (
250
+ <Maximize2Icon className="size-3.5" />
251
+ )}
231
252
  </ChromeButton>
232
253
  {hostControlled ? null : (
233
254
  <ChromeButton onClick={collapse} title="Collapse" label="Collapse dock">
@@ -299,11 +320,7 @@ export function WorkspaceDock({
299
320
  )}
300
321
 
301
322
  {/* 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
- )}
323
+ {maximized && <div className="fixed inset-0 z-40 flex flex-col bg-og-bg">{dockChrome}</div>}
307
324
  </div>
308
325
  );
309
326
  }
@@ -326,7 +343,7 @@ function ChromeButton({
326
343
  onClick={onClick}
327
344
  title={title}
328
345
  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"
346
+ className="inline-flex size-7 items-center justify-center rounded-og-sm p-1 transition-colors hover:bg-og-surface-2 hover:text-og-fg max-[1023px]:size-11 pointer-coarse:size-11"
330
347
  >
331
348
  {children}
332
349
  </button>
@@ -337,34 +354,73 @@ function DockChrome({
337
354
  tabs,
338
355
  current,
339
356
  onTab,
357
+ leading,
358
+ accessory,
340
359
  controls,
341
360
  }: {
342
361
  tabs: WorkspaceTab[];
343
362
  current: string;
344
363
  onTab: (id: string) => void;
364
+ /** Host navigation at the start of mobile overlay chrome. */
365
+ leading?: ReactNode | undefined;
366
+ /** A status accessory (machine chip) between the tab strip and the controls. */
367
+ accessory?: ReactNode | undefined;
345
368
  /** Right-aligned chrome controls (maximize / collapse, or the overlay close). */
346
369
  controls: ReactNode;
347
370
  }) {
348
371
  const active = tabs.find((t) => t.id === current) ?? tabs[0];
372
+ const activeIndex = tabs.findIndex((tab) => tab.id === active?.id);
373
+ const tabsetId = useId();
374
+ const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
375
+ const panelId = `${tabsetId}-panel`;
376
+
377
+ const activateTab = (index: number) => {
378
+ const tab = tabs[index];
379
+ if (!tab) return;
380
+ onTab(tab.id);
381
+ tabRefs.current[index]?.focus();
382
+ };
383
+
384
+ const onTabKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
385
+ if (tabs.length === 0) return;
386
+ let nextIndex: number | null = null;
387
+ if (event.key === "ArrowRight") nextIndex = (index + 1) % tabs.length;
388
+ else if (event.key === "ArrowLeft") nextIndex = (index - 1 + tabs.length) % tabs.length;
389
+ else if (event.key === "Home") nextIndex = 0;
390
+ else if (event.key === "End") nextIndex = tabs.length - 1;
391
+ if (nextIndex === null) return;
392
+ event.preventDefault();
393
+ activateTab(nextIndex);
394
+ };
395
+
349
396
  return (
350
397
  <>
351
398
  <div className="flex shrink-0 items-center gap-2 border-b border-og-border px-1.5 py-1">
399
+ {leading ? <div className="flex shrink-0 items-center">{leading}</div> : null}
352
400
  {/* The tab list scrolls horizontally when it can't fit — it must never
353
401
  grow into or overlap the chrome controls (they stay shrink-0). The
354
402
  scrollbar is hidden to keep the strip calm. */}
355
403
  <div
356
404
  className="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
357
405
  role="tablist"
406
+ aria-orientation="horizontal"
358
407
  >
359
- {tabs.map((tab) => (
408
+ {tabs.map((tab, index) => (
360
409
  <button
361
410
  key={tab.id}
411
+ ref={(node) => {
412
+ tabRefs.current[index] = node;
413
+ }}
414
+ id={`${tabsetId}-tab-${index}`}
362
415
  type="button"
363
416
  role="tab"
364
417
  aria-selected={tab.id === current}
418
+ aria-controls={panelId}
419
+ tabIndex={tab.id === current ? 0 : -1}
365
420
  onClick={() => onTab(tab.id)}
421
+ onKeyDown={(event) => onTabKeyDown(event, index)}
366
422
  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",
423
+ "flex min-h-7 shrink-0 items-center gap-1 rounded-og-sm px-2 py-1 text-og-xs font-medium transition-colors max-[1023px]:min-h-11 max-[1023px]:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11",
368
424
  tab.id === current
369
425
  ? "bg-og-accent-soft text-og-fg"
370
426
  : "text-og-fg-subtle hover:text-og-fg",
@@ -375,9 +431,16 @@ function DockChrome({
375
431
  </button>
376
432
  ))}
377
433
  </div>
434
+ {accessory ? <div className="flex shrink-0 items-center">{accessory}</div> : null}
378
435
  <div className="flex shrink-0 items-center gap-0.5 text-og-fg-subtle">{controls}</div>
379
436
  </div>
380
- <div className="min-h-0 min-w-0 flex-1 overflow-hidden" role="tabpanel">
437
+ <div
438
+ id={panelId}
439
+ aria-label={activeIndex < 0 ? "Workspace" : undefined}
440
+ aria-labelledby={activeIndex >= 0 ? `${tabsetId}-tab-${activeIndex}` : undefined}
441
+ className="min-h-0 min-w-0 flex-1 overflow-hidden"
442
+ role="tabpanel"
443
+ >
381
444
  {active?.content}
382
445
  </div>
383
446
  </>
@@ -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
  }
@@ -5,9 +5,8 @@ import { useOpenGeni, type ClientOverride } from "../provider";
5
5
  export type ComposerSendExtras = Omit<SendMessageInput, "text" | "clientEventId">;
6
6
 
7
7
  /**
8
- * Compose-time delivery choice. `queue` (the default) stacks the message
9
- * behind the running turn — visible, editable, reorderable until claimed.
10
- * `steer` interrupts the running turn and injects the message now.
8
+ * Enter appends a prompt; Cmd/Ctrl+Enter steers it to the head and supersedes
9
+ * the current inference.
11
10
  */
12
11
  export type ComposerMode = "queue" | "steer";
13
12
 
@@ -20,46 +19,42 @@ export type UseComposerOptions = ClientOverride & {
20
19
  * surrounding UI state (attachment pickers, model selectors, ...).
21
20
  */
22
21
  sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined;
23
- /** Initial delivery mode. Defaults to `"queue"`. */
24
- defaultMode?: ComposerMode | undefined;
25
22
  };
26
23
 
27
24
  export type ComposerState = {
28
25
  value: string;
29
26
  setValue: (value: string) => void;
30
- /** Send the draft (or an explicit text) using the current mode. */
31
- send: (text?: string) => Promise<boolean>;
27
+ /** Send the draft (or explicit text). Queue is the default; steer is explicit. */
28
+ send: (text?: string, mode?: ComposerMode) => Promise<boolean>;
32
29
  sending: boolean;
33
30
  canSend: boolean;
34
- /** Queue (default) vs steer the compose-time delivery choice. */
35
- mode: ComposerMode;
36
- setMode: (mode: ComposerMode) => void;
37
- /** Ask the agent to stop the current turn. */
38
- interrupt: (reason?: string) => Promise<void>;
39
- interrupting: boolean;
31
+ /** Pause the session without deleting its prompt queue. */
32
+ pause: (reason?: string) => Promise<void>;
33
+ pausing: boolean;
34
+ resume: (reason?: string) => Promise<void>;
35
+ resuming: boolean;
40
36
  error: Error | null;
41
37
  clearError: () => void;
42
38
  };
43
39
 
44
40
  /**
45
- * Draft + send + interrupt state for the chat composer — the only
41
+ * Draft + send + Pause/Resume state for the chat composer — the only
46
42
  * human-to-agent input surface. The draft survives a failed send (nothing is
47
43
  * more hostile than losing a typed message); each send carries a generated
48
44
  * `clientEventId` so retries stay idempotent server-side.
49
45
  */
50
- export function useComposer(sessionId: string | null | undefined, options: UseComposerOptions = {}): ComposerState {
46
+ export function useComposer(
47
+ sessionId: string | null | undefined,
48
+ options: UseComposerOptions = {},
49
+ ): ComposerState {
51
50
  const { client, workspaceId } = useOpenGeni(options);
52
- const defaultMode = options.defaultMode ?? "queue";
53
51
  const [value, setValue] = useState("");
54
- const [mode, setMode] = useState<ComposerMode>(defaultMode);
55
52
  const [sending, setSending] = useState(false);
56
- const [interrupting, setInterrupting] = useState(false);
53
+ const [pausing, setPausing] = useState(false);
54
+ const [resuming, setResuming] = useState(false);
57
55
  const [error, setError] = useState<Error | null>(null);
58
56
  const pendingClientEventId = useRef<string | null>(null);
59
57
  const onSent = options.onSent;
60
- // Read at send time so an in-flight send uses the mode chosen at submit.
61
- const modeRef = useRef(mode);
62
- modeRef.current = mode;
63
58
  // Read through a ref so a new extras closure (created every render by
64
59
  // callers passing inline functions) does not invalidate `send`.
65
60
  const sendExtrasRef = useRef(options.sendExtras);
@@ -75,12 +70,11 @@ export function useComposer(sessionId: string | null | undefined, options: UseCo
75
70
  pendingClientEventId.current = null;
76
71
  setValue("");
77
72
  setError(null);
78
- setMode(defaultMode);
79
73
  }
80
- }, [targetKey, defaultMode]);
74
+ }, [targetKey]);
81
75
 
82
76
  const send = useCallback(
83
- async (explicit?: string): Promise<boolean> => {
77
+ async (explicit?: string, delivery: ComposerMode = "queue"): Promise<boolean> => {
84
78
  const draftAtSend = value;
85
79
  const text = (explicit ?? draftAtSend).trim();
86
80
  // Resolve the extras once: a file-only message (empty text + ≥1 ready
@@ -101,7 +95,7 @@ export function useComposer(sessionId: string | null | undefined, options: UseCo
101
95
  // carries a minimal default so the attachments still get delivered.
102
96
  const sendText = text || FILE_ONLY_MESSAGE_TEXT;
103
97
  const input = composeSendInput(sendText, pendingClientEventId.current, extras);
104
- if (modeRef.current === "steer") {
98
+ if (delivery === "steer") {
105
99
  await client.steerMessage(workspaceId, sessionId, input);
106
100
  } else {
107
101
  await client.sendMessage(workspaceId, sessionId, input);
@@ -131,22 +125,38 @@ export function useComposer(sessionId: string | null | undefined, options: UseCo
131
125
  // on its `attachments.uploading` flag so a message never departs mid-upload.
132
126
  const hasReadyResources = (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
133
127
 
134
- const interrupt = useCallback(
128
+ const pause = useCallback(
135
129
  async (reason?: string): Promise<void> => {
136
- if (!sessionId || interrupting) {
130
+ if (!sessionId || pausing) {
137
131
  return;
138
132
  }
139
- setInterrupting(true);
133
+ setPausing(true);
140
134
  setError(null);
141
135
  try {
142
- await client.interrupt(workspaceId, sessionId, reason !== undefined ? { reason } : {});
136
+ await client.pauseSession(workspaceId, sessionId, reason !== undefined ? { reason } : {});
143
137
  } catch (cause) {
144
138
  setError(cause instanceof Error ? cause : new Error(String(cause)));
145
139
  } finally {
146
- setInterrupting(false);
140
+ setPausing(false);
147
141
  }
148
142
  },
149
- [client, workspaceId, sessionId, interrupting],
143
+ [client, workspaceId, sessionId, pausing],
144
+ );
145
+
146
+ const resume = useCallback(
147
+ async (reason?: string): Promise<void> => {
148
+ if (!sessionId || resuming) return;
149
+ setResuming(true);
150
+ setError(null);
151
+ try {
152
+ await client.resumeSession(workspaceId, sessionId, reason !== undefined ? { reason } : {});
153
+ } catch (cause) {
154
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
155
+ } finally {
156
+ setResuming(false);
157
+ }
158
+ },
159
+ [client, workspaceId, sessionId, resuming],
150
160
  );
151
161
 
152
162
  const updateValue = useCallback((next: string) => {
@@ -160,10 +170,10 @@ export function useComposer(sessionId: string | null | undefined, options: UseCo
160
170
  send,
161
171
  sending,
162
172
  canSend: Boolean(sessionId) && !sending && (value.trim().length > 0 || hasReadyResources),
163
- mode,
164
- setMode,
165
- interrupt,
166
- interrupting,
173
+ pause,
174
+ pausing,
175
+ resume,
176
+ resuming,
167
177
  error,
168
178
  clearError: useCallback(() => setError(null), []),
169
179
  };
@@ -197,13 +207,24 @@ export function composeSendInput(
197
207
  }
198
208
 
199
209
  /** Submit on plain Enter; Shift+Enter inserts a newline. Exported for tests. */
200
- export function shouldSubmitOnKey(event: { key: string; shiftKey: boolean; nativeEvent?: { isComposing?: boolean } }): boolean {
210
+ export function shouldSubmitOnKey(event: {
211
+ key: string;
212
+ shiftKey: boolean;
213
+ metaKey?: boolean;
214
+ ctrlKey?: boolean;
215
+ nativeEvent?: { isComposing?: boolean };
216
+ }): boolean {
201
217
  if (event.key !== "Enter" || event.shiftKey) {
202
218
  return false;
203
219
  }
204
220
  return event.nativeEvent?.isComposing !== true;
205
221
  }
206
222
 
223
+ /** Cmd/Ctrl+Enter steers; ordinary Enter appends to the queue. */
224
+ export function composerModeForKey(event: { metaKey?: boolean; ctrlKey?: boolean }): ComposerMode {
225
+ return event.metaKey || event.ctrlKey ? "steer" : "queue";
226
+ }
227
+
207
228
  function generateClientEventId(): string {
208
229
  const cryptoApi = globalThis.crypto;
209
230
  if (cryptoApi && "randomUUID" in cryptoApi) {