@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
@@ -5,7 +5,7 @@ import type {
5
5
  WorkspaceCaptureRepo,
6
6
  } from "@opengeni/sdk";
7
7
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
- import { useOpenGeni, type ClientOverride } from "../provider";
8
+ import { type ClientOverride, useOpenGeni } from "../provider";
9
9
 
10
10
  export type UseSandboxGitOptions = ClientOverride & {
11
11
  /** Live event log (usually `useSessionEvents().events`) — drives auto-refresh
@@ -18,8 +18,14 @@ export type UseSandboxGitOptions = ClientOverride & {
18
18
  repoPaths?: readonly string[] | undefined;
19
19
  /** Diff the staged index vs HEAD (`--cached`) instead of the working tree. */
20
20
  staged?: boolean | undefined;
21
+ /** Review scope. `branch` compares the current branch with the remote default
22
+ * branch, `working` shows uncommitted edits, and `staged` shows the index.
23
+ * `staged` above remains as a compatibility alias. */
24
+ comparison?: SandboxGitComparison | undefined;
21
25
  /** Hold off the initial fetch. Default true. */
22
26
  enabled?: boolean | undefined;
27
+ /** Pause live reads/invalidation while preserving the last rendered result. */
28
+ active?: boolean | undefined;
23
29
  /** Lease liveness ("warm" | "draining" | "cold"). When NOT warm, the diff is
24
30
  * served from the capture (cold/offline) instead of a live `gitDiff` RPC. */
25
31
  liveness?: string | undefined;
@@ -29,10 +35,14 @@ export type UseSandboxGitOptions = ClientOverride & {
29
35
  capture?: WorkspaceCaptureManifest | null | undefined;
30
36
  };
31
37
 
38
+ export type SandboxGitComparison = "branch" | "working" | "staged";
39
+
32
40
  /** A Git diff qualified into workspace scope. `repoRoot` is present for the
33
41
  * workspace-wide path and lets review UI group real repositories without
34
42
  * guessing from the first path segment. */
35
- export type SandboxGitFileDiff = GitFileDiff & { repoRoot?: string | undefined };
43
+ export type SandboxGitFileDiff = GitFileDiff & {
44
+ repoRoot?: string | undefined;
45
+ };
36
46
 
37
47
  export type UseSandboxGitResult = {
38
48
  /** Working-tree (or staged) diff vs HEAD — the structured hunks the Pierre
@@ -175,6 +185,7 @@ export function useSandboxGit(
175
185
  ): UseSandboxGitResult {
176
186
  const { client, workspaceId } = useOpenGeni(options);
177
187
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
188
+ const active = options.active ?? true;
178
189
  const repoPath = options.repoPath ?? "";
179
190
  const workspaceWide = options.repoPaths !== undefined;
180
191
  // Serialize the caller's array so inline values do not retrigger effects. An
@@ -183,10 +194,15 @@ export function useSandboxGit(
183
194
  options.repoPaths && options.repoPaths.length > 0 ? options.repoPaths : [repoPath],
184
195
  ).join("\u0000");
185
196
  const repoPaths = useMemo(() => repoPathsKey.split("\u0000"), [repoPathsKey]);
186
- const staged = options.staged ?? false;
187
- const capture = options.capture ?? null;
197
+ const comparison: SandboxGitComparison =
198
+ options.comparison ?? (options.staged ? "staged" : "working");
199
+ const hasAdvertisedRepoPaths = (options.repoPaths?.length ?? 0) > 0;
200
+ // New captures retain both working-tree and branch comparisons. Staged state
201
+ // remains live-only; legacy captures simply lack branchDiff and fall back via
202
+ // the workspace controller without inventing an empty branch comparison.
203
+ const capture = comparison === "staged" ? null : (options.capture ?? null);
188
204
  const isLive = options.liveness === "warm" || options.liveness === "draining";
189
- const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${repoPathsKey}\u0000${workspaceWide}\u0000${staged}`;
205
+ const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${repoPathsKey}\u0000${workspaceWide}\u0000${comparison}`;
190
206
 
191
207
  const [diff, setDiff] = useState<SandboxGitFileDiff[]>([]);
192
208
  const [branch, setBranch] = useState<string | null>(null);
@@ -204,6 +220,7 @@ export function useSandboxGit(
204
220
  const refreshGenerationRef = useRef(0);
205
221
  const refreshAbortRef = useRef<AbortController | null>(null);
206
222
  const lastChangeRef = useRef(0);
223
+ const commandRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
207
224
 
208
225
  const refresh = useCallback(async () => {
209
226
  if (!sessionId) return;
@@ -214,17 +231,69 @@ export function useSandboxGit(
214
231
  setLoading(true);
215
232
  setError(null);
216
233
  try {
217
- const statuses = await Promise.all(
218
- repoPaths.map(async (root) => ({
219
- root,
220
- status: await client.gitStatus(
221
- workspaceId,
222
- sessionId,
223
- { path: root },
224
- { signal: refreshAbort.signal },
225
- ),
226
- })),
227
- );
234
+ const diffRequest = (root: string) =>
235
+ client.gitDiff(
236
+ workspaceId,
237
+ sessionId,
238
+ {
239
+ path: root,
240
+ ...(comparison === "branch"
241
+ ? { fromRef: "origin/HEAD", includeUntracked: true }
242
+ : comparison === "working"
243
+ ? { fromRef: "HEAD", includeUntracked: true }
244
+ : { staged: true, includeUntracked: false }),
245
+ },
246
+ { signal: refreshAbort.signal },
247
+ );
248
+
249
+ // Capability-advertised roots are known repositories. Fetch status metadata
250
+ // and diff hunks together: both are independent Channel-A reads and each can
251
+ // take several seconds on a remote sandbox. The legacy root probe remains
252
+ // serial so a non-repository workspace never receives a failing git diff.
253
+ const prefetched = hasAdvertisedRepoPaths
254
+ ? await client
255
+ .gitReadBatch(
256
+ workspaceId,
257
+ sessionId,
258
+ {
259
+ requests: repoPaths.map((root) => ({
260
+ status: { path: root },
261
+ diff: {
262
+ path: root,
263
+ ...(comparison === "branch"
264
+ ? { fromRef: "origin/HEAD", includeUntracked: true }
265
+ : comparison === "working"
266
+ ? { fromRef: "HEAD", includeUntracked: true }
267
+ : { staged: true, includeUntracked: false }),
268
+ },
269
+ })),
270
+ },
271
+ { signal: refreshAbort.signal },
272
+ )
273
+ .then((batch) =>
274
+ batch.results.map((item, index) => ({
275
+ root: repoPaths[index] ?? "",
276
+ status: item.status,
277
+ result: item.diff ?? {
278
+ files: [],
279
+ revision: item.status.revision,
280
+ },
281
+ })),
282
+ )
283
+ : null;
284
+ const statuses =
285
+ prefetched ??
286
+ (await Promise.all(
287
+ repoPaths.map(async (root) => ({
288
+ root,
289
+ status: await client.gitStatus(
290
+ workspaceId,
291
+ sessionId,
292
+ { path: root },
293
+ { signal: refreshAbort.signal },
294
+ ),
295
+ })),
296
+ ));
228
297
  if (refreshGenerationRef.current !== generation) return;
229
298
  const repositories = statuses.filter(({ status }) => status.isRepo);
230
299
  const roots = repositories.map(({ root }) => root);
@@ -239,19 +308,11 @@ export function useSandboxGit(
239
308
  setSource("live");
240
309
  return;
241
310
  }
311
+ const prefetchedByRoot = new Map(prefetched?.map(({ root, result }) => [root, result]));
242
312
  const results = await Promise.all(
243
313
  repositories.map(async ({ root }) => ({
244
314
  root,
245
- result: await client.gitDiff(
246
- workspaceId,
247
- sessionId,
248
- {
249
- path: root,
250
- staged,
251
- includeUntracked: !staged,
252
- },
253
- { signal: refreshAbort.signal },
254
- ),
315
+ result: prefetchedByRoot.get(root) ?? (await diffRequest(root)),
255
316
  })),
256
317
  );
257
318
  if (refreshGenerationRef.current !== generation) return;
@@ -273,15 +334,25 @@ export function useSandboxGit(
273
334
  if (refreshGenerationRef.current === generation) setLoading(false);
274
335
  if (refreshAbortRef.current === refreshAbort) refreshAbortRef.current = null;
275
336
  }
276
- }, [client, workspaceId, sessionId, repoPaths, staged, workspaceWide]);
337
+ }, [
338
+ client,
339
+ workspaceId,
340
+ sessionId,
341
+ repoPaths,
342
+ hasAdvertisedRepoPaths,
343
+ workspaceWide,
344
+ comparison,
345
+ ]);
277
346
 
278
- // Serve the diff from a capture (the cold/offline source). `staged` has no
279
- // meaning here: the capture records the combined turn-end change surface vs HEAD.
347
+ // Serve the requested review scope from a capture when that scope exists.
280
348
  const seedFromCapture = useCallback(
281
349
  (manifest: WorkspaceCaptureManifest) => {
282
350
  const repositories = workspaceWide
283
351
  ? manifest.repos
284
352
  : ([repoForPath(manifest, repoPath)].filter(Boolean) as WorkspaceCaptureRepo[]);
353
+ if (comparison === "branch" && repositories.some((repo) => repo.branchDiff === undefined)) {
354
+ return;
355
+ }
285
356
  if (repositories.length === 0) {
286
357
  setIsRepo(false);
287
358
  setBranch(null);
@@ -301,16 +372,19 @@ export function useSandboxGit(
301
372
  setDiff((prev) =>
302
373
  mergeDiffs(
303
374
  prev,
304
- repositories.flatMap((repo) =>
305
- workspaceWide ? repo.diff.map((file) => qualifyDiff(repo.root, file)) : repo.diff,
306
- ),
375
+ repositories.flatMap((repo) => {
376
+ const capturedDiff = comparison === "branch" ? (repo.branchDiff ?? []) : repo.diff;
377
+ return workspaceWide
378
+ ? capturedDiff.map((file) => qualifyDiff(repo.root, file))
379
+ : capturedDiff;
380
+ }),
307
381
  ),
308
382
  );
309
383
  sourceRef.current = "capture";
310
384
  setSource("capture");
311
385
  setError(null);
312
386
  },
313
- [repoPath, workspaceWide],
387
+ [comparison, repoPath, workspaceWide],
314
388
  );
315
389
 
316
390
  // Source selection on mount / liveness / capture-revision change. Key on the
@@ -349,6 +423,10 @@ export function useSandboxGit(
349
423
  if (currentCapture && (identityChanged || !isLive || sourceRef.current !== "live")) {
350
424
  seedFromCapture(currentCapture);
351
425
  }
426
+ if (!active) {
427
+ if (!active) setLoading(false);
428
+ return;
429
+ }
352
430
  if (isLive || !currentCapture) {
353
431
  void refresh();
354
432
  }
@@ -357,25 +435,54 @@ export function useSandboxGit(
357
435
  refreshAbortRef.current = null;
358
436
  refreshGenerationRef.current += 1;
359
437
  };
360
- }, [enabled, isLive, captureRevision, identityKey, refresh, seedFromCapture]);
438
+ }, [enabled, active, isLive, captureRevision, identityKey, refresh, seedFromCapture]);
361
439
 
362
- // git.changed → re-fetch the LIVE diff. A git.changed only originates from a
363
- // live box, so this both keeps warm sessions fresh and folds a cold box that
364
- // just came up (unchanged from the pre-capture behavior).
440
+ // git.changed → re-fetch the LIVE diff. Agent tool completion is also an
441
+ // invalidation point because shell/apply-patch writes can bypass Channel-A and
442
+ // therefore emit no git.changed event. This is event-driven, never polling.
365
443
  const events = options.events;
366
444
  useEffect(() => {
367
- if (!enabled || !events) return;
445
+ if (!enabled || !active || !events) {
446
+ if (commandRefreshTimerRef.current) {
447
+ clearTimeout(commandRefreshTimerRef.current);
448
+ commandRefreshTimerRef.current = null;
449
+ }
450
+ return;
451
+ }
368
452
  let latest = lastChangeRef.current;
453
+ let immediate = false;
454
+ let commandDelta = false;
369
455
  for (const event of events) {
370
- if (event.type === "git.changed" && event.sequence > latest) {
456
+ if (event.sequence <= latest) continue;
457
+ if (event.type === "git.changed" || event.type === "agent.toolCall.output") {
458
+ latest = event.sequence;
459
+ immediate = true;
460
+ } else if (event.type === "sandbox.command.output.delta") {
371
461
  latest = event.sequence;
462
+ commandDelta = true;
372
463
  }
373
464
  }
374
465
  if (latest > lastChangeRef.current) {
375
466
  lastChangeRef.current = latest;
376
- void refresh();
467
+ if (immediate) {
468
+ if (commandRefreshTimerRef.current) clearTimeout(commandRefreshTimerRef.current);
469
+ commandRefreshTimerRef.current = null;
470
+ void refresh();
471
+ } else if (commandDelta && !commandRefreshTimerRef.current) {
472
+ commandRefreshTimerRef.current = setTimeout(() => {
473
+ commandRefreshTimerRef.current = null;
474
+ void refresh();
475
+ }, 1_000);
476
+ }
377
477
  }
378
- }, [enabled, events, refresh]);
478
+ }, [enabled, active, events, refresh]);
479
+
480
+ useEffect(
481
+ () => () => {
482
+ if (commandRefreshTimerRef.current) clearTimeout(commandRefreshTimerRef.current);
483
+ },
484
+ [],
485
+ );
379
486
 
380
487
  const identityMatches = enabled && stateIdentity === identityKey;
381
488
  const visibleRepoRoots = identityMatches ? repoRoots : [];
@@ -7,6 +7,7 @@ import {
7
7
  } from "@opengeni/sdk";
8
8
  import { useCallback, useEffect, useRef, useState } from "react";
9
9
  import { useOpenGeni, type ClientOverride } from "../provider";
10
+ import { usePageLiveActivity } from "./internal";
10
11
 
11
12
  // "on-demand" is the boxless-benign resting state: the lease is cold and NOTHING
12
13
  // asked to warm a box (no viewer/consent action, no files-tab warm). Under lazy
@@ -157,6 +158,8 @@ export function useSessionCapabilities(
157
158
  ): UseSessionCapabilitiesResult {
158
159
  const { client, workspaceId } = useOpenGeni(options);
159
160
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
161
+ const pageLive = usePageLiveActivity();
162
+ const lifecycleEnabled = enabled && pageLive;
160
163
  const attachDesktop = options.attachDesktop ?? false;
161
164
  const attachTerminal = options.attachTerminal ?? false;
162
165
  const attachFiles = options.attachFiles ?? false;
@@ -201,21 +204,21 @@ export function useSessionCapabilities(
201
204
  epochRef.current = 0;
202
205
  viewerIdRef.current = null;
203
206
  }
204
- if (!enabled || !sessionId) {
207
+ if (!lifecycleEnabled || !sessionId) {
205
208
  setState("idle");
206
209
  setCapabilities(null);
207
210
  return;
208
211
  }
209
212
  let cancelled = false;
210
213
  let pollTimer: ReturnType<typeof setTimeout> | null = null;
211
- let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
214
+ let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
212
215
  let localViewerId: string | null = null;
213
216
  const requestAbort = new AbortController();
214
217
  const startedAt = Date.now();
215
218
 
216
219
  const clearTimers = () => {
217
220
  if (pollTimer !== null) clearTimeout(pollTimer);
218
- if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
221
+ if (heartbeatTimer !== null) clearTimeout(heartbeatTimer);
219
222
  pollTimer = null;
220
223
  heartbeatTimer = null;
221
224
  };
@@ -231,27 +234,35 @@ export function useSessionCapabilities(
231
234
  if (!localViewerId || heartbeatTimer !== null) return;
232
235
  const intervalMs =
233
236
  caps.viewerHeartbeatIntervalMs > 0 ? caps.viewerHeartbeatIntervalMs : 30_000;
234
- heartbeatTimer = setInterval(() => {
237
+ const schedule = () => {
235
238
  if (cancelled || !localViewerId) return;
236
- void client
237
- .heartbeatViewer(workspaceId, sessionId, localViewerId, { leaseEpoch: epochRef.current })
238
- .then((res) => {
239
- // alive:false ⇒ the holder was reaped or the epoch moved under us;
240
- // re-negotiate to re-acquire against the new owner.
241
- if (!res.alive && !cancelled) {
242
- renegotiate();
243
- }
244
- })
245
- .catch((cause) => {
246
- if (cancelled) return;
247
- if (
248
- cause instanceof OpenGeniApiError &&
249
- (cause.status === 409 || cause.status === 410)
250
- ) {
251
- renegotiate();
252
- }
253
- });
254
- }, intervalMs);
239
+ heartbeatTimer = setTimeout(() => {
240
+ heartbeatTimer = null;
241
+ if (cancelled || !localViewerId) return;
242
+ void client
243
+ .heartbeatViewer(workspaceId, sessionId, localViewerId, {
244
+ leaseEpoch: epochRef.current,
245
+ })
246
+ .then((res) => {
247
+ // alive:false ⇒ the holder was reaped or the epoch moved under us;
248
+ // re-negotiate to re-acquire against the new owner.
249
+ if (!res.alive && !cancelled) {
250
+ renegotiate();
251
+ }
252
+ })
253
+ .catch((cause) => {
254
+ if (cancelled) return;
255
+ if (
256
+ cause instanceof OpenGeniApiError &&
257
+ (cause.status === 409 || cause.status === 410)
258
+ ) {
259
+ renegotiate();
260
+ }
261
+ })
262
+ .finally(schedule);
263
+ }, intervalMs);
264
+ };
265
+ schedule();
255
266
  };
256
267
 
257
268
  const pollUntilWarm = () => {
@@ -455,7 +466,7 @@ export function useSessionCapabilities(
455
466
  client,
456
467
  workspaceId,
457
468
  sessionId,
458
- enabled,
469
+ lifecycleEnabled,
459
470
  attachDesktop,
460
471
  attachTerminal,
461
472
  attachFiles,
@@ -4,6 +4,7 @@ import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../sessi
4
4
  import { buildTimeline, groupTimeline, sessionStatusFromEvents } from "../timeline/projection";
5
5
  import type { TimelineItem } from "../timeline/types";
6
6
  import type { EmbeddedSessionClientLike } from "../client";
7
+ import { usePageLiveActivity } from "./internal";
7
8
 
8
9
  export type SessionEventsConnectionState = StreamConnectionState | "idle" | "ended" | "error";
9
10
 
@@ -135,6 +136,8 @@ export function useSessionEvents(
135
136
  ): UseSessionEventsResult {
136
137
  const { client, workspaceId, reconcileSession } = useEmbeddedSession(options);
137
138
  const enabled = options.enabled ?? true;
139
+ const pageLive = usePageLiveActivity();
140
+ const streamEnabled = enabled && pageLive;
138
141
  const after = options.after ?? 0;
139
142
  const replay = options.replay ?? "windowed";
140
143
  const fullReplay = replay === "full" || after !== 0;
@@ -224,7 +227,7 @@ export function useSessionEvents(
224
227
  loadingOlderRef.current = false;
225
228
  setLoadingOlder(false);
226
229
  }
227
- if (!sessionId || !enabled) {
230
+ if (!sessionId || !streamEnabled) {
228
231
  setConnectionState("idle");
229
232
  return;
230
233
  }
@@ -338,7 +341,18 @@ export function useSessionEvents(
338
341
  const stream = client.streamEvents(workspaceId, sessionId, {
339
342
  after: streamResumeSequenceRef.current,
340
343
  signal: controller.signal,
341
- beforeLive: async () => await reconcileSession(sessionId),
344
+ onOpen: () => {
345
+ // Reconciliation repairs projections, but it must not block reading
346
+ // the already-open SSE body. Otherwise live events accumulate in the
347
+ // browser and appear as one delayed burst after every reconciler ends.
348
+ void Promise.resolve()
349
+ .then(() => reconcileSession(sessionId))
350
+ .catch((cause) => {
351
+ if (isCurrent()) {
352
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
353
+ }
354
+ });
355
+ },
342
356
  onStateChange: (state) => {
343
357
  if (isCurrent()) {
344
358
  setConnectionState(state);
@@ -399,7 +413,7 @@ export function useSessionEvents(
399
413
  workspaceId,
400
414
  sessionId,
401
415
  after,
402
- enabled,
416
+ streamEnabled,
403
417
  fullReplay,
404
418
  streamKey,
405
419
  streamEpoch,
@@ -1175,7 +1189,7 @@ async function loadPreviousPage(
1175
1189
  compact: true,
1176
1190
  // Monitoring summaries omit correlation fields once a bounded durable
1177
1191
  // payload exceeds their preview threshold. The browser timeline needs the
1178
- // sanitized stored payload so persisted tool outputs still match their
1192
+ // exact stored payload so persisted tool outputs still match their
1179
1193
  // calls and expose truthful truncation telemetry after a reload.
1180
1194
  payloadMode: "full",
1181
1195
  });
@@ -214,7 +214,7 @@ export type UseTranscriptionOptions = {
214
214
  disabled?: boolean | undefined;
215
215
  createLocalSessionId?: (() => string) | undefined;
216
216
  lifecycleTimeouts?: Partial<TranscriptionLifecycleTimeouts> | undefined;
217
- /** Receives bounded, redacted diagnostics; this detail is never rendered by the hook. */
217
+ /** Receives the exact diagnostic emitted by the transcription transport. */
218
218
  onDiagnostic?: ((diagnostic: TranscriptionDiagnostic) => void) | undefined;
219
219
  };
220
220
 
@@ -277,7 +277,7 @@ export function useTranscription({
277
277
  (diagnostic: unknown) => {
278
278
  if (!onDiagnostic) return;
279
279
  try {
280
- onDiagnostic(sanitizeTranscriptionDiagnostic(diagnostic));
280
+ onDiagnostic(normalizeTranscriptionDiagnostic(diagnostic));
281
281
  } catch {
282
282
  // Observability must never break the local privacy or UI lifecycle.
283
283
  }
@@ -625,7 +625,7 @@ export function normalizeTranscriptionErrorCode(code: unknown): TranscriptionErr
625
625
  }
626
626
  }
627
627
 
628
- export function sanitizeTranscriptionDiagnostic(diagnostic: unknown): TranscriptionDiagnostic {
628
+ export function normalizeTranscriptionDiagnostic(diagnostic: unknown): TranscriptionDiagnostic {
629
629
  const source = isRecord(diagnostic) ? diagnostic : {};
630
630
  const operation =
631
631
  source.operation === "start" ||
@@ -635,20 +635,10 @@ export function sanitizeTranscriptionDiagnostic(diagnostic: unknown): Transcript
635
635
  ? source.operation
636
636
  : "session";
637
637
  const rawDetail = typeof source.detail === "string" ? source.detail : "No diagnostic detail.";
638
- const detail = rawDetail
639
- .replace(/[\u0000-\u001f\u007f]+/gu, " ")
640
- .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/giu, "Bearer [REDACTED]")
641
- .replace(/\bsk-[A-Za-z0-9_-]{4,}\b/gu, "[REDACTED]")
642
- .replace(
643
- /\b(api[-_ ]?key|authorization|access[-_ ]?token|refresh[-_ ]?token|token|secret)\b\s*[:=]\s*[^\s,;]+/giu,
644
- "$1=[REDACTED]",
645
- )
646
- .trim()
647
- .slice(0, 512);
648
638
  return {
649
639
  operation,
650
640
  code: normalizeTranscriptionErrorCode(source.code),
651
- detail: detail || "No diagnostic detail.",
641
+ detail: rawDetail,
652
642
  };
653
643
  }
654
644
 
@@ -12,6 +12,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
12
12
  import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context";
13
13
  import {
14
14
  useDebouncedCallback,
15
+ usePageLiveActivity,
15
16
  useSessionEventTrigger,
16
17
  type SessionEventFeedOptions,
17
18
  } from "./internal";
@@ -91,6 +92,7 @@ export function useTurnQueue(
91
92
  useEmbeddedSession(options);
92
93
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
93
94
  const targetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
95
+ const pageLive = usePageLiveActivity();
94
96
  const [snapshot, setSnapshot] = useState<SessionQueueSnapshot | null>(null);
95
97
  const [stateTargetKey, setStateTargetKey] = useState(targetKey);
96
98
  const [loading, setLoading] = useState(enabled);
@@ -174,24 +176,34 @@ export function useTurnQueue(
174
176
  );
175
177
 
176
178
  useEffect(() => {
177
- if (!enabled) {
179
+ if (!enabled || !pageLive) {
178
180
  setLoading(false);
179
181
  return;
180
182
  }
181
183
  setLoading(true);
182
- void load();
183
184
  const pollIntervalMs = options.pollIntervalMs;
184
185
  if (pollIntervalMs === undefined || pollIntervalMs <= 0) {
186
+ void load();
185
187
  return () => {
186
188
  readGeneration.current += 1;
187
189
  };
188
190
  }
189
- const timer = setInterval(() => void load(), pollIntervalMs);
191
+ let cancelled = false;
192
+ let timer: ReturnType<typeof setTimeout> | null = null;
193
+ const schedule = () => {
194
+ if (cancelled) return;
195
+ timer = setTimeout(() => {
196
+ timer = null;
197
+ void load().finally(schedule);
198
+ }, pollIntervalMs);
199
+ };
200
+ void load().finally(schedule);
190
201
  return () => {
191
- clearInterval(timer);
202
+ cancelled = true;
203
+ if (timer !== null) clearTimeout(timer);
192
204
  readGeneration.current += 1;
193
205
  };
194
- }, [load, enabled, options.pollIntervalMs]);
206
+ }, [load, enabled, pageLive, options.pollIntervalMs]);
195
207
 
196
208
  useEffect(() => {
197
209
  if (enabled && workspaceControlEvent) void load();