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"
);