@opengeni/react 0.13.0 → 0.20.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 (113) hide show
  1. package/README.md +129 -23
  2. package/dist/chunk-6BXBGJ3B.js +1956 -0
  3. package/dist/chunk-6BXBGJ3B.js.map +1 -0
  4. package/dist/chunk-6UT5OC3P.js +2133 -0
  5. package/dist/chunk-6UT5OC3P.js.map +1 -0
  6. package/dist/chunk-EBTT3MYN.js +92 -0
  7. package/dist/chunk-EBTT3MYN.js.map +1 -0
  8. package/dist/chunk-HHFA4AFX.js +425 -0
  9. package/dist/chunk-HHFA4AFX.js.map +1 -0
  10. package/dist/chunk-LAZ2A743.js +2393 -0
  11. package/dist/chunk-LAZ2A743.js.map +1 -0
  12. package/dist/chunk-MRSECXRP.js +563 -0
  13. package/dist/chunk-MRSECXRP.js.map +1 -0
  14. package/dist/chunk-TK7G6XLT.js +18 -0
  15. package/dist/chunk-TK7G6XLT.js.map +1 -0
  16. package/dist/composer-DvUaa5ki.d.ts +585 -0
  17. package/dist/composer.d.ts +6 -0
  18. package/dist/composer.js +56 -0
  19. package/dist/composer.js.map +1 -0
  20. package/dist/index.d.ts +642 -1080
  21. package/dist/index.js +7672 -8005
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +25 -1
  25. package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
  26. package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
  27. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
  28. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
  29. package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
  30. package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
  31. package/dist/session-Gd4iVPYw.d.ts +425 -0
  32. package/dist/session-context-DIFFlXKc.d.ts +52 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +50 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-BCfm4mzX.d.ts +112 -0
  37. package/package.json +23 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +50 -1
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +104 -751
  42. package/src/components/code-editor.tsx +104 -26
  43. package/src/components/command-palette.tsx +8 -2
  44. package/src/components/composer-transcription-control.tsx +211 -0
  45. package/src/components/composer.tsx +1536 -0
  46. package/src/components/desktop-viewer.tsx +3 -3
  47. package/src/components/enrollment-consent.tsx +2 -2
  48. package/src/components/file-browser.tsx +367 -29
  49. package/src/components/human-input-form.tsx +417 -0
  50. package/src/components/machine-card.tsx +82 -16
  51. package/src/components/machine-health-pill.tsx +68 -0
  52. package/src/components/machine-metrics.tsx +10 -24
  53. package/src/components/machines/health.ts +146 -0
  54. package/src/components/machines/machine-detail.tsx +220 -0
  55. package/src/components/machines/metric-history-chart.tsx +298 -0
  56. package/src/components/machines/metric-sparkline.tsx +76 -0
  57. package/src/components/machines/series.ts +113 -0
  58. package/src/components/machines-dashboard.tsx +16 -4
  59. package/src/components/message-timeline.tsx +15 -7
  60. package/src/components/model-picker.tsx +12 -3
  61. package/src/components/pierre-diff.tsx +32 -6
  62. package/src/components/queue-surface-implementation.tsx +1041 -0
  63. package/src/components/queue-surface-state.tsx +94 -0
  64. package/src/components/queue-surface.tsx +60 -0
  65. package/src/components/sandbox-files.tsx +230 -21
  66. package/src/components/sandbox-terminal.tsx +8 -2
  67. package/src/components/sandbox-workspace.tsx +489 -135
  68. package/src/components/session-status.tsx +0 -6
  69. package/src/components/workbench-changes.tsx +145 -50
  70. package/src/components/workspace-dock.tsx +329 -68
  71. package/src/composer.ts +60 -0
  72. package/src/hooks/internal.ts +115 -34
  73. package/src/hooks/use-composer.ts +635 -74
  74. package/src/hooks/use-human-input.ts +131 -0
  75. package/src/hooks/use-machine-chip.ts +2 -2
  76. package/src/hooks/use-machines.ts +27 -11
  77. package/src/hooks/use-sandbox-files.ts +310 -62
  78. package/src/hooks/use-sandbox-git.ts +154 -40
  79. package/src/hooks/use-sandbox-terminal.ts +28 -6
  80. package/src/hooks/use-session-capabilities.ts +38 -9
  81. package/src/hooks/use-session-control.ts +43 -15
  82. package/src/hooks/use-session-events.ts +523 -48
  83. package/src/hooks/use-session-lineage.ts +15 -6
  84. package/src/hooks/use-session.ts +10 -2
  85. package/src/hooks/use-slash-commands.ts +38 -38
  86. package/src/hooks/use-transcription.ts +757 -0
  87. package/src/hooks/use-turn-queue.ts +273 -77
  88. package/src/hooks/use-windowed-sections.ts +2 -2
  89. package/src/hooks/use-workspace-capture.ts +146 -40
  90. package/src/hooks/use-workspace-edit.ts +50 -14
  91. package/src/hooks/use-workspace-sessions.ts +3 -0
  92. package/src/human-input.ts +72 -0
  93. package/src/index.ts +88 -12
  94. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  95. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  96. package/src/lib/use-portal-token-style.ts +51 -0
  97. package/src/lib/use-unicode-fonts.ts +57 -0
  98. package/src/machines.ts +16 -0
  99. package/src/provider.tsx +163 -41
  100. package/src/session-context.ts +135 -0
  101. package/src/session.ts +79 -0
  102. package/src/timeline/parsers.ts +78 -6
  103. package/src/timeline/projection.ts +36 -6
  104. package/src/timeline/tool-renderers.tsx +41 -0
  105. package/src/timeline/turn-summary.tsx +2 -2
  106. package/src/timeline/types.ts +16 -8
  107. package/styles/index.css +78 -0
  108. package/styles/index.d.ts +2 -0
  109. package/styles/tokens.css +11 -6
  110. package/styles/tokens.d.ts +2 -0
  111. package/dist/chunk-NFYVQWIB.js +0 -1377
  112. package/dist/chunk-NFYVQWIB.js.map +0 -1
  113. package/dist/machines-CnlMb7E-.d.ts +0 -329
@@ -1,17 +1,13 @@
1
1
  import type { SessionEvent, SessionStatus, StreamConnectionState } from "@opengeni/sdk";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { useOpenGeni, type ClientOverride } from "../provider";
4
- import {
5
- buildTimeline,
6
- groupTimeline,
7
- sessionStatusFromEvents,
8
- type TimelineItem,
9
- } from "../timeline";
10
- import type { SessionClientLike } from "../client";
3
+ import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context";
4
+ import { buildTimeline, groupTimeline, sessionStatusFromEvents } from "../timeline/projection";
5
+ import type { TimelineItem } from "../timeline/types";
6
+ import type { EmbeddedSessionClientLike } from "../client";
11
7
 
12
8
  export type SessionEventsConnectionState = StreamConnectionState | "idle" | "ended" | "error";
13
9
 
14
- export type UseSessionEventsOptions = ClientOverride & {
10
+ export type UseSessionEventsOptions = EmbeddedSessionClientOverride & {
15
11
  /** Resume after this sequence (exclusive). Nonzero keeps full replay/resume semantics. */
16
12
  after?: number | undefined;
17
13
  /** Load a bounded tail by default, or opt back into full replay from `after`. */
@@ -30,6 +26,10 @@ export type UseSessionEventsResult = {
30
26
  connectionState: SessionEventsConnectionState;
31
27
  /** Highest sequence seen so far (0 before the first event). */
32
28
  lastSequence: number;
29
+ /** Exact serialized bytes retained in the current browser event window. */
30
+ windowBytes: number;
31
+ /** Whether older delivered events were evicted from the browser window. */
32
+ windowTruncated: boolean;
33
33
  /** True until the initial tail window has been applied (windowed mode). */
34
34
  initialLoading: boolean;
35
35
  /** Whether older durable events are available before the current window. */
@@ -48,6 +48,49 @@ const OLDER_GROUP_TARGET = 32;
48
48
  const OLDER_FETCH_CAP = 2;
49
49
  const BOUNDARY_PAGE_CAP = 4;
50
50
  const EMPTY_EVENTS: SessionEvent[] = [];
51
+ const encoder = new TextEncoder();
52
+ const decoder = new TextDecoder();
53
+
54
+ const BROWSER_EVENT_TYPE_MAX_BYTES = 256;
55
+ const BROWSER_EVENT_ID_MAX_BYTES = 256;
56
+ const BROWSER_EVENT_CLIENT_ID_MAX_BYTES = 4 * 1024;
57
+ const BROWSER_EVENT_DUPLICATE_REASON_MAX_BYTES = 4 * 1024;
58
+ const BROWSER_EVENT_PAYLOAD_PREVIEW_MAX_BYTES = 48 * 1024;
59
+ const BROWSER_EVENT_PAYLOAD_IDENTITY_FIELDS = [
60
+ "id",
61
+ "callId",
62
+ "call_id",
63
+ "name",
64
+ "toolName",
65
+ "status",
66
+ "code",
67
+ "isError",
68
+ "stream",
69
+ "commandId",
70
+ "sequence",
71
+ "coalescedUntil",
72
+ "coalescedCount",
73
+ "firstSequence",
74
+ "lastSequence",
75
+ ] as const;
76
+
77
+ export const SESSION_EVENT_BROWSER_MAX_BYTES = 8 * 1024 * 1024;
78
+ export const SESSION_EVENT_BROWSER_MAX_COUNT = 10_000;
79
+ export const SESSION_EVENT_BROWSER_SINGLE_EVENT_MAX_BYTES = 96 * 1024;
80
+ export const SESSION_EVENT_BROWSER_PENDING_MAX_BYTES = 1024 * 1024;
81
+ export const SESSION_EVENT_BROWSER_PENDING_MAX_COUNT = 256;
82
+
83
+ export type BrowserSessionEventWindow = {
84
+ events: SessionEvent[];
85
+ bytes: number;
86
+ truncated: boolean;
87
+ };
88
+
89
+ const EMPTY_EVENT_WINDOW: BrowserSessionEventWindow = {
90
+ events: EMPTY_EVENTS,
91
+ bytes: 2,
92
+ truncated: false,
93
+ };
51
94
 
52
95
  /**
53
96
  * Live-stream a session's event log with replay-by-sequence, reconnect, and
@@ -58,25 +101,40 @@ export function useSessionEvents(
58
101
  sessionId: string | null | undefined,
59
102
  options: UseSessionEventsOptions = {},
60
103
  ): UseSessionEventsResult {
61
- const { client, workspaceId } = useOpenGeni(options);
104
+ const { client, workspaceId, reconcileSession } = useEmbeddedSession(options);
62
105
  const enabled = options.enabled ?? true;
63
106
  const after = options.after ?? 0;
64
107
  const replay = options.replay ?? "windowed";
65
108
  const fullReplay = replay === "full" || after !== 0;
66
109
  const streamKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${after}\u0000${fullReplay ? "full" : "windowed"}`;
67
110
 
68
- const [events, setEvents] = useState<SessionEvent[]>([]);
111
+ const [eventWindow, setEventWindow] = useState<BrowserSessionEventWindow>(EMPTY_EVENT_WINDOW);
69
112
  const [connectionState, setConnectionState] = useState<SessionEventsConnectionState>("idle");
70
113
  const [error, setError] = useState<Error | null>(null);
71
114
  const [hasOlder, setHasOlder] = useState(false);
115
+ const [sessionStatusProjection, setSessionStatusProjection] = useState<SessionStatus | null>(
116
+ null,
117
+ );
72
118
  const [initialLoading, setInitialLoading] = useState(true);
73
119
  const [loadingOlder, setLoadingOlder] = useState(false);
120
+ const [streamEpoch, setStreamEpoch] = useState(0);
74
121
  const lastSequenceRef = useRef(after);
122
+ const streamResumeSequenceRef = useRef(after);
75
123
  const oldestSequenceRef = useRef<number | null>(null);
76
124
  const hasOlderRef = useRef(false);
77
125
  const loadingOlderRef = useRef(false);
126
+ const initialWindowLoadedRef = useRef(false);
127
+ const streamAbortRef = useRef<AbortController | null>(null);
78
128
  const streamKeyRef = useRef<string | null>(null);
79
129
  const generationRef = useRef(0);
130
+ const eventWindowRef = useRef<BrowserSessionEventWindow>(EMPTY_EVENT_WINDOW);
131
+ const sessionStatusRef = useRef<{
132
+ sequence: number;
133
+ status: SessionStatus | null;
134
+ }>({
135
+ sequence: after,
136
+ status: null,
137
+ });
80
138
  // Effects reset state after commit. Tag the state so the first render for a
81
139
  // new stream identity cannot expose the previous session's event log.
82
140
  const [stateStreamKey, setStateStreamKey] = useState(streamKey);
@@ -86,53 +144,102 @@ export function useSessionEvents(
86
144
  // pausing via `enabled: false` keeps the timeline visible.
87
145
  if (streamKeyRef.current !== streamKey) {
88
146
  streamKeyRef.current = streamKey;
89
- generationRef.current += 1;
90
147
  setStateStreamKey(streamKey);
91
- setEvents([]);
148
+ eventWindowRef.current = EMPTY_EVENT_WINDOW;
149
+ setEventWindow(EMPTY_EVENT_WINDOW);
92
150
  setError(null);
93
151
  setHasOlder(false);
152
+ sessionStatusRef.current = { sequence: after, status: null };
153
+ setSessionStatusProjection(null);
94
154
  setLoadingOlder(false);
95
155
  setInitialLoading(true);
96
156
  lastSequenceRef.current = after;
157
+ streamResumeSequenceRef.current = after;
97
158
  oldestSequenceRef.current = null;
98
159
  hasOlderRef.current = false;
99
160
  loadingOlderRef.current = false;
161
+ initialWindowLoadedRef.current = false;
162
+ }
163
+ // AbortController is advisory: custom SDK clients and async iterators may
164
+ // ignore it and resolve/yield after cleanup. Fence every effect instance so
165
+ // only the newest dependency generation can mutate refs or React state.
166
+ const generation = generationRef.current + 1;
167
+ generationRef.current = generation;
168
+ if (loadingOlderRef.current) {
169
+ loadingOlderRef.current = false;
170
+ setLoadingOlder(false);
100
171
  }
101
172
  if (!sessionId || !enabled) {
102
173
  setConnectionState("idle");
103
174
  return;
104
175
  }
105
176
  const controller = new AbortController();
177
+ const isCurrent = () => generationRef.current === generation && !controller.signal.aborted;
178
+ streamAbortRef.current = controller;
106
179
  // Batch yielded events into one React update per flush window so a long
107
- // replay (thousands of events) does not render per event.
180
+ // replay (thousands of events) does not render per event. Project every
181
+ // event before retaining it here and synchronously flush at independent
182
+ // count+byte high-water marks: a synchronously yielding async iterator can
183
+ // otherwise starve the 16 ms timer and grow this pre-React buffer without
184
+ // bound even though the final browser window is bounded.
108
185
  let pending: SessionEvent[] = [];
186
+ let pendingBytes = 2; // []
109
187
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
110
188
  const flush = () => {
111
- flushTimer = null;
189
+ // A synchronous count/byte high-water flush can run before the scheduled
190
+ // callback gets a macrotask. Cancel that callback before clearing its
191
+ // handle so a long synchronously yielding replay retains at most one
192
+ // timer rather than one stale callback per flushed batch.
193
+ if (flushTimer !== null) {
194
+ clearTimeout(flushTimer);
195
+ flushTimer = null;
196
+ }
197
+ if (!isCurrent()) {
198
+ pending = [];
199
+ pendingBytes = 2;
200
+ return;
201
+ }
112
202
  if (pending.length === 0) {
113
203
  return;
114
204
  }
115
205
  const batch = pending;
116
206
  pending = [];
207
+ pendingBytes = 2;
117
208
  // The resume cursor only advances with delivered batches: events still
118
209
  // sitting in `pending` when the stream is torn down are re-fetched on
119
210
  // the next connect instead of being skipped.
120
211
  const lastInBatch = batch[batch.length - 1];
121
212
  if (lastInBatch) {
122
- lastSequenceRef.current = Math.max(
123
- lastSequenceRef.current,
124
- ...batch.map(eventResumeSequence),
213
+ const batchResumeSequence = Math.max(...batch.map(eventResumeSequence));
214
+ lastSequenceRef.current = Math.max(lastSequenceRef.current, batchResumeSequence);
215
+ streamResumeSequenceRef.current = Math.max(
216
+ streamResumeSequenceRef.current,
217
+ batchResumeSequence,
125
218
  );
126
219
  }
127
- setEvents((existing) => [...existing, ...batch]);
220
+ observeSessionStatus(batch, sessionStatusRef, setSessionStatusProjection);
221
+ const current = eventWindowRef.current;
222
+ const next = boundBrowserSessionEventWindow([...current.events, ...batch]);
223
+ const retained = {
224
+ ...next,
225
+ truncated: current.truncated || next.truncated,
226
+ };
227
+ eventWindowRef.current = retained;
228
+ setEventWindow(retained);
229
+ oldestSequenceRef.current = retained.events[0]?.sequence ?? null;
230
+ if (retained.truncated) {
231
+ hasOlderRef.current = true;
232
+ setHasOlder(true);
233
+ }
128
234
  };
129
235
  const scheduleFlush = () => {
236
+ if (!isCurrent()) return;
130
237
  flushTimer ??= setTimeout(flush, 16);
131
238
  };
132
239
 
133
240
  void (async () => {
134
241
  try {
135
- if (!fullReplay) {
242
+ if (!fullReplay && !initialWindowLoadedRef.current) {
136
243
  setConnectionState("connecting");
137
244
  // First paint is ONE compact fetch — the newest window, revealed at
138
245
  // the bottom in a few hundred ms. Deeper history loads only when the
@@ -144,38 +251,64 @@ export function useSessionEvents(
144
251
  maxFetches: INITIAL_FETCH_CAP,
145
252
  signal: controller.signal,
146
253
  });
147
- if (controller.signal.aborted) {
254
+ if (!isCurrent()) {
148
255
  return;
149
256
  }
150
- oldestSequenceRef.current = window.oldestSequence;
151
- hasOlderRef.current = window.hasOlder;
257
+ observeSessionStatus(window.events, sessionStatusRef, setSessionStatusProjection);
258
+ const retained = boundBrowserSessionEventWindow(window.events);
259
+ eventWindowRef.current = retained;
260
+ setEventWindow(retained);
261
+ oldestSequenceRef.current = retained.events[0]?.sequence ?? window.oldestSequence;
262
+ hasOlderRef.current = window.hasOlder || retained.truncated;
152
263
  lastSequenceRef.current = window.newestSequence;
153
- setHasOlder(window.hasOlder);
154
- setEvents(window.events);
264
+ streamResumeSequenceRef.current = window.newestSequence;
265
+ initialWindowLoadedRef.current = true;
266
+ setHasOlder(window.hasOlder || retained.truncated);
155
267
  setInitialLoading(false);
156
268
  }
157
269
  if (fullReplay) {
158
270
  setInitialLoading(false);
159
271
  }
160
272
  const stream = client.streamEvents(workspaceId, sessionId, {
161
- after: lastSequenceRef.current,
273
+ after: streamResumeSequenceRef.current,
162
274
  signal: controller.signal,
275
+ beforeLive: async () => await reconcileSession(sessionId),
163
276
  onStateChange: (state) => {
164
- if (!controller.signal.aborted) {
277
+ if (isCurrent()) {
165
278
  setConnectionState(state);
166
279
  }
167
280
  },
168
281
  });
169
282
  for await (const event of stream) {
170
- pending.push(event);
171
- scheduleFlush();
283
+ if (!isCurrent()) break;
284
+ const boundedEvent = boundBrowserLegacyEvent(event);
285
+ const boundedEventBytes = browserJsonBytes(boundedEvent);
286
+ const separatorBytes = pending.length === 0 ? 0 : 1;
287
+ if (
288
+ pending.length > 0 &&
289
+ (pending.length >= SESSION_EVENT_BROWSER_PENDING_MAX_COUNT ||
290
+ pendingBytes + separatorBytes + boundedEventBytes >
291
+ SESSION_EVENT_BROWSER_PENDING_MAX_BYTES)
292
+ ) {
293
+ flush();
294
+ }
295
+ pending.push(boundedEvent);
296
+ pendingBytes += (pending.length === 1 ? 0 : 1) + boundedEventBytes;
297
+ if (
298
+ pending.length >= SESSION_EVENT_BROWSER_PENDING_MAX_COUNT ||
299
+ pendingBytes >= SESSION_EVENT_BROWSER_PENDING_MAX_BYTES
300
+ ) {
301
+ flush();
302
+ } else {
303
+ scheduleFlush();
304
+ }
172
305
  }
173
- if (!controller.signal.aborted) {
306
+ if (isCurrent()) {
174
307
  flush();
175
308
  setConnectionState("ended");
176
309
  }
177
310
  } catch (cause) {
178
- if (!controller.signal.aborted) {
311
+ if (isCurrent()) {
179
312
  flush();
180
313
  setError(cause instanceof Error ? cause : new Error(String(cause)));
181
314
  setConnectionState("error");
@@ -185,14 +318,30 @@ export function useSessionEvents(
185
318
 
186
319
  return () => {
187
320
  controller.abort();
321
+ if (generationRef.current === generation) {
322
+ generationRef.current = generation + 1;
323
+ }
324
+ if (streamAbortRef.current === controller) {
325
+ streamAbortRef.current = null;
326
+ }
188
327
  if (flushTimer !== null) {
189
328
  clearTimeout(flushTimer);
190
329
  }
191
330
  };
192
- }, [client, workspaceId, sessionId, after, enabled, fullReplay, streamKey]);
331
+ }, [
332
+ client,
333
+ workspaceId,
334
+ sessionId,
335
+ after,
336
+ enabled,
337
+ fullReplay,
338
+ streamKey,
339
+ streamEpoch,
340
+ reconcileSession,
341
+ ]);
193
342
 
194
343
  const loadOlder = useCallback(async (): Promise<boolean> => {
195
- if (!sessionId || fullReplay || loadingOlderRef.current || !hasOlderRef.current) {
344
+ if (!sessionId || loadingOlderRef.current || !hasOlderRef.current) {
196
345
  return false;
197
346
  }
198
347
  const before = oldestSequenceRef.current;
@@ -220,41 +369,367 @@ export function useSessionEvents(
220
369
  setHasOlder(false);
221
370
  return false;
222
371
  }
223
- oldestSequenceRef.current = window.oldestSequence;
224
- hasOlderRef.current = window.hasOlder;
225
- setHasOlder(window.hasOlder);
226
- setEvents((existing) => {
227
- assertPrependOrder(existing, window.events);
228
- return [...window.events, ...existing];
372
+ const current = eventWindowRef.current;
373
+ assertPrependOrder(current.events, window.events);
374
+ // Freeze the live iterator before replacing its in-memory window. Rows
375
+ // pending in the aborted iterator were never cursor-committed and will
376
+ // be replayed from the retained high-water mark below.
377
+ streamAbortRef.current?.abort();
378
+ observeSessionStatus(window.events, sessionStatusRef, setSessionStatusProjection);
379
+ const next = boundBrowserSessionEventWindow([...window.events, ...current.events], {
380
+ direction: "oldest",
229
381
  });
230
- return window.hasOlder;
382
+ const retained = {
383
+ ...next,
384
+ truncated: current.truncated || next.truncated,
385
+ };
386
+ const retainedOldest = retained.events[0]?.sequence ?? null;
387
+ if (retainedOldest === null || retainedOldest >= before) {
388
+ throw new Error("@opengeni/react: loadOlder made no durable sequence progress");
389
+ }
390
+ eventWindowRef.current = retained;
391
+ setEventWindow(retained);
392
+ oldestSequenceRef.current = retainedOldest;
393
+ streamResumeSequenceRef.current = maxResumeSequence(retained.events);
394
+ // Oldest-directed eviction can discard newer in-memory rows. That fact
395
+ // keeps windowTruncated true, but it does not imply older durable rows
396
+ // exist; only the backward DB page can answer hasOlder truthfully.
397
+ const olderStillAvailable = window.hasOlder;
398
+ hasOlderRef.current = olderStillAvailable;
399
+ setHasOlder(olderStillAvailable);
400
+ setStreamEpoch((epoch) => epoch + 1);
401
+ return olderStillAvailable;
231
402
  } finally {
232
403
  if (generationRef.current === generation) {
233
404
  loadingOlderRef.current = false;
234
405
  setLoadingOlder(false);
235
406
  }
236
407
  }
237
- }, [client, workspaceId, sessionId, fullReplay]);
408
+ }, [client, workspaceId, sessionId]);
238
409
 
239
410
  const identityMatches = stateStreamKey === streamKey;
240
- const visibleEvents = identityMatches ? events : EMPTY_EVENTS;
411
+ const visibleEvents = identityMatches ? eventWindow.events : EMPTY_EVENTS;
241
412
  const timeline = useMemo(() => buildTimeline(visibleEvents), [visibleEvents]);
242
- const sessionStatus = useMemo(() => sessionStatusFromEvents(visibleEvents), [visibleEvents]);
243
413
 
244
414
  return {
245
415
  events: visibleEvents,
246
416
  timeline,
247
- sessionStatus,
417
+ sessionStatus: identityMatches ? sessionStatusProjection : null,
248
418
  connectionState: identityMatches ? connectionState : "idle",
249
419
  lastSequence: identityMatches ? lastSequenceRef.current : after,
420
+ windowBytes: identityMatches ? eventWindow.bytes : 2,
421
+ windowTruncated: identityMatches ? eventWindow.truncated : false,
250
422
  initialLoading: fullReplay ? false : identityMatches ? initialLoading : true,
251
- hasOlder: fullReplay || !identityMatches ? false : hasOlder,
252
- loadingOlder: fullReplay || !identityMatches ? false : loadingOlder,
423
+ hasOlder: !identityMatches ? false : hasOlder,
424
+ loadingOlder: !identityMatches ? false : loadingOlder,
253
425
  loadOlder,
254
426
  error: identityMatches ? error : null,
255
427
  };
256
428
  }
257
429
 
430
+ /**
431
+ * Keep one direction-aware count+byte-bounded browser window. Live/default
432
+ * accumulation retains the newest suffix; backward paging retains the oldest
433
+ * prefix so newly fetched history cannot be immediately evicted. This is
434
+ * deliberately separate from durable history and transport paging: when a
435
+ * backward page evicts the live tail, the hook reconnects from the retained
436
+ * high-water mark while preserving the highest-ever-observed sequence
437
+ * separately. The source event remains durable in PostgreSQL throughout.
438
+ */
439
+ export function boundBrowserSessionEventWindow(
440
+ events: readonly SessionEvent[],
441
+ options: {
442
+ maxBytes?: number;
443
+ maxCount?: number;
444
+ direction?: "newest" | "oldest";
445
+ } = {},
446
+ ): BrowserSessionEventWindow {
447
+ const maxBytes = Math.max(1024, options.maxBytes ?? SESSION_EVENT_BROWSER_MAX_BYTES);
448
+ const maxCount = Math.max(1, Math.floor(options.maxCount ?? SESSION_EVENT_BROWSER_MAX_COUNT));
449
+ const safe = events.map(boundBrowserLegacyEvent);
450
+ const selected: SessionEvent[] = [];
451
+ let bytes = 2; // []
452
+ const direction = options.direction ?? "newest";
453
+ const start = direction === "newest" ? safe.length - 1 : 0;
454
+ const end = direction === "newest" ? Math.max(-1, safe.length - maxCount - 1) : safe.length;
455
+ const step = direction === "newest" ? -1 : 1;
456
+ for (let index = start; index !== end && selected.length < maxCount; index += step) {
457
+ const event = safe[index]!;
458
+ const eventBytes = browserJsonBytes(event);
459
+ const separator = selected.length === 0 ? 0 : 1;
460
+ if (bytes + separator + eventBytes > maxBytes) break;
461
+ selected.push(event);
462
+ bytes += separator + eventBytes;
463
+ }
464
+ if (direction === "newest") selected.reverse();
465
+ return {
466
+ events: selected,
467
+ bytes,
468
+ truncated: selected.length < safe.length,
469
+ };
470
+ }
471
+
472
+ function boundBrowserLegacyEvent(event: SessionEvent): SessionEvent {
473
+ // The server-side canonical projection lives in @opengeni/contracts. The
474
+ // publishable React package may depend only on the zero-runtime-dependency
475
+ // SDK, so this is intentionally a last-resort client guard rather than a
476
+ // second durable representation. Reconstructing the SDK wire shape prevents
477
+ // legacy/malformed extra properties from bypassing the browser byte cap.
478
+ const serialized = browserSerialize(event);
479
+ const originalBytes = serialized.serializable
480
+ ? encoder.encode(serialized.value).byteLength
481
+ : null;
482
+ const typeIsSafe =
483
+ browserUtf8Bytes(event.type) <= BROWSER_EVENT_TYPE_MAX_BYTES &&
484
+ !event.type.includes("\n") &&
485
+ !event.type.includes("\r");
486
+ const clientEventId = boundBrowserOptionalText(
487
+ event.clientEventId,
488
+ BROWSER_EVENT_CLIENT_ID_MAX_BYTES,
489
+ );
490
+ const duplicateReason = boundBrowserOptionalText(
491
+ event.duplicateReason,
492
+ BROWSER_EVENT_DUPLICATE_REASON_MAX_BYTES,
493
+ );
494
+
495
+ if (
496
+ serialized.serializable &&
497
+ originalBytes !== null &&
498
+ originalBytes <= SESSION_EVENT_BROWSER_SINGLE_EVENT_MAX_BYTES &&
499
+ typeIsSafe &&
500
+ clientEventId === event.clientEventId &&
501
+ duplicateReason === event.duplicateReason
502
+ ) {
503
+ return event;
504
+ }
505
+
506
+ const envelopeProjection = [
507
+ !typeIsSafe
508
+ ? browserEnvelopeFieldProjection("type", event.type, "session.event.envelope_omitted")
509
+ : null,
510
+ clientEventId !== event.clientEventId
511
+ ? browserEnvelopeFieldProjection("clientEventId", event.clientEventId, clientEventId)
512
+ : null,
513
+ duplicateReason !== event.duplicateReason
514
+ ? browserEnvelopeFieldProjection("duplicateReason", event.duplicateReason, duplicateReason)
515
+ : null,
516
+ ].filter((field) => field !== null);
517
+ const payloadSerialization = browserSerialize(event.payload);
518
+ const payloadBytes = payloadSerialization.serializable
519
+ ? encoder.encode(payloadSerialization.value).byteLength
520
+ : null;
521
+ const preview = truncateBrowserUtf8Middle(
522
+ payloadSerialization.value,
523
+ BROWSER_EVENT_PAYLOAD_PREVIEW_MAX_BYTES,
524
+ );
525
+ const truncation = {
526
+ truncated: true as const,
527
+ surface: "browser_legacy_guard" as const,
528
+ reason: serialized.serializable ? "event_envelope_bytes_exceeded" : "event_not_serializable",
529
+ originalBytes,
530
+ deliveredBytes: 0,
531
+ omittedBytes: originalBytes,
532
+ estimatedOriginalTokens: originalBytes === null ? null : Math.ceil(originalBytes / 4),
533
+ estimatedDeliveredTokens: 0,
534
+ fullEvidence: { available: false as const, reason: "not_retained" as const },
535
+ details: [
536
+ {
537
+ path: "$.payload",
538
+ kind: "payload_preview",
539
+ originalBytes: payloadBytes,
540
+ deliveredBytes: browserUtf8Bytes(preview),
541
+ },
542
+ ],
543
+ };
544
+ const payload: Record<string, unknown> = {
545
+ ...browserPayloadIdentity(event.payload),
546
+ preview,
547
+ ...(envelopeProjection.length > 0
548
+ ? {
549
+ originalType: boundBrowserText(event.type, BROWSER_EVENT_TYPE_MAX_BYTES),
550
+ envelopeProjection: {
551
+ truncated: true,
552
+ surface: "browser_legacy_guard",
553
+ fields: envelopeProjection,
554
+ },
555
+ }
556
+ : {}),
557
+ truncation,
558
+ };
559
+ const bounded: SessionEvent = {
560
+ id: boundBrowserText(event.id, BROWSER_EVENT_ID_MAX_BYTES),
561
+ workspaceId: boundBrowserText(event.workspaceId, BROWSER_EVENT_ID_MAX_BYTES),
562
+ sessionId: boundBrowserText(event.sessionId, BROWSER_EVENT_ID_MAX_BYTES),
563
+ sequence: event.sequence,
564
+ type: typeIsSafe ? event.type : "session.event.envelope_omitted",
565
+ payload,
566
+ occurredAt: boundBrowserText(event.occurredAt, BROWSER_EVENT_ID_MAX_BYTES),
567
+ clientEventId,
568
+ turnId: boundBrowserOptionalText(event.turnId, BROWSER_EVENT_ID_MAX_BYTES),
569
+ turnGeneration: event.turnGeneration,
570
+ turnAttemptId: boundBrowserOptionalText(event.turnAttemptId, BROWSER_EVENT_ID_MAX_BYTES),
571
+ turnAssociation: event.turnAssociation,
572
+ duplicateOfEventId: boundBrowserOptionalText(
573
+ event.duplicateOfEventId,
574
+ BROWSER_EVENT_ID_MAX_BYTES,
575
+ ),
576
+ duplicateReason,
577
+ };
578
+
579
+ settleBrowserEventTruncation(bounded, truncation);
580
+ if (browserJsonBytes(bounded) > SESSION_EVENT_BROWSER_SINGLE_EVENT_MAX_BYTES) {
581
+ payload.preview = truncateBrowserUtf8Middle(String(payload.preview), 4 * 1024);
582
+ truncation.details = truncation.details.slice(0, 1);
583
+ settleBrowserEventTruncation(bounded, truncation);
584
+ }
585
+ if (browserJsonBytes(bounded) > SESSION_EVENT_BROWSER_SINGLE_EVENT_MAX_BYTES) {
586
+ bounded.clientEventId = null;
587
+ bounded.duplicateReason = null;
588
+ payload.preview = "[legacy event omitted at the browser byte boundary]";
589
+ settleBrowserEventTruncation(bounded, truncation);
590
+ }
591
+ return bounded;
592
+ }
593
+
594
+ function browserJsonBytes(value: unknown): number {
595
+ return encoder.encode(browserSerialize(value).value).byteLength;
596
+ }
597
+
598
+ function browserSerialize(value: unknown): { value: string; serializable: boolean } {
599
+ try {
600
+ const serialized = JSON.stringify(value);
601
+ return { value: serialized === undefined ? "null" : serialized, serializable: true };
602
+ } catch {
603
+ return {
604
+ value: '"[unserializable event payload omitted at browser boundary]"',
605
+ serializable: false,
606
+ };
607
+ }
608
+ }
609
+
610
+ function browserPayloadIdentity(payload: unknown): Record<string, unknown> {
611
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {};
612
+ const record = payload as Record<string, unknown>;
613
+ const identity: Record<string, unknown> = {};
614
+ for (const field of BROWSER_EVENT_PAYLOAD_IDENTITY_FIELDS) {
615
+ const value = record[field];
616
+ if (typeof value === "string") {
617
+ identity[field] = boundBrowserText(value, BROWSER_EVENT_ID_MAX_BYTES);
618
+ } else if (typeof value === "number" || typeof value === "boolean" || value === null) {
619
+ identity[field] = value;
620
+ }
621
+ }
622
+ return identity;
623
+ }
624
+
625
+ function browserEnvelopeFieldProjection(
626
+ field: string,
627
+ original: string | null | undefined,
628
+ delivered: string | null | undefined,
629
+ ): { field: string; originalBytes: number; deliveredBytes: number } {
630
+ return {
631
+ field,
632
+ originalBytes: typeof original === "string" ? browserUtf8Bytes(original) : 0,
633
+ deliveredBytes: typeof delivered === "string" ? browserUtf8Bytes(delivered) : 0,
634
+ };
635
+ }
636
+
637
+ function boundBrowserOptionalText<T extends string | null | undefined>(
638
+ value: T,
639
+ maxBytes: number,
640
+ ): T {
641
+ return (typeof value === "string" ? boundBrowserText(value, maxBytes) : value) as T;
642
+ }
643
+
644
+ function boundBrowserText(value: string, maxBytes: number): string {
645
+ const bytes = encoder.encode(value);
646
+ if (bytes.byteLength <= maxBytes) return value;
647
+ const marker = "…[truncated]";
648
+ const prefixBudget = Math.max(0, maxBytes - browserUtf8Bytes(marker));
649
+ let prefixEnd = Math.min(prefixBudget, bytes.byteLength);
650
+ while (
651
+ prefixEnd > 0 &&
652
+ prefixEnd < bytes.byteLength &&
653
+ isBrowserUtf8Continuation(bytes[prefixEnd]!)
654
+ ) {
655
+ prefixEnd -= 1;
656
+ }
657
+ return `${decoder.decode(bytes.subarray(0, prefixEnd))}${marker}`;
658
+ }
659
+
660
+ function truncateBrowserUtf8Middle(value: string, maxBytes: number): string {
661
+ const bytes = encoder.encode(value);
662
+ if (bytes.byteLength <= maxBytes) return value;
663
+ const marker = `…[${bytes.byteLength - maxBytes} bytes omitted]…`;
664
+ const contentBudget = Math.max(0, maxBytes - browserUtf8Bytes(marker));
665
+ const leftBudget = Math.floor(contentBudget / 2);
666
+ const rightBudget = contentBudget - leftBudget;
667
+ let leftEnd = Math.min(leftBudget, bytes.byteLength);
668
+ while (leftEnd > 0 && leftEnd < bytes.byteLength && isBrowserUtf8Continuation(bytes[leftEnd]!)) {
669
+ leftEnd -= 1;
670
+ }
671
+ let rightStart = Math.max(0, bytes.byteLength - rightBudget);
672
+ while (rightStart < bytes.byteLength && isBrowserUtf8Continuation(bytes[rightStart]!)) {
673
+ rightStart += 1;
674
+ }
675
+ return `${decoder.decode(bytes.subarray(0, leftEnd))}${marker}${decoder.decode(bytes.subarray(rightStart))}`;
676
+ }
677
+
678
+ function settleBrowserEventTruncation(
679
+ event: SessionEvent,
680
+ truncation: {
681
+ originalBytes: number | null;
682
+ deliveredBytes: number;
683
+ omittedBytes: number | null;
684
+ estimatedDeliveredTokens: number;
685
+ },
686
+ ): void {
687
+ for (let attempt = 0; attempt < 16; attempt += 1) {
688
+ const deliveredBytes = browserJsonBytes(event);
689
+ const omittedBytes =
690
+ truncation.originalBytes === null
691
+ ? null
692
+ : Math.max(0, truncation.originalBytes - deliveredBytes);
693
+ const estimatedDeliveredTokens = Math.ceil(deliveredBytes / 4);
694
+ if (
695
+ truncation.deliveredBytes === deliveredBytes &&
696
+ truncation.omittedBytes === omittedBytes &&
697
+ truncation.estimatedDeliveredTokens === estimatedDeliveredTokens
698
+ ) {
699
+ return;
700
+ }
701
+ truncation.deliveredBytes = deliveredBytes;
702
+ truncation.omittedBytes = omittedBytes;
703
+ truncation.estimatedDeliveredTokens = estimatedDeliveredTokens;
704
+ }
705
+ throw new RangeError("Browser event byte accounting did not converge");
706
+ }
707
+
708
+ function browserUtf8Bytes(value: string): number {
709
+ return encoder.encode(value).byteLength;
710
+ }
711
+
712
+ function isBrowserUtf8Continuation(value: number): boolean {
713
+ return (value & 0xc0) === 0x80;
714
+ }
715
+
716
+ function observeSessionStatus(
717
+ events: readonly SessionEvent[],
718
+ ref: { current: { sequence: number; status: SessionStatus | null } },
719
+ setStatus: (status: SessionStatus | null) => void,
720
+ ): void {
721
+ let latest: { sequence: number; status: SessionStatus } | null = null;
722
+ for (const event of events) {
723
+ const status = sessionStatusFromEvents([event]);
724
+ if (status && (!latest || event.sequence >= latest.sequence)) {
725
+ latest = { sequence: event.sequence, status };
726
+ }
727
+ }
728
+ if (!latest || latest.sequence < ref.current.sequence) return;
729
+ ref.current = latest;
730
+ setStatus(latest.status);
731
+ }
732
+
258
733
  type LoadedEventWindow = {
259
734
  events: SessionEvent[];
260
735
  oldestSequence: number | null;
@@ -263,7 +738,7 @@ type LoadedEventWindow = {
263
738
  };
264
739
 
265
740
  async function loadEventWindow(
266
- client: SessionClientLike,
741
+ client: EmbeddedSessionClientLike,
267
742
  workspaceId: string,
268
743
  sessionId: string,
269
744
  options: {
@@ -363,7 +838,7 @@ function findBoundaryIndex(events: SessionEvent[]): number {
363
838
  type PreviousPage = SessionEvent[] & { requested: number };
364
839
 
365
840
  async function loadPreviousPage(
366
- client: SessionClientLike,
841
+ client: EmbeddedSessionClientLike,
367
842
  workspaceId: string,
368
843
  sessionId: string,
369
844
  before: number,