@opengeni/react 0.12.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.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-5TIXRCSI.js → chunk-NFYVQWIB.js} +150 -64
  3. package/dist/chunk-NFYVQWIB.js.map +1 -0
  4. package/dist/index.d.ts +760 -142
  5. package/dist/index.js +5345 -2146
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BeZ3bD3t.d.ts → machines-CnlMb7E-.d.ts} +5 -5
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +1 -1
  10. package/package.json +17 -12
  11. package/src/approvals.ts +16 -4
  12. package/src/client.ts +32 -5
  13. package/src/commands/index.ts +1 -7
  14. package/src/commands/registry.ts +46 -12
  15. package/src/components/chat-composer.tsx +135 -39
  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 +16 -4
  25. package/src/components/machine-dock-bar.tsx +20 -8
  26. package/src/components/machine-metrics.tsx +31 -8
  27. package/src/components/machine-status-pill.tsx +26 -5
  28. package/src/components/machines-dashboard.tsx +8 -2
  29. package/src/components/markdown.tsx +39 -9
  30. package/src/components/message-timeline.tsx +633 -109
  31. package/src/components/pierre-diff.tsx +83 -15
  32. package/src/components/pierre-file.tsx +10 -9
  33. package/src/components/sandbox-files.tsx +110 -227
  34. package/src/components/sandbox-terminal.tsx +256 -88
  35. package/src/components/sandbox-workspace.tsx +823 -0
  36. package/src/components/session-status.tsx +28 -2
  37. package/src/components/workbench-changes.tsx +541 -0
  38. package/src/components/workspace-dock.tsx +85 -22
  39. package/src/hooks/internal.ts +5 -2
  40. package/src/hooks/use-available-models.ts +7 -2
  41. package/src/hooks/use-billing-usage.ts +4 -1
  42. package/src/hooks/use-codex-accounts.ts +74 -44
  43. package/src/hooks/use-composer.ts +57 -36
  44. package/src/hooks/use-environments.ts +92 -47
  45. package/src/hooks/use-file-attachments.ts +101 -55
  46. package/src/hooks/use-goal.ts +52 -16
  47. package/src/hooks/use-machine-chip.ts +134 -0
  48. package/src/hooks/use-machines.ts +34 -16
  49. package/src/hooks/use-packs.ts +24 -19
  50. package/src/hooks/use-relay-frame-stream.ts +5 -2
  51. package/src/hooks/use-rigs.ts +335 -0
  52. package/src/hooks/use-sandbox-files.ts +213 -39
  53. package/src/hooks/use-sandbox-git.ts +188 -11
  54. package/src/hooks/use-sandbox-terminal.ts +18 -14
  55. package/src/hooks/use-scheduled-tasks.ts +10 -2
  56. package/src/hooks/use-session-capabilities.ts +77 -20
  57. package/src/hooks/use-session-control.ts +49 -21
  58. package/src/hooks/use-session-events.ts +48 -20
  59. package/src/hooks/use-session-lineage.ts +119 -0
  60. package/src/hooks/use-session.ts +43 -28
  61. package/src/hooks/use-slash-commands.ts +6 -4
  62. package/src/hooks/use-turn-queue.ts +74 -147
  63. package/src/hooks/use-windowed-sections.ts +204 -0
  64. package/src/hooks/use-workspace-capture.ts +233 -0
  65. package/src/hooks/use-workspace-edit.ts +231 -0
  66. package/src/hooks/use-workspace-sessions.ts +48 -5
  67. package/src/hooks/use-workspaces.ts +18 -15
  68. package/src/index.ts +127 -23
  69. package/src/lib/format.ts +3 -3
  70. package/src/lib/xterm-renderer.ts +65 -0
  71. package/src/lib/xterm-theme.ts +224 -18
  72. package/src/machines.ts +13 -3
  73. package/src/provider.tsx +3 -1
  74. package/src/timeline/activity-rail.tsx +213 -54
  75. package/src/timeline/disclosure-context.tsx +12 -2
  76. package/src/timeline/index.ts +21 -2
  77. package/src/timeline/parsers.ts +44 -10
  78. package/src/timeline/projection.ts +388 -85
  79. package/src/timeline/shared.tsx +111 -24
  80. package/src/timeline/tool-diff.tsx +24 -20
  81. package/src/timeline/tool-renderers.tsx +98 -21
  82. package/src/timeline/turn-summary.tsx +76 -23
  83. package/src/timeline/types.ts +96 -12
  84. package/styles/tokens.css +2 -2
  85. package/dist/chunk-5TIXRCSI.js.map +0 -1
@@ -1,117 +1,77 @@
1
- import type { SessionEvent, SessionTurn, UpdateSessionTurnRequest } from "@opengeni/sdk";
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 { useDebouncedCallback, useMutationRunner, useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
5
-
6
- /** Event types that change the turn queue (queue/edit/reorder/claim/finish). */
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 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
- });
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 & SessionEventFeedOptions & {
61
- /** Optional safety-net polling (ms). Off by default — turn.* events drive updates. */
62
- pollIntervalMs?: number | undefined;
63
- };
21
+ export type UseTurnQueueOptions = ClientOverride &
22
+ SessionEventFeedOptions & {
23
+ pollIntervalMs?: number | undefined;
24
+ };
64
25
 
65
26
  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. */
27
+ snapshot: SessionQueueSnapshot | null;
28
+ /** Human/API prompts exactly in server execution order. Never client-sorted. */
69
29
  queue: SessionTurn[];
70
- /** The running / requires_action turn, if any. */
71
- activeTurn: SessionTurn | null;
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
- /** 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. */
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 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.
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(sessionId: string | null | undefined, options: UseTurnQueueOptions = {}): UseTurnQueueResult {
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 [turns, setTurns] = useState<SessionTurn[]>([]);
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 mutation = useMutationRunner();
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.listTurns(workspaceId, sessionId);
72
+ const fetched = await client.getQueue(workspaceId, sessionId);
113
73
  if (ticket === generation.current) {
114
- setTurns(fetched);
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
- setTurns([]);
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<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 {
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
- return result;
134
+ replaceSnapshot(result.snapshot);
135
+ return true;
211
136
  },
212
- [client, workspaceId, sessionId, mutation.run, load],
137
+ [client, workspaceId, sessionId, run, load, replaceSnapshot],
213
138
  );
214
139
 
215
140
  return {
216
- turns,
217
- queue: queueFromTurns(turns),
218
- activeTurn: activeTurnFromTurns(turns),
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: mutation.mutating,
226
- mutationError: mutation.mutationError,
227
- clearMutationError: mutation.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
+ }