@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.
- package/README.md +129 -23
- package/dist/chunk-6BXBGJ3B.js +1956 -0
- 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 +642 -1080
- package/dist/index.js +7672 -8005
- package/dist/index.js.map +1 -1
- package/dist/machines.d.ts +412 -3
- package/dist/machines.js +25 -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 +23 -4
- package/src/approvals.ts +5 -3
- package/src/client.ts +50 -1
- package/src/components/approval-surface.tsx +198 -0
- package/src/components/chat-composer.tsx +104 -751
- 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 +82 -16
- package/src/components/machine-health-pill.tsx +68 -0
- package/src/components/machine-metrics.tsx +10 -24
- package/src/components/machines/health.ts +146 -0
- package/src/components/machines/machine-detail.tsx +220 -0
- package/src/components/machines/metric-history-chart.tsx +298 -0
- package/src/components/machines/metric-sparkline.tsx +76 -0
- package/src/components/machines/series.ts +113 -0
- package/src/components/machines-dashboard.tsx +16 -4
- 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 +60 -0
- package/src/components/sandbox-files.tsx +230 -21
- package/src/components/sandbox-terminal.tsx +8 -2
- package/src/components/sandbox-workspace.tsx +489 -135
- package/src/components/session-status.tsx +0 -6
- package/src/components/workbench-changes.tsx +145 -50
- package/src/components/workspace-dock.tsx +329 -68
- package/src/composer.ts +60 -0
- package/src/hooks/internal.ts +115 -34
- package/src/hooks/use-composer.ts +635 -74
- 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 +43 -15
- package/src/hooks/use-session-events.ts +523 -48
- package/src/hooks/use-session-lineage.ts +15 -6
- package/src/hooks/use-session.ts +10 -2
- package/src/hooks/use-slash-commands.ts +38 -38
- package/src/hooks/use-transcription.ts +757 -0
- package/src/hooks/use-turn-queue.ts +273 -77
- 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 +88 -12
- 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/machines.ts +16 -0
- package/src/provider.tsx +163 -41
- package/src/session-context.ts +135 -0
- package/src/session.ts +79 -0
- package/src/timeline/parsers.ts +78 -6
- package/src/timeline/projection.ts +36 -6
- 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 +78 -0
- package/styles/index.d.ts +2 -0
- package/styles/tokens.css +11 -6
- package/styles/tokens.d.ts +2 -0
- package/dist/chunk-NFYVQWIB.js +0 -1377
- package/dist/chunk-NFYVQWIB.js.map +0 -1
- package/dist/machines-CnlMb7E-.d.ts +0 -329
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
47
|
-
*
|
|
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 } =
|
|
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
|
|
59
|
-
const
|
|
60
|
-
const
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
+
readGeneration.current += 1;
|
|
103
178
|
};
|
|
104
179
|
}
|
|
105
180
|
const timer = setInterval(() => void load(), pollIntervalMs);
|
|
106
181
|
return () => {
|
|
107
182
|
clearInterval(timer);
|
|
108
|
-
|
|
183
|
+
readGeneration.current += 1;
|
|
109
184
|
};
|
|
110
|
-
}, [load, enabled,
|
|
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(
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
119
|
-
async (
|
|
120
|
-
|
|
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
|
|
123
|
-
if (!current || !
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
258
|
+
beforeTurnId,
|
|
128
259
|
}),
|
|
129
260
|
);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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,
|
|
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:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 (
|
|
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]);
|