import { recordResearchActivity } from "../../../api-client/research-activity"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button, ConfirmDialog, DataTableStackView, PaneStatusBody, QueryBar, Spinner, Tabs, useExternalLinkFooter, usePaneHeaderTabs, useTableLoadMore, type DataTableCell, type DataTableKeyEvent, type DataTableRootKeyContext, type PaneFooterSegment, type PaneHint, } from "../../../components"; import { TickerBadgeList } from "../../../components/ticker/badge/list"; import { useShortcut } from "../../../react/input"; import { useInlineTickerOpener } from "../../../state/hooks/inline-tickers"; import { colors } from "../../../theme/colors"; import { Box, type InputRenderable, type ScrollBoxRenderable } from "../../../ui"; import { useDialog, type PromptContext } from "../../../ui/dialog"; import { isPlainKey } from "../../../utils/keyboard"; import { isPlainArrowUp, stopSearchFocusNavigation } from "../../../utils/search-focus-navigation"; import { usePaneSettingValue } from "../../../state/app/context"; import { usePluginPaneState } from "../../runtime"; import type { PaneProps } from "../../../types/plugin"; import type { CloudSavedSearch, CloudSearchDocType, CloudSearchDocument, CloudSearchHit, CloudSearchSort, } from "../../../api-client"; import { SignInWall } from "../cloud/auth-actions"; import { useCloudPlanAction } from "../shared/cloud-upgrade"; import { usePlanAccess } from "../shared/plan-access"; import { createSavedSearch, deleteSavedSearch, errorMessage, isAbortError, loadSavedSearches, loadSearchDocument, runDocumentSearch, statusOf, updateSavedSearch, } from "./data"; import { useDocumentFocusRequest } from "./focus-handoff"; import { SearchDocumentView } from "./document-view"; import { SavedSearchesView } from "./saved-view"; import { appendUniqueHits, buildResultColumns, buildSearchParams, DEFAULT_FILTERS, DOC_TYPE_OPTIONS, filtersFromSaved, filtersToSaved, formatHitDate, hitMatchCountLabel, hitTypeLabel, parseTickerFilter, RANGE_OPTIONS, RESEARCH_SEARCH_PANE_ID, savedSearchName, SORT_OPTIONS, type SearchColumn, type SearchFilters, type SearchRangeKey, } from "./model"; import { parseMarkedSnippet, snippetPlainText, truncateSegments } from "./snippet"; import { SnippetText } from "./snippet-text"; const QUERY_DEBOUNCE_MS = 300; const TICKER_FIELD_WIDTH = 22; const MODE_TABS = [ { label: "Results", value: "results" }, { label: "Saved", value: "saved" }, ]; type PaneMode = "results" | "saved"; type LoadStatus = "idle" | "loading" | "loaded" | "error"; type ActiveField = "query" | "tickers" | null; interface RequestFailure { message: string; status?: number; } export function ResearchSearchPane({ focused, paneId, width, height }: PaneProps) { const access = usePlanAccess(); const openPlan = useCloudPlanAction(); const dialog = useDialog(); const openTicker = useInlineTickerOpener(); const [seedQuery] = usePaneSettingValue("query", ""); const [mode, setMode] = usePluginPaneState("mode", "results"); const [query, setQuery] = usePluginPaneState("query", String(seedQuery ?? "").trim()); const [filters, setFilters] = usePluginPaneState("filters", DEFAULT_FILTERS); const [hits, setHits] = useState([]); const [status, setStatus] = useState("idle"); const [failure, setFailure] = useState(null); const [hasMore, setHasMore] = useState(false); const [nextOffset, setNextOffset] = useState(0); const [loadingMore, setLoadingMore] = useState(false); // The selected row and the open document are kept by hit id, so a reload or // a shared layout comes back to the same document. const [selectedHitId, setSelectedHitId] = usePluginPaneState("selectedHitId", null); const [openHitId, setOpenHitId] = usePluginPaneState("openHitId", null); const [document, setDocument] = useState(null); const [documentLoading, setDocumentLoading] = useState(false); const [documentFailure, setDocumentFailure] = useState(null); const [saved, setSaved] = useState([]); const [savedStatus, setSavedStatus] = useState("idle"); const [savedFailure, setSavedFailure] = useState(null); const [savedSelectedId, setSavedSelectedId] = usePluginPaneState("savedSelectedId", null); const [savedBusy, setSavedBusy] = useState(false); const [activeField, setActiveField] = useState(null); const [fieldFocusToken, setFieldFocusToken] = useState(0); const [typePickerOpen, setTypePickerOpen] = useState(false); const queryInputRef = useRef(null); const tickerInputRef = useRef(null); const tableScrollRef = useRef(null); const searchAbortRef = useRef(null); const moreAbortRef = useRef(null); const trimmedQuery = query.trim(); const openHit = useMemo( () => (openHitId ? hits.find((hit) => hit.id === openHitId) ?? null : null), [hits, openHitId], ); const focusField = useCallback((field: Exclude) => { setActiveField(field); setFieldFocusToken((current) => current + 1); }, []); const blurField = useCallback(() => setActiveField(null), []); const runSearch = useCallback(() => { searchAbortRef.current?.abort(); moreAbortRef.current?.abort(); if (!trimmedQuery || !access.emailVerified) { searchAbortRef.current = null; setHits([]); setStatus("idle"); setFailure(null); setHasMore(false); return; } const controller = new AbortController(); searchAbortRef.current = controller; setStatus("loading"); setFailure(null); void runDocumentSearch(buildSearchParams(trimmedQuery, filters), controller.signal) .then((response) => { // A newer query already took over; this answer is for text nobody is reading. if (searchAbortRef.current !== controller) return; setHits(response.hits ?? []); setHasMore(response.hasMore === true); setNextOffset(response.nextOffset ?? (response.hits?.length ?? 0)); setStatus("loaded"); }) .catch((error: unknown) => { if (searchAbortRef.current !== controller || isAbortError(error)) return; setHits([]); setHasMore(false); setFailure({ message: errorMessage(error), status: statusOf(error) }); setStatus("error"); }); }, [access.emailVerified, filters, trimmedQuery]); useEffect(() => { runSearch(); return () => { searchAbortRef.current?.abort(); searchAbortRef.current = null; moreAbortRef.current?.abort(); moreAbortRef.current = null; }; }, [runSearch]); const loadMore = useCallback(() => { if (loadingMore || !hasMore || status !== "loaded" || !trimmedQuery) return; moreAbortRef.current?.abort(); const controller = new AbortController(); moreAbortRef.current = controller; setLoadingMore(true); void runDocumentSearch( buildSearchParams(trimmedQuery, filters, { offset: nextOffset }), controller.signal, ) .then((response) => { if (moreAbortRef.current !== controller) return; setHits((current) => appendUniqueHits(current, response.hits ?? [])); setHasMore(response.hasMore === true); setNextOffset(response.nextOffset ?? nextOffset + (response.hits?.length ?? 0)); }) .catch((error: unknown) => { if (moreAbortRef.current !== controller || isAbortError(error)) return; setFailure({ message: errorMessage(error), status: statusOf(error) }); }) .finally(() => { if (moreAbortRef.current === controller) setLoadingMore(false); }); }, [filters, hasMore, loadingMore, nextOffset, status, trimmedQuery]); const loadMoreFromScroll = useTableLoadMore( tableScrollRef, hasMore && !loadingMore && status === "loaded", loadMore, ); useEffect(() => { if (!openHit) { setDocument(null); setDocumentFailure(null); return; } const controller = new AbortController(); setDocumentLoading(true); setDocumentFailure(null); setDocument(null); void loadSearchDocument(openHit.docType, openHit.sourceId, controller.signal) .then((loaded) => { if (controller.signal.aborted) return; setDocument(loaded); }) .catch((error: unknown) => { if (controller.signal.aborted || isAbortError(error)) return; setDocumentFailure({ message: errorMessage(error), status: statusOf(error) }); }) .finally(() => { if (!controller.signal.aborted) setDocumentLoading(false); }); return () => controller.abort(); }, [openHit]); const refreshSaved = useCallback(() => { if (!access.emailVerified) return; setSavedStatus((current) => (current === "loaded" ? current : "loading")); setSavedFailure(null); void loadSavedSearches() .then((searches) => { setSaved(searches); // Alerts and delete act on the cursor row, so the list must start on one. setSavedSelectedId((current) => ( current && searches.some((search) => search.id === current) ? current : searches[0]?.id ?? null )); setSavedStatus("loaded"); }) .catch((error: unknown) => { setSavedFailure({ message: errorMessage(error), status: statusOf(error) }); setSavedStatus("error"); }); }, [access.emailVerified]); useEffect(() => { if (mode !== "saved") return; refreshSaved(); }, [mode, refreshSaved]); const saveCurrentSearch = useCallback(() => { if (!trimmedQuery || savedBusy) return; setSavedBusy(true); void createSavedSearch({ name: savedSearchName(trimmedQuery, filters), query: trimmedQuery, filters: filtersToSaved(filters), }) .then((search) => { setSaved((current) => [search, ...current.filter((entry) => entry.id !== search.id)]); setSavedSelectedId(search.id); setSavedStatus("loaded"); setSavedFailure(null); setMode("saved"); }) .catch((error: unknown) => { setSavedFailure({ message: errorMessage(error), status: statusOf(error) }); }) .finally(() => setSavedBusy(false)); }, [filters, savedBusy, setMode, trimmedQuery]); const toggleAlert = useCallback((search: CloudSavedSearch) => { const next = !search.alertEnabled; // Optimistic: the switch has to answer the click, and a failure puts it back. setSaved((current) => current.map((entry) => ( entry.id === search.id ? { ...entry, alertEnabled: next } : entry ))); void updateSavedSearch(search.id, { alertEnabled: next }) .then((updated) => { setSaved((current) => current.map((entry) => (entry.id === updated.id ? updated : entry))); setSavedFailure(null); }) .catch((error: unknown) => { setSaved((current) => current.map((entry) => ( entry.id === search.id ? { ...entry, alertEnabled: search.alertEnabled } : entry ))); setSavedFailure({ message: errorMessage(error), status: statusOf(error) }); }); }, []); const removeSaved = useCallback(async (search: CloudSavedSearch) => { const confirmed = await dialog.prompt({ closeOnClickOutside: true, content: (context: PromptContext) => ( line.length > 0)} confirmLabel="Delete" width={44} /> ), }).catch(() => false); if (confirmed !== true) return; const previous = saved; setSaved((current) => current.filter((entry) => entry.id !== search.id)); try { await deleteSavedSearch(search.id); setSavedFailure(null); } catch (error) { setSaved(previous); setSavedFailure({ message: errorMessage(error), status: statusOf(error) }); } }, [dialog, saved]); const runSavedSearch = useCallback((search: CloudSavedSearch) => { setQuery(search.query); setFilters(filtersFromSaved(search)); setSelectedHitId(null); setMode("results"); }, [setFilters, setMode, setQuery, setSelectedHitId]); const columns = useMemo(() => buildResultColumns(width), [width]); const renderCell = useCallback(( hit: CloudSearchHit, column: SearchColumn, _index: number, rowState: { selected: boolean }, ): DataTableCell => { const selectedColor = rowState.selected ? colors.selectedText : undefined; switch (column.id) { case "ticker": return { text: hit.ticker, color: selectedColor ?? colors.textBright, content: ( ), }; case "type": return { text: hitTypeLabel(hit), color: selectedColor ?? colors.textMuted }; case "date": return { text: formatHitDate(hit.publishedAt), color: selectedColor ?? colors.textDim }; case "title": return { text: hit.title, color: selectedColor ?? colors.text }; case "match": { // The count leads so collapsing chunks into one row stays visible even // where the snippet behind it is cut off. const count = hitMatchCountLabel(hit); const segments = truncateSegments( [...(count ? [{ text: count, marked: false }] : []), ...parseMarkedSnippet(hit.snippet)], column.width, ); return { text: `${count}${snippetPlainText(hit.snippet)}`, content: ( ), }; } } }, []); const closeDetail = useCallback(() => setOpenHitId(null), [setOpenHitId]); // Opened straight onto a hit from the command bar, ahead of its own results. const focusRequestedHit = useCallback((hit: CloudSearchHit) => { setSelectedHitId(hit.id); setOpenHitId(hit.id); }, [setOpenHitId, setSelectedHitId]); useDocumentFocusRequest(paneId, focusRequestedHit); const handleRootKeyDown = useCallback(( event: DataTableKeyEvent, context: DataTableRootKeyContext, ) => { if (context.selectedIndex <= 0 && isPlainArrowUp(event)) { stopSearchFocusNavigation(event); focusField("query"); return true; } if (isPlainKey(event, "/")) { stopSearchFocusNavigation(event); focusField("query"); return true; } // Ctrl+S and [t]icker are footer hints: the footer binds them across the // pane, Ctrl+S also while a field has focus. return false; }, [focusField]); // Tab and Shift+Tab move between the query and the tickers field. The app's // pane cycle never sees Tab from a field, so the ring only has to claim it // from the webview's own focus order. useShortcut((event) => { if (event.name !== "tab" || event.ctrl || event.meta || event.alt || event.super) return; event.preventDefault(); event.stopPropagation(); focusField(activeField === "query" ? "tickers" : "query"); }, { allowEditable: true, enabled: focused && activeField !== null && !openHit && mode === "results", scope: "research-search:fields", }); // Search is free and uncapped, so nothing is gated up front: the upsell only // appears if the server itself refuses the query. const proRequired = failure?.status === 402; useEffect(() => { if (focused && status === "loaded") recordResearchActivity("research_viewed", "search"); }, [focused, status]); const signInRequired = !access.signedIn || failure?.status === 401 || savedFailure?.status === 401; const verificationRequired = !signInRequired && (!access.emailVerified || failure?.status === 403); const footerInfo = useMemo(() => { const info: PaneFooterSegment[] = []; if (status === "loading" || documentLoading || savedStatus === "loading") { info.push({ id: "loading", parts: [{ text: "loading", tone: "muted" }] }); } if (loadingMore) { info.push({ id: "loading-more", parts: [{ text: "loading more", tone: "muted" }] }); } if (proRequired) { info.push({ id: "pro", parts: [{ text: "pro required", tone: "warning" }] }); } // The results table and the document view each show their own failure, so // only a status token belongs here. Saved-search writes have nowhere else. if (failure && status === "error") { info.push({ id: "error", parts: [{ text: "error", tone: "warning" }] }); } if (savedFailure) { info.push({ id: "saved-error", parts: [{ text: savedFailure.message, tone: "warning" }] }); } return info; }, [ documentLoading, failure, loadingMore, proRequired, savedFailure, savedStatus, status, ]); // The ticker a badge click would open: the open document's, else the cursor // row's (the table's cursor rests on the first row until one is chosen). const hintTicker = mode === "results" ? (openHit ?? hits.find((hit) => hit.id === selectedHitId) ?? hits[0])?.ticker || null : null; const footerHints = useMemo(() => { // A wall or upsell replaces the results, so nothing there can be saved or opened. if (signInRequired || verificationRequired || proRequired) return []; if (mode === "saved") { const selected = saved.find((entry) => entry.id === savedSelectedId); if (!selected) return []; return [ { id: "alert", key: "a", label: "lerts", onPress: () => toggleAlert(selected) }, { id: "delete", key: "d", label: "elete", onPress: () => { void removeSaved(selected); } }, ]; } return [ ...(hintTicker ? [{ id: "ticker", key: "t", label: "icker", onPress: () => openTicker(hintTicker) }] : []), ...(!openHit && trimmedQuery ? [{ id: "save", key: "Ctrl+S", label: "save search", onPress: saveCurrentSearch }] : []), ]; }, [ hintTicker, mode, openHit, openTicker, proRequired, removeSaved, saved, savedSelectedId, saveCurrentSearch, signInRequired, toggleAlert, trimmedQuery, verificationRequired, ]); // The stack title already names the open document, so the footer carries what // the title cannot: which company, when, and what kind. Not how long: a // section count is the kind of number the reader scrolls past anyway. const documentIdentity = useMemo(() => { if (!openHit) return null; return [ formatHitDate(openHit.publishedAt), // The type leads the footer when the document has no ticker, so repeating // it here would say the same thing twice on one row. openHit.ticker ? hitTypeLabel(openHit) : null, ].filter((part): part is string => !!part).join(" ยท "); }, [openHit]); useExternalLinkFooter({ registrationId: RESEARCH_SEARCH_PANE_ID, focused, url: openHit?.url ?? null, source: documentIdentity, // Never a fixed word: the row is led by whichever of these the document // actually has, because a footer is for what changes as you move between // documents, not for saying "document". label: openHit ? (openHit.ticker || hitTypeLabel(openHit)) : "", info: footerInfo, hints: footerHints, showHint: !!openHit?.url, }); const modeTabs = MODE_TABS; // Every tab leads to the same wall, so the strip waits until it is gone. const tabsInHeader = usePaneHeaderTabs(signInRequired || verificationRequired ? null : { tabs: modeTabs, activeValue: mode, onSelect: (value) => setMode(value as PaneMode), focused: focused && !openHit && activeField === null && !typePickerOpen, }); const tabRows = tabsInHeader ? 0 : 1; if (signInRequired || verificationRequired) { return ( ); } const tabs = tabsInHeader ? null : ( setMode(value as PaneMode)} focused={focused && !openHit && activeField === null && !typePickerOpen} /> ); if (mode === "saved") { return ( {tabs} { void removeSaved(search); }} focused={focused} width={width} height={Math.max(1, height - tabRows)} emptyTitle={savedStatus === "loading" ? "Loading saved searches..." : "Run a search, then press Ctrl+S to save it and get keyword alerts."} /> ); } if (proRequired) { return ( {tabs} } /> ); } const queryBar = ( active ? focusField("query") : blurField(), focusToken: fieldFocusToken, inputRef: queryInputRef, debounceMs: QUERY_DEBOUNCE_MS, onNavigateDown: blurField, }} filters={[ { id: "tickers", kind: "text", label: "Tickers", value: filters.tickers.join(" "), onChange: (value: string) => setFilters({ ...filters, tickers: parseTickerFilter(value) }), placeholder: "any", focused: focused && !openHit && !typePickerOpen, active: activeField === "tickers", onActiveChange: (active: boolean) => active ? focusField("tickers") : blurField(), focusToken: fieldFocusToken, inputRef: tickerInputRef, debounceMs: QUERY_DEBOUNCE_MS, width: TICKER_FIELD_WIDTH, }, { id: "types", kind: "multi", label: "Types", title: "Document types", values: filters.docTypes, options: DOC_TYPE_OPTIONS, emptyLabel: "All", onOpenChange: setTypePickerOpen, onChange: (values: string[]) => setFilters({ ...filters, docTypes: values as CloudSearchDocType[] }), }, { id: "range", label: "Date", inline: true, value: filters.range === "custom" ? "all" : filters.range, defaultValue: "all", options: RANGE_OPTIONS, onChange: (value: string) => setFilters({ ...filters, range: value as SearchRangeKey, // Presets own the window from here, so stored bounds must not linger. from: undefined, to: undefined, }), }, ]} view={{ value: filters.sort, options: SORT_OPTIONS, onChange: (value: string) => setFilters({ ...filters, sort: value as CloudSearchSort }), }} /> ); const emptyTitle = !trimmedQuery ? "Type a query to search transcripts, news, and filings." : failure ? "Search failed." : "No documents matched."; return ( {tabs} focused={focused && activeField === null && !typePickerOpen} detailOpen={!!openHit} onBack={closeDetail} detailTitle={openHit?.title} detailContent={openHit ? ( ) : ( )} rootBefore={queryBar} onRootKeyDown={handleRootKeyDown} rootWidth={width} rootHeight={Math.max(1, height - tabRows)} scrollRef={tableScrollRef} onBodyScrollActivity={loadMoreFromScroll} resetScrollKey={`${trimmedQuery}:${filters.sort}:${filters.range}:${filters.tickers.join(",")}:${filters.docTypes.join(",")}`} columns={columns} items={hits} selection={{ kind: "id", selectedId: selectedHitId, getId: (hit) => hit.id, onChange: setSelectedHitId, }} onActivate={(hit) => { blurField(); setOpenHitId(hit.id); }} sortColumnId={filters.sort === "relevance" ? "match" : "date"} sortDirection={filters.sort === "oldest" ? "asc" : "desc"} onHeaderClick={(columnId) => { if (columnId === "match") { setFilters({ ...filters, sort: "relevance" }); return; } if (columnId !== "date") return; setFilters({ ...filters, sort: filters.sort === "newest" ? "oldest" : "newest" }); }} getItemKey={(hit) => hit.id} renderCell={renderCell} showHorizontalScrollbar={false} emptyContent={status === "loading" && hits.length === 0 ? : undefined} emptyStateTitle={emptyTitle} emptyStateHint={failure?.message} /> ); }