@opengeni/react 0.11.0 → 0.13.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 +1 -1
- package/dist/{chunk-5C7RGAWA.js → chunk-NFYVQWIB.js} +170 -64
- package/dist/chunk-NFYVQWIB.js.map +1 -0
- package/dist/index.d.ts +791 -145
- package/dist/index.js +5447 -2213
- package/dist/index.js.map +1 -1
- package/dist/{machines-BeZ3bD3t.d.ts → machines-CnlMb7E-.d.ts} +5 -5
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +1 -1
- package/package.json +17 -12
- package/src/approvals.ts +16 -4
- package/src/client.ts +32 -5
- package/src/commands/index.ts +1 -7
- package/src/commands/registry.ts +46 -12
- package/src/components/chat-composer.tsx +135 -39
- 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 +16 -4
- package/src/components/machine-dock-bar.tsx +20 -8
- package/src/components/machine-metrics.tsx +31 -8
- package/src/components/machine-status-pill.tsx +26 -5
- package/src/components/machines-dashboard.tsx +8 -2
- 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/sandbox-files.tsx +110 -227
- package/src/components/sandbox-terminal.tsx +256 -88
- package/src/components/sandbox-workspace.tsx +823 -0
- package/src/components/session-status.tsx +28 -2
- package/src/components/workbench-changes.tsx +541 -0
- package/src/components/workspace-dock.tsx +85 -22
- 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 +57 -36
- 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 +49 -21
- package/src/hooks/use-session-events.ts +48 -20
- package/src/hooks/use-session-lineage.ts +119 -0
- package/src/hooks/use-session.ts +43 -28
- package/src/hooks/use-slash-commands.ts +6 -4
- package/src/hooks/use-turn-queue.ts +74 -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 +128 -23
- package/src/lib/format.ts +44 -6
- package/src/lib/xterm-renderer.ts +65 -0
- package/src/lib/xterm-theme.ts +224 -18
- package/src/machines.ts +13 -3
- package/src/provider.tsx +3 -1
- 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 +44 -10
- package/src/timeline/projection.ts +435 -84
- 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/tokens.css +2 -2
- package/dist/chunk-5C7RGAWA.js.map +0 -1
|
@@ -1,117 +1,77 @@
|
|
|
1
|
-
import type { SessionEvent,
|
|
1
|
+
import type { SessionEvent, SessionQueueSnapshot, SessionTurn } from "@opengeni/sdk";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import {
|
|
5
|
+
useDebouncedCallback,
|
|
6
|
+
useMutationRunner,
|
|
7
|
+
useSessionEventTrigger,
|
|
8
|
+
type SessionEventFeedOptions,
|
|
9
|
+
} from "./internal";
|
|
10
|
+
|
|
11
|
+
/** Events that can change the authoritative prompt queue or its pause gates. */
|
|
7
12
|
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
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
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));
|
|
13
|
+
return (
|
|
14
|
+
event.type.startsWith("turn.") ||
|
|
15
|
+
event.type.startsWith("session.queue.") ||
|
|
16
|
+
event.type.startsWith("session.control.") ||
|
|
17
|
+
event.type.startsWith("workspace.inference.")
|
|
18
|
+
);
|
|
58
19
|
}
|
|
59
20
|
|
|
60
|
-
export type UseTurnQueueOptions = ClientOverride &
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
};
|
|
21
|
+
export type UseTurnQueueOptions = ClientOverride &
|
|
22
|
+
SessionEventFeedOptions & {
|
|
23
|
+
pollIntervalMs?: number | undefined;
|
|
24
|
+
};
|
|
64
25
|
|
|
65
26
|
export type UseTurnQueueResult = {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
/** Queued turns in execution order — render this as the editable queue. */
|
|
27
|
+
snapshot: SessionQueueSnapshot | null;
|
|
28
|
+
/** Human/API prompts exactly in server execution order. Never client-sorted. */
|
|
69
29
|
queue: SessionTurn[];
|
|
70
|
-
|
|
71
|
-
|
|
30
|
+
controlState: SessionQueueSnapshot["controlState"] | null;
|
|
31
|
+
controlGeneration: number | null;
|
|
32
|
+
workspaceInferenceState: SessionQueueSnapshot["workspaceInferenceState"] | null;
|
|
33
|
+
workspaceInferenceGeneration: number | null;
|
|
34
|
+
workspaceRunExceptionGeneration: number | null;
|
|
72
35
|
loading: boolean;
|
|
73
36
|
error: Error | null;
|
|
74
37
|
refresh: () => Promise<void>;
|
|
75
|
-
/**
|
|
76
|
-
|
|
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. */
|
|
38
|
+
/** Delete a waiting prompt before the worker claims it. */
|
|
39
|
+
removeTurn: (turnId: string) => Promise<boolean>;
|
|
82
40
|
mutating: boolean;
|
|
83
|
-
/** Last failed mutation, until the next mutation or clear. */
|
|
84
41
|
mutationError: Error | null;
|
|
85
42
|
clearMutationError: () => void;
|
|
86
43
|
};
|
|
87
44
|
|
|
88
45
|
/**
|
|
89
|
-
* The
|
|
90
|
-
*
|
|
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.
|
|
46
|
+
* The single authoritative waiting-prompt queue. The server owns ordering; the
|
|
47
|
+
* client renders the returned array verbatim and supports deletion only.
|
|
95
48
|
*/
|
|
96
|
-
export function useTurnQueue(
|
|
49
|
+
export function useTurnQueue(
|
|
50
|
+
sessionId: string | null | undefined,
|
|
51
|
+
options: UseTurnQueueOptions = {},
|
|
52
|
+
): UseTurnQueueResult {
|
|
97
53
|
const { client, workspaceId } = useOpenGeni(options);
|
|
98
54
|
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
99
|
-
const [
|
|
55
|
+
const [snapshot, setSnapshot] = useState<SessionQueueSnapshot | null>(null);
|
|
100
56
|
const [loading, setLoading] = useState(enabled);
|
|
101
57
|
const [error, setError] = useState<Error | null>(null);
|
|
102
|
-
const
|
|
58
|
+
const { run, mutating, mutationError, clearMutationError } = useMutationRunner();
|
|
103
59
|
const generation = useRef(0);
|
|
104
60
|
const targetKeyRef = useRef<string | null>(null);
|
|
61
|
+
const snapshotRef = useRef<SessionQueueSnapshot | null>(null);
|
|
62
|
+
|
|
63
|
+
const replaceSnapshot = useCallback((next: SessionQueueSnapshot | null): void => {
|
|
64
|
+
snapshotRef.current = next;
|
|
65
|
+
setSnapshot(next);
|
|
66
|
+
}, []);
|
|
105
67
|
|
|
106
68
|
const load = useCallback(async (): Promise<void> => {
|
|
107
|
-
if (!sessionId)
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
69
|
+
if (!sessionId) return;
|
|
110
70
|
const ticket = ++generation.current;
|
|
111
71
|
try {
|
|
112
|
-
const fetched = await client.
|
|
72
|
+
const fetched = await client.getQueue(workspaceId, sessionId);
|
|
113
73
|
if (ticket === generation.current) {
|
|
114
|
-
|
|
74
|
+
replaceSnapshot(fetched);
|
|
115
75
|
setError(null);
|
|
116
76
|
setLoading(false);
|
|
117
77
|
}
|
|
@@ -121,14 +81,13 @@ export function useTurnQueue(sessionId: string | null | undefined, options: UseT
|
|
|
121
81
|
setLoading(false);
|
|
122
82
|
}
|
|
123
83
|
}
|
|
124
|
-
}, [client, workspaceId, sessionId]);
|
|
84
|
+
}, [client, workspaceId, sessionId, replaceSnapshot]);
|
|
125
85
|
|
|
126
|
-
// Reset when the target session changes; initial load + optional polling.
|
|
127
86
|
useEffect(() => {
|
|
128
87
|
const targetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
|
|
129
88
|
if (targetKeyRef.current !== targetKey) {
|
|
130
89
|
targetKeyRef.current = targetKey;
|
|
131
|
-
|
|
90
|
+
replaceSnapshot(null);
|
|
132
91
|
setError(null);
|
|
133
92
|
}
|
|
134
93
|
if (!enabled) {
|
|
@@ -148,82 +107,50 @@ export function useTurnQueue(sessionId: string | null | undefined, options: UseT
|
|
|
148
107
|
clearInterval(timer);
|
|
149
108
|
generation.current += 1;
|
|
150
109
|
};
|
|
151
|
-
}, [load, enabled, workspaceId, sessionId, options.pollIntervalMs]);
|
|
110
|
+
}, [load, enabled, workspaceId, sessionId, options.pollIntervalMs, replaceSnapshot]);
|
|
152
111
|
|
|
153
|
-
// Live updates: any turn.* event re-syncs the queue (debounced).
|
|
154
112
|
const scheduleRefresh = useDebouncedCallback(() => void load());
|
|
155
113
|
useSessionEventTrigger(client, workspaceId, sessionId, isTurnQueueEvent, scheduleRefresh, {
|
|
156
114
|
enabled,
|
|
157
115
|
...(options.events !== undefined ? { events: options.events } : {}),
|
|
158
116
|
});
|
|
159
117
|
|
|
160
|
-
const editTurn = useCallback(
|
|
161
|
-
async (turnId: string, update: UpdateSessionTurnRequest): Promise<SessionTurn | null> => {
|
|
162
|
-
if (!sessionId) {
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
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
|
-
},
|
|
174
|
-
[client, workspaceId, sessionId, mutation.run, load],
|
|
175
|
-
);
|
|
176
|
-
|
|
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;
|
|
194
|
-
},
|
|
195
|
-
[client, workspaceId, sessionId, mutation.run, load],
|
|
196
|
-
);
|
|
197
|
-
|
|
198
118
|
const removeTurn = useCallback(
|
|
199
|
-
async (turnId: string): Promise<
|
|
200
|
-
if (!sessionId)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const result = await
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
119
|
+
async (turnId: string): Promise<boolean> => {
|
|
120
|
+
if (!sessionId) return false;
|
|
121
|
+
const current = snapshotRef.current;
|
|
122
|
+
const item = current?.items.find((turn) => turn.id === turnId);
|
|
123
|
+
if (!current || !item) return false;
|
|
124
|
+
const result = await run(() =>
|
|
125
|
+
client.cancelQueueItem(workspaceId, sessionId, turnId, {
|
|
126
|
+
expectedQueueVersion: current.version,
|
|
127
|
+
expectedItemVersion: item.version,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
if (!result) {
|
|
208
131
|
void load();
|
|
132
|
+
return false;
|
|
209
133
|
}
|
|
210
|
-
|
|
134
|
+
replaceSnapshot(result.snapshot);
|
|
135
|
+
return true;
|
|
211
136
|
},
|
|
212
|
-
[client, workspaceId, sessionId,
|
|
137
|
+
[client, workspaceId, sessionId, run, load, replaceSnapshot],
|
|
213
138
|
);
|
|
214
139
|
|
|
215
140
|
return {
|
|
216
|
-
|
|
217
|
-
queue:
|
|
218
|
-
|
|
141
|
+
snapshot,
|
|
142
|
+
queue: snapshot?.items ?? [],
|
|
143
|
+
controlState: snapshot?.controlState ?? null,
|
|
144
|
+
controlGeneration: snapshot?.controlGeneration ?? null,
|
|
145
|
+
workspaceInferenceState: snapshot?.workspaceInferenceState ?? null,
|
|
146
|
+
workspaceInferenceGeneration: snapshot?.workspaceInferenceGeneration ?? null,
|
|
147
|
+
workspaceRunExceptionGeneration: snapshot?.workspaceRunExceptionGeneration ?? null,
|
|
219
148
|
loading,
|
|
220
149
|
error,
|
|
221
150
|
refresh: load,
|
|
222
|
-
editTurn,
|
|
223
|
-
reorderTurns,
|
|
224
151
|
removeTurn,
|
|
225
|
-
mutating
|
|
226
|
-
mutationError
|
|
227
|
-
clearMutationError
|
|
152
|
+
mutating,
|
|
153
|
+
mutationError,
|
|
154
|
+
clearMutationError,
|
|
228
155
|
};
|
|
229
156
|
}
|
|
@@ -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
|
+
}
|