@opengeni/react 3.3.2 → 3.7.0-canary.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 (79) hide show
  1. package/dist/artifacts.d.ts +1 -1
  2. package/dist/artifacts.js +241 -18
  3. package/dist/artifacts.js.map +1 -1
  4. package/dist/{browser-viewer-MKKFRRRV.js → browser-viewer-E5DDEFQ4.js} +3 -3
  5. package/dist/{chunk-2DZVYHVO.js → chunk-4JFUT22W.js} +39 -13
  6. package/dist/chunk-4JFUT22W.js.map +1 -0
  7. package/dist/{chunk-ELIYWBZR.js → chunk-BV5A4OTE.js} +4 -4
  8. package/dist/{chunk-3AGBGTRT.js → chunk-C6WDLHBA.js} +148 -26
  9. package/dist/chunk-C6WDLHBA.js.map +1 -0
  10. package/dist/{chunk-YG4QEQF5.js → chunk-DOK4KZF2.js} +7863 -7736
  11. package/dist/chunk-DOK4KZF2.js.map +1 -0
  12. package/dist/{chunk-JAQ5K7W6.js → chunk-FJBOU2TH.js} +167 -58
  13. package/dist/chunk-FJBOU2TH.js.map +1 -0
  14. package/dist/{chunk-XYRDDSXF.js → chunk-R55HFTT3.js} +80 -34
  15. package/dist/chunk-R55HFTT3.js.map +1 -0
  16. package/dist/{chunk-ZPBTI3TE.js → chunk-W2RGYBCV.js} +4 -4
  17. package/dist/{chunk-WSK7G5LE.js → chunk-W3OUAH6M.js} +80 -80
  18. package/dist/chunk-W3OUAH6M.js.map +1 -0
  19. package/dist/{chunk-X4A66EWZ.js → chunk-WMGF4Z7X.js} +41 -43
  20. package/dist/chunk-WMGF4Z7X.js.map +1 -0
  21. package/dist/client.d.ts +1 -1
  22. package/dist/components/artifacts/index.d.ts +1 -1
  23. package/dist/components/artifacts/published-html-artifact-frame.d.ts +45 -3
  24. package/dist/components/composer.d.ts +2 -0
  25. package/dist/components/session-chrome.d.ts +1 -1
  26. package/dist/composer.js +4 -4
  27. package/dist/{computer-viewer-FWEZISW4.js → computer-viewer-7TEZC6Y2.js} +3 -3
  28. package/dist/{fleet-decision-row-GGCAT4E4.js → fleet-decision-row-YZ3ZKWMM.js} +2 -1
  29. package/dist/fleet-decision-row-YZ3ZKWMM.js.map +1 -0
  30. package/dist/hooks/use-file-attachments.d.ts +8 -0
  31. package/dist/hooks/use-sandbox-files.d.ts +4 -1
  32. package/dist/hooks/use-session-events.d.ts +1 -1
  33. package/dist/hooks/use-workspace-edit.d.ts +4 -0
  34. package/dist/index.js +195 -141
  35. package/dist/index.js.map +1 -1
  36. package/dist/interaction.js +3 -3
  37. package/dist/model-policy.d.ts +2 -1
  38. package/dist/model-policy.js +1 -1
  39. package/dist/{queue-surface-implementation-BSKUWHRI.js → queue-surface-implementation-XL2A6PBM.js} +2 -2
  40. package/dist/realtime.js +2 -2
  41. package/dist/session-ui.js +6 -6
  42. package/dist/session.js +4 -4
  43. package/dist/timeline/registry.d.ts +2 -0
  44. package/dist/timeline/types.d.ts +3 -1
  45. package/package.json +2 -2
  46. package/src/artifacts.ts +3 -0
  47. package/src/client.ts +2 -1
  48. package/src/components/artifacts/index.ts +3 -0
  49. package/src/components/artifacts/published-html-artifact-frame.tsx +284 -4
  50. package/src/components/composer.tsx +103 -29
  51. package/src/components/message-timeline.tsx +48 -10
  52. package/src/components/model-policy-picker.tsx +37 -10
  53. package/src/components/sandbox-workspace.tsx +18 -7
  54. package/src/components/session-chrome.tsx +3 -3
  55. package/src/hooks/use-file-attachments.ts +62 -2
  56. package/src/hooks/use-sandbox-files.ts +68 -20
  57. package/src/hooks/use-session-events.ts +176 -21
  58. package/src/hooks/use-workspace-edit.ts +23 -5
  59. package/src/model-policy.ts +48 -53
  60. package/src/timeline/fleet-decision-projection.ts +2 -1
  61. package/src/timeline/fleet-decision-row.tsx +1 -0
  62. package/src/timeline/projection.ts +157 -8
  63. package/src/timeline/registry.ts +15 -3
  64. package/src/timeline/tool-renderers.tsx +117 -1
  65. package/src/timeline/types.ts +10 -1
  66. package/styles/compiled.css +5 -0
  67. package/dist/chunk-2DZVYHVO.js.map +0 -1
  68. package/dist/chunk-3AGBGTRT.js.map +0 -1
  69. package/dist/chunk-JAQ5K7W6.js.map +0 -1
  70. package/dist/chunk-WSK7G5LE.js.map +0 -1
  71. package/dist/chunk-X4A66EWZ.js.map +0 -1
  72. package/dist/chunk-XYRDDSXF.js.map +0 -1
  73. package/dist/chunk-YG4QEQF5.js.map +0 -1
  74. package/dist/fleet-decision-row-GGCAT4E4.js.map +0 -1
  75. /package/dist/{browser-viewer-MKKFRRRV.js.map → browser-viewer-E5DDEFQ4.js.map} +0 -0
  76. /package/dist/{chunk-ELIYWBZR.js.map → chunk-BV5A4OTE.js.map} +0 -0
  77. /package/dist/{chunk-ZPBTI3TE.js.map → chunk-W2RGYBCV.js.map} +0 -0
  78. /package/dist/{computer-viewer-FWEZISW4.js.map → computer-viewer-7TEZC6Y2.js.map} +0 -0
  79. /package/dist/{queue-surface-implementation-BSKUWHRI.js.map → queue-surface-implementation-XL2A6PBM.js.map} +0 -0
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ FileSystemRouteIdentity,
2
3
  FsChangedPayload,
3
4
  FsReadResponse,
4
5
  FsTreeNode,
@@ -84,6 +85,9 @@ export type UseSandboxFilesOptions = ClientOverride & {
84
85
  events?: SessionEvent[] | undefined;
85
86
  /** Initial path to list (workspace root by default). */
86
87
  rootPath?: string | undefined;
88
+ /** Exact capability identity for the selected filesystem route. A route/root
89
+ * change resets this hook and makes stale provider requests fail retryably. */
90
+ route?: FileSystemRouteIdentity | undefined;
87
91
  /** Hold off the initial list (e.g. panel collapsed). Default true. */
88
92
  enabled?: boolean | undefined;
89
93
  /** Pause live reads/invalidation while preserving the last rendered result. */
@@ -169,8 +173,12 @@ function normalizeRoot(root: string): string {
169
173
  return normalized === "." ? "" : normalized;
170
174
  }
171
175
 
176
+ function isAbsoluteTreePath(path: string): boolean {
177
+ return path.startsWith("/") || /^[A-Za-z]:\//u.test(path);
178
+ }
179
+
172
180
  function joinTreeRoot(root: string, path: string): string {
173
- if (!root || path.startsWith("/")) return path;
181
+ if (!root || isAbsoluteTreePath(path)) return path;
174
182
  return root === "/" ? `/${path}` : `${root}/${path}`;
175
183
  }
176
184
 
@@ -301,6 +309,7 @@ const FS_LIST_BATCH_REQUEST_LIMIT = 16;
301
309
  function initialDirectoryFrontier(rootPath: string, repoPaths: readonly string[]): string[] {
302
310
  const root = normalizeRoot(rootPath);
303
311
  const rootParts = root.split("/").filter(Boolean);
312
+ const rootPrefix = root.startsWith("//") ? "//" : root.startsWith("/") ? "/" : "";
304
313
  const paths = new Set<string>();
305
314
  for (const repoPath of repoPaths) {
306
315
  const canonicalRepoPath = joinTreeRoot(root, normalizeRoot(repoPath));
@@ -308,7 +317,7 @@ function initialDirectoryFrontier(rootPath: string, repoPaths: readonly string[]
308
317
  if (rootParts.some((part, index) => parts[index] !== part)) continue;
309
318
  for (let depth = rootParts.length + 1; depth <= parts.length; depth += 1) {
310
319
  const path = parts.slice(0, depth).join("/");
311
- paths.add(root.startsWith("/") ? `/${path}` : path);
320
+ paths.add(`${rootPrefix}${path}`);
312
321
  }
313
322
  }
314
323
  // A workspace with hundreds of repositories should remain lazy. This still
@@ -491,6 +500,15 @@ export function useSandboxFiles(
491
500
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
492
501
  const active = options.active ?? true;
493
502
  const rootPath = options.rootPath ?? "";
503
+ const routeEpoch = options.route?.epoch ?? null;
504
+ const routeRoot = options.route?.root ?? null;
505
+ const route = useMemo<FileSystemRouteIdentity | undefined>(
506
+ () =>
507
+ routeEpoch !== null && routeRoot !== null
508
+ ? { epoch: routeEpoch, root: routeRoot }
509
+ : undefined,
510
+ [routeEpoch, routeRoot],
511
+ );
494
512
  const repoPathsKey = [
495
513
  ...new Set((options.repoPaths?.length ? options.repoPaths : [rootPath]).map(normalizeRoot)),
496
514
  ].join("\u0000");
@@ -503,7 +521,7 @@ export function useSandboxFiles(
503
521
  const acceptsEventReadsRef = useRef(acceptsEventReads);
504
522
  acceptsEventReadsRef.current = acceptsEventReads;
505
523
  const hasLiveIoFence = options.liveness !== undefined;
506
- const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${rootPath}\u0000${repoPathsKey}`;
524
+ const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${rootPath}\u0000${repoPathsKey}\u0000${routeEpoch ?? ""}\u0000${routeRoot ?? ""}`;
507
525
 
508
526
  const [tree, setTree] = useState<FileTreeNode[]>([]);
509
527
  // A ref mirror of the current tree — lets the optimistic path snapshot the
@@ -597,6 +615,7 @@ export function useSandboxFiles(
597
615
  const listRequests = [rootPath, ...frontierPaths].map((path) => ({
598
616
  path,
599
617
  depth: 1,
618
+ ...(route ? { route } : {}),
600
619
  }));
601
620
  // Root plus the bounded repository frontier share one API request, one
602
621
  // direct holder, and one provider attach. The old per-directory fan-out
@@ -677,7 +696,7 @@ export function useSandboxFiles(
677
696
  if (refreshGenerationRef.current === generation) setGitLoading(false);
678
697
  if (refreshAbortRef.current === refreshAbort) refreshAbortRef.current = null;
679
698
  }
680
- }, [client, workspaceId, sessionId, rootPath, repoPaths, applyStatus]);
699
+ }, [client, workspaceId, sessionId, rootPath, repoPaths, route, applyStatus]);
681
700
 
682
701
  // Seed (or re-seed) the tree from a turn-end capture — the COLD/offline paint,
683
702
  // zero Channel-A calls. The tree index is workspace-relative (`treeIndex`); the
@@ -737,7 +756,7 @@ export function useSandboxFiles(
737
756
  const listed = await client.fsList(
738
757
  workspaceId,
739
758
  sessionId,
740
- { path, depth: 1 },
759
+ { path, depth: 1, ...(route ? { route } : {}) },
741
760
  { signal: identitySignal },
742
761
  );
743
762
  if (identityGenerationRef.current !== identityGeneration) return;
@@ -757,7 +776,7 @@ export function useSandboxFiles(
757
776
  }
758
777
  }
759
778
  },
760
- [client, workspaceId, sessionId, capture, source, applyStatus],
779
+ [client, workspaceId, sessionId, capture, source, route, applyStatus],
761
780
  );
762
781
 
763
782
  const readFile = useCallback(
@@ -850,9 +869,14 @@ export function useSandboxFiles(
850
869
  }
851
870
  throw new Error("Captured file download failed after refreshing its URL.");
852
871
  }
853
- return await client.fsRead(workspaceId, sessionId, { path }, { signal });
872
+ return await client.fsRead(
873
+ workspaceId,
874
+ sessionId,
875
+ { path, ...(route ? { route } : {}) },
876
+ { signal },
877
+ );
854
878
  },
855
- [client, workspaceId, sessionId, capture, source],
879
+ [client, workspaceId, sessionId, capture, source, route],
856
880
  );
857
881
 
858
882
  // TARGETED reconcile of a single directory — re-list ONE parent at depth 1 and
@@ -874,7 +898,12 @@ export function useSandboxFiles(
874
898
  // visible to update; they re-list fresh on the next expand).
875
899
  if (!parentIsLoaded(treeRef.current, path, rootPath)) return;
876
900
  try {
877
- const listed = await client.fsList(workspaceId, sessionId, { path, depth: 1 }, { signal });
901
+ const listed = await client.fsList(
902
+ workspaceId,
903
+ sessionId,
904
+ { path, depth: 1, ...(route ? { route } : {}) },
905
+ { signal },
906
+ );
878
907
  if (identityGenerationRef.current !== identityGeneration) return;
879
908
  const children = (listed.root.children ?? []).map((node) => fsNodeToTree(node));
880
909
  if (path === normalizeRoot(rootPath)) {
@@ -892,7 +921,7 @@ export function useSandboxFiles(
892
921
  // root-refresh here (that would collapse the tree the user is working in).
893
922
  }
894
923
  },
895
- [client, workspaceId, sessionId, rootPath, applyStatus],
924
+ [client, workspaceId, sessionId, rootPath, route, applyStatus],
896
925
  );
897
926
 
898
927
  // Reconcile the visible directory frontier through bounded batch requests.
@@ -915,7 +944,13 @@ export function useSandboxFiles(
915
944
  const listed = await client.fsListBatch(
916
945
  workspaceId,
917
946
  sessionId,
918
- { requests: chunk.map((path) => ({ path, depth: 1 })) },
947
+ {
948
+ requests: chunk.map((path) => ({
949
+ path,
950
+ depth: 1,
951
+ ...(route ? { route } : {}),
952
+ })),
953
+ },
919
954
  { signal },
920
955
  );
921
956
  if (identityGenerationRef.current !== identityGeneration) return;
@@ -943,7 +978,7 @@ export function useSandboxFiles(
943
978
  }
944
979
  }
945
980
  },
946
- [client, workspaceId, sessionId, rootPath, applyStatus],
981
+ [client, workspaceId, sessionId, rootPath, route, applyStatus],
947
982
  );
948
983
 
949
984
  // Run a Channel-A op behind an OPTIMISTIC tree edit. `apply` splices the change
@@ -1027,7 +1062,7 @@ export function useSandboxFiles(
1027
1062
  const live = await client.fsRead(
1028
1063
  workspaceId,
1029
1064
  sessionId,
1030
- { path },
1065
+ { path, ...(route ? { route } : {}) },
1031
1066
  { signal: identitySignal },
1032
1067
  );
1033
1068
  if (identityGenerationRef.current !== identityGeneration) {
@@ -1046,12 +1081,13 @@ export function useSandboxFiles(
1046
1081
  path,
1047
1082
  content,
1048
1083
  overwrite: true,
1084
+ ...(route ? { route } : {}),
1049
1085
  });
1050
1086
  },
1051
1087
  exists ? [] : [parent],
1052
1088
  );
1053
1089
  },
1054
- [client, workspaceId, sessionId, tree, rootPath, runOptimistic],
1090
+ [client, workspaceId, sessionId, tree, rootPath, route, runOptimistic],
1055
1091
  );
1056
1092
 
1057
1093
  const createFile = useCallback(
@@ -1072,11 +1108,12 @@ export function useSandboxFiles(
1072
1108
  path,
1073
1109
  content: "",
1074
1110
  overwrite: false,
1111
+ ...(route ? { route } : {}),
1075
1112
  }),
1076
1113
  [parent],
1077
1114
  );
1078
1115
  },
1079
- [client, workspaceId, sessionId, rootPath, runOptimistic],
1116
+ [client, workspaceId, sessionId, rootPath, route, runOptimistic],
1080
1117
  );
1081
1118
 
1082
1119
  const createDir = useCallback(
@@ -1094,11 +1131,16 @@ export function useSandboxFiles(
1094
1131
  await runOptimistic(
1095
1132
  "create folder",
1096
1133
  (nodes) => insertNode(nodes, parent, node, rootPath),
1097
- () => client.fsMkdir(workspaceId, sessionId, { path, recursive: true }),
1134
+ () =>
1135
+ client.fsMkdir(workspaceId, sessionId, {
1136
+ path,
1137
+ recursive: true,
1138
+ ...(route ? { route } : {}),
1139
+ }),
1098
1140
  [parent],
1099
1141
  );
1100
1142
  },
1101
- [client, workspaceId, sessionId, rootPath, runOptimistic],
1143
+ [client, workspaceId, sessionId, rootPath, route, runOptimistic],
1102
1144
  );
1103
1145
 
1104
1146
  const deleteEntry = useCallback(
@@ -1107,11 +1149,16 @@ export function useSandboxFiles(
1107
1149
  await runOptimistic(
1108
1150
  "delete",
1109
1151
  (nodes) => removeNode(nodes, path, rootPath),
1110
- () => client.fsDelete(workspaceId, sessionId, { path, recursive }),
1152
+ () =>
1153
+ client.fsDelete(workspaceId, sessionId, {
1154
+ path,
1155
+ recursive,
1156
+ ...(route ? { route } : {}),
1157
+ }),
1111
1158
  [parentWithinTree(path, rootPath)],
1112
1159
  );
1113
1160
  },
1114
- [client, workspaceId, sessionId, rootPath, runOptimistic],
1161
+ [client, workspaceId, sessionId, rootPath, route, runOptimistic],
1115
1162
  );
1116
1163
 
1117
1164
  const moveEntry = useCallback(
@@ -1133,11 +1180,12 @@ export function useSandboxFiles(
1133
1180
  path,
1134
1181
  newPath,
1135
1182
  overwrite: opts?.overwrite ?? false,
1183
+ ...(route ? { route } : {}),
1136
1184
  }),
1137
1185
  to === from ? [from] : [from, to],
1138
1186
  );
1139
1187
  },
1140
- [client, workspaceId, sessionId, rootPath, runOptimistic],
1188
+ [client, workspaceId, sessionId, rootPath, route, runOptimistic],
1141
1189
  );
1142
1190
 
1143
1191
  // Initial paint + reset on identity change. Source selection:
@@ -1,4 +1,9 @@
1
- import type { SessionEvent, SessionStatus, StreamConnectionState } from "@opengeni/sdk";
1
+ import {
2
+ sessionEventStreamCoveredThrough,
3
+ type SessionEvent,
4
+ type SessionStatus,
5
+ type StreamConnectionState,
6
+ } from "@opengeni/sdk";
2
7
  import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
3
8
  import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context";
4
9
  import { createOlderHistoryLoadReceipt, type OlderHistoryLoadReceipt } from "../older-history";
@@ -69,10 +74,10 @@ export type UseSessionEventsResult = {
69
74
  error: Error | null;
70
75
  };
71
76
 
72
- const INITIAL_TAIL_PAGE_SIZE = 1000;
73
- const OLDER_PAGE_SIZE = 5000;
74
- const NEWER_PAGE_SIZE = 5000;
75
- const OLDEST_PAGE_SIZE = 1000;
77
+ // Keep every browser history read inside one database batch, including the
78
+ // server's one-row continuation lookahead. A large total session must never
79
+ // turn one lazy page into dozens of sequential database round trips.
80
+ const SESSION_HISTORY_PAGE_SIZE = 255;
76
81
  const INITIAL_FETCH_CAP = 1;
77
82
  const OLDER_GROUP_TARGET = 32;
78
83
  const OLDER_FETCH_CAP = 2;
@@ -80,7 +85,19 @@ const NEWER_GROUP_TARGET = 32;
80
85
  const NEWER_FETCH_CAP = 2;
81
86
  const OLDEST_GROUP_TARGET = 32;
82
87
  const OLDEST_FETCH_CAP = 2;
83
- const BOUNDARY_PAGE_CAP = 4;
88
+ // A tail page may land inside one unusually dense turn. Permit exactly one
89
+ // additional bounded page to find its user/session boundary without turning a
90
+ // fresh open into an unbounded history walk.
91
+ const BOUNDARY_PAGE_CAP = 1;
92
+ // Foreground reconciliation is intentionally semantic, not merely time-based:
93
+ // tiny raw gaps can stay on SSE; medium raw gaps get one compact probe so a
94
+ // token-heavy single answer is not mistaken for hundreds of visible messages;
95
+ // only a large/complex missed window reloads the latest tail.
96
+ const FOREGROUND_DIRECT_REPLAY_MAX_SEQUENCES = 16;
97
+ const FOREGROUND_COMPACT_PROBE_MAX_SEQUENCES = SESSION_HISTORY_PAGE_SIZE;
98
+ const FOREGROUND_COMPACT_CATCHUP_MAX_EVENTS = 128;
99
+ const FOREGROUND_COMPACT_CATCHUP_MAX_GROUPS = 16;
100
+ const FOREGROUND_COMPACT_CATCHUP_MAX_BYTES = 512 * 1024;
84
101
  const EMPTY_EVENTS: SessionEvent[] = [];
85
102
  const encoder = new TextEncoder();
86
103
  const decoder = new TextDecoder();
@@ -172,6 +189,7 @@ export function useSessionEvents(
172
189
  const loadingLatestRef = useRef(false);
173
190
  const viewModeRef = useRef<"live" | "history">("live");
174
191
  const initialWindowLoadedRef = useRef(false);
192
+ const reconcileAfterPageResumeRef = useRef(false);
175
193
  const streamAbortRef = useRef<AbortController | null>(null);
176
194
  const streamKeyRef = useRef<string | null>(null);
177
195
  const generationRef = useRef(0);
@@ -218,6 +236,7 @@ export function useSessionEvents(
218
236
  viewModeRef.current = "live";
219
237
  setViewMode("live");
220
238
  initialWindowLoadedRef.current = false;
239
+ reconcileAfterPageResumeRef.current = false;
221
240
  }
222
241
  // AbortController is advisory: custom SDK clients and async iterators may
223
242
  // ignore it and resolve/yield after cleanup. Fence every effect instance so
@@ -229,16 +248,35 @@ export function useSessionEvents(
229
248
  setLoadingOlder(false);
230
249
  }
231
250
  if (!sessionId || !streamEnabled) {
251
+ // A page that stayed hidden beyond the live-activity grace deliberately
252
+ // closed its SSE connection. Replaying from the old cursor on return can
253
+ // drip a large background backlog through React in many fast batches and
254
+ // leave the pinned camera catching up long after the user is foregrounded.
255
+ // Remember that suspension so the next live effect replaces the browser
256
+ // window with one compact durable tail instead.
257
+ if (
258
+ sessionId &&
259
+ enabled &&
260
+ !pageLive &&
261
+ !fullReplay &&
262
+ viewModeRef.current === "live" &&
263
+ initialWindowLoadedRef.current
264
+ ) {
265
+ reconcileAfterPageResumeRef.current = true;
266
+ }
232
267
  setConnectionState("idle");
233
268
  return;
234
269
  }
235
270
  // History view owns a non-tip window; do not open SSE (it would replay the
236
271
  // entire gap into the browser). jumpToLatest / catching loadNewer resume live.
237
272
  if (viewMode === "history") {
273
+ reconcileAfterPageResumeRef.current = false;
238
274
  setConnectionState("idle");
239
275
  setInitialLoading(false);
240
276
  return;
241
277
  }
278
+ const reconcileForegroundResume = reconcileAfterPageResumeRef.current && !fullReplay;
279
+ reconcileAfterPageResumeRef.current = false;
242
280
  const controller = new AbortController();
243
281
  const isCurrent = () => generationRef.current === generation && !controller.signal.aborted;
244
282
  streamAbortRef.current = controller;
@@ -309,6 +347,56 @@ export function useSessionEvents(
309
347
 
310
348
  void (async () => {
311
349
  try {
350
+ if (reconcileForegroundResume) {
351
+ setConnectionState("connecting");
352
+ const cursor = streamResumeSequenceRef.current;
353
+ let plan: ForegroundCatchupPlan = { kind: "resume" };
354
+ try {
355
+ plan = await planForegroundCatchup(client, workspaceId, sessionId, cursor, {
356
+ signal: controller.signal,
357
+ });
358
+ } catch {
359
+ if (controller.signal.aborted) return;
360
+ // This read is an optimization gate, not a new availability
361
+ // dependency. If it fails, preserve the SDK's exact cursor replay
362
+ // rather than blanking a usable timeline or failing the stream.
363
+ plan = { kind: "resume" };
364
+ }
365
+ if (!isCurrent()) return;
366
+ if (plan.kind === "append") {
367
+ const current = eventWindowRef.current;
368
+ assertAppendOrder(current.events, plan.events);
369
+ const status = observeSessionStatus(plan.events, sessionStatusRef);
370
+ const next = boundBrowserSessionEventWindow([...current.events, ...plan.events]);
371
+ const retained = {
372
+ ...next,
373
+ truncated: current.truncated || next.truncated,
374
+ };
375
+ eventWindowRef.current = retained;
376
+ oldestSequenceRef.current = retained.events[0]?.sequence ?? null;
377
+ newestSequenceRef.current = maxResumeSequenceOrNull(retained.events);
378
+ lastSequenceRef.current = Math.max(lastSequenceRef.current, plan.resumeSequence);
379
+ streamResumeSequenceRef.current = Math.max(
380
+ streamResumeSequenceRef.current,
381
+ plan.resumeSequence,
382
+ );
383
+ if (status !== undefined) {
384
+ setSessionStatusProjection(status);
385
+ }
386
+ setEventWindow(retained);
387
+ if (retained.truncated) {
388
+ hasOlderRef.current = true;
389
+ setHasOlder(true);
390
+ }
391
+ } else if (plan.kind === "reload") {
392
+ // A large/complex missed window would make the foreground timeline
393
+ // and pinned camera chase many rapid commits. Keep the last known
394
+ // complete window visible until the bounded latest replacement is
395
+ // ready, then install that replacement atomically below.
396
+ initialWindowLoadedRef.current = false;
397
+ setError(null);
398
+ }
399
+ }
312
400
  if (!fullReplay && !initialWindowLoadedRef.current) {
313
401
  setConnectionState("connecting");
314
402
  // First paint is ONE compact fetch — the newest window, revealed at
@@ -316,9 +404,10 @@ export function useSessionEvents(
316
404
  // reader actually scrolls up (the sentinel drives loadOlder).
317
405
  const window = await loadEventWindow(client, workspaceId, sessionId, {
318
406
  before: Number.MAX_SAFE_INTEGER,
319
- pageSize: INITIAL_TAIL_PAGE_SIZE,
407
+ pageSize: SESSION_HISTORY_PAGE_SIZE,
320
408
  targetGroups: Number.POSITIVE_INFINITY,
321
409
  maxFetches: INITIAL_FETCH_CAP,
410
+ boundaryPageCap: BOUNDARY_PAGE_CAP,
322
411
  signal: controller.signal,
323
412
  });
324
413
  if (!isCurrent()) {
@@ -425,6 +514,8 @@ export function useSessionEvents(
425
514
  workspaceId,
426
515
  sessionId,
427
516
  after,
517
+ enabled,
518
+ pageLive,
428
519
  streamEnabled,
429
520
  fullReplay,
430
521
  streamKey,
@@ -459,7 +550,7 @@ export function useSessionEvents(
459
550
  try {
460
551
  const window = await loadEventWindow(client, workspaceId, sessionId, {
461
552
  before,
462
- pageSize: OLDER_PAGE_SIZE,
553
+ pageSize: SESSION_HISTORY_PAGE_SIZE,
463
554
  targetGroups: OLDER_GROUP_TARGET,
464
555
  maxFetches: OLDER_FETCH_CAP,
465
556
  });
@@ -564,7 +655,7 @@ export function useSessionEvents(
564
655
  try {
565
656
  const window = await loadForwardEventWindow(client, workspaceId, sessionId, {
566
657
  after: 0,
567
- pageSize: OLDEST_PAGE_SIZE,
658
+ pageSize: SESSION_HISTORY_PAGE_SIZE,
568
659
  targetGroups: OLDEST_GROUP_TARGET,
569
660
  maxFetches: OLDEST_FETCH_CAP,
570
661
  });
@@ -633,7 +724,7 @@ export function useSessionEvents(
633
724
  try {
634
725
  const window = await loadForwardEventWindow(client, workspaceId, sessionId, {
635
726
  after: afterSequence,
636
- pageSize: NEWER_PAGE_SIZE,
727
+ pageSize: SESSION_HISTORY_PAGE_SIZE,
637
728
  targetGroups: NEWER_GROUP_TARGET,
638
729
  maxFetches: NEWER_FETCH_CAP,
639
730
  });
@@ -1083,6 +1174,70 @@ function observeSessionStatus(
1083
1174
  return latest.status;
1084
1175
  }
1085
1176
 
1177
+ type ForegroundCatchupPlan =
1178
+ | { kind: "resume" }
1179
+ | { kind: "append"; events: SessionEvent[]; resumeSequence: number }
1180
+ | { kind: "reload" };
1181
+
1182
+ /**
1183
+ * Decide how a sustained hidden-tab suspension rejoins the durable event log.
1184
+ *
1185
+ * `lastSequence - cursor` is the exact raw durable work missed, but it is not
1186
+ * the number of visible messages: one streaming answer can own thousands of
1187
+ * adjacent delta rows. Small raw gaps therefore resume directly; medium gaps
1188
+ * get one forward compact page and are judged by the browser-visible result;
1189
+ * a page that cannot prove complete bounded coverage, or that would add too
1190
+ * many rows/groups/bytes, reloads the latest tail instead.
1191
+ */
1192
+ async function planForegroundCatchup(
1193
+ client: EmbeddedSessionClientLike,
1194
+ workspaceId: string,
1195
+ sessionId: string,
1196
+ cursor: number,
1197
+ options: { signal?: AbortSignal } = {},
1198
+ ): Promise<ForegroundCatchupPlan> {
1199
+ if (options.signal?.aborted) throw abortError();
1200
+ const session = await client.getSession(workspaceId, sessionId, {
1201
+ fresh: true,
1202
+ ...(options.signal ? { signal: options.signal } : {}),
1203
+ });
1204
+ if (options.signal?.aborted) throw abortError();
1205
+ const durableHead = Math.max(cursor, session.lastSequence);
1206
+ const rawGap = durableHead - cursor;
1207
+ if (rawGap <= FOREGROUND_DIRECT_REPLAY_MAX_SEQUENCES) {
1208
+ return { kind: "resume" };
1209
+ }
1210
+ if (rawGap > FOREGROUND_COMPACT_PROBE_MAX_SEQUENCES) {
1211
+ return { kind: "reload" };
1212
+ }
1213
+
1214
+ const page = await loadNextPage(client, workspaceId, sessionId, cursor, {
1215
+ pageSize: rawGap,
1216
+ ...(options.signal ? { signal: options.signal } : {}),
1217
+ });
1218
+ const events = page
1219
+ .filter((event) => eventResumeSequence(event) > cursor)
1220
+ .map(boundBrowserLegacyEvent);
1221
+ if (events.length === 0) {
1222
+ return { kind: "reload" };
1223
+ }
1224
+ assertAscending(events);
1225
+ const resumeSequence = maxResumeSequence(events);
1226
+ if (resumeSequence < durableHead) {
1227
+ // Byte/count truncation or an incomplete projection means this page cannot
1228
+ // prove that one append reaches the head observed above.
1229
+ return { kind: "reload" };
1230
+ }
1231
+ if (
1232
+ events.length > FOREGROUND_COMPACT_CATCHUP_MAX_EVENTS ||
1233
+ groupCount(events) > FOREGROUND_COMPACT_CATCHUP_MAX_GROUPS ||
1234
+ browserJsonBytes(events) > FOREGROUND_COMPACT_CATCHUP_MAX_BYTES
1235
+ ) {
1236
+ return { kind: "reload" };
1237
+ }
1238
+ return { kind: "append", events, resumeSequence };
1239
+ }
1240
+
1086
1241
  type LoadedEventWindow = {
1087
1242
  events: SessionEvent[];
1088
1243
  oldestSequence: number | null;
@@ -1106,6 +1261,7 @@ async function loadEventWindow(
1106
1261
  pageSize: number;
1107
1262
  targetGroups: number;
1108
1263
  maxFetches: number;
1264
+ boundaryPageCap?: number;
1109
1265
  signal?: AbortSignal;
1110
1266
  },
1111
1267
  ): Promise<LoadedEventWindow> {
@@ -1140,14 +1296,13 @@ async function loadEventWindow(
1140
1296
  // turn boundary already in the buffer — the dropped fragment is refetched by
1141
1297
  // the next loadOlder (everything below the new oldest sequence), whose own
1142
1298
  // window snaps the same way, so every seam lands on a turn start. Extra
1143
- // pages are fetched only when the buffer holds no boundary at all (one
1144
- // monster turn); past the cap a mid-turn top is accepted.
1299
+ // page is fetched only when the buffer holds no boundary at all (one dense
1300
+ // turn); past the cap the existing truncation/hasOlder signal remains true.
1145
1301
  let snapPages = 0;
1146
1302
  while (
1147
1303
  !reachedStart &&
1148
1304
  findBoundaryIndex(buffer) === -1 &&
1149
- snapPages < BOUNDARY_PAGE_CAP &&
1150
- fetches < options.maxFetches
1305
+ snapPages < (options.boundaryPageCap ?? 0)
1151
1306
  ) {
1152
1307
  const page = await loadPreviousPage(client, workspaceId, sessionId, cursor, {
1153
1308
  pageSize: options.pageSize,
@@ -1378,13 +1533,13 @@ function maxResumeSequenceOrNull(events: readonly SessionEvent[]): number | null
1378
1533
  }
1379
1534
 
1380
1535
  function eventResumeSequence(event: SessionEvent): number {
1381
- const payload = asRecord(event.payload);
1382
- const coalescedUntil = Number(payload.coalescedUntil);
1383
- return Math.max(event.sequence, Number.isFinite(coalescedUntil) ? Math.floor(coalescedUntil) : 0);
1384
- }
1385
-
1386
- function asRecord(value: unknown): Record<string, unknown> {
1387
- return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
1536
+ const streamedCoverage = sessionEventStreamCoveredThrough(event);
1537
+ if (streamedCoverage !== null) return streamedCoverage;
1538
+ return typeof event.coveredThrough === "number" &&
1539
+ Number.isSafeInteger(event.coveredThrough) &&
1540
+ event.coveredThrough >= event.sequence
1541
+ ? event.coveredThrough
1542
+ : event.sequence;
1388
1543
  }
1389
1544
 
1390
1545
  function assertAscending(events: SessionEvent[]): void {
@@ -1,4 +1,5 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import type { FileSystemRouteIdentity } from "@opengeni/sdk";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
3
  import { useOpenGeni, type ClientOverride } from "../provider";
3
4
  import { sandboxAcceptsLiveIo } from "../lib/sandbox-liveness";
4
5
 
@@ -39,6 +40,9 @@ export type UseWorkspaceEditOptions = ClientOverride & {
39
40
  path?: string | null | undefined;
40
41
  /** The capture-served content loaded into the editor — the flush base (C2). */
41
42
  baseContent?: string | null | undefined;
43
+ /** Exact capability identity for the selected filesystem route. A route/root
44
+ * change discards the buffered edit and makes stale flushes fail retryably. */
45
+ route?: FileSystemRouteIdentity | undefined;
42
46
  /** `capabilities.liveness` — "warm" | "draining" | "cold". Drives the flush. */
43
47
  liveness?: string | undefined;
44
48
  /** True while the host is actively warming the box (attach in flight, not warm
@@ -95,9 +99,18 @@ export function useWorkspaceEdit(
95
99
  const { client, workspaceId } = useOpenGeni(options);
96
100
  const path = options.path ?? null;
97
101
  const baseContent = options.baseContent ?? null;
102
+ const routeEpoch = options.route?.epoch;
103
+ const routeRoot = options.route?.root;
104
+ const route = useMemo<FileSystemRouteIdentity | undefined>(
105
+ () =>
106
+ routeEpoch !== undefined && routeRoot !== undefined
107
+ ? { epoch: routeEpoch, root: routeRoot }
108
+ : undefined,
109
+ [routeEpoch, routeRoot],
110
+ );
98
111
  const live = isLive(options.liveness);
99
112
  const offline = options.offline === true;
100
- const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${path ?? ""}`;
113
+ const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${path ?? ""}\u0000${routeEpoch ?? ""}\u0000${routeRoot ?? ""}`;
101
114
 
102
115
  const [buffer, setBuffer] = useState<string | null>(null);
103
116
  const [wantsWarm, setWantsWarm] = useState(false);
@@ -178,7 +191,7 @@ export function useWorkspaceEdit(
178
191
  const liveRead = await client.fsRead(
179
192
  workspaceId,
180
193
  sessionId,
181
- { path },
194
+ { path, ...(route ? { route } : {}) },
182
195
  { signal: readAbort.signal },
183
196
  );
184
197
  if (readAbortRef.current === readAbort) readAbortRef.current = null;
@@ -190,7 +203,12 @@ export function useWorkspaceEdit(
190
203
  return;
191
204
  }
192
205
  }
193
- await client.fsWrite(workspaceId, sessionId, { path, content, overwrite: true });
206
+ await client.fsWrite(workspaceId, sessionId, {
207
+ path,
208
+ content,
209
+ overwrite: true,
210
+ ...(route ? { route } : {}),
211
+ });
194
212
  if (identityGenerationRef.current !== identityGeneration) return;
195
213
  setConflict(null);
196
214
  setFlush("flushed");
@@ -207,7 +225,7 @@ export function useWorkspaceEdit(
207
225
  }
208
226
  }
209
227
  },
210
- [client, workspaceId, sessionId, path, baseContent, stateIdentity, identityKey],
228
+ [client, workspaceId, sessionId, path, baseContent, route, stateIdentity, identityKey],
211
229
  );
212
230
 
213
231
  // Auto-flush once the box is warm and a buffer is pending (the wake completed).