@opengeni/react 0.12.0 → 0.15.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 +20 -14
- package/dist/chunk-TOJR776I.js +2280 -0
- package/dist/chunk-TOJR776I.js.map +1 -0
- package/dist/index.d.ts +1040 -363
- package/dist/index.js +10938 -6281
- package/dist/index.js.map +1 -1
- package/dist/{machines-BeZ3bD3t.d.ts → machines-BpdwuQcD.d.ts} +134 -14
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +23 -1
- package/package.json +20 -12
- package/src/approvals.ts +16 -4
- package/src/client.ts +41 -5
- package/src/commands/index.ts +1 -7
- package/src/commands/registry.ts +46 -12
- package/src/components/chat-composer.tsx +417 -69
- package/src/components/code-editor.tsx +28 -36
- package/src/components/command-palette.tsx +26 -5
- package/src/components/desktop-viewer.tsx +29 -16
- package/src/components/diff-view.tsx +27 -189
- package/src/components/enrollment-consent.tsx +28 -10
- package/src/components/enrollment-device-flow.tsx +8 -5
- package/src/components/file-browser.tsx +190 -56
- package/src/components/fleet-tile.tsx +13 -4
- package/src/components/machine-card.tsx +93 -15
- package/src/components/machine-dock-bar.tsx +20 -8
- package/src/components/machine-health-pill.tsx +68 -0
- package/src/components/machine-metrics.tsx +28 -19
- package/src/components/machine-status-pill.tsx +26 -5
- 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 +21 -3
- package/src/components/markdown.tsx +39 -9
- package/src/components/message-timeline.tsx +633 -109
- package/src/components/pierre-diff.tsx +83 -15
- package/src/components/pierre-file.tsx +10 -9
- package/src/components/queue-surface.tsx +578 -0
- package/src/components/sandbox-files.tsx +193 -225
- package/src/components/sandbox-terminal.tsx +256 -88
- package/src/components/sandbox-workspace.tsx +957 -0
- package/src/components/session-status.tsx +22 -2
- package/src/components/workbench-changes.tsx +585 -0
- package/src/components/workspace-dock.tsx +213 -59
- package/src/hooks/internal.ts +5 -2
- package/src/hooks/use-available-models.ts +7 -2
- package/src/hooks/use-billing-usage.ts +4 -1
- package/src/hooks/use-codex-accounts.ts +74 -44
- package/src/hooks/use-composer.ts +414 -63
- package/src/hooks/use-environments.ts +92 -47
- package/src/hooks/use-file-attachments.ts +101 -55
- package/src/hooks/use-goal.ts +52 -16
- package/src/hooks/use-machine-chip.ts +134 -0
- package/src/hooks/use-machines.ts +34 -16
- package/src/hooks/use-packs.ts +24 -19
- package/src/hooks/use-relay-frame-stream.ts +5 -2
- package/src/hooks/use-rigs.ts +335 -0
- package/src/hooks/use-sandbox-files.ts +213 -39
- package/src/hooks/use-sandbox-git.ts +188 -11
- package/src/hooks/use-sandbox-terminal.ts +18 -14
- package/src/hooks/use-scheduled-tasks.ts +10 -2
- package/src/hooks/use-session-capabilities.ts +77 -20
- package/src/hooks/use-session-control.ts +50 -23
- package/src/hooks/use-session-events.ts +50 -21
- package/src/hooks/use-session-lineage.ts +128 -0
- package/src/hooks/use-session.ts +53 -30
- package/src/hooks/use-slash-commands.ts +6 -4
- package/src/hooks/use-turn-queue.ts +202 -147
- package/src/hooks/use-windowed-sections.ts +204 -0
- package/src/hooks/use-workspace-capture.ts +233 -0
- package/src/hooks/use-workspace-edit.ts +231 -0
- package/src/hooks/use-workspace-sessions.ts +48 -5
- package/src/hooks/use-workspaces.ts +18 -15
- package/src/index.ts +133 -23
- package/src/lib/format.ts +3 -3
- package/src/lib/xterm-renderer.ts +65 -0
- package/src/lib/xterm-theme.ts +224 -18
- package/src/machines.ts +29 -3
- package/src/provider.tsx +195 -6
- package/src/timeline/activity-rail.tsx +213 -54
- package/src/timeline/disclosure-context.tsx +12 -2
- package/src/timeline/index.ts +21 -2
- package/src/timeline/parsers.ts +87 -16
- package/src/timeline/projection.ts +412 -87
- package/src/timeline/shared.tsx +111 -24
- package/src/timeline/tool-diff.tsx +24 -20
- package/src/timeline/tool-renderers.tsx +98 -21
- package/src/timeline/turn-summary.tsx +76 -23
- package/src/timeline/types.ts +96 -12
- package/styles/index.css +22 -0
- package/styles/tokens.css +2 -2
- package/dist/chunk-5TIXRCSI.js +0 -1291
- package/dist/chunk-5TIXRCSI.js.map +0 -1
|
@@ -1,135 +1,125 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
ComposerDraft,
|
|
3
|
+
EffectiveSessionControl,
|
|
4
|
+
SessionEvent,
|
|
5
|
+
SessionQueueMutationResponse,
|
|
6
|
+
SessionQueueSnapshot,
|
|
7
|
+
SessionTurn,
|
|
8
|
+
} from "@opengeni/sdk";
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
10
|
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
useDebouncedCallback,
|
|
13
|
+
useSessionEventTrigger,
|
|
14
|
+
type SessionEventFeedOptions,
|
|
15
|
+
} from "./internal";
|
|
5
16
|
|
|
6
|
-
/**
|
|
17
|
+
/** Events that can change the authoritative prompt queue or effective control. */
|
|
7
18
|
export function isTurnQueueEvent(event: Pick<SessionEvent, "type">): boolean {
|
|
8
|
-
return
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
.filter((turn) => turn.status === "queued")
|
|
15
|
-
.sort((a, b) => a.position - b.position || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** The turn currently holding the session (running or awaiting approval). */
|
|
19
|
-
export function activeTurnFromTurns(turns: SessionTurn[]): SessionTurn | null {
|
|
20
|
-
return turns.find((turn) => turn.status === "running" || turn.status === "requires_action") ?? null;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Optimistic projection of a queued-turn edit. */
|
|
24
|
-
export function applyTurnEdit(turns: SessionTurn[], turnId: string, update: UpdateSessionTurnRequest): SessionTurn[] {
|
|
25
|
-
return turns.map((turn) => {
|
|
26
|
-
if (turn.id !== turnId || turn.status !== "queued") {
|
|
27
|
-
return turn;
|
|
28
|
-
}
|
|
29
|
-
return {
|
|
30
|
-
...turn,
|
|
31
|
-
...(update.prompt !== undefined ? { prompt: update.prompt } : {}),
|
|
32
|
-
...(update.resources !== undefined ? { resources: update.resources } : {}),
|
|
33
|
-
...(update.tools !== undefined ? { tools: update.tools } : {}),
|
|
34
|
-
...(update.model !== undefined ? { model: update.model } : {}),
|
|
35
|
-
...(update.reasoningEffort !== undefined ? { reasoningEffort: update.reasoningEffort } : {}),
|
|
36
|
-
...(update.sandboxBackend !== undefined ? { sandboxBackend: update.sandboxBackend } : {}),
|
|
37
|
-
...(update.metadata !== undefined ? { metadata: update.metadata } : {}),
|
|
38
|
-
};
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Optimistic projection of a reorder, mirroring the server: the listed
|
|
44
|
-
* queued turns get positions 1..n in the given order; everything else keeps
|
|
45
|
-
* its position.
|
|
46
|
-
*/
|
|
47
|
-
export function applyTurnReorder(turns: SessionTurn[], turnIds: string[]): SessionTurn[] {
|
|
48
|
-
const positions = new Map(turnIds.map((turnId, index) => [turnId, index + 1] as const));
|
|
49
|
-
return turns.map((turn) => {
|
|
50
|
-
const position = positions.get(turn.id);
|
|
51
|
-
return position !== undefined && turn.status === "queued" ? { ...turn, position } : turn;
|
|
52
|
-
});
|
|
19
|
+
return (
|
|
20
|
+
event.type.startsWith("turn.") ||
|
|
21
|
+
event.type.startsWith("session.queue.") ||
|
|
22
|
+
event.type.startsWith("session.control.") ||
|
|
23
|
+
event.type.startsWith("workspace.inference.")
|
|
24
|
+
);
|
|
53
25
|
}
|
|
54
26
|
|
|
55
|
-
|
|
56
|
-
export function applyTurnRemoval(turns: SessionTurn[], turnId: string): SessionTurn[] {
|
|
57
|
-
return turns.map((turn) => (turn.id === turnId && turn.status === "queued" ? { ...turn, status: "cancelled" as const } : turn));
|
|
58
|
-
}
|
|
27
|
+
export type QueueMutationKind = "move" | "edit" | "steer" | "delete";
|
|
59
28
|
|
|
60
|
-
export type UseTurnQueueOptions = ClientOverride &
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
};
|
|
29
|
+
export type UseTurnQueueOptions = ClientOverride &
|
|
30
|
+
SessionEventFeedOptions & {
|
|
31
|
+
pollIntervalMs?: number | undefined;
|
|
32
|
+
};
|
|
64
33
|
|
|
65
34
|
export type UseTurnQueueResult = {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
/** Queued turns in execution order — render this as the editable queue. */
|
|
35
|
+
snapshot: SessionQueueSnapshot | null;
|
|
36
|
+
/** Human/API prompts exactly in server execution order. Never client-sorted. */
|
|
69
37
|
queue: SessionTurn[];
|
|
70
|
-
|
|
71
|
-
activeTurn: SessionTurn | null;
|
|
38
|
+
effectiveControl: EffectiveSessionControl | null;
|
|
72
39
|
loading: boolean;
|
|
73
40
|
error: Error | null;
|
|
74
41
|
refresh: () => Promise<void>;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
/**
|
|
42
|
+
moveTurn: (turnId: string, beforeTurnId: string | null) => Promise<boolean>;
|
|
43
|
+
/** Atomically withdraw a waiting prompt into the private durable composer draft. */
|
|
44
|
+
editTurn: (
|
|
45
|
+
turnId: string,
|
|
46
|
+
options: { expectedDraftRevision: number; replaceDraft: boolean },
|
|
47
|
+
) => Promise<ComposerDraft | null>;
|
|
48
|
+
/** Advance the same durable waiting prompt; no duplicate prompt is created. */
|
|
49
|
+
steerTurn: (turnId: string) => Promise<boolean>;
|
|
50
|
+
removeTurn: (turnId: string) => Promise<boolean>;
|
|
51
|
+
pendingByTurn: Readonly<Record<string, QueueMutationKind>>;
|
|
52
|
+
mutationFor: (turnId: string) => QueueMutationKind | null;
|
|
82
53
|
mutating: boolean;
|
|
83
|
-
/** Last failed mutation, until the next mutation or clear. */
|
|
84
54
|
mutationError: Error | null;
|
|
85
55
|
clearMutationError: () => void;
|
|
86
56
|
};
|
|
87
57
|
|
|
88
58
|
/**
|
|
89
|
-
* The
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* event stream (`turn.*`), either shared via `options.events` (pass the log
|
|
93
|
-
* from `useSessionEvents` to reuse its connection) or a dedicated tail
|
|
94
|
-
* stream. All mutations apply optimistically and reconcile with the server.
|
|
59
|
+
* The one authoritative human prompt queue. Every mutation carries the exact
|
|
60
|
+
* server versions the operator saw and accepts only monotonic snapshots. A
|
|
61
|
+
* conflict immediately reloads server truth; the client never invents order.
|
|
95
62
|
*/
|
|
96
|
-
export function useTurnQueue(
|
|
97
|
-
|
|
63
|
+
export function useTurnQueue(
|
|
64
|
+
sessionId: string | null | undefined,
|
|
65
|
+
options: UseTurnQueueOptions = {},
|
|
66
|
+
): UseTurnQueueResult {
|
|
67
|
+
const { client, workspaceId, workspaceControlEvent, registerSessionReconciler } =
|
|
68
|
+
useOpenGeni(options);
|
|
98
69
|
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
99
|
-
const [
|
|
70
|
+
const [snapshot, setSnapshot] = useState<SessionQueueSnapshot | null>(null);
|
|
100
71
|
const [loading, setLoading] = useState(enabled);
|
|
101
72
|
const [error, setError] = useState<Error | null>(null);
|
|
102
|
-
const
|
|
103
|
-
const
|
|
73
|
+
const [mutationError, setMutationError] = useState<Error | null>(null);
|
|
74
|
+
const [pendingByTurn, setPendingByTurn] = useState<Record<string, QueueMutationKind>>({});
|
|
75
|
+
const pendingRef = useRef<Record<string, QueueMutationKind>>({});
|
|
76
|
+
const readGeneration = useRef(0);
|
|
104
77
|
const targetKeyRef = useRef<string | null>(null);
|
|
78
|
+
const snapshotRef = useRef<SessionQueueSnapshot | null>(null);
|
|
105
79
|
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
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;
|
|
109
89
|
}
|
|
110
|
-
|
|
90
|
+
snapshotRef.current = next;
|
|
91
|
+
setSnapshot(next);
|
|
92
|
+
return true;
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
const load = useCallback(async (): Promise<void> => {
|
|
96
|
+
if (!sessionId) return;
|
|
97
|
+
const ticket = ++readGeneration.current;
|
|
111
98
|
try {
|
|
112
|
-
const fetched = await client.
|
|
113
|
-
if (ticket ===
|
|
114
|
-
|
|
99
|
+
const fetched = await client.getQueue(workspaceId, sessionId);
|
|
100
|
+
if (ticket === readGeneration.current) {
|
|
101
|
+
acceptSnapshot(fetched);
|
|
115
102
|
setError(null);
|
|
116
103
|
setLoading(false);
|
|
117
104
|
}
|
|
118
105
|
} catch (cause) {
|
|
119
|
-
if (ticket ===
|
|
120
|
-
setError(
|
|
106
|
+
if (ticket === readGeneration.current) {
|
|
107
|
+
setError(asError(cause));
|
|
121
108
|
setLoading(false);
|
|
122
109
|
}
|
|
123
110
|
}
|
|
124
|
-
}, [client, workspaceId, sessionId]);
|
|
111
|
+
}, [client, workspaceId, sessionId, acceptSnapshot]);
|
|
125
112
|
|
|
126
|
-
// Reset when the target session changes; initial load + optional polling.
|
|
127
113
|
useEffect(() => {
|
|
128
114
|
const targetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
|
|
129
115
|
if (targetKeyRef.current !== targetKey) {
|
|
130
116
|
targetKeyRef.current = targetKey;
|
|
131
|
-
|
|
117
|
+
readGeneration.current += 1;
|
|
118
|
+
acceptSnapshot(null);
|
|
132
119
|
setError(null);
|
|
120
|
+
setMutationError(null);
|
|
121
|
+
setPendingByTurn({});
|
|
122
|
+
pendingRef.current = {};
|
|
133
123
|
}
|
|
134
124
|
if (!enabled) {
|
|
135
125
|
setLoading(false);
|
|
@@ -140,90 +130,155 @@ export function useTurnQueue(sessionId: string | null | undefined, options: UseT
|
|
|
140
130
|
const pollIntervalMs = options.pollIntervalMs;
|
|
141
131
|
if (pollIntervalMs === undefined || pollIntervalMs <= 0) {
|
|
142
132
|
return () => {
|
|
143
|
-
|
|
133
|
+
readGeneration.current += 1;
|
|
144
134
|
};
|
|
145
135
|
}
|
|
146
136
|
const timer = setInterval(() => void load(), pollIntervalMs);
|
|
147
137
|
return () => {
|
|
148
138
|
clearInterval(timer);
|
|
149
|
-
|
|
139
|
+
readGeneration.current += 1;
|
|
150
140
|
};
|
|
151
|
-
}, [load, enabled, workspaceId, sessionId, options.pollIntervalMs]);
|
|
141
|
+
}, [load, enabled, workspaceId, sessionId, options.pollIntervalMs, acceptSnapshot]);
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (enabled && workspaceControlEvent) void load();
|
|
145
|
+
}, [enabled, load, workspaceControlEvent]);
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!sessionId || !enabled) return;
|
|
148
|
+
return registerSessionReconciler(sessionId, "queue", load);
|
|
149
|
+
}, [enabled, load, registerSessionReconciler, sessionId]);
|
|
152
150
|
|
|
153
|
-
// Live updates: any turn.* event re-syncs the queue (debounced).
|
|
154
151
|
const scheduleRefresh = useDebouncedCallback(() => void load());
|
|
155
152
|
useSessionEventTrigger(client, workspaceId, sessionId, isTurnQueueEvent, scheduleRefresh, {
|
|
156
153
|
enabled,
|
|
157
154
|
...(options.events !== undefined ? { events: options.events } : {}),
|
|
158
155
|
});
|
|
159
156
|
|
|
160
|
-
const
|
|
161
|
-
async (
|
|
162
|
-
|
|
157
|
+
const mutate = useCallback(
|
|
158
|
+
async (
|
|
159
|
+
turnId: string,
|
|
160
|
+
kind: QueueMutationKind,
|
|
161
|
+
command: (
|
|
162
|
+
current: SessionQueueSnapshot,
|
|
163
|
+
turn: SessionTurn,
|
|
164
|
+
) => Promise<SessionQueueMutationResponse>,
|
|
165
|
+
): Promise<SessionQueueMutationResponse | null> => {
|
|
166
|
+
if (!sessionId || pendingRef.current[turnId]) return null;
|
|
167
|
+
const current = snapshotRef.current;
|
|
168
|
+
const turn = current?.items.find((candidate) => candidate.id === turnId);
|
|
169
|
+
if (!current || !turn) return null;
|
|
170
|
+
pendingRef.current = { ...pendingRef.current, [turnId]: kind };
|
|
171
|
+
setPendingByTurn(pendingRef.current);
|
|
172
|
+
setMutationError(null);
|
|
173
|
+
try {
|
|
174
|
+
const result = await command(current, turn);
|
|
175
|
+
acceptSnapshot(result.snapshot);
|
|
176
|
+
return result;
|
|
177
|
+
} catch (cause) {
|
|
178
|
+
setMutationError(asError(cause));
|
|
179
|
+
await load();
|
|
163
180
|
return null;
|
|
181
|
+
} finally {
|
|
182
|
+
if (turnId in pendingRef.current) {
|
|
183
|
+
const next = { ...pendingRef.current };
|
|
184
|
+
delete next[turnId];
|
|
185
|
+
pendingRef.current = next;
|
|
186
|
+
setPendingByTurn(next);
|
|
187
|
+
}
|
|
164
188
|
}
|
|
165
|
-
setTurns((current) => applyTurnEdit(current, turnId, update));
|
|
166
|
-
const result = await mutation.run(() => client.updateQueuedTurn(workspaceId, sessionId, turnId, update));
|
|
167
|
-
if (result) {
|
|
168
|
-
setTurns((current) => current.map((turn) => (turn.id === result.id ? result : turn)));
|
|
169
|
-
} else {
|
|
170
|
-
void load();
|
|
171
|
-
}
|
|
172
|
-
return result;
|
|
173
189
|
},
|
|
174
|
-
[
|
|
190
|
+
[acceptSnapshot, load, sessionId],
|
|
175
191
|
);
|
|
176
192
|
|
|
177
|
-
const
|
|
178
|
-
async (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
193
|
+
const moveTurn = useCallback(
|
|
194
|
+
async (turnId: string, beforeTurnId: string | null): Promise<boolean> => {
|
|
195
|
+
const result = await mutate(turnId, "move", (current) =>
|
|
196
|
+
client.moveQueueItem(workspaceId, sessionId!, turnId, {
|
|
197
|
+
clientEventId: operationKey(),
|
|
198
|
+
expectedQueueVersion: current.version,
|
|
199
|
+
beforeTurnId,
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
return result !== null;
|
|
203
|
+
},
|
|
204
|
+
[client, mutate, sessionId, workspaceId],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const editTurn = useCallback(
|
|
208
|
+
async (
|
|
209
|
+
turnId: string,
|
|
210
|
+
edit: { expectedDraftRevision: number; replaceDraft: boolean },
|
|
211
|
+
): Promise<ComposerDraft | null> => {
|
|
212
|
+
const result = await mutate(turnId, "edit", (_current, turn) =>
|
|
213
|
+
client.editQueueItem(workspaceId, sessionId!, turnId, {
|
|
214
|
+
clientEventId: operationKey(),
|
|
215
|
+
expectedTurnVersion: turn.version,
|
|
216
|
+
expectedDraftRevision: edit.expectedDraftRevision,
|
|
217
|
+
replaceDraft: edit.replaceDraft,
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
return result?.draft ?? null;
|
|
194
221
|
},
|
|
195
|
-
[client,
|
|
222
|
+
[client, mutate, sessionId, workspaceId],
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const steerTurn = useCallback(
|
|
226
|
+
async (turnId: string): Promise<boolean> => {
|
|
227
|
+
const result = await mutate(turnId, "steer", (current, turn) =>
|
|
228
|
+
client.steerQueueItem(workspaceId, sessionId!, turnId, {
|
|
229
|
+
clientEventId: operationKey(),
|
|
230
|
+
expectedTurnVersion: turn.version,
|
|
231
|
+
controlEtag: current.effectiveControl.controlEtag,
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
return result !== null;
|
|
235
|
+
},
|
|
236
|
+
[client, mutate, sessionId, workspaceId],
|
|
196
237
|
);
|
|
197
238
|
|
|
198
239
|
const removeTurn = useCallback(
|
|
199
|
-
async (turnId: string): Promise<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
void load();
|
|
209
|
-
}
|
|
210
|
-
return result;
|
|
240
|
+
async (turnId: string): Promise<boolean> => {
|
|
241
|
+
const result = await mutate(turnId, "delete", (_current, turn) =>
|
|
242
|
+
client.deleteQueueItem(workspaceId, sessionId!, turnId, {
|
|
243
|
+
clientEventId: operationKey(),
|
|
244
|
+
expectedTurnVersion: turn.version,
|
|
245
|
+
reason: "Deleted from the prompt queue",
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
return result !== null;
|
|
211
249
|
},
|
|
212
|
-
[client,
|
|
250
|
+
[client, mutate, sessionId, workspaceId],
|
|
213
251
|
);
|
|
214
252
|
|
|
253
|
+
const mutationFor = useCallback(
|
|
254
|
+
(turnId: string): QueueMutationKind | null => pendingByTurn[turnId] ?? null,
|
|
255
|
+
[pendingByTurn],
|
|
256
|
+
);
|
|
257
|
+
const mutating = useMemo(() => Object.keys(pendingByTurn).length > 0, [pendingByTurn]);
|
|
258
|
+
|
|
215
259
|
return {
|
|
216
|
-
|
|
217
|
-
queue:
|
|
218
|
-
|
|
260
|
+
snapshot,
|
|
261
|
+
queue: snapshot?.items ?? [],
|
|
262
|
+
effectiveControl: snapshot?.effectiveControl ?? null,
|
|
219
263
|
loading,
|
|
220
264
|
error,
|
|
221
265
|
refresh: load,
|
|
266
|
+
moveTurn,
|
|
222
267
|
editTurn,
|
|
223
|
-
|
|
268
|
+
steerTurn,
|
|
224
269
|
removeTurn,
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
270
|
+
pendingByTurn,
|
|
271
|
+
mutationFor,
|
|
272
|
+
mutating,
|
|
273
|
+
mutationError,
|
|
274
|
+
clearMutationError: useCallback(() => setMutationError(null), []),
|
|
228
275
|
};
|
|
229
276
|
}
|
|
277
|
+
|
|
278
|
+
function operationKey(): string {
|
|
279
|
+
return globalThis.crypto.randomUUID();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function asError(cause: unknown): Error {
|
|
283
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
284
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/* ----------------------------------------------------------------------------
|
|
4
|
+
Windowed sections — variable-height file-section virtualization.
|
|
5
|
+
|
|
6
|
+
The Changes tab stacks N file diffs in one scroll pane. Mounting N Shiki
|
|
7
|
+
highlighters (Pierre's `PatchDiff`) at once is the exact wart this replaces,
|
|
8
|
+
so we mount only the sections inside the visible window ± overscan and reserve
|
|
9
|
+
the rest as empty space (dossier §10.7, D2).
|
|
10
|
+
|
|
11
|
+
Why NOT `virtua` here (unlike the file tree/rail): Pierre renders each diff in
|
|
12
|
+
a shadow DOM with its own internal overflow/virtualization, so an outer
|
|
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
|
|
15
|
+
file sections off a scroll offset and a per-section height ESTIMATE (refined
|
|
16
|
+
by measurement when a real browser provides it), which never depends on
|
|
17
|
+
Pierre reporting its own height — and the math is pure + deterministic, so
|
|
18
|
+
D2 is provable in both happy-dom (drive scrollTop/clientHeight) and a real
|
|
19
|
+
browser (Playwright, the verification-matrix path).
|
|
20
|
+
-------------------------------------------------------------------------- */
|
|
21
|
+
|
|
22
|
+
export type WindowRange = {
|
|
23
|
+
/** First mounted index (inclusive). */
|
|
24
|
+
start: number;
|
|
25
|
+
/** One past the last mounted index (exclusive). */
|
|
26
|
+
end: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pure windowing math: given each section's height (px), the scroll offset, the
|
|
31
|
+
* viewport height, and an overscan count, return the `[start, end)` index window
|
|
32
|
+
* to mount. Robust to a zero viewport (a headless/pre-layout pass) — it then
|
|
33
|
+
* mounts a small overscan window anchored at the item under `scrollTop` rather
|
|
34
|
+
* than the whole list.
|
|
35
|
+
*/
|
|
36
|
+
export function computeWindowRange(
|
|
37
|
+
heights: readonly number[],
|
|
38
|
+
scrollTop: number,
|
|
39
|
+
viewport: number,
|
|
40
|
+
overscan: number,
|
|
41
|
+
): WindowRange {
|
|
42
|
+
const n = heights.length;
|
|
43
|
+
if (n === 0) return { start: 0, end: 0 };
|
|
44
|
+
const top = Math.max(scrollTop, 0);
|
|
45
|
+
const bottom = top + Math.max(viewport, 0);
|
|
46
|
+
|
|
47
|
+
let firstVisible = -1;
|
|
48
|
+
let lastVisible = -1;
|
|
49
|
+
let acc = 0;
|
|
50
|
+
let anchor = 0; // the item containing `scrollTop` — the zero-viewport fallback
|
|
51
|
+
for (let i = 0; i < n; i++) {
|
|
52
|
+
const secTop = acc;
|
|
53
|
+
const secBottom = secTop + Math.max(heights[i] ?? 0, 0);
|
|
54
|
+
if (secTop <= top) anchor = i;
|
|
55
|
+
// A section is visible when it overlaps [top, bottom); with viewport 0 the
|
|
56
|
+
// half-open interval is empty, so nothing matches and we fall back to anchor.
|
|
57
|
+
if (secBottom > top && secTop < bottom) {
|
|
58
|
+
if (firstVisible === -1) firstVisible = i;
|
|
59
|
+
lastVisible = i;
|
|
60
|
+
}
|
|
61
|
+
acc = secBottom;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (firstVisible === -1) {
|
|
65
|
+
firstVisible = anchor;
|
|
66
|
+
lastVisible = anchor;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
start: Math.max(0, firstVisible - overscan),
|
|
71
|
+
end: Math.min(n, lastVisible + 1 + overscan),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type WindowedSections = {
|
|
76
|
+
/** Attach to the scroll container. */
|
|
77
|
+
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
78
|
+
/** The mounted window `[start, end)`. */
|
|
79
|
+
range: WindowRange;
|
|
80
|
+
/** Prefix offsets: `offsets[i]` is the top px of section `i`; `offsets[count]`
|
|
81
|
+
* is the total content height. */
|
|
82
|
+
offsets: number[];
|
|
83
|
+
/** Total content height (px) — set on the spacer so scroll length is stable. */
|
|
84
|
+
totalHeight: number;
|
|
85
|
+
/** Report a MOUNTED section's real height (px) so the layout refines as async
|
|
86
|
+
* content — e.g. Pierre's Shiki render — grows. Drive it from a ResizeObserver;
|
|
87
|
+
* a zero/idle value is ignored so a headless pass keeps the estimate. */
|
|
88
|
+
measure: (index: number, height: number) => void;
|
|
89
|
+
/** Scroll section `index` to the top of the pane (the rail-jump). */
|
|
90
|
+
scrollToIndex: (index: number) => void;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Variable-height section windowing over a scroll container. Seeds every section
|
|
95
|
+
* to `estimateHeight(i)`, mounts only `range`, and refines heights from real
|
|
96
|
+
* measurements (a no-op in a zero-layout headless pass, which keeps the estimate
|
|
97
|
+
* — deterministic for tests). Pass a STABLE `estimateHeight` (wrap in useCallback).
|
|
98
|
+
*/
|
|
99
|
+
export function useWindowedSections(opts: {
|
|
100
|
+
count: number;
|
|
101
|
+
estimateHeight: (index: number) => number;
|
|
102
|
+
overscan?: number | undefined;
|
|
103
|
+
}): WindowedSections {
|
|
104
|
+
const { count, estimateHeight, overscan = 3 } = opts;
|
|
105
|
+
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
106
|
+
|
|
107
|
+
const [heights, setHeights] = useState<number[]>(() =>
|
|
108
|
+
Array.from({ length: count }, (_, i) => estimateHeight(i)),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Re-seed heights when the section count changes (a new diff arrives), keeping
|
|
112
|
+
// any already-measured heights for surviving indices.
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
setHeights((prev) => {
|
|
115
|
+
if (prev.length === count) return prev;
|
|
116
|
+
return Array.from({ length: count }, (_, i) => prev[i] ?? estimateHeight(i));
|
|
117
|
+
});
|
|
118
|
+
// estimateHeight is expected stable; count is the real trigger.
|
|
119
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
120
|
+
}, [count]);
|
|
121
|
+
|
|
122
|
+
const heightsRef = useRef(heights);
|
|
123
|
+
heightsRef.current = heights;
|
|
124
|
+
|
|
125
|
+
const [range, setRange] = useState<WindowRange>(() => ({
|
|
126
|
+
start: 0,
|
|
127
|
+
end: Math.min(count, overscan + 1),
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
const recompute = useCallback(() => {
|
|
131
|
+
const el = scrollRef.current;
|
|
132
|
+
if (!el) return;
|
|
133
|
+
const next = computeWindowRange(heightsRef.current, el.scrollTop, el.clientHeight, overscan);
|
|
134
|
+
setRange((prev) => (prev.start === next.start && prev.end === next.end ? prev : next));
|
|
135
|
+
}, [overscan]);
|
|
136
|
+
|
|
137
|
+
// Recompute on scroll (rAF-coalesced) and on container resize.
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const el = scrollRef.current;
|
|
140
|
+
if (!el) return;
|
|
141
|
+
let raf = 0;
|
|
142
|
+
const onScroll = () => {
|
|
143
|
+
if (raf) return;
|
|
144
|
+
raf =
|
|
145
|
+
typeof requestAnimationFrame === "function"
|
|
146
|
+
? requestAnimationFrame(() => {
|
|
147
|
+
raf = 0;
|
|
148
|
+
recompute();
|
|
149
|
+
})
|
|
150
|
+
: (recompute(), 0);
|
|
151
|
+
};
|
|
152
|
+
el.addEventListener("scroll", onScroll, { passive: true });
|
|
153
|
+
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => recompute()) : null;
|
|
154
|
+
ro?.observe(el);
|
|
155
|
+
recompute();
|
|
156
|
+
return () => {
|
|
157
|
+
el.removeEventListener("scroll", onScroll);
|
|
158
|
+
ro?.disconnect();
|
|
159
|
+
if (raf && typeof cancelAnimationFrame === "function") cancelAnimationFrame(raf);
|
|
160
|
+
};
|
|
161
|
+
}, [recompute, count]);
|
|
162
|
+
|
|
163
|
+
const measure = useCallback((index: number, height: number) => {
|
|
164
|
+
if (height <= 0) return; // zero-layout pass — keep the estimate
|
|
165
|
+
setHeights((prev) => {
|
|
166
|
+
if (Math.abs((prev[index] ?? 0) - height) < 1) return prev;
|
|
167
|
+
const next = prev.slice();
|
|
168
|
+
next[index] = height;
|
|
169
|
+
return next;
|
|
170
|
+
});
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
const offsets = useMemo(() => {
|
|
174
|
+
const out = new Array<number>(count + 1);
|
|
175
|
+
out[0] = 0;
|
|
176
|
+
for (let i = 0; i < count; i++) out[i + 1] = (out[i] ?? 0) + Math.max(heights[i] ?? 0, 0);
|
|
177
|
+
return out;
|
|
178
|
+
}, [heights, count]);
|
|
179
|
+
|
|
180
|
+
// A measured height can shift the window (e.g. estimates were short) — keep the
|
|
181
|
+
// mounted set in sync without waiting for the next scroll.
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
recompute();
|
|
184
|
+
}, [offsets, recompute]);
|
|
185
|
+
|
|
186
|
+
const scrollToIndex = useCallback(
|
|
187
|
+
(index: number) => {
|
|
188
|
+
const el = scrollRef.current;
|
|
189
|
+
if (!el) return;
|
|
190
|
+
const clamped = Math.max(0, Math.min(count - 1, index));
|
|
191
|
+
el.scrollTop = offsets[clamped] ?? 0;
|
|
192
|
+
},
|
|
193
|
+
[offsets, count],
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
scrollRef,
|
|
198
|
+
range,
|
|
199
|
+
offsets,
|
|
200
|
+
totalHeight: offsets[count] ?? 0,
|
|
201
|
+
measure,
|
|
202
|
+
scrollToIndex,
|
|
203
|
+
};
|
|
204
|
+
}
|