@opengeni/react 3.5.1-canary.0 → 3.7.0-canary.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.
- package/dist/artifacts.d.ts +1 -1
- package/dist/artifacts.js +248 -6
- package/dist/artifacts.js.map +1 -1
- package/dist/{browser-viewer-E5DDEFQ4.js → browser-viewer-NTD6UIPR.js} +3 -3
- package/dist/{chunk-W2RGYBCV.js → chunk-24M4YBCL.js} +4 -4
- package/dist/{chunk-WY3MELVJ.js → chunk-5IXTJTK3.js} +1438 -397
- package/dist/chunk-5IXTJTK3.js.map +1 -0
- package/dist/chunk-5PQJOCX5.js +902 -0
- package/dist/chunk-5PQJOCX5.js.map +1 -0
- package/dist/{chunk-UNCXXIQR.js → chunk-INGB2N5R.js} +4310 -3982
- package/dist/chunk-INGB2N5R.js.map +1 -0
- package/dist/{chunk-TJFMMDQU.js → chunk-MOWYEBCQ.js} +1839 -17
- package/dist/chunk-MOWYEBCQ.js.map +1 -0
- package/dist/chunk-PAJU5KKL.js +331 -0
- package/dist/chunk-PAJU5KKL.js.map +1 -0
- package/dist/{chunk-VTOREEXF.js → chunk-PLYRL5ER.js} +24 -2
- package/dist/{chunk-VTOREEXF.js.map → chunk-PLYRL5ER.js.map} +1 -1
- package/dist/{chunk-YVCHAQZY.js → chunk-ZFOFY5ST.js} +27 -349
- package/dist/chunk-ZFOFY5ST.js.map +1 -0
- package/dist/client.d.ts +1 -1
- package/dist/components/artifacts/index.d.ts +1 -1
- package/dist/components/artifacts/published-html-artifact-frame.d.ts +46 -3
- package/dist/components/composer.d.ts +2 -0
- package/dist/components/session-chrome.d.ts +38 -5
- package/dist/components/session-commands-panel.d.ts +6 -0
- package/dist/components/session-conversation.d.ts +14 -0
- package/dist/components/timeline-anchor.d.ts +21 -0
- package/dist/composer.js +2 -3
- package/dist/conversation-timeline.d.ts +5 -0
- package/dist/{fleet-decision-row-GGCAT4E4.js → fleet-decision-row-YZ3ZKWMM.js} +2 -1
- package/dist/fleet-decision-row-YZ3ZKWMM.js.map +1 -0
- package/dist/hooks/use-file-attachments.d.ts +6 -0
- package/dist/hooks/use-sandbox-files.d.ts +4 -1
- package/dist/hooks/use-workspace-edit.d.ts +4 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +823 -965
- package/dist/index.js.map +1 -1
- package/dist/interaction.js +2 -2
- package/dist/lib/format.d.ts +5 -0
- package/dist/machines.js +3 -2
- package/dist/session-ui.d.ts +3 -0
- package/dist/session-ui.js +14 -4
- package/dist/session.js +19 -19
- package/dist/timeline/registry.d.ts +2 -0
- package/dist/timeline/types.d.ts +4 -2
- package/package.json +2 -2
- package/src/artifacts.ts +3 -0
- package/src/client.ts +2 -1
- package/src/components/artifacts/index.ts +3 -0
- package/src/components/artifacts/published-html-artifact-frame.tsx +307 -4
- package/src/components/composer.tsx +89 -25
- package/src/components/machine-input-display.ts +6 -0
- package/src/components/message-timeline.tsx +351 -204
- package/src/components/sandbox-workspace.tsx +11 -1
- package/src/components/session-chrome.tsx +501 -128
- package/src/components/session-commands-panel.tsx +113 -0
- package/src/components/session-conversation.tsx +141 -0
- package/src/components/timeline-anchor.tsx +91 -0
- package/src/conversation-timeline.ts +83 -0
- package/src/hooks/use-file-attachments.ts +34 -0
- package/src/hooks/use-sandbox-files.ts +68 -20
- package/src/hooks/use-session-background-commands.ts +21 -10
- package/src/hooks/use-session-events.ts +56 -39
- package/src/hooks/use-session.ts +23 -2
- package/src/hooks/use-workspace-edit.ts +23 -5
- package/src/index.ts +4 -0
- package/src/lib/format.ts +32 -0
- package/src/session-ui.ts +3 -0
- package/src/timeline/fleet-decision-projection.ts +2 -1
- package/src/timeline/fleet-decision-row.tsx +1 -0
- package/src/timeline/projection.ts +157 -19
- package/src/timeline/registry.ts +15 -3
- package/src/timeline/tool-renderers.tsx +117 -1
- package/src/timeline/types.ts +12 -1
- package/styles/compiled.css +495 -411
- package/styles/index.css +23 -0
- package/dist/chunk-7EAFGICR.js +0 -302
- package/dist/chunk-7EAFGICR.js.map +0 -1
- package/dist/chunk-IB27C53Y.js +0 -2582
- package/dist/chunk-IB27C53Y.js.map +0 -1
- package/dist/chunk-TJFMMDQU.js.map +0 -1
- package/dist/chunk-UNCXXIQR.js.map +0 -1
- package/dist/chunk-WY3MELVJ.js.map +0 -1
- package/dist/chunk-YVCHAQZY.js.map +0 -1
- package/dist/fleet-decision-row-GGCAT4E4.js.map +0 -1
- /package/dist/{browser-viewer-E5DDEFQ4.js.map → browser-viewer-NTD6UIPR.js.map} +0 -0
- /package/dist/{chunk-W2RGYBCV.js.map → chunk-24M4YBCL.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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
() =>
|
|
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
|
-
() =>
|
|
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,5 +1,5 @@
|
|
|
1
1
|
import type { SessionBackgroundCommand } from "@opengeni/sdk";
|
|
2
|
-
import { useCallback } from "react";
|
|
2
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
3
3
|
|
|
4
4
|
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
5
5
|
import { usePolledValue } from "./internal";
|
|
@@ -23,28 +23,39 @@ export function useSessionBackgroundCommands(
|
|
|
23
23
|
): UseSessionBackgroundCommandsResult {
|
|
24
24
|
const { client, workspaceId } = useOpenGeni(options);
|
|
25
25
|
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
26
|
-
const load = useCallback(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
const load = useCallback(
|
|
27
|
+
async (signal?: AbortSignal) => {
|
|
28
|
+
if (!sessionId) return { commands: [] };
|
|
29
|
+
if (!client.listSessionBackgroundCommands) {
|
|
30
|
+
throw new Error("The configured OpenGeni client does not support background commands");
|
|
31
|
+
}
|
|
32
|
+
return await client.listSessionBackgroundCommands(workspaceId, sessionId, { signal });
|
|
33
|
+
},
|
|
34
|
+
[client, workspaceId, sessionId],
|
|
35
|
+
);
|
|
33
36
|
const state = usePolledValue(load, {
|
|
34
37
|
enabled,
|
|
35
38
|
pollIntervalMs: options.pollIntervalMs,
|
|
36
39
|
});
|
|
37
40
|
const refresh = state.refresh;
|
|
41
|
+
const generation = useRef(0);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
generation.current += 1;
|
|
44
|
+
return () => {
|
|
45
|
+
generation.current += 1;
|
|
46
|
+
};
|
|
47
|
+
}, [client, workspaceId, sessionId, enabled]);
|
|
38
48
|
const cancel = useCallback(
|
|
39
49
|
async (commandId: string): Promise<void> => {
|
|
40
50
|
if (!sessionId) return;
|
|
41
51
|
if (!client.cancelSessionBackgroundCommand) {
|
|
42
52
|
throw new Error("The configured OpenGeni client does not support background commands");
|
|
43
53
|
}
|
|
54
|
+
const ticket = generation.current;
|
|
44
55
|
await client.cancelSessionBackgroundCommand(workspaceId, sessionId, commandId);
|
|
45
|
-
await refresh();
|
|
56
|
+
if (enabled && ticket === generation.current) await refresh();
|
|
46
57
|
},
|
|
47
|
-
[client, workspaceId, sessionId, refresh],
|
|
58
|
+
[client, workspaceId, sessionId, refresh, enabled],
|
|
48
59
|
);
|
|
49
60
|
return {
|
|
50
61
|
commands: state.data?.commands ?? [],
|
|
@@ -74,10 +74,10 @@ export type UseSessionEventsResult = {
|
|
|
74
74
|
error: Error | null;
|
|
75
75
|
};
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
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 = 1000;
|
|
81
81
|
const INITIAL_FETCH_CAP = 1;
|
|
82
82
|
const OLDER_GROUP_TARGET = 32;
|
|
83
83
|
const OLDER_FETCH_CAP = 2;
|
|
@@ -85,13 +85,16 @@ const NEWER_GROUP_TARGET = 32;
|
|
|
85
85
|
const NEWER_FETCH_CAP = 2;
|
|
86
86
|
const OLDEST_GROUP_TARGET = 32;
|
|
87
87
|
const OLDEST_FETCH_CAP = 2;
|
|
88
|
-
|
|
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;
|
|
89
92
|
// Foreground reconciliation is intentionally semantic, not merely time-based:
|
|
90
93
|
// tiny raw gaps can stay on SSE; medium raw gaps get one compact probe so a
|
|
91
94
|
// token-heavy single answer is not mistaken for hundreds of visible messages;
|
|
92
95
|
// only a large/complex missed window reloads the latest tail.
|
|
93
96
|
const FOREGROUND_DIRECT_REPLAY_MAX_SEQUENCES = 16;
|
|
94
|
-
const FOREGROUND_COMPACT_PROBE_MAX_SEQUENCES =
|
|
97
|
+
const FOREGROUND_COMPACT_PROBE_MAX_SEQUENCES = SESSION_HISTORY_PAGE_SIZE;
|
|
95
98
|
const FOREGROUND_COMPACT_CATCHUP_MAX_EVENTS = 128;
|
|
96
99
|
const FOREGROUND_COMPACT_CATCHUP_MAX_GROUPS = 16;
|
|
97
100
|
const FOREGROUND_COMPACT_CATCHUP_MAX_BYTES = 512 * 1024;
|
|
@@ -190,6 +193,7 @@ export function useSessionEvents(
|
|
|
190
193
|
const streamAbortRef = useRef<AbortController | null>(null);
|
|
191
194
|
const streamKeyRef = useRef<string | null>(null);
|
|
192
195
|
const generationRef = useRef(0);
|
|
196
|
+
const navigationGenerationRef = useRef(0);
|
|
193
197
|
const eventWindowRef = useRef<BrowserSessionEventWindow>(EMPTY_EVENT_WINDOW);
|
|
194
198
|
const sessionStatusRef = useRef<{
|
|
195
199
|
sequence: number;
|
|
@@ -202,6 +206,21 @@ export function useSessionEvents(
|
|
|
202
206
|
// new stream identity cannot expose the previous session's event log.
|
|
203
207
|
const [stateStreamKey, setStateStreamKey] = useState(streamKey);
|
|
204
208
|
|
|
209
|
+
// Reopening SSE after a prepend must not cancel the next history page.
|
|
210
|
+
// Navigation belongs to the session/client lifetime, not the transport.
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
navigationGenerationRef.current += 1;
|
|
213
|
+
loadingOlderRef.current = false;
|
|
214
|
+
loadingNewerRef.current = false;
|
|
215
|
+
loadingOldestRef.current = false;
|
|
216
|
+
setLoadingOlder(false);
|
|
217
|
+
setLoadingNewer(false);
|
|
218
|
+
setLoadingOldest(false);
|
|
219
|
+
return () => {
|
|
220
|
+
navigationGenerationRef.current += 1;
|
|
221
|
+
};
|
|
222
|
+
}, [client, workspaceId, sessionId, after, enabled, fullReplay]);
|
|
223
|
+
|
|
205
224
|
useEffect(() => {
|
|
206
225
|
// Reset the accumulated log only when the stream identity changes —
|
|
207
226
|
// pausing via `enabled: false` keeps the timeline visible.
|
|
@@ -240,10 +259,6 @@ export function useSessionEvents(
|
|
|
240
259
|
// only the newest dependency generation can mutate refs or React state.
|
|
241
260
|
const generation = generationRef.current + 1;
|
|
242
261
|
generationRef.current = generation;
|
|
243
|
-
if (loadingOlderRef.current) {
|
|
244
|
-
loadingOlderRef.current = false;
|
|
245
|
-
setLoadingOlder(false);
|
|
246
|
-
}
|
|
247
262
|
if (!sessionId || !streamEnabled) {
|
|
248
263
|
// A page that stayed hidden beyond the live-activity grace deliberately
|
|
249
264
|
// closed its SSE connection. Replaying from the old cursor on return can
|
|
@@ -387,19 +402,10 @@ export function useSessionEvents(
|
|
|
387
402
|
}
|
|
388
403
|
} else if (plan.kind === "reload") {
|
|
389
404
|
// A large/complex missed window would make the foreground timeline
|
|
390
|
-
// and pinned camera chase many rapid commits.
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
oldestSequenceRef.current = null;
|
|
394
|
-
newestSequenceRef.current = null;
|
|
395
|
-
hasOlderRef.current = false;
|
|
396
|
-
hasNewerRef.current = false;
|
|
405
|
+
// and pinned camera chase many rapid commits. Keep the last known
|
|
406
|
+
// complete window visible until the bounded latest replacement is
|
|
407
|
+
// ready, then install that replacement atomically below.
|
|
397
408
|
initialWindowLoadedRef.current = false;
|
|
398
|
-
streamResumeSequenceRef.current = after;
|
|
399
|
-
setEventWindow(EMPTY_EVENT_WINDOW);
|
|
400
|
-
setHasOlder(false);
|
|
401
|
-
setHasNewer(false);
|
|
402
|
-
setInitialLoading(true);
|
|
403
409
|
setError(null);
|
|
404
410
|
}
|
|
405
411
|
}
|
|
@@ -410,9 +416,10 @@ export function useSessionEvents(
|
|
|
410
416
|
// reader actually scrolls up (the sentinel drives loadOlder).
|
|
411
417
|
const window = await loadEventWindow(client, workspaceId, sessionId, {
|
|
412
418
|
before: Number.MAX_SAFE_INTEGER,
|
|
413
|
-
pageSize:
|
|
419
|
+
pageSize: SESSION_HISTORY_PAGE_SIZE,
|
|
414
420
|
targetGroups: Number.POSITIVE_INFINITY,
|
|
415
421
|
maxFetches: INITIAL_FETCH_CAP,
|
|
422
|
+
boundaryPageCap: BOUNDARY_PAGE_CAP,
|
|
416
423
|
signal: controller.signal,
|
|
417
424
|
});
|
|
418
425
|
if (!isCurrent()) {
|
|
@@ -420,6 +427,16 @@ export function useSessionEvents(
|
|
|
420
427
|
}
|
|
421
428
|
const status = observeSessionStatus(window.events, sessionStatusRef);
|
|
422
429
|
const retained = boundBrowserSessionEventWindow(window.events);
|
|
430
|
+
// A replacement tail retires requests against the discarded window.
|
|
431
|
+
// Ordinary SSE reconnects preserve navigation, but splicing an old
|
|
432
|
+
// page into this new tail could leave an inaccessible history gap.
|
|
433
|
+
navigationGenerationRef.current += 1;
|
|
434
|
+
loadingOlderRef.current = false;
|
|
435
|
+
loadingNewerRef.current = false;
|
|
436
|
+
loadingOldestRef.current = false;
|
|
437
|
+
setLoadingOlder(false);
|
|
438
|
+
setLoadingNewer(false);
|
|
439
|
+
setLoadingOldest(false);
|
|
423
440
|
eventWindowRef.current = retained;
|
|
424
441
|
oldestSequenceRef.current = retained.events[0]?.sequence ?? window.oldestSequence;
|
|
425
442
|
newestSequenceRef.current =
|
|
@@ -548,18 +565,18 @@ export function useSessionEvents(
|
|
|
548
565
|
setHasOlder(false);
|
|
549
566
|
return false;
|
|
550
567
|
}
|
|
551
|
-
const generation =
|
|
568
|
+
const generation = navigationGenerationRef.current;
|
|
552
569
|
loadingOlderRef.current = true;
|
|
553
570
|
setLoadingOlder(true);
|
|
554
571
|
let published = false;
|
|
555
572
|
try {
|
|
556
573
|
const window = await loadEventWindow(client, workspaceId, sessionId, {
|
|
557
574
|
before,
|
|
558
|
-
pageSize:
|
|
575
|
+
pageSize: SESSION_HISTORY_PAGE_SIZE,
|
|
559
576
|
targetGroups: OLDER_GROUP_TARGET,
|
|
560
577
|
maxFetches: OLDER_FETCH_CAP,
|
|
561
578
|
});
|
|
562
|
-
if (
|
|
579
|
+
if (navigationGenerationRef.current !== generation) {
|
|
563
580
|
return false;
|
|
564
581
|
}
|
|
565
582
|
if (window.events.length === 0) {
|
|
@@ -640,7 +657,7 @@ export function useSessionEvents(
|
|
|
640
657
|
published = true;
|
|
641
658
|
return olderStillAvailable;
|
|
642
659
|
} finally {
|
|
643
|
-
if (!published) {
|
|
660
|
+
if (!published && navigationGenerationRef.current === generation) {
|
|
644
661
|
loadingOlderRef.current = false;
|
|
645
662
|
setLoadingOlder(false);
|
|
646
663
|
}
|
|
@@ -653,18 +670,18 @@ export function useSessionEvents(
|
|
|
653
670
|
if (!sessionId || navigationBusy() || !hasOlderRef.current) {
|
|
654
671
|
return false;
|
|
655
672
|
}
|
|
656
|
-
const generation =
|
|
673
|
+
const generation = navigationGenerationRef.current;
|
|
657
674
|
loadingOldestRef.current = true;
|
|
658
675
|
setLoadingOldest(true);
|
|
659
676
|
let published = false;
|
|
660
677
|
try {
|
|
661
678
|
const window = await loadForwardEventWindow(client, workspaceId, sessionId, {
|
|
662
679
|
after: 0,
|
|
663
|
-
pageSize:
|
|
680
|
+
pageSize: SESSION_HISTORY_PAGE_SIZE,
|
|
664
681
|
targetGroups: OLDEST_GROUP_TARGET,
|
|
665
682
|
maxFetches: OLDEST_FETCH_CAP,
|
|
666
683
|
});
|
|
667
|
-
if (
|
|
684
|
+
if (navigationGenerationRef.current !== generation) {
|
|
668
685
|
return false;
|
|
669
686
|
}
|
|
670
687
|
if (window.events.length === 0) {
|
|
@@ -705,7 +722,7 @@ export function useSessionEvents(
|
|
|
705
722
|
published = true;
|
|
706
723
|
return newer;
|
|
707
724
|
} finally {
|
|
708
|
-
if (!published) {
|
|
725
|
+
if (!published && navigationGenerationRef.current === generation) {
|
|
709
726
|
loadingOldestRef.current = false;
|
|
710
727
|
setLoadingOldest(false);
|
|
711
728
|
}
|
|
@@ -722,18 +739,18 @@ export function useSessionEvents(
|
|
|
722
739
|
setHasNewer(false);
|
|
723
740
|
return false;
|
|
724
741
|
}
|
|
725
|
-
const generation =
|
|
742
|
+
const generation = navigationGenerationRef.current;
|
|
726
743
|
loadingNewerRef.current = true;
|
|
727
744
|
setLoadingNewer(true);
|
|
728
745
|
let published = false;
|
|
729
746
|
try {
|
|
730
747
|
const window = await loadForwardEventWindow(client, workspaceId, sessionId, {
|
|
731
748
|
after: afterSequence,
|
|
732
|
-
pageSize:
|
|
749
|
+
pageSize: SESSION_HISTORY_PAGE_SIZE,
|
|
733
750
|
targetGroups: NEWER_GROUP_TARGET,
|
|
734
751
|
maxFetches: NEWER_FETCH_CAP,
|
|
735
752
|
});
|
|
736
|
-
if (
|
|
753
|
+
if (navigationGenerationRef.current !== generation) {
|
|
737
754
|
return false;
|
|
738
755
|
}
|
|
739
756
|
if (window.events.length === 0) {
|
|
@@ -805,7 +822,7 @@ export function useSessionEvents(
|
|
|
805
822
|
published = true;
|
|
806
823
|
return newer;
|
|
807
824
|
} finally {
|
|
808
|
-
if (!published) {
|
|
825
|
+
if (!published && navigationGenerationRef.current === generation) {
|
|
809
826
|
loadingNewerRef.current = false;
|
|
810
827
|
setLoadingNewer(false);
|
|
811
828
|
}
|
|
@@ -1266,6 +1283,7 @@ async function loadEventWindow(
|
|
|
1266
1283
|
pageSize: number;
|
|
1267
1284
|
targetGroups: number;
|
|
1268
1285
|
maxFetches: number;
|
|
1286
|
+
boundaryPageCap?: number;
|
|
1269
1287
|
signal?: AbortSignal;
|
|
1270
1288
|
},
|
|
1271
1289
|
): Promise<LoadedEventWindow> {
|
|
@@ -1300,14 +1318,13 @@ async function loadEventWindow(
|
|
|
1300
1318
|
// turn boundary already in the buffer — the dropped fragment is refetched by
|
|
1301
1319
|
// the next loadOlder (everything below the new oldest sequence), whose own
|
|
1302
1320
|
// window snaps the same way, so every seam lands on a turn start. Extra
|
|
1303
|
-
//
|
|
1304
|
-
//
|
|
1321
|
+
// page is fetched only when the buffer holds no boundary at all (one dense
|
|
1322
|
+
// turn); past the cap the existing truncation/hasOlder signal remains true.
|
|
1305
1323
|
let snapPages = 0;
|
|
1306
1324
|
while (
|
|
1307
1325
|
!reachedStart &&
|
|
1308
1326
|
findBoundaryIndex(buffer) === -1 &&
|
|
1309
|
-
snapPages <
|
|
1310
|
-
fetches < options.maxFetches
|
|
1327
|
+
snapPages < (options.boundaryPageCap ?? 0)
|
|
1311
1328
|
) {
|
|
1312
1329
|
const page = await loadPreviousPage(client, workspaceId, sessionId, cursor, {
|
|
1313
1330
|
pageSize: options.pageSize,
|
package/src/hooks/use-session.ts
CHANGED
|
@@ -38,6 +38,10 @@ export function isTitleEvent(event: Pick<SessionEvent, "type">): boolean {
|
|
|
38
38
|
return event.type === "session.title_set";
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function isSessionDetailEvent(event: Pick<SessionEvent, "type">): boolean {
|
|
42
|
+
return isTitleEvent(event) || event.type.startsWith("session.command.");
|
|
43
|
+
}
|
|
44
|
+
|
|
41
45
|
/** Fetch one session (with optional polling), live-patching its title on `session.title_set`. */
|
|
42
46
|
export function useSession(
|
|
43
47
|
sessionId: string | null | undefined,
|
|
@@ -99,10 +103,27 @@ export function useSession(
|
|
|
99
103
|
[base, override],
|
|
100
104
|
);
|
|
101
105
|
|
|
106
|
+
const sharedEvents = options.events;
|
|
107
|
+
|
|
102
108
|
// Live-patch the title on auto (agent) + cross-client (user/agent) renames so
|
|
103
109
|
// the UI reflects the new title without polling or a full re-fetch.
|
|
104
110
|
const onTitleEvent = useCallback(
|
|
105
111
|
(event: SessionEvent) => {
|
|
112
|
+
// Reconciliation emits only the last matching member of a shared batch.
|
|
113
|
+
// A later title must not hide an earlier command activity change.
|
|
114
|
+
if (
|
|
115
|
+
sharedEvents?.some(
|
|
116
|
+
(item) =>
|
|
117
|
+
item.sessionId === sessionId &&
|
|
118
|
+
item.type.startsWith("session.command.") &&
|
|
119
|
+
item.sequence > (base?.lastSequence ?? -1),
|
|
120
|
+
)
|
|
121
|
+
) {
|
|
122
|
+
void refresh();
|
|
123
|
+
} else if (event.type.startsWith("session.command.")) {
|
|
124
|
+
if (!base || event.sequence > base.lastSequence) void refresh();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
106
127
|
// The fetched session row is the authoritative title projection through
|
|
107
128
|
// lastSequence. Shared feeds may replay that historical tail after the
|
|
108
129
|
// fetch; applying it would undo a row-only migration quarantine. Only an
|
|
@@ -122,9 +143,9 @@ export function useSession(
|
|
|
122
143
|
return { ...next, title, titleSource: source };
|
|
123
144
|
});
|
|
124
145
|
},
|
|
125
|
-
[base],
|
|
146
|
+
[base, refresh, sharedEvents, sessionId],
|
|
126
147
|
);
|
|
127
|
-
useSessionEventTrigger(client, workspaceId, sessionId,
|
|
148
|
+
useSessionEventTrigger(client, workspaceId, sessionId, isSessionDetailEvent, onTitleEvent, {
|
|
128
149
|
enabled,
|
|
129
150
|
...(options.events !== undefined ? { events: options.events } : {}),
|
|
130
151
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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, {
|
|
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).
|