import { ArrowLeftIcon, ArrowRightIcon, ChevronDownIcon, ChevronUpIcon, Loader2Icon, SearchIcon, } from "lucide-react"; import { useLayoutEffect, useRef, type KeyboardEvent, type RefObject } from "react"; import { MarkdownText } from "../markdown"; import { Button } from "../../../ui/button"; import { SearchMarkdown } from "./search-markdown-highlight"; import { SearchText } from "./search-text"; import { cn } from "../../lib/utils"; /** Hidden/unmounted dialog panes must not overwrite the last visible position. */ function useRetainedScroll( element: RefObject, position: RefObject | undefined, ready: boolean, revision: unknown, identity: string, ) { const restoration = useRef<{ node: HTMLDivElement; position: RefObject; identity: string; savedPosition: number; restored: boolean; } | null>(null); useLayoutEffect(() => { const node = element.current; if (!ready || !node || !position) { restoration.current = null; return; } // New batches may render between the browser's scroll and its asynchronous // event. Do not replay a stale saved position over an already-live pane. // A new query, remount, or explicit caller reset starts a fresh restoration. if ( restoration.current?.node !== node || restoration.current.position !== position || restoration.current.identity !== identity || restoration.current.savedPosition !== position.current ) restoration.current = { node, position, identity, savedPosition: position.current, restored: false, }; const current = restoration.current; if (current.restored) return; const desired = position.current; let frame = 0; const restore = () => { if (current.restored || node.clientHeight === 0) return; // Revalidation/hidden mobile panes may not have enough layout yet. if (node.scrollHeight - node.clientHeight < desired) return; node.scrollTop = desired; current.restored = true; }; restore(); frame = requestAnimationFrame(restore); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(restore); observer?.observe(node); return () => { cancelAnimationFrame(frame); observer?.disconnect(); }; }, [element, position, ready, revision, identity]); return (node: HTMLDivElement) => { if (ready && restoration.current?.restored && node.clientHeight > 0 && position) { position.current = node.scrollTop; restoration.current.savedPosition = node.scrollTop; } }; } export type SearchResultSummary = { sessionId: string; title: string; subtitle: string; snippet: string; matchingMessages: number; titleMatch: boolean; }; export function SearchResultsView(props: { query: string; results: SearchResultSummary[]; selectedId: string | null; onSelect: (id: string) => void; loading: boolean; error: string | null; onRetry: () => void; hasMore: boolean; onMore: () => void; scrollPosition?: RefObject | undefined; active?: boolean | undefined; }) { const list = useRef(null); const saveScroll = useRetainedScroll( list, props.scrollPosition, // Title results are interactive while the independent message scan runs. // Loading only prevents persistence when it has replaced the actual rows. props.active !== false && !!props.query.trim() && props.results.length > 0, props.results, props.query, ); function onKeyDown(event: KeyboardEvent) { if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; if (!props.results.length) return; event.preventDefault(); const current = props.results.findIndex((result) => result.sessionId === props.selectedId); const next = Math.max( 0, Math.min(props.results.length - 1, current + (event.key === "ArrowDown" ? 1 : -1)), ); props.onSelect(props.results[next]!.sessionId); list.current?.querySelectorAll("[data-search-result]")[next]?.focus(); } return (
{ saveScroll(event.currentTarget); }} > {props.query.trim() && props.error ? (
{props.error}
) : null} {!props.query.trim() ? ( Search session titles and messages across this workspace. ) : props.loading && props.results.length === 0 ? ( ) : props.results.length === 0 && props.error ? null : props.results.length === 0 ? ( ) : ( <>
{props.results.length} matching sessions{props.hasMore ? ", more available" : ""}
{props.results.map((result) => ( ))} {props.hasMore ? (
) : null} )}
); } export type SearchPreviewMessage = { key: string; role: "user" | "assistant"; text: string; selected: boolean; formatted?: boolean | undefined; snippet?: string | undefined; offset?: number | undefined; }; export function SearchPreviewView(props: { title: string; query: string; messages: SearchPreviewMessage[]; loading: boolean; error: string | null; onRetry: () => void; onOpen: () => void; onBack: () => void; onPrevious: () => void; onNext: () => void; previousDisabled: boolean; nextDisabled: boolean; counter: string; titleOnly: boolean; scrollPosition?: RefObject | undefined; active?: boolean | undefined; }) { const body = useRef(null); const saveScroll = useRetainedScroll( body, props.scrollPosition, props.active !== false && props.messages.length > 0, props.messages, props.query, ); return (

{props.title}

{ saveScroll(event.currentTarget); }} className="min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain px-5 py-4" > {props.error ? (
{props.error}
) : null} {props.loading && props.messages.length === 0 ? ( Loading context… ) : props.titleOnly && !props.error ? ( This session title matches. No matching message was found. ) : ( props.messages.map((message) => (

{message.role === "user" ? "User" : "Assistant"} {message.selected ? " · Matching passage" : ""}

{message.formatted ? (
{message.selected ? ( ) : ( )}
) : (

)}
)) )}
{!props.titleOnly ? (
{props.counter}
) : null}
); } function SearchMessage({ children }: { children: React.ReactNode }) { return (
{children}
); }