@opengeni/react 0.15.0 → 0.23.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 (103) hide show
  1. package/README.md +110 -10
  2. package/dist/{chunk-TOJR776I.js → chunk-6XUS5VFM.js} +45 -369
  3. package/dist/chunk-6XUS5VFM.js.map +1 -0
  4. package/dist/chunk-CATATY77.js +2393 -0
  5. package/dist/chunk-CATATY77.js.map +1 -0
  6. package/dist/chunk-I3BJZIG5.js +439 -0
  7. package/dist/chunk-I3BJZIG5.js.map +1 -0
  8. package/dist/chunk-TK7G6XLT.js +18 -0
  9. package/dist/chunk-TK7G6XLT.js.map +1 -0
  10. package/dist/chunk-UAHPKLB6.js +2237 -0
  11. package/dist/chunk-UAHPKLB6.js.map +1 -0
  12. package/dist/chunk-UQS7OQDE.js +591 -0
  13. package/dist/chunk-UQS7OQDE.js.map +1 -0
  14. package/dist/chunk-YDNWMKXO.js +102 -0
  15. package/dist/chunk-YDNWMKXO.js.map +1 -0
  16. package/dist/composer-CVm5uoUI.d.ts +585 -0
  17. package/dist/composer.d.ts +6 -0
  18. package/dist/composer.js +56 -0
  19. package/dist/composer.js.map +1 -0
  20. package/dist/index.d.ts +1288 -1785
  21. package/dist/index.js +7140 -8817
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +3 -1
  25. package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
  26. package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
  27. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
  28. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
  29. package/dist/queue-surface-implementation-3UW3MIIR.js +890 -0
  30. package/dist/queue-surface-implementation-3UW3MIIR.js.map +1 -0
  31. package/dist/session-Db00TRwl.d.ts +446 -0
  32. package/dist/session-context-rgrfElMl.d.ts +58 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +54 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-By1wn3MM.d.ts +99 -0
  37. package/package.json +20 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +44 -0
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +94 -993
  42. package/src/components/code-editor.tsx +104 -26
  43. package/src/components/command-palette.tsx +8 -2
  44. package/src/components/composer-transcription-control.tsx +211 -0
  45. package/src/components/composer.tsx +1536 -0
  46. package/src/components/desktop-viewer.tsx +3 -3
  47. package/src/components/enrollment-consent.tsx +2 -2
  48. package/src/components/file-browser.tsx +367 -29
  49. package/src/components/human-input-form.tsx +417 -0
  50. package/src/components/machine-card.tsx +1 -1
  51. package/src/components/machines-dashboard.tsx +3 -3
  52. package/src/components/message-timeline.tsx +205 -25
  53. package/src/components/model-picker.tsx +12 -3
  54. package/src/components/pierre-diff.tsx +32 -6
  55. package/src/components/queue-draft-policy.ts +18 -0
  56. package/src/components/queue-surface-implementation.tsx +1037 -0
  57. package/src/components/queue-surface-state.tsx +94 -0
  58. package/src/components/queue-surface.tsx +42 -559
  59. package/src/components/sandbox-files.tsx +138 -14
  60. package/src/components/sandbox-terminal.tsx +8 -2
  61. package/src/components/sandbox-workspace.tsx +339 -119
  62. package/src/components/workbench-changes.tsx +84 -33
  63. package/src/components/workspace-dock.tsx +188 -18
  64. package/src/composer.ts +60 -0
  65. package/src/hooks/internal.ts +115 -34
  66. package/src/hooks/use-composer.ts +385 -104
  67. package/src/hooks/use-human-input.ts +131 -0
  68. package/src/hooks/use-machine-chip.ts +2 -2
  69. package/src/hooks/use-machines.ts +27 -11
  70. package/src/hooks/use-sandbox-files.ts +310 -62
  71. package/src/hooks/use-sandbox-git.ts +154 -40
  72. package/src/hooks/use-sandbox-terminal.ts +28 -6
  73. package/src/hooks/use-session-capabilities.ts +38 -9
  74. package/src/hooks/use-session-control.ts +37 -8
  75. package/src/hooks/use-session-events.ts +522 -48
  76. package/src/hooks/use-session-mcp-approval-policy.ts +161 -0
  77. package/src/hooks/use-slash-commands.ts +38 -38
  78. package/src/hooks/use-transcription.ts +757 -0
  79. package/src/hooks/use-turn-queue.ts +134 -66
  80. package/src/hooks/use-windowed-sections.ts +2 -2
  81. package/src/hooks/use-workspace-capture.ts +146 -40
  82. package/src/hooks/use-workspace-edit.ts +50 -14
  83. package/src/hooks/use-workspace-sessions.ts +3 -0
  84. package/src/human-input.ts +72 -0
  85. package/src/index.ts +85 -6
  86. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  87. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  88. package/src/lib/use-portal-token-style.ts +51 -0
  89. package/src/lib/use-unicode-fonts.ts +57 -0
  90. package/src/provider.tsx +4 -69
  91. package/src/session-context.ts +160 -0
  92. package/src/session.ts +89 -0
  93. package/src/timeline/parsers.ts +35 -0
  94. package/src/timeline/projection.ts +12 -4
  95. package/src/timeline/tool-renderers.tsx +41 -0
  96. package/src/timeline/turn-summary.tsx +2 -2
  97. package/src/timeline/types.ts +10 -9
  98. package/styles/index.css +56 -0
  99. package/styles/index.d.ts +2 -0
  100. package/styles/tokens.css +11 -6
  101. package/styles/tokens.d.ts +2 -0
  102. package/dist/chunk-TOJR776I.js.map +0 -1
  103. 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 { useOpenGeni, type ClientOverride } from "../provider";
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 = ClientOverride &
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
- useOpenGeni(options);
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<string | null>(null);
93
+ const targetKeyRef = useRef(targetKey);
78
94
  const snapshotRef = useRef<SessionQueueSnapshot | null>(null);
79
95
 
80
- const acceptSnapshot = useCallback((next: SessionQueueSnapshot | null): boolean => {
81
- const current = snapshotRef.current;
82
- if (
83
- next &&
84
- current &&
85
- (next.version < current.version ||
86
- next.effectiveControl.controlVersion < current.effectiveControl.controlVersion)
87
- ) {
88
- return false;
89
- }
90
- snapshotRef.current = next;
91
- setSnapshot(next);
92
- return true;
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 load = useCallback(async (): Promise<void> => {
96
- if (!sessionId) return;
97
- const ticket = ++readGeneration.current;
98
- try {
99
- const fetched = await client.getQueue(workspaceId, sessionId);
100
- if (ticket === readGeneration.current) {
101
- acceptSnapshot(fetched);
102
- setError(null);
103
- setLoading(false);
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
- } catch (cause) {
106
- if (ticket === readGeneration.current) {
107
- setError(asError(cause));
108
- setLoading(false);
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
- }, [client, workspaceId, sessionId, acceptSnapshot]);
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, workspaceId, sessionId, options.pollIntervalMs, acceptSnapshot]);
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(client, workspaceId, sessionId, isTurnQueueEvent, scheduleRefresh, {
153
- enabled,
154
- ...(options.events !== undefined ? { events: options.events } : {}),
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
- if (!sessionId || pendingRef.current[turnId]) return null;
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
- acceptSnapshot(result.snapshot);
231
+ if (targetKeyRef.current !== ownedTargetKey) return null;
232
+ acceptSnapshot(ownedTargetKey, result.snapshot);
176
233
  return result;
177
234
  } catch (cause) {
178
- setMutationError(asError(cause));
179
- await load();
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 => pendingByTurn[turnId] ?? null,
255
- [pendingByTurn],
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: snapshot?.items ?? [],
262
- effectiveControl: snapshot?.effectiveControl ?? null,
263
- loading,
264
- error,
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(() => setMutationError(null), []),
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 (dossier §10.7, D2).
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 (dossier risk #6). This hook windows by mounting/unmounting whole
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, dossier §10.4/§12-A1). The manifest is served inline in the common
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
- const res: GetWorkspaceCaptureResponse = await client.getWorkspaceCapture(
104
- workspaceId,
105
- sessionId,
106
- );
107
- if (generationRef.current !== generation) return;
108
- if (!res.available) {
109
- setCapture(null);
110
- setAvailable(false);
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 (!response.ok)
128
- throw new Error(`workspace capture manifest fetch failed: ${response.status}`);
129
- manifest = (await response.json()) as WorkspaceCaptureManifest;
130
- if (generationRef.current !== generation) return;
131
- }
132
- if (!manifest) {
133
- // available:true but neither manifest nor a working URL — degrade, never crash.
134
- setCapture(null);
135
- setAvailable(false);
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
- setCapture(manifest);
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]);