diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 0f014763f9..198579a673 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -88,6 +88,7 @@ import { resolveDashboardVisibleMobileComposerReserve, resolveMobileComposerReserve, } from "@/components/clinical-dashboard/mobile-composer-reserve"; +import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { FavouritesGuestGate } from "@/components/clinical-dashboard/favourites-guest-gate"; import { useDashboardShellActions } from "@/components/clinical-dashboard/use-dashboard-shell-actions"; import { focusComposerInput as scheduleComposerFocus } from "@/components/clinical-dashboard/focus-composer-input"; @@ -3026,6 +3027,21 @@ export function ClinicalDashboard({ activeModeResultKind === "answer" && answerProgressEvents.length > 0 && (loading || (Boolean(answer) && answerProgressCompleted)); + const universalAlsoMatchesQuery = activeModeResultKind === "answer" ? (latestAnswerQuery ?? query) : query; + // Answer-mode also-matches wait for a completed generation (`answer && !loading`) + // so the panel never sits under the drafting skeleton/stepper. Tools/Favourites + // still mount on submission. Follow-ups hide the panel while loading so stale + // matches for the prior query do not compete with the new Drafting stepper. + const showUniversalAlsoMatches = + !showSharedHome && + Boolean(universalAlsoMatchesQuery.trim()) && + (activeModeResultKind === "tools" || + activeModeResultKind === "favourites" || + (activeModeResultKind === "answer" && Boolean(answer) && !loading) || + ((activeModeResultKind === "documents" || + activeModeResultKind === "services" || + activeModeResultKind === "forms") && + modeSearchSubmitted)); const showDesktopHomeComposer = !error && (showSharedHome || @@ -3624,6 +3640,15 @@ export function ClinicalDashboard({ ) : null)} + {showUniversalAlsoMatches && + (activeModeResultKind === "tools" || + activeModeResultKind === "favourites" || + activeModeResultKind === "documents" || + activeModeResultKind === "services" || + activeModeResultKind === "forms") ? ( + + ) : null} + {showSharedHome ? ( // The one home surface, shared by all 13 modes. It sits above every // mode-specific branch so picking a mode on `/` changes only its @@ -3784,6 +3809,10 @@ export function ClinicalDashboard({ > ) : null ) : null} + + {showUniversalAlsoMatches && activeModeResultKind === "answer" ? ( + + ) : null} {showSystemNotice && answer ? ( diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index a757860f4a..517d1f57d2 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -44,6 +44,7 @@ import { SearchResultsEmptyState, SearchResultsHeaderBand, } from "@/components/clinical-dashboard/search-results-header-band"; +import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { appModeIcons } from "@/lib/app-mode-icons"; import { canAccessFavouritesMode } from "@/lib/app-modes"; import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; @@ -1248,6 +1249,7 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: onSelectItem={setSelectedItemId} /> )} + {selectedItem ? ( 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 0000000000..e954ae0f10 --- /dev/null +++ b/src/components/clinical-dashboard/universal-search-also-matches.tsx @@ -0,0 +1,239 @@ +"use client"; + +import Link from "next/link"; +import { ChevronDown, Layers } from "lucide-react"; +import { useEffect, useId, useState } from "react"; + +import { useFavouritesAccess } from "@/components/clinical-dashboard/use-favourites-access"; +import { useUniversalSearch } from "@/components/clinical-dashboard/use-universal-search"; +import { cn, textMuted } from "@/components/ui-primitives"; +import { appModeDefinition, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; +import { appModeIcons } from "@/lib/app-mode-icons"; +import { isLocalNoAuthMode, resolveClientDemoMode } from "@/lib/client-env"; +import { useAuthSession } from "@/lib/supabase/client"; +import { universalSearchModeForDomain, universalSearchPreferredDomains } from "@/lib/universal-search-mode-context"; + +function isFavouritesHref(href: string) { + return href === "/favourites" || href.startsWith("/favourites?"); +} + +function matchCountLabel(count: number) { + return count === 1 ? "1 related mode" : `${count} related modes`; +} + +export function UniversalSearchAlsoMatches({ + modeId, + query, + className, +}: { + modeId: AppModeId; + query: string; + className?: string; +}) { + const auth = useAuthSession(); + const clientDemoMode = resolveClientDemoMode({ + explicitDemoMode: process.env.NEXT_PUBLIC_DEMO_MODE === "true", + authUnavailableFallback: !auth.isConfigured, + localNoAuthMode: isLocalNoAuthMode(), + }); + const { favouritesAccessible } = useFavouritesAccess(auth.status === "authenticated", clientDemoMode); + const trimmedQuery = query.trim(); + const panelId = useId(); + // Collapsed by default on phones so this cross-mode panel does not push the + // primary results down; desktop always shows the grid (see the sm: rules below), + // so the toggle state only governs the narrow-viewport disclosure. + const [expanded, setExpanded] = useState(false); + // Track the sm breakpoint (640px) so the header's disclosure semantics match + // reality: on desktop the grid is always visible, so the button reports + // expanded and drops out of the interaction/tab flow rather than claiming to + // be a collapsed control the user can toggle to no effect. + const [isWide, setIsWide] = useState(false); + const [viewportReady, setViewportReady] = useState(false); + useEffect(() => { + const query = window.matchMedia("(min-width: 640px)"); + const sync = () => { + setIsWide(query.matches); + setViewportReady(true); + }; + sync(); + query.addEventListener("change", sync); + return () => query.removeEventListener("change", sync); + }, []); + // ClinicalDashboard mounts Answer-mode also-matches only after generation + // completes (`answer && !loading`), so this fetch never races the answer + // stream. Once mounted, keep the panel eager and invisible until real matches + // arrive; a speculative phone disclosure would add dead space to short + // answers that have no cross-mode matches. + const searchActive = isWide || modeId === "answer" || expanded; + const universal = useUniversalSearch({ + query: trimmedQuery, + enabled: trimmedQuery.length >= 2 && searchActive, + contextMode: modeId, + excludeDomains: universalSearchPreferredDomains(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; + if (targetModeId === "favourites" && !favouritesAccessible) continue; + const modeGroup = byMode.get(targetModeId) ?? { modeId: targetModeId, items: [] }; + for (const item of group.items) { + if (!favouritesAccessible && isFavouritesHref(item.href)) continue; + 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); + })(); + + const currentGroups = universal.query === trimmedQuery ? groups : []; + const searchPending = searchActive && (universal.loading || universal.query !== trimmedQuery); + const panelStatus = searchPending ? "Searching other modes" : "No additional matches in other modes."; + const matchCount = currentGroups.length; + const phoneSubtitle = searchPending + ? "Searching…" + : !searchActive + ? "Tap to browse related modes" + : matchCount > 0 + ? matchCountLabel(matchCount) + : "No additional matches"; + + if (!viewportReady || trimmedQuery.length < 2) return null; + if (modeId === "answer" && currentGroups.length === 0) return null; + if (isWide && !searchPending && currentGroups.length === 0) return null; + + // Count badge: ellipsis while collapsed/pending/empty so a finished-empty + // disclosure does not show a literal "0" next to "No additional matches". + const phoneCountBadge = !searchActive || searchPending || matchCount === 0 ? "…" : String(matchCount); + + return ( + + { + if (!isWide) setExpanded((value) => !value); + }} + aria-expanded={isWide ? true : expanded} + aria-controls={panelId} + tabIndex={isWide ? -1 : undefined} + className={cn( + "flex min-h-tap w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors", + "hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", + // On desktop the panel is always open, so the header is inert copy rather than a control. + "sm:pointer-events-none sm:mb-1.5 sm:min-h-0 sm:cursor-default sm:gap-2 sm:px-2 sm:py-1 sm:hover:bg-transparent", + )} + > + + + + + + + Also matches in other modes + + + {phoneCountBadge} + + + {/* Visual cue only — keep the button name to the title (+ optional count). */} + + {phoneSubtitle} + + + Across Clinical KB + + + + + + {searchPending || currentGroups.length === 0 ? ( + + {panelStatus} + + ) : null} + {currentGroups.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/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index 6cc1469704..98c820eb1f 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -36,6 +36,7 @@ import { SearchResultsEmptyState, SearchResultsHeaderBand, } from "@/components/clinical-dashboard/search-results-header-band"; +import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { ResultFilterTrigger } from "@/components/clinical-dashboard/result-filter-control"; import { FormCodeBadge } from "@/components/forms/form-code-badge"; import { Sheet } from "@/components/ui/sheet"; @@ -890,6 +891,7 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { > )} + > ) : null} {supportsPathwayClaims ? : null} diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 585926aa0d..cbf976c70f 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -29,6 +29,7 @@ import { SearchResultsHeaderBand, SearchResultsSkeleton, } from "@/components/clinical-dashboard/search-results-header-band"; +import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { ResultFilterSheet, ResultFilterTrigger, @@ -820,6 +821,7 @@ export function ServicesNavigatorPage() { /> ))} + > )} diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index 87231f067c..abe143c338 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -277,6 +277,89 @@ test.describe("universal search typeahead", () => { await context.close(); } }); + + test("keeps submitted cross-mode matches off the unsubmitted shared home", async ({ page }) => { + await mockUniversalSearch(page); + await page.goto("/?mode=therapy-compass&q=acamprosate&run=1", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("universal-also-matches")).toBeVisible(); + + const input = await openComposer(page, "/?mode=therapy-compass&focus=1"); + await input.fill("acamprosate"); + + await expect(page.getByTestId("universal-also-matches")).toHaveCount(0); + }); + + test("keeps compact cross-mode matches visible after submission", async ({ page }) => { + await mockUniversalSearch(page); + const universalRequest = page.waitForRequest(/\/api\/search\/universal(?:\?.*)?$/); + await page.goto("/services?q=13YARN&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(); + expect(new URL((await universalRequest).url()).searchParams.get("domains")?.split(",")).not.toContain("services"); + }); + + test("places submitted cross-mode matches after the owning mode results", async ({ page }) => { + // "13YARN" matches the demo service fixture so service-search-results renders. + // Use an inline mock that echoes back the same query so universal-also-matches renders. + await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { + const url = new URL(route.request().url()); + const mode = url.searchParams.get("mode") ?? "services"; + const q = url.searchParams.get("q") ?? "13YARN"; + await fulfillUniversalSearch(route, { + ...universalPayload, + query: q, + contextMode: mode, + preferredDomains: [], + domainOrder: universalPayload.groups.map((g) => g.kind), + }); + }); + await page.goto("/services?q=13YARN&run=1", { waitUntil: "domcontentloaded" }); + + const results = page.getByTestId("service-search-results"); + const alsoMatches = page.getByTestId("universal-also-matches"); + await expect(results).toBeVisible(); + await expect(alsoMatches).toBeVisible(); + expect( + await alsoMatches.evaluate((node) => { + const resultNode = document.querySelector('[data-testid="service-search-results"]'); + return Boolean((resultNode?.compareDocumentPosition(node) ?? 0) & Node.DOCUMENT_POSITION_FOLLOWING); + }), + "universal-also-matches panel must appear after primary results in the DOM", + ).toBe(true); + }); + + test("loads submitted cross-mode matches on phones only after expansion", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + const universalRequests: string[] = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/search/universal") universalRequests.push(request.url()); + }); + await mockUniversalSearch(page); + await page.goto("/forms?q=acamprosate&run=1", { waitUntil: "domcontentloaded" }); + + const alsoMatches = page.getByTestId("universal-also-matches"); + await expect(alsoMatches).toBeVisible(); + await expect(alsoMatches).toHaveCount(1); + expect(universalRequests).toHaveLength(0); + + await alsoMatches.getByRole("button", { name: /Also matches in other modes/ }).click(); + await expect.poll(() => universalRequests.length).toBe(1); + await expect(alsoMatches.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. @@ -353,6 +436,77 @@ test.describe("universal search smart affordances", () => { 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 page.getByRole("button", { name: "Generate source-backed answer" }).click(); + + await expect(page.getByTestId("universal-also-matches")).toBeVisible(); + }); + + test("hides Answer-mode also-matches while drafting and shows them after the final answer", async ({ page }) => { + await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { + await fulfillUniversalSearch(route, smartPayload); + }); + + await page.addInitScript( + ({ payload }) => { + const originalFetch = window.fetch.bind(window); + window.fetch = async (input, init) => { + const rawUrl = typeof input === "string" ? input : input instanceof Request ? input.url : String(input); + const pathname = new URL(rawUrl, window.location.href).pathname; + if (pathname !== "/api/answer/stream") return originalFetch(input, init); + + const encoder = new TextEncoder(); + const events: Array<{ delay: number; event: string; data: unknown }> = [ + { delay: 0, event: "progress", data: { stage: "scoping", message: "Preparing scope." } }, + { delay: 80, event: "progress", data: { stage: "retrieving", message: "Searching documents." } }, + { delay: 160, event: "progress", data: { stage: "ranking", message: "Selecting governed sources." } }, + { + delay: 240, + event: "progress", + data: { stage: "generating", message: "Drafting a cited answer from the selected passages." }, + }, + { + delay: 1_800, + event: "progress", + data: { stage: "complete", message: "Answer ready.", elapsedMs: 1_800 }, + }, + { delay: 1_900, event: "final", data: payload }, + ]; + + return new Response( + new ReadableStream({ + start(controller) { + for (const item of events) { + window.setTimeout(() => { + controller.enqueue(encoder.encode(`event: ${item.event}\ndata: ${JSON.stringify(item.data)}\n\n`)); + if (item.event === "final") controller.close(); + }, item.delay); + } + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream; charset=utf-8" } }, + ); + }; + }, + { payload: syntheticAnswer }, + ); + + const input = await openComposer(page, "/?mode=answer&focus=1"); + await input.fill("acamprosat"); + await page.getByRole("button", { name: "Generate source-backed answer" }).click(); + + const progress = page.getByTestId("answer-progress-stepper"); + await expect(progress).toBeVisible(); + await expect(progress).toContainText("Drafting a cited answer from the selected passages."); + await expect(page.getByTestId("universal-also-matches")).toHaveCount(0); + + await expect(page.getByTestId("universal-also-matches")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText("Also matches in other modes")).toBeVisible(); + }); + test("keeps a saved exact match first in Favourites", async ({ page }) => { await mockSmartSearch(page); const input = await openComposer(page, "/favourites?focus=1");
+ {panelStatus} +