@opengeni/react 0.45.0 → 0.48.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 (141) hide show
  1. package/README.md +68 -12
  2. package/dist/{chunk-U3CSCMWA.js → chunk-FK6VG4LL.js} +9 -13
  3. package/dist/chunk-FK6VG4LL.js.map +1 -0
  4. package/dist/{chunk-HFO4ERGQ.js → chunk-FWXGSNXT.js} +2 -2
  5. package/dist/{chunk-SRFUT2ZU.js → chunk-I4MPFDRP.js} +119 -63
  6. package/dist/chunk-I4MPFDRP.js.map +1 -0
  7. package/dist/{chunk-EB67J347.js → chunk-KZ73RL76.js} +111 -31
  8. package/dist/chunk-KZ73RL76.js.map +1 -0
  9. package/dist/{chunk-LWR4MXSS.js → chunk-LHLYWG5R.js} +10 -1
  10. package/dist/chunk-LHLYWG5R.js.map +1 -0
  11. package/dist/{chunk-L3EWO3UK.js → chunk-NJ7M2B6Z.js} +445 -84
  12. package/dist/chunk-NJ7M2B6Z.js.map +1 -0
  13. package/dist/{chunk-VCBFUSUZ.js → chunk-O4VXOBGF.js} +3 -3
  14. package/dist/{chunk-VCBFUSUZ.js.map → chunk-O4VXOBGF.js.map} +1 -1
  15. package/dist/{chunk-6EDV6YFE.js → chunk-OL6QVXGF.js} +121 -29
  16. package/dist/chunk-OL6QVXGF.js.map +1 -0
  17. package/dist/chunk-VZTQ73XT.js +86 -0
  18. package/dist/chunk-VZTQ73XT.js.map +1 -0
  19. package/dist/{chunk-EHSYZ4KP.js → chunk-WYA65Y3F.js} +16 -16
  20. package/dist/chunk-WYA65Y3F.js.map +1 -0
  21. package/dist/{chunk-FD72GJMK.js → chunk-XCNTXWBB.js} +274 -125
  22. package/dist/chunk-XCNTXWBB.js.map +1 -0
  23. package/dist/client.d.ts +1 -1
  24. package/dist/components/chat-composer.d.ts +7 -2
  25. package/dist/components/composer-transcription-control.d.ts +2 -0
  26. package/dist/components/composer.d.ts +14 -0
  27. package/dist/components/machines/machine-detail.d.ts +3 -1
  28. package/dist/components/machines-dashboard.d.ts +3 -3
  29. package/dist/components/message-timeline.d.ts +12 -2
  30. package/dist/components/sandbox-files.d.ts +2 -2
  31. package/dist/components/sandbox-terminal.d.ts +29 -0
  32. package/dist/components/sandbox-workspace.d.ts +5 -1
  33. package/dist/components/workspace-dock.d.ts +3 -1
  34. package/dist/composer.d.ts +2 -2
  35. package/dist/composer.js +6 -6
  36. package/dist/hooks/internal.d.ts +6 -0
  37. package/dist/hooks/use-codex-accounts.d.ts +1 -1
  38. package/dist/hooks/use-environments.d.ts +10 -4
  39. package/dist/hooks/use-machines.d.ts +7 -0
  40. package/dist/hooks/use-sandbox-files.d.ts +19 -0
  41. package/dist/hooks/use-sandbox-git.d.ts +7 -0
  42. package/dist/hooks/use-transcription.d.ts +2 -2
  43. package/dist/hooks/use-voice-input.d.ts +13 -4
  44. package/dist/index.d.ts +6 -5
  45. package/dist/index.js +904 -538
  46. package/dist/index.js.map +1 -1
  47. package/dist/lib/composer-responsive-context.d.ts +9 -0
  48. package/dist/lib/use-portal-token-style.d.ts +14 -3
  49. package/dist/machines.js +2 -2
  50. package/dist/{queue-surface-implementation-J4XJFKRW.js → queue-surface-implementation-U5JFG7HT.js} +19 -18
  51. package/dist/queue-surface-implementation-U5JFG7HT.js.map +1 -0
  52. package/dist/realtime/realtime-control.d.ts +9 -0
  53. package/dist/realtime.js +29 -32
  54. package/dist/realtime.js.map +1 -1
  55. package/dist/session-ui.js +5 -5
  56. package/dist/session.js +4 -4
  57. package/dist/timeline/activity-rail.d.ts +3 -2
  58. package/dist/timeline/index.d.ts +2 -2
  59. package/dist/timeline/parsers.d.ts +3 -3
  60. package/dist/timeline/registry.d.ts +3 -0
  61. package/dist/timeline/screenshot-lightbox.d.ts +1 -1
  62. package/dist/timeline/types.d.ts +4 -0
  63. package/dist/voice-recording-store.d.ts +8 -2
  64. package/package.json +20 -4
  65. package/src/client.ts +3 -0
  66. package/src/components/chat-composer.tsx +12 -2
  67. package/src/components/code-editor.tsx +1 -1
  68. package/src/components/command-palette.tsx +1 -1
  69. package/src/components/composer-transcription-control.tsx +41 -16
  70. package/src/components/composer.tsx +52 -26
  71. package/src/components/copy-button.tsx +2 -2
  72. package/src/components/desktop-viewer.tsx +2 -2
  73. package/src/components/file-browser.tsx +39 -17
  74. package/src/components/human-input-form.tsx +2 -2
  75. package/src/components/machine-card.tsx +1 -1
  76. package/src/components/machines/machine-detail.tsx +39 -15
  77. package/src/components/machines-dashboard.tsx +7 -6
  78. package/src/components/message-timeline.tsx +44 -11
  79. package/src/components/model-picker.tsx +2 -2
  80. package/src/components/model-policy-picker.tsx +16 -15
  81. package/src/components/queue-surface-implementation.tsx +20 -16
  82. package/src/components/queue-surface-state.tsx +2 -2
  83. package/src/components/sandbox-files.tsx +140 -60
  84. package/src/components/sandbox-terminal.tsx +24 -5
  85. package/src/components/sandbox-workspace.tsx +180 -142
  86. package/src/components/session-chrome.tsx +14 -14
  87. package/src/components/tooltip.tsx +13 -16
  88. package/src/components/user-message-body.tsx +1 -1
  89. package/src/components/workbench-changes.tsx +4 -4
  90. package/src/components/workspace-dock.tsx +13 -5
  91. package/src/composer.ts +3 -0
  92. package/src/hooks/internal.ts +132 -38
  93. package/src/hooks/use-codex-accounts.ts +4 -4
  94. package/src/hooks/use-environments.ts +24 -5
  95. package/src/hooks/use-file-attachments.ts +70 -32
  96. package/src/hooks/use-goal.ts +17 -5
  97. package/src/hooks/use-machines.ts +47 -1
  98. package/src/hooks/use-sandbox-files.ts +310 -59
  99. package/src/hooks/use-sandbox-git.ts +148 -41
  100. package/src/hooks/use-session-capabilities.ts +35 -24
  101. package/src/hooks/use-session-events.ts +18 -4
  102. package/src/hooks/use-transcription.ts +4 -14
  103. package/src/hooks/use-turn-queue.ts +17 -5
  104. package/src/hooks/use-voice-input.ts +481 -36
  105. package/src/hooks/use-windowed-sections.ts +9 -7
  106. package/src/index.ts +11 -6
  107. package/src/lib/composer-responsive-context.ts +15 -0
  108. package/src/lib/use-portal-token-style.ts +60 -15
  109. package/src/provider.tsx +52 -7
  110. package/src/realtime/dropdown-menu.tsx +15 -19
  111. package/src/realtime/realtime-control.tsx +18 -7
  112. package/src/timeline/activity-rail.tsx +15 -6
  113. package/src/timeline/index.ts +1 -1
  114. package/src/timeline/parsers.ts +33 -20
  115. package/src/timeline/projection.ts +23 -0
  116. package/src/timeline/registry.ts +7 -0
  117. package/src/timeline/screenshot-lightbox.tsx +15 -4
  118. package/src/timeline/shared.tsx +8 -8
  119. package/src/timeline/tool-renderers.tsx +166 -22
  120. package/src/timeline/turn-summary.tsx +6 -6
  121. package/src/timeline/types.ts +4 -0
  122. package/src/voice-recording-store.ts +15 -0
  123. package/styles/compiled.css +5065 -0
  124. package/styles/compiled.d.ts +2 -0
  125. package/styles/effective-tokens.css +27 -0
  126. package/styles/index.css +3 -0
  127. package/styles/responsive.css +196 -0
  128. package/styles/responsive.d.ts +2 -0
  129. package/styles/tokens.css +50 -17
  130. package/dist/chunk-6EDV6YFE.js.map +0 -1
  131. package/dist/chunk-EB67J347.js.map +0 -1
  132. package/dist/chunk-EHSYZ4KP.js.map +0 -1
  133. package/dist/chunk-FD72GJMK.js.map +0 -1
  134. package/dist/chunk-JB5725BI.js +0 -48
  135. package/dist/chunk-JB5725BI.js.map +0 -1
  136. package/dist/chunk-L3EWO3UK.js.map +0 -1
  137. package/dist/chunk-LWR4MXSS.js.map +0 -1
  138. package/dist/chunk-SRFUT2ZU.js.map +0 -1
  139. package/dist/chunk-U3CSCMWA.js.map +0 -1
  140. package/dist/queue-surface-implementation-J4XJFKRW.js.map +0 -1
  141. /package/dist/{chunk-HFO4ERGQ.js.map → chunk-FWXGSNXT.js.map} +0 -0
@@ -108,16 +108,18 @@ export function useWindowedSections(opts: {
108
108
  Array.from({ length: count }, (_, i) => estimateHeight(i)),
109
109
  );
110
110
 
111
- // Re-seed heights when the section count changes (a new diff arrives), keeping
112
- // any already-measured heights for surviving indices.
111
+ // Re-seed when the estimate function changes as well as when the count changes.
112
+ // A replacement diff can contain the same number of files but radically
113
+ // different section sizes; retaining measurements by index would then place
114
+ // every unvisited section at an offset belonging to the previous diff.
113
115
  useEffect(() => {
114
116
  setHeights((prev) => {
115
- if (prev.length === count) return prev;
116
- return Array.from({ length: count }, (_, i) => prev[i] ?? estimateHeight(i));
117
+ const next = Array.from({ length: count }, (_, i) => estimateHeight(i));
118
+ return prev.length === next.length && prev.every((height, index) => height === next[index])
119
+ ? prev
120
+ : next;
117
121
  });
118
- // estimateHeight is expected stable; count is the real trigger.
119
- // eslint-disable-next-line react-hooks/exhaustive-deps
120
- }, [count]);
122
+ }, [count, estimateHeight]);
121
123
 
122
124
  const heightsRef = useRef(heights);
123
125
  heightsRef.current = heights;
package/src/index.ts CHANGED
@@ -3,9 +3,10 @@
3
3
 
4
4
  // @opengeni/react — hooks + styled components on @opengeni/sdk.
5
5
  //
6
- // Import the styles once in your Tailwind entry CSS:
7
- // @import "@opengeni/react/styles.css";
8
- // @source "../node_modules/@opengeni/react/src";
6
+ // Import the ready-to-use styles once:
7
+ // import "@opengeni/react/compiled.css";
8
+ // Tailwind v4 hosts may instead use the additive styles.css + @source bridge;
9
+ // container-responsive composers additionally import responsive.css.
9
10
 
10
11
  export type {
11
12
  EmbeddedFileAttachmentClientLike,
@@ -22,6 +23,7 @@ export { OpenGeniProvider } from "./provider";
22
23
  export type { OpenGeniProviderProps } from "./provider";
23
24
  export { useOpenGeni, useOpenGeniClient } from "./session-context";
24
25
  export type { ClientOverride, OpenGeniContextValue } from "./session-context";
26
+ export { usePageLiveActivity } from "./hooks/internal";
25
27
 
26
28
  // Hooks
27
29
  export { useSession, isTitleEvent } from "./hooks/use-session";
@@ -79,7 +81,9 @@ export type {
79
81
  VoiceRecordingChunk,
80
82
  VoiceRecordingChunkUploadState,
81
83
  VoiceRecordingFinalizationState,
84
+ VoiceRecordingHandoffMode,
82
85
  VoiceRecordingManifest,
86
+ VoiceRecordingRecoveryMode,
83
87
  VoiceRecordingStore,
84
88
  VoiceRecordingTranscriptionState,
85
89
  VoiceRecordingUploadState,
@@ -229,12 +233,14 @@ export type {
229
233
  CapturedFileUnavailableReason,
230
234
  FileTreeNode,
231
235
  FileTreeStatus,
236
+ SandboxFilesGitSummary,
232
237
  SandboxWriteFileOptions,
233
238
  UseSandboxFilesOptions,
234
239
  UseSandboxFilesResult,
235
240
  } from "./hooks/use-sandbox-files";
236
241
  export { useSandboxGit } from "./hooks/use-sandbox-git";
237
242
  export type {
243
+ SandboxGitComparison,
238
244
  SandboxGitFileDiff,
239
245
  UseSandboxGitOptions,
240
246
  UseSandboxGitResult,
@@ -364,7 +370,7 @@ export type {
364
370
  TurnSummaryProps,
365
371
  } from "./timeline";
366
372
 
367
- // Pure provider-shape parsers (exec banner, V4A diff, secret redaction, …)
373
+ // Pure provider-shape parsers (exec banner, V4A diff, tool arguments, …)
368
374
  export {
369
375
  applyPatchOps,
370
376
  applyPatchOpsFromToolItem,
@@ -376,7 +382,6 @@ export {
376
382
  parseExecBannerSessionId,
377
383
  parseFreeformApplyPatch,
378
384
  parseToolArgs,
379
- redactSecrets,
380
385
  sandboxCommandExitCode,
381
386
  stripExecBanner,
382
387
  tailPeek,
@@ -423,7 +428,7 @@ export type {
423
428
  ComposerTranscriptionMessages,
424
429
  } from "./components/composer-transcription-control";
425
430
  export { defaultChatComposerMessages } from "./components/composer";
426
- export type { ChatComposerMessages } from "./components/composer";
431
+ export type { ChatComposerMessages, ResponsiveBasis } from "./components/composer";
427
432
  export { ModelPicker } from "./components/model-picker";
428
433
  export type { ModelPickerProps } from "./components/model-picker";
429
434
  export {
@@ -0,0 +1,15 @@
1
+ import { createContext, useContext, type RefObject } from "react";
2
+
3
+ /** The measurement surface used by responsive composer chrome. */
4
+ export type ResponsiveBasis = "viewport" | "container";
5
+
6
+ export type ComposerResponsiveContextValue = {
7
+ responsiveBasis: ResponsiveBasis;
8
+ rootRef: RefObject<HTMLDivElement | null>;
9
+ };
10
+
11
+ export const ComposerResponsiveContext = createContext<ComposerResponsiveContextValue | null>(null);
12
+
13
+ export function useComposerResponsiveContext(): ComposerResponsiveContextValue | null {
14
+ return useContext(ComposerResponsiveContext);
15
+ }
@@ -1,6 +1,37 @@
1
- import { type CSSProperties, type RefObject, useEffect, useState } from "react";
1
+ import {
2
+ type CSSProperties,
3
+ type RefCallback,
4
+ type RefObject,
5
+ useCallback,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { useComposerResponsiveContext } from "./composer-responsive-context";
2
11
 
3
- export type PortalTokenStyle = CSSProperties & Record<`--${string}`, string>;
12
+ export type PortalTokenStyle = Pick<CSSProperties, "colorScheme"> &
13
+ Partial<Record<`--${string}`, string>>;
14
+
15
+ export const PORTAL_SOURCE_INLINE_SIZE = "--og-portal-source-inline-size";
16
+
17
+ /**
18
+ * A callback ref that publishes the currently mounted source element through
19
+ * React state. Unlike a bare mutable ref, late mounts and replacements cause
20
+ * consumers to render and restart their observers without polling.
21
+ */
22
+ export function usePortalTokenSource<T extends HTMLElement>(): {
23
+ source: T | null;
24
+ currentRef: RefObject<T | null>;
25
+ ref: RefCallback<T>;
26
+ } {
27
+ const currentRef = useRef<T | null>(null);
28
+ const [source, setSource] = useState<T | null>(null);
29
+ const ref = useCallback<RefCallback<T>>((node) => {
30
+ currentRef.current = node;
31
+ setSource((current) => (current === node ? current : node));
32
+ }, []);
33
+ return { source, currentRef, ref };
34
+ }
4
35
 
5
36
  /**
6
37
  * Copy the effective SDK tokens across a portal boundary. A locally themed or
@@ -8,11 +39,13 @@ export type PortalTokenStyle = CSSProperties & Record<`--${string}`, string>;
8
39
  * `<body>`. Ancestor theme/class/style mutations and OS color-scheme changes
9
40
  * keep the copied values live.
10
41
  */
11
- export function usePortalTokenStyle(sourceRef: RefObject<HTMLElement | null>): PortalTokenStyle {
42
+ export function usePortalTokenStyle(source: HTMLElement | null): PortalTokenStyle {
43
+ const responsiveContext = useComposerResponsiveContext();
12
44
  const [style, setStyle] = useState<PortalTokenStyle>({});
13
45
  useEffect(() => {
14
- const source = sourceRef.current;
15
- if (!source || typeof MutationObserver === "undefined") return;
46
+ if (!source || typeof window === "undefined") return;
47
+ const layoutSource =
48
+ responsiveContext?.responsiveBasis === "container" ? responsiveContext.rootRef.current : null;
16
49
  let signature = "";
17
50
  const sync = () => {
18
51
  const computed = getComputedStyle(source);
@@ -22,6 +55,12 @@ export function usePortalTokenStyle(sourceRef: RefObject<HTMLElement | null>): P
22
55
  if (!property.startsWith("--og-")) continue;
23
56
  next[property as `--${string}`] = computed.getPropertyValue(property);
24
57
  }
58
+ if (layoutSource) {
59
+ const inlineSize = layoutSource.clientWidth || layoutSource.getBoundingClientRect().width;
60
+ if (inlineSize > 0) {
61
+ next[PORTAL_SOURCE_INLINE_SIZE] = `${Math.round(inlineSize * 1000) / 1000}px`;
62
+ }
63
+ }
25
64
  const nextSignature = JSON.stringify(next);
26
65
  if (nextSignature !== signature) {
27
66
  signature = nextSignature;
@@ -29,23 +68,29 @@ export function usePortalTokenStyle(sourceRef: RefObject<HTMLElement | null>): P
29
68
  }
30
69
  };
31
70
  const observers: MutationObserver[] = [];
32
- let ancestor: HTMLElement | null = source;
33
- while (ancestor) {
34
- const observer = new MutationObserver(sync);
35
- observer.observe(ancestor, {
36
- attributes: true,
37
- attributeFilter: ["class", "data-og-density", "data-og-theme", "style"],
38
- });
39
- observers.push(observer);
40
- ancestor = ancestor.parentElement;
71
+ if (typeof MutationObserver !== "undefined") {
72
+ let ancestor: HTMLElement | null = source;
73
+ while (ancestor) {
74
+ const observer = new MutationObserver(sync);
75
+ observer.observe(ancestor, {
76
+ attributes: true,
77
+ attributeFilter: ["class", "data-og-density", "data-og-theme", "style"],
78
+ });
79
+ observers.push(observer);
80
+ ancestor = ancestor.parentElement;
81
+ }
41
82
  }
83
+ const resizeObserver =
84
+ layoutSource && typeof ResizeObserver !== "undefined" ? new ResizeObserver(sync) : null;
85
+ if (layoutSource) resizeObserver?.observe(layoutSource);
42
86
  const colorScheme = window.matchMedia?.("(prefers-color-scheme: dark)");
43
87
  colorScheme?.addEventListener?.("change", sync);
44
88
  sync();
45
89
  return () => {
46
90
  for (const observer of observers) observer.disconnect();
91
+ resizeObserver?.disconnect();
47
92
  colorScheme?.removeEventListener?.("change", sync);
48
93
  };
49
- }, [sourceRef]);
94
+ }, [responsiveContext?.responsiveBasis, responsiveContext?.rootRef, source]);
50
95
  return style;
51
96
  }
package/src/provider.tsx CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  } from "@opengeni/sdk";
7
7
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
8
8
  import type { SessionClientLike } from "./client";
9
+ import { usePageLiveActivity } from "./hooks/internal";
9
10
  import { OpenGeniContext } from "./session-context";
10
11
 
11
12
  export { useOpenGeni, useOpenGeniClient } from "./session-context";
@@ -40,6 +41,9 @@ export function OpenGeniProvider({
40
41
  );
41
42
  const callbackRef = useRef(onWorkspaceControlEvent);
42
43
  const reconcilersRef = useRef(new Map<string, Map<string, () => Promise<void>>>());
44
+ const reconcileInFlightRef = useRef(new Map<string, Promise<void>>());
45
+ const workspaceControlSequencesRef = useRef(new Map<string, number>());
46
+ const pageLive = usePageLiveActivity();
43
47
  callbackRef.current = onWorkspaceControlEvent;
44
48
 
45
49
  const verifyApiContract = useCallback(async (): Promise<void> => {
@@ -76,31 +80,57 @@ export function OpenGeniProvider({
76
80
  );
77
81
  const reconcileSession = useMemo(
78
82
  () =>
79
- async (sessionId: string): Promise<void> => {
83
+ (sessionId: string): Promise<void> => {
84
+ const existing = reconcileInFlightRef.current.get(sessionId);
85
+ if (existing) return existing;
80
86
  // This read also crosses the exact API-contract handshake before stale
81
87
  // state can be presented as live after a deployment.
82
- await verifyApiContract();
83
- const callbacks = [...(reconcilersRef.current.get(sessionId)?.values() ?? [])];
84
- await Promise.all(callbacks.map((reconcile) => reconcile()));
88
+ const promise = (async () => {
89
+ await withTimeout(verifyApiContract(), 10_000, "API contract reconciliation");
90
+ const callbacks = [...(reconcilersRef.current.get(sessionId)?.values() ?? [])];
91
+ await withTimeout(
92
+ Promise.all(callbacks.map((reconcile) => reconcile())).then(() => undefined),
93
+ 15_000,
94
+ "session reconciliation",
95
+ );
96
+ })().finally(() => {
97
+ if (reconcileInFlightRef.current.get(sessionId) === promise) {
98
+ reconcileInFlightRef.current.delete(sessionId);
99
+ }
100
+ });
101
+ reconcileInFlightRef.current.set(sessionId, promise);
102
+ return promise;
85
103
  },
86
104
  [verifyApiContract],
87
105
  );
88
106
 
89
107
  useEffect(() => {
108
+ if (!pageLive) {
109
+ setWorkspaceControlConnectionState("idle");
110
+ return;
111
+ }
90
112
  const controller = new AbortController();
91
- setWorkspaceControlEvent(null);
92
113
  setWorkspaceControlConnectionState("connecting");
93
114
  void (async () => {
94
115
  try {
95
116
  await verifyApiContract();
96
117
  const workspace = await client.getWorkspace(workspaceId);
118
+ const resumeAfter = Math.max(
119
+ workspaceControlSequencesRef.current.get(workspaceId) ?? 0,
120
+ workspace.inferenceControl.revision,
121
+ );
122
+ workspaceControlSequencesRef.current.set(workspaceId, resumeAfter);
97
123
  const stream = client.streamWorkspaceControlEvents(workspaceId, {
98
- after: workspace.inferenceControl.revision,
124
+ after: resumeAfter,
99
125
  signal: controller.signal,
100
126
  onStateChange: setWorkspaceControlConnectionState,
101
127
  });
102
128
  for await (const event of stream) {
103
129
  if (controller.signal.aborted) return;
130
+ workspaceControlSequencesRef.current.set(
131
+ workspaceId,
132
+ Math.max(workspaceControlSequencesRef.current.get(workspaceId) ?? 0, event.sequence),
133
+ );
104
134
  setWorkspaceControlEvent((current) =>
105
135
  !current || event.sequence > current.sequence ? event : current,
106
136
  );
@@ -115,7 +145,7 @@ export function OpenGeniProvider({
115
145
  }
116
146
  })();
117
147
  return () => controller.abort();
118
- }, [client, verifyApiContract, workspaceId]);
148
+ }, [client, pageLive, verifyApiContract, workspaceId]);
119
149
 
120
150
  const value = useMemo(
121
151
  () => ({
@@ -143,6 +173,21 @@ export function OpenGeniProvider({
143
173
  );
144
174
  }
145
175
 
176
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
177
+ let timer: ReturnType<typeof setTimeout> | undefined;
178
+ return Promise.race([
179
+ promise,
180
+ new Promise<T>((_resolve, reject) => {
181
+ timer = setTimeout(
182
+ () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
183
+ timeoutMs,
184
+ );
185
+ }),
186
+ ]).finally(() => {
187
+ if (timer !== undefined) clearTimeout(timer);
188
+ });
189
+ }
190
+
146
191
  function ApiContractMismatchScreen({ mismatch }: { mismatch: OpenGeniApiContractMismatchError }) {
147
192
  return (
148
193
  <div
@@ -2,18 +2,16 @@
2
2
 
3
3
  import { ChevronRightIcon } from "lucide-react";
4
4
  import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
5
- import {
6
- createContext,
7
- useContext,
8
- useRef,
9
- type ComponentProps,
10
- type Ref,
11
- type RefObject,
12
- } from "react";
5
+ import { createContext, useContext, type ComponentProps, type Ref } from "react";
13
6
  import { cn } from "../lib/cn";
14
- import { usePortalTokenStyle } from "../lib/use-portal-token-style";
7
+ import { usePortalTokenSource, usePortalTokenStyle } from "../lib/use-portal-token-style";
15
8
 
16
- const DropdownMenuSourceContext = createContext<RefObject<HTMLElement | null> | null>(null);
9
+ type DropdownMenuSourceContextValue = {
10
+ source: HTMLElement | null;
11
+ ref: (node: HTMLElement | null) => void;
12
+ };
13
+
14
+ const DropdownMenuSourceContext = createContext<DropdownMenuSourceContextValue | null>(null);
17
15
 
18
16
  function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
19
17
  if (typeof ref === "function") ref(value);
@@ -21,9 +19,9 @@ function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
21
19
  }
22
20
 
23
21
  function DropdownMenu(props: ComponentProps<typeof DropdownMenuPrimitive.Root>) {
24
- const sourceRef = useRef<HTMLElement | null>(null);
22
+ const source = usePortalTokenSource<HTMLElement>();
25
23
  return (
26
- <DropdownMenuSourceContext.Provider value={sourceRef}>
24
+ <DropdownMenuSourceContext.Provider value={source}>
27
25
  <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
28
26
  </DropdownMenuSourceContext.Provider>
29
27
  );
@@ -38,7 +36,7 @@ function DropdownMenuTrigger({
38
36
  <DropdownMenuPrimitive.Trigger
39
37
  data-slot="dropdown-menu-trigger"
40
38
  ref={(node) => {
41
- if (sourceRef) sourceRef.current = node;
39
+ sourceRef?.ref(node);
42
40
  assignRef(ref, node);
43
41
  }}
44
42
  {...props}
@@ -52,9 +50,8 @@ function DropdownMenuContent({
52
50
  style,
53
51
  ...props
54
52
  }: ComponentProps<typeof DropdownMenuPrimitive.Content>) {
55
- const sourceRef = useContext(DropdownMenuSourceContext);
56
- const fallbackRef = useRef<HTMLElement | null>(null);
57
- const portalStyle = usePortalTokenStyle(sourceRef ?? fallbackRef);
53
+ const source = useContext(DropdownMenuSourceContext);
54
+ const portalStyle = usePortalTokenStyle(source?.source ?? null);
58
55
  return (
59
56
  <DropdownMenuPrimitive.Portal>
60
57
  <DropdownMenuPrimitive.Content
@@ -138,9 +135,8 @@ function DropdownMenuSubContent({
138
135
  style,
139
136
  ...props
140
137
  }: ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
141
- const sourceRef = useContext(DropdownMenuSourceContext);
142
- const fallbackRef = useRef<HTMLElement | null>(null);
143
- const portalStyle = usePortalTokenStyle(sourceRef ?? fallbackRef);
138
+ const source = useContext(DropdownMenuSourceContext);
139
+ const portalStyle = usePortalTokenStyle(source?.source ?? null);
144
140
  return (
145
141
  <DropdownMenuPrimitive.SubContent
146
142
  data-slot="dropdown-menu-sub-content"
@@ -297,6 +297,13 @@ export function SessionRealtimeControl(props: {
297
297
  onRealtimeAutostartConsumed?: (() => void) | undefined;
298
298
  /** Host hides dictate (and can choreograph layout) while realtime voice is live. */
299
299
  onVoiceActiveChange?: ((active: boolean) => void) | undefined;
300
+ /**
301
+ * `split` attaches the model chevron at every breakpoint (public default).
302
+ * `split-desktop` hides it below `sm` when the host owns mobile selection.
303
+ * Inside a container-responsive Composer.Root, `sm` follows that composer.
304
+ * `none` never attaches a model menu to the bar button.
305
+ */
306
+ modelMenu?: "split" | "split-desktop" | "none" | undefined;
300
307
  /** Deterministic browser-test/demo seam. Production hosts should use the SDK default. */
301
308
  controllerFactory?: SessionRealtimeControllerFactory | undefined;
302
309
  }) {
@@ -365,6 +372,7 @@ export function SessionRealtimeControl(props: {
365
372
  admissionBlocker={realtime.admissionBlocker}
366
373
  modelAvailable={selectedModel.available}
367
374
  menuSide="top"
375
+ modelMenu={props.modelMenu ?? "split"}
368
376
  audioRef={realtime.audioRef}
369
377
  selectedModel={selectedModel}
370
378
  models={selection.models}
@@ -475,6 +483,7 @@ export function NewSessionRealtimeControl(props: {
475
483
  /**
476
484
  * `split` attaches the model chevron at every breakpoint (public default).
477
485
  * `split-desktop` hides it below `sm` when the host owns mobile selection (e.g. “+”).
486
+ * Inside a container-responsive Composer.Root, `sm` follows that composer.
478
487
  * `none` never attaches a model menu to the bar button.
479
488
  */
480
489
  modelMenu?: "split" | "split-desktop" | "none" | undefined;
@@ -556,6 +565,7 @@ export function RealtimeVoiceControl(props: {
556
565
  /**
557
566
  * `split` = start + model chevron at every breakpoint (public SDK default).
558
567
  * `split-desktop` = chevron from `sm` up; below that the host owns selection.
568
+ * Inside a container-responsive Composer.Root, `sm` follows that composer.
559
569
  * `none` = start button only.
560
570
  */
561
571
  modelMenu?: "split" | "split-desktop" | "none" | undefined;
@@ -632,7 +642,8 @@ export function RealtimeVoiceControl(props: {
632
642
  role="group"
633
643
  aria-label="Realtime voice"
634
644
  data-picker-side={props.menuSide ?? "top"}
635
- className="inline-flex shrink-0 items-center"
645
+ data-model-menu={modelMenu}
646
+ className="og-realtime-control inline-flex shrink-0 items-center"
636
647
  >
637
648
  <audio ref={props.audioRef} autoPlay className="hidden" aria-hidden="true" />
638
649
  <RealtimeComposerGlow phase={status.phase} reduceMotion={reduceMotion === true} />
@@ -719,7 +730,7 @@ export function RealtimeVoiceControl(props: {
719
730
  // Match transcription mic: ghost icon when idle; filled only when live.
720
731
  // Public contract: coarse pointers keep a 44px target; split menu uses
721
732
  // left-only rounding at `sm+` where the chevron attaches.
722
- "relative inline-flex size-8 items-center justify-center outline-none",
733
+ "og-realtime-primary relative inline-flex size-8 items-center justify-center outline-hidden",
723
734
  "rounded-og-md transition-[background-color,border-color,color,box-shadow] duration-200 ease-og-out",
724
735
  "focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-og-accent/45",
725
736
  "disabled:cursor-not-allowed disabled:opacity-45 pointer-coarse:size-11",
@@ -746,11 +757,11 @@ export function RealtimeVoiceControl(props: {
746
757
  title={`Voice model: ${selectedModel.label}`}
747
758
  disabled={props.selectionDisabled}
748
759
  className={cn(
749
- // ≥24px wide so WCAG 2.2 target-size passes when the chevron is shown.
750
- "inline-flex h-8 w-6 items-center justify-center rounded-r-og-md outline-none",
760
+ // Keep the split trigger compact on desktop while preserving a
761
+ // full 44px mobile target independent of pointer-media support.
762
+ "og-realtime-model-trigger inline-flex h-11 w-11 items-center justify-center rounded-r-og-md outline-hidden sm:h-8 sm:w-6",
751
763
  "transition-[background-color,border-color,color] duration-200 ease-og-out",
752
764
  "focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-og-accent/45",
753
- "pointer-coarse:h-11 pointer-coarse:w-7",
754
765
  desktopOnlyModelMenu && "hidden sm:inline-flex",
755
766
  voiceChevronTone(status.phase),
756
767
  )}
@@ -763,7 +774,7 @@ export function RealtimeVoiceControl(props: {
763
774
  align="end"
764
775
  sideOffset={8}
765
776
  collisionPadding={12}
766
- className="flex w-72 max-h-[min(20rem,var(--radix-dropdown-menu-content-available-height))] flex-col overflow-hidden rounded-xl border-border bg-surface p-1.5 shadow-xl"
777
+ className="og-realtime-menu flex w-72 max-h-[min(20rem,var(--radix-dropdown-menu-content-available-height))] flex-col overflow-hidden rounded-xl border-border bg-surface p-1.5 shadow-xl"
767
778
  onCloseAutoFocus={(event) => event.preventDefault()}
768
779
  >
769
780
  <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
@@ -931,7 +942,7 @@ function RealtimeMuteButton(props: {
931
942
  {...(props.reduceMotion ? {} : { whileTap: { scale: 0.92 } })}
932
943
  onClick={props.onToggle}
933
944
  className={cn(
934
- "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md outline-none",
945
+ "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md outline-hidden",
935
946
  "transition-[background-color,border-color,color] duration-150 ease-og-out",
936
947
  "focus-visible:ring-2 focus-visible:ring-og-accent/45 pointer-coarse:size-11",
937
948
  props.muted
@@ -12,7 +12,7 @@ import { cn } from "../lib/cn";
12
12
  import { truncate } from "../lib/format";
13
13
  import { defaultToolRegistry } from "./tool-renderers";
14
14
  import { useEntranceAnimation, useEntranceAnimationLive } from "./entrance";
15
- import type { ToolRegistry } from "./registry";
15
+ import type { RetainedScreenshotLoader, ToolRegistry } from "./registry";
16
16
  import { useSeenActivityIds } from "./seen-activity-ids";
17
17
  import { BodyNote, PayloadBlock, ActivityDisclosure } from "./shared";
18
18
  import { toolDisplayName } from "./tool-display-name";
@@ -43,6 +43,7 @@ export type ActivityRailProps = {
43
43
  * row is then non-interactive rich content). See {@link MessageTimelineProps}.
44
44
  */
45
45
  onMemoryClick?: ((memoryId: string) => void) | undefined;
46
+ loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
46
47
  /** Drop the left rule + indent (used inside a folded turn summary). */
47
48
  bare?: boolean | undefined;
48
49
  className?: string | undefined;
@@ -65,6 +66,7 @@ export function ActivityRail({
65
66
  toolRegistry = defaultToolRegistry,
66
67
  onOpenSession,
67
68
  onMemoryClick,
69
+ loadRetainedScreenshot,
68
70
  bare,
69
71
  className,
70
72
  }: ActivityRailProps) {
@@ -112,7 +114,13 @@ export function ActivityRail({
112
114
  >
113
115
  {items.map((item, index) => {
114
116
  const newFamily = index > 0 && familyOf(item) !== familyOf(items[index - 1]!);
115
- const row = renderActivity(item, toolRegistry, onOpenSession, onMemoryClick);
117
+ const row = renderActivity(
118
+ item,
119
+ toolRegistry,
120
+ onOpenSession,
121
+ onMemoryClick,
122
+ loadRetainedScreenshot,
123
+ );
116
124
  return (
117
125
  <div
118
126
  key={item.id}
@@ -137,13 +145,14 @@ function renderActivity(
137
145
  toolRegistry: ToolRegistry,
138
146
  onOpenSession: ((sessionId: string) => void) | undefined,
139
147
  onMemoryClick: ((memoryId: string) => void) | undefined,
148
+ loadRetainedScreenshot: RetainedScreenshotLoader | undefined,
140
149
  ) {
141
150
  switch (item.kind) {
142
151
  case "reasoning":
143
152
  return <ReasoningRow item={item} />;
144
153
  case "tool-call": {
145
154
  const Renderer = toolRegistry.resolve(item);
146
- return <Renderer item={item} />;
155
+ return <Renderer item={item} loadRetainedScreenshot={loadRetainedScreenshot} />;
147
156
  }
148
157
  case "worker":
149
158
  return <WorkerRow item={item} onOpenSession={onOpenSession} />;
@@ -281,7 +290,7 @@ function MemoryRow({
281
290
  onClick={() => onMemoryClick?.(targetId)}
282
291
  className={cn(
283
292
  "group/memlink -mx-1 inline-flex w-fit items-center gap-1 rounded-og-sm px-1 py-0.5 text-left text-og-sm text-og-fg-subtle",
284
- "outline-none transition-colors duration-150 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent",
293
+ "outline-hidden transition-colors duration-150 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent",
285
294
  )}
286
295
  >
287
296
  View in memory
@@ -418,8 +427,8 @@ function WorkerRow({
418
427
  onClick={() => onOpenSession(sessionId)}
419
428
  className={cn(
420
429
  "group/worker -mx-1.5 flex w-full items-start gap-2 rounded-og-sm px-1.5 py-1.5 text-left",
421
- "outline-none transition-colors duration-150 hover:bg-og-surface-1 focus-visible:ring-2 focus-visible:ring-og-accent",
422
- "pointer-coarse:py-2.5",
430
+ "outline-hidden transition-colors duration-150 hover:bg-og-surface-1 focus-visible:ring-2 focus-visible:ring-og-accent",
431
+ "pointer-coarse:min-h-11 pointer-coarse:py-2.5",
423
432
  )}
424
433
  >
425
434
  {inner}
@@ -56,6 +56,7 @@ export type {
56
56
  export { createToolRegistry, rawTypeOf } from "./registry";
57
57
  export type {
58
58
  CreateToolRegistryOptions,
59
+ RetainedScreenshotLoader,
59
60
  ToolRegistry,
60
61
  ToolRegistryEntry,
61
62
  ToolRenderer,
@@ -123,7 +124,6 @@ export {
123
124
  looksBinary,
124
125
  parseExecBannerSessionId,
125
126
  parseToolArgs,
126
- redactSecrets,
127
127
  sandboxCommandExitCode,
128
128
  stripExecBanner,
129
129
  tailPeek,
@@ -1,4 +1,4 @@
1
- import { normalizeMcpOutput, type GitFileDiff } from "@opengeni/sdk";
1
+ import { normalizeMcpOutput, type GitFileDiff, type RetainedArtifactMetadata } from "@opengeni/sdk";
2
2
  import { tryParseJson } from "../lib/format";
3
3
 
4
4
  /* ----------------------------------------------------------------------------
@@ -437,25 +437,6 @@ export function isApplyPatch(item: { name: string; raw: unknown }): boolean {
437
437
  return name === "apply_patch_call" || name === "apply_patch" || name.endsWith("__apply_patch");
438
438
  }
439
439
 
440
- /* --- secret redaction ------------------------------------------------------- */
441
-
442
- const SECRET_KEY = /^(value|secret|token|password|api[_-]?key|signing[_-]?key)$/i;
443
-
444
- /** Deep-redact secret-looking values so arguments never leak a key into the UI. */
445
- export function redactSecrets(value: unknown): unknown {
446
- if (Array.isArray(value)) {
447
- return value.map(redactSecrets);
448
- }
449
- if (value && typeof value === "object") {
450
- const out: Record<string, unknown> = {};
451
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
452
- out[k] = SECRET_KEY.test(k) ? "••••" : redactSecrets(v);
453
- }
454
- return out;
455
- }
456
- return value;
457
- }
458
-
459
440
  /** Parse tool arguments that may arrive as a JSON string or an object. */
460
441
  export function parseToolArgs(args: unknown): Record<string, unknown> {
461
442
  if (args == null) {
@@ -558,6 +539,38 @@ export function screenshotDataUrl(out: unknown): string | null {
558
539
  return null;
559
540
  }
560
541
 
542
+ /** Parse the closed retained-screenshot receipt carried by new tool events. */
543
+ export function retainedScreenshotMetadata(out: unknown): RetainedArtifactMetadata | null {
544
+ if (typeof out === "string" && (out.startsWith("{") || out.startsWith("["))) {
545
+ const parsed = tryParseJson(out);
546
+ if (parsed !== undefined && parsed !== out) return retainedScreenshotMetadata(parsed);
547
+ }
548
+ if (Array.isArray(out)) {
549
+ for (const entry of out) {
550
+ const metadata = retainedScreenshotMetadata(entry);
551
+ if (metadata) return metadata;
552
+ }
553
+ return null;
554
+ }
555
+ if (!out || typeof out !== "object") return null;
556
+ const value = out as Record<string, unknown>;
557
+ if (typeof value.artifactId !== "string" || typeof value.available !== "boolean") return null;
558
+ if (!value.available) {
559
+ return typeof value.reason === "string" ? (value as unknown as RetainedArtifactMetadata) : null;
560
+ }
561
+ return value.kind === "computer_screenshot" &&
562
+ value.contentType === "image/png" &&
563
+ typeof value.originalBytes === "number" &&
564
+ typeof value.sha256 === "string" &&
565
+ value.dimensions !== null &&
566
+ typeof value.dimensions === "object" &&
567
+ value.retention !== null &&
568
+ typeof value.retention === "object" &&
569
+ (value.retention as Record<string, unknown>).policy === "session_screenshot"
570
+ ? (value as unknown as RetainedArtifactMetadata)
571
+ : null;
572
+ }
573
+
561
574
  export type TimelineMediaPreview = {
562
575
  type: "media_preview";
563
576
  mediaType: string;