@opengeni/react 0.15.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.
- package/README.md +110 -10
- package/dist/{chunk-TOJR776I.js → chunk-6BXBGJ3B.js} +45 -369
- package/dist/chunk-6BXBGJ3B.js.map +1 -0
- package/dist/chunk-6UT5OC3P.js +2133 -0
- package/dist/chunk-6UT5OC3P.js.map +1 -0
- package/dist/chunk-EBTT3MYN.js +92 -0
- package/dist/chunk-EBTT3MYN.js.map +1 -0
- package/dist/chunk-HHFA4AFX.js +425 -0
- package/dist/chunk-HHFA4AFX.js.map +1 -0
- package/dist/chunk-LAZ2A743.js +2393 -0
- package/dist/chunk-LAZ2A743.js.map +1 -0
- package/dist/chunk-MRSECXRP.js +563 -0
- package/dist/chunk-MRSECXRP.js.map +1 -0
- package/dist/chunk-TK7G6XLT.js +18 -0
- package/dist/chunk-TK7G6XLT.js.map +1 -0
- package/dist/composer-DvUaa5ki.d.ts +585 -0
- package/dist/composer.d.ts +6 -0
- package/dist/composer.js +56 -0
- package/dist/composer.js.map +1 -0
- package/dist/index.d.ts +1288 -1785
- package/dist/index.js +7015 -8806
- package/dist/index.js.map +1 -1
- package/dist/machines.d.ts +412 -3
- package/dist/machines.js +3 -1
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
- package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
- package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
- package/dist/session-Gd4iVPYw.d.ts +425 -0
- package/dist/session-context-DIFFlXKc.d.ts +52 -0
- package/dist/session.d.ts +4 -0
- package/dist/session.js +50 -0
- package/dist/session.js.map +1 -0
- package/dist/use-composer-BCfm4mzX.d.ts +112 -0
- package/package.json +20 -4
- package/src/approvals.ts +5 -3
- package/src/client.ts +40 -0
- package/src/components/approval-surface.tsx +198 -0
- package/src/components/chat-composer.tsx +94 -993
- package/src/components/code-editor.tsx +104 -26
- package/src/components/command-palette.tsx +8 -2
- package/src/components/composer-transcription-control.tsx +211 -0
- package/src/components/composer.tsx +1536 -0
- package/src/components/desktop-viewer.tsx +3 -3
- package/src/components/enrollment-consent.tsx +2 -2
- package/src/components/file-browser.tsx +367 -29
- package/src/components/human-input-form.tsx +417 -0
- package/src/components/machine-card.tsx +1 -1
- package/src/components/machines-dashboard.tsx +3 -3
- package/src/components/message-timeline.tsx +15 -7
- package/src/components/model-picker.tsx +12 -3
- package/src/components/pierre-diff.tsx +32 -6
- package/src/components/queue-surface-implementation.tsx +1041 -0
- package/src/components/queue-surface-state.tsx +94 -0
- package/src/components/queue-surface.tsx +41 -559
- package/src/components/sandbox-files.tsx +138 -14
- package/src/components/sandbox-terminal.tsx +8 -2
- package/src/components/sandbox-workspace.tsx +339 -119
- package/src/components/workbench-changes.tsx +84 -33
- package/src/components/workspace-dock.tsx +188 -18
- package/src/composer.ts +60 -0
- package/src/hooks/internal.ts +115 -34
- package/src/hooks/use-composer.ts +335 -104
- package/src/hooks/use-human-input.ts +131 -0
- package/src/hooks/use-machine-chip.ts +2 -2
- package/src/hooks/use-machines.ts +27 -11
- package/src/hooks/use-sandbox-files.ts +310 -62
- package/src/hooks/use-sandbox-git.ts +154 -40
- package/src/hooks/use-sandbox-terminal.ts +28 -6
- package/src/hooks/use-session-capabilities.ts +38 -9
- package/src/hooks/use-session-control.ts +37 -8
- package/src/hooks/use-session-events.ts +522 -48
- package/src/hooks/use-slash-commands.ts +38 -38
- package/src/hooks/use-transcription.ts +757 -0
- package/src/hooks/use-turn-queue.ts +134 -66
- package/src/hooks/use-windowed-sections.ts +2 -2
- package/src/hooks/use-workspace-capture.ts +146 -40
- package/src/hooks/use-workspace-edit.ts +50 -14
- package/src/hooks/use-workspace-sessions.ts +3 -0
- package/src/human-input.ts +72 -0
- package/src/index.ts +76 -6
- package/src/lib/noto-sans-arabic-loader.ts +21 -0
- package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
- package/src/lib/use-portal-token-style.ts +51 -0
- package/src/lib/use-unicode-fonts.ts +57 -0
- package/src/provider.tsx +4 -69
- package/src/session-context.ts +135 -0
- package/src/session.ts +79 -0
- package/src/timeline/parsers.ts +35 -0
- package/src/timeline/projection.ts +12 -4
- package/src/timeline/tool-renderers.tsx +41 -0
- package/src/timeline/turn-summary.tsx +2 -2
- package/src/timeline/types.ts +16 -8
- package/styles/index.css +56 -0
- package/styles/index.d.ts +2 -0
- package/styles/tokens.css +11 -6
- package/styles/tokens.d.ts +2 -0
- package/dist/chunk-TOJR776I.js.map +0 -1
- package/dist/machines-BpdwuQcD.d.ts +0 -449
|
@@ -6,8 +6,8 @@ import type {
|
|
|
6
6
|
SessionQueueSnapshot,
|
|
7
7
|
SessionTurn,
|
|
8
8
|
} from "@opengeni/sdk";
|
|
9
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
10
|
-
import {
|
|
9
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
10
|
+
import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context";
|
|
11
11
|
import {
|
|
12
12
|
useDebouncedCallback,
|
|
13
13
|
useSessionEventTrigger,
|
|
@@ -24,9 +24,21 @@ export function isTurnQueueEvent(event: Pick<SessionEvent, "type">): boolean {
|
|
|
24
24
|
);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function queueSnapshotCovers(
|
|
28
|
+
candidate: SessionQueueSnapshot | null,
|
|
29
|
+
observed: SessionQueueSnapshot,
|
|
30
|
+
): boolean {
|
|
31
|
+
return Boolean(
|
|
32
|
+
candidate &&
|
|
33
|
+
candidate.version >= observed.version &&
|
|
34
|
+
candidate.effectiveControl.controlVersion >= observed.effectiveControl.controlVersion,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
export type QueueMutationKind = "move" | "edit" | "steer" | "delete";
|
|
39
|
+
const EMPTY_PENDING_BY_TURN: Readonly<Record<string, QueueMutationKind>> = {};
|
|
28
40
|
|
|
29
|
-
export type UseTurnQueueOptions =
|
|
41
|
+
export type UseTurnQueueOptions = EmbeddedSessionClientOverride &
|
|
30
42
|
SessionEventFeedOptions & {
|
|
31
43
|
pollIntervalMs?: number | undefined;
|
|
32
44
|
};
|
|
@@ -36,6 +48,8 @@ export type UseTurnQueueResult = {
|
|
|
36
48
|
/** Human/API prompts exactly in server execution order. Never client-sorted. */
|
|
37
49
|
queue: SessionTurn[];
|
|
38
50
|
effectiveControl: EffectiveSessionControl | null;
|
|
51
|
+
/** The latest interrupted attempt has not yet durably proved physical quiescence. */
|
|
52
|
+
stoppingPreviousAttempt: boolean;
|
|
39
53
|
loading: boolean;
|
|
40
54
|
error: Error | null;
|
|
41
55
|
refresh: () => Promise<void>;
|
|
@@ -65,62 +79,92 @@ export function useTurnQueue(
|
|
|
65
79
|
options: UseTurnQueueOptions = {},
|
|
66
80
|
): UseTurnQueueResult {
|
|
67
81
|
const { client, workspaceId, workspaceControlEvent, registerSessionReconciler } =
|
|
68
|
-
|
|
82
|
+
useEmbeddedSession(options);
|
|
69
83
|
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
84
|
+
const targetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
|
|
70
85
|
const [snapshot, setSnapshot] = useState<SessionQueueSnapshot | null>(null);
|
|
86
|
+
const [stateTargetKey, setStateTargetKey] = useState(targetKey);
|
|
71
87
|
const [loading, setLoading] = useState(enabled);
|
|
72
88
|
const [error, setError] = useState<Error | null>(null);
|
|
73
89
|
const [mutationError, setMutationError] = useState<Error | null>(null);
|
|
74
90
|
const [pendingByTurn, setPendingByTurn] = useState<Record<string, QueueMutationKind>>({});
|
|
75
91
|
const pendingRef = useRef<Record<string, QueueMutationKind>>({});
|
|
76
92
|
const readGeneration = useRef(0);
|
|
77
|
-
const targetKeyRef = useRef
|
|
93
|
+
const targetKeyRef = useRef(targetKey);
|
|
78
94
|
const snapshotRef = useRef<SessionQueueSnapshot | null>(null);
|
|
79
95
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
// Revoke the old target only when the new one commits. A concurrent render
|
|
97
|
+
// may suspend while the previous target remains visible and interactive.
|
|
98
|
+
useLayoutEffect(() => {
|
|
99
|
+
if (targetKeyRef.current === targetKey) return;
|
|
100
|
+
targetKeyRef.current = targetKey;
|
|
101
|
+
readGeneration.current += 1;
|
|
102
|
+
snapshotRef.current = null;
|
|
103
|
+
pendingRef.current = {};
|
|
104
|
+
setStateTargetKey(targetKey);
|
|
105
|
+
setSnapshot(null);
|
|
106
|
+
setLoading(enabled);
|
|
107
|
+
setError(null);
|
|
108
|
+
setMutationError(null);
|
|
109
|
+
setPendingByTurn({});
|
|
110
|
+
}, [enabled, targetKey]);
|
|
94
111
|
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
112
|
+
const acceptSnapshot = useCallback(
|
|
113
|
+
(ownedTargetKey: string, next: SessionQueueSnapshot | null): boolean => {
|
|
114
|
+
if (targetKeyRef.current !== ownedTargetKey) return false;
|
|
115
|
+
const current = snapshotRef.current;
|
|
116
|
+
if (
|
|
117
|
+
next &&
|
|
118
|
+
current &&
|
|
119
|
+
(next.version < current.version ||
|
|
120
|
+
next.effectiveControl.controlVersion < current.effectiveControl.controlVersion)
|
|
121
|
+
) {
|
|
122
|
+
return false;
|
|
104
123
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
124
|
+
snapshotRef.current = next;
|
|
125
|
+
setStateTargetKey(ownedTargetKey);
|
|
126
|
+
setSnapshot(next);
|
|
127
|
+
return true;
|
|
128
|
+
},
|
|
129
|
+
[],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const load = useCallback(
|
|
133
|
+
async (rejectOnFailure = false): Promise<void> => {
|
|
134
|
+
if (!sessionId) return;
|
|
135
|
+
const ownedTargetKey = `${workspaceId}\u0000${sessionId}`;
|
|
136
|
+
const ticket = ++readGeneration.current;
|
|
137
|
+
try {
|
|
138
|
+
const fetched = await client.getQueue(workspaceId, sessionId);
|
|
139
|
+
if (targetKeyRef.current !== ownedTargetKey) return;
|
|
140
|
+
const ownsLatestRead = ticket === readGeneration.current;
|
|
141
|
+
if (rejectOnFailure) {
|
|
142
|
+
const committed = acceptSnapshot(ownedTargetKey, fetched);
|
|
143
|
+
if (!committed && !queueSnapshotCovers(snapshotRef.current, fetched)) {
|
|
144
|
+
throw new TypeError("Queue reconciliation did not commit authoritative state");
|
|
145
|
+
}
|
|
146
|
+
setError(null);
|
|
147
|
+
if (ownsLatestRead) setLoading(false);
|
|
148
|
+
} else if (ownsLatestRead) {
|
|
149
|
+
acceptSnapshot(ownedTargetKey, fetched);
|
|
150
|
+
setError(null);
|
|
151
|
+
setLoading(false);
|
|
152
|
+
}
|
|
153
|
+
} catch (cause) {
|
|
154
|
+
if (
|
|
155
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
156
|
+
(ticket === readGeneration.current || rejectOnFailure)
|
|
157
|
+
) {
|
|
158
|
+
setError(asError(cause));
|
|
159
|
+
if (ticket === readGeneration.current) setLoading(false);
|
|
160
|
+
}
|
|
161
|
+
if (rejectOnFailure) throw cause;
|
|
109
162
|
}
|
|
110
|
-
}
|
|
111
|
-
|
|
163
|
+
},
|
|
164
|
+
[client, workspaceId, sessionId, acceptSnapshot],
|
|
165
|
+
);
|
|
112
166
|
|
|
113
167
|
useEffect(() => {
|
|
114
|
-
const targetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
|
|
115
|
-
if (targetKeyRef.current !== targetKey) {
|
|
116
|
-
targetKeyRef.current = targetKey;
|
|
117
|
-
readGeneration.current += 1;
|
|
118
|
-
acceptSnapshot(null);
|
|
119
|
-
setError(null);
|
|
120
|
-
setMutationError(null);
|
|
121
|
-
setPendingByTurn({});
|
|
122
|
-
pendingRef.current = {};
|
|
123
|
-
}
|
|
124
168
|
if (!enabled) {
|
|
125
169
|
setLoading(false);
|
|
126
170
|
return;
|
|
@@ -138,7 +182,7 @@ export function useTurnQueue(
|
|
|
138
182
|
clearInterval(timer);
|
|
139
183
|
readGeneration.current += 1;
|
|
140
184
|
};
|
|
141
|
-
}, [load, enabled,
|
|
185
|
+
}, [load, enabled, options.pollIntervalMs]);
|
|
142
186
|
|
|
143
187
|
useEffect(() => {
|
|
144
188
|
if (enabled && workspaceControlEvent) void load();
|
|
@@ -149,10 +193,18 @@ export function useTurnQueue(
|
|
|
149
193
|
}, [enabled, load, registerSessionReconciler, sessionId]);
|
|
150
194
|
|
|
151
195
|
const scheduleRefresh = useDebouncedCallback(() => void load());
|
|
152
|
-
useSessionEventTrigger(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
196
|
+
useSessionEventTrigger(
|
|
197
|
+
client,
|
|
198
|
+
workspaceId,
|
|
199
|
+
sessionId,
|
|
200
|
+
isTurnQueueEvent,
|
|
201
|
+
scheduleRefresh,
|
|
202
|
+
{
|
|
203
|
+
enabled,
|
|
204
|
+
...(options.events !== undefined ? { events: options.events } : {}),
|
|
205
|
+
},
|
|
206
|
+
async () => await load(true),
|
|
207
|
+
);
|
|
156
208
|
|
|
157
209
|
const mutate = useCallback(
|
|
158
210
|
async (
|
|
@@ -163,23 +215,30 @@ export function useTurnQueue(
|
|
|
163
215
|
turn: SessionTurn,
|
|
164
216
|
) => Promise<SessionQueueMutationResponse>,
|
|
165
217
|
): Promise<SessionQueueMutationResponse | null> => {
|
|
166
|
-
|
|
218
|
+
const ownedTargetKey = targetKey;
|
|
219
|
+
if (!sessionId || targetKeyRef.current !== ownedTargetKey || pendingRef.current[turnId]) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
167
222
|
const current = snapshotRef.current;
|
|
168
223
|
const turn = current?.items.find((candidate) => candidate.id === turnId);
|
|
169
224
|
if (!current || !turn) return null;
|
|
170
225
|
pendingRef.current = { ...pendingRef.current, [turnId]: kind };
|
|
226
|
+
setStateTargetKey(ownedTargetKey);
|
|
171
227
|
setPendingByTurn(pendingRef.current);
|
|
172
228
|
setMutationError(null);
|
|
173
229
|
try {
|
|
174
230
|
const result = await command(current, turn);
|
|
175
|
-
|
|
231
|
+
if (targetKeyRef.current !== ownedTargetKey) return null;
|
|
232
|
+
acceptSnapshot(ownedTargetKey, result.snapshot);
|
|
176
233
|
return result;
|
|
177
234
|
} catch (cause) {
|
|
178
|
-
|
|
179
|
-
|
|
235
|
+
if (targetKeyRef.current === ownedTargetKey) {
|
|
236
|
+
setMutationError(asError(cause));
|
|
237
|
+
await load();
|
|
238
|
+
}
|
|
180
239
|
return null;
|
|
181
240
|
} finally {
|
|
182
|
-
if (turnId in pendingRef.current) {
|
|
241
|
+
if (targetKeyRef.current === ownedTargetKey && turnId in pendingRef.current) {
|
|
183
242
|
const next = { ...pendingRef.current };
|
|
184
243
|
delete next[turnId];
|
|
185
244
|
pendingRef.current = next;
|
|
@@ -187,7 +246,7 @@ export function useTurnQueue(
|
|
|
187
246
|
}
|
|
188
247
|
}
|
|
189
248
|
},
|
|
190
|
-
[acceptSnapshot, load, sessionId],
|
|
249
|
+
[acceptSnapshot, load, sessionId, targetKey],
|
|
191
250
|
);
|
|
192
251
|
|
|
193
252
|
const moveTurn = useCallback(
|
|
@@ -250,28 +309,37 @@ export function useTurnQueue(
|
|
|
250
309
|
[client, mutate, sessionId, workspaceId],
|
|
251
310
|
);
|
|
252
311
|
|
|
312
|
+
const identityMatches = stateTargetKey === targetKey;
|
|
313
|
+
const visibleSnapshot = identityMatches ? snapshot : null;
|
|
314
|
+
const visiblePendingByTurn = identityMatches ? pendingByTurn : EMPTY_PENDING_BY_TURN;
|
|
315
|
+
const mutating = useMemo(
|
|
316
|
+
() => Object.keys(visiblePendingByTurn).length > 0,
|
|
317
|
+
[visiblePendingByTurn],
|
|
318
|
+
);
|
|
253
319
|
const mutationFor = useCallback(
|
|
254
|
-
(turnId: string): QueueMutationKind | null =>
|
|
255
|
-
[
|
|
320
|
+
(turnId: string): QueueMutationKind | null => visiblePendingByTurn[turnId] ?? null,
|
|
321
|
+
[visiblePendingByTurn],
|
|
256
322
|
);
|
|
257
|
-
const mutating = useMemo(() => Object.keys(pendingByTurn).length > 0, [pendingByTurn]);
|
|
258
323
|
|
|
259
324
|
return {
|
|
260
|
-
snapshot,
|
|
261
|
-
queue:
|
|
262
|
-
effectiveControl:
|
|
263
|
-
|
|
264
|
-
|
|
325
|
+
snapshot: visibleSnapshot,
|
|
326
|
+
queue: visibleSnapshot?.items ?? [],
|
|
327
|
+
effectiveControl: visibleSnapshot?.effectiveControl ?? null,
|
|
328
|
+
stoppingPreviousAttempt: visibleSnapshot?.stoppingPreviousAttempt ?? false,
|
|
329
|
+
loading: identityMatches ? loading : enabled,
|
|
330
|
+
error: identityMatches ? error : null,
|
|
265
331
|
refresh: load,
|
|
266
332
|
moveTurn,
|
|
267
333
|
editTurn,
|
|
268
334
|
steerTurn,
|
|
269
335
|
removeTurn,
|
|
270
|
-
pendingByTurn,
|
|
336
|
+
pendingByTurn: visiblePendingByTurn,
|
|
271
337
|
mutationFor,
|
|
272
338
|
mutating,
|
|
273
|
-
mutationError,
|
|
274
|
-
clearMutationError: useCallback(() =>
|
|
339
|
+
mutationError: identityMatches ? mutationError : null,
|
|
340
|
+
clearMutationError: useCallback(() => {
|
|
341
|
+
if (targetKeyRef.current === targetKey) setMutationError(null);
|
|
342
|
+
}, [targetKey]),
|
|
275
343
|
};
|
|
276
344
|
}
|
|
277
345
|
|
|
@@ -6,12 +6,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
6
6
|
The Changes tab stacks N file diffs in one scroll pane. Mounting N Shiki
|
|
7
7
|
highlighters (Pierre's `PatchDiff`) at once is the exact wart this replaces,
|
|
8
8
|
so we mount only the sections inside the visible window ± overscan and reserve
|
|
9
|
-
the rest as empty space (
|
|
9
|
+
the rest as empty space (D2).
|
|
10
10
|
|
|
11
11
|
Why NOT `virtua` here (unlike the file tree/rail): Pierre renders each diff in
|
|
12
12
|
a shadow DOM with its own internal overflow/virtualization, so an outer
|
|
13
13
|
measuring virtualizer can read a collapsed or fixed height and lay sections
|
|
14
|
-
out wrong (
|
|
14
|
+
out wrong (known layout risk). This hook windows by mounting/unmounting whole
|
|
15
15
|
file sections off a scroll offset and a per-section height ESTIMATE (refined
|
|
16
16
|
by measurement when a real browser provides it), which never depends on
|
|
17
17
|
Pierre reporting its own height — and the math is pure + deterministic, so
|
|
@@ -14,6 +14,79 @@ const REVISION_CAPTURED = "workspace.revision.captured";
|
|
|
14
14
|
const REVISION_DEGRADED = "workspace.revision.degraded";
|
|
15
15
|
const MANIFEST_FETCH_TIMEOUT_MS = 15_000;
|
|
16
16
|
|
|
17
|
+
type AvailableCaptureResponse = Extract<GetWorkspaceCaptureResponse, { available: true }>;
|
|
18
|
+
|
|
19
|
+
const CAPTURE_STATS_NUMBER_FIELDS = [
|
|
20
|
+
"repoCount",
|
|
21
|
+
"fileCount",
|
|
22
|
+
"additions",
|
|
23
|
+
"deletions",
|
|
24
|
+
"totalBytes",
|
|
25
|
+
"tooLargeCount",
|
|
26
|
+
"binaryCount",
|
|
27
|
+
"treeEntryCount",
|
|
28
|
+
"durationMs",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Signed manifests bypass the authenticated JSON client's wire decoder. Treat
|
|
37
|
+
* the blob as untrusted until its outer schema and exact revision identity match
|
|
38
|
+
* the metadata response that minted the URL. The API performs the full contract
|
|
39
|
+
* parse; this client-side boundary prevents a corrupt/mis-keyed response from
|
|
40
|
+
* being presented as a different authoritative capture.
|
|
41
|
+
*/
|
|
42
|
+
function captureManifestForResponse(
|
|
43
|
+
value: unknown,
|
|
44
|
+
response: AvailableCaptureResponse,
|
|
45
|
+
): WorkspaceCaptureManifest {
|
|
46
|
+
if (
|
|
47
|
+
!isRecord(value) ||
|
|
48
|
+
value.version !== 1 ||
|
|
49
|
+
!Number.isSafeInteger(value.revision) ||
|
|
50
|
+
typeof value.capturedAt !== "string" ||
|
|
51
|
+
!(value.turnId === null || typeof value.turnId === "string") ||
|
|
52
|
+
!Number.isSafeInteger(value.leaseEpoch) ||
|
|
53
|
+
!isRecord(value.treeIndex) ||
|
|
54
|
+
typeof value.treeTruncated !== "boolean" ||
|
|
55
|
+
!Array.isArray(value.repos) ||
|
|
56
|
+
!Array.isArray(value.files) ||
|
|
57
|
+
!isRecord(value.stats)
|
|
58
|
+
) {
|
|
59
|
+
throw new Error("Workspace capture manifest failed validation.");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (
|
|
63
|
+
value.revision !== response.revision ||
|
|
64
|
+
value.capturedAt !== response.capturedAt ||
|
|
65
|
+
value.turnId !== response.turnId ||
|
|
66
|
+
value.leaseEpoch !== response.leaseEpoch
|
|
67
|
+
) {
|
|
68
|
+
throw new Error("Workspace capture manifest identity did not match its response.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const field of CAPTURE_STATS_NUMBER_FIELDS) {
|
|
72
|
+
if (!Number.isSafeInteger(value.stats[field]) || value.stats[field] !== response.stats[field]) {
|
|
73
|
+
throw new Error("Workspace capture manifest statistics did not match its response.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (
|
|
77
|
+
typeof value.stats.treeTruncated !== "boolean" ||
|
|
78
|
+
value.stats.treeTruncated !== response.stats.treeTruncated ||
|
|
79
|
+
(value.stats.fingerprint ?? null) !== (response.stats.fingerprint ?? null) ||
|
|
80
|
+
value.repos.length !== response.stats.repoCount ||
|
|
81
|
+
value.files.length !== response.stats.fileCount ||
|
|
82
|
+
value.treeTruncated !== response.stats.treeTruncated
|
|
83
|
+
) {
|
|
84
|
+
throw new Error("Workspace capture manifest statistics did not match its response.");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return value as WorkspaceCaptureManifest;
|
|
88
|
+
}
|
|
89
|
+
|
|
17
90
|
export type UseWorkspaceCaptureOptions = ClientOverride & {
|
|
18
91
|
/** Live event log (usually `useSessionEvents().events`) — a
|
|
19
92
|
* `workspace.revision.captured` for a NEWER revision refreshes the manifest. */
|
|
@@ -54,7 +127,7 @@ export type UseWorkspaceCaptureResult = {
|
|
|
54
127
|
/**
|
|
55
128
|
* The cold-paint data source: fetch the latest turn-end workspace capture with a
|
|
56
129
|
* SINGLE api round-trip on mount (no machine, no Channel-A — this is the <200ms
|
|
57
|
-
* first paint
|
|
130
|
+
* first paint). The manifest is served inline in the common
|
|
58
131
|
* case; a rare >2MB manifest comes back as a short-TTL signed URL we follow.
|
|
59
132
|
*
|
|
60
133
|
* Subscribes to the live event log: a `workspace.revision.captured` for a revision
|
|
@@ -89,64 +162,93 @@ export function useWorkspaceCapture(
|
|
|
89
162
|
// A generation counter fences a slow in-flight fetch against a newer one (or an
|
|
90
163
|
// identity change) so a stale response can never overwrite fresher state.
|
|
91
164
|
const generationRef = useRef(0);
|
|
165
|
+
const requestAbortRef = useRef<AbortController | null>(null);
|
|
92
166
|
// Event sequence numbers are scoped to a session. Keeping this cursor across an
|
|
93
167
|
// identity switch would discard the new session's low-numbered announcements.
|
|
94
168
|
const lastSeqRef = useRef(0);
|
|
95
169
|
|
|
96
170
|
const refresh = useCallback(async () => {
|
|
97
171
|
if (!sessionId) return;
|
|
172
|
+
requestAbortRef.current?.abort();
|
|
173
|
+
const requestAbort = new AbortController();
|
|
174
|
+
requestAbortRef.current = requestAbort;
|
|
98
175
|
const generation = (generationRef.current += 1);
|
|
99
176
|
setStateIdentity(identityKey);
|
|
100
177
|
setLoading(true);
|
|
101
178
|
setError(null);
|
|
102
179
|
try {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
setFileCount(0);
|
|
112
|
-
setDegradedReason(res.degradedReason ?? null);
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
setDegradedReason(null);
|
|
116
|
-
// Resolve the changed-file count immediately from the response's stats — the
|
|
117
|
-
// default-tab signal must not wait on a >2MB manifest-URL hop.
|
|
118
|
-
setFileCount(res.stats.fileCount);
|
|
119
|
-
// Exactly one of manifest / manifestUrl is non-null (M2 contract). The inline
|
|
120
|
-
// manifest is the <200ms common case; a >2MB manifest is a signed URL hop.
|
|
121
|
-
let manifest = res.manifest;
|
|
122
|
-
if (!manifest && res.manifestUrl) {
|
|
123
|
-
const response = await fetch(res.manifestUrl.url, {
|
|
124
|
-
signal: AbortSignal.timeout(MANIFEST_FETCH_TIMEOUT_MS),
|
|
125
|
-
});
|
|
180
|
+
// A signed URL can expire between mint and GET. Re-enter the authenticated
|
|
181
|
+
// endpoint once to mint a fresh URL; never replay or log the old credential.
|
|
182
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
183
|
+
const res: GetWorkspaceCaptureResponse = await client.getWorkspaceCapture(
|
|
184
|
+
workspaceId,
|
|
185
|
+
sessionId,
|
|
186
|
+
{ signal: requestAbort.signal },
|
|
187
|
+
);
|
|
126
188
|
if (generationRef.current !== generation) return;
|
|
127
|
-
if (!
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
189
|
+
if (!res.available) {
|
|
190
|
+
setCapture(null);
|
|
191
|
+
setAvailable(false);
|
|
192
|
+
setFileCount(0);
|
|
193
|
+
setDegradedReason(res.degradedReason ?? null);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
setDegradedReason(null);
|
|
197
|
+
// Resolve the changed-file count immediately from the response's stats — the
|
|
198
|
+
// default-tab signal must not wait on a >2MB manifest-URL hop.
|
|
199
|
+
setFileCount(res.stats.fileCount);
|
|
200
|
+
// Exactly one of manifest / manifestUrl is non-null (M2 contract). The inline
|
|
201
|
+
// manifest is the <200ms common case; a >2MB manifest is a signed URL hop.
|
|
202
|
+
let manifestValue: unknown = res.manifest;
|
|
203
|
+
if (!manifestValue && res.manifestUrl) {
|
|
204
|
+
const response = await fetch(res.manifestUrl.url, {
|
|
205
|
+
signal: AbortSignal.any([
|
|
206
|
+
requestAbort.signal,
|
|
207
|
+
AbortSignal.timeout(MANIFEST_FETCH_TIMEOUT_MS),
|
|
208
|
+
]),
|
|
209
|
+
credentials: "omit",
|
|
210
|
+
cache: "no-store",
|
|
211
|
+
referrerPolicy: "no-referrer",
|
|
212
|
+
}).catch(() => null);
|
|
213
|
+
if (generationRef.current !== generation) return;
|
|
214
|
+
if (!response?.ok) {
|
|
215
|
+
if (
|
|
216
|
+
attempt === 0 &&
|
|
217
|
+
(response === null || response.status === 401 || response.status === 403)
|
|
218
|
+
) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
throw new Error(
|
|
222
|
+
`Workspace capture manifest download failed${response ? ` (${response.status})` : ""}.`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
manifestValue = await response.json();
|
|
227
|
+
} catch {
|
|
228
|
+
throw new Error("Workspace capture manifest was not valid JSON.");
|
|
229
|
+
}
|
|
230
|
+
if (generationRef.current !== generation) return;
|
|
231
|
+
}
|
|
232
|
+
if (!manifestValue) {
|
|
233
|
+
throw new Error("Workspace capture response did not include a manifest.");
|
|
234
|
+
}
|
|
235
|
+
const manifest = captureManifestForResponse(manifestValue, res);
|
|
236
|
+
setCapture(manifest);
|
|
237
|
+
setAvailable(true);
|
|
238
|
+
// Fold the served revision into the announced high-water mark so a capture we
|
|
239
|
+
// JUST loaded is never reported stale against an older announce.
|
|
240
|
+
setAnnouncedRevision((prev) =>
|
|
241
|
+
prev === null || manifest.revision > prev ? manifest.revision : prev,
|
|
242
|
+
);
|
|
136
243
|
return;
|
|
137
244
|
}
|
|
138
|
-
|
|
139
|
-
setAvailable(true);
|
|
140
|
-
// Fold the served revision into the announced high-water mark so a capture we
|
|
141
|
-
// JUST loaded is never reported stale against an older announce.
|
|
142
|
-
setAnnouncedRevision((prev) =>
|
|
143
|
-
prev === null || manifest.revision > prev ? manifest.revision : prev,
|
|
144
|
-
);
|
|
245
|
+
throw new Error("Workspace capture manifest download failed after refreshing its URL.");
|
|
145
246
|
} catch (cause) {
|
|
146
247
|
if (generationRef.current !== generation) return;
|
|
147
248
|
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
148
249
|
} finally {
|
|
149
250
|
if (generationRef.current === generation) setLoading(false);
|
|
251
|
+
if (requestAbortRef.current === requestAbort) requestAbortRef.current = null;
|
|
150
252
|
}
|
|
151
253
|
}, [client, workspaceId, sessionId, identityKey]);
|
|
152
254
|
|
|
@@ -154,6 +256,8 @@ export function useWorkspaceCapture(
|
|
|
154
256
|
useEffect(() => {
|
|
155
257
|
// Invalidate a request started for the previous identity before clearing its
|
|
156
258
|
// state. The cleanup also fences a response that arrives after unmount.
|
|
259
|
+
requestAbortRef.current?.abort();
|
|
260
|
+
requestAbortRef.current = null;
|
|
157
261
|
generationRef.current += 1;
|
|
158
262
|
lastSeqRef.current = 0;
|
|
159
263
|
setCapture(null);
|
|
@@ -168,6 +272,8 @@ export function useWorkspaceCapture(
|
|
|
168
272
|
}
|
|
169
273
|
void refresh();
|
|
170
274
|
return () => {
|
|
275
|
+
requestAbortRef.current?.abort();
|
|
276
|
+
requestAbortRef.current = null;
|
|
171
277
|
generationRef.current += 1;
|
|
172
278
|
};
|
|
173
279
|
}, [enabled, refresh]);
|