From 7aeaf5b4c521fdd48a90a41d56857fe1ef742d4a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:01:25 +0800 Subject: [PATCH 1/2] refactor(dashboard): extract SettingsDialog into settings-dialog module Move-only extraction of SettingsDialog + its sub-components (SettingsChip, SettingsClinicalContextStrip, SettingsSummaryTile, SettingsRow, SettingsHelpFooter) into clinical-dashboard/settings-dialog.tsx. Block 1470-1824 moved verbatim; testid/aria checksum byte-identical. Empty danger set, no runtime cycle. Monolith imports SettingsDialog back; global-mockup-search-shell repointed from the monolith to the new module. Stripped orphaned imports. Co-Authored-By: Claude Fable 5 --- src/components/ClinicalDashboard.tsx | 372 +---------------- .../global-mockup-search-shell.tsx | 3 +- .../clinical-dashboard/settings-dialog.tsx | 382 ++++++++++++++++++ 3 files changed, 385 insertions(+), 372 deletions(-) create mode 100644 src/components/clinical-dashboard/settings-dialog.tsx diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index a54aed1fc0..20462aabfa 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -5,35 +5,21 @@ import { useRouter, useSearchParams } from "next/navigation"; import dynamic from "next/dynamic"; import { AlertCircle, - Bell, BookOpen, ChevronDown, - CircleUserRound, ClipboardCheck, ExternalLink, FileText, - Globe2, - HelpCircle, Heart, - Keyboard, ListChecks, Loader2, - LogOut, - LockKeyhole, - Palette, - PanelTop, Plus, RefreshCw, Search, - Settings as SettingsIcon, ShieldAlert, - ShieldCheck, - SlidersHorizontal, Sparkles, - Stethoscope, Tag, UploadCloud, - UserRound, WifiOff, Wrench, X, @@ -76,14 +62,12 @@ import { } from "@/components/ui-primitives"; import { useAuthSession } from "@/lib/supabase/client"; import { SafeBoldText } from "@/components/SafeBoldText"; -import { Sheet } from "@/components/ui/sheet"; import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; import { StatusBadge } from "@/components/clinical-dashboard/badges"; import { - type SidebarIdentity, deriveSidebarIdentity, ClinicalDesktopSidebar, ClinicalMobileSidebar, @@ -104,6 +88,7 @@ import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/cli import { ScopeAndGovernanceNotice } from "@/components/clinical-dashboard/answer-content"; import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-panels"; import { RelatedDocumentsPanel, StagedAnswerResultSurface } from "@/components/clinical-dashboard/document-results"; +import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { emptyStates, errorCopy } from "@/lib/ui-copy"; import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; @@ -1467,361 +1452,6 @@ function DrawerGroupLabel({ title }: { title: string }) { ); } -export function SettingsDialog({ - open, - onClose, - identity, - theme, - onToggleTheme, - onSignOut, - onOpenGuide, -}: { - open: boolean; - onClose: () => void; - identity: SidebarIdentity; - theme: "light" | "dark"; - onToggleTheme: () => void; - onSignOut: () => void; - onOpenGuide: () => void; -}) { - const closeButtonRef = useRef(null); - const currentThemeLabel = theme === "dark" ? "Dark" : "Light"; - const settingSections = [ - { - title: "Account", - rows: [ - { icon: UserRound, label: "Profile", value: identity.displayName }, - { icon: Stethoscope, label: "Clinical role", value: "Consultant psychiatrist" }, - ], - }, - { - title: "Clinical defaults", - rows: [ - { icon: Globe2, label: "Jurisdiction", value: "Western Australia", active: true }, - { icon: CircleUserRound, label: "Default population", value: "Adults" }, - { icon: SlidersHorizontal, label: "Answer style", value: "Conservative" }, - ], - }, - { - title: "App preferences", - rows: [ - { - icon: Palette, - label: "Appearance", - value: currentThemeLabel, - onClick: onToggleTheme, - actionLabel: `Switch to ${theme === "dark" ? "light" : "dark"} mode`, - }, - { icon: SettingsIcon, label: "Interface density", value: "Comfortable" }, - ], - }, - ]; - const navItems = [ - { icon: SettingsIcon, label: "General" }, - { icon: Stethoscope, label: "Clinical defaults" }, - { icon: Sparkles, label: "Personalisation" }, - { icon: Bell, label: "Notifications" }, - { icon: LockKeyhole, label: "Security" }, - { icon: CircleUserRound, label: "Account", active: true }, - { icon: Keyboard, label: "Keyboard" }, - { - icon: HelpCircle, - label: "Help & About", - onClick: () => { - onClose(); - onOpenGuide(); - }, - }, - ]; - - const closeButton = ( - - ); - - return ( - -
- {closeButton} - - -
-
-
-

- Account & app -

-
- - Clinician account - -
- -
-
- - {identity.initials} - {identity.signedIn ? ( - - ) : null} - -
-

- Clinical context -

-

- {identity.displayName} -

-

- Consultant psychiatrist, Western Australia -

-
-
- - -
-
- -
- -
- - - -
- -
-
- {settingSections.map((section) => ( -
-

- {section.title} -

-
- {section.rows.map((row) => ( - - ))} - {section.title === "Account" && identity.signedIn ? ( - { - onSignOut(); - onClose(); - }} - actionLabel="Sign out" - /> - ) : null} -
-
- ))} -
- { - onClose(); - onOpenGuide(); - }} - /> -
-
-
-
- ); -} - -function SettingsChip({ label }: { label: string }) { - return ( - - {label} - - ); -} - -function SettingsClinicalContextStrip() { - return ( -
- - - Private workspace{" "} - · WA{" "} - · No PHI - -
- ); -} - -function SettingsSummaryTile({ - icon: Icon, - label, - value, - emphasized = false, -}: { - icon: typeof UserRound; - label: string; - value: string; - emphasized?: boolean; -}) { - return ( -
-
- - - - - - {label} - - - {value} - - -
-
- ); -} - -function SettingsRow({ - icon: Icon, - label, - value, - active = false, - onClick, - actionLabel, -}: { - icon: typeof UserRound; - label: string; - value: string; - active?: boolean; - onClick?: () => void; - actionLabel?: string; -}) { - const content = ( - <> - - - - - {label} - {value ? ( - - {value} - - ) : null} - - - - ); - - const className = - "flex min-h-[50px] w-full items-center gap-2.5 border-b border-[color:var(--border)]/70 px-3 py-1.5 text-left last:border-b-0 transition hover:bg-[color:var(--surface)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] sm:min-h-[54px] sm:gap-3 sm:px-3.5 sm:py-2 lg:min-h-10 lg:gap-3 lg:px-0 lg:py-0 lg:hover:bg-[color:var(--surface-lux)]/55"; - const testId = `settings-row-${label - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, "")}`; - - if (onClick) { - return ( - - ); - } - - return ( -
- {content} -
- ); -} - -function SettingsHelpFooter({ onClick }: { onClick: () => void }) { - return ( -
- -
- ); -} - function ToolsHub({ query, onQueryChange, diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index 31cc310b23..39323e1a83 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -4,7 +4,8 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Suspense, type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { ClinicalDashboard } from "@/components/clinical-dashboard"; -import { recentQueryStorageKey, SettingsDialog } from "@/components/ClinicalDashboard"; +import { recentQueryStorageKey } from "@/components/ClinicalDashboard"; +import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; import { ClinicalDesktopSidebar, ClinicalMobileSidebar, diff --git a/src/components/clinical-dashboard/settings-dialog.tsx b/src/components/clinical-dashboard/settings-dialog.tsx new file mode 100644 index 0000000000..c7900309ac --- /dev/null +++ b/src/components/clinical-dashboard/settings-dialog.tsx @@ -0,0 +1,382 @@ +"use client"; + +import { useRef } from "react"; +import { + Bell, + BookOpen, + ChevronDown, + CircleUserRound, + Globe2, + HelpCircle, + Keyboard, + LockKeyhole, + LogOut, + Palette, + PanelTop, + Settings as SettingsIcon, + ShieldCheck, + SlidersHorizontal, + Sparkles, + Stethoscope, + UserRound, + X, +} from "lucide-react"; + +import { type SidebarIdentity } from "@/components/clinical-dashboard/ClinicalSidebar"; +import { cn } from "@/components/ui-primitives"; +import { Sheet } from "@/components/ui/sheet"; + +export function SettingsDialog({ + open, + onClose, + identity, + theme, + onToggleTheme, + onSignOut, + onOpenGuide, +}: { + open: boolean; + onClose: () => void; + identity: SidebarIdentity; + theme: "light" | "dark"; + onToggleTheme: () => void; + onSignOut: () => void; + onOpenGuide: () => void; +}) { + const closeButtonRef = useRef(null); + const currentThemeLabel = theme === "dark" ? "Dark" : "Light"; + const settingSections = [ + { + title: "Account", + rows: [ + { icon: UserRound, label: "Profile", value: identity.displayName }, + { icon: Stethoscope, label: "Clinical role", value: "Consultant psychiatrist" }, + ], + }, + { + title: "Clinical defaults", + rows: [ + { icon: Globe2, label: "Jurisdiction", value: "Western Australia", active: true }, + { icon: CircleUserRound, label: "Default population", value: "Adults" }, + { icon: SlidersHorizontal, label: "Answer style", value: "Conservative" }, + ], + }, + { + title: "App preferences", + rows: [ + { + icon: Palette, + label: "Appearance", + value: currentThemeLabel, + onClick: onToggleTheme, + actionLabel: `Switch to ${theme === "dark" ? "light" : "dark"} mode`, + }, + { icon: SettingsIcon, label: "Interface density", value: "Comfortable" }, + ], + }, + ]; + const navItems = [ + { icon: SettingsIcon, label: "General" }, + { icon: Stethoscope, label: "Clinical defaults" }, + { icon: Sparkles, label: "Personalisation" }, + { icon: Bell, label: "Notifications" }, + { icon: LockKeyhole, label: "Security" }, + { icon: CircleUserRound, label: "Account", active: true }, + { icon: Keyboard, label: "Keyboard" }, + { + icon: HelpCircle, + label: "Help & About", + onClick: () => { + onClose(); + onOpenGuide(); + }, + }, + ]; + + const closeButton = ( + + ); + + return ( + +
+ {closeButton} + + +
+
+
+

+ Account & app +

+
+ + Clinician account + +
+ +
+
+ + {identity.initials} + {identity.signedIn ? ( + + ) : null} + +
+

+ Clinical context +

+

+ {identity.displayName} +

+

+ Consultant psychiatrist, Western Australia +

+
+
+ + +
+
+ +
+ +
+ + + +
+ +
+
+ {settingSections.map((section) => ( +
+

+ {section.title} +

+
+ {section.rows.map((row) => ( + + ))} + {section.title === "Account" && identity.signedIn ? ( + { + onSignOut(); + onClose(); + }} + actionLabel="Sign out" + /> + ) : null} +
+
+ ))} +
+ { + onClose(); + onOpenGuide(); + }} + /> +
+
+
+
+ ); +} + +function SettingsChip({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function SettingsClinicalContextStrip() { + return ( +
+ + + Private workspace{" "} + · WA{" "} + · No PHI + +
+ ); +} + +function SettingsSummaryTile({ + icon: Icon, + label, + value, + emphasized = false, +}: { + icon: typeof UserRound; + label: string; + value: string; + emphasized?: boolean; +}) { + return ( +
+
+ + + + + + {label} + + + {value} + + +
+
+ ); +} + +function SettingsRow({ + icon: Icon, + label, + value, + active = false, + onClick, + actionLabel, +}: { + icon: typeof UserRound; + label: string; + value: string; + active?: boolean; + onClick?: () => void; + actionLabel?: string; +}) { + const content = ( + <> + + + + + {label} + {value ? ( + + {value} + + ) : null} + + + + ); + + const className = + "flex min-h-[50px] w-full items-center gap-2.5 border-b border-[color:var(--border)]/70 px-3 py-1.5 text-left last:border-b-0 transition hover:bg-[color:var(--surface)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] sm:min-h-[54px] sm:gap-3 sm:px-3.5 sm:py-2 lg:min-h-10 lg:gap-3 lg:px-0 lg:py-0 lg:hover:bg-[color:var(--surface-lux)]/55"; + const testId = `settings-row-${label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "")}`; + + if (onClick) { + return ( + + ); + } + + return ( +
+ {content} +
+ ); +} + +function SettingsHelpFooter({ onClick }: { onClick: () => void }) { + return ( +
+ +
+ ); +} From 4056faf75fb8479b3c8d3b58d26f3d384917e6f9 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:05:59 +0800 Subject: [PATCH 2/2] refactor(dashboard): extract ToolsHub + MobileSectionFab into dashboard-nav module Move-only extraction of ToolsHub, MobileSectionFab, and the mobile-section-fab helpers/types (buildMobileSectionFabState, fabToneClassName, mobileSectionItemLabel, MobileSectionFabItem/Tone/State) into clinical-dashboard/dashboard-nav.tsx. Block moved verbatim; testid/aria checksum byte-identical. Monolith imports ToolsHub + MobileSectionFab + buildMobileSectionFabState back; the module back-imports navigationHashes + mobileSectionFabMediaQuery + ApplicationsLauncherWorkspace from the monolith (runtime-safe cycle, same established pattern). Stripped the orphaned useDismissableLayer import. Co-Authored-By: Claude Fable 5 --- src/components/ClinicalDashboard.tsx | 390 +---------------- .../clinical-dashboard/dashboard-nav.tsx | 394 ++++++++++++++++++ 2 files changed, 399 insertions(+), 385 deletions(-) create mode 100644 src/components/clinical-dashboard/dashboard-nav.tsx diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 20462aabfa..b7eeb3f3be 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -32,7 +32,6 @@ import { } from "@/components/DocumentOrganizationBadges"; import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; -import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; import { isLocalNoAuthMode } from "@/lib/env"; @@ -89,6 +88,7 @@ import { ScopeAndGovernanceNotice } from "@/components/clinical-dashboard/answer import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-panels"; import { RelatedDocumentsPanel, StagedAnswerResultSurface } from "@/components/clinical-dashboard/document-results"; import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; +import { buildMobileSectionFabState, MobileSectionFab, ToolsHub } from "@/components/clinical-dashboard/dashboard-nav"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { emptyStates, errorCopy } from "@/lib/ui-copy"; import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; @@ -108,7 +108,7 @@ const MedicationPrescribingWorkspace = dynamic( ), { ssr: false }, ); -const ApplicationsLauncherWorkspace = dynamic( +export const ApplicationsLauncherWorkspace = dynamic( () => import("@/components/applications-launcher-page").then((m) => m.ApplicationsLauncherWorkspace), { ssr: false }, ); @@ -180,8 +180,9 @@ import type { SearchScopeFilters } from "@/lib/search-scope"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { type AnswerViewMode, shouldPollForUpdates } from "@/lib/ward-output"; -const navigationHashes = ["#search", "#sources"] as const; -const mobileSectionFabMediaQuery = "(max-width: 768px), ((max-width: 1023px) and (hover: none) and (pointer: coarse))"; +export const navigationHashes = ["#search", "#sources"] as const; +export const mobileSectionFabMediaQuery = + "(max-width: 768px), ((max-width: 1023px) and (hover: none) and (pointer: coarse))"; export const recentQueryStorageKey = "clinical-kb-recent-queries"; const documentPageSize = 150; @@ -1452,387 +1453,6 @@ function DrawerGroupLabel({ title }: { title: string }) { ); } -function ToolsHub({ - query, - onQueryChange, - desktopComposerSlotId, - showDetailPanel, -}: { - query: string; - onQueryChange: (nextQuery: string) => void; - desktopComposerSlotId?: string; - showDetailPanel?: boolean; -}) { - return ( - - ); -} - -type MobileSectionFabItem = { - label: string; - description: string; - icon: typeof FileText; - href: (typeof navigationHashes)[number]; - count: number | null; - empty?: boolean; -}; - -type MobileSectionFabTone = "neutral" | "ready" | "warning" | "empty"; - -type MobileSectionFabState = { - statusLabel: string; - statusTone: MobileSectionFabTone; - nextStep: string; - badgeLabel: string | null; - badgeTone: MobileSectionFabTone; -}; - -function mobileSectionItemLabel(item: MobileSectionFabItem) { - if (item.count === null) return item.label; - return `${item.label}, ${item.count} item${item.count === 1 ? "" : "s"}`; -} - -function fabToneClassName(tone: MobileSectionFabTone) { - if (tone === "ready") { - return "border-[color:var(--success)]/25 bg-[color:var(--success-soft)] text-[color:var(--success)]"; - } - if (tone === "warning") { - return "border-[color:var(--warning)]/25 bg-[color:var(--warning-soft)] text-[color:var(--warning)]"; - } - if (tone === "empty") { - return "border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]"; - } - return "border-[color:var(--primary)]/20 bg-[color:var(--primary-soft)] text-[color:var(--primary-strong)]"; -} - -function buildMobileSectionFabState({ - hasAnswer, - searchMode, - sourceCount, - quoteCount, - weakEvidence, - governanceWarningCount, -}: { - hasAnswer: boolean; - searchMode: AppModeId; - sourceCount: number; - quoteCount: number; - weakEvidence: boolean; - governanceWarningCount: number; -}): MobileSectionFabState { - const modeSearch = appModeSearchConfig(searchMode); - if (!hasAnswer) { - if (modeSearch.resultKind === "tools") { - return { - statusLabel: "Tools", - statusTone: "neutral", - nextStep: "Launch a clinical tool", - badgeLabel: null, - badgeTone: "neutral", - }; - } - if (modeSearch.resultKind === "differentials") { - return { - statusLabel: "Diffs", - statusTone: "neutral", - nextStep: modeSearch.nextStep, - badgeLabel: null, - badgeTone: "neutral", - }; - } - return { - statusLabel: modeSearch.resultKind === "documents" ? modeSearch.statusLabel : "No answer yet", - statusTone: "empty", - nextStep: modeSearch.nextStep, - badgeLabel: modeSearch.badgeLabel, - badgeTone: "empty", - }; - } - - if (weakEvidence) { - return { - statusLabel: "Weak support", - statusTone: "warning", - nextStep: "Verify source before using", - badgeLabel: "!", - badgeTone: "warning", - }; - } - - if (governanceWarningCount > 0) { - return { - statusLabel: "Needs source check", - statusTone: "warning", - nextStep: `${governanceWarningCount} source warning${governanceWarningCount === 1 ? "" : "s"}`, - badgeLabel: "!", - badgeTone: "warning", - }; - } - - if (quoteCount > 0) { - return { - statusLabel: "Ready to verify", - statusTone: "ready", - nextStep: "Next: review exact quotes", - badgeLabel: String(quoteCount), - badgeTone: "ready", - }; - } - - if (sourceCount > 0) { - return { - statusLabel: "Ready to verify", - statusTone: "ready", - nextStep: "Next: verify sources", - badgeLabel: String(sourceCount), - badgeTone: "ready", - }; - } - - return { - statusLabel: "Answer ready", - statusTone: "neutral", - nextStep: "Review answer structure", - badgeLabel: null, - badgeTone: "neutral", - }; -} - -function MobileSectionFab({ - items, - activeHash, - state, - hidden = false, - onNavigate, -}: { - items: readonly MobileSectionFabItem[]; - activeHash: string; - state: MobileSectionFabState; - hidden?: boolean; - onNavigate: (href: MobileSectionFabItem["href"]) => void; -}) { - const [open, setOpen] = useState(false); - const [active, setActive] = useState(false); - const buttonRef = useRef(null); - const panelRef = useRef(null); - const panelId = "mobile-section-fab-menu"; - const labelId = "mobile-section-fab-label"; - const activeItem = items.find((item) => item.href === activeHash) ?? items[0]; - const ActiveIcon = activeItem.icon; - const activeItemLabel = mobileSectionItemLabel(activeItem); - - const closeMenu = useCallback((options: { restoreFocus?: boolean } = {}) => { - setOpen(false); - if (options.restoreFocus ?? true) { - window.requestAnimationFrame(() => buttonRef.current?.focus()); - } - }, []); - const dismissMobileSectionMenu = useCallback(() => closeMenu(), [closeMenu]); - - useDismissableLayer({ - enabled: open, - refs: [buttonRef, panelRef], - restoreFocusRef: buttonRef, - onDismiss: dismissMobileSectionMenu, - }); - - useEffect(() => { - const mediaQuery = window.matchMedia(mobileSectionFabMediaQuery); - const syncActivation = () => { - const matches = mediaQuery.matches; - setActive(matches); - if (!matches) closeMenu({ restoreFocus: false }); - }; - - const frame = window.requestAnimationFrame(syncActivation); - mediaQuery.addEventListener("change", syncActivation); - return () => { - window.cancelAnimationFrame(frame); - mediaQuery.removeEventListener("change", syncActivation); - }; - }, [closeMenu]); - - useEffect(() => { - if (!open) return; - const closeForRouteChange = () => closeMenu({ restoreFocus: false }); - window.addEventListener("hashchange", closeForRouteChange); - return () => window.removeEventListener("hashchange", closeForRouteChange); - }, [closeMenu, open]); - - useEffect(() => { - if (!hidden) return; - const frame = window.requestAnimationFrame(() => closeMenu({ restoreFocus: false })); - return () => window.cancelAnimationFrame(frame); - }, [closeMenu, hidden]); - - if (hidden || !active) return null; - - return ( -
- {open ? ( - - ); -} - function answerReferencesDocument(answer: RagAnswer | null, documentId: string) { if (!answer) return false; return ( diff --git a/src/components/clinical-dashboard/dashboard-nav.tsx b/src/components/clinical-dashboard/dashboard-nav.tsx new file mode 100644 index 0000000000..bf6621cb6e --- /dev/null +++ b/src/components/clinical-dashboard/dashboard-nav.tsx @@ -0,0 +1,394 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { FileText, X } from "lucide-react"; + +import { + ApplicationsLauncherWorkspace, + mobileSectionFabMediaQuery, + navigationHashes, +} from "@/components/ClinicalDashboard"; +import { cn } from "@/components/ui-primitives"; +import { useDismissableLayer } from "@/components/use-dismissable-layer"; +import { type AppModeId, appModeSearchConfig } from "@/lib/app-modes"; + +export function ToolsHub({ + query, + onQueryChange, + desktopComposerSlotId, + showDetailPanel, +}: { + query: string; + onQueryChange: (nextQuery: string) => void; + desktopComposerSlotId?: string; + showDetailPanel?: boolean; +}) { + return ( + + ); +} + +type MobileSectionFabItem = { + label: string; + description: string; + icon: typeof FileText; + href: (typeof navigationHashes)[number]; + count: number | null; + empty?: boolean; +}; + +type MobileSectionFabTone = "neutral" | "ready" | "warning" | "empty"; + +type MobileSectionFabState = { + statusLabel: string; + statusTone: MobileSectionFabTone; + nextStep: string; + badgeLabel: string | null; + badgeTone: MobileSectionFabTone; +}; + +function mobileSectionItemLabel(item: MobileSectionFabItem) { + if (item.count === null) return item.label; + return `${item.label}, ${item.count} item${item.count === 1 ? "" : "s"}`; +} + +function fabToneClassName(tone: MobileSectionFabTone) { + if (tone === "ready") { + return "border-[color:var(--success)]/25 bg-[color:var(--success-soft)] text-[color:var(--success)]"; + } + if (tone === "warning") { + return "border-[color:var(--warning)]/25 bg-[color:var(--warning-soft)] text-[color:var(--warning)]"; + } + if (tone === "empty") { + return "border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]"; + } + return "border-[color:var(--primary)]/20 bg-[color:var(--primary-soft)] text-[color:var(--primary-strong)]"; +} + +export function buildMobileSectionFabState({ + hasAnswer, + searchMode, + sourceCount, + quoteCount, + weakEvidence, + governanceWarningCount, +}: { + hasAnswer: boolean; + searchMode: AppModeId; + sourceCount: number; + quoteCount: number; + weakEvidence: boolean; + governanceWarningCount: number; +}): MobileSectionFabState { + const modeSearch = appModeSearchConfig(searchMode); + if (!hasAnswer) { + if (modeSearch.resultKind === "tools") { + return { + statusLabel: "Tools", + statusTone: "neutral", + nextStep: "Launch a clinical tool", + badgeLabel: null, + badgeTone: "neutral", + }; + } + if (modeSearch.resultKind === "differentials") { + return { + statusLabel: "Diffs", + statusTone: "neutral", + nextStep: modeSearch.nextStep, + badgeLabel: null, + badgeTone: "neutral", + }; + } + return { + statusLabel: modeSearch.resultKind === "documents" ? modeSearch.statusLabel : "No answer yet", + statusTone: "empty", + nextStep: modeSearch.nextStep, + badgeLabel: modeSearch.badgeLabel, + badgeTone: "empty", + }; + } + + if (weakEvidence) { + return { + statusLabel: "Weak support", + statusTone: "warning", + nextStep: "Verify source before using", + badgeLabel: "!", + badgeTone: "warning", + }; + } + + if (governanceWarningCount > 0) { + return { + statusLabel: "Needs source check", + statusTone: "warning", + nextStep: `${governanceWarningCount} source warning${governanceWarningCount === 1 ? "" : "s"}`, + badgeLabel: "!", + badgeTone: "warning", + }; + } + + if (quoteCount > 0) { + return { + statusLabel: "Ready to verify", + statusTone: "ready", + nextStep: "Next: review exact quotes", + badgeLabel: String(quoteCount), + badgeTone: "ready", + }; + } + + if (sourceCount > 0) { + return { + statusLabel: "Ready to verify", + statusTone: "ready", + nextStep: "Next: verify sources", + badgeLabel: String(sourceCount), + badgeTone: "ready", + }; + } + + return { + statusLabel: "Answer ready", + statusTone: "neutral", + nextStep: "Review answer structure", + badgeLabel: null, + badgeTone: "neutral", + }; +} + +export function MobileSectionFab({ + items, + activeHash, + state, + hidden = false, + onNavigate, +}: { + items: readonly MobileSectionFabItem[]; + activeHash: string; + state: MobileSectionFabState; + hidden?: boolean; + onNavigate: (href: MobileSectionFabItem["href"]) => void; +}) { + const [open, setOpen] = useState(false); + const [active, setActive] = useState(false); + const buttonRef = useRef(null); + const panelRef = useRef(null); + const panelId = "mobile-section-fab-menu"; + const labelId = "mobile-section-fab-label"; + const activeItem = items.find((item) => item.href === activeHash) ?? items[0]; + const ActiveIcon = activeItem.icon; + const activeItemLabel = mobileSectionItemLabel(activeItem); + + const closeMenu = useCallback((options: { restoreFocus?: boolean } = {}) => { + setOpen(false); + if (options.restoreFocus ?? true) { + window.requestAnimationFrame(() => buttonRef.current?.focus()); + } + }, []); + const dismissMobileSectionMenu = useCallback(() => closeMenu(), [closeMenu]); + + useDismissableLayer({ + enabled: open, + refs: [buttonRef, panelRef], + restoreFocusRef: buttonRef, + onDismiss: dismissMobileSectionMenu, + }); + + useEffect(() => { + const mediaQuery = window.matchMedia(mobileSectionFabMediaQuery); + const syncActivation = () => { + const matches = mediaQuery.matches; + setActive(matches); + if (!matches) closeMenu({ restoreFocus: false }); + }; + + const frame = window.requestAnimationFrame(syncActivation); + mediaQuery.addEventListener("change", syncActivation); + return () => { + window.cancelAnimationFrame(frame); + mediaQuery.removeEventListener("change", syncActivation); + }; + }, [closeMenu]); + + useEffect(() => { + if (!open) return; + const closeForRouteChange = () => closeMenu({ restoreFocus: false }); + window.addEventListener("hashchange", closeForRouteChange); + return () => window.removeEventListener("hashchange", closeForRouteChange); + }, [closeMenu, open]); + + useEffect(() => { + if (!hidden) return; + const frame = window.requestAnimationFrame(() => closeMenu({ restoreFocus: false })); + return () => window.cancelAnimationFrame(frame); + }, [closeMenu, hidden]); + + if (hidden || !active) return null; + + return ( +
+ {open ? ( + + ); +}