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",
],
}));