1import type { InlineSkill } from "@t3tools/shared/inlineSkills";
2import type {
3 OrchestrationV2ThreadProjection,
4 OrchestrationV2SearchThreadResult,
5 ScopedThreadRef,
6} from "@t3tools/contracts";
7import { scopedThreadKey } from "@t3tools/client-runtime/environment";
8import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
9import { orchestrationEnvironment } from "~/state/orchestration";
10import { useEnvironmentQuery } from "~/state/query";
11import { useDebouncedValue } from "~/state/queries";
12import {
13 type ThreadFindPositionReader,
14 type ThreadFindStart,
15 stepThreadFindIndex,
16} from "./threadFind";
17import { subscribeThreadFindOpen } from "./threadFindActionBus";
18import { toastManager } from "../ui/toast";
19
20const EMPTY_SKILLS: readonly InlineSkill[] = [];
21
22const CLOSED_FIND = {
23 threadKey: null as string | null,
24 query: "",
25 offset: 0,
26 start: undefined as ThreadFindStart | undefined,
27 focusRequestId: 0,
28 navigationId: 0,
29 requestId: 0,
30 localSelection: null as {
31 base: OrchestrationV2SearchThreadResult;
32 baseNavigationId: number;
33 activeIndex: number;
34 match: NonNullable<OrchestrationV2SearchThreadResult["match"]>;
35 navigationId: number;
36 } | null,
37};
38
39/** Owns find state for the active thread and its environment. */
40export function useThreadFind({
41 thread,
42 enabled,
43 progressive = false,
44 skills = EMPTY_SKILLS,
45 content,
46}: {
47 thread: ScopedThreadRef | null;
48 enabled: boolean;
49 progressive?: boolean;
50 skills?: readonly InlineSkill[];
51 content: Pick<OrchestrationV2ThreadProjection, "visibleTurnItems" | "runs"> | undefined;
52}) {
53 const findPositionReaderRef = useRef<ThreadFindPositionReader | null>(null);
54 const lastContentRef = useRef(content);
55 const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
56 const refreshAfterSearchRef = useRef(false);
57 const threadKey = thread ? scopedThreadKey(thread) : null;
58 const [state, setState] = useState(CLOSED_FIND);
59 if (state.threadKey !== null && (!enabled || state.threadKey !== threadKey))
60 setState(CLOSED_FIND);
61 const isOpen = enabled && threadKey !== null && state.threadKey === threadKey;
62 const open = useCallback(() => {
63 if (threadKey === null) return;
64 if (!enabled) {
65 toastManager.add({
66 id: "thread-find-unavailable",
67 title: "Thread search is unavailable on this server.",
68 description: "Update the server to enable it.",
69 });
70 return;
71 }
72 const start =
73 state.threadKey === threadKey && state.query.trim()
74 ? findPositionReaderRef.current?.(state.query.trim())
75 : undefined;
76 setState((previous) => {
77 const current = previous.threadKey === threadKey ? previous : CLOSED_FIND;
78 return {
79 ...current,
80 threadKey,
81 focusRequestId: previous.focusRequestId + 1,
82 ...(current.query.trim()
83 ? {
84 start,
85 offset: 0,
86 localSelection: null,
87 navigationId: current.navigationId + 1,
88 requestId: current.navigationId + 1,
89 }
90 : {}),
91 };
92 });
93 }, [enabled, state.query, state.threadKey, threadKey]);
94 const close = useCallback(() => setState(CLOSED_FIND), []);
95 useEffect(() => subscribeThreadFindOpen(open), [open]);
96
97 const remote = useServerResults(
98 isOpen ? thread : null,
99 state.query,
100 state.offset,
101 state.requestId,
102 state.start,
103 skills,
104 progressive,
105 );
106 const status: "loading" | "error" | null = remote.error
107 ? "error"
108 : remote.isPending && remote.data === null
109 ? "loading"
110 : null;
111 const localSelection = rebaseLocalSelection(
112 state.localSelection,
113 remote.data,
114 remote.navigationId,
115 );
116 const count = remote.data?.totalMatches ?? 0;
117 const activeIndex = localSelection?.activeIndex ?? remote.data?.activeIndex ?? 0;
118 const selectedMatch = localSelection?.match ?? remote.data?.match;
119 const entryId = selectedMatch?.entryId;
120 const runId = selectedMatch?.runId;
121 const occurrence = selectedMatch?.occurrence;
122 // Finishing the count must not rerender the timeline or request another jump.
123 const match = useMemo(
124 () =>
125 entryId !== undefined && occurrence !== undefined
126 ? { entryId, runId: runId ?? null, occurrence }
127 : null,
128 [entryId, runId, occurrence],
129 );
130 const counting = remote.data?.complete === false;
131 const navigationId = localSelection?.navigationId ?? remote.navigationId;
132 const step = (delta: number) => {
133 if (count === 0 || counting) return;
134 setState((previous) => {
135 const local = rebaseLocalSelection(previous.localSelection, remote.data, remote.navigationId);
136 const pending = previous.navigationId !== (local?.navigationId ?? remote.navigationId);
137 const selected = local?.match ?? remote.data?.match;
138 const nextIndex = stepThreadFindIndex(
139 local?.activeIndex ?? remote.data?.activeIndex ?? 0,
140 count,
141 delta,
142 );
143 const entry = remote.data?.navigation?.find(
144 (entry) => nextIndex >= entry.startIndex && nextIndex < entry.startIndex + entry.count,
145 );
146 const nextNavigationId = previous.navigationId + 1;
147 if (
148 !pending &&
149 !remote.isPending &&
150 entry &&
151 remote.data &&
152 (!local ||
153 local.base === remote.data ||
154 remote.data.navigation?.some(
155 (entry) =>
156 entry.entryId === local.match.entryId && local.match.occurrence < entry.count,
157 )) &&
158 refreshTimerRef.current === null &&
159 lastContentRef.current === content
160 ) {
161 return {
162 ...previous,
163 navigationId: nextNavigationId,
164 localSelection: {
165 base: remote.data,
166 baseNavigationId: remote.navigationId,
167 activeIndex: nextIndex,
168 match: {
169 entryId: entry.entryId,
170 runId: entry.runId,
171 occurrence: nextIndex - entry.startIndex,
172 },
173 navigationId: nextNavigationId,
174 },
175 };
176 }
177 return {
178 ...previous,
179 start:
180 pending || !selected
181 ? previous.start
182 : { entryId: selected.entryId, occurrence: selected.occurrence },
183 offset: pending ? previous.offset + delta : delta,
184 navigationId: nextNavigationId,
185 requestId: nextNavigationId,
186 };
187 });
188 };
189
190 // Refresh at most once per 300 ms, even while tokens continue arriving.
191 // An identity anchor keeps newly inserted matches from shifting the selection.
192 const refreshLiveResultsRef = useRef(() => {});
193 useLayoutEffect(() => {
194 refreshLiveResultsRef.current = () => {
195 if (!isOpen || !state.query.trim()) return;
196 // Paging can change client content while the server is still counting.
197 // Coalesce those updates instead of repeatedly restarting the scan.
198 if (progressive && remote.isPending) {
199 refreshAfterSearchRef.current = true;
200 return;
201 }
202 if (!match || state.navigationId !== navigationId) {
203 remote.refresh();
204 return;
205 }
206 const start = { entryId: match.entryId, occurrence: match.occurrence };
207 if (
208 state.offset === 0 &&
209 state.start?.entryId === start.entryId &&
210 state.start.occurrence === start.occurrence &&
211 state.requestId === state.navigationId
212 ) {
213 remote.refresh();
214 } else {
215 setState((previous) => ({
216 ...previous,
217 start,
218 offset: 0,
219 requestId: previous.navigationId,
220 }));
221 }
222 };
223 });
224 useEffect(() => {
225 if (!refreshAfterSearchRef.current || remote.isPending) return;
226 refreshAfterSearchRef.current = false;
227 if (!remote.error) refreshLiveResultsRef.current();
228 }, [remote.isPending, remote.error]);
229 useEffect(() => {
230 if (lastContentRef.current === content) return;
231 lastContentRef.current = content;
232 if (!isOpen || !state.query.trim() || refreshTimerRef.current !== null) return;
233 refreshTimerRef.current = setTimeout(() => {
234 refreshTimerRef.current = null;
235 refreshLiveResultsRef.current();
236 }, 300);
237 }, [content, isOpen, state.query]);
238 useEffect(() => {
239 if (!isOpen || threadKey === null || !state.query.trim()) return;
240 return () => {
241 if (refreshTimerRef.current !== null) clearTimeout(refreshTimerRef.current);
242 refreshTimerRef.current = null;
243 refreshAfterSearchRef.current = false;
244 };
245 }, [threadKey, isOpen, state.query]);
246
247 const findExpanded = isOpen && state.query.trim().length > 0;
248 const findQuery = isOpen && remote.data?.match ? state.query : "";
249 const timelineProps = useMemo(
250 () => ({
251 findOpen: isOpen,
252 // Keep folds open between keystrokes while the next result loads.
253 findExpanded,
254 findPositionReaderRef,
255 findQuery,
256 activeFindMatch: match,
257 findNavigationId: navigationId,
258 }),
259 [isOpen, findExpanded, findQuery, match, navigationId],
260 );
261
262 return {
263 isOpen,
264 open,
265 close,
266 barProps: {
267 open: isOpen,
268 query: state.query,
269 matchCount: count,
270 counting,
271 activeIndex,
272 status,
273 focusRequestId: state.focusRequestId,
274 onRetry: remote.refresh,
275 onQueryChange: (query: string) => {
276 const start = findPositionReaderRef.current?.(query.trim());
277 setState((previous) => ({
278 ...previous,
279 query,
280 offset: 0,
281 start,
282 localSelection: null,
283 navigationId: previous.navigationId + 1,
284 requestId: previous.navigationId + 1,
285 }));
286 },
287 onNext: () => step(1),
288 onPrevious: () => step(-1),
289 onClose: close,
290 },
291 timelineProps,
292 };
293}
294
295/** A response for an older anchor can update counts without undoing newer local steps. */
296function rebaseLocalSelection(
297 selection: typeof CLOSED_FIND.localSelection,
298 data: OrchestrationV2SearchThreadResult | null,
299 navigationId: number,
300) {
301 if (!selection || !data) return null;
302 if (selection.base === data && selection.baseNavigationId === navigationId) return selection;
303 if (navigationId >= selection.navigationId) return null;
304 const entry = data.navigation?.find((entry) => entry.entryId === selection.match.entryId);
305 if (!entry || selection.match.occurrence >= entry.count) return selection;
306 const activeIndex = entry.startIndex + selection.match.occurrence;
307 return activeIndex === selection.activeIndex ? selection : { ...selection, activeIndex };
308}
309
310/** Query atoms cancel obsolete requests; navigation retains only the current query's result. */
311function useServerResults(
312 thread: ScopedThreadRef | null,
313 query: string,
314 offset: number,
315 navigationId: number,
316 start: ThreadFindStart | undefined,
317 skills: readonly InlineSkill[],
318 progressive: boolean,
319) {
320 const skillLabels = useMemo(
321 () => skills.map(({ name, displayName }) => ({ name, displayName })),
322 [skills],
323 );
324 const normalizedQuery = query.trim();
325 const debouncedQuery = useDebouncedValue(normalizedQuery, 100);
326 const environmentId = thread?.environmentId;
327 const threadId = thread?.threadId;
328 const atom = useMemo(
329 () =>
330 environmentId && threadId && debouncedQuery && normalizedQuery === debouncedQuery
331 ? (progressive
332 ? orchestrationEnvironment.threadFindProgressive
333 : orchestrationEnvironment.threadFind)({
334 environmentId,
335 input: {
336 threadId,
337 query: debouncedQuery,
338 ...(start ? { start } : {}),
339 ...(offset !== 0 ? { offset } : {}),
340 skills: skillLabels,
341 },
342 })
343 : null,
344 [
345 environmentId,
346 threadId,
347 debouncedQuery,
348 normalizedQuery,
349 start,
350 offset,
351 skillLabels,
352 progressive,
353 ],
354 );
355 const result = useEnvironmentQuery(atom);
356 const key = thread
357 ? JSON.stringify([thread.environmentId, thread.threadId, normalizedQuery, skillLabels])
358 : null;
359 const [previous, setPrevious] = useState({
360 key,
361 data: result.data,
362 navigationId,
363 });
364 if (
365 previous.key !== key ||
366 (result.data !== null &&
367 (!result.isPending || (progressive && previous.data !== result.data)) &&
368 (previous.data !== result.data || previous.navigationId !== navigationId))
369 ) {
370 const response = result.data;
371 setPrevious({
372 key,
373 data: response,
374 navigationId,
375 });
376 }
377 return {
378 ...result,
379 data: key === previous.key && !result.error ? previous.data : null,
380 navigationId: previous.navigationId,
381 isPending:
382 thread !== null &&
383 normalizedQuery.length > 0 &&
384 (normalizedQuery !== debouncedQuery || result.isPending),
385 };
386}