@opengeni/react 0.13.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +129 -23
  2. package/dist/chunk-6BXBGJ3B.js +1956 -0
  3. package/dist/chunk-6BXBGJ3B.js.map +1 -0
  4. package/dist/chunk-6UT5OC3P.js +2133 -0
  5. package/dist/chunk-6UT5OC3P.js.map +1 -0
  6. package/dist/chunk-EBTT3MYN.js +92 -0
  7. package/dist/chunk-EBTT3MYN.js.map +1 -0
  8. package/dist/chunk-HHFA4AFX.js +425 -0
  9. package/dist/chunk-HHFA4AFX.js.map +1 -0
  10. package/dist/chunk-LAZ2A743.js +2393 -0
  11. package/dist/chunk-LAZ2A743.js.map +1 -0
  12. package/dist/chunk-MRSECXRP.js +563 -0
  13. package/dist/chunk-MRSECXRP.js.map +1 -0
  14. package/dist/chunk-TK7G6XLT.js +18 -0
  15. package/dist/chunk-TK7G6XLT.js.map +1 -0
  16. package/dist/composer-DvUaa5ki.d.ts +585 -0
  17. package/dist/composer.d.ts +6 -0
  18. package/dist/composer.js +56 -0
  19. package/dist/composer.js.map +1 -0
  20. package/dist/index.d.ts +642 -1080
  21. package/dist/index.js +7672 -8005
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +25 -1
  25. package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
  26. package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
  27. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
  28. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
  29. package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
  30. package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
  31. package/dist/session-Gd4iVPYw.d.ts +425 -0
  32. package/dist/session-context-DIFFlXKc.d.ts +52 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +50 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-BCfm4mzX.d.ts +112 -0
  37. package/package.json +23 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +50 -1
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +104 -751
  42. package/src/components/code-editor.tsx +104 -26
  43. package/src/components/command-palette.tsx +8 -2
  44. package/src/components/composer-transcription-control.tsx +211 -0
  45. package/src/components/composer.tsx +1536 -0
  46. package/src/components/desktop-viewer.tsx +3 -3
  47. package/src/components/enrollment-consent.tsx +2 -2
  48. package/src/components/file-browser.tsx +367 -29
  49. package/src/components/human-input-form.tsx +417 -0
  50. package/src/components/machine-card.tsx +82 -16
  51. package/src/components/machine-health-pill.tsx +68 -0
  52. package/src/components/machine-metrics.tsx +10 -24
  53. package/src/components/machines/health.ts +146 -0
  54. package/src/components/machines/machine-detail.tsx +220 -0
  55. package/src/components/machines/metric-history-chart.tsx +298 -0
  56. package/src/components/machines/metric-sparkline.tsx +76 -0
  57. package/src/components/machines/series.ts +113 -0
  58. package/src/components/machines-dashboard.tsx +16 -4
  59. package/src/components/message-timeline.tsx +15 -7
  60. package/src/components/model-picker.tsx +12 -3
  61. package/src/components/pierre-diff.tsx +32 -6
  62. package/src/components/queue-surface-implementation.tsx +1041 -0
  63. package/src/components/queue-surface-state.tsx +94 -0
  64. package/src/components/queue-surface.tsx +60 -0
  65. package/src/components/sandbox-files.tsx +230 -21
  66. package/src/components/sandbox-terminal.tsx +8 -2
  67. package/src/components/sandbox-workspace.tsx +489 -135
  68. package/src/components/session-status.tsx +0 -6
  69. package/src/components/workbench-changes.tsx +145 -50
  70. package/src/components/workspace-dock.tsx +329 -68
  71. package/src/composer.ts +60 -0
  72. package/src/hooks/internal.ts +115 -34
  73. package/src/hooks/use-composer.ts +635 -74
  74. package/src/hooks/use-human-input.ts +131 -0
  75. package/src/hooks/use-machine-chip.ts +2 -2
  76. package/src/hooks/use-machines.ts +27 -11
  77. package/src/hooks/use-sandbox-files.ts +310 -62
  78. package/src/hooks/use-sandbox-git.ts +154 -40
  79. package/src/hooks/use-sandbox-terminal.ts +28 -6
  80. package/src/hooks/use-session-capabilities.ts +38 -9
  81. package/src/hooks/use-session-control.ts +43 -15
  82. package/src/hooks/use-session-events.ts +523 -48
  83. package/src/hooks/use-session-lineage.ts +15 -6
  84. package/src/hooks/use-session.ts +10 -2
  85. package/src/hooks/use-slash-commands.ts +38 -38
  86. package/src/hooks/use-transcription.ts +757 -0
  87. package/src/hooks/use-turn-queue.ts +273 -77
  88. package/src/hooks/use-windowed-sections.ts +2 -2
  89. package/src/hooks/use-workspace-capture.ts +146 -40
  90. package/src/hooks/use-workspace-edit.ts +50 -14
  91. package/src/hooks/use-workspace-sessions.ts +3 -0
  92. package/src/human-input.ts +72 -0
  93. package/src/index.ts +88 -12
  94. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  95. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  96. package/src/lib/use-portal-token-style.ts +51 -0
  97. package/src/lib/use-unicode-fonts.ts +57 -0
  98. package/src/machines.ts +16 -0
  99. package/src/provider.tsx +163 -41
  100. package/src/session-context.ts +135 -0
  101. package/src/session.ts +79 -0
  102. package/src/timeline/parsers.ts +78 -6
  103. package/src/timeline/projection.ts +36 -6
  104. package/src/timeline/tool-renderers.tsx +41 -0
  105. package/src/timeline/turn-summary.tsx +2 -2
  106. package/src/timeline/types.ts +16 -8
  107. package/styles/index.css +78 -0
  108. package/styles/index.d.ts +2 -0
  109. package/styles/tokens.css +11 -6
  110. package/styles/tokens.d.ts +2 -0
  111. package/dist/chunk-NFYVQWIB.js +0 -1377
  112. package/dist/chunk-NFYVQWIB.js.map +0 -1
  113. package/dist/machines-CnlMb7E-.d.ts +0 -329
@@ -1,14 +1,20 @@
1
- import type { SessionEvent, SessionQueueSnapshot, SessionTurn } from "@opengeni/sdk";
2
- import { useCallback, useEffect, useRef, useState } from "react";
3
- import { useOpenGeni, type ClientOverride } from "../provider";
1
+ import type {
2
+ ComposerDraft,
3
+ EffectiveSessionControl,
4
+ SessionEvent,
5
+ SessionQueueMutationResponse,
6
+ SessionQueueSnapshot,
7
+ SessionTurn,
8
+ } from "@opengeni/sdk";
9
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
10
+ import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context";
4
11
  import {
5
12
  useDebouncedCallback,
6
- useMutationRunner,
7
13
  useSessionEventTrigger,
8
14
  type SessionEventFeedOptions,
9
15
  } from "./internal";
10
16
 
11
- /** Events that can change the authoritative prompt queue or its pause gates. */
17
+ /** Events that can change the authoritative prompt queue or effective control. */
12
18
  export function isTurnQueueEvent(event: Pick<SessionEvent, "type">): boolean {
13
19
  return (
14
20
  event.type.startsWith("turn.") ||
@@ -18,7 +24,21 @@ export function isTurnQueueEvent(event: Pick<SessionEvent, "type">): boolean {
18
24
  );
19
25
  }
20
26
 
21
- export type UseTurnQueueOptions = ClientOverride &
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
+
38
+ export type QueueMutationKind = "move" | "edit" | "steer" | "delete";
39
+ const EMPTY_PENDING_BY_TURN: Readonly<Record<string, QueueMutationKind>> = {};
40
+
41
+ export type UseTurnQueueOptions = EmbeddedSessionClientOverride &
22
42
  SessionEventFeedOptions & {
23
43
  pollIntervalMs?: number | undefined;
24
44
  };
@@ -27,69 +47,124 @@ export type UseTurnQueueResult = {
27
47
  snapshot: SessionQueueSnapshot | null;
28
48
  /** Human/API prompts exactly in server execution order. Never client-sorted. */
29
49
  queue: SessionTurn[];
30
- controlState: SessionQueueSnapshot["controlState"] | null;
31
- controlGeneration: number | null;
32
- workspaceInferenceState: SessionQueueSnapshot["workspaceInferenceState"] | null;
33
- workspaceInferenceGeneration: number | null;
34
- workspaceRunExceptionGeneration: number | null;
50
+ effectiveControl: EffectiveSessionControl | null;
51
+ /** The latest interrupted attempt has not yet durably proved physical quiescence. */
52
+ stoppingPreviousAttempt: boolean;
35
53
  loading: boolean;
36
54
  error: Error | null;
37
55
  refresh: () => Promise<void>;
38
- /** Delete a waiting prompt before the worker claims it. */
56
+ moveTurn: (turnId: string, beforeTurnId: string | null) => Promise<boolean>;
57
+ /** Atomically withdraw a waiting prompt into the private durable composer draft. */
58
+ editTurn: (
59
+ turnId: string,
60
+ options: { expectedDraftRevision: number; replaceDraft: boolean },
61
+ ) => Promise<ComposerDraft | null>;
62
+ /** Advance the same durable waiting prompt; no duplicate prompt is created. */
63
+ steerTurn: (turnId: string) => Promise<boolean>;
39
64
  removeTurn: (turnId: string) => Promise<boolean>;
65
+ pendingByTurn: Readonly<Record<string, QueueMutationKind>>;
66
+ mutationFor: (turnId: string) => QueueMutationKind | null;
40
67
  mutating: boolean;
41
68
  mutationError: Error | null;
42
69
  clearMutationError: () => void;
43
70
  };
44
71
 
45
72
  /**
46
- * The single authoritative waiting-prompt queue. The server owns ordering; the
47
- * client renders the returned array verbatim and supports deletion only.
73
+ * The one authoritative human prompt queue. Every mutation carries the exact
74
+ * server versions the operator saw and accepts only monotonic snapshots. A
75
+ * conflict immediately reloads server truth; the client never invents order.
48
76
  */
49
77
  export function useTurnQueue(
50
78
  sessionId: string | null | undefined,
51
79
  options: UseTurnQueueOptions = {},
52
80
  ): UseTurnQueueResult {
53
- const { client, workspaceId } = useOpenGeni(options);
81
+ const { client, workspaceId, workspaceControlEvent, registerSessionReconciler } =
82
+ useEmbeddedSession(options);
54
83
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
84
+ const targetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
55
85
  const [snapshot, setSnapshot] = useState<SessionQueueSnapshot | null>(null);
86
+ const [stateTargetKey, setStateTargetKey] = useState(targetKey);
56
87
  const [loading, setLoading] = useState(enabled);
57
88
  const [error, setError] = useState<Error | null>(null);
58
- const { run, mutating, mutationError, clearMutationError } = useMutationRunner();
59
- const generation = useRef(0);
60
- const targetKeyRef = useRef<string | null>(null);
89
+ const [mutationError, setMutationError] = useState<Error | null>(null);
90
+ const [pendingByTurn, setPendingByTurn] = useState<Record<string, QueueMutationKind>>({});
91
+ const pendingRef = useRef<Record<string, QueueMutationKind>>({});
92
+ const readGeneration = useRef(0);
93
+ const targetKeyRef = useRef(targetKey);
61
94
  const snapshotRef = useRef<SessionQueueSnapshot | null>(null);
62
95
 
63
- const replaceSnapshot = useCallback((next: SessionQueueSnapshot | null): void => {
64
- snapshotRef.current = next;
65
- setSnapshot(next);
66
- }, []);
67
-
68
- const load = useCallback(async (): Promise<void> => {
69
- if (!sessionId) return;
70
- const ticket = ++generation.current;
71
- try {
72
- const fetched = await client.getQueue(workspaceId, sessionId);
73
- if (ticket === generation.current) {
74
- replaceSnapshot(fetched);
75
- setError(null);
76
- setLoading(false);
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]);
111
+
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;
77
123
  }
78
- } catch (cause) {
79
- if (ticket === generation.current) {
80
- setError(cause instanceof Error ? cause : new Error(String(cause)));
81
- 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;
82
162
  }
83
- }
84
- }, [client, workspaceId, sessionId, replaceSnapshot]);
163
+ },
164
+ [client, workspaceId, sessionId, acceptSnapshot],
165
+ );
85
166
 
86
167
  useEffect(() => {
87
- const targetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
88
- if (targetKeyRef.current !== targetKey) {
89
- targetKeyRef.current = targetKey;
90
- replaceSnapshot(null);
91
- setError(null);
92
- }
93
168
  if (!enabled) {
94
169
  setLoading(false);
95
170
  return;
@@ -99,58 +174,179 @@ export function useTurnQueue(
99
174
  const pollIntervalMs = options.pollIntervalMs;
100
175
  if (pollIntervalMs === undefined || pollIntervalMs <= 0) {
101
176
  return () => {
102
- generation.current += 1;
177
+ readGeneration.current += 1;
103
178
  };
104
179
  }
105
180
  const timer = setInterval(() => void load(), pollIntervalMs);
106
181
  return () => {
107
182
  clearInterval(timer);
108
- generation.current += 1;
183
+ readGeneration.current += 1;
109
184
  };
110
- }, [load, enabled, workspaceId, sessionId, options.pollIntervalMs, replaceSnapshot]);
185
+ }, [load, enabled, options.pollIntervalMs]);
186
+
187
+ useEffect(() => {
188
+ if (enabled && workspaceControlEvent) void load();
189
+ }, [enabled, load, workspaceControlEvent]);
190
+ useEffect(() => {
191
+ if (!sessionId || !enabled) return;
192
+ return registerSessionReconciler(sessionId, "queue", load);
193
+ }, [enabled, load, registerSessionReconciler, sessionId]);
111
194
 
112
195
  const scheduleRefresh = useDebouncedCallback(() => void load());
113
- useSessionEventTrigger(client, workspaceId, sessionId, isTurnQueueEvent, scheduleRefresh, {
114
- enabled,
115
- ...(options.events !== undefined ? { events: options.events } : {}),
116
- });
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
+ );
117
208
 
118
- const removeTurn = useCallback(
119
- async (turnId: string): Promise<boolean> => {
120
- if (!sessionId) return false;
209
+ const mutate = useCallback(
210
+ async (
211
+ turnId: string,
212
+ kind: QueueMutationKind,
213
+ command: (
214
+ current: SessionQueueSnapshot,
215
+ turn: SessionTurn,
216
+ ) => Promise<SessionQueueMutationResponse>,
217
+ ): Promise<SessionQueueMutationResponse | null> => {
218
+ const ownedTargetKey = targetKey;
219
+ if (!sessionId || targetKeyRef.current !== ownedTargetKey || pendingRef.current[turnId]) {
220
+ return null;
221
+ }
121
222
  const current = snapshotRef.current;
122
- const item = current?.items.find((turn) => turn.id === turnId);
123
- if (!current || !item) return false;
124
- const result = await run(() =>
125
- client.cancelQueueItem(workspaceId, sessionId, turnId, {
223
+ const turn = current?.items.find((candidate) => candidate.id === turnId);
224
+ if (!current || !turn) return null;
225
+ pendingRef.current = { ...pendingRef.current, [turnId]: kind };
226
+ setStateTargetKey(ownedTargetKey);
227
+ setPendingByTurn(pendingRef.current);
228
+ setMutationError(null);
229
+ try {
230
+ const result = await command(current, turn);
231
+ if (targetKeyRef.current !== ownedTargetKey) return null;
232
+ acceptSnapshot(ownedTargetKey, result.snapshot);
233
+ return result;
234
+ } catch (cause) {
235
+ if (targetKeyRef.current === ownedTargetKey) {
236
+ setMutationError(asError(cause));
237
+ await load();
238
+ }
239
+ return null;
240
+ } finally {
241
+ if (targetKeyRef.current === ownedTargetKey && turnId in pendingRef.current) {
242
+ const next = { ...pendingRef.current };
243
+ delete next[turnId];
244
+ pendingRef.current = next;
245
+ setPendingByTurn(next);
246
+ }
247
+ }
248
+ },
249
+ [acceptSnapshot, load, sessionId, targetKey],
250
+ );
251
+
252
+ const moveTurn = useCallback(
253
+ async (turnId: string, beforeTurnId: string | null): Promise<boolean> => {
254
+ const result = await mutate(turnId, "move", (current) =>
255
+ client.moveQueueItem(workspaceId, sessionId!, turnId, {
256
+ clientEventId: operationKey(),
126
257
  expectedQueueVersion: current.version,
127
- expectedItemVersion: item.version,
258
+ beforeTurnId,
128
259
  }),
129
260
  );
130
- if (!result) {
131
- void load();
132
- return false;
133
- }
134
- replaceSnapshot(result.snapshot);
135
- return true;
261
+ return result !== null;
262
+ },
263
+ [client, mutate, sessionId, workspaceId],
264
+ );
265
+
266
+ const editTurn = useCallback(
267
+ async (
268
+ turnId: string,
269
+ edit: { expectedDraftRevision: number; replaceDraft: boolean },
270
+ ): Promise<ComposerDraft | null> => {
271
+ const result = await mutate(turnId, "edit", (_current, turn) =>
272
+ client.editQueueItem(workspaceId, sessionId!, turnId, {
273
+ clientEventId: operationKey(),
274
+ expectedTurnVersion: turn.version,
275
+ expectedDraftRevision: edit.expectedDraftRevision,
276
+ replaceDraft: edit.replaceDraft,
277
+ }),
278
+ );
279
+ return result?.draft ?? null;
136
280
  },
137
- [client, workspaceId, sessionId, run, load, replaceSnapshot],
281
+ [client, mutate, sessionId, workspaceId],
282
+ );
283
+
284
+ const steerTurn = useCallback(
285
+ async (turnId: string): Promise<boolean> => {
286
+ const result = await mutate(turnId, "steer", (current, turn) =>
287
+ client.steerQueueItem(workspaceId, sessionId!, turnId, {
288
+ clientEventId: operationKey(),
289
+ expectedTurnVersion: turn.version,
290
+ controlEtag: current.effectiveControl.controlEtag,
291
+ }),
292
+ );
293
+ return result !== null;
294
+ },
295
+ [client, mutate, sessionId, workspaceId],
296
+ );
297
+
298
+ const removeTurn = useCallback(
299
+ async (turnId: string): Promise<boolean> => {
300
+ const result = await mutate(turnId, "delete", (_current, turn) =>
301
+ client.deleteQueueItem(workspaceId, sessionId!, turnId, {
302
+ clientEventId: operationKey(),
303
+ expectedTurnVersion: turn.version,
304
+ reason: "Deleted from the prompt queue",
305
+ }),
306
+ );
307
+ return result !== null;
308
+ },
309
+ [client, mutate, sessionId, workspaceId],
310
+ );
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
+ );
319
+ const mutationFor = useCallback(
320
+ (turnId: string): QueueMutationKind | null => visiblePendingByTurn[turnId] ?? null,
321
+ [visiblePendingByTurn],
138
322
  );
139
323
 
140
324
  return {
141
- snapshot,
142
- queue: snapshot?.items ?? [],
143
- controlState: snapshot?.controlState ?? null,
144
- controlGeneration: snapshot?.controlGeneration ?? null,
145
- workspaceInferenceState: snapshot?.workspaceInferenceState ?? null,
146
- workspaceInferenceGeneration: snapshot?.workspaceInferenceGeneration ?? null,
147
- workspaceRunExceptionGeneration: snapshot?.workspaceRunExceptionGeneration ?? null,
148
- loading,
149
- 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,
150
331
  refresh: load,
332
+ moveTurn,
333
+ editTurn,
334
+ steerTurn,
151
335
  removeTurn,
336
+ pendingByTurn: visiblePendingByTurn,
337
+ mutationFor,
152
338
  mutating,
153
- mutationError,
154
- clearMutationError,
339
+ mutationError: identityMatches ? mutationError : null,
340
+ clearMutationError: useCallback(() => {
341
+ if (targetKeyRef.current === targetKey) setMutationError(null);
342
+ }, [targetKey]),
155
343
  };
156
344
  }
345
+
346
+ function operationKey(): string {
347
+ return globalThis.crypto.randomUUID();
348
+ }
349
+
350
+ function asError(cause: unknown): Error {
351
+ return cause instanceof Error ? cause : new Error(String(cause));
352
+ }
@@ -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]);