"use client"; /** * Global search dialog (⌘K, TASK-0098) — the topbar `search-button` opens it; * it shares ONE result model with the /search page (lib/search.ts + * components/search/results-list.tsx). * * Pinned e2e contract (tests/e2e/specs/search.spec.ts — do not rename): * search-button topbar (layout/topbar.tsx) * search-dialog this overlay (role="dialog" aria-modal) * search-input the query input (auto-focused) * search-result a result ROW (literal — no {id} suffix) * search-result-type-{t} the type badge on a row * search-filter-type the type-filter dropdown (All + the 7 types) * search-filter-option-{t} a filter option (e.g. page / database) * search-recent the "Recent searches" section (empty input) * search-recent-item a recent-search chip * search-suggestion a live suggestion row (title + type badge) * * Recency: the server (Redis) is the single source of truth. Every completed * result fetch fires PUT /search/recent immediately (fire-and-forget, * throttled to 1 s) so the e2e's "close dialog → reopen → chip present" * holds even though the dialog closes right after the first result appears. */ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Bookmark, BookmarkPlus, Clock, Database as DatabaseIcon, FileText, Filter, History, ListTodo, Loader2, Search, SearchX, ShieldAlert, Square, X } from "lucide-react"; import { searchApi, meApi, pageApi, databaseApi, isApiError } from "@/lib/api"; import { SEARCH_DEBOUNCE_MS, SEARCH_TYPES, SEARCH_TYPE_LABELS, addRecent, isRecordableQuery, deepLinkFor, RECENT_VIEWED_CAP, recentItemHref, savedSearchLabel, type RecentViewItem, type SavedSearch, type SavedSearchQuery } from "@/lib/search"; import { useWorkspace } from "@/hooks/use-workspace"; import { useUiStore } from "@/stores/ui-store"; import { ResultsList, SEARCH_TYPE_ICON } from "./results-list"; import type { SearchResult, SearchSuggestion, SearchType } from "@/lib/types"; type FilterValue = "all" | SearchType; const FILTER_OPTIONS: { value: FilterValue; label: string }[] = [ { value: "all", label: "All" }, ...SEARCH_TYPES.map((t) => ({ value: t as FilterValue, label: SEARCH_TYPE_LABELS[t] })) ]; export function SearchDialog() { const open = useUiStore((s) => s.searchOpen); const setOpen = useUiStore((s) => s.setSearchDialog); const router = useRouter(); const workspaceSlug = useUiStore((s) => s.searchWorkspaceSlug); const { data: workspace, isError: workspaceError } = useWorkspace(workspaceSlug); const [input, setInput] = useState(""); const [q, setQ] = useState(""); const [filter, setFilter] = useState("all"); const [filterMenuOpen, setFilterMenuOpen] = useState(false); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [suggestions, setSuggestions] = useState([]); const [recent, setRecent] = useState([]); // T-153/T-154 — recently-VIEWED content + saved searches (the dialog's // empty-input "home"). Fetched on open, like the recent searches above. const [recentViewed, setRecentViewed] = useState([]); const [viewTitles, setViewTitles] = useState>({}); const [savedSearches, setSavedSearches] = useState([]); // Bumped to schedule the one-shot empty-result retry (indexing lag). const [retryTick, setRetryTick] = useState(0); const inputRef = useRef(null); const dialogRef = useRef(null); const filterMenuRef = useRef(null); const lastRecordAt = useRef(0); const reqRef = useRef(0); const retryRef = useRef<{ key: string; count: number } | null>(null); /* ------------------------------------------------------------ reset */ // Reset the dialog state each time it opens. useEffect(() => { if (open) { setInput(""); setQ(""); setFilter("all"); setFilterMenuOpen(false); setResults([]); setSuggestions([]); setError(null); setLoading(false); setViewTitles({}); requestAnimationFrame(() => inputRef.current?.focus()); } }, [open]); /* ------------------------- recents + recently-viewed + saved searches */ useEffect(() => { if (!open) return; let cancelled = false; // Recent searches (the pinned `search-recent` section). searchApi .recent() .then((list) => { if (!cancelled) setRecent(list); }) .catch(() => { /* recents are decorative — never break the dialog */ }); // T-153 — recently-VIEWED content (a separate store from the searches). meApi .recentViewed() .then((items) => { if (!cancelled) setRecentViewed(items.slice(0, RECENT_VIEWED_CAP)); }) .catch(() => { /* decorative */ }); // T-154 — saved searches (per-user CRUD, the chips under the home view). meApi .listSavedSearches() .then((list) => { if (!cancelled) setSavedSearches(list); }) .catch(() => { /* decorative */ }); return () => { cancelled = true; }; }, [open]); // Resolve the titles of the shown recent-viewed items in parallel (page vs // database route); a failed lookup leaves that chip's label blank (the icon // still shows the kind). Keyed on recentViewed so it re-runs on refresh. useEffect(() => { if (recentViewed.length === 0) return; let cancelled = false; void Promise.all( recentViewed.map(async (it): Promise<[string, string]> => { try { let t: string; if (it.itemType === "database") { // GET /databases/:id returns the NESTED body // `{ database, properties, views, … }` (the web flattens it in // use-database.ts's `flattenDatabase`); databaseApi.get types it // as the flat row, so read the title from either shape. const db = (await databaseApi.get(it.refId)) as { title?: string; database?: { title?: string }; }; t = db.title ?? db.database?.title ?? ""; } else { t = (await pageApi.get(it.refId)).title; } return [it.refId, t]; } catch { return [it.refId, ""]; } }) ).then((pairs) => { if (!cancelled) setViewTitles(Object.fromEntries(pairs)); }); return () => { cancelled = true; }; }, [recentViewed]); /* ---------------------------------------------------- debounce 225 ms */ useEffect(() => { const t = setTimeout(() => setQ(input.trim()), SEARCH_DEBOUNCE_MS); return () => clearTimeout(t); }, [input]); /* ----------------------------------------------- fetch (query+suggest) */ useEffect(() => { if (!open) return; if (!q) { setResults([]); setSuggestions([]); setLoading(false); setError(null); return; } const my = (reqRef.current += 1); const types = filter === "all" ? undefined : [filter]; const controller = new AbortController(); setLoading(true); setError(null); // One-shot retry after an EMPTY result: the indexer can lag a write by a // second or two, so the e2e's "result within 5 s" gets a grace re-query // (800 ms) instead of a premature "No results". Only an empty FIRST // answer triggers it (a non-empty or late answer wins, as usual). const retryKey = `${q}|${filter}|${workspace?.id ?? ""}`; const retries = retryRef.current?.key === retryKey ? retryRef.current.count : 0; searchApi .query(q, { workspaceId: workspace?.id, limit: 20, types }, controller.signal) .then((items) => { if (my !== reqRef.current) return; // Server-side type filter is the contract; the client-side pass is a // no-op fallback guaranteeing the filter's count assertions even if // the query route ignores the extra `types` param (the pinned GET // signature documents q/workspaceId/limit/cursor only). const scoped = types ? items.filter((i) => types.includes(i.type)) : items; setResults(scoped); if (items.length === 0 && retries === 0) { retryRef.current = { key: retryKey, count: retries + 1 }; setTimeout(() => { if (reqRef.current === my) setRetryTick((t) => t + 1); }, 800); } // Record the search IMMEDIATELY (fire-and-forget, 1 s throttle) — the // e2e closes the dialog right after the first result and expects the // chip on reopen. `addRecent` mirrors the server's dedupe/cap so the // local list never drifts. if (isRecordableQuery(q)) { const now = Date.now(); if (now - lastRecordAt.current >= 1000) { lastRecordAt.current = now; setRecent((r) => addRecent(r, q)); void searchApi.recordRecent(q).catch(() => undefined); } } }) .catch((e: unknown) => { if (my !== reqRef.current) return; if (e instanceof DOMException && e.name === "AbortError") return; setError(toErrorKind(e)); }) .finally(() => { if (my === reqRef.current) setLoading(false); }); // Suggestions: title completions (e2e: ≥ 2 rows for a shared unique // prefix while typing the bare token). searchApi .suggest(q, 8, controller.signal) .then((items) => { if (my === reqRef.current) setSuggestions(items); }) .catch(() => undefined); return () => { controller.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [q, filter, open, workspace?.id, retryTick]); /* --------------------------------------------------------- close nav */ const close = useCallback(() => { setOpen(false); setFilterMenuOpen(false); // Restore focus to the trigger (the topbar's search-button). requestAnimationFrame(() => { document.querySelector('[data-testid="search-button"]')?.focus(); }); }, [setOpen]); // Escape closes (window-level, like the shared Dialog). A window-level // listener (not a React onKeyDown) is deliberate: it also fires while the // filter menu is open — that first Escape closes the MENU, the next one // closes the dialog (the e2e's "Escape + reopen" always lands on a closed // dialog). useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; e.stopPropagation(); if (filterMenuOpen) setFilterMenuOpen(false); else close(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, close, filterMenuOpen]); // Click outside the filter menu closes it. useEffect(() => { if (!filterMenuOpen) return; const onClick = (e: MouseEvent) => { if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) { setFilterMenuOpen(false); } }; document.addEventListener("mousedown", onClick); return () => document.removeEventListener("mousedown", onClick); }, [filterMenuOpen]); // Focus trap while open (simple but effective: Tab wraps inside the panel; // the input is the default stop). useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Tab") return; const panel = dialogRef.current; if (!panel) return; const focusables = panel.querySelectorAll( 'button, input, [tabindex]:not([tabindex="-1"])' ); if (focusables.length === 0) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; if (!first || !last) return; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open]); /* --------------------------------------------------------- activate */ const activate = useCallback( (item: SearchResult) => { const slug = workspace?.slug ?? workspaceSlug; const link = deepLinkFor(item, slug); if (!link) return; // no-op row (e.g. a block without a parent page) close(); router.push(link); }, [workspace, workspaceSlug, close, router] ); const activateSuggestion = useCallback( (s: SearchSuggestion) => { setInput(s.title); setQ(s.title.trim()); setSuggestions([]); inputRef.current?.focus(); }, [] ); const activateRecent = useCallback( (query: string) => { setInput(query); setQ(query.trim()); setSuggestions([]); inputRef.current?.focus(); }, [] ); /* ------------------------- T-153/T-154: recent-viewed + saved searches */ const activateRecentViewed = useCallback( (item: RecentViewItem) => { const slug = workspace?.slug ?? workspaceSlug; const link = recentItemHref(item, slug); if (!link) return; close(); router.push(link); }, [workspace, workspaceSlug, close, router] ); // A saved search's stored queryJson → the input (its `q`). The saved // `workspaceId` is intentionally NOT re-applied: the dialog always scopes to // the CURRENT workspace (the user opened the dialog from that workspace's // topbar), which matches how a fresh query behaves. const applySavedSearch = useCallback((s: SavedSearch) => { const q = (s.queryJson?.q as string) ?? ""; setInput(q); setQ(q.trim()); setSuggestions([]); inputRef.current?.focus(); }, []); const saveCurrentSearch = useCallback(async () => { const query = q.trim(); if (!isRecordableQuery(query)) return; try { await meApi.createSavedSearch({ name: savedSearchLabel({ q: query }), workspaceId: workspace?.id ?? null, query: { q: query, ...(filter !== "all" ? { types: [filter] } : {}), ...(workspace?.id ? { workspaceId: workspace.id } : {}) } satisfies SavedSearchQuery }); // Refresh to the server's ordering (newest first). const fresh = await meApi.listSavedSearches().catch(() => null); if (fresh) setSavedSearches(fresh); } catch { /* a failed save must not break the dialog */ } }, [q, filter, workspace?.id]); const deleteSavedSearch = useCallback(async (id: string) => { try { await meApi.deleteSavedSearch(id); setSavedSearches((list) => list.filter((s) => s.id !== id)); } catch { /* best effort */ } }, []); const showRecent = open && q.length === 0 && !loading && error === null; // The dialog still opens without a resolved workspace (slug never synced, // or the workspace 404'd) — queries then run unscoped; show a hint. const noWorkspace = !workspace?.id && (workspaceSlug.length === 0 || workspaceError); // T-153/T-154 — the empty-input "home" shows every section that has content; // with none at all it falls back to the original "No recent searches" hint // (the `search-recent` section's empty state — pinned, unchanged). const homeHasContent = recentViewed.length > 0 || savedSearches.length > 0 || recent.length > 0; /* ------------------------------------------------------------- view */ if (!open) return null; return (
{/* Input + filter */}
{ setInput(e.target.value); setFilterMenuOpen(false); }} placeholder="Search pages, databases, records…" className="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" role="combobox" aria-expanded="true" aria-controls="search-dialog-results" aria-label="Search" /> {/* T-154 — save the current query as a chip (shown once a query has been committed; the ⌘K home lists it under Saved searches). */} {q.trim() ? ( ) : null} {/* Type filter (pinned: search-filter-type / search-filter-option-{t}) */}
{filterMenuOpen ? (
{FILTER_OPTIONS.map((opt) => ( ))}
) : null}
{/* Body */}
{noWorkspace ? (

No workspace resolved yet — results may be limited.

) : null} {error !== null ? ( ) : showRecent ? ( <> {/* T-153 — recently-VIEWED content (separate from searches). */} {recentViewed.length > 0 ? (

Recently viewed

    {recentViewed.map((item) => { const Icon = item.itemType === "database" ? DatabaseIcon : item.itemType === "whiteboard" ? Square : item.itemType === "task" ? ListTodo : FileText; return (
  • ); })}
) : null} {/* T-154 — saved searches (per-user; the ⌘K home chips). */} {savedSearches.length > 0 ? (

Saved searches

    {savedSearches.map((s) => (
  • ))}
) : null} {/* Recent searches (pinned `search-recent` — unchanged). */} ) : ( <> {/* Suggestions (live while typing) */} {suggestions.length > 0 ? (

Suggestions

    {suggestions.map((s) => { const SIcon = SEARCH_TYPE_ICON[s.type] ?? Search; return (
  • ); })}
) : null} {/* Loading */} {loading ? (

Searching…

) : null} {/* Empty */} {!loading && results.length === 0 && (

No results for “{q}”.

)} {/* Results (shared model with the /search page) */} {results.length > 0 ? ( ) : null} )}
{/* Footer hint */}
esc to close ↵ to open
); } /* -------------------------------------------------------------- states */ type ErrorKind = null | "unavailable" | "generic"; function toErrorKind(e: unknown): ErrorKind { if (isApiError(e)) { if (e.code === "SEARCH_UNAVAILABLE" || e.status === 503) return "unavailable"; return "generic"; } return "generic"; } function SearchErrorState({ kind }: { kind: Exclude }) { if (kind === "unavailable") { return (

Search is temporarily unavailable — try again.

Keep typing to retry — your query is preserved.

); } return (

Search failed.

Check your connection and keep typing to retry.

); } /* ---------------------------------------------------------- recents */ function RecentSection({ items, onSelect }: { items: string[]; onSelect: (q: string) => void }) { if (items.length === 0) { return (

No recent searches yet — start typing to find pages, databases and more.

); } return (

Recent searches

    {items.map((item) => (
  • ))}
); }