import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useNavigate } from "../../lib/navigation"; import { SearchIcon } from "lucide-react"; import { OpenGeniApiError } from "@opengeni/sdk/browser"; import { useAppContext } from "../../app-context"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../../../ui/dialog"; import { Input } from "../../../ui/input"; import { Button } from "../../../ui/button"; import { useCommittedSearchQuery, useConversationSearch, type ConversationSearchMatch, } from "../../lib/use-conversation-search"; import { useSessionSearchResource } from "../../lib/use-session-search-resource"; import { cn } from "../../lib/utils"; import { selectedFormattedMessage } from "./search-markdown-highlight"; import { SearchPreviewView, SearchResultsView, type SearchPreviewMessage, type SearchResultSummary, } from "./search-results-view"; export default function SessionSearchDialog(props: { workspaceId: string; open: boolean; onOpenChange: (open: boolean) => void; returnFocus?: RefObject | undefined; /** Start with this query (the ⌘K menu's "Search messages for …"). */ seed?: { query: string; nonce: number } | null | undefined; }) { const { onOpenChange } = props; const { client, accessContext } = useAppContext(); const navigate = useNavigate(); const [query, setQuery] = useState(props.seed?.query ?? ""); const seedNonce = props.seed?.nonce; const seedQuery = props.seed?.query; const appliedSeed = useRef(seedNonce); useEffect(() => { if (seedNonce === undefined || appliedSeed.current === seedNonce || seedQuery === undefined) return; appliedSeed.current = seedNonce; setQuery(seedQuery); }, [seedNonce, seedQuery]); const [archiveStatus, setArchiveStatus] = useState<"active" | "archived" | "all">("all"); const [selectedId, setSelectedId] = useState(null); const [mobilePreview, setMobilePreview] = useState(false); const [titleCursor, setTitleCursor] = useState(); const [previewIndex, setPreviewIndex] = useState(0); const openRef = useRef(props.open); openRef.current = props.open; const restoreFocusOnClose = useRef(true); useEffect(() => { if (props.open) restoreFocusOnClose.current = true; }, [props.open]); const resultScroll = useRef(0); const previewScroll = useRef(0); const scope = JSON.stringify([accessContext.subjectId, props.workspaceId, archiveStatus]); const committedQuery = useCommittedSearchQuery(query, scope, props.open); const identity = JSON.stringify([ accessContext.subjectId, props.workspaceId, committedQuery, archiveStatus, ]); const search = useConversationSearch({ client, authority: accessContext.subjectId, workspaceId: props.workspaceId, query: committedQuery, debounceMs: 0, enabled: props.open, archiveStatus, }); const loadTitles = useCallback( (signal: AbortSignal) => client.listSessionPage(props.workspaceId, { search: committedQuery, signal, archiveStatus, limit: 20, ...(titleCursor ? { cursor: titleCursor } : {}), }), [client, props.workspaceId, committedQuery, archiveStatus, titleCursor], ); const titles = useSessionSearchResource( `${identity}:${titleCursor ?? ""}`, loadTitles, props.open && !!committedQuery.trim(), 0, ); const [deniedIdentity, setDeniedIdentity] = useState(null); const [recoveringAccess, setRecoveringAccess] = useState(false); const accessDenied = search.accessDenied || titles.accessDenied || deniedIdentity === identity; const results = useMemo(() => { if (accessDenied || !committedQuery.trim()) return []; const grouped = new Map(); for (const session of [...(titles.value?.pinned ?? []), ...(titles.value?.sessions ?? [])]) { grouped.set(session.id, { sessionId: session.id, title: session.title || "Untitled session", subtitle: new Date(session.updatedAt).toLocaleDateString(), snippet: session.initialMessage ?? "", matchingMessages: 0, titleMatch: true, }); } const seen = new Set(); for (const match of search.page?.matches ?? []) { const current = grouped.get(match.sessionId); const firstForMessage = !seen.has(match.eventId); seen.add(match.eventId); grouped.set(match.sessionId, { sessionId: match.sessionId, title: match.sessionTitle || "Untitled session", subtitle: current?.subtitle ?? "Message match", snippet: current?.matchingMessages ? current.snippet : match.snippet.text, matchingMessages: (current?.matchingMessages ?? 0) + (firstForMessage ? 1 : 0), titleMatch: current?.titleMatch ?? false, }); } return [...grouped.values()]; }, [titles.value, search.page, accessDenied, committedQuery]); // Keep the selected session while closed/revalidating so its cursor and preview // survive the dialog → conversation → dialog round trip. const retainedSelection = useRef<{ identity: string; selected: SearchResultSummary; } | null>(null); if (accessDenied || !committedQuery.trim()) retainedSelection.current = null; const selected = results.find((result) => result.sessionId === selectedId) ?? (retainedSelection.current?.identity === identity && retainedSelection.current.selected.sessionId === selectedId ? retainedSelection.current.selected : null) ?? results[0] ?? null; if (selected) retainedSelection.current = { identity, selected }; const previewSelection = selected ?? (retainedSelection.current?.identity === identity ? retainedSelection.current.selected : null); const selectedSearch = useConversationSearch({ client, authority: accessContext.subjectId, workspaceId: props.workspaceId, sessionId: previewSelection?.sessionId, query: committedQuery, debounceMs: 0, enabled: props.open && !!previewSelection, }); useEffect(() => { setTitleCursor(undefined); setSelectedId(null); setMobilePreview(false); setPreviewIndex(0); resultScroll.current = 0; previewScroll.current = 0; }, [identity]); useEffect(() => { if (search.accessDenied || titles.accessDenied) { retainedSelection.current = null; setSelectedId(null); setDeniedIdentity(identity); } }, [search.accessDenied, titles.accessDenied, identity]); useEffect(() => { // An authority failure invalidates every source. Do not reveal another // source's retained value while Retry is still rechecking live access. if (recoveringAccess && !search.loading && !titles.loading && !search.error && !titles.error) { setDeniedIdentity(null); setRecoveringAccess(false); } }, [recoveringAccess, search.loading, titles.loading, search.error, titles.error]); function select(id: string) { if (id !== selectedId) { setPreviewIndex(0); previewScroll.current = 0; } setSelectedId(id); setMobilePreview(true); } const onOpen = useCallback( (match?: ConversationSearchMatch) => { if (!previewSelection || accessDenied) return; // Result navigation owns the next focus target. Dismissal returns to // the opener; opening a conversation must not steal focus back later. restoreFocusOnClose.current = false; onOpenChange(false); void navigate({ to: "/workspaces/$workspaceId/sessions/$sessionId", params: { workspaceId: props.workspaceId, sessionId: previewSelection.sessionId, }, search: match ? { find: committedQuery, matchSequence: match.sequence, matchOffset: match.messageMatchOffset, searchOrigin: "session-search", } : { find: committedQuery, searchOrigin: "session-search" }, }); }, [navigate, props.workspaceId, onOpenChange, previewSelection, committedQuery, accessDenied], ); return ( { // This controlled dialog is mounted separately from its rail opener, // so Radix has no DialogTrigger ref to restore. A reopened dialog or // a result navigation owns focus independently of this retiring scope. event.preventDefault(); if (openRef.current || !restoreFocusOnClose.current) return; const target = props.returnFocus?.current; if (target?.isConnected) target.focus({ preventScroll: true }); }} >
Search sessions
{query !== committedQuery ? (

Showing results for “{committedQuery}”

) : null}
Literal text in user and completed assistant messages.
{ if (search.error || accessDenied) search.retry(); if (titles.error || accessDenied) titles.retry(); if (accessDenied) setRecoveringAccess(true); }} hasMore={!!search.page?.hasMore} onMore={() => { resultScroll.current = 0; search.next(); }} scrollPosition={resultScroll} active={props.open} />
{search.pageIndex > 0 ? ( ) : null} {titles.value?.nextCursor ? ( ) : null} {search.loading ? ( Searching saved history {search.scanned ? ` · ${search.scanned} messages visited` : "…"} ) : search.page?.hasMore ? ( More history available ) : null}
{previewSelection ? ( setDeniedIdentity(identity)} enabled={props.open} onOpen={onOpen} onBack={() => setMobilePreview(false)} search={selectedSearch} index={previewIndex} setIndex={setPreviewIndex} scrollPosition={previewScroll} /> ) : (
Select a result to read it in context.
)}
); } type SessionSearchPreviewProps = { client: ReturnType["client"]; authority: string; workspaceId: string; sessionId: string; title: string; query: string; enabled: boolean; onOpen: (match?: ConversationSearchMatch) => void; onBack: () => void; search: ReturnType; index: number; setIndex: (index: number) => void; scrollPosition: RefObject; onAccessDenied?: (() => void) | undefined; }; export function SessionSearchPreview(props: SessionSearchPreviewProps) { const { search, setIndex, onAccessDenied } = props; const matches = search.page?.matches ?? []; // -1 means land on the last occurrence when a previous batch arrives. const index = props.index < 0 ? Math.max(0, matches.length - 1) : props.index; const match = matches[Math.min(index, Math.max(0, matches.length - 1))]; const loadContext = useCallback( async (signal: AbortSignal): Promise => { if (!match) return []; const options = { signal, mode: "forensic" as const, // Bound transfer even for legacy events whose retained payload is enormous. payloadMode: "summary" as const, includeTypes: ["user.message", "agent.message.completed"] as Array< "user.message" | "agent.message.completed" >, limit: 2, }; const [before, after, selectedPreview] = await Promise.all([ props.client.listEvents(props.workspaceId, props.sessionId, { ...options, before: match.sequence, direction: "before", }), props.client.listEvents(props.workspaceId, props.sessionId, { ...options, after: match.sequence, direction: "after", }), props.client .getSessionMessagePreview( props.workspaceId, props.sessionId, { eventId: match.eventId, sequence: match.sequence }, { signal }, ) .catch((error: unknown) => { // A stale result can disappear after the search read. Keep its // authoritative excerpt; do not swallow access or transport errors. if (error instanceof OpenGeniApiError && error.status === 404) return { status: "unavailable" as const }; throw error; }), ]); const selectedText = selectedFormattedMessage(selectedPreview, match, props.query); const context = async (events: typeof before): Promise => { const messages = await Promise.all( events.map(async (event): Promise => { if (event.type !== "user.message" && event.type !== "agent.message.completed") return null; const payload = event.payload as Record; if ( match.messageId && event.type === "agent.message.completed" && payload.messageId === match.messageId && event.turnId === match.turnId ) return null; // Summary projections do not carry the payload codec version. Even // short context text may be an undecoded lossless storage marker. const preview = await props.client .getSessionMessagePreview( props.workspaceId, props.sessionId, { eventId: event.id, sequence: event.sequence }, { signal }, ) .catch((error: unknown) => { if (error instanceof OpenGeniApiError && error.status === 404) return { status: "unavailable" as const }; throw error; }); if (preview.status !== "available") return null; let text = preview.text; if (text.length > 1800) { const end = /[\uD800-\uDBFF]/.test(text[1799]!) ? 1799 : 1800; text = `${text.slice(0, end)}…`; } return { key: event.id, role: event.type === "user.message" ? "user" : "assistant", text, selected: false, formatted: preview.text.length <= 1800, }; }), ); return messages.filter((message): message is SearchPreviewMessage => message !== null); }; const [preceding, following] = await Promise.all([context(before), context(after)]); return [ ...preceding, { key: match.eventId, role: match.role, text: selectedText ?? match.snippet.text, selected: true, formatted: selectedText !== null, snippet: match.snippet.text, offset: match.messageMatchOffset, }, ...following, ]; }, [props.client, props.workspaceId, props.sessionId, props.query, match], ); const preview = useSessionSearchResource( `${props.authority}:${props.workspaceId}:${props.sessionId}:${props.query}:${match?.eventId}:${match?.messageMatchOffset}`, loadContext, props.enabled && !!match, 100, ); useEffect(() => { if (preview.accessDenied || search.accessDenied) onAccessDenied?.(); }, [preview.accessDenied, search.accessDenied, onAccessDenied]); if (preview.accessDenied || search.accessDenied) return
Search access is unavailable.
; const titleOnly = !!search.page && !search.page.hasMore && !matches.length; return ( { if (search.error) search.retry(); if (preview.error) preview.retry(); }} onOpen={() => props.onOpen(match)} onBack={props.onBack} titleOnly={titleOnly} scrollPosition={props.scrollPosition} counter={ matches.length ? `Match ${(search.page?.matchedOccurrenceCount ?? matches.length) - matches.length + index + 1} of ${search.page?.matchedOccurrenceCount ?? matches.length}${search.page?.hasMore ? "+" : ""}` : search.loading ? "Searching saved history…" : search.error ? "Search unavailable" : "No matching messages" } previousDisabled={index === 0 && search.pageIndex === 0} nextDisabled={!search.page?.hasMore && index >= matches.length - 1} onPrevious={() => { props.scrollPosition.current = 0; if (index > 0) setIndex(index - 1); else { setIndex(-1); search.previous(); } }} onNext={() => { props.scrollPosition.current = 0; if (index < matches.length - 1) setIndex(index + 1); else { setIndex(0); search.next(); } }} /> ); }