@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.
Files changed (94) hide show
  1. package/README.md +20 -14
  2. package/dist/chunk-TOJR776I.js +2280 -0
  3. package/dist/chunk-TOJR776I.js.map +1 -0
  4. package/dist/index.d.ts +1040 -363
  5. package/dist/index.js +10938 -6281
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BeZ3bD3t.d.ts → machines-BpdwuQcD.d.ts} +134 -14
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +23 -1
  10. package/package.json +20 -12
  11. package/src/approvals.ts +16 -4
  12. package/src/client.ts +41 -5
  13. package/src/commands/index.ts +1 -7
  14. package/src/commands/registry.ts +46 -12
  15. package/src/components/chat-composer.tsx +417 -69
  16. package/src/components/code-editor.tsx +28 -36
  17. package/src/components/command-palette.tsx +26 -5
  18. package/src/components/desktop-viewer.tsx +29 -16
  19. package/src/components/diff-view.tsx +27 -189
  20. package/src/components/enrollment-consent.tsx +28 -10
  21. package/src/components/enrollment-device-flow.tsx +8 -5
  22. package/src/components/file-browser.tsx +190 -56
  23. package/src/components/fleet-tile.tsx +13 -4
  24. package/src/components/machine-card.tsx +93 -15
  25. package/src/components/machine-dock-bar.tsx +20 -8
  26. package/src/components/machine-health-pill.tsx +68 -0
  27. package/src/components/machine-metrics.tsx +28 -19
  28. package/src/components/machine-status-pill.tsx +26 -5
  29. package/src/components/machines/health.ts +146 -0
  30. package/src/components/machines/machine-detail.tsx +220 -0
  31. package/src/components/machines/metric-history-chart.tsx +298 -0
  32. package/src/components/machines/metric-sparkline.tsx +76 -0
  33. package/src/components/machines/series.ts +113 -0
  34. package/src/components/machines-dashboard.tsx +21 -3
  35. package/src/components/markdown.tsx +39 -9
  36. package/src/components/message-timeline.tsx +633 -109
  37. package/src/components/pierre-diff.tsx +83 -15
  38. package/src/components/pierre-file.tsx +10 -9
  39. package/src/components/queue-surface.tsx +578 -0
  40. package/src/components/sandbox-files.tsx +193 -225
  41. package/src/components/sandbox-terminal.tsx +256 -88
  42. package/src/components/sandbox-workspace.tsx +957 -0
  43. package/src/components/session-status.tsx +22 -2
  44. package/src/components/workbench-changes.tsx +585 -0
  45. package/src/components/workspace-dock.tsx +213 -59
  46. package/src/hooks/internal.ts +5 -2
  47. package/src/hooks/use-available-models.ts +7 -2
  48. package/src/hooks/use-billing-usage.ts +4 -1
  49. package/src/hooks/use-codex-accounts.ts +74 -44
  50. package/src/hooks/use-composer.ts +414 -63
  51. package/src/hooks/use-environments.ts +92 -47
  52. package/src/hooks/use-file-attachments.ts +101 -55
  53. package/src/hooks/use-goal.ts +52 -16
  54. package/src/hooks/use-machine-chip.ts +134 -0
  55. package/src/hooks/use-machines.ts +34 -16
  56. package/src/hooks/use-packs.ts +24 -19
  57. package/src/hooks/use-relay-frame-stream.ts +5 -2
  58. package/src/hooks/use-rigs.ts +335 -0
  59. package/src/hooks/use-sandbox-files.ts +213 -39
  60. package/src/hooks/use-sandbox-git.ts +188 -11
  61. package/src/hooks/use-sandbox-terminal.ts +18 -14
  62. package/src/hooks/use-scheduled-tasks.ts +10 -2
  63. package/src/hooks/use-session-capabilities.ts +77 -20
  64. package/src/hooks/use-session-control.ts +50 -23
  65. package/src/hooks/use-session-events.ts +50 -21
  66. package/src/hooks/use-session-lineage.ts +128 -0
  67. package/src/hooks/use-session.ts +53 -30
  68. package/src/hooks/use-slash-commands.ts +6 -4
  69. package/src/hooks/use-turn-queue.ts +202 -147
  70. package/src/hooks/use-windowed-sections.ts +204 -0
  71. package/src/hooks/use-workspace-capture.ts +233 -0
  72. package/src/hooks/use-workspace-edit.ts +231 -0
  73. package/src/hooks/use-workspace-sessions.ts +48 -5
  74. package/src/hooks/use-workspaces.ts +18 -15
  75. package/src/index.ts +133 -23
  76. package/src/lib/format.ts +3 -3
  77. package/src/lib/xterm-renderer.ts +65 -0
  78. package/src/lib/xterm-theme.ts +224 -18
  79. package/src/machines.ts +29 -3
  80. package/src/provider.tsx +195 -6
  81. package/src/timeline/activity-rail.tsx +213 -54
  82. package/src/timeline/disclosure-context.tsx +12 -2
  83. package/src/timeline/index.ts +21 -2
  84. package/src/timeline/parsers.ts +87 -16
  85. package/src/timeline/projection.ts +412 -87
  86. package/src/timeline/shared.tsx +111 -24
  87. package/src/timeline/tool-diff.tsx +24 -20
  88. package/src/timeline/tool-renderers.tsx +98 -21
  89. package/src/timeline/turn-summary.tsx +76 -23
  90. package/src/timeline/types.ts +96 -12
  91. package/styles/index.css +22 -0
  92. package/styles/tokens.css +2 -2
  93. package/dist/chunk-5TIXRCSI.js +0 -1291
  94. package/dist/chunk-5TIXRCSI.js.map +0 -1
@@ -1,135 +1,125 @@
1
- import type { SessionEvent, SessionTurn, UpdateSessionTurnRequest } from "@opengeni/sdk";
2
- import { useCallback, useEffect, useRef, useState } from "react";
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 { useDebouncedCallback, useMutationRunner, useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
11
+ import {
12
+ useDebouncedCallback,
13
+ useSessionEventTrigger,
14
+ type SessionEventFeedOptions,
15
+ } from "./internal";
5
16
 
6
- /** Event types that change the turn queue (queue/edit/reorder/claim/finish). */
17
+ /** Events that can change the authoritative prompt queue or effective control. */
7
18
  export function isTurnQueueEvent(event: Pick<SessionEvent, "type">): boolean {
8
- return event.type.startsWith("turn.");
9
- }
10
-
11
- /** Queued turns in execution order (position, then creation time). */
12
- export function queueFromTurns(turns: SessionTurn[]): SessionTurn[] {
13
- return turns
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
- /** Optimistic projection of a queued-turn delete (server marks it cancelled). */
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 & SessionEventFeedOptions & {
61
- /** Optional safety-net polling (ms). Off by default — turn.* events drive updates. */
62
- pollIntervalMs?: number | undefined;
63
- };
29
+ export type UseTurnQueueOptions = ClientOverride &
30
+ SessionEventFeedOptions & {
31
+ pollIntervalMs?: number | undefined;
32
+ };
64
33
 
65
34
  export type UseTurnQueueResult = {
66
- /** All turns the API returned (history + queue), newest server view. */
67
- turns: SessionTurn[];
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
- /** The running / requires_action turn, if any. */
71
- activeTurn: SessionTurn | null;
38
+ effectiveControl: EffectiveSessionControl | null;
72
39
  loading: boolean;
73
40
  error: Error | null;
74
41
  refresh: () => Promise<void>;
75
- /** Edit a queued turn (optimistic; rolls back via refetch on failure). */
76
- editTurn: (turnId: string, update: UpdateSessionTurnRequest) => Promise<SessionTurn | null>;
77
- /** Reorder the queue to the given queued-turn id order (optimistic). */
78
- reorderTurns: (turnIds: string[]) => Promise<SessionTurn[] | null>;
79
- /** Delete (cancel) a queued turn before it is claimed (optimistic). */
80
- removeTurn: (turnId: string) => Promise<SessionTurn | null>;
81
- /** True while an edit/reorder/remove is in flight. */
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 live turn queue the heart of the queue-by-default interaction model.
90
- * Messages sent mid-turn stack up here, visible and editable/reorderable/
91
- * deletable until the worker claims them. Updates arrive over the session
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(sessionId: string | null | undefined, options: UseTurnQueueOptions = {}): UseTurnQueueResult {
97
- const { client, workspaceId } = useOpenGeni(options);
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 [turns, setTurns] = useState<SessionTurn[]>([]);
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 mutation = useMutationRunner();
103
- const generation = useRef(0);
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 load = useCallback(async (): Promise<void> => {
107
- if (!sessionId) {
108
- return;
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
- const ticket = ++generation.current;
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.listTurns(workspaceId, sessionId);
113
- if (ticket === generation.current) {
114
- setTurns(fetched);
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 === generation.current) {
120
- setError(cause instanceof Error ? cause : new Error(String(cause)));
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
- setTurns([]);
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
- generation.current += 1;
133
+ readGeneration.current += 1;
144
134
  };
145
135
  }
146
136
  const timer = setInterval(() => void load(), pollIntervalMs);
147
137
  return () => {
148
138
  clearInterval(timer);
149
- generation.current += 1;
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 editTurn = useCallback(
161
- async (turnId: string, update: UpdateSessionTurnRequest): Promise<SessionTurn | null> => {
162
- if (!sessionId) {
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
- [client, workspaceId, sessionId, mutation.run, load],
190
+ [acceptSnapshot, load, sessionId],
175
191
  );
176
192
 
177
- const reorderTurns = useCallback(
178
- async (turnIds: string[]): Promise<SessionTurn[] | null> => {
179
- if (!sessionId || turnIds.length === 0) {
180
- return null;
181
- }
182
- setTurns((current) => applyTurnReorder(current, turnIds));
183
- const result = await mutation.run(() => client.reorderQueuedTurns(workspaceId, sessionId, turnIds));
184
- if (result) {
185
- // The server returns the queued turns; merge them over local state.
186
- setTurns((current) => {
187
- const bySId = new Map(result.map((turn) => [turn.id, turn] as const));
188
- return current.map((turn) => bySId.get(turn.id) ?? turn);
189
- });
190
- } else {
191
- void load();
192
- }
193
- return result;
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, workspaceId, sessionId, mutation.run, load],
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<SessionTurn | null> => {
200
- if (!sessionId) {
201
- return null;
202
- }
203
- setTurns((current) => applyTurnRemoval(current, turnId));
204
- const result = await mutation.run(() => client.deleteQueuedTurn(workspaceId, sessionId, turnId));
205
- if (result) {
206
- setTurns((current) => current.map((turn) => (turn.id === result.id ? result : turn)));
207
- } else {
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, workspaceId, sessionId, mutation.run, load],
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
- turns,
217
- queue: queueFromTurns(turns),
218
- activeTurn: activeTurnFromTurns(turns),
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
- reorderTurns,
268
+ steerTurn,
224
269
  removeTurn,
225
- mutating: mutation.mutating,
226
- mutationError: mutation.mutationError,
227
- clearMutationError: mutation.clearMutationError,
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
+ }