diff --git a/src/app/api/search/universal/route.ts b/src/app/api/search/universal/route.ts index 366847a5e..ef4b0541b 100644 --- a/src/app/api/search/universal/route.ts +++ b/src/app/api/search/universal/route.ts @@ -19,6 +19,7 @@ import { type UniversalSearchResponse, } from "@/lib/universal-search"; import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; +import { appModeIds } from "@/lib/app-modes"; export const runtime = "nodejs"; @@ -33,6 +34,7 @@ export const runtime = "nodejs"; const universalSearchQuerySchema = z.object({ q: z.string().trim().min(2).max(200), limit: queryInteger({ fallback: 5, min: 1, max: 10 }), + mode: z.enum(appModeIds).optional(), domains: z .string() .trim() @@ -64,10 +66,20 @@ function universalResponse( export async function GET(request: Request) { try { - const { q, limit, domains } = parseRequestQuery(request, universalSearchQuerySchema, "Invalid universal query."); + const { q, limit, domains, mode } = parseRequestQuery( + request, + universalSearchQuerySchema, + "Invalid universal query.", + ); if (isDemoMode() || isLocalNoAuthMode()) { - const payload = await runUniversalSearch({ query: q, limitPerDomain: limit, domains, demo: true }); + const payload = await runUniversalSearch({ + query: q, + limitPerDomain: limit, + domains, + contextMode: mode, + demo: true, + }); return universalResponse({ ...payload, demoMode: true }); } @@ -91,6 +103,7 @@ export async function GET(request: Request) { query: q, limitPerDomain: limit, domains, + contextMode: mode, supabase, ownerId: access.ownerId, demo: false, diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2f851be2a..a3be64c25 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -80,6 +80,7 @@ import { } from "@/components/clinical-dashboard/answer-progress"; import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-map-model"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; +import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { @@ -3019,6 +3020,11 @@ export function ClinicalDashboard({ activeModeResultKind === "answer" && answerProgressEvents.length > 0 && (loading || (Boolean(answer) && answerProgressCompleted)); + const universalAlsoMatchesQuery = activeModeResultKind === "answer" ? (latestAnswerQuery ?? query) : query; + const showUniversalAlsoMatches = + (modeSearchSubmitted || activeModeResultKind === "tools" || activeModeResultKind === "favourites") && + Boolean(universalAlsoMatchesQuery.trim()) && + (activeModeResultKind === "answer" || activeModeResultKind === "tools" || activeModeResultKind === "favourites"); const showDesktopHomeComposer = !error && (activeModeResultKind === "tools" || @@ -3586,6 +3592,10 @@ export function ClinicalDashboard({ ) : null)} + {showUniversalAlsoMatches && activeModeResultKind !== "answer" ? ( + + ) : null} + {activeModeResultKind === "differentials" ? ( ) : null} + + {showUniversalAlsoMatches && activeModeResultKind === "answer" ? ( + + ) : null} {showSystemNotice && answer ? renderSystemNotice("sm:hidden") : null} diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index ed5906316..018b94cf5 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -25,6 +25,7 @@ import { import { ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; +import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { useDifferentialSearch } from "@/components/clinical-dashboard/use-differential-catalog"; import { useResultSort } from "@/components/use-result-sort"; import { cn } from "@/components/ui-primitives"; @@ -882,6 +883,7 @@ function SearchResultsView({ sortValue={sortValue} onSortChange={setSortValue} /> +

{trimmedQuery && !shouldShowHome ? ( -

- -
+ <> +
+ +
+ + ) : null} {recordMatchCount > 0 || matches.length > 0 || diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 3c85e179e..a680df022 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -45,6 +45,7 @@ import { useSearchCommand } from "@/components/clinical-dashboard/search-command import { favouriteMatchesCommandScopes } from "@/lib/search-command-surface"; import { appModeIcons } from "@/lib/app-mode-icons"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; type FavouriteType = "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form" | "Differential"; @@ -1006,6 +1007,7 @@ export function FavouritesCommandLibraryPage({ query = "" }: { query?: string })
+ +

Medication search

diff --git a/src/components/clinical-dashboard/universal-search-also-matches.tsx b/src/components/clinical-dashboard/universal-search-also-matches.tsx new file mode 100644 index 000000000..3234005e6 --- /dev/null +++ b/src/components/clinical-dashboard/universal-search-also-matches.tsx @@ -0,0 +1,108 @@ +"use client"; + +import Link from "next/link"; + +import { useUniversalSearch } from "@/components/clinical-dashboard/use-universal-search"; +import { cn } from "@/components/ui-primitives"; +import { appModeDefinition, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; +import { appModeIcons } from "@/lib/app-mode-icons"; +import { universalSearchModeForDomain } from "@/lib/universal-search-mode-context"; + +export function UniversalSearchAlsoMatches({ + modeId, + query, + className, +}: { + modeId: AppModeId; + query: string; + className?: string; +}) { + const trimmedQuery = query.trim(); + const universal = useUniversalSearch({ + query: trimmedQuery, + enabled: trimmedQuery.length >= 2, + contextMode: modeId, + limitPerDomain: 2, + }); + const preferred = new Set(universal.preferredDomains ?? []); + const groups = (() => { + const groupByDomain = new Map(universal.groups.map((group) => [group.kind, group])); + const orderedGroups = (universal.domainOrder ?? universal.groups.map((group) => group.kind)) + .map((domain) => groupByDomain.get(domain)) + .filter((group): group is NonNullable => + Boolean(group && !preferred.has(group.kind) && group.items.length > 0), + ); + const byMode = new Map< + AppModeId, + { modeId: AppModeId; items: Array<(typeof universal.groups)[number]["items"][number]> } + >(); + + for (const group of orderedGroups) { + const targetModeId = universalSearchModeForDomain(group.kind); + if (targetModeId === modeId) continue; + const modeGroup = byMode.get(targetModeId) ?? { modeId: targetModeId, items: [] }; + for (const item of group.items) { + if (modeGroup.items.length >= 2) break; + if (!modeGroup.items.some((existing) => existing.href === item.href)) modeGroup.items.push(item); + } + byMode.set(targetModeId, modeGroup); + } + + return [...byMode.values()].filter((group) => group.items.length > 0).slice(0, 4); + })(); + + if (universal.query !== trimmedQuery || groups.length === 0) return null; + + return ( +
+
+

Also matches in other modes

+ Across Clinical KB +
+
+ {groups.map((group) => { + const targetModeId = group.modeId; + const targetMode = appModeDefinition(targetModeId); + const TargetIcon = appModeIcons[targetModeId]; + return ( +
+ + + + + + {targetMode.label} + + {group.items.map((item) => ( + + {item.title} + + ))} + + + View all + +
+ ); + })} +
+
+ ); +} diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 429e79c17..ffda3c92f 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -1,6 +1,6 @@ "use client"; -import { TriangleAlert, Clock, CornerDownLeft, Loader2, Search, Sparkles } from "lucide-react"; +import { TriangleAlert, Clock, CornerDownLeft, Heart, Loader2, Search, Sparkles } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useId, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react"; @@ -11,6 +11,12 @@ import { } from "@/components/clinical-dashboard/mode-action-popup"; import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; import { useUniversalSearch } from "@/components/clinical-dashboard/use-universal-search"; +import { + favouriteItems, + favouriteSets, + type FavouriteItem, +} from "@/components/clinical-dashboard/favourites-prototype-data"; +import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-saved-registry-favourites"; import { cn } from "@/components/ui-primitives"; import { appModeDefinition, type AppModeId } from "@/lib/app-modes"; import { appModeIcons } from "@/lib/app-mode-icons"; @@ -21,32 +27,25 @@ import { searchCommandSurfaceConfig, } from "@/lib/search-command-surface"; import type { UniversalSearchDomain } from "@/lib/universal-search"; +import { universalSearchModeForDomain } from "@/lib/universal-search-mode-context"; -// Reverse of modeIdByDomain for chip counts: the domains whose live result totals a -// cross-mode chip should sum. Answer/favourites chips have no countable domain; the +// Domains whose live result totals a cross-mode chip should sum. Answer/favourites +// chips have no countable domain; the // differentials chip counts both of its domains because the mode home search composes // presentations and diagnoses into one result list. const domainsByTargetMode: Partial> = { documents: ["documents"], + // Prescribing prefers both medication records and source documents, but its + // destination workspace lists medication rows; the shortcut count must match + // those visible rows rather than summing source-document hits. prescribing: ["medications"], services: ["services"], forms: ["forms"], differentials: ["differentials", "presentations"], + formulation: ["specifiers"], tools: ["tools"], }; -const modeIdByDomain: Record = { - documents: "documents", - medications: "prescribing", - services: "services", - forms: "forms", - differentials: "differentials", - // Presentations are the differentials mode's umbrella pages — no app mode of their own, - // so the group borrows the differentials icon and "View all in Differentials" target. - presentations: "differentials", - tools: "tools", -}; - const domainHeadings: Record = { documents: "Documents", medications: "Medications", @@ -54,9 +53,63 @@ const domainHeadings: Record = { forms: "Forms", differentials: "Differentials", presentations: "Presentations", + specifiers: "Formulation", tools: "Tools", }; +type LocalFavouriteMatch = { + id: string; + title: string; + subtitle?: string; + href: string; + standalone: boolean; + score: number; +}; + +function rankLocalFavourites(items: FavouriteItem[], query: string): LocalFavouriteMatch[] { + const normalized = query.trim().toLowerCase(); + if (!normalized) return []; + const tokens = normalized.split(/\s+/).filter(Boolean); + const byKey = new Map(); + + for (const item of items) { + const title = item.title.toLowerCase(); + const text = [item.title, item.meta, item.sourceMeta, item.set, item.keywords].join(" ").toLowerCase(); + if (!tokens.every((token) => text.includes(token))) continue; + const score = title.includes(normalized) + ? 100 + : tokens.reduce((sum, token) => sum + (title.includes(token) ? 10 : 2), 0); + const key = item.href || item.id; + const match: LocalFavouriteMatch = { + id: item.id, + title: item.title, + subtitle: item.meta, + href: item.href, + // Saved searches are standalone Favourites artifacts; saved canonical entities + // already surface through their owning universal domain outside Favourites mode. + standalone: item.primaryAction === "Run", + score, + }; + if ((byKey.get(key)?.score ?? -1) < score) byKey.set(key, match); + } + + for (const set of favouriteSets) { + const text = [set.title, set.meta, set.keywords].join(" ").toLowerCase(); + if (!tokens.every((token) => text.includes(token))) continue; + const score = set.title.toLowerCase().includes(normalized) ? 100 : 8; + byKey.set(`set:${set.id}`, { + id: `set:${set.id}`, + title: set.title, + subtitle: `${set.count} saved ${set.count === 1 ? "item" : "items"} · ${set.meta}`, + href: `/favourites?q=${encodeURIComponent(set.title)}&run=1`, + standalone: true, + score, + }); + } + + return [...byKey.values()].sort((left, right) => right.score - left.score || left.title.localeCompare(right.title)); +} + const SMART_HINT_ROTATION_MS = 3200; type DropdownItem = { @@ -163,9 +216,7 @@ function CommandDropdown({ // template, so without it the section headings inherit text-center. "universal-command-dropdown absolute left-0 right-0 z-30 overflow-hidden rounded-2xl border border-[color:var(--border-strong)] bg-[color:var(--surface)] text-left shadow-[var(--shadow-elevated)]", opensUpward ? "bottom-[calc(100%+0.5rem)] top-auto" : "top-[calc(100%+0.5rem)]", - // Phones never get the typeahead popup — it crowds the small screen — - // so both placements stay hidden below their smallest useful width. - placement === "bottom-dock" ? "hidden sm:block" : "hidden lg:block", + "block max-sm:rounded-xl", )} role="presentation" > @@ -190,7 +241,10 @@ function CommandDropdown({ id={listboxId} role="listbox" aria-label={`${mode.label} search suggestions`} - className={cn("overflow-y-auto p-2", opensUpward ? "max-h-[min(38dvh,20rem)]" : "max-h-[min(42dvh,24rem)]")} + className={cn( + "max-h-[min(55dvh,26rem)] overflow-y-auto overscroll-contain p-2", + opensUpward ? "sm:max-h-[min(38dvh,20rem)]" : "sm:max-h-[min(42dvh,24rem)]", + )} > {sections.map((section) => section.items.length ? ( @@ -322,34 +376,20 @@ export function UniversalSearchCommandSurface({ const [activeIndex, setActiveIndex] = useState(-1); const trimmedQuery = query.trim(); const mode = appModeDefinition(modeId); - // The dropdown is CSS-hidden below sm (bottom-dock) / lg (inline), so skip the - // typeahead fetches at widths where nothing could display the results. - const dropdownMediaQuery = placement === "bottom-dock" ? "(min-width: 640px)" : "(min-width: 1024px)"; - // Initialise from the real viewport. The mode-home composer can be moved into - // its portal while the input is receiving focus; starting every fresh mount - // at false loses that focus event and leaves the desktop popup closed. - const [dropdownDisplayable, setDropdownDisplayable] = useState( - () => typeof window !== "undefined" && window.matchMedia(dropdownMediaQuery).matches, - ); - useEffect(() => { - const mediaQuery = window.matchMedia(dropdownMediaQuery); - const sync = () => { - setDropdownDisplayable(mediaQuery.matches); - if (!mediaQuery.matches) { - onDropdownOpenChange(false); - setActiveIndex(-1); - } - }; - sync(); - mediaQuery.addEventListener("change", sync); - return () => mediaQuery.removeEventListener("change", sync); - }, [dropdownMediaQuery, onDropdownOpenChange]); // A true "everything" view: the active mode's own domain is included (no excludeDomain) so // the palette surfaces every entity type, ordered by the server's intent-aware domainOrder. const universal = useUniversalSearch({ query: trimmedQuery, - enabled: dropdownOpen && dropdownDisplayable && Boolean(config), + enabled: dropdownOpen && Boolean(config), + contextMode: modeId, }); + const savedRegistryFavourites = useSavedRegistryFavourites(); + const allFavouriteItems = useMemo(() => [...favouriteItems, ...savedRegistryFavourites], [savedRegistryFavourites]); + const favouriteMatches = useMemo( + () => rankLocalFavourites(allFavouriteItems, trimmedQuery), + [allFavouriteItems, trimmedQuery], + ); + const savedHrefs = useMemo(() => new Set(allFavouriteItems.map((item) => item.href)), [allFavouriteItems]); const showSafetyBanner = modeId === "differentials" && differentialRedFlagTerms.some((term) => trimmedQuery.toLowerCase().includes(term)); @@ -361,6 +401,7 @@ export function UniversalSearchCommandSurface({ domainOrder: universalDomainOrder, topHit: universalTopHit, answerAction: universalAnswerAction, + preferredDomains: universalPreferredDomains = [], } = universal; // Render the cross-entity groups in the server's intent-aware order (drug query → medications @@ -396,8 +437,10 @@ export function UniversalSearchCommandSurface({ // Best-bet: a single near-exact match pinned to the top so the strongest hit is one keystroke // away regardless of which domain it lives in. - if (trimmedQuery && universalQuery === trimmedQuery && universalTopHit) { - const HitIcon = appModeIcons[modeIdByDomain[universalTopHit.kind]]; + const topHitIsSavedFavourite = + modeId === "favourites" && Boolean(universalTopHit && savedHrefs.has(universalTopHit.href)); + if (trimmedQuery && universalQuery === trimmedQuery && universalTopHit && !topHitIsSavedFavourite) { + const HitIcon = appModeIcons[universalSearchModeForDomain(universalTopHit.kind)]; const hit = universalTopHit; built.push({ key: "top-hit", @@ -424,6 +467,11 @@ export function UniversalSearchCommandSurface({ {hit.subtitle} ) : null} + + {universalPreferredDomains.includes(hit.kind) + ? "Current mode" + : `Also in ${appModeDefinition(universalSearchModeForDomain(hit.kind)).label}`} + {hit.badge ? ( @@ -437,6 +485,41 @@ export function UniversalSearchCommandSurface({ }); } + const visibleFavouriteMatches = + modeId === "favourites" ? favouriteMatches : favouriteMatches.filter((match) => match.standalone); + if (trimmedQuery && visibleFavouriteMatches.length) { + built.push({ + key: "local-favourites", + heading: `${modeId === "favourites" ? "Current mode" : "Also in Favourites"} · ${visibleFavouriteMatches.length}`, + items: visibleFavouriteMatches.slice(0, 4).map((match) => ({ + id: nextId(), + label: match.title, + onSelect: () => { + onDropdownOpenChange(false); + router.push(match.href); + }, + render: (active) => ( + + + + + + {match.title} + {match.subtitle ? ( + + {match.subtitle} + + ) : null} + + + Saved + + + ), + })), + }); + } + if (showFormCodeHint) { built.push({ key: "form-code", @@ -561,14 +644,20 @@ export function UniversalSearchCommandSurface({ // nothing highlighted still runs the mode-scoped search. if (trimmedQuery && universalQuery === trimmedQuery && orderedUniversalGroups.length) { for (const group of orderedUniversalGroups) { - const targetModeId = modeIdByDomain[group.kind]; + const targetModeId = universalSearchModeForDomain(group.kind); const targetMode = appModeDefinition(targetModeId); const GroupIcon = appModeIcons[targetModeId]; + const visibleItems = + modeId === "favourites" ? group.items.filter((item) => !savedHrefs.has(item.href)) : group.items; + if (!visibleItems.length) continue; + const isCurrentModeGroup = universalPreferredDomains.includes(group.kind); built.push({ key: `universal-${group.kind}`, - heading: `${domainHeadings[group.kind]} · ${group.total}`, + heading: isCurrentModeGroup + ? `Current mode · ${domainHeadings[group.kind]} · ${visibleItems.length}` + : `Also in ${targetMode.label} · ${domainHeadings[group.kind]} · ${visibleItems.length}`, items: [ - ...group.items.map((item) => ({ + ...visibleItems.map((item) => ({ id: nextId(), label: item.title, onSelect: () => { @@ -593,6 +682,11 @@ export function UniversalSearchCommandSurface({ {item.badge} ) : null} + {savedHrefs.has(item.href) ? ( + + Saved + + ) : null} ), })), @@ -707,9 +801,19 @@ export function UniversalSearchCommandSurface({ }); } + if (modeId === "answer") { + const actionsIndex = built.findIndex((section) => section.key === "actions"); + if (actionsIndex >= 0) { + const [actionsSection] = built.splice(actionsIndex, 1); + const insertionIndex = built[0]?.key === "top-hit" ? 1 : 0; + built.splice(insertionIndex, 0, actionsSection); + } + } + return built; }, [ config, + favouriteMatches, listboxId, mode, modeId, @@ -721,6 +825,7 @@ export function UniversalSearchCommandSurface({ onSearch, recentQueries, router, + savedHrefs, showFormCodeHint, trimmedQuery, universalGroups, @@ -728,20 +833,13 @@ export function UniversalSearchCommandSurface({ universalQuery, universalTopHit, universalAnswerAction, + universalPreferredDomains, ]); const flatItems = useMemo(() => sections.flatMap((section) => section.items), [sections]); const activeItemId = activeIndex >= 0 && activeIndex < flatItems.length ? flatItems[activeIndex].id : null; function handleComposerKeyDown(event: ReactKeyboardEvent) { - if (!dropdownDisplayable) { - if (event.key === "Escape") { - onDropdownOpenChange(false); - setActiveIndex(-1); - } - onInputKeyDown?.(event); - return; - } if (event.key === "ArrowDown") { event.preventDefault(); onDropdownOpenChange(true); @@ -781,6 +879,23 @@ export function UniversalSearchCommandSurface({ onListboxIdReady?.(listboxId); }, [listboxId, onListboxIdReady]); + useEffect(() => { + if (!dropdownOpen) return; + + function handleScroll(event: Event) { + const target = event.target; + if (target instanceof Element && target.closest(".universal-command-dropdown")) return; + onDropdownOpenChange(false); + setActiveIndex(-1); + } + + // Page movement means the user has left the composer context. Closing the + // floating sheet also prevents it covering result-page controls that the + // browser scrolls into view, while preserving scrolling inside the listbox. + window.addEventListener("scroll", handleScroll, true); + return () => window.removeEventListener("scroll", handleScroll, true); + }, [dropdownOpen, onDropdownOpenChange]); + useEffect(() => { function handleSlashFocus(event: KeyboardEvent) { if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return; @@ -821,7 +936,7 @@ export function UniversalSearchCommandSurface({ } }} onFocusCapture={() => { - if (dropdownDisplayable) onDropdownOpenChange(true); + onDropdownOpenChange(true); }} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { @@ -831,7 +946,7 @@ export function UniversalSearchCommandSurface({ }} > {children} - {dropdownOpen && dropdownDisplayable ? ( + {dropdownOpen ? ( { onQueryChange(example); - if (dropdownDisplayable) onDropdownOpenChange(true); + onDropdownOpenChange(true); onFocusSearchInput?.(); }} /> diff --git a/src/components/clinical-dashboard/use-universal-search.ts b/src/components/clinical-dashboard/use-universal-search.ts index 729f98763..8fa2dc967 100644 --- a/src/components/clinical-dashboard/use-universal-search.ts +++ b/src/components/clinical-dashboard/use-universal-search.ts @@ -11,6 +11,7 @@ import type { // Value import from the leaf module only: universal-search.ts itself is server-only // (snapshot catalogues, rag, supabase) and must never enter the client bundle. import { universalSearchDomains, type UniversalSearchDomain } from "@/lib/universal-search-domains"; +import type { AppModeId } from "@/lib/app-modes"; import { useAuthSession } from "@/lib/supabase/client"; export type UniversalSearchState = { @@ -26,6 +27,8 @@ export type UniversalSearchState = { topHit?: UniversalSearchTopHit; /** Jump into Answer mode for question-like queries. */ answerAction?: UniversalSearchAnswerAction; + contextMode?: AppModeId; + preferredDomains?: UniversalSearchDomain[]; }; type UniversalSearchResult = { @@ -35,6 +38,8 @@ type UniversalSearchResult = { domainOrder?: UniversalSearchDomain[]; topHit?: UniversalSearchTopHit; answerAction?: UniversalSearchAnswerAction; + contextMode?: AppModeId; + preferredDomains?: UniversalSearchDomain[]; }; const debounceMs = 250; @@ -47,10 +52,16 @@ const minQueryLength = 2; const resultCacheMax = 50; const resultCache = new Map(); -function cacheKeyFor(query: string, excludeDomain: string | undefined, limitPerDomain: number, authSignature: string) { +function cacheKeyFor( + query: string, + contextMode: AppModeId, + excludeDomain: string | undefined, + limitPerDomain: number, + authSignature: string, +) { // JSON-array key so no field can collide with another via a shared delimiter (auth header // values and the query itself can contain spaces, commas, etc.). - return JSON.stringify([authSignature, excludeDomain ?? "", limitPerDomain, query]); + return JSON.stringify([authSignature, contextMode, excludeDomain ?? "", limitPerDomain, query]); } // Non-mutating read used during render (must stay pure — no recency side effect here). @@ -86,6 +97,7 @@ function writeResultCache(key: string, value: UniversalSearchResult) { export function useUniversalSearch(args: { query: string; enabled: boolean; + contextMode: AppModeId; excludeDomain?: UniversalSearchDomain; limitPerDomain?: number; }): UniversalSearchState { @@ -98,7 +110,9 @@ export function useUniversalSearch(args: { const limitPerDomain = args.limitPerDomain ?? 3; const excludeDomain = args.excludeDomain; const authSignature = JSON.stringify(authorizationHeader ?? {}); - const cacheKey = active ? cacheKeyFor(trimmedQuery, excludeDomain, limitPerDomain, authSignature) : null; + const cacheKey = active + ? cacheKeyFor(trimmedQuery, args.contextMode, excludeDomain, limitPerDomain, authSignature) + : null; useEffect(() => { const authChanged = prevAuthRef.current !== authorizationHeader; @@ -135,12 +149,13 @@ export function useUniversalSearch(args: { q: trimmedQuery, limit: String(limitPerDomain), domains: domains.join(","), + mode: args.contextMode, }); fetch(`/api/search/universal?${params.toString()}`, { headers: authorizationHeader, signal: controller.signal }) .then(async (response) => { if (requestId !== requestSeqRef.current) return; if (!response.ok) { - setResult({ groups: [], query: trimmedQuery }); + setResult({ groups: [], query: trimmedQuery, contextMode: args.contextMode }); return; } const payload = (await response.json()) as Partial; @@ -152,6 +167,8 @@ export function useUniversalSearch(args: { domainOrder: payload.domainOrder, topHit: payload.topHit, answerAction: payload.answerAction, + contextMode: payload.contextMode ?? args.contextMode, + preferredDomains: payload.preferredDomains, }; writeResultCache(key, next); setResult(next); @@ -160,7 +177,7 @@ export function useUniversalSearch(args: { // An aborted fetch is a superseded keystroke, not a failure — leave state to the newer request. if (error instanceof DOMException && error.name === "AbortError") return; if (requestId !== requestSeqRef.current) return; - setResult({ groups: [], query: trimmedQuery }); + setResult({ groups: [], query: trimmedQuery, contextMode: args.contextMode }); }); }, debounceMs); @@ -168,7 +185,7 @@ export function useUniversalSearch(args: { window.clearTimeout(timer); controller.abort(); }; - }, [active, cacheKey, trimmedQuery, excludeDomain, limitPerDomain, authorizationHeader]); + }, [active, cacheKey, trimmedQuery, excludeDomain, limitPerDomain, authorizationHeader, args.contextMode]); if (!active) return { groups: [], loading: false, query: "" }; @@ -184,9 +201,11 @@ export function useUniversalSearch(args: { domainOrder: cached.domainOrder, topHit: cached.topHit, answerAction: cached.answerAction, + contextMode: cached.contextMode, + preferredDomains: cached.preferredDomains, }; } - const fresh = result.query === trimmedQuery; + const fresh = result.query === trimmedQuery && result.contextMode === args.contextMode; return { groups: fresh ? result.groups : [], loading: !fresh, @@ -195,5 +214,7 @@ export function useUniversalSearch(args: { domainOrder: fresh ? result.domainOrder : undefined, topHit: fresh ? result.topHit : undefined, answerAction: fresh ? result.answerAction : undefined, + contextMode: fresh ? result.contextMode : undefined, + preferredDomains: fresh ? result.preferredDomains : undefined, }; } diff --git a/src/components/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index 8f67bfe06..a8f88b4c4 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -39,6 +39,7 @@ import { useSearchCommand } from "@/components/clinical-dashboard/search-command import { recordMatchesCommandScopes } from "@/lib/search-command-surface"; import { sortResultItems, type ResultSortValue } from "@/lib/result-sort"; import { useResultSort } from "@/components/use-result-sort"; +import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; type FormsSearchResultsPageProps = { query: string; @@ -659,6 +660,7 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { onSortChange={setSortValue} />
+ {query.trim() && displayedMatches.length === 0 ? ( + +
-
- -
-
- -
+ + } sidebar={ diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index a829b75c2..3898cf4ca 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -2,16 +2,19 @@ import type { ClinicalQueryMode } from "@/lib/types"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { appendSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context"; -export type AppModeId = - | "answer" - | "documents" - | "services" - | "forms" - | "favourites" - | "differentials" - | "formulation" - | "prescribing" - | "tools"; +export const appModeIds = [ + "answer", + "documents", + "services", + "forms", + "favourites", + "differentials", + "formulation", + "prescribing", + "tools", +] as const; + +export type AppModeId = (typeof appModeIds)[number]; export type SearchableAppModeId = AppModeId; export type AppModeSearchKind = diff --git a/src/lib/search-command-surface.ts b/src/lib/search-command-surface.ts index 20556cd40..384e27c88 100644 --- a/src/lib/search-command-surface.ts +++ b/src/lib/search-command-surface.ts @@ -119,6 +119,26 @@ const searchCommandSurfaceByMode: Partial = { + answer: ["documents"], + documents: ["documents"], + services: ["services"], + forms: ["forms"], + favourites: [], + differentials: ["differentials", "presentations"], + formulation: ["specifiers"], + prescribing: ["medications", "documents"], + tools: ["tools"], +}; + +const modeByDomain: Record = { + documents: "documents", + medications: "prescribing", + services: "services", + forms: "forms", + differentials: "differentials", + presentations: "differentials", + specifiers: "formulation", + tools: "tools", +}; + +export function universalSearchPreferredDomains(mode: AppModeId | undefined): UniversalSearchDomain[] { + return mode ? [...preferredDomainsByMode[mode]] : []; +} + +export function universalSearchDomainBelongsToMode(domain: UniversalSearchDomain, mode: AppModeId): boolean { + return preferredDomainsByMode[mode].includes(domain); +} + +export function universalSearchModeForDomain(domain: UniversalSearchDomain): AppModeId { + return modeByDomain[domain]; +} diff --git a/src/lib/universal-search.ts b/src/lib/universal-search.ts index 566bbd2e1..fe7337579 100644 --- a/src/lib/universal-search.ts +++ b/src/lib/universal-search.ts @@ -18,9 +18,12 @@ import { registryCorpusDetailHref } from "@/lib/registry-corpus-links"; import { rowToServiceRecord } from "@/lib/registry-records"; import { fetchOwnerRegistryRowsWithSeed } from "@/lib/registry-seed"; import { rankServiceRecords, serviceRecords, type ServiceRecord } from "@/lib/services"; +import { searchFormulationMechanisms } from "@/lib/formulation"; import { rankToolRecords } from "@/lib/tools-catalog"; import type { ClinicalQueryAnalysis, SearchResult } from "@/lib/types"; import { universalSearchDomains, type UniversalSearchDomain } from "@/lib/universal-search-domains"; +import { universalSearchPreferredDomains } from "@/lib/universal-search-mode-context"; +import type { AppModeId } from "@/lib/app-modes"; // Server-side federated cross-entity search: one parallel in-process fan-out to the document // retrieval pipeline plus the shared registry rankers (medications, services, forms, @@ -90,6 +93,8 @@ export type UniversalSearchResponse = { domainOrder?: UniversalSearchDomain[]; topHit?: UniversalSearchTopHit; answerAction?: UniversalSearchAnswerAction; + contextMode?: AppModeId; + preferredDomains?: UniversalSearchDomain[]; // demoMode / publicAccess are attached by the route to its JSON response, not by runUniversalSearch. }; @@ -97,6 +102,7 @@ export type RunUniversalSearchArgs = { query: string; limitPerDomain: number; domains?: UniversalSearchDomain[]; + contextMode?: AppModeId; // Live mode: both present. Demo/public mode: demo=true and the registry adapters serve // fixtures without touching Supabase. supabase?: AdminClient; @@ -250,6 +256,21 @@ async function searchToolsDomain(args: ResolvedSearchArgs): Promise { + return searchFormulationMechanisms(args.baseQuery) + .slice(0, args.limitPerDomain) + .map(({ mechanism, score }) => ({ + id: mechanism.id, + kind: "specifiers" as const, + title: mechanism.name, + subtitle: mechanism.summary, + href: `/formulation/${mechanism.id}`, + score, + badge: mechanism.domains[0], + meta: mechanism.diagnosticContexts.slice(0, 2).join(" · ") || undefined, + })); +} + function searchResultDocumentHref(result: SearchResult) { const metadata = result.source_metadata && typeof result.source_metadata === "object" @@ -349,6 +370,7 @@ const domainAdapters: Record< forms: { run: searchFormsDomain, timeoutMs: registryDomainTimeoutMs }, differentials: { run: searchDifferentialsDomain, timeoutMs: registryDomainTimeoutMs }, presentations: { run: searchPresentationsDomain, timeoutMs: registryDomainTimeoutMs }, + specifiers: { run: searchSpecifiersDomain, timeoutMs: registryDomainTimeoutMs }, tools: { run: searchToolsDomain, timeoutMs: registryDomainTimeoutMs }, }; @@ -410,13 +432,22 @@ function preferredLeadDomains(analysis: ClinicalQueryAnalysis): UniversalSearchD return []; } -function buildDomainOrder(analysis: ClinicalQueryAnalysis, groups: UniversalSearchGroup[]): UniversalSearchDomain[] { +function buildDomainOrder( + analysis: ClinicalQueryAnalysis, + groups: UniversalSearchGroup[], + preferredDomains: UniversalSearchDomain[], +): UniversalSearchDomain[] { const present = new Set(groups.map((group) => group.kind)); const confidentDomains = groups .filter((group) => group.items.some((item) => item.confident)) .map((group) => group.kind); const order: UniversalSearchDomain[] = []; - for (const domain of [...preferredLeadDomains(analysis), ...confidentDomains, ...universalSearchDomains]) { + for (const domain of [ + ...preferredDomains, + ...preferredLeadDomains(analysis), + ...confidentDomains, + ...universalSearchDomains, + ]) { if (present.has(domain) && !order.includes(domain)) order.push(domain); } return order; @@ -498,7 +529,10 @@ export async function runUniversalSearch(args: RunUniversalSearchArgs): Promise< return { kind: requested[index], total: 0, items: [], latencyMs: Date.now() - startedAt, error: true }; }); - const domainOrder = buildDomainOrder(analysis, groups); + const preferredDomains = universalSearchPreferredDomains(args.contextMode).filter((domain) => + requested.includes(domain), + ); + const domainOrder = buildDomainOrder(analysis, groups, preferredDomains); return { query: args.query, groups, @@ -507,6 +541,8 @@ export async function runUniversalSearch(args: RunUniversalSearchArgs): Promise< domainOrder, topHit: buildTopHit(groups, domainOrder), answerAction: buildAnswerAction(analysis, args.query), + contextMode: args.contextMode, + preferredDomains, }; } @@ -524,6 +560,8 @@ export function universalSearchViewAllHref(domain: UniversalSearchDomain, query: // The differentials mode home search composes both kinds, so presentations share it. case "presentations": return `/differentials?q=${encodeURIComponent(query)}&run=1`; + case "specifiers": + return `/formulation?q=${encodeURIComponent(query)}&run=1`; case "tools": return `/?mode=tools&q=${encodeURIComponent(query)}&run=1`; } diff --git a/tests/app-modes.test.ts b/tests/app-modes.test.ts index 327d87a9e..d3fc27bcf 100644 --- a/tests/app-modes.test.ts +++ b/tests/app-modes.test.ts @@ -12,8 +12,17 @@ import { isSearchableAppMode, visibleAppModeDefinitions, } from "@/lib/app-modes"; +import { universalSearchPreferredDomains } from "@/lib/universal-search-mode-context"; describe("app mode search contract", () => { + it("maps every mode to its preferred universal-search domains", () => { + expect(universalSearchPreferredDomains("answer")).toEqual(["documents"]); + expect(universalSearchPreferredDomains("prescribing")).toEqual(["medications", "documents"]); + expect(universalSearchPreferredDomains("differentials")).toEqual(["differentials", "presentations"]); + expect(universalSearchPreferredDomains("formulation")).toEqual(["specifiers"]); + expect(universalSearchPreferredDomains("favourites")).toEqual([]); + }); + it("requires every mode to declare its search behavior and copy", () => { const ids = new Set(); diff --git a/tests/search-command-surface.test.ts b/tests/search-command-surface.test.ts index 065af7b33..71cc15dba 100644 --- a/tests/search-command-surface.test.ts +++ b/tests/search-command-surface.test.ts @@ -33,7 +33,8 @@ describe("search command surface", () => { expect(documents?.examples.length).toBeGreaterThan(0); expect(documents?.crossModes).toContain("prescribing"); - expect(searchCommandSurfaceConfig("tools")).toBeNull(); + expect(searchCommandSurfaceConfig("tools")?.examples.length).toBeGreaterThan(0); + expect(searchCommandSurfaceConfig("formulation")?.crossModes).toContain("differentials"); }); it("detects form code queries", () => { diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 7cc15cfbb..20b50dabd 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1788,15 +1788,20 @@ test.describe("Clinical KB UI smoke coverage", () => { const main = document.querySelector("main#main-content"); const header = document.querySelector("header"); const surface = document.querySelector('[data-dashboard-stage="answer-surface"]'); + const alsoMatches = document.querySelector('[data-testid="universal-also-matches"]'); return { scrollHeight: main?.scrollHeight ?? 0, clientHeight: main?.clientHeight ?? 0, headerBottom: header ? Math.round(header.getBoundingClientRect().bottom) : 0, surfaceTop: surface ? Math.round(surface.getBoundingClientRect().top) : 0, + alsoMatchesHeight: alsoMatches ? Math.ceil(alsoMatches.getBoundingClientRect().height) : 0, }; }); - // Content-sized section => no phantom scroll (old floor made scrollHeight ≫ clientHeight). - expect(geo.scrollHeight - geo.clientHeight).toBeLessThanOrEqual(4); + // Content-sized section => no unexplained phantom scroll. Submitted universal + // matches are real content below the answer, so their compact panel may account + // for the overflow; the old viewport floor created much more empty scroll. + const permittedOverflow = geo.alsoMatchesHeight > 0 ? geo.alsoMatchesHeight + 24 : 4; + expect(geo.scrollHeight - geo.clientHeight).toBeLessThanOrEqual(permittedOverflow); // Top-aligned: the answer sits just under the header, not pushed toward the dock // (a bottom-anchor regression would push surfaceTop far down the viewport). expect(geo.surfaceTop - geo.headerBottom).toBeGreaterThanOrEqual(0); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index e89e7af2e..d3f4e0d72 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -623,7 +623,7 @@ test.describe("Clinical KB tools launcher", () => { } }); - test("phone bottom-dock search keeps the command popup hidden", async ({ page }) => { + test("phone bottom-dock search opens the bounded command results sheet", async ({ page }) => { await page.setViewportSize({ width: 390, height: 820 }); await gotoLauncher(page, "/services?q=13YARN&focus=1&run=1"); await expect(page.getByRole("button", { name: "Mode Services" })).toBeVisible(); @@ -631,13 +631,16 @@ test.describe("Clinical KB tools launcher", () => { await expect(input).toBeVisible(); await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined); - // Phones never show the universal command popup — it crowds the small - // screen — so focusing/typing must not surface the dropdown or its scrim. + // Phone searches use the same accessible listbox as larger viewports, bounded + // above the bottom dock so results stay reachable without horizontal overflow. await input.click(); await input.press("ArrowDown"); - await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0); - await expect(page.locator("form.answer-footer-search-dock")).not.toHaveAttribute("data-command-open", "true"); + await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(1); + await expect(page.getByRole("listbox", { name: "Services search suggestions" })).toBeVisible(); await expectNoPageHorizontalOverflow(page); + + await page.evaluate(() => window.dispatchEvent(new Event("scroll"))); + await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0); }); test("phone mode homes keep the shared search in the hero, not the bottom dock", async ({ page }) => { @@ -656,10 +659,11 @@ test.describe("Clinical KB tools launcher", () => { expect(geometry.top).toBeGreaterThan(0); expect(geometry.bottom).toBeLessThan(geometry.viewportHeight - 40); - // Focusing the hero composer must not surface the universal popup on phones. + // Hero composers expose the same bounded universal results sheet on phones. await heroInput.click(); await heroInput.press("ArrowDown"); - await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0); + await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(1); + await expect(page.getByRole("listbox")).toBeVisible(); await expectNoPageHorizontalOverflow(page); } }); diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index 8259fdb31..6f2d08cd9 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -9,6 +9,21 @@ const universalPayload = { tookMs: 12, demoMode: true, groups: [ + { + kind: "documents", + total: 1, + latencyMs: 2, + items: [ + { + id: "acamprosate-guideline", + kind: "documents", + title: "Acamprosate prescribing guideline", + subtitle: "Alcohol dependence", + href: "/documents/acamprosate-guideline", + score: 0.86, + }, + ], + }, { kind: "medications", total: 1, @@ -61,12 +76,33 @@ const universalPayload = { async function mockUniversalSearch(page: Page) { await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { - await route.fulfill({ json: universalPayload }); + const mode = new URL(route.request().url()).searchParams.get("mode") ?? "documents"; + const preferredByMode: Record = { + answer: ["documents"], + documents: ["documents"], + prescribing: ["medications", "documents"], + services: ["services"], + forms: ["forms"], + favourites: [], + differentials: ["differentials", "presentations"], + formulation: ["specifiers"], + tools: ["tools"], + }; + const preferredDomains = preferredByMode[mode] ?? []; + const responseOrder = universalPayload.groups.map((group) => group.kind); + await route.fulfill({ + json: { + ...universalPayload, + contextMode: mode, + preferredDomains, + domainOrder: [...preferredDomains, ...responseOrder.filter((domain) => !preferredDomains.includes(domain))], + }, + }); }); } -async function openComposer(page: Page) { - await page.goto("/?mode=documents&focus=1", { waitUntil: "domcontentloaded" }); +async function openComposer(page: Page, href = "/?mode=documents&focus=1") { + await page.goto(href, { waitUntil: "domcontentloaded" }); const input = page.getByTestId("global-search-input").first(); await input.click(); return input; @@ -79,7 +115,8 @@ test.describe("universal search typeahead", () => { await input.fill("acamprosate"); await expect(page.getByText("Medications · 1")).toBeVisible(); - await expect(page.getByRole("option", { name: /Acamprosate/ })).toBeVisible(); + await expect(page.getByText(/Current mode · Documents · 1/)).toBeVisible(); + await expect(page.getByRole("option", { name: /^Acamprosate Alcohol/ })).toBeVisible(); await expect(page.getByText("Forms · 1")).toBeVisible(); await expect(page.getByRole("option", { name: /View all in Medication/ })).toBeVisible(); // Presentations render as their own group borrowing the differentials mode target. @@ -88,6 +125,24 @@ test.describe("universal search typeahead", () => { await expect(page.getByRole("option", { name: /View all in Differentials/ })).toBeVisible(); }); + test("does not count document-only hits as visible Medication rows", async ({ page }) => { + await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { + await route.fulfill({ + json: { + query: "prescribing policy", + contextMode: "documents", + preferredDomains: ["documents"], + domainOrder: ["documents"], + groups: [universalPayload.groups[0]], + }, + }); + }); + const input = await openComposer(page); + await input.fill("prescribing policy"); + + await expect(page.getByRole("option", { name: "Medication", exact: true })).toHaveText("Medication"); + }); + test("selecting a presentation result navigates to the workflow page", async ({ page }) => { await mockUniversalSearch(page); const input = await openComposer(page); @@ -96,7 +151,9 @@ test.describe("universal search typeahead", () => { const option = page.getByRole("option", { name: /Acute Confusion/ }); await expect(option).toBeVisible(); await option.click(); - await expect(page).toHaveURL(/\/differentials\/presentations\/acute-confusion-encephalopathy/); + await expect(page).toHaveURL(/\/differentials\/presentations\/acute-confusion-encephalopathy/, { + timeout: 30_000, + }); }); test("selecting a grouped result navigates to the record", async ({ page }) => { @@ -104,10 +161,10 @@ test.describe("universal search typeahead", () => { const input = await openComposer(page); await input.fill("acamprosate"); - const option = page.getByRole("option", { name: /Acamprosate/ }); + const option = page.getByRole("option", { name: /^Acamprosate Alcohol/ }); await expect(option).toBeVisible(); await option.click(); - await expect(page).toHaveURL(/\/medications\/acamprosate/); + await expect(page).toHaveURL(/\/medications\/acamprosate/, { timeout: 30_000 }); }); test("Enter with nothing highlighted still runs the mode-scoped search", async ({ page }) => { @@ -122,6 +179,56 @@ test.describe("universal search typeahead", () => { await expect(page.getByText("Medications · 1")).toBeHidden(); await expect(page).not.toHaveURL(/\/medications\//); }); + + test("shows local saved content first in Favourites without uploading it", async ({ page }) => { + await mockUniversalSearch(page); + const input = await openComposer(page, "/favourites?focus=1"); + await input.fill("ward round"); + + await expect(page.getByText(/Current mode · \d+/)).toBeVisible(); + await expect(page.getByRole("option", { name: /Ward round/ })).toBeVisible(); + await expect(page.getByText("Saved").first()).toBeVisible(); + }); + + test("shows cross-mode typeahead on a phone viewport", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockUniversalSearch(page); + const input = await openComposer(page); + await input.fill("acamprosate"); + + await expect(page.getByText(/Current mode · Documents · 1/)).toBeVisible(); + await expect(page.getByText("Also in Medication · Medications · 1")).toBeVisible(); + }); + + test("keeps compact cross-mode matches visible after submission", async ({ page }) => { + await mockUniversalSearch(page); + await page.goto("/services?q=acamprosate&run=1", { waitUntil: "domcontentloaded" }); + + await expect(page.getByTestId("universal-also-matches")).toBeVisible(); + await expect(page.getByText("Also matches in other modes")).toBeVisible(); + await expect(page.getByRole("link", { name: "Acamprosate", exact: true })).toBeVisible(); + }); + + test("shows submitted cross-mode matches on phones outside hidden desktop headers", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockUniversalSearch(page); + await page.goto("/forms?q=acamprosate&run=1", { waitUntil: "domcontentloaded" }); + + await expect(page.getByTestId("universal-also-matches")).toBeVisible(); + await expect(page.getByTestId("universal-also-matches")).toHaveCount(1); + }); + + test("shows submitted cross-mode matches once for Favourites and after a Tools search", async ({ page }) => { + await mockUniversalSearch(page); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto("/favourites?q=acamprosate&run=1", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("universal-also-matches")).toHaveCount(1); + + const input = await openComposer(page, "/tools?focus=1"); + await input.fill("acamprosate"); + await input.press("Enter"); + await expect(page.getByTestId("universal-also-matches")).toBeVisible(); + }); }); // Smart affordances: query interpretation banner, pinned best-bet, and the Answer-mode bridge. @@ -199,4 +306,23 @@ test.describe("universal search smart affordances", () => { await expect(page.getByRole("main").getByRole("heading", { name: "Answer", exact: true })).toBeVisible(); await expect(page).toHaveURL(/mode=answer/); }); + + test("keeps a completed Answer query eligible for submitted cross-mode matches", async ({ page }) => { + await mockSmartSearch(page); + const input = await openComposer(page, "/?mode=answer&focus=1"); + await input.fill("acamprosat"); + await input.press("Enter"); + + await expect(page.getByTestId("universal-also-matches")).toBeVisible(); + }); + + test("keeps a saved exact match first in Favourites", async ({ page }) => { + await mockSmartSearch(page); + const input = await openComposer(page, "/favourites?focus=1"); + await input.fill("acamprosate"); + + await expect(page.getByText("Best match")).toBeHidden(); + await expect(page.getByRole("option").first()).toContainText("Acamprosate renal screen"); + await expect(page.getByRole("option").first()).toContainText("Saved"); + }); }); diff --git a/tests/universal-search.test.ts b/tests/universal-search.test.ts index 076655840..3e713c271 100644 --- a/tests/universal-search.test.ts +++ b/tests/universal-search.test.ts @@ -41,6 +41,15 @@ describe("runUniversalSearch (demo/fixtures path)", () => { const tools = toolsResponse.groups.find((group) => group.kind === "tools"); expect(tools?.items.some((item) => item.id === "forms")).toBe(true); expect(forms?.items.every((item) => item.href.startsWith("/forms/"))).toBe(true); + + const formulationResponse = await runUniversalSearch({ + query: "avoidance", + limitPerDomain: 5, + demo: true, + }); + const formulation = formulationResponse.groups.find((group) => group.kind === "specifiers"); + expect(formulation?.items[0]?.title).toBe("Avoidance"); + expect(formulation?.items[0]?.href).toBe("/formulation/avoidance"); }); it("filters to requested domains only", async () => { @@ -52,8 +61,8 @@ describe("runUniversalSearch (demo/fixtures path)", () => { demo: true, }); expect(response.groups.map((group) => group.kind)).toEqual( - ["documents", "medications", "services", "forms", "differentials", "presentations", "tools"].filter((domain) => - ["tools", "differentials"].includes(domain), + ["documents", "medications", "services", "forms", "differentials", "presentations", "specifiers", "tools"].filter( + (domain) => ["tools", "differentials"].includes(domain), ), ); }); @@ -215,6 +224,26 @@ describe("GET /api/search/universal (demo mode)", () => { expect(payload.groups.map((group) => group.kind)).toEqual(["presentations"]); expect(payload.groups[0]?.items[0]?.href).toContain("/differentials/presentations/"); }); + + it("accepts a mode context and rejects unknown modes", async () => { + vi.stubEnv("NEXT_PUBLIC_DEMO_MODE", "true"); + const { GET } = await import("../src/app/api/search/universal/route"); + const response = await GET( + new Request("http://localhost/api/search/universal?q=transport&mode=forms&domains=forms,services"), + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + contextMode?: string; + preferredDomains?: string[]; + domainOrder?: string[]; + }; + expect(payload.contextMode).toBe("forms"); + expect(payload.preferredDomains).toEqual(["forms"]); + expect(payload.domainOrder?.[0]).toBe("forms"); + + const invalid = await GET(new Request("http://localhost/api/search/universal?q=transport&mode=bogus")); + expect(invalid.status).toBe(400); + }); }); describe("runUniversalSearch (query intelligence & ranking)", () => { @@ -226,6 +255,22 @@ describe("runUniversalSearch (query intelligence & ranking)", () => { expect(response.domainOrder?.[0]).toBe("medications"); }); + it("leads normal groups with the active mode but still allows an exact external Best match", async () => { + const { runUniversalSearch } = await loadUniversalSearch(); + const response = await runUniversalSearch({ + query: "avoidance", + limitPerDomain: 5, + contextMode: "documents", + demo: true, + }); + + expect(response.contextMode).toBe("documents"); + expect(response.preferredDomains).toEqual(["documents"]); + expect(response.domainOrder?.[0]).toBe("documents"); + expect(response.topHit?.kind).toBe("specifiers"); + expect(response.topHit?.title).toBe("Avoidance"); + }); + it("typo-corrects the base query so a misspelled drug still finds the record", async () => { const { runUniversalSearch } = await loadUniversalSearch(); const response = await runUniversalSearch({ query: "clozapin", limitPerDomain: 5, demo: true }); @@ -415,6 +460,7 @@ describe("GET /api/search/universal (live public/owner path)", () => { "forms", "differentials", "presentations", + "specifiers", "tools", ], }));