| 1 | import type { InlineSkill } from "@t3tools/shared/inlineSkills"; |
| 2 | import type { |
| 3 | OrchestrationV2ThreadProjection, |
| 4 | OrchestrationV2SearchThreadResult, |
| 5 | ScopedThreadRef, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 8 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 9 | import { orchestrationEnvironment } from "~/state/orchestration"; |
| 10 | import { useEnvironmentQuery } from "~/state/query"; |
| 11 | import { useDebouncedValue } from "~/state/queries"; |
| 12 | import { |
| 13 | type ThreadFindPositionReader, |
| 14 | type ThreadFindStart, |
| 15 | stepThreadFindIndex, |
| 16 | } from "./threadFind"; |
| 17 | import { subscribeThreadFindOpen } from "./threadFindActionBus"; |
| 18 | import { toastManager } from "../ui/toast"; |
| 19 | |
| 20 | const EMPTY_SKILLS: readonly InlineSkill[] = []; |
| 21 | |
| 22 | const 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. */ |
| 40 | export 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. */ |
| 296 | function 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. */ |
| 311 | function 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 | } |