From becee44f8843788af40d1aa8a31e82fdd1bcd97c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:14:36 +0800 Subject: [PATCH 01/13] feat: add global mockup search shell --- mockups/README.md | 29 +- scripts/playwright-base-url.ts | 33 +- src/app/applications/page.tsx | 12 + src/app/medications/[slug]/page.tsx | 35 + src/app/medications/page.tsx | 5 + src/app/mockups/layout.tsx | 7 + src/app/page.tsx | 30 +- src/app/tools/page.tsx | 461 +---- src/components/ClinicalDashboard.tsx | 1646 +++++++++++++++-- src/components/applications-launcher-page.tsx | 1162 ++++++++++++ .../global-mockup-search-shell.tsx | 174 ++ .../master-search-header.tsx | 279 ++- .../medication-prescribing-workspace.tsx | 1236 +++++++++++++ src/lib/app-modes.ts | 199 ++ src/lib/source-governance.ts | 2 +- src/lib/tools.ts | 5 +- tests/app-modes.test.ts | 85 + tests/ui-accessibility.spec.ts | 6 +- tests/ui-smoke.spec.ts | 181 +- tests/ui-stress.spec.ts | 39 +- tests/ui-tools.spec.ts | 93 +- 21 files changed, 4941 insertions(+), 778 deletions(-) create mode 100644 src/app/applications/page.tsx create mode 100644 src/app/medications/[slug]/page.tsx create mode 100644 src/app/medications/page.tsx create mode 100644 src/app/mockups/layout.tsx create mode 100644 src/components/applications-launcher-page.tsx create mode 100644 src/components/clinical-dashboard/global-mockup-search-shell.tsx create mode 100644 src/components/clinical-dashboard/medication-prescribing-workspace.tsx create mode 100644 src/lib/app-modes.ts create mode 100644 tests/app-modes.test.ts diff --git a/mockups/README.md b/mockups/README.md index 7b96b91572..f2d3cc7ea9 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -4,18 +4,35 @@ This folder collects the current mockup files for the Clinical KB Database proje ## Included mockups -- `favourites-hub/page.tsx` - copied from `src/app/mockups/favourites-hub/page.tsx` -- `medication-prescribing/page.tsx` - copied from `src/app/mockups/medication-prescribing/page.tsx` +- Medication prescribing now lives in the app at `/?mode=prescribing` and `/medications/acamprosate`. - `answer-evidence-popups/page.tsx` - copied from `src/app/mockups/answer-evidence-popups/page.tsx` -- `user-home-profile/page.tsx` - copied from `src/app/mockups/user-home-profile/page.tsx` - `mode-dropdown` - runnable mockup only, in `src/app/mockups/mode-dropdown/page.tsx` +- `recent-searches-bottom` - runnable mockup only, in `src/app/mockups/recent-searches-bottom/page.tsx` +- `settings-search-general` - runnable mockup only, in `src/app/mockups/settings-search-general/page.tsx` +- `settings-search-clinical` - runnable mockup only, in `src/app/mockups/settings-search-clinical/page.tsx` +- `settings-search-privacy` - runnable mockup only, in `src/app/mockups/settings-search-privacy/page.tsx` ## App routes The runnable versions remain in the Next.js app route tree: -- `/mockups/favourites-hub` -- `/mockups/medication-prescribing` +- `/?mode=prescribing` +- `/medications/acamprosate` - `/mockups/answer-evidence-popups` -- `/mockups/user-home-profile` - `/mockups/mode-dropdown` +- `/mockups/recent-searches-bottom` + +Favourites now lives in the live dashboard flow at `/?mode=favourites`; `/mockups/favourites-hub` redirects there for old links. + +## Global search shell + +New runnable mockups under `src/app/mockups/*` inherit the shared Clinical KB header and bottom search composer from +`src/app/mockups/layout.tsx`. + +- Put the mockup content between the global header and bottom composer; do not copy the header or composer into new pages. +- Use `?mode=answer`, `?mode=documents`, `?mode=prescribing`, `?mode=evidence`, or `?mode=favourites` to preview the active search mode. +- The bottom composer routes live searches to the dashboard with `mode`, `q`, and `run=1`; New chat routes to `/?mode=answer&focus=1`. +- If a future mockup must be standalone, move it outside the `/mockups` route shell or add an explicit opt-out route group before implementing it. +- `/mockups/settings-search-general` +- `/mockups/settings-search-clinical` +- `/mockups/settings-search-privacy` diff --git a/scripts/playwright-base-url.ts b/scripts/playwright-base-url.ts index 314d8b7abc..d0c3ac75e7 100644 --- a/scripts/playwright-base-url.ts +++ b/scripts/playwright-base-url.ts @@ -8,7 +8,7 @@ const localUrlPattern = /^http:\/\/localhost:\d+$/; const identityScript = ` const http = require("node:http"); const url = process.argv[1] + "/api/local-project-id"; -const request = http.get(url, { timeout: 5000 }, (response) => { +const request = http.get(url, { timeout: 15000 }, (response) => { let body = ""; response.setEncoding("utf8"); response.on("data", (chunk) => { body += chunk; }); @@ -21,12 +21,33 @@ request.on("timeout", () => { request.destroy(); process.exit(3); }); request.on("error", () => process.exit(4)); `; +function sleepSync(ms: number) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + function verifyLocalProjectIdentity(baseUrl: string) { - const output = execFileSync(process.execPath, ["-e", identityScript, baseUrl], { - cwd: projectRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "inherit"], - }).trim(); + let lastError: unknown = null; + let output = ""; + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + output = execFileSync(process.execPath, ["-e", identityScript, baseUrl], { + cwd: projectRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + timeout: 20_000, + }).trim(); + break; + } catch (error) { + lastError = error; + if (attempt < 2) sleepSync(750); + } + } + + if (!output) { + throw lastError instanceof Error ? lastError : new Error(`Could not verify local project identity: ${baseUrl}`); + } + const payload = JSON.parse(output) as { appName?: string; projectId?: string; diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx new file mode 100644 index 0000000000..2f7fd01fd7 --- /dev/null +++ b/src/app/applications/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ApplicationsLauncherPage } from "@/components/applications-launcher-page"; + +export const metadata: Metadata = { + title: "Applications - Clinical KB", + description: "Launch Clinical KB applications, workflows, and connected clinical tools.", +}; + +export default function ApplicationsRoute() { + return ; +} diff --git a/src/app/medications/[slug]/page.tsx b/src/app/medications/[slug]/page.tsx new file mode 100644 index 0000000000..754c5f7f2f --- /dev/null +++ b/src/app/medications/[slug]/page.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { AcamprosateMedicationPage } from "@/components/clinical-dashboard/medication-prescribing-workspace"; + +type MedicationPageProps = { + params: Promise<{ + slug: string; + }>; +}; + +export function generateStaticParams() { + return [{ slug: "acamprosate" }]; +} + +export async function generateMetadata({ params }: MedicationPageProps): Promise { + const { slug } = await params; + if (slug !== "acamprosate") { + return { + title: "Medication | Clinical KB", + }; + } + + return { + title: "Acamprosate | Clinical KB", + description: "Acamprosate prescribing summary, dosing, safety checks, monitoring, access, and provenance.", + }; +} + +export default async function MedicationPage({ params }: MedicationPageProps) { + const { slug } = await params; + if (slug !== "acamprosate") notFound(); + + return ; +} diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx new file mode 100644 index 0000000000..64432c1b99 --- /dev/null +++ b/src/app/medications/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function MedicationsIndexPage() { + redirect("/?mode=prescribing"); +} diff --git a/src/app/mockups/layout.tsx b/src/app/mockups/layout.tsx new file mode 100644 index 0000000000..33a8f03872 --- /dev/null +++ b/src/app/mockups/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +import { GlobalMockupSearchShell } from "@/components/clinical-dashboard/global-mockup-search-shell"; + +export default function MockupsLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index bdb327a09f..9f76ba479e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,31 @@ import { ClinicalDashboard } from "@/components/clinical-dashboard"; +import { isAppModeId, isAppModeVisible, type AppModeId } from "@/lib/app-modes"; -export default function Home() { - return ; +type HomeProps = { + searchParams?: Promise<{ + mode?: string | string[]; + q?: string | string[]; + focus?: string | string[]; + }>; +}; + +function firstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function Home({ searchParams }: HomeProps) { + const params = searchParams ? await searchParams : {}; + const requestedMode = firstSearchParam(params.mode); + const requestedQuery = firstSearchParam(params.q)?.trim() ?? ""; + const requestedFocus = firstSearchParam(params.focus); + const initialSearchMode: AppModeId = + isAppModeId(requestedMode) && isAppModeVisible(requestedMode) ? requestedMode : "answer"; + + return ( + + ); } diff --git a/src/app/tools/page.tsx b/src/app/tools/page.tsx index 7e6d835f6a..0d55ea9bd7 100644 --- a/src/app/tools/page.tsx +++ b/src/app/tools/page.tsx @@ -1,449 +1,20 @@ -import Link from "next/link"; -import { - ArrowDown, - ArrowRight, - BookOpen, - Brain, - CheckCircle2, - CircleDashed, - ClipboardCheck, - ClipboardList, - ExternalLink, - FileImage, - FileText, - HeartHandshake, - LayoutList, - ListChecks, - Network, - Pill, - Quote, - Search, - ShieldAlert, - Sparkles, - Target, - UploadCloud, - type LucideIcon, -} from "lucide-react"; -import { cn } from "@/components/ui-primitives"; -import { defaultFavoriteToolIds, type ToolCategory, type ToolIconName, type ToolItem, toolCatalog } from "@/lib/tools"; - -type ToneName = "primary" | "info" | "success" | "warning" | "danger"; - -type ToneTheme = { - aura: string; - border: string; - button: string; - glow: string; - icon: string; - rail: string; - surface: string; - text: string; -}; - -type ToolPresentation = { - cadence: string; - role: string; - shortLabel: string; - tone: ToneName; -}; - -const iconRegistry = { - Brain, - ClipboardList, - Search, - FileImage, - FileText, - HeartHandshake, - Network, - Pill, - UploadCloud, - BookOpen, - ClipboardCheck, - ListChecks, - Sparkles, - ShieldAlert, - Quote, - Target, - ExternalLink, - Clipboard: ClipboardCheck, -} satisfies Record; - -const toneTheme: Record = { - primary: { - aura: "bg-[radial-gradient(circle_at_18%_0%,color-mix(in_srgb,var(--primary)_20%,transparent),transparent_18rem)]", - border: "border-[color:color-mix(in_srgb,var(--primary)_28%,transparent)]", - button: "bg-[color:var(--primary)] text-[color:var(--primary-contrast)] hover:bg-[color:var(--primary-strong)]", - glow: "shadow-[0_18px_52px_color-mix(in_srgb,var(--primary)_14%,transparent)]", - icon: "border-[color:color-mix(in_srgb,var(--primary)_34%,transparent)] bg-[color:color-mix(in_srgb,var(--primary)_14%,transparent)] text-[color:var(--primary-100)]", - rail: "bg-[color:var(--primary)]", - surface: - "bg-[linear-gradient(145deg,color-mix(in_srgb,var(--app-shell-muted)_82%,transparent),color-mix(in_srgb,var(--app-shell)_96%,black))]", - text: "text-[color:var(--primary-100)]", - }, - info: { - aura: "bg-[radial-gradient(circle_at_18%_0%,color-mix(in_srgb,var(--info)_20%,transparent),transparent_18rem)]", - border: "border-[color:color-mix(in_srgb,var(--info)_30%,transparent)]", - button: "bg-[color:var(--info-soft)] text-[color:var(--app-shell)] hover:bg-white", - glow: "shadow-[0_18px_52px_color-mix(in_srgb,var(--info)_14%,transparent)]", - icon: "border-[color:color-mix(in_srgb,var(--info)_34%,transparent)] bg-[color:color-mix(in_srgb,var(--info)_14%,transparent)] text-[color:var(--info-bg)]", - rail: "bg-[color:var(--info)]", - surface: - "bg-[linear-gradient(145deg,color-mix(in_srgb,var(--info)_16%,var(--app-shell-muted)),color-mix(in_srgb,var(--app-shell)_96%,black))]", - text: "text-[color:var(--info-bg)]", - }, - success: { - aura: "bg-[radial-gradient(circle_at_18%_0%,color-mix(in_srgb,var(--success)_20%,transparent),transparent_18rem)]", - border: "border-[color:color-mix(in_srgb,var(--success)_30%,transparent)]", - button: "bg-[color:var(--success-soft)] text-[color:var(--app-shell)] hover:bg-white", - glow: "shadow-[0_18px_52px_color-mix(in_srgb,var(--success)_14%,transparent)]", - icon: "border-[color:color-mix(in_srgb,var(--success)_34%,transparent)] bg-[color:color-mix(in_srgb,var(--success)_14%,transparent)] text-[color:var(--success-bg)]", - rail: "bg-[color:var(--success)]", - surface: - "bg-[linear-gradient(145deg,color-mix(in_srgb,var(--success)_16%,var(--app-shell-muted)),color-mix(in_srgb,var(--app-shell)_96%,black))]", - text: "text-[color:var(--success-bg)]", - }, - warning: { - aura: "bg-[radial-gradient(circle_at_18%_0%,color-mix(in_srgb,var(--warning)_20%,transparent),transparent_18rem)]", - border: "border-[color:color-mix(in_srgb,var(--warning)_30%,transparent)]", - button: "bg-[color:var(--warning-soft)] text-[color:var(--app-shell)] hover:bg-white", - glow: "shadow-[0_18px_52px_color-mix(in_srgb,var(--warning)_14%,transparent)]", - icon: "border-[color:color-mix(in_srgb,var(--warning)_34%,transparent)] bg-[color:color-mix(in_srgb,var(--warning)_14%,transparent)] text-[color:var(--warning-bg)]", - rail: "bg-[color:var(--warning)]", - surface: - "bg-[linear-gradient(145deg,color-mix(in_srgb,var(--warning)_16%,var(--app-shell-muted)),color-mix(in_srgb,var(--app-shell)_96%,black))]", - text: "text-[color:var(--warning-bg)]", - }, - danger: { - aura: "bg-[radial-gradient(circle_at_18%_0%,color-mix(in_srgb,var(--danger)_20%,transparent),transparent_18rem)]", - border: "border-[color:color-mix(in_srgb,var(--danger)_30%,transparent)]", - button: "bg-[color:var(--danger-soft)] text-[color:var(--app-shell)] hover:bg-white", - glow: "shadow-[0_18px_52px_color-mix(in_srgb,var(--danger)_14%,transparent)]", - icon: "border-[color:color-mix(in_srgb,var(--danger)_34%,transparent)] bg-[color:color-mix(in_srgb,var(--danger)_14%,transparent)] text-[color:var(--danger-bg)]", - rail: "bg-[color:var(--danger)]", - surface: - "bg-[linear-gradient(145deg,color-mix(in_srgb,var(--danger)_16%,var(--app-shell-muted)),color-mix(in_srgb,var(--app-shell)_96%,black))]", - text: "text-[color:var(--danger-bg)]", - }, -}; - -const categoryTone: Record = { - Admin: "danger", - Clinical: "primary", - Docs: "info", - Operations: "warning", - Research: "success", -}; - -const toolPresentation: Record = { - differentials: { - cadence: "Rule-outs", - role: "Check likely rule-outs, red flags, and competing DSM-5 explanations.", - shortLabel: "Diffs", - tone: "success", - }, - "dsm-5-diagnoses": { - cadence: "DSM-5 criteria", - role: "Open criteria, symptom clusters, and diagnostic anchors.", - shortLabel: "DSM", - tone: "success", - }, - forms: { - cadence: "Capture", - role: "Start structured intake, review, and patient-facing form workflows.", - shortLabel: "Forms", - tone: "warning", - }, - formulation: { - cadence: "Case theory", - role: "Build formulation from problems, risks, maintaining factors, and treatment direction.", - shortLabel: "Form", - tone: "primary", - }, - medications: { - cadence: "Prescribing", - role: "Check prescribing context, monitoring, safety issues, and medication review.", - shortLabel: "Meds", - tone: "primary", - }, - "psychiatry-notes": { - cadence: "Output", - role: "Open summaries, documentation flows, and review-ready note outputs.", - shortLabel: "Notes", - tone: "danger", - }, - services: { - cadence: "Pathways", - role: "Find referral pathways, access points, and service-matching options.", - shortLabel: "Svc", - tone: "warning", - }, - specifiers: { - cadence: "Qualifiers", - role: "Review severity, course, and specifier language for a diagnosis.", - shortLabel: "Spec", - tone: "info", - }, - therapy: { - cadence: "Treatment", - role: "Open treatment planning, session structure, and intervention options.", - shortLabel: "Tx", - tone: "primary", - }, -}; - -const favoriteTools = defaultFavoriteToolIds - .map((id) => toolCatalog.find((tool) => tool.id === id)) - .filter((tool): tool is ToolItem => Boolean(tool)); - -function isInactive(tool: ToolItem) { - return tool.status === "offline" || tool.status === "coming-soon"; -} - -function getPresentation(tool: ToolItem) { - return ( - toolPresentation[tool.id] ?? { - cadence: tool.category, - role: tool.description, - shortLabel: tool.title, - tone: categoryTone[tool.category], +import { permanentRedirect } from "next/navigation"; + +export default async function ToolsCompatibilityRoute({ + searchParams, +}: { + searchParams: Promise>; +}) { + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(await searchParams)) { + if (Array.isArray(value)) { + for (const item of value) params.append(key, item); + } else if (typeof value === "string") { + params.set(key, value); } - ); -} - -function getLaunchContext(tool: ToolItem) { - try { - const url = new URL(tool.href); - return `${url.hostname}${url.port ? `:${url.port}` : ""}`; - } catch { - return tool.target === "external" ? "External app" : "Internal route"; } -} - -function getStatusLabel(tool: ToolItem) { - if (tool.status === "coming-soon") return "Soon"; - if (tool.status === "offline") return "Paused"; - if (tool.status === "beta") return "Preview"; - return "Live"; -} - -function LaunchCard({ tool }: { tool: ToolItem }) { - const Icon = iconRegistry[tool.icon]; - const disabled = isInactive(tool); - const presentation = getPresentation(tool); - const theme = toneTheme[presentation.tone]; - const target = tool.target === "external" && tool.openInNewTab ? "_blank" : undefined; - const rel = tool.target === "external" ? "noopener noreferrer" : undefined; - - return ( -
-
-
-
- -
-
- - - - - {disabled ? ( - - ) : ( - - )} - {getStatusLabel(tool)} - -
- -
-

{presentation.cadence}

-

- {tool.title} -

-

{presentation.role}

-
- -
-
- {getLaunchContext(tool)} - {disabled ? ( - - Unavailable - - ) : ( - - Launch - - - )} -
-
-
-
- ); -} - -function AppDock({ tools }: { tools: ToolItem[] }) { - return ( -
- {tools.map((tool) => { - const Icon = iconRegistry[tool.icon]; - const presentation = getPresentation(tool); - const theme = toneTheme[presentation.tone]; - - return ( - - - - - {presentation.shortLabel} - {presentation.cadence} - - - ); - })} -
- ); -} - -function HeroConsole() { - return ( - - ); -} - -export default function ToolsLauncherPage() { - return ( -
-
-
-
-
- -
-
- - -
-
-

- Open the right clinical tool. -

-

- Jump to formulation, DSM-5 criteria, medications, differentials, notes, forms, therapy, specifiers, or - service pathways. Each launch opens the local app shown on the card. -

-
- - -
-
- -
-
-
-
-

All clinical tools

-

- Use the host label to confirm the local app, then open the tool in a new tab. -

-
- - - Opens in a new tab - -
-
- {toolCatalog.map((tool) => ( - - ))} -
-
-
-
-
-
- ); + const query = params.toString(); + permanentRedirect(`/applications${query ? `?${query}` : ""}`); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index c648d80cbc..1abcd25563 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3,8 +3,11 @@ /* eslint-disable @next/next/no-img-element */ import Link from "next/link"; +import { useRouter } from "next/navigation"; import { + Activity, AlertCircle, + ArrowUpDown, BookOpen, Brain, CheckCircle2, @@ -15,7 +18,11 @@ import { FileImage, FileText, Filter, + Folder, + FolderInput, + Heart, Layers, + LayoutList, ListChecks, Loader2, LogIn, @@ -35,6 +42,7 @@ import { ShieldCheck, SlidersHorizontal, Sparkles, + Table2, Tag, Target, UploadCloud, @@ -98,6 +106,7 @@ import { tableCardHeader, tableMicroActionRow, textMuted, + toolbarButton, toneDanger, toneInfo, toneNeutral, @@ -125,6 +134,7 @@ import { sourceDisplayTitle, } from "@/components/clinical-dashboard/display-text"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; +import { MedicationPrescribingWorkspace } from "@/components/clinical-dashboard/medication-prescribing-workspace"; import { DocumentSearchResultsPanel, MatchExplanationChips, @@ -151,6 +161,15 @@ import { type AnswerPayload, type SearchError, } from "@/components/clinical-dashboard/search-utils"; +import { + appModeQueryMode, + appModeHomeHref, + appModeResultKind, + appModeSearchConfig, + isAppModeId, + isAppModeVisible, + type AppModeId, +} from "@/lib/app-modes"; import { logSourceOpen, SourceActionRow, sourceResultHref } from "@/components/clinical-dashboard/source-actions"; import { clinicalProseUsefulness, sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer"; import { groupSourceGovernanceWarnings, type SourceGovernanceWarning } from "@/lib/source-governance"; @@ -234,7 +253,6 @@ type DocumentPagination = { nextOffset: number; hasMore: boolean; }; -type SearchMode = "answer" | "documents"; type RefreshOptions = { includeSetup?: boolean; includeDashboardData?: boolean; @@ -1071,6 +1089,16 @@ function isRedundantStructuredItem(item: string, primaryAnswer: string) { type ClinicalDetailSection = ReturnType[number]; +function displayItemsForClinicalDetailSection( + section: ClinicalDetailSection, + primaryAnswer: string, + showLead: boolean, +) { + if (showLead) return section.items; + const nonRedundantItems = section.items.filter((item) => !isRedundantStructuredItem(item, primaryAnswer)); + return nonRedundantItems.length > 0 || section.items.length === 0 ? nonRedundantItems : section.items; +} + const clinicalDetailPriority: Record = { action: 10, escalation: 20, @@ -1170,6 +1198,396 @@ function clinicalDetailSummaryItems(sections: ClinicalDetailSection[]) { return items.filter((item) => item.value > 0); } +type ClinicalNotesTabId = "safety" | "monitor"; + +type ClinicalNotesRow = { + id: string; + title: string; + detail: string; + sourceIndex: number; + tone: "safe" | "warn"; +}; + +const clinicalNotesTabMeta: Record< + ClinicalNotesTabId, + { label: string; icon: typeof ShieldCheck; sectionIds: string[] } +> = { + safety: { + label: "Safety", + icon: ShieldCheck, + sectionIds: ["escalation", "cautions", "source-gap", "thresholds"], + }, + monitor: { + label: "Monitor", + icon: Activity, + sectionIds: ["monitoring", "medication", "action"], + }, +}; + +function compactClinicalNoteText(value: string) { + return value + .replace(/\*\*/g, "") + .replace(/\s*\[\d+(?:,\s*\d+)*\]\s*/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function clinicalNoteTitleFromItem(item: string, section: ClinicalDetailSection, index: number) { + const text = compactClinicalNoteText(item); + const colonIndex = text.indexOf(":"); + if (colonIndex > 8 && colonIndex < 54) return text.slice(0, colonIndex).trim(); + const dashIndex = text.search(/\s[-–]\s/); + if (dashIndex > 8 && dashIndex < 54) return text.slice(0, dashIndex).trim(); + if (section.items.length === 1 && section.title.length <= 42) return section.title; + const words = text.split(" ").filter(Boolean); + return words.slice(0, Math.min(words.length, index === 0 ? 5 : 4)).join(" ") || section.title; +} + +function clinicalNoteDetailFromItem(item: string, title: string) { + const text = compactClinicalNoteText(item); + const normalizedTitle = title.toLowerCase(); + const lowerText = text.toLowerCase(); + if (lowerText.startsWith(`${normalizedTitle}:`)) return text.slice(title.length + 1).trim(); + if (lowerText.startsWith(`${normalizedTitle} -`) || lowerText.startsWith(`${normalizedTitle} –`)) { + return text.slice(title.length + 2).trim(); + } + return text === title ? "Review linked source context before using this note." : text; +} + +function clinicalNoteTitleFromFragment(fragment: string) { + const text = compactClinicalNoteText(fragment).replace(/^(and|or)\s+/i, "").replace(/[.;:,]+$/g, ""); + if (!text) return "Clinical note"; + return text.replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function splitClinicalNoteFragments(item: string, section: ClinicalDetailSection, title: string) { + const detail = clinicalNoteDetailFromItem(item, title); + const titleLooksGeneric = /\b(checkpoint|checklist|item|point|monitoring|safety)\b/i.test(title); + if (!titleLooksGeneric && section.items.length > 1) return null; + + const fragments = detail + .replace(/\band\s+/gi, "") + .split(/[,;]\s+/) + .map((fragment) => compactClinicalNoteText(fragment).replace(/[.;:,]+$/g, "")) + .filter((fragment) => fragment.length > 5); + + return fragments.length >= 3 ? fragments.slice(0, 5) : null; +} + +function clinicalNoteToneForText(text: string, fallback: ClinicalNotesRow["tone"]) { + if (/\b(toxicity|toxic|warning|caution|urgent|red flag|adverse|confusion|ataxia|tremor)\b/i.test(text)) { + return "warn"; + } + return fallback; +} + +function clinicalNoteHasDistinctDetail(row: ClinicalNotesRow) { + const title = compactClinicalNoteText(row.title).toLowerCase(); + const detail = compactClinicalNoteText(row.detail).toLowerCase(); + return Boolean(detail) && detail !== title; +} + +function clinicalNotesTableEvidenceCount(answer: RagAnswer) { + return (answer.visualEvidence ?? answer.smartPanel?.visualEvidence ?? []).filter( + (item) => item.accessibleTableMarkdown || item.tableRows?.length, + ).length; +} + +function clinicalNotesRowsForTab(sections: ClinicalDetailSection[], tab: ClinicalNotesTabId) { + const meta = clinicalNotesTabMeta[tab]; + const rows: ClinicalNotesRow[] = []; + let sourceIndex = 1; + + for (const section of sections) { + const sectionText = `${section.title} ${section.items.join(" ")}`.toLowerCase(); + const hasMonitoringText = + tab === "monitor" && /\b(monitor|screen|level|fbc|anc|metabolic|renal|thyroid|function)\b/i.test(sectionText); + if (!meta.sectionIds.includes(section.id) && !hasMonitoringText) { + continue; + } + const tone: ClinicalNotesRow["tone"] = + section.id === "escalation" || section.id === "cautions" ? "warn" : "safe"; + + for (const item of section.items.slice(0, 4)) { + if (section.tables?.length && /\b(table|showing domains|table showing)\b/i.test(item)) continue; + const title = clinicalNoteTitleFromItem(item, section, rows.length); + const fragments = splitClinicalNoteFragments(item, section, title); + if (fragments) { + for (const fragment of fragments) { + const fragmentTitle = clinicalNoteTitleFromFragment(fragment); + rows.push({ + id: `${tab}:${section.id}:${rows.length}:${fragmentTitle}`, + title: fragmentTitle, + detail: fragment, + sourceIndex: sourceIndex++, + tone: clinicalNoteToneForText(fragment, tone), + }); + } + } else { + rows.push({ + id: `${tab}:${section.id}:${rows.length}:${title}`, + title, + detail: clinicalNoteDetailFromItem(item, title), + sourceIndex: sourceIndex++, + tone: clinicalNoteToneForText(item, tone), + }); + } + } + } + + return rows.slice(0, 6); +} + +function clinicalNotesAvailableTabs(sections: ClinicalDetailSection[]) { + return (Object.keys(clinicalNotesTabMeta) as ClinicalNotesTabId[]) + .map((id) => ({ id, ...clinicalNotesTabMeta[id], count: clinicalNotesRowsForTab(sections, id).length })) + .filter((tab) => tab.count > 0); +} + +function clinicalNotesDetailSectionsForAnswer(answer: RagAnswer, viewMode: AnswerViewMode) { + const sections = + viewMode === "high_yield" ? buildHighYieldClinicalOutputSections(answer) : buildClinicalOutputSections(answer); + const primaryAnswer = plainAnswerText(answer.answer); + return sortClinicalDetailSections( + sections + .filter((section) => section.id !== "verify-source" && section.id !== "bottom-line") + .map((section) => ({ + ...section, + items: displayItemsForClinicalDetailSection(section, primaryAnswer, false), + })) + .filter((section) => section.items.length > 0), + ); +} + +function clinicalNotesDisplayCountForAnswer(answer: RagAnswer, viewMode: AnswerViewMode, fallback: number) { + const tabs = clinicalNotesAvailableTabs(clinicalNotesDetailSectionsForAnswer(answer, viewMode)); + const largestTabCount = tabs.reduce((largest, tab) => Math.max(largest, tab.count), 0); + return Math.max(1, largestTabCount || fallback); +} + +function ClinicalNotesChecklistPanel({ + answer, + viewMode, + evidenceMapRows, + bestSource, + copied, + onCopy, + onOpenTables, +}: { + answer: RagAnswer; + viewMode: AnswerViewMode; + evidenceMapRows: AnswerEvidenceMapRow[]; + bestSource: BestSourceRecommendation | null; + copied: boolean; + onCopy: () => void; + onOpenTables?: () => void; +}) { + const detailSections = clinicalNotesDetailSectionsForAnswer(answer, viewMode); + const tabs = clinicalNotesAvailableTabs(detailSections); + const [requestedTab, setRequestedTab] = useState(tabs[0]?.id ?? "safety"); + const activeTab = tabs.some((tab) => tab.id === requestedTab) ? requestedTab : (tabs[0]?.id ?? "safety"); + const rows = clinicalNotesRowsForTab(detailSections, activeTab); + const tableEvidenceCount = clinicalNotesTableEvidenceCount(answer); + const [expandedRowId, setExpandedRowId] = useState(null); + const firstExpandableRow = rows.find(clinicalNoteHasDistinctDetail) ?? null; + const activeRow = rows.find((row) => row.id === expandedRowId) ?? firstExpandableRow; + const [added, setAdded] = useState(false); + const toggles: Array<{ + id: ClinicalNotesTabId | "table"; + label: string; + icon: typeof ShieldCheck; + count?: number; + popout?: boolean; + }> = [ + ...tabs.map((tab) => ({ ...tab, popout: false })), + ...(tableEvidenceCount > 0 && onOpenTables + ? [{ id: "table" as const, label: "Table", icon: Table2, count: tableEvidenceCount, popout: true }] + : []), + ]; + + if (!tabs.length || rows.length === 0) { + return ( + + ); + } + + const ActiveIcon = clinicalNotesTabMeta[activeTab].icon; + + return ( +
+
+
+ {toggles.map((tab) => { + const Icon = tab.icon; + const selected = !tab.popout && tab.id === activeTab; + return ( + + ); + })} +
+
+ +
+ +
+

+ {activeTab === "safety" ? "Safety checklist" : "Monitoring checklist"} +

+

+ {activeTab === "safety" + ? "Key actions to start and continue safely." + : "Monitoring and medication follow-up items."} +

+
+
+ +
+ {rows.map((row) => { + const expanded = row.id === activeRow?.id; + const hasDistinctDetail = clinicalNoteHasDistinctDetail(row); + const RowIcon = row.tone === "warn" ? AlertCircle : CheckCircle2; + return ( +
+ {expanded ? ( + + ) : null} + + {expanded && hasDistinctDetail ? ( +
+

{row.detail}

+
+ ) : null} +
+ ); + })} +
+ +
+
+ {bestSource ? ( + + + Source + + ) : ( + + + Source + + )} + + +
+
+
+ ); +} + function SafetyFindingsPanel({ findings }: { findings: ReturnType }) { if (findings.length === 0) return null; @@ -2138,7 +2556,7 @@ function ClinicalOutputPanel({ .filter((section) => (showLead ? section.id !== leadSection?.id : section.id !== "bottom-line")) .map((section) => ({ ...section, - items: showLead ? section.items : section.items.filter((item) => !isRedundantStructuredItem(item, primaryAnswer)), + items: displayItemsForClinicalDetailSection(section, primaryAnswer, showLead), })) .filter((section) => section.items.length > 0 || Boolean(section.tables?.length)); const orderedDetailSections = sortClinicalDetailSections(detailSections); @@ -2617,6 +3035,7 @@ function MobileEvidenceSheetContent({ query, visualEvidence, answerEvidenceMapRows, + initialTab, pendingFeedback, copiedQuotes, onCopyQuotes, @@ -2629,6 +3048,7 @@ function MobileEvidenceSheetContent({ query: string; visualEvidence: VisualEvidenceCard[]; answerEvidenceMapRows: AnswerEvidenceMapRow[]; + initialTab?: EvidenceTabName | null; pendingFeedback: AnswerFeedbackType | null; copiedQuotes: boolean; onCopyQuotes: () => void; @@ -2638,7 +3058,7 @@ function MobileEvidenceSheetContent({ }) { const order = evidenceTabOrder(answer); const pdfSources = uniquePdfSources(answer).slice(0, 6); - const [selectedTab, setSelectedTab] = useState(null); + const [selectedTab, setSelectedTab] = useState(() => initialTab ?? null); const activeTab = selectedTab && order.includes(selectedTab) ? selectedTab : order[0]; const panelIdFor = (tab: EvidenceTabName) => `mobile-evidence-panel-${tab.toLowerCase()}`; @@ -3270,9 +3690,23 @@ function StagedAnswerResultSurface({ }) { const noteCount = clinicalNotesCount(answer); const showClinicalNotes = safetyFindings.length > 0 || noteCount > 0; + const clinicalNoteDisplayCount = clinicalNotesDisplayCountForAnswer( + answer, + answerViewMode, + noteCount || safetyFindings.length, + ); const sourceCount = sourceSummary?.total_sources ?? sources.length ?? answer.sources?.length ?? answer.citations.length; const centralTable = answerHasCentralTable(answer) ? primaryVisualTable(answer) : null; + const [clinicalNotesOpen, setClinicalNotesOpen] = useState(false); + const [evidenceOpen, setEvidenceOpen] = useState(false); + const [evidenceInitialTab, setEvidenceInitialTab] = useState(null); + const openTableEvidence = useCallback(() => { + setClinicalNotesOpen(false); + setEvidenceInitialTab("Tables"); + setEvidenceOpen(true); + }, [setClinicalNotesOpen, setEvidenceInitialTab, setEvidenceOpen]); + return (
@@ -3311,19 +3745,54 @@ function StagedAnswerResultSurface({ {showClinicalNotes ? ( + + + } + sheetTitleAccessory={ + + {clinicalNoteDisplayCount} + + } + sheetDescriptionContent={ + + + Source-backed + + } + sheetHeaderActions={ + bestSource ? ( + + + + ) : null + } + sheetDescription={null} + sheetContentClassName="max-h-[92dvh] translate-y-0 bg-[color:var(--surface-raised)] motion-safe:animate-none sm:h-auto sm:max-h-[88dvh] sm:max-w-lg" + sheetContentStyle={{ height: "80dvh" }} + sheetBodyClassName="bg-[color:var(--surface-raised)] px-5 pb-0 pt-5 sm:p-5" > - ) : null} @@ -3335,6 +3804,11 @@ function StagedAnswerResultSurface({ summary={compactEvidenceSummary(answer, sources, sourceSummary)} mobileSummary={compactEvidenceSummary(answer, sources, sourceSummary)} className={evidenceRow} + open={evidenceOpen} + onOpenChange={(open) => { + setEvidenceOpen(open); + if (!open) setEvidenceInitialTab(null); + }} >
undefined} @@ -3662,6 +4137,7 @@ function DocumentDrawer({ documents, pagination, loadingMoreDocuments, + mode, selectedDocumentIds, statusFilter, onToggleScope, @@ -3679,6 +4155,7 @@ function DocumentDrawer({ documents: ClinicalDocument[]; pagination: DocumentPagination | null; loadingMoreDocuments: boolean; + mode: DocumentDrawerMode; selectedDocumentIds: string[]; statusFilter: DocumentDrawerStatusFilter; onToggleScope: (documentId: string) => void; @@ -3705,22 +4182,48 @@ function DocumentDrawer({ sourceType: "", category: "", }); - const filtered = documents.filter((document) => { - if (!documentStatusMatchesFilter(document, statusFilter)) return false; - const labelText = tagSearchText(document); - const summaryText = document.summary?.summary ?? ""; - const haystack = `${document.title} ${document.file_name} ${labelText} ${summaryText}`.toLowerCase(); - return haystack.includes(filter.toLowerCase()); - }); - const visibleStatusLabel = statusFilterLabel(statusFilter); + const isAdminMode = mode === "admin" && canManageDocuments; + const modeLabel = + mode === "recent" + ? "Recent documents" + : mode === "source" + ? "Source PDFs" + : mode === "admin" + ? statusFilterLabel(statusFilter) + : "Source library"; + const modeSummary = + mode === "recent" + ? "Recently updated indexed sources." + : mode === "source" + ? "PDF source documents ready to open." + : mode === "admin" + ? "Document maintenance and indexing tools." + : "Search and open indexed clinical sources."; + const filterValue = filter.toLowerCase(); + const filtered = documents + .filter((document) => { + if (!documentStatusMatchesFilter(document, statusFilter)) return false; + if (mode === "source") { + const typeText = `${document.file_type} ${document.file_name}`.toLowerCase(); + if (!typeText.includes("pdf")) return false; + } + const labelText = tagSearchText(document); + const summaryText = document.summary?.summary ?? ""; + const haystack = `${document.title} ${document.file_name} ${labelText} ${summaryText}`.toLowerCase(); + return haystack.includes(filterValue); + }) + .sort((left, right) => { + if (mode !== "recent") return 0; + return new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime(); + }); return (
-

{visibleStatusLabel}

+

{modeLabel}

- {filtered.length} matching document{filtered.length === 1 ? "" : "s"} + {modeSummary} {filtered.length} matching document{filtered.length === 1 ? "" : "s"}.

@@ -3729,7 +4232,7 @@ function DocumentDrawer({ setFilter(event.target.value)} - placeholder="Find a document" + placeholder={mode === "source" ? "Find a source PDF" : "Find a document"} className={fieldControlWithIcon} /> @@ -3738,9 +4241,9 @@ function DocumentDrawer({ Showing {documents.length} of {pagination.total} documents. Load more to manage older files.

) : null} - - - {selectedDocumentIds.length ? ( + {isAdminMode ? : null} + {isAdminMode ? : null} + {isAdminMode && selectedDocumentIds.length ? (
@@ -3934,12 +4437,14 @@ function DocumentDrawer({
- + {isAdminMode ? ( + + ) : null} - + + + {tabMenuOpen ? ( +
+ {favouriteTabs.map((tab) => { + const Icon = tab.icon; + const selected = selectedTab === tab.id; + const count = favouriteTypeCount(tab.id); + return ( + + ); + })} +
+ ) : null} +
+ {normalizedQuery ? ( + + ) : null} + {selectedSet ? ( + + ) : null} +
+
+ + +
+
+ +
+
+

Saved sets

+ +
+
+ {visibleSets.slice(0, 3).map((set) => ( + { + setSelectedSetId(set.id); + if (selectedTab === "sets") setSelectedTab("all"); + }} + /> + ))} +
+
+ +
+
+
+
+

+ {selectedTab === "all" + ? "Recent favourites" + : selectedTab === "sets" + ? "Saved sets" + : `${selectedTabLabel} favourites`} +

+

+ {selectedTab === "sets" ? "Open a focused clinical set." : "Open, ask, copy, or organise saved items."} +

+
+ + {selectedTab === "sets" ? visibleSets.length : visibleItems.length} + +
+ +
+ {showSets && selectedTab === "sets" + ? visibleSets.map((set) => ( + { + setSelectedSetId(set.id); + setSelectedTab("all"); + }} + /> + )) + : null} + + {showItems + ? visibleItems.map((item) => ( + setSelectedTab("sets")} /> + )) + : null} + + {empty ? ( +
+
+ +

No favourites match

+

+ Clear the composer text or choose another tab. +

+
+
+ ) : null} +
+
+ + +
+
+ ); +} + +function FavouriteItemRow({ item, onMoveToSet }: { item: FavouriteItem; onMoveToSet: () => void }) { + const Icon = item.icon; + return ( +
+ + + +
+

{item.title}

+

{item.meta}

+
+ {item.set} + + {item.sourceMeta} + +
+
+
+ + +
+ +
+ ); +} + +function FavouriteSetRow({ + favouriteSet, + compact = false, + selected = false, + onSelect, +}: { + favouriteSet: FavouriteSet; + compact?: boolean; + selected?: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + type MobileSectionFabItem = { label: string; description: string; @@ -4956,18 +6057,28 @@ function buildMobileSectionFabState({ governanceWarningCount, }: { hasAnswer: boolean; - searchMode: SearchMode; + searchMode: AppModeId; sourceCount: number; quoteCount: number; weakEvidence: boolean; governanceWarningCount: number; }): MobileSectionFabState { + const modeSearch = appModeSearchConfig(searchMode); if (!hasAnswer) { + if (modeSearch.resultKind === "favourites") { + return { + statusLabel: "Favourites", + statusTone: "neutral", + nextStep: "Browse saved items", + badgeLabel: null, + badgeTone: "neutral", + }; + } return { - statusLabel: searchMode === "documents" ? "Document search" : "No answer yet", + statusLabel: modeSearch.resultKind === "documents" ? "Document search" : "No answer yet", statusTone: "empty", - nextStep: searchMode === "documents" ? "Review matching documents" : "Ask a question first", - badgeLabel: searchMode === "documents" ? null : "?", + nextStep: modeSearch.nextStep, + badgeLabel: modeSearch.badgeLabel, badgeTone: "empty", }; } @@ -5336,15 +6447,23 @@ function mergeDocumentRefresh(current: ClinicalDocument[], updates: ClinicalDocu }); } -export function ClinicalDashboard() { +export function ClinicalDashboard({ + initialSearchMode = "answer", + initialQuery = "", + focusSearch = false, +}: { initialSearchMode?: AppModeId; initialQuery?: string; focusSearch?: boolean } = {}) { + const router = useRouter(); const mainRef = useRef(null); + const composerInputRef = useRef(null); const scrollFrameRef = useRef(null); const navSyncLockRef = useRef(null); const refreshInFlightRef = useRef | null>(null); const nextWorkStatePollRef = useRef(0); const urlSearchBootstrappedRef = useRef(false); + const urlDocumentSearchBootstrappedRef = useRef(false); const [documents, setDocuments] = useState([]); const [documentsPagination, setDocumentsPagination] = useState(null); + const indexedDocumentTotal = documentsPagination?.total ?? documents.length; const [dashboardDataLoading, setDashboardDataLoading] = useState(true); const [loadingMoreDocuments, setLoadingMoreDocuments] = useState(false); const [jobs, setJobs] = useState([]); @@ -5352,14 +6471,17 @@ export function ClinicalDashboard() { const [qualityItems, setQualityItems] = useState([]); const jobsRef = useRef(jobs); const batchesRef = useRef(batches); - const [query, setQuery] = useState(""); - const [searchMode, setSearchMode] = useState("answer"); + const [query, setQuery] = useState(initialQuery); + const [searchMode, setSearchMode] = useState(initialSearchMode); const [answer, setAnswer] = useState(null); const [sources, setSources] = useState([]); const [documentMatches, setDocumentMatches] = useState([]); const [searchRelevance, setSearchRelevance] = useState(null); const [searchFacets, setSearchFacets] = useState(null); const [queryMode, setQueryMode] = useState("auto"); + const activeModeSearch = appModeSearchConfig(searchMode); + const activeModeResultKind = appModeResultKind(searchMode); + const requestQueryMode = appModeQueryMode(searchMode, queryMode); const [scopeFilters, setScopeFilters] = useState({}); const [searchScope, setSearchScope] = useState(null); const [sourceGovernanceWarnings, setSourceGovernanceWarnings] = useState([]); @@ -5384,6 +6506,7 @@ export function ClinicalDashboard() { const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false); + const [documentsDrawerMode, setDocumentsDrawerMode] = useState("library"); const [uploadDrawerOpen, setUploadDrawerOpen] = useState(false); const [uploadMobileTab, setUploadMobileTab] = useState("upload"); const [documentDrawerStatusFilter, setDocumentDrawerStatusFilter] = useState("indexed"); @@ -5405,12 +6528,20 @@ export function ClinicalDashboard() { const storedSessionExists = typeof window !== "undefined" && Object.keys(localStorage).some((k) => k.startsWith("sb-") && k.endsWith("-auth-token")); + const localDevCanAttemptPrivateApis = process.env.NODE_ENV !== "production" && hasReadyPublicSearchSetup(setupChecks); const canUsePrivateApis = localProjectReady && - (localNoAuthMode || authStatus === "authenticated" || (supabaseEnvStatus === "ready" && storedSessionExists)); + (localNoAuthMode || + localDevCanAttemptPrivateApis || + authStatus === "authenticated" || + (supabaseEnvStatus === "ready" && storedSessionExists)); const canRunSearch = explicitDemoMode || (hasReadyPublicSearchSetup(setupChecks) && canUsePrivateApis); const openGuide = useCallback(() => setGuideOpen(true), []); const closeGuide = useCallback(() => setGuideOpen(false), []); + const prefetchApplications = useCallback(() => { + router.prefetch("/applications"); + void import("@/components/applications-launcher-page"); + }, [router]); const openLibraryHealthTarget = useCallback((target: LibraryHealthTarget) => { const targetId = target === "documents" @@ -5421,6 +6552,7 @@ export function ClinicalDashboard() { if (target === "documents") { setDocumentDrawerStatusFilter("indexed"); + setDocumentsDrawerMode("admin"); setDocumentsDrawerOpen(true); } else if (target === "indexing") { setUploadMobileTab("jobs"); @@ -5441,6 +6573,11 @@ export function ClinicalDashboard() { }, 0); }, []); + useEffect(() => { + const timeoutId = window.setTimeout(prefetchApplications, 250); + return () => window.clearTimeout(timeoutId); + }, [prefetchApplications]); + useEffect(() => { let cancelled = false; const frame = window.requestAnimationFrame(() => { @@ -5897,16 +7034,43 @@ export function ClinicalDashboard() { }, []); useEffect(() => { - if (urlSearchBootstrappedRef.current || !canRunSearch) return; + if (!focusSearch) return undefined; + focusComposerInput(); + const timeout = window.setTimeout(focusComposerInput, 500); + return () => window.clearTimeout(timeout); + }, [focusSearch]); + + useEffect(() => { + if (urlSearchBootstrappedRef.current) return; const params = new URLSearchParams(window.location.search); const mode = params.get("mode"); const searchText = params.get("q")?.trim(); - if (mode !== "documents") return; + const shouldFocusComposer = params.get("focus") === "1"; + if (!isAppModeId(mode) || !isAppModeVisible(mode)) return; urlSearchBootstrappedRef.current = true; - const frame = window.requestAnimationFrame(() => setSearchMode("documents")); - if (searchText) void runDocumentSearchShortcut(searchText, scopeFilters, false); + const targetMode = mode; + const frame = window.requestAnimationFrame(() => { + setSearchMode(targetMode); + if (searchText) setQuery(searchText); + if (shouldFocusComposer) focusComposerInput(); + }); return () => window.cancelAnimationFrame(frame); - // URL bootstrap intentionally runs once when search setup becomes available. + }, []); + + useEffect(() => { + if (urlDocumentSearchBootstrappedRef.current) return; + const params = new URLSearchParams(window.location.search); + const mode = params.get("mode"); + const searchText = params.get("q")?.trim(); + if (!searchText || !isAppModeId(mode) || !isAppModeVisible(mode)) return; + if (mode === "prescribing") return; + const modeSearch = appModeSearchConfig(mode); + const shouldRun = params.get("run") === "1" || modeSearch.kind === "documents"; + if (!shouldRun) return; + if (modeSearch.kind !== "favourites" && !canRunSearch) return; + urlDocumentSearchBootstrappedRef.current = true; + void executeSearch(searchText, mode, scopeFilters); + // URL search intentionally runs once when the selected mode can execute. // eslint-disable-next-line react-hooks/exhaustive-deps }, [canRunSearch]); @@ -5943,7 +7107,11 @@ export function ClinicalDashboard() { ); } - async function requestDocuments(queryText: string, filtersOverride?: SearchScopeFilters) { + async function requestDocuments( + queryText: string, + filtersOverride?: SearchScopeFilters, + queryModeOverride: ClinicalQueryMode = requestQueryMode, + ) { let response: Response; try { response = await fetch("/api/search", { @@ -5957,7 +7125,7 @@ export function ClinicalDashboard() { mode: "documents", documentIds: selectedDocumentIds.length > 0 ? selectedDocumentIds : undefined, filters: compactScopeFilters(filtersOverride ?? scopeFilters), - queryMode, + queryMode: queryModeOverride, documentLimit: 30, topK: 20, }), @@ -5991,7 +7159,11 @@ export function ClinicalDashboard() { }; } - async function requestAnswer(queryText: string) { + async function requestAnswer( + queryText: string, + filtersOverride: SearchScopeFilters = scopeFilters, + queryModeOverride: ClinicalQueryMode = requestQueryMode, + ) { let response: Response; try { response = await fetch("/api/answer/stream", { @@ -6003,8 +7175,8 @@ export function ClinicalDashboard() { body: JSON.stringify({ query: queryText, documentIds: selectedDocumentIds.length > 0 ? selectedDocumentIds : undefined, - filters: compactScopeFilters(scopeFilters), - queryMode, + filters: compactScopeFilters(filtersOverride), + queryMode: queryModeOverride, }), }); } catch { @@ -6089,14 +7261,25 @@ export function ClinicalDashboard() { if (answerData.demoMode) setDemoMode(true); } - async function ask() { - const trimmedQuery = query.trim(); + async function executeSearch(searchText: string, targetMode: AppModeId = searchMode, filtersOverride = scopeFilters) { + const trimmedQuery = searchText.trim(); if (!trimmedQuery) return; + const modeSearch = appModeSearchConfig(targetMode); + const targetQueryMode = appModeQueryMode(targetMode, queryMode); + + setSearchMode(targetMode); + setQuery(trimmedQuery); + + if (modeSearch.kind === "favourites") { + setError(null); + rememberRecentQuery(trimmedQuery); + setActionNotice({ tone: "success", message: "Favourites filtered from the composer." }); + return; + } if (!canRunSearch) { setError("Search setup not ready."); return; } - setLoading(true); setError(null); setSearchRelevance(null); @@ -6104,7 +7287,7 @@ export function ClinicalDashboard() { setSearchScope(null); setSourceGovernanceWarnings([]); setAnswerViewMode("high_yield"); - setAnswerProgress(searchMode === "documents" ? "Finding matching documents." : "Searching indexed documents."); + setAnswerProgress(modeSearch.progressLabel); rememberRecentQuery(trimmedQuery); const fallbackQuery = keywordQueryFromNaturalLanguage(trimmedQuery); @@ -6125,9 +7308,9 @@ export function ClinicalDashboard() { try { const payload = - searchMode === "documents" - ? await runWithRetries(() => requestDocuments(entry.query)) - : await runWithRetries(() => requestAnswer(entry.query)); + modeSearch.kind === "documents" + ? await runWithRetries(() => requestDocuments(entry.query, filtersOverride, targetQueryMode)) + : await runWithRetries(() => requestAnswer(entry.query, filtersOverride, targetQueryMode)); if (!resultUsable(payload)) { lastError = makeSearchError("No usable results were found.", 404, false); @@ -6162,6 +7345,35 @@ export function ClinicalDashboard() { } } + function setMedicationSearchQuery(searchText: string, updateUrl = true) { + const trimmedSearchText = searchText.trim(); + if (!trimmedSearchText) return; + setSearchMode("prescribing"); + setQuery(trimmedSearchText); + setLoading(false); + setError(null); + setAnswerProgress(null); + rememberRecentQuery(trimmedSearchText); + window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); + if (updateUrl) router.replace(appModeHomeHref("prescribing", { query: trimmedSearchText })); + } + + async function ask() { + if (searchMode === "prescribing") { + setMedicationSearchQuery(query); + return; + } + await executeSearch(query, searchMode, scopeFilters); + } + + function pickRecentQuery(recentQuery: string) { + if (searchMode === "prescribing") { + setMedicationSearchQuery(recentQuery); + return; + } + setQuery(recentQuery); + } + async function submitAnswerFeedback(feedbackType: AnswerFeedbackType) { if (!answer || pendingFeedback) return; if (clientDemoMode) { @@ -6243,14 +7455,16 @@ export function ClinicalDashboard() { window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); } - function updateDocumentSearchUrl(searchText: string) { - const params = new URLSearchParams(window.location.search); - params.set("mode", "documents"); - params.set("q", searchText); - window.history.replaceState(null, "", `/?${params.toString()}`); + function updateDocumentSearchUrl(searchText: string, mode: AppModeId = "documents") { + window.history.replaceState(null, "", appModeHomeHref(mode, { query: searchText })); } - async function runDocumentSearchShortcut(searchText: string, filtersOverride = scopeFilters, updateUrl = true) { + async function runDocumentSearchShortcut( + searchText: string, + filtersOverride = scopeFilters, + updateUrl = true, + targetMode: AppModeId = "documents", + ) { const trimmedSearchText = searchText.trim(); if (!trimmedSearchText) return; if (!canRunSearch) { @@ -6259,10 +7473,10 @@ export function ClinicalDashboard() { } setQuery(trimmedSearchText); - setSearchMode("documents"); + setSearchMode(targetMode); setLoading(true); setError(null); - setAnswerProgress("Finding matching documents."); + setAnswerProgress(appModeSearchConfig(targetMode).progressLabel); setSearchRelevance(null); setSearchFacets(null); setSearchScope(null); @@ -6270,10 +7484,13 @@ export function ClinicalDashboard() { setAnswerViewMode("high_yield"); rememberRecentQuery(trimmedSearchText); window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); - if (updateUrl) updateDocumentSearchUrl(trimmedSearchText); + if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode); try { - const payload = await runWithRetries(() => requestDocuments(trimmedSearchText, filtersOverride)); + const shortcutQueryMode = appModeQueryMode(targetMode, queryMode); + const payload = await runWithRetries(() => + requestDocuments(trimmedSearchText, filtersOverride, shortcutQueryMode), + ); applySearchResult(payload); } catch (requestError) { setError(requestError instanceof Error ? requestError.message : "Document search failed"); @@ -6356,8 +7573,25 @@ export function ClinicalDashboard() { } } + function selectSearchMode(mode: AppModeId) { + setSearchMode(mode); + router.replace(appModeHomeHref(mode)); + } + + function focusComposerInput() { + window.requestAnimationFrame(() => { + composerInputRef.current?.focus({ preventScroll: true }); + window.setTimeout(() => composerInputRef.current?.focus({ preventScroll: true }), 150); + }); + } + function startNewChat() { + const href = appModeHomeHref("answer", { focus: true }); setQuery(""); + setSearchMode("answer"); + setQueryMode("auto"); + setSelectedDocumentIds([]); + setScopeFilters({}); setAnswer(null); setSources([]); setDocumentMatches([]); @@ -6368,11 +7602,46 @@ export function ClinicalDashboard() { setError(null); setAnswerProgress(null); setAnswerViewMode("high_yield"); - window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); + router.replace(href); + window.requestAnimationFrame(() => { + mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }); + focusComposerInput(); + } + + function openDocumentsDrawer(mode: DocumentDrawerMode) { + setSearchMode("documents"); + setDocumentDrawerStatusFilter("indexed"); + setDocumentsDrawerMode(mode); + setDocumentsDrawerOpen(true); + window.requestAnimationFrame(() => { + document.getElementById("dashboard-documents-drawer")?.scrollIntoView({ block: "start", behavior: "smooth" }); + }); + } + + function openRecentDocuments() { + openDocumentsDrawer("recent"); + } + + function openSourceLibrary() { + openDocumentsDrawer("library"); + } + + function openSourcePdfBrowser() { + openDocumentsDrawer("source"); } function openUploadDrawer() { + if (!canUsePrivateApis) { + openDocumentsDrawer("library"); + setActionNotice({ + tone: "warning", + message: "Upload and indexing tools are admin-only. Use the source library to open indexed documents.", + }); + return; + } setSearchMode("documents"); + setDocumentsDrawerMode("admin"); setUploadDrawerOpen(true); window.requestAnimationFrame(() => { const drawer = document.getElementById("dashboard-upload-drawer") as HTMLDetailsElement | null; @@ -6560,21 +7829,30 @@ export function ClinicalDashboard() { ); const bottomNavItems = [ { - label: searchMode === "answer" ? "Answer" : "Docs", + label: activeModeSearch.statusLabel, description: - searchMode === "answer" - ? answer - ? weakEvidence - ? "Read synthesis carefully" - : "Clinical synthesis" - : "Ask a question first" - : documentMatches.length - ? "Document results" - : "Search documents", - icon: searchMode === "answer" ? Search : FileText, + activeModeResultKind === "favourites" + ? query.trim() + ? "Filtered favourites" + : "Browse saved items" + : activeModeResultKind === "answer" + ? answer + ? weakEvidence + ? "Read synthesis carefully" + : "Clinical synthesis" + : activeModeSearch.nextStep + : documentMatches.length + ? "Document results" + : activeModeSearch.readyTitle, + icon: activeModeResultKind === "favourites" ? Heart : activeModeResultKind === "answer" ? Search : FileText, href: "#search", - count: searchMode === "documents" ? documentMatches.length : null, - empty: searchMode === "documents" && documentMatches.length === 0, + count: + activeModeResultKind === "favourites" + ? favouriteItems.length + favouriteSets.length + : activeModeResultKind === "documents" + ? documentMatches.length + : null, + empty: activeModeResultKind === "documents" && documentMatches.length === 0, }, { label: "Quotes", @@ -6693,6 +7971,36 @@ export function ClinicalDashboard() { setUploadMobileTab("jobs"); void refresh({ includeSetup: false, includeDashboardData: true, includeDocumentMeta: false }); }; + const documentsDrawerIsAdmin = documentsDrawerMode === "admin" && canUsePrivateApis; + const documentsDrawerTitle = + documentsDrawerMode === "recent" + ? "Recent documents" + : documentsDrawerMode === "source" + ? "Source PDFs" + : documentsDrawerIsAdmin + ? "Document admin" + : "Source library"; + const documentsDrawerSummary = dashboardDataLoading + ? "Loading indexed document status." + : indexedDocumentTotal + ? documentsDrawerMode === "recent" + ? `${indexedDocumentTotal.toLocaleString()} indexed sources, sorted by recent updates` + : documentsDrawerMode === "source" + ? "Open original PDF source documents" + : documentsDrawerIsAdmin + ? `${indexedDocumentTotal.toLocaleString()} indexed documents available` + : `${indexedDocumentTotal.toLocaleString()} indexed sources available` + : "No indexed documents yet."; + const documentsDrawerMobileSummary = dashboardDataLoading + ? "Loading library" + : documentsDrawerMode === "recent" + ? "Recent sources" + : documentsDrawerMode === "source" + ? "PDF sources" + : documentsDrawerIsAdmin + ? "Admin" + : "Library"; + const drawerGroupTitle = uploadDrawerOpen || documentsDrawerIsAdmin ? "Library and admin" : "Sources"; return (
setQuery("")} onClearScope={() => setSelectedDocumentIds([])} @@ -6741,6 +8051,8 @@ export function ClinicalDashboard() { onOpenMobileSidebar={() => setMobileSidebarOpen(true)} onToggleTheme={toggleTheme} queryModeOptions={clinicalQueryModeOptions} + queryInputRef={composerInputRef} + queryInputAutoFocus={focusSearch} />

- {searchMode === "answer" ? "Answer" : "Document matches"} + {activeModeSearch.resultHeading}

{error && (
)} - {loading && answerProgress && ( + {loading && answerProgress && searchMode !== "prescribing" && (
)} - {searchMode === "documents" ? ( - <> - - setQuery("")} + onAddFavourite={() => + setActionNotice({ + tone: "success", + message: "Favourite actions are ready for the selected answer, medication, or source.", + }) + } + /> + ) : activeModeResultKind === "documents" ? ( + searchMode === "prescribing" ? ( + - + ) : ( + <> + + + + ) ) : loading && !answer ? ( ) : answer ? ( @@ -6868,7 +8208,7 @@ export function ClinicalDashboard() { {showSystemNotice && answer ? renderSystemNotice("sm:hidden") : null} - {searchMode === "answer" && answer && ( + {activeModeResultKind === "answer" && answer && ( - - - + + {documentsDrawerOpen ? ( + + {documentsDrawerIsAdmin ? ( + + ) : null} - - - + + ) : null} + + {uploadDrawerOpen ? ( +
- + + ) : null} )} @@ -7075,8 +8410,9 @@ export function ClinicalDashboard() { recentQueries={recentQueries} onOpenChange={setMobileSidebarOpen} onNewChat={startNewChat} - onPickRecent={setQuery} + onPickRecent={pickRecentQuery} onOpenGuide={openGuide} + onPrefetchApplications={prefetchApplications} />
diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx new file mode 100644 index 0000000000..39ba7b6483 --- /dev/null +++ b/src/components/applications-launcher-page.tsx @@ -0,0 +1,1162 @@ +"use client"; + +import Link from "next/link"; +import { + BookOpen, + Brain, + Check, + ChevronRight, + ClipboardList, + ExternalLink, + FileText, + Grid2X2, + Globe2, + ListChecks, + Mic, + Menu, + MoreVertical, + Pill, + Pin, + PinOff, + Plus, + Puzzle, + Search, + Send, + Settings, + Sparkles, + Star, + Stethoscope, + Users, + X, + type LucideIcon, +} from "lucide-react"; +import { type FormEvent, useMemo, useState } from "react"; + +import { Sheet } from "@/components/ui/sheet"; +import { + chatComposerIconButton, + chatComposerInput, + chatComposerShell, + chatSendButton, + cn, + sidebarItem, + sidebarToolTile as sidebarApplicationTile, + textMuted, +} from "@/components/ui-primitives"; +import { toolCatalog } from "@/lib/tools"; + +type LauncherStatus = "ready" | "recent" | "review_due"; +type LauncherCategory = "clinical" | "admin" | "recent"; + +type LauncherApp = { + id: string; + title: string; + shortTitle?: string; + description: string; + detail: string; + href: string; + external: boolean; + icon: LucideIcon; + category: LauncherCategory; + workflow: string; + lastUsed: string; + status: LauncherStatus; + sourceToolId?: string; + relatedIds: string[]; + quickActions: string[]; + recentWorkflows: Array<{ title: string; date: string }>; +}; + +const statusStyles: Record = { + ready: "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]", + recent: "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]", + review_due: "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]", +}; + +const statusLabels: Record = { + ready: "Ready", + recent: "Recent", + review_due: "Review due", +}; + +const filterOptions = [ + { id: "all", label: "All" }, + { id: "clinical", label: "Clinical" }, + { id: "admin", label: "Admin" }, + { id: "recent", label: "Recent" }, +] as const; + +const sidebarApplicationItems = [ + { label: "Formulation", icon: Brain, href: "/applications" }, + { label: "DSM-5", icon: BookOpen, href: "/applications" }, + { label: "Meds", icon: Pill, href: "/?mode=prescribing" }, + { label: "Diffs", icon: Search, href: "/applications" }, +] as const; + +function toolById(id: string) { + return toolCatalog.find((tool) => tool.id === id); +} + +function hrefForTool(id: string, fallback: string) { + return toolById(id)?.href ?? fallback; +} + +function isExternalTool(id: string) { + return toolById(id)?.target === "external"; +} + +const launcherApps: LauncherApp[] = [ + { + id: "differential-diagnosis", + title: "Differential Diagnosis", + description: "Generate and explore differential diagnoses.", + detail: "Compare likely differentials, rule-outs, red flags, and competing diagnostic explanations.", + href: hrefForTool("differentials", "http://127.0.0.1:53375"), + external: isExternalTool("differentials"), + icon: Stethoscope, + category: "recent", + workflow: "Assessment", + lastUsed: "Today, 10:24 AM", + status: "recent", + sourceToolId: "differentials", + relatedIds: ["specifiers", "services", "clinical-kb-search"], + quickActions: ["Start differential", "Review red flags", "Compare rule-outs", "Open assessment history"], + recentWorkflows: [ + { title: "Chest pain differential", date: "Today, 10:24 AM" }, + { title: "Headache workup", date: "Yesterday, 3:11 PM" }, + { title: "Fatigue assessment", date: "May 10, 2025" }, + ], + }, + { + id: "specifiers", + title: "Specifiers", + description: "Add clinical specifiers and refine conditions.", + detail: "Review severity, course, qualifiers, and specifier language for a diagnosis.", + href: hrefForTool("specifiers", "http://127.0.0.1:58123"), + external: isExternalTool("specifiers"), + icon: ListChecks, + category: "clinical", + workflow: "Assessment", + lastUsed: "Yesterday, 4:15 PM", + status: "ready", + sourceToolId: "specifiers", + relatedIds: ["differential-diagnosis", "formulation", "clinical-kb-search"], + quickActions: ["Open specifier review", "Check course descriptors", "Review severity", "Browse qualifiers"], + recentWorkflows: [ + { title: "Mood episode specifiers", date: "Yesterday, 4:15 PM" }, + { title: "Anxiety course review", date: "May 11, 2025" }, + { title: "Psychosis qualifiers", date: "May 8, 2025" }, + ], + }, + { + id: "services", + title: "Services", + description: "Browse and manage clinical services.", + detail: "Find referral pathways, access points, service matching, and destination options.", + href: hrefForTool("services", "http://127.0.0.1:53174"), + external: isExternalTool("services"), + icon: Users, + category: "clinical", + workflow: "Care planning", + lastUsed: "May 12, 2025", + status: "ready", + sourceToolId: "services", + relatedIds: ["formulation", "documents", "clinical-kb-search"], + quickActions: ["Find referral pathway", "Browse service options", "Review access criteria", "Open saved pathway"], + recentWorkflows: [ + { title: "Community referral options", date: "May 12, 2025" }, + { title: "Crisis pathway review", date: "May 9, 2025" }, + { title: "Outpatient access check", date: "May 7, 2025" }, + ], + }, + { + id: "formulation", + title: "Formulation", + description: "Create and manage clinical formulations.", + detail: "Structure case formulations to support assessment, conceptualisation, care planning, and team reuse.", + href: hrefForTool("formulation", "http://localhost:53210"), + external: isExternalTool("formulation"), + icon: Puzzle, + category: "recent", + workflow: "Care planning", + lastUsed: "Today, 9:02 AM", + status: "recent", + sourceToolId: "formulation", + relatedIds: ["differential-diagnosis", "medication-prescribing", "documents", "clinical-kb-search"], + quickActions: ["Create new formulation", "Browse my formulations", "Shared with me", "Formulation templates"], + recentWorkflows: [ + { title: "Mood & anxiety formulation - Jane D.", date: "Today, 9:02 AM" }, + { title: "Complex case review - M. Smith", date: "May 12, 2025" }, + { title: "Care plan formulation - P. Johnson", date: "May 9, 2025" }, + { title: "Discharge formulation - K. Patel", date: "May 7, 2025" }, + ], + }, + { + id: "medication-prescribing", + title: "Medication Prescribing", + shortTitle: "Medication", + description: "Search, prescribe and review medications.", + detail: "Check prescribing context, monitoring, interactions, and medication-specific safety issues.", + href: "/?mode=prescribing", + external: false, + icon: Pill, + category: "clinical", + workflow: "Care planning", + lastUsed: "May 12, 2025", + status: "review_due", + sourceToolId: "medications", + relatedIds: ["formulation", "documents", "clinical-kb-search"], + quickActions: ["Create new prescription", "Browse formulary", "Review interactions", "Medication templates"], + recentWorkflows: [ + { title: "Medication review - Sam T.", date: "May 12, 2025" }, + { title: "Repeat prescription - Atorvastatin 20mg", date: "May 9, 2025" }, + { title: "Renal dose screen", date: "May 8, 2025" }, + ], + }, + { + id: "documents", + title: "Documents", + description: "Access and manage clinical documents.", + detail: "Search indexed PDFs, guidelines, policies, notes, and source documents.", + href: "/?mode=documents", + external: false, + icon: FileText, + category: "admin", + workflow: "Reference", + lastUsed: "May 10, 2025", + status: "ready", + relatedIds: ["clinical-kb-search", "favourites", "formulation"], + quickActions: ["Search documents", "Browse library", "Open source PDF", "Review indexed documents"], + recentWorkflows: [ + { title: "Lithium monitoring guideline", date: "May 10, 2025" }, + { title: "Safety plan source review", date: "May 9, 2025" }, + { title: "Clozapine monitoring protocol", date: "May 7, 2025" }, + ], + }, + { + id: "favourites", + title: "Favourites", + description: "View and manage your saved items.", + detail: "Open saved sources, medications, documents, workflows, and reusable clinical sets.", + href: "/?mode=favourites", + external: false, + icon: Star, + category: "recent", + workflow: "Reference", + lastUsed: "Today, 8:45 AM", + status: "recent", + relatedIds: ["documents", "clinical-kb-search", "formulation"], + quickActions: ["Open saved items", "Manage pinned sets", "Review due favourites", "Add current answer"], + recentWorkflows: [ + { title: "Ward round set", date: "Today, 8:45 AM" }, + { title: "Prescribing safety set", date: "May 12, 2025" }, + { title: "Document QA set", date: "May 10, 2025" }, + ], + }, + { + id: "clinical-kb-search", + title: "Clinical KB Search", + description: "Search the knowledge base content.", + detail: "Search the Clinical KB for source-backed guidance, answers, and evidence.", + href: "/?mode=answer", + external: false, + icon: Search, + category: "clinical", + workflow: "Reference", + lastUsed: "Today, 7:30 AM", + status: "ready", + relatedIds: ["documents", "favourites", "differential-diagnosis"], + quickActions: ["Ask clinical question", "Search indexed guidelines", "Open document scope", "Review sources"], + recentWorkflows: [ + { title: "Lithium monitoring search", date: "Today, 7:30 AM" }, + { title: "Safety plan search", date: "May 12, 2025" }, + { title: "Clozapine source check", date: "May 10, 2025" }, + ], + }, +]; + +const seedPinnedIds = ["formulation", "differential-diagnosis", "medication-prescribing"]; + +const recentActivity = [ + { id: "formulation", label: "Formulation opened", date: "Today, 9:02 AM", icon: Puzzle }, + { + id: "differential-diagnosis", + label: "Differential Diagnosis launched", + date: "Today, 10:24 AM", + icon: Stethoscope, + }, + { id: "medication-prescribing", label: "Medication Prescribing reviewed", date: "May 12, 2025", icon: Pill }, + { id: "documents", label: "Documents opened", date: "May 10, 2025", icon: FileText }, +] as const; + +function appById(id: string) { + return launcherApps.find((app) => app.id === id) ?? launcherApps[0]; +} + +function StatusPill({ status }: { status: LauncherStatus }) { + return ( + + {statusLabels[status]} + + ); +} + +function AppIcon({ app, compact = false }: { app: LauncherApp; compact?: boolean }) { + const Icon = app.icon; + return ( + + + + ); +} + +function LaunchLink({ app, compact = false, className }: { app: LauncherApp; compact?: boolean; className?: string }) { + const label = `Launch ${app.title}`; + const classes = cn( + "inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[color:var(--clinical-chat-teal)] px-4 text-sm font-semibold text-white shadow-[var(--shadow-tight)] hover:bg-[color:var(--primary-strong)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", + compact && "min-h-9 px-3 text-xs", + className, + ); + + if (app.external) { + return ( + + Launch + + + ); + } + + return ( + + Launch + + + ); +} + +function HeaderFilter({ + activeFilter, + onFilterChange, +}: { + activeFilter: (typeof filterOptions)[number]["id"]; + onFilterChange: (filter: (typeof filterOptions)[number]["id"]) => void; +}) { + return ( +
+ {filterOptions.map((option) => { + const active = option.id === activeFilter; + return ( + + ); + })} +
+ ); +} + +function PinnedSection({ + pinnedApps, + selectedId, + onSelect, + onTogglePin, +}: { + pinnedApps: LauncherApp[]; + selectedId: string; + onSelect: (id: string) => void; + onTogglePin: (id: string) => void; +}) { + return ( +
+
+
+ + Pinned +
+ {pinnedApps.length} pinned +
+
+ {pinnedApps.map((app) => { + const selected = selectedId === app.id; + return ( +
+ + + +
+ ); + })} +
+
+ ); +} + +function ApplicationRow({ + app, + selected, + pinned, + onSelect, + onTogglePin, +}: { + app: LauncherApp; + selected: boolean; + pinned: boolean; + onSelect: (id: string) => void; + onTogglePin: (id: string) => void; +}) { + return ( +
+ + + +
+ ); +} + +function MobileApplicationRow({ + app, + selected, + onSelect, +}: { + app: LauncherApp; + selected: boolean; + onSelect: (id: string) => void; +}) { + return ( + + ); +} + +function DetailPanel({ + app, + pinned, + onTogglePin, + onClose, + headingId, + testId = "selected-application-panel", + variant = "inline", +}: { + app: LauncherApp; + pinned: boolean; + onTogglePin: (id: string) => void; + onClose?: () => void; + headingId?: string; + testId?: string; + variant?: "inline" | "sheet"; +}) { + const related = app.relatedIds.map(appById); + + return ( + + ); +} + +function ApplicationsMobileMenu({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const recentQueries = ["lithium", "tables", "dosing for lithium", "management of bulimia nervosa"]; + + return ( + onOpenChange(false)} + title="Clinical Guide" + description="Recent chats, applications, help, and settings." + closeLabel="Close Clinical Guide menu" + placement="left" + contentClassName="lg:hidden" + > +
+ onOpenChange(false)} + className="inline-flex min-h-[44px] w-full items-center justify-center gap-2 rounded-lg bg-[color:var(--clinical-chat-teal)] px-3 text-sm font-semibold text-white shadow-[var(--shadow-tight)] hover:bg-[color:var(--primary-strong)]" + > + + New chat + + + + +
+

+ Recent chats +

+
+ {recentQueries.map((recent) => ( + + ))} +
+
+ +
+

+ Applications +

+
+ {sidebarApplicationItems.map((item) => { + const Icon = item.icon; + return ( + onOpenChange(false)} + className={sidebarApplicationTile} + > + + {item.label} + + ); + })} +
+
+ +
+ + +
+ + AK + + + Dr A. Khan + Ready + +
+
+
+
+ ); +} + +function ApplicationsModeMenu({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const options = [ + { + label: "Answer", + description: "Source-backed clinical answer", + href: "/?mode=answer", + icon: Sparkles, + active: false, + }, + { + label: "Documents", + description: "Search indexed PDFs and notes", + href: "/?mode=documents", + icon: FileText, + active: false, + }, + { + label: "Favourites", + description: "Saved sources and workflows", + href: "/?mode=favourites", + icon: Star, + active: false, + }, + { + label: "Applications", + description: "Launch connected applications", + href: "/applications", + icon: Grid2X2, + active: true, + }, + ] as const; + + if (!open) return null; + + return ( +
+ {options.map((option) => { + const Icon = option.icon; + return ( + onOpenChange(false)} + aria-current={option.active ? "page" : undefined} + className={cn( + "grid min-h-[3.25rem] w-full grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-2.5 py-2 text-left transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", + option.active + ? "bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)]" + : "text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]", + )} + > + + + + + {option.label} + + {option.description} + + + {option.active ? : null} + + ); + })} +
+ ); +} + +function ApplicationsHeader({ + mobileMenuOpen, + modeMenuOpen, + onMobileMenuOpenChange, + onModeMenuOpenChange, +}: { + mobileMenuOpen: boolean; + modeMenuOpen: boolean; + onMobileMenuOpenChange: (open: boolean) => void; + onModeMenuOpenChange: (open: boolean) => void; +}) { + return ( + + ); +} + +export function ApplicationsLauncherPage() { + const [query, setQuery] = useState(""); + const [activeFilter, setActiveFilter] = useState<(typeof filterOptions)[number]["id"]>("all"); + const [selectedId, setSelectedId] = useState("formulation"); + const [pinnedIds, setPinnedIds] = useState(seedPinnedIds); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [modeMenuOpen, setModeMenuOpen] = useState(false); + const [mobileDetailOpen, setMobileDetailOpen] = useState(false); + + const normalizedQuery = query.trim().toLowerCase(); + const pinnedApps = pinnedIds.map(appById); + const selectedApp = appById(selectedId); + + const filteredApps = useMemo(() => { + return launcherApps.filter((app) => { + const matchesFilter = + activeFilter === "all" || + (activeFilter === "recent" + ? app.status === "recent" || app.category === "recent" + : app.category === activeFilter); + const matchesQuery = + !normalizedQuery || + [app.title, app.description, app.workflow, app.detail].some((value) => + value.toLowerCase().includes(normalizedQuery), + ); + return matchesFilter && matchesQuery; + }); + }, [activeFilter, normalizedQuery]); + + function togglePin(id: string) { + setPinnedIds((current) => (current.includes(id) ? current.filter((item) => item !== id) : [id, ...current])); + } + + function selectApplication(id: string) { + setSelectedId(id); + if (typeof window !== "undefined" && window.matchMedia("(max-width: 1023px)").matches) { + setMobileDetailOpen(true); + } + } + + function submitFooterSearch(event: FormEvent) { + event.preventDefault(); + const firstMatch = filteredApps[0]; + if (firstMatch) selectApplication(firstMatch.id); + } + + return ( +
+ + + +
+
+ + + +

+ Applications +

+

+ Open the clinical applications and connected workflows you use for assessment, formulation, prescribing, + documents, and saved workflows. +

+
+ +
+
+ +
+
+ + +
+
+ +

All applications

+
+ +
+ Application + Last used + Status + Action + +
+ +
+ {filteredApps.map((app) => ( + + ))} +
+ +
+ {filteredApps.map((app) => ( + + ))} +
+ +

+ Showing {filteredApps.length > 0 ? "1" : "0"} to {filteredApps.length} of {launcherApps.length}{" "} + applications +

+
+ +
+
+

Recent activity

+ +
+
+ {recentActivity.slice(0, 3).map((item) => { + const Icon = item.icon; + return ( + + ); + })} +
+
+
+ +
+ +
+
+
+ + setMobileDetailOpen(false)} + labelledBy="selected-application-sheet-heading" + closeLabel="Close selected application" + contentClassName="lg:hidden rounded-t-[1.75rem] bg-[color:var(--surface-lux)]" + bodyClassName="px-5 pb-6 pt-4 sm:px-5" + portal + > + setMobileDetailOpen(false)} + headingId="selected-application-sheet-heading" + testId="selected-application-sheet-panel" + variant="sheet" + /> + + +
+ + + + +
+
+ ); +} diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx new file mode 100644 index 0000000000..ec738e064c --- /dev/null +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { FileText, Heart, ListChecks, Pill, Search, Sparkles } from "lucide-react"; +import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react"; + +import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; +import { useTheme } from "@/components/clinical-dashboard/use-theme"; +import { Sheet } from "@/components/ui/sheet"; +import { cn, sidebarItem } from "@/components/ui-primitives"; +import { + appModeDefinition, + appModeHomeHref, + isAppModeId, + isAppModeVisible, + visibleAppModeDefinitions, + type AppModeId, +} from "@/lib/app-modes"; +import type { SearchScopeFilters } from "@/lib/search-scope"; +import type { ClinicalQueryMode } from "@/lib/types"; + +const mockupQueryModeOptions: Array<{ value: ClinicalQueryMode; label: string }> = [ + { value: "auto", label: "Auto" }, + { value: "monitoring_schedule", label: "Monitoring" }, + { value: "dose_threshold_lookup", label: "Dose / thresholds" }, + { value: "contraindications_cautions", label: "Cautions" }, + { value: "escalation_criteria", label: "Escalation" }, + { value: "required_documentation", label: "Documentation" }, + { value: "compare_guidance", label: "Compare" }, +]; + +const appModeIcons: Record = { + answer: Sparkles, + documents: FileText, + prescribing: Pill, + evidence: ListChecks, + favourites: Heart, +}; + +export function GlobalMockupSearchShell({ children }: { children: ReactNode }) { + const router = useRouter(); + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [searchMode, setSearchMode] = useState("answer"); + const [queryMode, setQueryMode] = useState("auto"); + const [scopeFilters, setScopeFilters] = useState({}); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const { theme, toggleTheme } = useTheme(); + + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + const params = new URLSearchParams(window.location.search); + const requestedMode = params.get("mode"); + if (isAppModeId(requestedMode) && isAppModeVisible(requestedMode)) setSearchMode(requestedMode); + + const requestedQuery = params.get("q")?.trim(); + if (requestedQuery) setQuery(requestedQuery); + + if (params.get("focus") === "1") inputRef.current?.focus({ preventScroll: true }); + }); + return () => window.cancelAnimationFrame(frame); + }, []); + + function navigateToMode(mode: AppModeId, options: { query?: string; run?: boolean; focus?: boolean } = {}) { + router.push(appModeHomeHref(mode, options)); + } + + function submitSearch() { + const trimmedQuery = query.trim(); + navigateToMode(searchMode, { + query: trimmedQuery || undefined, + run: Boolean(trimmedQuery), + focus: true, + }); + } + + function changeMode(mode: AppModeId) { + setSearchMode(mode); + setMobileMenuOpen(false); + navigateToMode(mode, { focus: true }); + } + + function startNewChat() { + setQuery(""); + setSearchMode("answer"); + setMobileMenuOpen(false); + navigateToMode("answer", { focus: true }); + } + + return ( +
+ setQuery("")} + onClearScope={() => undefined} + onQueryModeChange={setQueryMode} + onScopeFiltersChange={setScopeFilters} + onToggleScope={() => undefined} + onOpenUpload={() => router.push(`${appModeHomeHref("documents", { focus: true })}#sources`)} + onOpenEvidence={() => navigateToMode("evidence", { focus: true })} + onNewChat={startNewChat} + onOpenMobileSidebar={() => setMobileMenuOpen(true)} + onToggleTheme={toggleTheme} + queryModeOptions={mockupQueryModeOptions} + scopeVariant="placeholder" + queryInputRef={inputRef} + modeAlignment="center" + /> + +
+ {children} +
+ + setMobileMenuOpen(false)} + title="Clinical Guide" + description="Choose the search workspace." + closeLabel="Close Clinical Guide menu" + placement="left" + contentClassName="max-w-[min(20rem,calc(100vw-1rem))]" + > + + +
+ ); +} diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index e19a1bdd9f..1d05004de8 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1,8 +1,10 @@ "use client"; -import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FormEvent, useCallback, useEffect, useMemo, useRef, useState, type Ref } from "react"; import { + Activity, + CalendarDays, Check, CheckCircle2, ChevronDown, @@ -19,10 +21,11 @@ import { Plus, Search, Send, + ShieldCheck, Sparkles, Sun, - UserRound, X, + Lock, } from "lucide-react"; import { DocumentTagCloud } from "@/components/DocumentTagCloud"; @@ -37,6 +40,14 @@ import { eyebrowText, } from "@/components/ui-primitives"; import { Sheet } from "@/components/ui/sheet"; +import { + appModeDefinition, + appModeDefinitions, + appModeSearchConfig, + isSearchableAppMode, + visibleAppModeDefinitions, + type AppModeId, +} from "@/lib/app-modes"; import { type ResolvedTheme } from "@/lib/theme"; import type { ClinicalDocument, ClinicalQueryMode } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; @@ -44,64 +55,14 @@ import { tagSearchText } from "@/lib/document-tags"; const mobileSheetMediaQuery = "(max-width: 639px)"; -type AppModeId = "answer" | "documents" | "prescribing" | "evidence" | "favourites" | "profile"; - -const appModeOptions: Array<{ - id: AppModeId; - label: string; - description: string; - icon: typeof Search; - href?: string; - devOnly?: boolean; -}> = [ - { - id: "answer", - label: "Answer", - description: "Source-backed clinical answer", - icon: Sparkles, - }, - { - id: "documents", - label: "Documents", - description: "Search indexed PDFs and notes", - icon: FileText, - }, - { - id: "prescribing", - label: "Prescribing", - description: "Medication checks and guidance", - icon: Pill, - href: "/mockups/medication-prescribing", - devOnly: true, - }, - { - id: "evidence", - label: "Evidence", - description: "Tables, quotes, images, PDFs", - icon: ListChecks, - href: "/mockups/answer-evidence-popups", - devOnly: true, - }, - { - id: "favourites", - label: "Favourites", - description: "Saved sources and workflows", - icon: Heart, - href: "/mockups/favourites-hub", - devOnly: true, - }, - { - id: "profile", - label: "Profile", - description: "Home, preferences, review queue", - icon: UserRound, - href: "/mockups/user-home-profile", - devOnly: true, - }, -]; - -const isDev = process.env.NODE_ENV === "development"; -const visibleAppModeOptions = appModeOptions.filter((mode) => !mode.devOnly || isDev); +const visibleAppModeOptions = visibleAppModeDefinitions(); +const appModeIcons: Record = { + answer: Sparkles, + documents: FileText, + prescribing: Pill, + evidence: ListChecks, + favourites: Heart, +}; function splitFilterText(value: string) { return value @@ -128,6 +89,7 @@ function documentScopeMeta(document: ClinicalDocument) { export function MasterSearchHeader({ documents, + documentTotal, query, searchMode, loading, @@ -151,10 +113,15 @@ export function MasterSearchHeader({ onOpenMobileSidebar, onToggleTheme, queryModeOptions, + scopeVariant = "full", + queryInputRef, + queryInputAutoFocus = false, + modeAlignment = "default", }: { documents: ClinicalDocument[]; + documentTotal?: number; query: string; - searchMode: "answer" | "documents"; + searchMode: AppModeId; loading: boolean; selectedDocumentIds: string[]; queryMode: ClinicalQueryMode; @@ -162,7 +129,7 @@ export function MasterSearchHeader({ realDataReady: boolean; theme: ResolvedTheme; onQueryChange: (query: string) => void; - onSearchModeChange: (mode: "answer" | "documents") => void; + onSearchModeChange: (mode: AppModeId) => void; onAsk: () => void; onClearQuery: () => void; onClearScope: () => void; @@ -176,9 +143,24 @@ export function MasterSearchHeader({ onOpenMobileSidebar?: () => void; onToggleTheme: () => void; queryModeOptions: Array<{ value: ClinicalQueryMode; label: string }>; + scopeVariant?: "full" | "placeholder"; + queryInputRef?: Ref; + queryInputAutoFocus?: boolean; + modeAlignment?: "default" | "center"; }) { const trimmedQuery = query.trim(); - const canAsk = trimmedQuery.length >= 1 && !loading && realDataReady; + const selectedSearch = appModeSearchConfig(searchMode); + const selectedAppMode = appModeDefinition(searchMode); + const selectedSearchable = isSearchableAppMode(searchMode); + const scopeIsPlaceholder = scopeVariant === "placeholder"; + const canRunLocalSearch = selectedSearch.kind === "favourites"; + const canAsk = + trimmedQuery.length >= 1 && !loading && selectedSearchable && (realDataReady || canRunLocalSearch); + const indexedDocumentTotal = documentTotal ?? documents.length; + const hasUnloadedDocuments = indexedDocumentTotal > documents.length; + const loadedScopeSummary = hasUnloadedDocuments + ? `${documents.length.toLocaleString()} loaded of ${indexedDocumentTotal.toLocaleString()}` + : `${documents.length.toLocaleString()} available`; const [scopeFilter, setScopeFilter] = useState(""); const [scopeOpen, setScopeOpen] = useState(false); const [scopeSheetOpen, setScopeSheetOpen] = useState(false); @@ -186,6 +168,7 @@ export function MasterSearchHeader({ const [modeMenuOpen, setModeMenuOpen] = useState(false); const [usesScopeSheet, setUsesScopeSheet] = useState(false); const dailyActionButtonRef = useRef(null); + const dailyActionsMenuRef = useRef(null); const firstDailyActionRef = useRef(null); const modeMenuRef = useRef(null); const scopeDetailsRef = useRef(null); @@ -222,18 +205,59 @@ export function MasterSearchHeader({ const hiddenScopeMatchCount = requireScopeFilter ? Math.max(0, selectedDocuments.length ? documents.length - selectedDocumentIds.length : documents.length) : Math.max(0, matchingDocuments.length - visibleScopeDocuments.length); - const submitLabel = searchMode === "answer" ? (trimmedQuery ? "Answer" : "Ask") : "Docs"; - const queryPlaceholder = searchMode === "documents" ? "Search documents..." : "Ask a clinical question..."; - const selectedAppMode = appModeOptions.find((mode) => mode.id === searchMode) ?? appModeOptions[0]; - const SelectedAppModeIcon = selectedAppMode.icon; - const dailyActions = [ - { label: "Search library", icon: Search }, - { label: "Add document", icon: FileText }, - { label: "Scope", icon: Filter }, - { label: "Evidence", icon: ListChecks }, - { label: "Clinical tools", icon: Sparkles }, - ] as const; + const submitLabel = trimmedQuery ? selectedSearch.submitBusyLabel : selectedSearch.submitIdleLabel; + const queryPlaceholder = selectedSearch.placeholder; + const SelectedAppModeIcon = appModeIcons[selectedAppMode.id]; + const dailyActions = + searchMode === "prescribing" + ? ([ + { label: "Dose", icon: CalendarDays }, + { label: "Safety", icon: ShieldCheck }, + { label: "Monitoring", icon: Activity }, + { label: "Access", icon: Lock }, + ] as const) + : ([ + { label: "Search library", icon: Search }, + { label: "Add document", icon: FileText }, + { label: "Scope", icon: Filter }, + { label: "Evidence", icon: ListChecks }, + { label: "Applications", icon: Sparkles }, + ] as const); + const dailyActionsTitle = searchMode === "prescribing" ? "Medication checks" : "Daily actions"; + const dailyActionsButtonLabel = searchMode === "prescribing" ? "Open medication checks" : "Open daily actions"; + const dailyActionsDescription = + searchMode === "prescribing" + ? "Choose a dose, safety, monitoring, or access focus." + : "Search, add, scope, evidence, or applications."; + + function currentUsesScopeSheet() { + return window.matchMedia(mobileSheetMediaQuery).matches; + } + function runDailyAction(label: (typeof dailyActions)[number]["label"]) { + if (searchMode === "prescribing") { + const medicationQuery = trimmedQuery || "acamprosate renal dose"; + if (label === "Dose") { + onQueryModeChange("dose_threshold_lookup"); + onQueryChange(medicationQuery); + return; + } + if (label === "Safety") { + onQueryModeChange("contraindications_cautions"); + onQueryChange(trimmedQuery || "acamprosate contraindications"); + return; + } + if (label === "Monitoring") { + onQueryModeChange("monitoring_schedule"); + onQueryChange(trimmedQuery || "acamprosate monitoring"); + return; + } + if (label === "Access") { + onQueryModeChange("required_documentation"); + onQueryChange(trimmedQuery || "acamprosate PBS access"); + return; + } + } if (label === "Search library") { onSearchModeChange("documents"); return; @@ -243,7 +267,9 @@ export function MasterSearchHeader({ return; } if (label === "Scope") { - if (usesScopeSheet) { + const nextUsesScopeSheet = currentUsesScopeSheet(); + setUsesScopeSheet(nextUsesScopeSheet); + if (nextUsesScopeSheet) { setScopeSheetOpen(true); } else { setScopeOpen(true); @@ -256,16 +282,16 @@ export function MasterSearchHeader({ onOpenEvidence?.(); return; } - window.location.assign("/tools"); + window.location.assign("/applications"); } - function selectAppMode(mode: (typeof appModeOptions)[number]) { + function selectAppMode(mode: (typeof appModeDefinitions)[number]) { setModeMenuOpen(false); - if (mode.id === "answer" || mode.id === "documents") { + if (isSearchableAppMode(mode.id)) { onSearchModeChange(mode.id); return; } - if (mode.href) window.location.assign(mode.href); + if ("href" in mode && mode.href) window.location.assign(mode.href); } const collectionOptions = useMemo(() => { const values = new Set(); @@ -305,6 +331,31 @@ export function MasterSearchHeader({ onScopeOpenChange?.(scopeOpen || scopeSheetOpen); }, [onScopeOpenChange, scopeOpen, scopeSheetOpen]); + useEffect(() => { + if (!dailyActionsOpen || usesScopeSheet) return undefined; + + function handlePointerDown(event: PointerEvent) { + const target = event.target; + if (!(target instanceof Node)) return; + if (!dailyActionsMenuRef.current?.contains(target)) setDailyActionsOpen(false); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + setDailyActionsOpen(false); + window.requestAnimationFrame(() => dailyActionButtonRef.current?.focus()); + } + } + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [dailyActionsOpen, usesScopeSheet]); + useEffect(() => { if (!modeMenuOpen) return undefined; @@ -452,7 +503,7 @@ export function MasterSearchHeader({

Document scope

- {selectedDocumentIds.length ? `${selectedDocumentIds.length} selected` : `${documents.length} available`} + {selectedDocumentIds.length ? `${selectedDocumentIds.length} selected` : loadedScopeSummary}
@@ -495,7 +546,8 @@ export function MasterSearchHeader({
{requireScopeFilter && visibleScopeDocuments.length === 0 ? (

- Type to filter {documents.length} documents. Selected documents stay pinned here. + Type to filter {documents.length.toLocaleString()} loaded documents. Selected documents stay pinned + here.

) : null} {visibleScopeDocuments.map((document) => { @@ -556,7 +608,7 @@ export function MasterSearchHeader({ {hiddenScopeMatchCount > 0 ? (

{requireScopeFilter - ? `${documents.length} documents available. Type a title or file name to narrow the list.` + ? `${loadedScopeSummary} documents. Type a title or file name to narrow the loaded list.` : `Showing ${visibleScopeDocuments.length} of ${matchingDocuments.length}. Keep typing to narrow the list.`}

) : null} @@ -572,7 +624,7 @@ export function MasterSearchHeader({ id="search" className="sticky top-0 z-30 border-b border-[color:var(--border)] bg-[color:var(--surface-lux)]/95 px-3 py-2 pt-[max(0.5rem,env(safe-area-inset-top))] text-[color:var(--text)] shadow-[var(--shadow-tight)] backdrop-blur-xl sm:px-4 lg:px-6" > -
+
-
+
+ ) : usesScopeSheet ? ( {dailyActionsOpen && !usesScopeSheet ? (
{dailyActions.map((item) => { @@ -797,12 +872,16 @@ export function MasterSearchHeader({
@@ -8061,15 +8246,15 @@ export function ClinicalDashboard({ ? query.trim() ? "Filtered tools" : "Browse tools" - : activeModeResultKind === "answer" - ? answer - ? weakEvidence - ? "Read synthesis carefully" - : "Clinical synthesis" - : activeModeSearch.nextStep - : documentMatches.length - ? "Document results" - : activeModeSearch.readyTitle, + : activeModeResultKind === "answer" + ? answer + ? weakEvidence + ? "Read synthesis carefully" + : "Clinical synthesis" + : activeModeSearch.nextStep + : documentMatches.length + ? "Document results" + : activeModeSearch.readyTitle, icon: activeModeResultKind === "favourites" ? Heart @@ -8084,9 +8269,9 @@ export function ClinicalDashboard({ ? favouriteItems.length + favouriteSets.length : activeModeResultKind === "tools" ? toolCatalog.length - : activeModeResultKind === "documents" - ? documentMatches.length - : null, + : activeModeResultKind === "documents" + ? documentMatches.length + : null, empty: activeModeResultKind === "documents" && documentMatches.length === 0, }, { @@ -8331,9 +8516,9 @@ export function ClinicalDashboard({ ? "mx-auto w-full max-w-6xl space-y-4 overflow-x-hidden" : activeModeResultKind === "tools" ? "mx-auto w-full max-w-6xl space-y-4 overflow-x-hidden" - : activeModeResultKind === "documents" - ? "mx-auto w-full max-w-6xl space-y-4 overflow-x-hidden" - : "mx-auto w-full max-w-3xl space-y-4 overflow-x-hidden", + : activeModeResultKind === "documents" + ? "mx-auto w-full max-w-6xl space-y-4 overflow-x-hidden" + : "mx-auto w-full max-w-3xl space-y-4 overflow-x-hidden", )} >

@@ -8467,35 +8652,35 @@ export function ClinicalDashboard({ open={documentsDrawerOpen} onOpenChange={setDocumentsDrawerOpen} > - {documentsDrawerIsAdmin ? ( - + ) : null} + - ) : null} - ) : null} @@ -8509,124 +8694,126 @@ export function ClinicalDashboard({ open={uploadDrawerOpen} onOpenChange={setUploadDrawerOpen} > - -
- {uploadTabs.map((tab) => { - const active = uploadMobileTab === tab.id; - const Icon = tab.icon; - return ( - - ); - })} -
-
-
-

- Developer setup status -

- - {showAuthPanel && } -
-
-

Clinical upload

- -
+
-

- Indexing progress -

- + {uploadTabs.map((tab) => { + const active = uploadMobileTab === tab.id; + const Icon = tab.icon; + return ( + + ); + })}
-
-

- Ingestion quality console -

- +
+
+

+ Developer setup status +

+ + {showAuthPanel && } +
+
+

+ Clinical upload +

+ +
+
+

+ Indexing progress +

+ +
+
+

+ Ingestion quality console +

+ +
-
) : null} diff --git a/src/components/DashboardFloatingFab.tsx b/src/components/DashboardFloatingFab.tsx index 98aa73a048..7b16a19440 100644 --- a/src/components/DashboardFloatingFab.tsx +++ b/src/components/DashboardFloatingFab.tsx @@ -92,12 +92,12 @@ export function DashboardFloatingFab() { Copy link setOpen(false)} className={cn(floatingControl, "h-9 min-h-9 px-3 text-xs", !open && "hidden")} > - Tools + Applications {copyNotice && (

diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index b20149abfe..93e6c2d248 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -2072,9 +2072,7 @@ export function DocumentViewer({ ? `/?mode=documents&q=${encodeURIComponent(documentDisplayTitle(readyDocument))}` : documentHomeHref; const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis; - const summarizeTitle = canSummarizeDocument - ? "Answer from this document" - : "Load a source document before answering"; + const summarizeTitle = canSummarizeDocument ? "Answer from this document" : "Load a source document before answering"; const selectedPage = pages.find((page) => page.page_number === initialPage) ?? pages[0]; const selectedChunk = chunkId ? chunks.find((chunk) => chunk.id === chunkId) : undefined; const clinicalImages = images.filter( diff --git a/src/components/clinical-dashboard/dashboard-shell.tsx b/src/components/clinical-dashboard/dashboard-shell.tsx index b710deb450..5a9a573858 100644 --- a/src/components/clinical-dashboard/dashboard-shell.tsx +++ b/src/components/clinical-dashboard/dashboard-shell.tsx @@ -1,7 +1,7 @@ "use client"; import { BookOpen, ChevronDown, type LucideIcon } from "lucide-react"; -import { ReactNode, useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { Sheet } from "@/components/ui/sheet"; import { @@ -76,6 +76,14 @@ export function UtilityDrawer({ onOpenChange, className, mobileInline = false, + sheetHeaderLeading, + sheetTitleAccessory, + sheetDescriptionContent, + sheetHeaderActions, + sheetContentClassName, + sheetContentStyle, + sheetBodyClassName, + sheetDescription, }: { id?: string; title: string; @@ -88,10 +96,23 @@ export function UtilityDrawer({ onOpenChange?: (open: boolean) => void; className?: string; mobileInline?: boolean; + sheetHeaderLeading?: ReactNode; + sheetTitleAccessory?: ReactNode; + sheetDescriptionContent?: ReactNode; + sheetHeaderActions?: ReactNode; + sheetContentClassName?: string; + sheetContentStyle?: CSSProperties; + sheetBodyClassName?: string; + sheetDescription?: string | null; }) { const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const [usesSheet, setUsesSheet] = useState(false); + const mobileTriggerRef = useRef(null); const open = controlledOpen ?? uncontrolledOpen; + const triggerClassName = cn( + "flex min-h-[56px] w-full cursor-pointer list-none items-center justify-between gap-3 rounded-lg px-4 py-3 text-left transition motion-safe:duration-150 hover:bg-[color:var(--surface-subtle)]", + className, + ); const setOpen = useCallback( (nextOpen: boolean) => { if (controlledOpen === undefined) setUncontrolledOpen(nextOpen); @@ -111,16 +132,12 @@ export function UtilityDrawer({ return ( <> + ); + })} + + +

+ {documentCount.toLocaleString()} indexed source{documentCount === 1 ? "" : "s"} +

); } @@ -315,6 +443,63 @@ function SearchResultsHeader({ resultLabel, trimmedQuery }: { resultLabel: strin ); } +function DocumentResultsOverview({ + documentCount, + displayedCount, + matchCount, + activeFacetCount, + trimmedQuery, + onOpenLibrary, +}: { + documentCount: number; + displayedCount: number; + matchCount: number; + activeFacetCount: number; + trimmedQuery: string; + onOpenLibrary: () => void; +}) { + return ( +
+
+

Documents overview

+
+ + {documentCount.toLocaleString()} indexed + + + {matchCount.toLocaleString()} match{matchCount === 1 ? "" : "es"} + + {activeFacetCount > 0 ? ( + + {displayedCount.toLocaleString()} after filters + + ) : null} + {trimmedQuery ? ( + + {trimmedQuery} + + ) : null} +
+
+ +
+ ); +} + export function MatchExplanationChips({ source }: { source: SearchResult }) { const explanation = source.match_explanation; const reasons = explanation?.reasons?.length @@ -360,6 +545,9 @@ export function DocumentSearchResultsPanel({ facets: _facets, onScopeDocument, onAnswerFromDocument, + onOpenRecentDocuments, + onOpenLibrary, + onOpenSourcePdf, onTagSearch, }: { matches: DocumentMatch[]; @@ -373,6 +561,9 @@ export function DocumentSearchResultsPanel({ facets?: SearchFacets | null; onScopeDocument: (documentId: string) => void; onAnswerFromDocument: (documentId: string) => void; + onOpenRecentDocuments: () => void; + onOpenLibrary: () => void; + onOpenSourcePdf: () => void; onTagSearch: (tag: SmartDocumentTag | SmartDocumentTagFacet) => void; }) { void _facets; @@ -456,10 +647,23 @@ export function DocumentSearchResultsPanel({

) : ( - + ) ) : ( <> + {resultTabs.length > 1 ? (
{resultTabs.map((tab) => { @@ -506,8 +710,8 @@ export function DocumentSearchResultsPanel({ {displayedMatches.map((document, index) => { const evidenceBadges = compactEvidenceBadges(document); const relevanceDisplay = relevanceTone(document); - const fileKind = documentFileKind(document.file_name, "DOC"); const relevanceVariant = relevanceDisplay.short === "Relevant" ? "relevant" : "high"; + const fileKind = documentFileKind(document.file_name, "DOC"); const summaryText = cleanDocumentCardSummary(document.summarySnippet || compactMatchReason(document)); const openHref = documentOpenHref(document); return ( @@ -515,82 +719,84 @@ export function DocumentSearchResultsPanel({ key={document.document_id} className={cn( sourceCard, - "relative overflow-hidden p-0 shadow-[0_10px_24px_rgb(15_27_45_/_5%)]", - index === 0 && "border-l-4 border-l-[color:var(--clinical-chat-teal)]", + "relative overflow-visible p-0 shadow-[0_8px_18px_rgb(15_27_45_/_4%)] transition hover:border-[color:var(--clinical-chat-teal-border)] hover:shadow-[0_14px_32px_rgb(15_27_45_/_7%)]", + index === 0 && "ring-1 ring-[color:var(--clinical-chat-teal)]/15", )} > -
- +
+
-

- {documentKindLabel(document)} +

+ {documentKindLabel(document)} + {index === 0 ? ( + <> +

- {documentDisplayTitle(document)} - -
-
- {index === 0 ? ( - - Best match - - ) : null} - - {relevanceDisplay.short} - , {relevanceDisplay.detail} - +
-
- {index === 0 ? ( - - Best match - - ) : null} +
{relevanceDisplay.short} , {relevanceDisplay.detail} + {evidenceBadges.map((badge) => ( + + {badge.label} + + ))}
- - {evidenceBadges.length ? {evidenceBadges.join(", ")} : null} -

+ {evidenceBadges.length ? ( + {evidenceBadges.map((badge) => badge.label).join(", ")} + ) : null} +

-
+
+ - Open + {contextualOpenLabel(document)} onScopeDocument(document.document_id)} icon={Filter} - className="min-h-12 border-r border-[color:var(--border)] text-sm text-[color:var(--text)]" + className="min-h-11 rounded-lg px-2.5 text-xs text-[color:var(--text)]" aria-label={`Scope search to ${document.title}`} > Scope @@ -598,7 +804,7 @@ export function DocumentSearchResultsPanel({ onAnswerFromDocument(document.document_id)} icon={Sparkles} - className="min-h-12 text-sm text-[color:var(--clinical-chat-teal)] hover:bg-[color:var(--clinical-chat-teal-soft)]" + className="ml-auto min-h-11 rounded-lg px-2.5 text-xs text-[color:var(--clinical-chat-teal)] hover:bg-[color:var(--clinical-chat-teal-soft)]" aria-label={`Answer from ${document.title}`} > Answer diff --git a/src/components/clinical-dashboard/document-ui.tsx b/src/components/clinical-dashboard/document-ui.tsx index ee3e573876..565c7dca98 100644 --- a/src/components/clinical-dashboard/document-ui.tsx +++ b/src/components/clinical-dashboard/document-ui.tsx @@ -11,9 +11,9 @@ export type DocumentTileTone = "teal" | "info"; const badgeStyles: Record = { best: "border-[color:var(--clinical-chat-teal)]/20 bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)] shadow-[var(--shadow-inset)]", - high: "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]", + high: "border-[color:var(--clinical-chat-teal)]/18 bg-[color:var(--surface-raised)] text-[color:var(--clinical-chat-teal)] shadow-[var(--shadow-inset)]", relevant: "border-[color:var(--info)]/15 bg-[color:var(--info-soft)]/70 text-[color:var(--info)]", - neutral: "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]", + neutral: "border-[color:var(--border-lux)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]", }; const tileStyles: Record = { diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index ddccf7005d..68089c97d8 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -156,8 +156,7 @@ export function MasterSearchHeader({ const selectedSearchable = isSearchableAppMode(searchMode); const scopeIsPlaceholder = scopeVariant === "placeholder"; const canRunLocalSearch = selectedSearch.kind === "favourites" || selectedSearch.kind === "tools"; - const canAsk = - trimmedQuery.length >= 1 && !loading && selectedSearchable && (realDataReady || canRunLocalSearch); + const canAsk = trimmedQuery.length >= 1 && !loading && selectedSearchable && (realDataReady || canRunLocalSearch); const indexedDocumentTotal = documentTotal ?? documents.length; const hasUnloadedDocuments = indexedDocumentTotal > documents.length; const loadedScopeSummary = hasUnloadedDocuments @@ -640,8 +639,7 @@ export function MasterSearchHeader({ ref={modeMenuRef} className={cn( "relative z-40 mx-auto sm:mx-0", - modeAlignment === "center" && - "absolute left-1/2 top-1/2 mx-0 -translate-x-1/2 -translate-y-1/2", + modeAlignment === "center" && "absolute left-1/2 top-1/2 mx-0 -translate-x-1/2 -translate-y-1/2", )} > + {navItems.map(({ label, icon: Icon }) => { + const selected = label === active; + return ( + + ); + })} + + ); +} + +function SummaryTile({ row }: { row: SettingsRow }) { + const Icon = row.icon ?? Settings; + + return ( + + ); +} + +function StatusChip({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function DesktopProfileStrip() { + return ( + + ); +} + +function SettingRow({ row }: { row: SettingsRow }) { + const Icon = row.icon; + return ( + + ); +} + +function DesktopModal({ concept }: { concept: Concept }) { + return ( +
+
+ +
+
+
+

+ {concept.eyebrow} +

+

{concept.activeNav}

+
+ + Private workspace + +
+ + + +
+ {concept.summary.map((row) => ( + + ))} +
+ +
+ {concept.sections.map((section, index) => ( +
0 && "border-t border-[color:var(--border)] pt-4")}> +

+ {section.title} +

+
+ {section.rows.map((row) => ( + + ))} +
+
+ ))} +
+
+
+
+ ); +} + +function PhoneStatusBar() { + return ( +
+ 9:41 + + + + +
+ ); +} + +function PhoneBackdrop() { + return ( +