@opengeni/react 0.29.0 → 0.32.1

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 (207) hide show
  1. package/dist/approvals.d.ts +14 -0
  2. package/dist/{chunk-M7X4JZOD.js → chunk-4QOQ2DSR.js} +8 -1
  3. package/dist/chunk-4QOQ2DSR.js.map +1 -0
  4. package/dist/{chunk-HQFPO7VX.js → chunk-7CJOAW3V.js} +24 -3
  5. package/dist/{chunk-HQFPO7VX.js.map → chunk-7CJOAW3V.js.map} +1 -1
  6. package/dist/{chunk-CSJBF3QJ.js → chunk-A2WITZTI.js} +377 -113
  7. package/dist/chunk-A2WITZTI.js.map +1 -0
  8. package/dist/{chunk-BC6RKIHS.js → chunk-GZ4KR67U.js} +11 -11
  9. package/dist/{chunk-AVLMIWYO.js → chunk-KZRUYCL4.js} +12 -6
  10. package/dist/chunk-KZRUYCL4.js.map +1 -0
  11. package/dist/{chunk-OKXCECLG.js → chunk-TCCE5ZU5.js} +171 -22
  12. package/dist/chunk-TCCE5ZU5.js.map +1 -0
  13. package/dist/chunk-U255M5EZ.js +178 -0
  14. package/dist/chunk-U255M5EZ.js.map +1 -0
  15. package/dist/{chunk-IPVOVOW6.js → chunk-WY2QN2CX.js} +2539 -2174
  16. package/dist/chunk-WY2QN2CX.js.map +1 -0
  17. package/dist/chunk-ZNNE2VES.js +6413 -0
  18. package/dist/chunk-ZNNE2VES.js.map +1 -0
  19. package/dist/client.d.ts +33 -0
  20. package/dist/commands/index.d.ts +3 -0
  21. package/dist/commands/registry.d.ts +41 -0
  22. package/dist/commands/types.d.ts +85 -0
  23. package/dist/components/approval-surface.d.ts +28 -0
  24. package/dist/components/chat-composer.d.ts +51 -0
  25. package/dist/components/code-editor.d.ts +43 -0
  26. package/dist/components/command-palette.d.ts +25 -0
  27. package/dist/components/composer-transcription-control.d.ts +36 -0
  28. package/dist/components/composer.d.ts +277 -0
  29. package/dist/components/copy-button.d.ts +26 -0
  30. package/dist/components/desktop-viewer.d.ts +85 -0
  31. package/dist/components/diff-view.d.ts +38 -0
  32. package/dist/components/enrollment-consent.d.ts +33 -0
  33. package/dist/components/enrollment-device-flow.d.ts +27 -0
  34. package/dist/components/file-browser.d.ts +47 -0
  35. package/dist/components/fleet-tile.d.ts +18 -0
  36. package/dist/components/human-input-form.d.ts +47 -0
  37. package/dist/components/machine-card.d.ts +21 -0
  38. package/dist/components/machine-dock-bar.d.ts +31 -0
  39. package/dist/components/machine-health-pill.d.ts +9 -0
  40. package/dist/components/machine-metrics.d.ts +15 -0
  41. package/dist/components/machine-status-pill.d.ts +54 -0
  42. package/dist/components/machines/health.d.ts +25 -0
  43. package/dist/components/machines/machine-detail.d.ts +14 -0
  44. package/dist/components/machines/metric-history-chart.d.ts +24 -0
  45. package/dist/components/machines/metric-sparkline.d.ts +9 -0
  46. package/dist/components/machines/series.d.ts +24 -0
  47. package/dist/components/machines-dashboard.d.ts +30 -0
  48. package/dist/components/markdown.d.ts +31 -0
  49. package/dist/components/message-timeline.d.ts +98 -0
  50. package/dist/components/model-picker.d.ts +19 -0
  51. package/dist/components/pierre-diff.d.ts +34 -0
  52. package/dist/components/pierre-file.d.ts +27 -0
  53. package/dist/components/queue-draft-policy.d.ts +7 -0
  54. package/dist/components/queue-surface-implementation.d.ts +20 -0
  55. package/dist/components/queue-surface-state.d.ts +12 -0
  56. package/dist/components/queue-surface.d.ts +12 -0
  57. package/dist/components/sandbox-files.d.ts +49 -0
  58. package/dist/components/sandbox-terminal.d.ts +123 -0
  59. package/dist/components/sandbox-workspace.d.ts +128 -0
  60. package/dist/components/session-chrome.d.ts +70 -0
  61. package/dist/components/session-status.d.ts +25 -0
  62. package/dist/components/soften-streaming-markdown.d.ts +13 -0
  63. package/dist/components/stream-reveal.d.ts +37 -0
  64. package/dist/components/tip-follow.d.ts +139 -0
  65. package/dist/components/tooltip.d.ts +16 -0
  66. package/dist/components/workbench-changes.d.ts +34 -0
  67. package/dist/components/workspace-dock.d.ts +35 -0
  68. package/dist/composer.d.ts +11 -7
  69. package/dist/composer.js +9 -6
  70. package/dist/hooks/internal.d.ts +46 -0
  71. package/dist/hooks/use-available-models.d.ts +37 -0
  72. package/dist/hooks/use-billing-usage.d.ts +26 -0
  73. package/dist/hooks/use-codex-accounts.d.ts +69 -0
  74. package/dist/{use-composer-ClHy1rkL.d.ts → hooks/use-composer.d.ts} +16 -24
  75. package/dist/hooks/use-desktop-stream.d.ts +43 -0
  76. package/dist/hooks/use-environments.d.ts +35 -0
  77. package/dist/{use-file-attachments-BmT8WtqO.d.ts → hooks/use-file-attachments.d.ts} +6 -9
  78. package/dist/hooks/use-goal.d.ts +46 -0
  79. package/dist/hooks/use-human-input.d.ts +26 -0
  80. package/dist/hooks/use-last-started-turn-policy.d.ts +27 -0
  81. package/dist/hooks/use-machine-chip.d.ts +54 -0
  82. package/dist/hooks/use-machines.d.ts +81 -0
  83. package/dist/hooks/use-packs.d.ts +27 -0
  84. package/dist/hooks/use-relay-frame-stream.d.ts +53 -0
  85. package/dist/hooks/use-rigs.d.ts +87 -0
  86. package/dist/hooks/use-sandbox-files.d.ts +108 -0
  87. package/dist/hooks/use-sandbox-git.d.ts +58 -0
  88. package/dist/hooks/use-sandbox-terminal.d.ts +62 -0
  89. package/dist/hooks/use-scheduled-tasks.d.ts +15 -0
  90. package/dist/hooks/use-session-capabilities.d.ts +83 -0
  91. package/dist/hooks/use-session-control.d.ts +22 -0
  92. package/dist/hooks/use-session-events.d.ts +92 -0
  93. package/dist/hooks/use-session-lineage.d.ts +17 -0
  94. package/dist/hooks/use-session-mcp-approval-policy.d.ts +23 -0
  95. package/dist/hooks/use-session.d.ts +23 -0
  96. package/dist/hooks/use-slash-commands.d.ts +66 -0
  97. package/dist/hooks/use-terminal-stream.d.ts +46 -0
  98. package/dist/hooks/use-transcription.d.ts +73 -0
  99. package/dist/hooks/use-turn-queue.d.ts +44 -0
  100. package/dist/hooks/use-voice-input.d.ts +25 -0
  101. package/dist/hooks/use-windowed-sections.d.ts +42 -0
  102. package/dist/hooks/use-workspace-capture.d.ts +49 -0
  103. package/dist/hooks/use-workspace-edit.d.ts +73 -0
  104. package/dist/hooks/use-workspace-sessions.d.ts +30 -0
  105. package/dist/hooks/use-workspaces.d.ts +22 -0
  106. package/dist/human-input.d.ts +18 -0
  107. package/dist/index.d.ts +144 -2119
  108. package/dist/index.js +421 -324
  109. package/dist/index.js.map +1 -1
  110. package/dist/lib/clipboard.d.ts +7 -0
  111. package/dist/lib/cn.d.ts +3 -0
  112. package/dist/lib/format.d.ts +48 -0
  113. package/dist/lib/git-patch.d.ts +8 -0
  114. package/dist/lib/motion-inspect.d.ts +5 -0
  115. package/dist/lib/motion.d.ts +14 -0
  116. package/dist/lib/noto-sans-arabic-loader.d.ts +1 -0
  117. package/dist/lib/noto-sans-jp-loader.generated.d.ts +1 -0
  118. package/dist/lib/relay-wire.d.ts +30 -0
  119. package/dist/lib/use-portal-token-style.d.ts +9 -0
  120. package/dist/lib/use-theme-type.d.ts +5 -0
  121. package/dist/lib/use-unicode-fonts.d.ts +12 -0
  122. package/dist/lib/xterm-renderer.d.ts +19 -0
  123. package/dist/lib/xterm-theme.d.ts +75 -0
  124. package/dist/machines.d.ts +30 -413
  125. package/dist/machines.js +3 -3
  126. package/dist/model-policy.d.ts +32 -0
  127. package/dist/model-policy.js +33 -0
  128. package/dist/model-policy.js.map +1 -0
  129. package/dist/provider.d.ts +16 -0
  130. package/dist/{queue-surface-implementation-UQ535OKQ.js → queue-surface-implementation-THPAKGMD.js} +2 -2
  131. package/dist/{queue-surface-implementation-UQ535OKQ.js.map → queue-surface-implementation-THPAKGMD.js.map} +1 -1
  132. package/dist/session-context.d.ts +82 -0
  133. package/dist/session-ui.d.ts +10 -7
  134. package/dist/session-ui.js +9 -5
  135. package/dist/session.d.ts +28 -6
  136. package/dist/session.js +11 -11
  137. package/dist/timeline/activity-rail.d.ts +19 -0
  138. package/dist/timeline/disclosure-context.d.ts +14 -0
  139. package/dist/timeline/entrance.d.ts +14 -0
  140. package/dist/timeline/fleet-decision-projection.d.ts +9 -0
  141. package/dist/timeline/fleet-decision-row.d.ts +11 -0
  142. package/dist/timeline/fold-memory.d.ts +15 -0
  143. package/dist/timeline/index.d.ts +18 -0
  144. package/dist/timeline/parsers.d.ts +96 -0
  145. package/dist/timeline/projection.d.ts +38 -0
  146. package/dist/timeline/registry.d.ts +40 -0
  147. package/dist/timeline/screenshot-lightbox.d.ts +26 -0
  148. package/dist/timeline/seen-activity-ids.d.ts +2 -0
  149. package/dist/timeline/shared.d.ts +125 -0
  150. package/dist/timeline/tool-diff.d.ts +8 -0
  151. package/dist/timeline/tool-display-name.d.ts +10 -0
  152. package/dist/timeline/tool-renderers.d.ts +5 -0
  153. package/dist/timeline/turn-summary.d.ts +116 -0
  154. package/dist/{use-turn-queue-D_1a8W_l.d.ts → timeline/types.d.ts} +41 -69
  155. package/dist/types/machines.d.ts +11 -0
  156. package/package.json +8 -4
  157. package/src/client.ts +1 -0
  158. package/src/components/composer-transcription-control.tsx +278 -130
  159. package/src/components/composer.tsx +20 -15
  160. package/src/components/copy-button.tsx +127 -0
  161. package/src/components/markdown.tsx +231 -32
  162. package/src/components/message-timeline.tsx +1304 -378
  163. package/src/components/model-picker.tsx +83 -43
  164. package/src/components/queue-surface-implementation.tsx +1 -1
  165. package/src/components/session-chrome.tsx +942 -0
  166. package/src/components/soften-streaming-markdown.ts +205 -0
  167. package/src/components/stream-reveal.ts +195 -0
  168. package/src/components/tip-follow.ts +459 -0
  169. package/src/components/tooltip.tsx +103 -0
  170. package/src/composer.ts +6 -0
  171. package/src/hooks/internal.ts +30 -4
  172. package/src/hooks/use-available-models.ts +51 -4
  173. package/src/hooks/use-composer.ts +23 -3
  174. package/src/hooks/use-last-started-turn-policy.ts +95 -0
  175. package/src/hooks/use-session-events.ts +359 -5
  176. package/src/hooks/use-voice-input.ts +232 -0
  177. package/src/index.ts +47 -1
  178. package/src/lib/clipboard.ts +58 -0
  179. package/src/lib/format.ts +18 -0
  180. package/src/lib/motion-inspect.ts +5 -0
  181. package/src/lib/motion.ts +80 -0
  182. package/src/model-policy.ts +210 -0
  183. package/src/session-ui.ts +7 -0
  184. package/src/timeline/activity-rail.tsx +46 -7
  185. package/src/timeline/entrance.tsx +10 -0
  186. package/src/timeline/fold-memory.ts +52 -0
  187. package/src/timeline/index.ts +9 -1
  188. package/src/timeline/projection.ts +226 -41
  189. package/src/timeline/seen-activity-ids.ts +19 -0
  190. package/src/timeline/shared.tsx +1 -1
  191. package/src/timeline/tool-renderers.tsx +75 -6
  192. package/src/timeline/turn-summary.tsx +387 -97
  193. package/src/timeline/types.ts +21 -0
  194. package/styles/index.css +99 -7
  195. package/styles/tokens.css +44 -3
  196. package/dist/chunk-AVLMIWYO.js.map +0 -1
  197. package/dist/chunk-CSJBF3QJ.js.map +0 -1
  198. package/dist/chunk-IPVOVOW6.js.map +0 -1
  199. package/dist/chunk-M7X4JZOD.js.map +0 -1
  200. package/dist/chunk-OKXCECLG.js.map +0 -1
  201. package/dist/chunk-ZMNPFZWL.js +0 -3918
  202. package/dist/chunk-ZMNPFZWL.js.map +0 -1
  203. package/dist/composer-BMzimGTO.d.ts +0 -528
  204. package/dist/session-DaONt-dY.d.ts +0 -264
  205. package/dist/session-context-CAjlaeZb.d.ts +0 -84
  206. package/dist/session-ui-B5_roA95.d.ts +0 -259
  207. /package/dist/{chunk-BC6RKIHS.js.map → chunk-GZ4KR67U.js.map} +0 -0
@@ -19,7 +19,7 @@ export type UseComposerOptions = EmbeddedSessionClientOverride &
19
19
  /** Called with the exact accepted wire input after a successful send. */
20
20
  onSent?: ((text: string, input: SendMessageInput) => void) | undefined;
21
21
  /**
22
- * Extra message fields (resources, tools, model, reasoningEffort) merged
22
+ * Extra message fields (resources, tools, model, reasoningEffort, latencyMode) merged
23
23
  * into every send. A function is evaluated at send time so it can read the
24
24
  * surrounding UI state (attachment pickers, model selectors, ...).
25
25
  */
@@ -448,12 +448,21 @@ export function useComposer(
448
448
  replaceLocal ||
449
449
  (!localWasDirtyAtStart && localAtStart === localEditRevision.current)
450
450
  ) {
451
+ // Model/effort ride in sendExtras (outside localEditRevision). If
452
+ // the picker changed during this fetch, skip onDraftApplied so a
453
+ // stale server model/effort cannot undo the operator's pick.
454
+ const extrasNow = resolveSendExtras(sendExtrasRef.current);
455
+ const pickerChangedDuringFetch =
456
+ extrasNow.model !== extrasAtStart.model ||
457
+ extrasNow.reasoningEffort !== extrasAtStart.reasoningEffort;
451
458
  valueRef.current = fetched.text;
452
459
  restoredResourcesRef.current = fetched.resources;
453
460
  lastSavedSignature.current = draftSignature(draftPayload(fetched));
454
461
  setValue(fetched.text);
455
462
  setRestoredResources(fetched.resources);
456
- onDraftAppliedRef.current?.(fetched);
463
+ if (replaceLocal || !pickerChangedDuringFetch) {
464
+ onDraftAppliedRef.current?.(fetched);
465
+ }
457
466
  }
458
467
  }
459
468
  } catch (cause) {
@@ -1194,6 +1203,7 @@ function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest {
1194
1203
  resources: draft.resources,
1195
1204
  model: draft.model,
1196
1205
  reasoningEffort: draft.reasoningEffort,
1206
+ latencyMode: draft.latencyMode ?? "standard",
1197
1207
  };
1198
1208
  }
1199
1209
 
@@ -1209,6 +1219,7 @@ function composerDraftPayload(
1209
1219
  resources: mergeResources(restoredResources, extras.resources ?? []),
1210
1220
  model: extras.model ?? base.model,
1211
1221
  reasoningEffort: extras.reasoningEffort ?? base.reasoningEffort,
1222
+ latencyMode: extras.latencyMode ?? base.latencyMode ?? "standard",
1212
1223
  };
1213
1224
  }
1214
1225
 
@@ -1220,7 +1231,16 @@ function draftSignature(payload: SaveComposerDraftRequest): string {
1220
1231
  function mergeResources(base: ResourceRef[], additions: ResourceRef[]): ResourceRef[] {
1221
1232
  const seen = new Set<string>();
1222
1233
  return [...base, ...additions].filter((resource) => {
1223
- const key = JSON.stringify(resource);
1234
+ // Reconnect reconciliation can restore the canonical server form while
1235
+ // the still-mounted upload card supplies the same ready file without its
1236
+ // default mount. Treat those two wire shapes as one selected attachment;
1237
+ // preserving the first representation keeps custom mounts and ordering
1238
+ // intact while preventing the draft and command paths from seeing
1239
+ // different duplicate counts after server normalization.
1240
+ const key =
1241
+ resource.kind === "file"
1242
+ ? `file:${resource.fileId}\u0000${resource.mountPath ?? `files/${resource.fileId}`}`
1243
+ : JSON.stringify(resource);
1224
1244
  if (seen.has(key)) return false;
1225
1245
  seen.add(key);
1226
1246
  return true;
@@ -0,0 +1,95 @@
1
+ import type { LatencyMode, ReasoningEffort, SessionEvent, SessionTurn } from "@opengeni/sdk";
2
+ import { useCallback, useEffect } from "react";
3
+ import { useOpenGeni, type ClientOverride } from "../session-context";
4
+ import { usePolledValue, useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
5
+
6
+ /** Refresh when a turn actually admits — not on composer picker / queue-only changes. */
7
+ export function isLastStartedTurnPolicyEvent(event: Pick<SessionEvent, "type">): boolean {
8
+ return event.type === "turn.started";
9
+ }
10
+
11
+ export type LastStartedTurnPolicy = {
12
+ model: string;
13
+ reasoningEffort: ReasoningEffort;
14
+ latencyMode: LatencyMode;
15
+ turnId: string;
16
+ };
17
+
18
+ export type UseLastStartedTurnPolicyOptions = ClientOverride &
19
+ SessionEventFeedOptions & {
20
+ pollIntervalMs?: number | undefined;
21
+ };
22
+
23
+ export type UseLastStartedTurnPolicyResult = {
24
+ /** Null until some turn has durably emitted `turn.started`. */
25
+ policy: LastStartedTurnPolicy | null;
26
+ loading: boolean;
27
+ error: Error | null;
28
+ refresh: () => Promise<void>;
29
+ };
30
+
31
+ function policyFromTurn(turn: SessionTurn | null | undefined): LastStartedTurnPolicy | null {
32
+ if (!turn) return null;
33
+ return {
34
+ model: turn.model,
35
+ reasoningEffort: turn.reasoningEffort,
36
+ latencyMode: turn.latencyMode ?? "standard",
37
+ turnId: turn.id,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Model·effort·latency of the newest admitted turn (`turn.started`). This is
43
+ * historical runtime truth for the session header — not the composer next-turn
44
+ * picker and not the frozen session creation defaults.
45
+ */
46
+ export function useLastStartedTurnPolicy(
47
+ sessionId: string | null | undefined,
48
+ options: UseLastStartedTurnPolicyOptions = {},
49
+ ): UseLastStartedTurnPolicyResult {
50
+ const { client, workspaceId, workspaceControlEvent, registerSessionReconciler } =
51
+ useOpenGeni(options);
52
+ const enabled = (options.enabled ?? true) && Boolean(sessionId);
53
+
54
+ const load = useCallback(async (): Promise<LastStartedTurnPolicy | null> => {
55
+ if (!sessionId) return null;
56
+ const turns = await client.listTurns(workspaceId, sessionId, { latestStarted: true });
57
+ return policyFromTurn(turns[0]);
58
+ }, [client, workspaceId, sessionId]);
59
+
60
+ const { data, loading, error, refresh } = usePolledValue(load, {
61
+ pollIntervalMs: options.pollIntervalMs,
62
+ enabled,
63
+ });
64
+
65
+ useEffect(() => {
66
+ if (enabled && workspaceControlEvent) void refresh();
67
+ }, [enabled, refresh, workspaceControlEvent]);
68
+
69
+ useEffect(() => {
70
+ if (!sessionId || !enabled) return;
71
+ return registerSessionReconciler(sessionId, "last-started-turn-policy", refresh);
72
+ }, [enabled, refresh, registerSessionReconciler, sessionId]);
73
+
74
+ // Shared-feed only: the session header lives outside the route event log and
75
+ // must not open a second SSE. Without `events`, rely on the initial fetch +
76
+ // optional poll / workspace-control / reconciler.
77
+ useSessionEventTrigger(
78
+ client,
79
+ workspaceId,
80
+ sessionId,
81
+ isLastStartedTurnPolicyEvent,
82
+ () => void refresh(),
83
+ {
84
+ events: options.events,
85
+ enabled: enabled && options.events !== undefined,
86
+ },
87
+ );
88
+
89
+ return {
90
+ policy: enabled ? data : null,
91
+ loading,
92
+ error,
93
+ refresh,
94
+ };
95
+ }
@@ -38,14 +38,46 @@ export type UseSessionEventsResult = {
38
38
  loadingOlder: boolean;
39
39
  /** Prepend an older density-bounded window; resolves true when more remain. */
40
40
  loadOlder: () => Promise<boolean>;
41
+ /**
42
+ * Durable events exist after the current window (history view jumped away
43
+ * from the live tip, or a forward page is incomplete).
44
+ */
45
+ hasNewer: boolean;
46
+ /** True while a newer history page is being fetched. */
47
+ loadingNewer: boolean;
48
+ /**
49
+ * Append one density-bounded newer page without pulling the whole gap.
50
+ * Resolves true when more newer history remains.
51
+ */
52
+ loadNewer: () => Promise<boolean>;
53
+ /** True while replacing the window with the session start. */
54
+ loadingOldest: boolean;
55
+ /**
56
+ * Jump to the durable session start: one bounded oldest window, no middle.
57
+ * Leaves live streaming until {@link jumpToLatest} (or loadNewer catches up).
58
+ */
59
+ loadOldest: () => Promise<boolean>;
60
+ /** True while reloading the live tip window. */
61
+ loadingLatest: boolean;
62
+ /**
63
+ * Reload the newest bounded tip and resume live streaming. Use for
64
+ * "Jump to latest" when the tip is not in memory (history view).
65
+ */
66
+ jumpToLatest: () => Promise<void>;
41
67
  error: Error | null;
42
68
  };
43
69
 
44
70
  const INITIAL_TAIL_PAGE_SIZE = 1000;
45
71
  const OLDER_PAGE_SIZE = 5000;
72
+ const NEWER_PAGE_SIZE = 5000;
73
+ const OLDEST_PAGE_SIZE = 1000;
46
74
  const INITIAL_FETCH_CAP = 1;
47
75
  const OLDER_GROUP_TARGET = 32;
48
76
  const OLDER_FETCH_CAP = 2;
77
+ const NEWER_GROUP_TARGET = 32;
78
+ const NEWER_FETCH_CAP = 2;
79
+ const OLDEST_GROUP_TARGET = 32;
80
+ const OLDEST_FETCH_CAP = 2;
49
81
  const BOUNDARY_PAGE_CAP = 4;
50
82
  const EMPTY_EVENTS: SessionEvent[] = [];
51
83
  const encoder = new TextEncoder();
@@ -112,17 +144,29 @@ export function useSessionEvents(
112
144
  const [connectionState, setConnectionState] = useState<SessionEventsConnectionState>("idle");
113
145
  const [error, setError] = useState<Error | null>(null);
114
146
  const [hasOlder, setHasOlder] = useState(false);
147
+ const [hasNewer, setHasNewer] = useState(false);
115
148
  const [sessionStatusProjection, setSessionStatusProjection] = useState<SessionStatus | null>(
116
149
  null,
117
150
  );
118
151
  const [initialLoading, setInitialLoading] = useState(true);
119
152
  const [loadingOlder, setLoadingOlder] = useState(false);
153
+ const [loadingNewer, setLoadingNewer] = useState(false);
154
+ const [loadingOldest, setLoadingOldest] = useState(false);
155
+ const [loadingLatest, setLoadingLatest] = useState(false);
120
156
  const [streamEpoch, setStreamEpoch] = useState(0);
157
+ // "history" = reading a non-tip window; live SSE must not fill the gap.
158
+ const [viewMode, setViewMode] = useState<"live" | "history">("live");
121
159
  const lastSequenceRef = useRef(after);
122
160
  const streamResumeSequenceRef = useRef(after);
123
161
  const oldestSequenceRef = useRef<number | null>(null);
162
+ const newestSequenceRef = useRef<number | null>(null);
124
163
  const hasOlderRef = useRef(false);
164
+ const hasNewerRef = useRef(false);
125
165
  const loadingOlderRef = useRef(false);
166
+ const loadingNewerRef = useRef(false);
167
+ const loadingOldestRef = useRef(false);
168
+ const loadingLatestRef = useRef(false);
169
+ const viewModeRef = useRef<"live" | "history">("live");
126
170
  const initialWindowLoadedRef = useRef(false);
127
171
  const streamAbortRef = useRef<AbortController | null>(null);
128
172
  const streamKeyRef = useRef<string | null>(null);
@@ -149,15 +193,26 @@ export function useSessionEvents(
149
193
  setEventWindow(EMPTY_EVENT_WINDOW);
150
194
  setError(null);
151
195
  setHasOlder(false);
196
+ setHasNewer(false);
152
197
  sessionStatusRef.current = { sequence: after, status: null };
153
198
  setSessionStatusProjection(null);
154
199
  setLoadingOlder(false);
200
+ setLoadingNewer(false);
201
+ setLoadingOldest(false);
202
+ setLoadingLatest(false);
155
203
  setInitialLoading(true);
156
204
  lastSequenceRef.current = after;
157
205
  streamResumeSequenceRef.current = after;
158
206
  oldestSequenceRef.current = null;
207
+ newestSequenceRef.current = null;
159
208
  hasOlderRef.current = false;
209
+ hasNewerRef.current = false;
160
210
  loadingOlderRef.current = false;
211
+ loadingNewerRef.current = false;
212
+ loadingOldestRef.current = false;
213
+ loadingLatestRef.current = false;
214
+ viewModeRef.current = "live";
215
+ setViewMode("live");
161
216
  initialWindowLoadedRef.current = false;
162
217
  }
163
218
  // AbortController is advisory: custom SDK clients and async iterators may
@@ -173,6 +228,13 @@ export function useSessionEvents(
173
228
  setConnectionState("idle");
174
229
  return;
175
230
  }
231
+ // History view owns a non-tip window; do not open SSE (it would replay the
232
+ // entire gap into the browser). jumpToLatest / catching loadNewer resume live.
233
+ if (viewMode === "history") {
234
+ setConnectionState("idle");
235
+ setInitialLoading(false);
236
+ return;
237
+ }
176
238
  const controller = new AbortController();
177
239
  const isCurrent = () => generationRef.current === generation && !controller.signal.aborted;
178
240
  streamAbortRef.current = controller;
@@ -227,6 +289,7 @@ export function useSessionEvents(
227
289
  eventWindowRef.current = retained;
228
290
  setEventWindow(retained);
229
291
  oldestSequenceRef.current = retained.events[0]?.sequence ?? null;
292
+ newestSequenceRef.current = retained.events.at(-1)?.sequence ?? null;
230
293
  if (retained.truncated) {
231
294
  hasOlderRef.current = true;
232
295
  setHasOlder(true);
@@ -259,11 +322,14 @@ export function useSessionEvents(
259
322
  eventWindowRef.current = retained;
260
323
  setEventWindow(retained);
261
324
  oldestSequenceRef.current = retained.events[0]?.sequence ?? window.oldestSequence;
325
+ newestSequenceRef.current = retained.events.at(-1)?.sequence ?? window.newestSequence;
262
326
  hasOlderRef.current = window.hasOlder || retained.truncated;
327
+ hasNewerRef.current = false;
263
328
  lastSequenceRef.current = window.newestSequence;
264
329
  streamResumeSequenceRef.current = window.newestSequence;
265
330
  initialWindowLoadedRef.current = true;
266
331
  setHasOlder(window.hasOlder || retained.truncated);
332
+ setHasNewer(false);
267
333
  setInitialLoading(false);
268
334
  }
269
335
  if (fullReplay) {
@@ -337,11 +403,18 @@ export function useSessionEvents(
337
403
  fullReplay,
338
404
  streamKey,
339
405
  streamEpoch,
406
+ viewMode,
340
407
  reconcileSession,
341
408
  ]);
342
409
 
410
+ const navigationBusy = (): boolean =>
411
+ loadingOlderRef.current ||
412
+ loadingNewerRef.current ||
413
+ loadingOldestRef.current ||
414
+ loadingLatestRef.current;
415
+
343
416
  const loadOlder = useCallback(async (): Promise<boolean> => {
344
- if (!sessionId || loadingOlderRef.current || !hasOlderRef.current) {
417
+ if (!sessionId || navigationBusy() || !hasOlderRef.current) {
345
418
  return false;
346
419
  }
347
420
  const before = oldestSequenceRef.current;
@@ -387,9 +460,11 @@ export function useSessionEvents(
387
460
  if (retainedOldest === null || retainedOldest >= before) {
388
461
  throw new Error("@opengeni/react: loadOlder made no durable sequence progress");
389
462
  }
463
+ const retainedNewest = retained.events.at(-1)?.sequence ?? null;
390
464
  eventWindowRef.current = retained;
391
465
  setEventWindow(retained);
392
466
  oldestSequenceRef.current = retainedOldest;
467
+ newestSequenceRef.current = retainedNewest;
393
468
  streamResumeSequenceRef.current = maxResumeSequence(retained.events);
394
469
  // Oldest-directed eviction can discard newer in-memory rows. That fact
395
470
  // keeps windowTruncated true, but it does not imply older durable rows
@@ -397,16 +472,189 @@ export function useSessionEvents(
397
472
  const olderStillAvailable = window.hasOlder;
398
473
  hasOlderRef.current = olderStillAvailable;
399
474
  setHasOlder(olderStillAvailable);
400
- setStreamEpoch((epoch) => epoch + 1);
475
+ if (viewModeRef.current === "history") {
476
+ const highWater = lastSequenceRef.current;
477
+ const newer = retainedNewest !== null && (retainedNewest < highWater || retained.truncated);
478
+ hasNewerRef.current = newer;
479
+ setHasNewer(newer);
480
+ } else {
481
+ // Live mode keeps the historical contract: reconnect and replay the
482
+ // evicted tip through SSE so the window stays one contiguous suffix.
483
+ setStreamEpoch((epoch) => epoch + 1);
484
+ }
401
485
  return olderStillAvailable;
402
486
  } finally {
403
- if (generationRef.current === generation) {
404
- loadingOlderRef.current = false;
405
- setLoadingOlder(false);
487
+ loadingOlderRef.current = false;
488
+ setLoadingOlder(false);
489
+ }
490
+ }, [client, workspaceId, sessionId]);
491
+
492
+ const loadOldest = useCallback(async (): Promise<boolean> => {
493
+ if (!sessionId || navigationBusy() || !hasOlderRef.current) {
494
+ return false;
495
+ }
496
+ const generation = generationRef.current;
497
+ loadingOldestRef.current = true;
498
+ setLoadingOldest(true);
499
+ try {
500
+ const window = await loadForwardEventWindow(client, workspaceId, sessionId, {
501
+ after: 0,
502
+ pageSize: OLDEST_PAGE_SIZE,
503
+ targetGroups: OLDEST_GROUP_TARGET,
504
+ maxFetches: OLDEST_FETCH_CAP,
505
+ });
506
+ if (generationRef.current !== generation) {
507
+ return false;
508
+ }
509
+ if (window.events.length === 0) {
510
+ hasOlderRef.current = false;
511
+ setHasOlder(false);
512
+ return false;
406
513
  }
514
+ streamAbortRef.current?.abort();
515
+ observeSessionStatus(window.events, sessionStatusRef, setSessionStatusProjection);
516
+ // Keep the oldest prefix — never the middle or tip.
517
+ const retained = boundBrowserSessionEventWindow(window.events, { direction: "oldest" });
518
+ const retainedOldest = retained.events[0]?.sequence ?? null;
519
+ const retainedNewest = retained.events.at(-1)?.sequence ?? null;
520
+ eventWindowRef.current = retained;
521
+ setEventWindow(retained);
522
+ oldestSequenceRef.current = retainedOldest;
523
+ newestSequenceRef.current = retainedNewest;
524
+ hasOlderRef.current = false;
525
+ setHasOlder(false);
526
+ const highWater = Math.max(lastSequenceRef.current, retainedNewest ?? 0);
527
+ lastSequenceRef.current = highWater;
528
+ const newer =
529
+ window.hasNewer ||
530
+ retained.truncated ||
531
+ (retainedNewest !== null && retainedNewest < highWater);
532
+ hasNewerRef.current = newer;
533
+ setHasNewer(newer);
534
+ initialWindowLoadedRef.current = true;
535
+ viewModeRef.current = "history";
536
+ setViewMode("history");
537
+ return newer;
538
+ } finally {
539
+ loadingOldestRef.current = false;
540
+ setLoadingOldest(false);
407
541
  }
408
542
  }, [client, workspaceId, sessionId]);
409
543
 
544
+ const loadNewer = useCallback(async (): Promise<boolean> => {
545
+ if (!sessionId || navigationBusy() || !hasNewerRef.current) {
546
+ return false;
547
+ }
548
+ const afterSequence = newestSequenceRef.current;
549
+ if (afterSequence === null) {
550
+ hasNewerRef.current = false;
551
+ setHasNewer(false);
552
+ return false;
553
+ }
554
+ const generation = generationRef.current;
555
+ loadingNewerRef.current = true;
556
+ setLoadingNewer(true);
557
+ try {
558
+ const window = await loadForwardEventWindow(client, workspaceId, sessionId, {
559
+ after: afterSequence,
560
+ pageSize: NEWER_PAGE_SIZE,
561
+ targetGroups: NEWER_GROUP_TARGET,
562
+ maxFetches: NEWER_FETCH_CAP,
563
+ });
564
+ if (generationRef.current !== generation) {
565
+ return false;
566
+ }
567
+ if (window.events.length === 0) {
568
+ hasNewerRef.current = false;
569
+ setHasNewer(false);
570
+ // Caught up with durable history — resume the live tip stream.
571
+ streamResumeSequenceRef.current = afterSequence;
572
+ viewModeRef.current = "live";
573
+ setViewMode("live");
574
+ setStreamEpoch((epoch) => epoch + 1);
575
+ return false;
576
+ }
577
+ const current = eventWindowRef.current;
578
+ assertAppendOrder(current.events, window.events);
579
+ observeSessionStatus(window.events, sessionStatusRef, setSessionStatusProjection);
580
+ const previousOldest = current.events[0]?.sequence ?? null;
581
+ // Moving forward through history: keep the newest suffix of the merge.
582
+ const next = boundBrowserSessionEventWindow([...current.events, ...window.events], {
583
+ direction: "newest",
584
+ });
585
+ const retained = {
586
+ ...next,
587
+ truncated: current.truncated || next.truncated || window.hasNewer,
588
+ };
589
+ const retainedOldest = retained.events[0]?.sequence ?? null;
590
+ const retainedNewest = retained.events.at(-1)?.sequence ?? null;
591
+ if (retainedNewest === null || retainedNewest <= afterSequence) {
592
+ throw new Error("@opengeni/react: loadNewer made no durable sequence progress");
593
+ }
594
+ eventWindowRef.current = retained;
595
+ setEventWindow(retained);
596
+ oldestSequenceRef.current = retainedOldest;
597
+ newestSequenceRef.current = retainedNewest;
598
+ // Evicting the start while paging forward re-arms hasOlder.
599
+ if (
600
+ retainedOldest !== null &&
601
+ (previousOldest === null ||
602
+ retainedOldest > previousOldest ||
603
+ (retained.truncated &&
604
+ retained.events[0]?.type !== "session.created" &&
605
+ retainedOldest > 1))
606
+ ) {
607
+ hasOlderRef.current = true;
608
+ setHasOlder(true);
609
+ }
610
+ const highWater = Math.max(lastSequenceRef.current, retainedNewest);
611
+ lastSequenceRef.current = highWater;
612
+ const newer = window.hasNewer || retainedNewest < highWater;
613
+ hasNewerRef.current = newer;
614
+ setHasNewer(newer);
615
+ if (!newer) {
616
+ streamResumeSequenceRef.current = retainedNewest;
617
+ viewModeRef.current = "live";
618
+ setViewMode("live");
619
+ setStreamEpoch((epoch) => epoch + 1);
620
+ }
621
+ return newer;
622
+ } finally {
623
+ loadingNewerRef.current = false;
624
+ setLoadingNewer(false);
625
+ }
626
+ }, [client, workspaceId, sessionId]);
627
+
628
+ const jumpToLatest = useCallback(async (): Promise<void> => {
629
+ if (!sessionId || navigationBusy()) {
630
+ return;
631
+ }
632
+ loadingLatestRef.current = true;
633
+ setLoadingLatest(true);
634
+ try {
635
+ streamAbortRef.current?.abort();
636
+ hasNewerRef.current = false;
637
+ setHasNewer(false);
638
+ hasOlderRef.current = false;
639
+ setHasOlder(false);
640
+ newestSequenceRef.current = null;
641
+ oldestSequenceRef.current = null;
642
+ eventWindowRef.current = EMPTY_EVENT_WINDOW;
643
+ setEventWindow(EMPTY_EVENT_WINDOW);
644
+ initialWindowLoadedRef.current = false;
645
+ streamResumeSequenceRef.current = after;
646
+ setInitialLoading(true);
647
+ viewModeRef.current = "live";
648
+ setViewMode("live");
649
+ setStreamEpoch((epoch) => epoch + 1);
650
+ // Effect reloads the tip. The tip fetch itself is owned by the effect.
651
+ await Promise.resolve();
652
+ } finally {
653
+ loadingLatestRef.current = false;
654
+ setLoadingLatest(false);
655
+ }
656
+ }, [after, sessionId]);
657
+
410
658
  const identityMatches = stateStreamKey === streamKey;
411
659
  const visibleEvents = identityMatches ? eventWindow.events : EMPTY_EVENTS;
412
660
  const timeline = useMemo(() => buildTimeline(visibleEvents), [visibleEvents]);
@@ -423,6 +671,13 @@ export function useSessionEvents(
423
671
  hasOlder: !identityMatches ? false : hasOlder,
424
672
  loadingOlder: !identityMatches ? false : loadingOlder,
425
673
  loadOlder,
674
+ hasNewer: !identityMatches ? false : hasNewer,
675
+ loadingNewer: !identityMatches ? false : loadingNewer,
676
+ loadNewer,
677
+ loadingOldest: !identityMatches ? false : loadingOldest,
678
+ loadOldest,
679
+ loadingLatest: !identityMatches ? false : loadingLatest,
680
+ jumpToLatest,
426
681
  error: identityMatches ? error : null,
427
682
  };
428
683
  }
@@ -737,6 +992,13 @@ type LoadedEventWindow = {
737
992
  hasOlder: boolean;
738
993
  };
739
994
 
995
+ type LoadedForwardEventWindow = {
996
+ events: SessionEvent[];
997
+ oldestSequence: number | null;
998
+ newestSequence: number;
999
+ hasNewer: boolean;
1000
+ };
1001
+
740
1002
  async function loadEventWindow(
741
1003
  client: EmbeddedSessionClientLike,
742
1004
  workspaceId: string,
@@ -835,6 +1097,59 @@ function findBoundaryIndex(events: SessionEvent[]): number {
835
1097
  return -1;
836
1098
  }
837
1099
 
1100
+ /**
1101
+ * Forward (oldest→newer) density-bounded window. Used for jump-to-start and
1102
+ * loadNewer so the browser never walks the whole middle gap.
1103
+ */
1104
+ async function loadForwardEventWindow(
1105
+ client: EmbeddedSessionClientLike,
1106
+ workspaceId: string,
1107
+ sessionId: string,
1108
+ options: {
1109
+ after: number;
1110
+ pageSize: number;
1111
+ targetGroups: number;
1112
+ maxFetches: number;
1113
+ signal?: AbortSignal;
1114
+ },
1115
+ ): Promise<LoadedForwardEventWindow> {
1116
+ let cursor = options.after;
1117
+ let buffer: SessionEvent[] = [];
1118
+ let fetches = 0;
1119
+ let reachedEnd = false;
1120
+
1121
+ while (fetches < options.maxFetches) {
1122
+ if (buffer.length > 0 && groupCount(buffer) >= options.targetGroups) {
1123
+ break;
1124
+ }
1125
+ const page = await loadNextPage(client, workspaceId, sessionId, cursor, {
1126
+ pageSize: options.pageSize,
1127
+ ...(options.signal ? { signal: options.signal } : {}),
1128
+ });
1129
+ fetches += 1;
1130
+ if (page.length === 0) {
1131
+ reachedEnd = true;
1132
+ break;
1133
+ }
1134
+ assertAscending(page);
1135
+ buffer = [...buffer, ...page];
1136
+ cursor = page[page.length - 1]!.sequence;
1137
+ if (page.length < options.pageSize) {
1138
+ reachedEnd = true;
1139
+ break;
1140
+ }
1141
+ }
1142
+
1143
+ const oldest = buffer[0] ?? null;
1144
+ const newest = buffer[buffer.length - 1] ?? null;
1145
+ return {
1146
+ events: buffer,
1147
+ oldestSequence: oldest?.sequence ?? null,
1148
+ newestSequence: newest ? maxResumeSequence(buffer) : 0,
1149
+ hasNewer: buffer.length > 0 && !reachedEnd,
1150
+ };
1151
+ }
1152
+
838
1153
  type PreviousPage = SessionEvent[] & { requested: number };
839
1154
 
840
1155
  async function loadPreviousPage(
@@ -870,6 +1185,36 @@ async function loadPreviousPage(
870
1185
  return Object.assign(page, { requested });
871
1186
  }
872
1187
 
1188
+ async function loadNextPage(
1189
+ client: EmbeddedSessionClientLike,
1190
+ workspaceId: string,
1191
+ sessionId: string,
1192
+ after: number,
1193
+ options: {
1194
+ pageSize: number;
1195
+ signal?: AbortSignal;
1196
+ },
1197
+ ): Promise<PreviousPage> {
1198
+ if (options.signal?.aborted) {
1199
+ throw abortError();
1200
+ }
1201
+ const requested = options.pageSize;
1202
+ if (requested === 0) {
1203
+ return Object.assign([], { requested });
1204
+ }
1205
+ const page = await client.listEvents(workspaceId, sessionId, {
1206
+ after,
1207
+ limit: requested,
1208
+ compact: true,
1209
+ direction: "after",
1210
+ payloadMode: "full",
1211
+ });
1212
+ if (options.signal?.aborted) {
1213
+ throw abortError();
1214
+ }
1215
+ return Object.assign(page, { requested });
1216
+ }
1217
+
873
1218
  function groupCount(events: SessionEvent[]): number {
874
1219
  if (events.length === 0) {
875
1220
  return 0;
@@ -912,6 +1257,15 @@ function assertPrependOrder(existing: SessionEvent[], older: SessionEvent[]): vo
912
1257
  }
913
1258
  }
914
1259
 
1260
+ function assertAppendOrder(existing: SessionEvent[], newer: SessionEvent[]): void {
1261
+ if (!shouldAssertDevelopment() || existing.length === 0 || newer.length === 0) {
1262
+ return;
1263
+ }
1264
+ if (newer[0]!.sequence <= existing[existing.length - 1]!.sequence) {
1265
+ throw new Error("@opengeni/react: loadNewer returned overlapping session events");
1266
+ }
1267
+ }
1268
+
915
1269
  function shouldAssertDevelopment(): boolean {
916
1270
  const processEnv = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env;
917
1271
  return processEnv !== undefined && processEnv.NODE_ENV !== "production";