diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 31ab8aee5e..bc47dabb47 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -250,6 +250,16 @@ Golden retrieval fixture: `scripts/fixtures/rag-retrieval-golden.json` - Registry modes: services, forms, medications, differentials - Demo mode: synthetic data when Supabase unavailable (`demo-data.ts`, `isDemoMode()` in `env.ts`) +### Global search composer placement rules + +One shared composer (`master-search-header.tsx`) serves every mode. Placement: + +- **Mode homes** (`/services`, `/forms`, `/favourites`, `/differentials`, `/applications`, and dashboard homes): inline in the hero via the `mode-home-composer-slot` portal, on phone and tablet+ alike. +- **Result and detail views**: fixed bottom dock on phone (compact variant on submitted searches), sticky top from `sm` up. +- **Results routing**: standalone routes own their submitted searches via `?q=…&run=1` (`/services` → `ServicesNavigatorPage`, `/forms` → `FormsSearchResultsPage`, `/differentials` → `DifferentialsHome` results view, `/favourites` filters the command library in place). Answer, Documents, and Prescribing submitted searches render inside `ClinicalDashboard` — intentional, since they need retrieval/answer state. `/?mode=favourites` redirects to `/favourites`; `/?mode=differentials` redirects to `/differentials`. +- **Intentionally composer-free routes**: `/differentials/presentations/*` (comparison workflow owns its chrome), `/documents/[id]` viewer (has its own in-document ask composer), `/documents/source/*` (document flow owns mobile chrome). Do not re-flag these in search-consistency audits. +- **Local filter fields** (sidebar "Search chats", document drawer "Find a document"/"Find a source PDF") are scoped filters, not global search; they share the `fieldControlWithIcon`/`fieldIcon` primitives. + --- ## Key config files diff --git a/public/llms.txt b/public/llms.txt index c2ec0686b7..a908f6dc0f 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -5,7 +5,7 @@ Purpose: Clinical Guide is a local clinical knowledge-base interface for searchi Agent / codebase orientation: docs/codebase-index.md (module map, APIs, Supabase, worker). Route index: docs/site-map.md. Key routes: -- / opens the main dashboard. Use ?mode=answer, ?mode=documents, ?mode=tools, ?mode=favourites, ?mode=differentials, or ?mode=prescribing to choose the workspace. +- / opens the main dashboard. Use ?mode=answer, ?mode=documents, ?mode=tools, ?mode=differentials, or ?mode=prescribing to choose the workspace. ?mode=favourites redirects to /favourites. - /documents/search opens the documents search command centre after submitting a documents-mode query. - /documents/:id opens an indexed source document. - /services opens source-backed service records. diff --git a/scripts/capture-chrome-parity.ts b/scripts/capture-chrome-parity.ts index 567a937173..e2dcee26c9 100644 --- a/scripts/capture-chrome-parity.ts +++ b/scripts/capture-chrome-parity.ts @@ -90,16 +90,16 @@ const selectorGroups: Array<{ key: string; selector: string; pseudo?: string }> type Snapshot = Record>; async function mockApis(page: Page) { - await page.route("**/api/setup-status**", async (route) => { + await page.route("**/api/setup-status**", async (route: Route) => { await route.fulfill({ json: { demoMode: true, checks: readySetupChecks } }); }); - await page.route(/\/api\/documents\/[0-9a-f-]+(?:\?.*)?$/, async (route) => { + await page.route(/\/api\/documents\/[0-9a-f-]+(?:\?.*)?$/, async (route: Route) => { const id = new URL(route.request().url()).pathname.split("/").pop() ?? ""; const payload = getDemoDocumentPayload(id); if (payload) await route.fulfill({ json: payload }); else await route.fulfill({ status: 404, json: { error: "not found" } }); }); - await page.route(/\/api\/documents(?:\?.*)?$/, async (route) => { + await page.route(/\/api\/documents(?:\?.*)?$/, async (route: Route) => { await route.fulfill({ json: { documents: demoDocuments, diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 4711cafb05..cb996d8fa3 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -14,7 +14,6 @@ import { medicationValidationStatus, rowGovernance, rowToMedicationRecord, - type MedicationRecordRow, } from "@/lib/medication-records"; import { medicationToSearchResult, diff --git a/src/app/applications/layout.tsx b/src/app/applications/layout.tsx index c9a0125402..641518a9fb 100644 --- a/src/app/applications/layout.tsx +++ b/src/app/applications/layout.tsx @@ -4,7 +4,7 @@ import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search export default function ApplicationsLayout({ children }: { children: ReactNode }) { return ( - + {children} ); diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx index 2f7fd01fd7..6e844a80c5 100644 --- a/src/app/applications/page.tsx +++ b/src/app/applications/page.tsx @@ -7,6 +7,19 @@ export const metadata: Metadata = { description: "Launch Clinical KB applications, workflows, and connected clinical tools.", }; -export default function ApplicationsRoute() { - return ; +type ApplicationsPageProps = { + searchParams?: Promise<{ + q?: string | string[]; + }>; +}; + +function firstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function ApplicationsRoute({ searchParams }: ApplicationsPageProps) { + const params = searchParams ? await searchParams : {}; + const query = firstSearchParam(params.q)?.trim(); + + return query ? : ; } diff --git a/src/app/differentials/layout.tsx b/src/app/differentials/layout.tsx index 5a35655626..b37056fb20 100644 --- a/src/app/differentials/layout.tsx +++ b/src/app/differentials/layout.tsx @@ -3,5 +3,9 @@ import type { ReactNode } from "react"; import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; export default function DifferentialsLayout({ children }: { children: ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/src/app/differentials/page.tsx b/src/app/differentials/page.tsx index e3d42c168c..7c3137a01b 100644 --- a/src/app/differentials/page.tsx +++ b/src/app/differentials/page.tsx @@ -1,7 +1,7 @@ import { DifferentialsHomePage } from "@/components/differentials/differentials-home-page"; type DifferentialsRouteProps = { - searchParams?: Promise<{ query?: string | string[]; q?: string | string[] }>; + searchParams?: Promise<{ query?: string | string[]; q?: string | string[]; run?: string | string[] }>; }; function firstSearchParam(value?: string | string[]) { @@ -10,11 +10,12 @@ function firstSearchParam(value?: string | string[]) { export default async function DifferentialsHomeRoute({ searchParams }: DifferentialsRouteProps) { const params = searchParams ? await searchParams : {}; - const query = firstSearchParam(params.query ?? params.q)?.trim(); + const query = (firstSearchParam(params.q) ?? firstSearchParam(params.query) ?? "").trim(); + const hasSubmittedSearch = firstSearchParam(params.run) === "1" && query.length > 0; - if (!query) { + if (!hasSubmittedSearch) { return ; } - return ; + return ; } diff --git a/src/app/favourites/layout.tsx b/src/app/favourites/layout.tsx index ab1d61dbda..abc0eb04d3 100644 --- a/src/app/favourites/layout.tsx +++ b/src/app/favourites/layout.tsx @@ -4,7 +4,7 @@ import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search export default function FavouritesLayout({ children }: { children: ReactNode }) { return ( - + {children} ); diff --git a/src/app/forms/page.tsx b/src/app/forms/page.tsx index 4a29418523..bd750838fd 100644 --- a/src/app/forms/page.tsx +++ b/src/app/forms/page.tsx @@ -1,5 +1,24 @@ import { FormsHomePage } from "@/components/forms/forms-home-page"; +import { FormsSearchResultsPage } from "@/components/forms/forms-search-results-page"; -export default function FormsPage() { - return ; +type FormsSearchParams = Promise<{ [key: string]: string | string[] | undefined }>; + +function readFirstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function FormsPage({ searchParams }: { searchParams: FormsSearchParams }) { + const resolvedSearchParams = await searchParams; + const query = ( + readFirstSearchParam(resolvedSearchParams.q) ?? + readFirstSearchParam(resolvedSearchParams.query) ?? + "" + ).trim(); + const hasSubmittedSearch = readFirstSearchParam(resolvedSearchParams.run) === "1" && query.length > 0; + + if (!hasSubmittedSearch) { + return ; + } + + return ; } diff --git a/src/app/globals.css b/src/app/globals.css index 57cbbd8612..bd286e1899 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1533,7 +1533,10 @@ summary::-webkit-details-marker { padding-bottom: max(0.45rem, var(--safe-area-bottom)); } - .answer-footer-search-dock[data-scroll-hidden="true"] { + /* Must beat the edge-to-edge dock rule above (transform: none) so scroll-hide + actually slides the bar off-screen once data-scroll-hidden is set. */ + .answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge[data-scroll-hidden="true"], + .answer-footer-search-dock.dashboard-composer-edge.answer-footer-search-edge[data-scroll-hidden="true"] { transform: translateY(calc(100% + env(safe-area-inset-bottom))); pointer-events: none; } @@ -1852,14 +1855,29 @@ summary::-webkit-details-marker { box-shadow: 0 0 0 4px color-mix(in srgb, var(--focus) 25%, transparent) !important; } - /* Premium Hover Transitions for Source Capsules and Action row chips */ + /* Premium hover transitions for source capsules */ .source-capsule-hover { - transition: all 180ms cubic-bezier(0.34, 1.56, 0.64, 1) !important; + box-shadow: var(--glow-soft), var(--shadow-inset); + transition: + transform 180ms cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 180ms cubic-bezier(0.22, 1, 0.36, 1), + border-color 150ms ease, + background-color 150ms ease !important; } .source-capsule-hover:hover { - transform: translateY(-1px) scale(1.015) !important; - box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 8%, transparent) !important; + transform: translateY(-1px) scale(1.01); + box-shadow: var(--shadow-tight), var(--shadow-inset); + } + + .source-capsule-hover[aria-expanded="true"] { + border-color: var(--clinical-accent); + box-shadow: var(--glow-soft), var(--shadow-inset); + } + + .source-capsule-hover[aria-expanded="true"]:hover { + transform: translateY(-1px) scale(1.01); + box-shadow: var(--shadow-tight), var(--shadow-inset); } .polished-scroll { @@ -1902,6 +1920,11 @@ summary::-webkit-details-marker { scroll-behavior: auto !important; transition-duration: 0.01ms !important; } + + .source-capsule-hover:hover, + .source-capsule-hover[aria-expanded="true"]:hover { + transform: none !important; + } } @media (forced-colors: active) { diff --git a/src/app/medications/layout.tsx b/src/app/medications/layout.tsx index 31812964b3..b891de2329 100644 --- a/src/app/medications/layout.tsx +++ b/src/app/medications/layout.tsx @@ -3,5 +3,9 @@ import type { ReactNode } from "react"; import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; export default function MedicationsLayout({ children }: { children: ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index adcf4a4668..c083f5ee9e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,5 @@ +import { redirect } from "next/navigation"; + import { HomePageClient } from "@/app/home-page-client"; import { isAppModeId, isAppModeVisible, type AppModeId } from "@/lib/app-modes"; @@ -20,5 +22,27 @@ export default async function Home({ searchParams }: HomeProps) { const initialSearchMode: AppModeId = isAppModeId(requestedMode) && isAppModeVisible(requestedMode) ? requestedMode : "answer"; + // /favourites is the canonical favourites surface; deep links via the + // dashboard mode param would otherwise open a divergent hub view. + if (initialSearchMode === "favourites") { + const favouriteParams = new URLSearchParams(); + const query = firstSearchParam(params.q)?.trim(); + if (query) favouriteParams.set("q", query); + if (firstSearchParam(params.focus) === "1") favouriteParams.set("focus", "1"); + if (firstSearchParam(params.run) === "1") favouriteParams.set("run", "1"); + const suffix = favouriteParams.toString(); + redirect(suffix ? `/favourites?${suffix}` : "/favourites"); + } + + if (initialSearchMode === "differentials") { + const differentialParams = new URLSearchParams(); + const query = firstSearchParam(params.q)?.trim(); + if (query) differentialParams.set("q", query); + if (firstSearchParam(params.focus) === "1") differentialParams.set("focus", "1"); + if (firstSearchParam(params.run) === "1") differentialParams.set("run", "1"); + const suffix = differentialParams.toString(); + redirect(suffix ? `/differentials?${suffix}` : "/differentials"); + } + return ; } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 868b63df5e..816970de50 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -4124,6 +4124,8 @@ export function ClinicalDashboard({ followUpSuggestions={answerFollowUpSuggestions} onPickFollowUpSuggestion={handlePickFollowUpSuggestion} followUpSuggestionsDisabled={loading} + crossModeQueries={[...priorAnswerTurns.map((turn) => turn.query), latestAnswerQuery]} + onCrossModeSearch={crossModeSearch} /> ) : null diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index 5e2a4c9bc0..3e9daf7a1a 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -24,8 +24,10 @@ import { import { type FormEvent, useMemo, useState } from "react"; import { ModeHomeHero, ModeHomeVerificationFooter } from "@/components/mode-home-template"; +import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { cn } from "@/components/ui-primitives"; import { Sheet } from "@/components/ui/sheet"; +import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { toolCatalogRecords, type ToolCatalogArea, @@ -666,11 +668,12 @@ export function ApplicationsLauncherWorkspace({ desktopComposerSlotId, className, }: ApplicationsLauncherWorkspaceProps) { + const searchCommand = useSearchCommand(); const [localQuery, setLocalQuery] = useState(""); const [activeFilter, setActiveFilter] = useState("all"); const [detailOpen, setDetailOpen] = useState(false); const copy = toolsLauncherCopy; - const query = controlledQuery ?? localQuery; + const query = controlledQuery ?? searchCommand?.query ?? localQuery; const normalizedQuery = query.trim().toLowerCase(); const queryDerivedId = useMemo(() => initialToolId(query), [query]); const [selection, setSelection] = useState(() => ({ @@ -711,7 +714,7 @@ export function ApplicationsLauncherWorkspace({ : copy.allSectionLabel; function updateQuery(nextQuery: string) { - if (controlledQuery === undefined) setLocalQuery(nextQuery); + if (controlledQuery === undefined && !searchCommand) setLocalQuery(nextQuery); } function openTool(id: string) { @@ -827,6 +830,6 @@ export function ApplicationsLauncherWorkspace({ ); } -export function ApplicationsLauncherPage() { - return ; +export function ApplicationsLauncherPage({ query }: { query?: string }) { + return ; } diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index d9172af478..e8e6f8aacf 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -22,7 +22,14 @@ import { } from "lucide-react"; import { appModeIcons } from "@/lib/app-mode-icons"; import { BrandMark } from "@/components/clinical-dashboard/brand"; -import { cn, sidebarItem, statusDotReady, textMuted } from "@/components/ui-primitives"; +import { + cn, + fieldControlWithIcon, + fieldIcon, + sidebarItem, + statusDotReady, + textMuted, +} from "@/components/ui-primitives"; function useClientMounted() { return useSyncExternalStore( @@ -161,14 +168,14 @@ export function ClinicalSidebarContent({ pinned. */}
diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index f2c8cda5de..2aca026b1d 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -28,6 +28,7 @@ import { statusDotReview, subtleStatusPill, textMuted, + toneWarningQuiet, } from "@/components/ui-primitives"; import { sourceResultHref } from "@/components/clinical-dashboard/source-actions"; import { @@ -574,11 +575,12 @@ export function NaturalLanguageAnswer({ setCopiedSourceQuote(false); } } + const cautionCapsule = weakEvidence || !grounded; const sourceCapsuleButton = ( ); @@ -612,7 +620,7 @@ export function NaturalLanguageAnswer({ > -
+

@@ -698,7 +706,7 @@ export function NaturalLanguageAnswer({ />

-
+
+
+
- {link.modeLabel} - - ); })} diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index ced7037dac..78592feee2 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -16,7 +16,6 @@ import { GuideDialog } from "@/components/clinical-dashboard/dashboard-shell"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { FormsSearchResultsPage } from "@/components/forms/forms-search-results-page"; import { ClientHydrationBoundary } from "@/components/client-hydration-boundary"; import { cn } from "@/components/ui-primitives"; import { @@ -141,18 +140,25 @@ function GlobalMockupSearchShellClient({ const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search") || isDocumentFlowRoute; const isDocumentCommandSearchView = pathname === "/documents/search" && requestedQuery.length > 0; const useCompactBottomSearch = hasSubmittedModeSearch || isDocumentCommandSearchView; + // Services, forms, and favourites own their submitted-search views on their + // standalone routes; the shell must not swap them to the dashboard. On the + // home route the dashboard always renders, so these exclusions only apply + // to the standalone pages. const shouldRenderDashboardSearch = - hasSubmittedModeSearch && resolvedSearchMode !== "services" && !isDocumentSearchMockupRoute; - const isFormsOnlyShell = availableModeIds?.length === 1 && availableModeIds[0] === "forms"; - const shouldRenderFormsSearchResults = - shouldRenderDashboardSearch && resolvedSearchMode === "forms" && isFormsOnlyShell; + hasSubmittedModeSearch && + resolvedSearchMode !== "services" && + resolvedSearchMode !== "forms" && + resolvedSearchMode !== "favourites" && + resolvedSearchMode !== "differentials" && + !isDocumentSearchMockupRoute; const isStandaloneModeHome = !hasSubmittedModeSearch && !shouldRenderDashboardSearch && ((searchMode === "services" && pathname === "/services") || (searchMode === "forms" && pathname === "/forms") || (searchMode === "favourites" && pathname === "/favourites") || - (searchMode === "differentials" && pathname === "/differentials")); + (searchMode === "differentials" && pathname === "/differentials") || + (searchMode === "tools" && pathname === "/applications")); const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations"); const shouldShowDesktopSidebar = !hideDesktopSidebar; const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow ? true : sidebarCollapsed; @@ -285,8 +291,7 @@ function GlobalMockupSearchShellClient({ } const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname); - const shouldRenderClinicalDashboard = - !isMedicationDetailRoute && (isHomeRoute || (shouldRenderDashboardSearch && !shouldRenderFormsSearchResults)); + const shouldRenderClinicalDashboard = !isMedicationDetailRoute && (isHomeRoute || shouldRenderDashboardSearch); if (shouldRenderClinicalDashboard) { return ( @@ -400,9 +405,7 @@ function GlobalMockupSearchShellClient({ // result views: compact the phone bottom composer so results keep // maximum screen space. Mode homes keep the chip-row layout. mobileBottomSearchVariant={useCompactBottomSearch ? "compact" : "default"} - desktopSearchPlacement={ - (desktopSearchPlacement === "hero" || isFormsOnlyShell) && isStandaloneModeHome ? "hero" : "default" - } + desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} searchComposerVisible={shouldShowSearchComposer} desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} heroComposerFromTablet={isStandaloneModeHome} @@ -445,7 +448,7 @@ function GlobalMockupSearchShellClient({ onClearScopes: () => setCommandScopes([]), }} > - {shouldRenderFormsSearchResults ? : children} + {children}
diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index c4a95a6b05..528c123486 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -321,7 +321,9 @@ export function MasterSearchHeader({ }); const headerChromeHidden = scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused; - const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && isMobileBottomComposer && usesPhoneSearchLayout); + const phoneBottomSearchDockActive = + usesPhoneSearchLayout && searchComposerVisible && (isAnswerFooterComposer || mobileSearchPlacement === "bottom"); + const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && phoneBottomSearchDockActive); const bottomComposerHidden = bottomComposerScrollHiddenActive && scrollHidden && @@ -1198,7 +1200,9 @@ export function MasterSearchHeader({ const usesMobileBottomStyle = isMobileBottomComposer && !isDesktopHomeComposer; const usesCompactMobileBottomStyle = usesMobileBottomStyle && mobileBottomSearchVariant === "compact"; const usesBottomComposerPlacement = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); - const usesFooterChipLayout = usesBottomComposerPlacement || isDesktopHomeComposer; + // Sticky-top result composers (tablet+) share the footer chip layout so the + // pill + chip row looks identical across homes, results, and the answer dock. + const usesFooterChipLayout = usesBottomComposerPlacement || isDesktopHomeComposer || usesMobileBottomStyle; // Keep footer suggestion chips on tablet/desktop; phones reach the same actions via "+". const showFooterSearchChips = usesFooterChipLayout && !usesPhoneSearchLayout; const usesSendAffordance = searchMode === "answer" || usesFooterChipLayout; @@ -1209,11 +1213,8 @@ export function MasterSearchHeader({ const secondaryFooterChip = footerSecondaryChipFor(searchMode); const TrustFooterChipIcon = trustFooterChip?.icon ?? BadgeCheck; const SecondaryFooterChipIcon = secondaryFooterChip?.icon ?? ListChecks; - const composerPlaceholder = - usesMobileBottomStyle && searchMode === "differentials" ? "Search a presentation" : queryPlaceholder; - const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout; - const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesMobileBottomStyle && usesPhoneFooterDock); + const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesPhoneFooterDock); const commandSurfacePlacement = usesBottomComposerPlacement ? "bottom-dock" : "inline"; @@ -1353,7 +1354,7 @@ export function MasterSearchHeader({ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") onAsk(); }} aria-label={`Search indexed guidelines by question or keyword - ${selectedSearch.inputAriaLabel}`} - placeholder={composerPlaceholder} + placeholder={queryPlaceholder} className={cn(chatComposerInput, "w-full min-w-0", "answer-footer-search-input")} /> {query && ( diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 388aa1e7b7..09dd1d006c 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -1,22 +1,10 @@ -"use client"; +"use client"; import { Activity, - AlertTriangle, - ArrowLeft, - ArrowLeftRight, - BadgeCheck, - Brain, CalendarDays, - Gauge, CheckCircle2, - ChevronDown, ChevronRight, - ClipboardCheck, - ClipboardList, - Droplet, - FileText, - FlaskConical, Lock, Pill, ShieldCheck, @@ -32,7 +20,7 @@ import { useSearchCommand } from "@/components/clinical-dashboard/search-command import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; import { medicationMatchesCommandScopes } from "@/lib/search-command-surface"; import { isDeployedClinicalKb } from "@/lib/deployed-app"; -import { cn, toneDanger, toneInfo, toneNeutral, toneSuccess, toneWarning } from "@/components/ui-primitives"; +import { cn } from "@/components/ui-primitives"; type MedicationPrescribingWorkspaceProps = { query: string; @@ -62,33 +50,6 @@ type MedicationResult = { href?: string; }; -// Badge tone key: clinical = action/instruction, success = verified/access, danger = stop/avoid, warning = adjust/check, neutral = passive metadata, info = process. -type ClinicalBadgeTone = "clinical" | "success" | "danger" | "warning" | "neutral" | "info"; - -type ClinicalBadgeItem = { - label: string; - tone?: ClinicalBadgeTone; - icon?: LucideIcon; -}; - -type DetailRow = { - label: string; - icon: LucideIcon; - summary?: string; - body?: string | string[]; - columns?: Array<{ label: string; value: string; meta?: string; metaTone?: ClinicalBadgeTone }>; - columnStyle?: "ledger" | "systems"; - badges?: ClinicalBadgeItem[]; - tone?: "default" | "danger"; - compact?: boolean; -}; - -type SideSection = { - title: string; - icon: LucideIcon; - items: Array<{ label: string; body: string; icon?: LucideIcon }>; -}; - type MedicationResultFilter = "best" | "indication" | "safety" | "monitoring"; const medicationResultFilters: Array<{ id: MedicationResultFilter; label: string }> = [ @@ -127,182 +88,6 @@ const medicationPrompts = [ { label: "sertraline max dose", icon: ShieldCheck }, ]; -const medicationIdentityBadges: ClinicalBadgeItem[] = [ - { label: "333 mg EC tablet", tone: "neutral" }, - { label: "PBS streamlined", tone: "success" }, - { label: "Reviewed", tone: "success", icon: BadgeCheck }, -]; - -const accessBadges: ClinicalBadgeItem[] = [ - { label: "Campral", tone: "neutral", icon: Pill }, - { label: "PBS streamlined", tone: "success" }, - { label: "Item 8357W", tone: "neutral" }, -]; - -const detailRows: DetailRow[] = [ - { - label: "Prescribing answer", - icon: ClipboardList, - summary: "Maintenance of alcohol abstinence after withdrawal, with renal function checked and support in place.", - body: [ - "Use for maintenance of alcohol abstinence once withdrawal is complete and the patient is abstinent.", - "Use alongside psychosocial support and relapse prevention. Not for acute alcohol withdrawal.", - ], - badges: [ - { label: "Abstinence maintenance", tone: "clinical", icon: CheckCircle2 }, - { label: "After withdrawal", tone: "neutral" }, - { label: "Psychosocial support", tone: "neutral" }, - ], - }, - { - label: "Dosing", - icon: CalendarDays, - summary: "666 mg TID with meals. Dose ceiling 1,998 mg/day.", - columnStyle: "ledger", - columns: [ - { label: "Usual dose", value: "666 mg (2 x 333 mg) TID with meals" }, - { label: "Dose ceiling", value: "1,998 mg/day", meta: "MAX", metaTone: "neutral" }, - { label: "Under 60 kg", value: "2 tablets morning, 1 midday, 1 night" }, - { label: "Treatment duration", value: "Around 1 year" }, - ], - badges: [ - { label: "666 mg TID", tone: "clinical", icon: CalendarDays }, - { label: "Max 1,998 mg/day", tone: "neutral", icon: Gauge }, - { label: "Reduce <60 kg", tone: "warning", icon: UserRound }, - { label: "Around 1 year", tone: "neutral" }, - ], - }, - { - label: "Administration", - icon: Pill, - summary: "Take with food. Swallow enteric-coated tablets whole.", - body: ["Take with food. Swallow EC tablets whole with water.", "Do not crush or chew."], - badges: [ - { label: "Take with food", tone: "clinical" }, - { label: "Swallow whole", tone: "clinical" }, - { label: "Do not crush", tone: "warning" }, - ], - }, - { - label: "Do not use", - icon: AlertTriangle, - tone: "danger", - summary: "Avoid if serum creatinine >120 micromol/L, Child-Pugh C, pregnancy, or breastfeeding.", - body: [ - "Known hypersensitivity to acamprosate or excipients.", - "Renal insufficiency: serum creatinine >120 micromol/L (contraindicated)", - "Severe hepatic failure (Child-Pugh C) (contraindicated)", - "Pregnancy (DO NOT USE)", - "Breastfeeding (DO NOT USE)", - ], - badges: [ - { label: "Cr >120 avoid", tone: "danger", icon: Droplet }, - { label: "Child-Pugh C", tone: "danger", icon: ShieldCheck }, - { label: "Pregnancy", tone: "danger" }, - { label: "Breastfeeding", tone: "danger" }, - ], - }, - { - label: "Populations", - icon: UserRound, - summary: "Avoid under 18 years and over 65 years because safety and efficacy are not established.", - body: "Avoid use in children/adolescents under 18 years and adults over 65 years: safety and efficacy are not established.", - badges: [ - { label: "Avoid <18 years", tone: "warning", icon: UserRound }, - { label: "Avoid >65 years", tone: "warning" }, - ], - }, - { - label: "Key risks", - icon: ShieldCheck, - summary: "Adverse effects grouped by system; separate from contraindications and do-not-use criteria.", - columnStyle: "systems", - columns: [ - { - label: "Gastrointestinal", - value: "Diarrhoea; nausea, vomiting, abdominal pain, flatulence", - meta: "Very common / common", - metaTone: "warning", - }, - { label: "Skin", value: "Rash and pruritus", meta: "Common", metaTone: "neutral" }, - { - label: "Sexual function", - value: "Reduced libido, impotence or frigidity", - meta: "Common", - metaTone: "neutral", - }, - { - label: "Neuropsychiatric", - value: "Mood change, depression, suicidal ideation: monitor clinically", - meta: "Monitor", - metaTone: "clinical", - }, - ], - badges: [ - { label: "Very common GI", tone: "warning" }, - { label: "Mood monitor", tone: "clinical" }, - { label: "Not contraindications", tone: "neutral" }, - ], - }, - { - label: "Pearls / PK", - icon: FlaskConical, - compact: true, - summary: "Renally excreted unchanged; half-life 13-28.4 hours.", - body: [ - "Mechanism is not fully established.", - "Not metabolised; excreted unchanged in urine.", - "Apparent half-life 13-28.4 h; minimal plasma protein binding.", - ], - badges: [ - { label: "Renal excretion", tone: "neutral" }, - { label: "Half-life 13-28.4 h", tone: "neutral" }, - { label: "Low protein binding", tone: "neutral" }, - ], - }, -]; - -const sideSections: SideSection[] = [ - { - title: "Checks and monitoring", - icon: Activity, - items: [ - { label: "Renal function", body: "Check baseline and periodically.", icon: Droplet }, - { label: "Hepatic status", body: "Avoid in severe hepatic failure; assess if suspected.", icon: ShieldCheck }, - { label: "Mood / suicidality", body: "Monitor, especially early in treatment.", icon: Brain }, - { label: "Adherence", body: "Reinforce regular dosing and psychosocial support.", icon: ClipboardCheck }, - ], - }, - { - title: "Interactions", - icon: ArrowLeftRight, - items: [ - { label: "Diazepam, disulfiram, imipramine", body: "No major PK interactions." }, - { label: "Naltrexone", body: "Increases acamprosate exposure; no dose adjustment required." }, - { label: "Other psychotropics", body: "Evidence is limited; monitor clinically." }, - ], - }, -]; - -type MedicationSectionId = "summary" | "dosing" | "safety" | "more"; -type ClinicalDetailView = "core" | "full"; - -const medicationSummaryTabs: Array<{ label: string; target: MedicationSectionId }> = [ - { label: "Summary", target: "summary" }, - { label: "Dosing", target: "dosing" }, - { label: "Safety", target: "safety" }, - { label: "More", target: "more" }, -]; - -const coreDetailLabels = new Set(["Prescribing answer", "Dosing", "Administration", "Do not use"]); - -function medicationSectionIdForLabel(label: string): MedicationSectionId { - if (label === "Dosing") return "dosing"; - if (label === "Do not use" || label === "Key risks") return "safety"; - if (label === "Populations" || label === "Pearls / PK") return "more"; - return "summary"; -} - function IconTile({ icon: Icon, tone = "teal", @@ -332,81 +117,6 @@ function IconTile({ ); } -function ClinicalBadge({ - label, - tone = "neutral", - icon: Icon, - compact = false, -}: ClinicalBadgeItem & { compact?: boolean }) { - const toneClassName: Record = { - clinical: - "border-[color:var(--clinical-accent)]/20 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", - success: toneSuccess, - danger: toneDanger, - warning: toneWarning, - neutral: toneNeutral, - info: toneInfo, - }; - - return ( - - {Icon ? - ); -} - -const clinicalBadgeTonePriority: Record = { - danger: 6, - warning: 5, - clinical: 4, - success: 3, - neutral: 2, - info: 1, -}; - -function BadgeCluster({ - items, - compact = false, - limit, - showOverflowCount = false, - className, -}: { - items?: ClinicalBadgeItem[]; - compact?: boolean; - limit?: number; - showOverflowCount?: boolean; - className?: string; -}) { - if (!items?.length) return null; - const orderedItems = - typeof limit === "number" - ? [...items].sort( - (a, b) => clinicalBadgeTonePriority[b.tone ?? "neutral"] - clinicalBadgeTonePriority[a.tone ?? "neutral"], - ) - : items; - const visibleItems = typeof limit === "number" ? orderedItems.slice(0, limit) : orderedItems; - const hiddenCount = typeof limit === "number" ? Math.max(0, items.length - visibleItems.length) : 0; - - return ( -
- {visibleItems.map((item, index) => ( - - ))} - {showOverflowCount && hiddenCount ? ( - - ) : null} -
- ); -} - function StatusNotice({ realDataReady, authUnavailable, @@ -613,7 +323,7 @@ function MedicationResults({ {catalog.loading ? ( -

Loading medication catalogue…

+

Loading medication catalogue…

) : catalog.error ? (

{catalog.error} @@ -773,579 +483,6 @@ function MedicationResults({ ); } - -function DetailTile({ - icon, - label, - value, - meta, - danger = false, -}: { - icon: LucideIcon; - label: string; - value: string; - meta?: string; - danger?: boolean; -}) { - return ( -

-
- -
-

- {label} -

-

{value}

- {meta ?

{meta}

: null} -
-
-
- ); -} - -function DetailRowBlock({ row }: { row: DetailRow }) { - const Icon = row.icon; - const body = row.body ? (Array.isArray(row.body) ? row.body : [row.body]) : []; - const columnStyle = row.columnStyle ?? "ledger"; - - return ( -
-
- -

- {row.label} -

-
-
- - {row.columns ? ( -
= 4 ? "md:grid-cols-4" : "md:grid-cols-3"), - columnStyle === "ledger" && "md:divide-x md:divide-y-0", - columnStyle === "systems" && "text-[color:var(--text-muted)]", - )} - > - {row.columns.map((column) => ( -
-
-

{column.label}

- {column.meta ? ( - - ) : null} -
-

- {column.value} -

-
- ))} -
- ) : row.tone === "danger" ? ( -
    - {body.map((item) => ( -
  • -
  • - ))} -
- ) : ( -
-
- {body.map((item) => ( -

{item}

- ))} -
- {row.compact ? ( -
- )} -
-
- ); -} - -function ClinicalViewToggle({ - value, - onChange, -}: { - value: ClinicalDetailView; - onChange: (value: ClinicalDetailView) => void; -}) { - const options: Array<{ value: ClinicalDetailView; label: string }> = [ - { value: "core", label: "Core" }, - { value: "full", label: "Full" }, - ]; - - return ( -
- {options.map((option) => { - const active = option.value === value; - return ( - - ); - })} -
- ); -} - -function DetailLedger({ - view, - onViewChange, -}: { - view: ClinicalDetailView; - onViewChange: (value: ClinicalDetailView) => void; -}) { - const coreRows = detailRows.filter((row) => coreDetailLabels.has(row.label)); - const secondaryRows = detailRows.filter((row) => !coreDetailLabels.has(row.label)); - const visibleRows = view === "core" ? coreRows : detailRows; - - return ( -
-
-
-

Clinical summary

-

- {view === "core" ? "High-yield prescribing information" : "Full medication reference"} -

-
- -
- - {visibleRows.map((row) => ( - - ))} - - {view === "core" ? ( -
- - - - - {secondaryRows.length} sections - - -
- {secondaryRows.map((row) => ( - - ))} -
-
- ) : null} -
- ); -} - -function SidePanel({ section }: { section: SideSection }) { - const Icon = section.icon; - return ( -
-
-
-
- {section.items.map((item) => { - const ItemIcon = item.icon; - return ( -
-
- {ItemIcon ? ( - - ) : ( -
-
- ); - })} -
-
- ); -} - -function MedicationSummaryTabs({ - activeSection, - onSectionChange, -}: { - activeSection: MedicationSectionId; - onSectionChange: (section: MedicationSectionId) => void; -}) { - return ( -
-
- {medicationSummaryTabs.map((item) => ( - - ))} -
-
- ); -} - -function MedicationBadges() { - return ; -} - -function AccessPanel() { - return ( -
-
-
- -
- {[ - ["Brand", "Campral"], - ["PBS status", "PBS streamlined"], - ["PBS item", "8357W"], - ].map(([label, value], index) => ( -
-
{label}
-
{value}
-
- ))} -
-
- ); -} - -function SourcesDisclosure({ mobile = false }: { mobile?: boolean }) { - return ( -
- - - - -
- Australian Product Information, PBS, DACAS, Australian Prescriber. -
-
- ); -} - -function MobileDetailCard({ row, compact = false }: { row: DetailRow; compact?: boolean }) { - const Icon = row.icon; - const body = row.body ? (Array.isArray(row.body) ? row.body : [row.body]) : []; - const columnStyle = row.columnStyle ?? "ledger"; - - return ( -
- -
-

- {row.label} -

- - {compact && row.summary ? ( -

{row.summary}

- ) : null} - {row.columns && !compact ? ( -
- {row.columns.map((column) => ( -
-
-

{column.label}

- {column.meta ? ( - - ) : null} -
-

- {column.value} -

-
- ))} -
- ) : !compact && body.length ? ( -
- {body.map((item) => ( -

- {row.tone === "danger" ? ( -

- ))} -
- ) : null} -
-
- ); -} - -type MobileDisclosurePanelData = { - label: string; - icon: LucideIcon; - badges?: ClinicalBadgeItem[]; - body: string[]; -}; - -function MobileDisclosurePanel({ panel }: { panel: MobileDisclosurePanelData }) { - const Icon = panel.icon; - - return ( -
- - - - -
- -
    - {panel.body.map((item) => { - const separatorIndex = item.indexOf(": "); - const label = separatorIndex >= 0 ? item.slice(0, separatorIndex) : null; - const value = separatorIndex >= 0 ? item.slice(separatorIndex + 2) : item; - - return ( -
  • - {label ? {label} : null} - {value} -
  • - ); - })} -
-
-
- ); -} - -function MobileDetailList({ activeSection }: { activeSection: MedicationSectionId }) { - const rowsBySection: Record = { - summary: detailRows.filter((row) => ["Prescribing answer", "Dosing", "Do not use"].includes(row.label)), - dosing: detailRows.filter((row) => ["Dosing", "Administration"].includes(row.label)), - safety: detailRows.filter((row) => ["Do not use", "Populations", "Key risks"].includes(row.label)), - more: detailRows.filter((row) => row.label === "Pearls / PK"), - }; - const morePanels: MobileDisclosurePanelData[] = [ - { - label: "Checks and monitoring", - icon: Activity, - badges: [ - { label: "Renal function", tone: "clinical", icon: Droplet }, - { label: "Mood monitor", tone: "clinical", icon: Brain }, - { label: "Adherence", tone: "neutral", icon: ClipboardCheck }, - ], - body: sideSections[0].items.map((item) => `${item.label}: ${item.body}`), - }, - { - label: "Interactions", - icon: ArrowLeftRight, - badges: [{ label: "PK interactions limited", tone: "neutral" }], - body: sideSections[1].items.map((item) => `${item.label}: ${item.body}`), - }, - { - label: "Access", - icon: Lock, - badges: accessBadges, - body: ["Brand: Campral", "PBS status: PBS streamlined", "PBS item: 8357W"], - }, - ]; - - return ( -
-
- {rowsBySection[activeSection].map((row) => ( - - ))} -
- - {activeSection === "more" ? ( - <> -
- {morePanels.map((panel) => ( - - ))} -
- - - - ) : null} -
- ); -} - -function MedicationDetail() { - const [clinicalDetailView, setClinicalDetailView] = useState("core"); - const [activeMobileSection, setActiveMobileSection] = useState("summary"); - - return ( -
-
-
-
-
- -
-

- Acamprosate -

-

- Alcohol abstinence maintenance ·{" "} - GABA/glutamate modulator -

- -
-
-
- -
- - - - -
- - - - - - -
- - -
-
- ); -} - -export function AcamprosateMedicationPage() { - return ( -
-
- -
-
- -
-
- Clinical KB provides evidence summaries, not medical advice. Verify clinical decisions. -
-
- ); -} - export function MedicationPrescribingWorkspace({ query, loading, diff --git a/src/components/clinical-dashboard/medication-record-page.tsx b/src/components/clinical-dashboard/medication-record-page.tsx index 70e35d2396..816c52b3c3 100644 --- a/src/components/clinical-dashboard/medication-record-page.tsx +++ b/src/components/clinical-dashboard/medication-record-page.tsx @@ -9,6 +9,7 @@ import { ChevronDown, ClipboardList, FlaskConical, + Lock, Pill, ShieldCheck, type LucideIcon, @@ -16,16 +17,18 @@ import { import Link from "next/link"; import { useMemo, useState } from "react"; +import { BadgeCluster, clinicalBadgeToneClass } from "@/components/clinical-dashboard/clinical-badge"; import { useMedicationDetail } from "@/components/clinical-dashboard/use-medication-catalog"; import { - medicationDetailTiles, + medicationAccessBadges, + medicationAccessFields, medicationIdentityBadges, - type MedicationRecord, - type MedicationSection, -} from "@/lib/medications"; -import { cn, toneDanger, toneInfo, toneNeutral, toneSuccess, toneWarning } from "@/components/ui-primitives"; - -type ClinicalBadgeTone = "clinical" | "success" | "danger" | "warning" | "neutral" | "info"; + medicationRowBadges, + medicationStatTone, + type MedicationGovernance, +} from "@/lib/medication-badges"; +import { medicationDetailTiles, type MedicationRecord, type MedicationSection } from "@/lib/medications"; +import { cn } from "@/components/ui-primitives"; const sectionIcons: Record = { dose: CalendarDays, @@ -37,29 +40,6 @@ const sectionIcons: Record = { src: BadgeCheck, }; -function ClinicalBadge({ label, tone = "neutral" }: { label: string; tone?: ClinicalBadgeTone }) { - const toneClassName: Record = { - clinical: - "border-[color:var(--clinical-accent)]/20 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", - success: toneSuccess, - danger: toneDanger, - warning: toneWarning, - neutral: toneNeutral, - info: toneInfo, - }; - - return ( - - {label} - - ); -} - function DetailTile({ label, value, @@ -98,6 +78,7 @@ function DetailTile({ function SectionCard({ section }: { section: MedicationSection }) { const Icon = sectionIcons[section.type] || ClipboardList; + return (
- {section.rows.map((row) => ( -
-

{row.key}

-

- {row.val.replace(/\*\*/g, "")} -

- {row.tags?.length ? ( -
- {row.tags.map((tag) => ( - - ))} -
- ) : null} -
- ))} + {section.rows.map((row) => { + const rowBadges = medicationRowBadges(row, section.type); + return ( +
+

{row.key}

+ +

+ {row.val.replace(/\*\*/g, "")} +

+
+ ); + })}
); } -function MedicationRecordDetail({ record }: { record: MedicationRecord }) { +function MedicationAccessPanel({ record }: { record: MedicationRecord }) { + const badges = useMemo(() => medicationAccessBadges(record), [record]); + const fields = useMemo(() => medicationAccessFields(record), [record]); + if (!badges.length && !fields.length) return null; + + return ( +
+
+
+
+ + {fields.length ? ( +
+ {fields.map((field, index) => ( +
+
{field.label}
+
{field.value}
+
+ ))} +
+ ) : null} +
+
+ ); +} + +function MedicationRecordDetail({ + record, + governance, +}: { + record: MedicationRecord; + governance?: MedicationGovernance; +}) { const tiles = useMemo(() => medicationDetailTiles(record), [record]); - const badges = useMemo(() => medicationIdentityBadges(record), [record]); + const badges = useMemo(() => medicationIdentityBadges(record, governance), [record, governance]); const [activeTab, setActiveTab] = useState<"summary" | "dosing" | "safety" | "more">("summary"); const sectionsByTab = useMemo(() => { @@ -179,11 +197,7 @@ function MedicationRecordDetail({ record }: { record: MedicationRecord }) { ) : null}

-
- {badges.map((badge) => ( - - ))} -
+ @@ -237,7 +251,7 @@ function MedicationRecordDetail({ record }: { record: MedicationRecord }) { -