From d0dcf8cbce542b4aa009d2736c81c7a52519c376 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:59:22 +0800 Subject: [PATCH 1/9] feat(search): make universal search mode-aware --- src/app/api/search/universal/route.ts | 17 +- src/components/ClinicalDashboard.tsx | 12 ++ .../search-results-header-band.tsx | 2 + .../universal-search-also-matches.tsx | 108 ++++++++++ .../universal-search-command-surface.tsx | 194 +++++++++++++----- .../use-universal-search.ts | 29 ++- .../services/services-navigator-page.tsx | 28 +-- .../specifiers/specifiers-home-page.tsx | 3 + src/lib/app-modes.ts | 23 ++- src/lib/search-command-surface.ts | 20 ++ src/lib/universal-search-domains.ts | 3 +- src/lib/universal-search-mode-context.ts | 37 ++++ src/lib/universal-search.ts | 44 +++- tests/app-modes.test.ts | 9 + tests/search-command-surface.test.ts | 3 +- tests/ui-tools.spec.ts | 15 +- tests/ui-universal-search.spec.ts | 82 +++++++- tests/universal-search.test.ts | 50 ++++- 18 files changed, 566 insertions(+), 113 deletions(-) create mode 100644 src/components/clinical-dashboard/universal-search-also-matches.tsx create mode 100644 src/lib/universal-search-mode-context.ts 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 016dc9373..def112534 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 { answerRecovery, errorCopy } from "@/lib/ui-copy"; @@ -3703,6 +3704,17 @@ export function ClinicalDashboard({ ) : null)} + {modeSearchSubmitted && + query.trim() && + (activeModeResultKind === "answer" || + activeModeResultKind === "tools" || + activeModeResultKind === "favourites") ? ( + + ) : null} + {activeModeResultKind === "differentials" ? ( ) : null} + ); } 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..6ac1da474 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,6 +27,7 @@ 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 @@ -28,25 +35,14 @@ import type { UniversalSearchDomain } from "@/lib/universal-search"; // presentations and diagnoses into one result list. const domainsByTargetMode: Partial> = { documents: ["documents"], - prescribing: ["medications"], + prescribing: ["medications", "documents"], services: ["services"], forms: ["forms"], differentials: ["differentials", "presentations"], + specifiers: ["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 +50,61 @@ const domainHeadings: Record = { forms: "Forms", differentials: "Differentials", presentations: "Presentations", + specifiers: "Specifiers", 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, + 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 +211,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 +236,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 +371,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 +396,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 @@ -397,7 +433,7 @@ 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 HitIcon = appModeIcons[universalSearchModeForDomain(universalTopHit.kind)]; const hit = universalTopHit; built.push({ key: "top-hit", @@ -424,6 +460,11 @@ export function UniversalSearchCommandSurface({ {hit.subtitle} ) : null} + + {universalPreferredDomains.includes(hit.kind) + ? "Current mode" + : `Also in ${appModeDefinition(universalSearchModeForDomain(hit.kind)).label}`} + {hit.badge ? ( @@ -437,6 +478,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 +637,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 +675,11 @@ export function UniversalSearchCommandSurface({ {item.badge} ) : null} + {savedHrefs.has(item.href) ? ( + + Saved + + ) : null} ), })), @@ -710,6 +797,7 @@ export function UniversalSearchCommandSurface({ return built; }, [ config, + favouriteMatches, listboxId, mode, modeId, @@ -721,6 +809,7 @@ export function UniversalSearchCommandSurface({ onSearch, recentQueries, router, + savedHrefs, showFormCodeHint, trimmedQuery, universalGroups, @@ -728,20 +817,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); @@ -821,7 +903,7 @@ export function UniversalSearchCommandSurface({ } }} onFocusCapture={() => { - if (dropdownDisplayable) onDropdownOpenChange(true); + onDropdownOpenChange(true); }} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { @@ -831,7 +913,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..53c383ca3 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,6 +149,7 @@ 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) => { @@ -152,6 +167,8 @@ export function useUniversalSearch(args: { domainOrder: payload.domainOrder, topHit: payload.topHit, answerAction: payload.answerAction, + contextMode: payload.contextMode, + preferredDomains: payload.preferredDomains, }; writeResultCache(key, next); setResult(next); @@ -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,6 +201,8 @@ export function useUniversalSearch(args: { domainOrder: cached.domainOrder, topHit: cached.topHit, answerAction: cached.answerAction, + contextMode: cached.contextMode, + preferredDomains: cached.preferredDomains, }; } const fresh = result.query === trimmedQuery; @@ -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/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 763042230..d0a483c2c 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -503,26 +503,14 @@ export function ServicesNavigatorPage() { className="mode-home-composer-slot hidden w-full min-w-0 [&:not(:empty)]:block" /> -
- -
-
- -
+ } sidebar={ diff --git a/src/components/specifiers/specifiers-home-page.tsx b/src/components/specifiers/specifiers-home-page.tsx index b8d9e202e..9941279ec 100644 --- a/src/components/specifiers/specifiers-home-page.tsx +++ b/src/components/specifiers/specifiers-home-page.tsx @@ -28,6 +28,7 @@ import { cn, eyebrowText } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { searchSpecifiers, specifierFamilies, specifierSearchPresets, type SpecifierFamily } from "@/lib/specifiers"; +import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; const diagnosisOptions = [ { value: "", label: "All diagnoses" }, @@ -201,6 +202,8 @@ function SpecifierResults({ query }: { query: string }) {

+ +
= { + answer: ["documents"], + documents: ["documents"], + services: ["services"], + forms: ["forms"], + favourites: [], + differentials: ["differentials", "presentations"], + specifiers: ["specifiers"], + prescribing: ["medications", "documents"], + tools: ["tools"], +}; + +const modeByDomain: Record = { + documents: "documents", + medications: "prescribing", + services: "services", + forms: "forms", + differentials: "differentials", + presentations: "differentials", + specifiers: "specifiers", + 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..a3e032615 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 { searchSpecifiers } from "@/lib/specifiers"; 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 searchSpecifiers(args.baseQuery) + .slice(0, args.limitPerDomain) + .map(({ record, score }) => ({ + id: record.slug, + kind: "specifiers" as const, + title: record.name, + subtitle: record.summary, + href: `/specifiers/${record.slug}`, + score, + badge: record.familyLabel, + meta: record.appliesTo.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 `/specifiers?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 50fd415d2..d1d606192 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("specifiers")).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..dfda59e3c 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("specifiers")?.crossModes).toContain("differentials"); }); it("detects form code queries", () => { diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 6b3121503..60479d083 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -622,7 +622,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(); @@ -630,12 +630,12 @@ 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); }); @@ -655,10 +655,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..39d06d201 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"], + specifiers: ["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. @@ -96,7 +133,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 +143,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 +161,35 @@ 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(); + }); }); // Smart affordances: query interpretation banner, pinned best-bet, and the Answer-mode bridge. diff --git a/tests/universal-search.test.ts b/tests/universal-search.test.ts index 076655840..edec2953b 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 specifierResponse = await runUniversalSearch({ + query: "with anxious distress", + limitPerDomain: 5, + demo: true, + }); + const specifiers = specifierResponse.groups.find((group) => group.kind === "specifiers"); + expect(specifiers?.items[0]?.title).toBe("With anxious distress"); + expect(specifiers?.items[0]?.href).toBe("/specifiers/with-anxious-distress"); }); 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: "with anxious distress", + 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("With anxious distress"); + }); + 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", ], })); From e53686c6bdf543e4c55361b05259431ceb5b7051 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:06:36 +0800 Subject: [PATCH 2/9] fix(search): close results sheet on page scroll --- .../universal-search-command-surface.tsx | 17 +++++++++++++++++ tests/ui-tools.spec.ts | 3 +++ 2 files changed, 20 insertions(+) diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 6ac1da474..8069e8083 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -863,6 +863,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; diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 60479d083..3ff26283f 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -637,6 +637,9 @@ test.describe("Clinical KB tools launcher", () => { 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 }) => { From b036632add7686fca294290423d1f013b314226b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:28:34 +0800 Subject: [PATCH 3/9] fix(search): address mode-aware result review findings --- src/components/ClinicalDashboard.tsx | 16 +++++----- .../universal-search-command-surface.tsx | 10 ++++-- .../use-universal-search.ts | 8 ++--- tests/ui-universal-search.spec.ts | 31 +++++++++++++++++++ 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 7184f2ded..a2d05d48e 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3011,6 +3011,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" || @@ -3578,15 +3583,8 @@ export function ClinicalDashboard({ ) : null)} - {modeSearchSubmitted && - query.trim() && - (activeModeResultKind === "answer" || - activeModeResultKind === "tools" || - activeModeResultKind === "favourites") ? ( - + {showUniversalAlsoMatches ? ( + ) : null} {activeModeResultKind === "differentials" ? ( diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 8069e8083..84fbe6c9b 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -29,8 +29,8 @@ import { 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> = { @@ -82,6 +82,8 @@ function rankLocalFavourites(items: FavouriteItem[], query: string): LocalFavour 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, }; @@ -432,7 +434,9 @@ 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 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({ diff --git a/src/components/clinical-dashboard/use-universal-search.ts b/src/components/clinical-dashboard/use-universal-search.ts index 53c383ca3..8fa2dc967 100644 --- a/src/components/clinical-dashboard/use-universal-search.ts +++ b/src/components/clinical-dashboard/use-universal-search.ts @@ -155,7 +155,7 @@ export function useUniversalSearch(args: { .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; @@ -167,7 +167,7 @@ export function useUniversalSearch(args: { domainOrder: payload.domainOrder, topHit: payload.topHit, answerAction: payload.answerAction, - contextMode: payload.contextMode, + contextMode: payload.contextMode ?? args.contextMode, preferredDomains: payload.preferredDomains, }; writeResultCache(key, next); @@ -177,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); @@ -205,7 +205,7 @@ export function useUniversalSearch(args: { preferredDomains: cached.preferredDomains, }; } - const fresh = result.query === trimmedQuery; + const fresh = result.query === trimmedQuery && result.contextMode === args.contextMode; return { groups: fresh ? result.groups : [], loading: !fresh, diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index 39d06d201..04a4d5b66 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -190,6 +190,18 @@ test.describe("universal search typeahead", () => { 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 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. @@ -267,4 +279,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"); + }); }); From 4395a978097dbeefe1579c4fd365d721d1973335 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:53:17 +0800 Subject: [PATCH 4/9] fix(search): keep answer results ahead of cross-mode matches --- src/components/ClinicalDashboard.tsx | 6 +++++- .../universal-search-command-surface.tsx | 9 +++++++++ tests/ui-smoke.spec.ts | 9 +++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index a2d05d48e..b4486c5ee 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3583,7 +3583,7 @@ export function ClinicalDashboard({ ) : null)} - {showUniversalAlsoMatches ? ( + {showUniversalAlsoMatches && activeModeResultKind !== "answer" ? ( ) : null} @@ -3746,6 +3746,10 @@ export function ClinicalDashboard({ }} /> ) : null} + + {showUniversalAlsoMatches && activeModeResultKind === "answer" ? ( + + ) : null}
{showSystemNotice && answer ? renderSystemNotice("sm:hidden") : null} diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 84fbe6c9b..70082ae8e 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -798,6 +798,15 @@ 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, diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 5dd766501..98d290c26 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1719,15 +1719,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); From 7fe8ab6951be2f7423ececb42dd46f4b1f2b8bd0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:08:21 +0800 Subject: [PATCH 5/9] fix(search): avoid hidden submitted-result requests --- .../clinical-dashboard/differentials-home.tsx | 2 ++ .../document-search-results.tsx | 24 +++++++++++-------- .../favourites-command-library-page.tsx | 2 ++ .../medication-prescribing-workspace.tsx | 2 ++ .../search-results-header-band.tsx | 2 -- .../forms/forms-search-results-page.tsx | 2 ++ .../services/services-navigator-page.tsx | 2 ++ tests/ui-universal-search.spec.ts | 9 +++++++ 8 files changed, 33 insertions(+), 12 deletions(-) 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/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index 781275b1d..374f4df83 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -7,7 +7,6 @@ import { cn } from "@/components/ui-primitives"; import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import type { AppModeId } from "@/lib/app-modes"; import { readResultSort, type ResultSortValue } from "@/lib/result-sort"; -import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; @@ -126,7 +125,6 @@ export function SearchResultsHeaderBand({ ) : null}
-
); } 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/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index 04a4d5b66..de496b4a4 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -191,6 +191,15 @@ test.describe("universal search typeahead", () => { 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 }); From 5b4b60481409bbc5ad53dd9840e0ba2bd203dd31 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:24:26 +0800 Subject: [PATCH 6/9] fix(search): align medication shortcut counts --- .../universal-search-command-surface.tsx | 5 ++++- tests/ui-universal-search.spec.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 70082ae8e..2c7c72615 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -35,7 +35,10 @@ import { universalSearchModeForDomain } from "@/lib/universal-search-mode-contex // presentations and diagnoses into one result list. const domainsByTargetMode: Partial> = { documents: ["documents"], - prescribing: ["medications", "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"], diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index de496b4a4..1aa272fe7 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -125,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); From 423075ce1990090da0be7ea9a6a5a7805c2b635f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:45:59 +0000 Subject: [PATCH 7/9] Changes before error encountered Agent-Logs-Url: https://github.com/BigSimmo/Database/sessions/4cd83a04-6079-4ce9-a01e-907e84461079 --- .../formulation/formulation-home-page.tsx | 13 ------ src/lib/app-modes.ts | 43 ++++++++++++------- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/components/formulation/formulation-home-page.tsx b/src/components/formulation/formulation-home-page.tsx index d8aa387d3..ee12b3d11 100644 --- a/src/components/formulation/formulation-home-page.tsx +++ b/src/components/formulation/formulation-home-page.tsx @@ -32,19 +32,6 @@ import { searchFormulationMechanisms, } from "@/lib/formulation"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; -<<<<<<< HEAD:src/components/specifiers/specifiers-home-page.tsx -import { searchSpecifiers, specifierFamilies, specifierSearchPresets, type SpecifierFamily } from "@/lib/specifiers"; -import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; - -const diagnosisOptions = [ - { value: "", label: "All diagnoses" }, - { value: "depressive", label: "Depressive" }, - { value: "bipolar", label: "Bipolar" }, - { value: "psychotic", label: "Psychotic" }, - { value: "mood", label: "Mood episodes" }, -]; -======= ->>>>>>> origin/main:src/components/formulation/formulation-home-page.tsx function presetHref(query: string) { return appModeHomeHref("formulation", { query, run: true, focus: true }); diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index 8ca543406..869545c59 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -2,7 +2,6 @@ import type { ClinicalQueryMode } from "@/lib/types"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { appendSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context"; -<<<<<<< HEAD export const appModeIds = [ "answer", "documents", @@ -11,29 +10,18 @@ export const appModeIds = [ "favourites", "differentials", "specifiers", + "formulation", "prescribing", "tools", ] as const; export type AppModeId = (typeof appModeIds)[number]; -======= -export type AppModeId = - | "answer" - | "documents" - | "services" - | "forms" - | "favourites" - | "differentials" - | "formulation" - | "prescribing" - | "tools"; ->>>>>>> origin/main export type SearchableAppModeId = AppModeId; export type AppModeSearchKind = - "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "formulation" | "tools"; + "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "specifiers" | "formulation" | "tools"; export type AppModeResultKind = - "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "formulation" | "tools"; + "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "specifiers" | "formulation" | "tools"; export type AppModeSearchConfig = { kind: AppModeSearchKind; @@ -196,6 +184,28 @@ export const appModeDefinitions = [ defaultQueryMode: "compare_guidance", }, }, + { + id: "specifiers", + label: "Specifiers", + description: "Refine diagnostic wording and episode patterns", + href: "/specifiers", + search: { + kind: "specifiers", + placeholder: "Describe the presentation or search a specifier...", + inputAriaLabel: "Search psychiatric specifiers by presentation or diagnosis", + submitIdleLabel: "Find", + submitBusyLabel: "Find", + submitAriaLabel: "Find matching psychiatric specifiers", + emptyTitle: "Describe the presentation", + readyTitle: "Find the most relevant specifier", + progressLabel: "Matching presentation features to specifiers.", + resultKind: "specifiers", + resultHeading: "Specifier matches", + statusLabel: "Specifiers", + nextStep: "Check fit and refine the diagnostic wording", + badgeLabel: null, + }, + }, { id: "formulation", label: "Formulation", @@ -290,7 +300,7 @@ export function appModeSearchConfig(modeId: AppModeId) { return appModeDefinition(modeId).search; } -const namespaceIsolatedModes = new Set(["services", "forms", "favourites", "differentials", "formulation"]); +const namespaceIsolatedModes = new Set(["services", "forms", "favourites", "differentials", "specifiers", "formulation"]); export function appModeHomeHref(modeId: AppModeId, options: SearchNavigationOptions = {}) { const mode = appModeDefinition(modeId); @@ -360,6 +370,7 @@ export function isSearchableAppMode(modeId: string): modeId is SearchableAppMode kind === "forms" || kind === "favourites" || kind === "differentials" || + kind === "specifiers" || kind === "formulation" || kind === "tools" ); From e2652288e2c01cdc1dfa50435153c951a4b75a94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:58:19 +0000 Subject: [PATCH 8/9] fix: continue merge conflict resolution - add missing specifiers stub and type fixes --- package-lock.json | 66 ------------------- .../formulation/formulation-home-page.tsx | 1 + 2 files changed, 1 insertion(+), 66 deletions(-) diff --git a/package-lock.json b/package-lock.json index ca680a1c3..5e4db79c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2401,9 +2401,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2421,9 +2418,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2441,9 +2435,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2461,9 +2452,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2481,9 +2469,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2501,9 +2486,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2521,9 +2503,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2541,9 +2520,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2790,9 +2766,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2807,9 +2780,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2824,9 +2794,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2841,9 +2808,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2858,9 +2822,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2875,9 +2836,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2892,9 +2850,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2909,9 +2864,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3113,9 +3065,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3133,9 +3082,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3153,9 +3099,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3173,9 +3116,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3193,9 +3133,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3213,9 +3150,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/src/components/formulation/formulation-home-page.tsx b/src/components/formulation/formulation-home-page.tsx index ee12b3d11..c828e67dc 100644 --- a/src/components/formulation/formulation-home-page.tsx +++ b/src/components/formulation/formulation-home-page.tsx @@ -32,6 +32,7 @@ import { searchFormulationMechanisms, } from "@/lib/formulation"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; function presetHref(query: string) { return appModeHomeHref("formulation", { query, run: true, focus: true }); From d77c2b763d5ad5c2a35244d8f1b152b368db9386 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:59:43 +0800 Subject: [PATCH 9/9] fix(search): retain formulation as the canonical mode --- src/lib/app-modes.ts | 29 +++-------------------------- 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index ec44b09a4..3898cf4ca 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -18,9 +18,9 @@ export type AppModeId = (typeof appModeIds)[number]; export type SearchableAppModeId = AppModeId; export type AppModeSearchKind = - "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "specifiers" | "formulation" | "tools"; + "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "formulation" | "tools"; export type AppModeResultKind = - "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "specifiers" | "formulation" | "tools"; + "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "formulation" | "tools"; export type AppModeSearchConfig = { kind: AppModeSearchKind; @@ -183,28 +183,6 @@ export const appModeDefinitions = [ defaultQueryMode: "compare_guidance", }, }, - { - id: "specifiers", - label: "Specifiers", - description: "Refine diagnostic wording and episode patterns", - href: "/specifiers", - search: { - kind: "specifiers", - placeholder: "Describe the presentation or search a specifier...", - inputAriaLabel: "Search psychiatric specifiers by presentation or diagnosis", - submitIdleLabel: "Find", - submitBusyLabel: "Find", - submitAriaLabel: "Find matching psychiatric specifiers", - emptyTitle: "Describe the presentation", - readyTitle: "Find the most relevant specifier", - progressLabel: "Matching presentation features to specifiers.", - resultKind: "specifiers", - resultHeading: "Specifier matches", - statusLabel: "Specifiers", - nextStep: "Check fit and refine the diagnostic wording", - badgeLabel: null, - }, - }, { id: "formulation", label: "Formulation", @@ -299,7 +277,7 @@ export function appModeSearchConfig(modeId: AppModeId) { return appModeDefinition(modeId).search; } -const namespaceIsolatedModes = new Set(["services", "forms", "favourites", "differentials", "specifiers", "formulation"]); +const namespaceIsolatedModes = new Set(["services", "forms", "favourites", "differentials", "formulation"]); export function appModeHomeHref(modeId: AppModeId, options: SearchNavigationOptions = {}) { const mode = appModeDefinition(modeId); @@ -369,7 +347,6 @@ export function isSearchableAppMode(modeId: string): modeId is SearchableAppMode kind === "forms" || kind === "favourites" || kind === "differentials" || - kind === "specifiers" || kind === "formulation" || kind === "tools" );