import { useCallback, useEffect, useRef, useState } from "react"; import { ChevronDownIcon, ChevronUpIcon, SearchIcon, XIcon } from "lucide-react"; import type { TimelineSearchTarget } from "../../../session-ui"; import { useAppContext } from "../../app-context"; import { Button } from "../../../ui/button"; import { Input } from "../../../ui/input"; import { useConversationSearch, useCommittedSearchQuery, type ConversationSearchMatch, type ConversationSearchPage, } from "../../lib/use-conversation-search"; import { requestSessionSearch, type SessionSearchRoute } from "../../lib/session-search-route"; import { useUiEventTarget } from "../../lib/ui-events"; export default function ConversationFind(props: { workspaceId: string; sessionId: string; open: boolean; focusRevision: number; initial: SessionSearchRoute; showBackToSessionSearch: boolean; onClose: () => void; onTarget: (target: TimelineSearchTarget | null) => void; onJump: (sequence: number, options?: { signal?: AbortSignal }) => Promise; }) { const windowEvents = useUiEventTarget("window"); const { onJump, onTarget } = props; const { client, accessContext } = useAppContext(); const [query, setQuery] = useState(props.initial.find ?? ""); const scope = JSON.stringify([accessContext.subjectId, props.workspaceId, props.sessionId]); const committedQuery = useCommittedSearchQuery(query, scope, props.open); const [index, setIndex] = useState(0); const desired = useRef<{ sequence: number; offset: number; query: string } | null>(null); const [seeking, setSeeking] = useState(false); const [navigationError, setNavigationError] = useState(null); const [navigating, setNavigating] = useState(false); const input = useRef(null); const navigationLifetime = useRef<{ generation: number; controller: AbortController | null }>({ generation: 0, controller: null, }); const handledPage = useRef(null); const pageLanding = useRef<"first" | "last">("first"); const search = useConversationSearch({ client, authority: accessContext.subjectId, workspaceId: props.workspaceId, sessionId: props.sessionId, query: committedQuery, debounceMs: 0, enabled: props.open, }); const matches = search.page?.matches ?? []; const page = search.page; const nextPage = search.next; const choose = useCallback( async (target: TimelineSearchTarget) => { const generation = ++navigationLifetime.current.generation; navigationLifetime.current.controller?.abort(); const controller = new AbortController(); navigationLifetime.current.controller = controller; setNavigationError(null); setNavigating(true); try { const retained = await onJump(target.sequence, { signal: controller.signal }); if (generation !== navigationLifetime.current.generation) return; if (retained) onTarget(target); else setNavigationError("This passage could not be loaded. Try the match again."); } catch { if (generation === navigationLifetime.current.generation) setNavigationError("This passage could not be loaded. Try the match again."); } finally { if (generation === navigationLifetime.current.generation) setNavigating(false); } }, [onJump, onTarget], ); const selectMatch = useCallback( (match: ConversationSearchMatch) => { void choose({ sequence: match.sequence, eventId: match.eventId, query: committedQuery, offset: match.messageMatchOffset, }); }, [choose, committedQuery], ); useEffect(() => { if (!props.open) { ++navigationLifetime.current.generation; navigationLifetime.current.controller?.abort(); setNavigating(false); onTarget(null); return; } input.current?.focus(); input.current?.select(); }, [props.open, props.focusRevision, onTarget]); useEffect(() => { const lifetime = navigationLifetime.current; return () => { ++lifetime.generation; lifetime.controller?.abort(); }; }, []); useEffect(() => { if (!props.initial.find) return; setQuery(props.initial.find); if (props.initial.matchSequence) { const target = { sequence: props.initial.matchSequence, offset: props.initial.matchOffset ?? 0, }; desired.current = { ...target, query: props.initial.find }; handledPage.current = null; setSeeking(true); void choose({ ...target, query: props.initial.find }); } }, [props.initial.find, props.initial.matchSequence, props.initial.matchOffset, choose]); useEffect(() => { if (!props.open || search.loading || !page || handledPage.current === page) return; const pageMatches = page.matches; const requested = desired.current; if (requested && requested.query !== committedQuery) return; handledPage.current = page; if (requested) { const found = pageMatches.findIndex( (match) => match.sequence === requested.sequence && match.messageMatchOffset === requested.offset, ); if (found >= 0) { setIndex(found); desired.current = null; setSeeking(false); selectMatch(pageMatches[found]!); } else if (page.hasMore) nextPage(); else { desired.current = null; setSeeking(false); setNavigationError("The original match is no longer in the search results."); } return; } const landing = pageLanding.current === "last" ? Math.max(0, pageMatches.length - 1) : 0; pageLanding.current = "first"; setIndex(landing); if (pageMatches[landing]) selectMatch(pageMatches[landing]); }, [ page, search.loading, props.open, committedQuery, selectMatch, nextPage, props.initial.find, props.initial.matchSequence, props.initial.matchOffset, ]); const lastIdentity = useRef({ scope, query: committedQuery, client }); useEffect(() => { const sameAuthority = lastIdentity.current.scope === scope && lastIdentity.current.client === client; if (sameAuthority && lastIdentity.current.query === committedQuery && !search.accessDenied) return; lastIdentity.current = { scope, query: committedQuery, client }; ++navigationLifetime.current.generation; navigationLifetime.current.controller?.abort(); setNavigating(false); // A replacement route installs its exact occurrence before the query // debounce commits. Preserve that pending target for this query, but never // carry it across authority changes, denial, or an unrelated draft commit. if (!sameAuthority || search.accessDenied || desired.current?.query !== committedQuery) { desired.current = null; } setSeeking(desired.current !== null); handledPage.current = null; setNavigationError(null); setIndex(0); onTarget(null); }, [scope, committedQuery, client, search.accessDenied, onTarget]); function move(direction: -1 | 1) { if (!matches.length || search.loading) return; const next = index + direction; if (next >= 0 && next < matches.length) { setIndex(next); selectMatch(matches[next]!); } else if (direction === 1 && search.page?.hasMore) { pageLanding.current = "first"; search.next(); } else if (direction === -1 && search.pageIndex > 0) { pageLanding.current = "last"; search.previous(); } } if (!props.open) return null; const total = search.page?.matchedOccurrenceCount ?? matches.length; const ordinal = total - matches.length + index + 1; const counter = search.loading || seeking ? "Searching…" : navigating ? "Loading…" : matches.length ? `${ordinal} / ${total}${search.page?.countIsExact ? "" : "+"}` : search.error ? "Unavailable" : committedQuery.trim() ? "No matches" : ""; return (
{/* One row while open, so the conversation never shifts under it. The back link is the only second line, and it lasts the bar's lifetime. */} {props.showBackToSessionSearch ? (
) : null} {search.error ? (
{search.error}
) : null} {navigationError ? (
{navigationError}
) : null}
); }