diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 67758856f..6783d5184 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -40,12 +40,11 @@ import { toneInfo, } from "@/components/ui-primitives"; import { useAuthSession } from "@/lib/supabase/client"; -import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { CrossModeLinksSection } from "@/components/clinical-dashboard/cross-mode-links"; import { useEventCallback } from "@/components/clinical-dashboard/use-event-callback"; +import { useHasEverBeenTrue } from "@/components/clinical-dashboard/use-has-ever-been-true"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { buildMobileSectionFabState, MobileSectionFab, ToolsHub } from "@/components/clinical-dashboard/dashboard-nav"; -import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; import { @@ -54,18 +53,18 @@ import { ClinicalMobileSidebar, } from "@/components/clinical-dashboard/ClinicalSidebar"; import { - SetupChecklist, - UploadPanel, - IndexingMonitor, - IngestionQualityConsole, - LibraryHealthStrip, fallbackSetupChecks, hasReadyRequiredPublicSearchConfig, hasReadyPublicSearchSetup, type SetupCheck, type IngestionQualityReviewItem, -} from "@/components/clinical-dashboard/DocumentManagerPanel"; -import { GuideDialog, GuideTrigger, UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell"; +} from "@/components/clinical-dashboard/document-manager-model"; +import { + DrawerGroupLabel, + GuideDialog, + GuideTrigger, + UtilityDrawer, +} from "@/components/clinical-dashboard/dashboard-shell"; import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/clinical-dashboard/display-text"; import { NaturalLanguageAnswer, @@ -78,12 +77,17 @@ import { MasterSearchHeader } from "@/components/clinical-dashboard/master-searc import { useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { answerRecovery, errorCopy } from "@/lib/ui-copy"; -import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; -import { - DrawerGroupLabel, - type DocumentDrawerMode, - type DocumentDrawerStatusFilter, - type LabelReviewMutationBody, +// The tools count comes from the shared data-only catalog, not the launcher page +// component: a static value import from applications-launcher-page would pull the +// whole 800-line module (icons and all) into this chunk and defeat the dynamic() +// split below. +import { toolCatalogRecords } from "@/lib/tools-catalog"; +// Type-only: erased at compile time, so this does not pull the admin drawer +// module into the eager bundle (its components load via dynamic() above). +import type { + DocumentDrawerMode, + DocumentDrawerStatusFilter, + LabelReviewMutationBody, } from "@/components/clinical-dashboard/document-admin"; const DifferentialsHome = dynamic( @@ -109,6 +113,42 @@ const DocumentDrawer = dynamic( () => import("@/components/clinical-dashboard/document-admin").then((m) => m.DocumentDrawer), { ssr: false }, ); +// The document-manager admin surfaces render only inside drawers that start +// closed, so their chunk downloads on first drawer open instead of shipping +// with the dashboard. Data-only helpers stay in document-manager-model.ts. +const SetupChecklist = dynamic( + () => import("@/components/clinical-dashboard/DocumentManagerPanel").then((m) => m.SetupChecklist), + { ssr: false }, +); +const UploadPanel = dynamic( + () => import("@/components/clinical-dashboard/DocumentManagerPanel").then((m) => m.UploadPanel), + { ssr: false }, +); +const IndexingMonitor = dynamic( + () => import("@/components/clinical-dashboard/DocumentManagerPanel").then((m) => m.IndexingMonitor), + { ssr: false }, +); +const IngestionQualityConsole = dynamic( + () => import("@/components/clinical-dashboard/DocumentManagerPanel").then((m) => m.IngestionQualityConsole), + { ssr: false }, +); +const LibraryHealthStrip = dynamic( + () => import("@/components/clinical-dashboard/DocumentManagerPanel").then((m) => m.LibraryHealthStrip), + { ssr: false }, +); +// Settings/account dialogs live in their own modules and render nothing until +// opened (Sheet returns null when closed), so they mount lazily on first open — +// gated with useHasEverBeenTrue so they stay mounted afterwards. GuideDialog +// stays a static import: it shares dashboard-shell.tsx with the eagerly-used +// UtilityDrawer/GuideTrigger, so splitting it would not remove any code. +const SettingsDialog = dynamic( + () => import("@/components/clinical-dashboard/settings-dialog").then((m) => m.SettingsDialog), + { ssr: false }, +); +const AccountSetupDialog = dynamic( + () => import("@/components/clinical-dashboard/account-setup-dialog").then((m) => m.AccountSetupDialog), + { ssr: false }, +); // Results surfaces load lazily: they only render after a submitted search/answer, so their chunk // downloads behind the (multi-second) retrieval/answer request rather than bloating the initial @@ -875,6 +915,8 @@ export function ClinicalDashboard({ const [guideOpen, setGuideOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [accountSetupOpen, setAccountSetupOpen] = useState(false); + const settingsDialogMounted = useHasEverBeenTrue(settingsOpen); + const accountSetupDialogMounted = useHasEverBeenTrue(accountSetupOpen); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false); @@ -2830,7 +2872,7 @@ export function ClinicalDashboard({ href: "#search", count: activeModeResultKind === "tools" - ? applicationsLauncherItemCount + ? toolCatalogRecords.length : activeModeResultKind === "favourites" ? null : activeModeResultKind === "documents" @@ -3762,16 +3804,18 @@ export function ClinicalDashboard({ onNavigate={navigateMobileSection} /> - - + {settingsDialogMounted ? ( + + ) : null} + {accountSetupDialogMounted ? : null} app.id === id) ?? launcherApps[0]; } diff --git a/src/components/clinical-dashboard-client.tsx b/src/components/clinical-dashboard-client.tsx deleted file mode 100644 index d9a3bee82..000000000 --- a/src/components/clinical-dashboard-client.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import type { AppModeId } from "@/lib/app-modes"; - -const ClinicalDashboard = dynamic(() => import("@/components/clinical-dashboard").then((m) => m.ClinicalDashboard), { - ssr: false, -}); - -type ClinicalDashboardClientProps = { - initialSearchMode?: AppModeId; - initialQuery?: string; - focusSearch?: boolean; - autoRunSearch?: boolean; -}; - -export function ClinicalDashboardClient(props: ClinicalDashboardClientProps) { - return ; -} diff --git a/src/components/clinical-dashboard-lazy.tsx b/src/components/clinical-dashboard-lazy.tsx deleted file mode 100644 index 70bd7aafc..000000000 --- a/src/components/clinical-dashboard-lazy.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; - -// `ssr: false` requires a Client Component in the App Router; this wrapper -// keeps the heavy dashboard bundle browser-only for the server-rendered -// home page. -export const ClinicalDashboardLazy = dynamic( - () => import("@/components/clinical-dashboard").then((m) => m.ClinicalDashboard), - { ssr: false }, -); diff --git a/src/components/clinical-dashboard/ClinicalDashboard.tsx b/src/components/clinical-dashboard/ClinicalDashboard.tsx deleted file mode 100644 index 4973c2930..000000000 --- a/src/components/clinical-dashboard/ClinicalDashboard.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ClinicalDashboard } from "@/components/ClinicalDashboard"; diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index 74ddae38d..57a810b94 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -21,96 +21,36 @@ import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text" import { emptyStates, errorCopy } from "@/lib/ui-copy"; import { StatusBadge } from "@/components/clinical-dashboard/badges"; import type { ClinicalDocument, IngestionJob, ImportBatch } from "@/lib/types"; - -// Setup and quality types -export type SetupCheckStatus = "ready" | "needs_setup" | "unknown"; -export type SetupCheck = { - id: "env" | "project" | "schema" | "search" | "openai" | "worker"; - label: string; - status: SetupCheckStatus; - detail: string; +import { + fallbackSetupChecks, + hasReadyPublicSearchSetup, + hasReadyRequiredPublicSearchConfig, + type IndexingMonitorFilter, + type IngestionQualityReviewItem, + type IngestionQualityReviewType, + type LibraryHealthTarget, + type SetupCheck, + type SetupCheckStatus, +} from "@/components/clinical-dashboard/document-manager-model"; + +// Setup/quality types and helpers live in document-manager-model.ts (data-only) +// so consumers that only need them never pull this component module; re-exported +// here to keep the panel the canonical import for panel users. +export { + fallbackSetupChecks, + hasReadyPublicSearchSetup, + hasReadyRequiredPublicSearchConfig, + type IndexingMonitorFilter, + type IngestionQualityReviewItem, + type IngestionQualityReviewType, + type LibraryHealthTarget, + type SetupCheck, + type SetupCheckStatus, }; const demoUploadReadOnlyMessage = "Demo mode is read-only. Configure Supabase, OpenAI, and the local worker before uploading private guideline files."; -export type LibraryHealthTarget = "documents" | "setup" | "indexing" | "failures"; -export type IndexingMonitorFilter = "all" | "active" | "failed"; - -export type IngestionQualityReviewType = - "failed_ocr" | "low_extraction_confidence" | "missing_tables" | "image_only_pages" | "failed_job" | "manual_review"; - -export type IngestionQualityReviewItem = { - id: string; - type: IngestionQualityReviewType; - severity: "danger" | "warning" | "info"; - title: string; - detail: string; - documentId: string; - documentTitle: string; - fileName: string; - jobId: string | null; - qualityScore: number | null; - extractionQuality: string | null; - reasons: string[]; - metrics: Record; - updatedAt: string | null; -}; - -export const fallbackSetupChecks: SetupCheck[] = [ - { - id: "env", - label: ".env.local configured", - status: "unknown", - detail: "Setup status has not loaded yet.", - }, - { - id: "project", - label: "Clinical KB Database target", - status: "unknown", - detail: "Setup status has not loaded yet.", - }, - { - id: "schema", - label: "supabase/schema.sql applied", - status: "unknown", - detail: "Setup status has not loaded yet.", - }, - { - id: "search", - label: "Search RPC and vector indexes", - status: "unknown", - detail: "Setup status has not loaded yet.", - }, - { - id: "openai", - label: "OpenAI API key available", - status: "unknown", - detail: "Setup status has not loaded yet.", - }, - { - id: "worker", - label: "npm run worker running", - status: "unknown", - detail: "Setup status has not loaded yet.", - }, -]; - -const publicSearchSetupCheckIds = new Set(["env", "project", "schema", "search", "openai"]); -const requiredPublicSearchConfigCheckIds = new Set(["env", "project", "schema", "openai"]); - -export function hasReadyPublicSearchSetup(checks: SetupCheck[]) { - return Array.from(publicSearchSetupCheckIds).every( - (id) => checks.find((check) => check.id === id)?.status === "ready", - ); -} - -export function hasReadyRequiredPublicSearchConfig(checks: SetupCheck[]) { - return Array.from(requiredPublicSearchConfigCheckIds).every( - (id) => checks.find((check) => check.id === id)?.status === "ready", - ); -} - function setupBadgeClasses(status: SetupCheckStatus) { if (status === "ready") { return toneSuccess; diff --git a/src/components/clinical-dashboard/dashboard-shell.tsx b/src/components/clinical-dashboard/dashboard-shell.tsx index 8842291d3..54d95e9d2 100644 --- a/src/components/clinical-dashboard/dashboard-shell.tsx +++ b/src/components/clinical-dashboard/dashboard-shell.tsx @@ -257,6 +257,15 @@ const guideSections = [ }, ] as const; +// Drawer-chrome primitive kept here (already in the eager shell chunk) rather +// than in document-admin: a value import from that module would pull the whole +// admin drawer into the dashboard bundle and defeat its dynamic() split. +export function DrawerGroupLabel({ title }: { title: string }) { + return ( +

{title}

+ ); +} + export function GuideDialog({ open, onClose }: { open: boolean; onClose: () => void }) { return ( {title}

- ); -} diff --git a/src/components/clinical-dashboard/document-manager-model.ts b/src/components/clinical-dashboard/document-manager-model.ts new file mode 100644 index 000000000..1050ec077 --- /dev/null +++ b/src/components/clinical-dashboard/document-manager-model.ts @@ -0,0 +1,89 @@ +// Data-only setup/quality model shared by ClinicalDashboard and the +// DocumentManagerPanel components. Kept free of React/JSX/icon imports so the +// dashboard can consume these types and helpers without statically pulling the +// heavy panel module, which loads via next/dynamic when an admin drawer opens. + +export type SetupCheckStatus = "ready" | "needs_setup" | "unknown"; +export type SetupCheck = { + id: "env" | "project" | "schema" | "search" | "openai" | "worker"; + label: string; + status: SetupCheckStatus; + detail: string; +}; + +export type LibraryHealthTarget = "documents" | "setup" | "indexing" | "failures"; +export type IndexingMonitorFilter = "all" | "active" | "failed"; + +export type IngestionQualityReviewType = + "failed_ocr" | "low_extraction_confidence" | "missing_tables" | "image_only_pages" | "failed_job" | "manual_review"; + +export type IngestionQualityReviewItem = { + id: string; + type: IngestionQualityReviewType; + severity: "danger" | "warning" | "info"; + title: string; + detail: string; + documentId: string; + documentTitle: string; + fileName: string; + jobId: string | null; + qualityScore: number | null; + extractionQuality: string | null; + reasons: string[]; + metrics: Record; + updatedAt: string | null; +}; + +export const fallbackSetupChecks: SetupCheck[] = [ + { + id: "env", + label: ".env.local configured", + status: "unknown", + detail: "Setup status has not loaded yet.", + }, + { + id: "project", + label: "Clinical KB Database target", + status: "unknown", + detail: "Setup status has not loaded yet.", + }, + { + id: "schema", + label: "supabase/schema.sql applied", + status: "unknown", + detail: "Setup status has not loaded yet.", + }, + { + id: "search", + label: "Search RPC and vector indexes", + status: "unknown", + detail: "Setup status has not loaded yet.", + }, + { + id: "openai", + label: "OpenAI API key available", + status: "unknown", + detail: "Setup status has not loaded yet.", + }, + { + id: "worker", + label: "npm run worker running", + status: "unknown", + detail: "Setup status has not loaded yet.", + }, +]; + +const publicSearchSetupCheckIds = new Set(["env", "project", "schema", "search", "openai"]); +const requiredPublicSearchConfigCheckIds = new Set(["env", "project", "schema", "openai"]); + +export function hasReadyPublicSearchSetup(checks: SetupCheck[]) { + return Array.from(publicSearchSetupCheckIds).every( + (id) => checks.find((check) => check.id === id)?.status === "ready", + ); +} + +export function hasReadyRequiredPublicSearchConfig(checks: SetupCheck[]) { + return Array.from(requiredPublicSearchConfigCheckIds).every( + (id) => checks.find((check) => check.id === id)?.status === "ready", + ); +} diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index 7b4f5c5fb..9f1f237d7 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -12,11 +12,14 @@ import { useState, } from "react"; -import { ClinicalDashboard } from "@/components/clinical-dashboard"; -import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; -import { recentQueryStorageKey } from "@/components/ClinicalDashboard"; +import dynamic from "next/dynamic"; + +// Import the dashboard module directly: the clinical-dashboard barrel re-exported +// heavy result surfaces whose value exports were pulled eagerly through it, +// defeating their dynamic() splits. +import { ClinicalDashboard, recentQueryStorageKey } from "@/components/ClinicalDashboard"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; -import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; +import { useHasEverBeenTrue } from "@/components/clinical-dashboard/use-has-ever-been-true"; import { ClinicalDesktopSidebar, ClinicalMobileSidebar, @@ -43,6 +46,17 @@ import type { SearchScopeFilters } from "@/lib/search-scope"; import { useAuthSession } from "@/lib/supabase/client"; import type { ClinicalQueryMode } from "@/lib/types"; +// Lazy dialogs, mounted on first open (see ClinicalDashboard for the same +// pattern): their chunks never download for users who never open them. +const SettingsDialog = dynamic( + () => import("@/components/clinical-dashboard/settings-dialog").then((m) => m.SettingsDialog), + { ssr: false }, +); +const AccountSetupDialog = dynamic( + () => import("@/components/clinical-dashboard/account-setup-dialog").then((m) => m.AccountSetupDialog), + { ssr: false }, +); + const mockupQueryModeOptions: Array<{ value: ClinicalQueryMode; label: string }> = [ { value: "auto", label: "Auto" }, { value: "monitoring_schedule", label: "Monitoring" }, @@ -148,6 +162,8 @@ function GlobalMockupSearchShellClient({ const [guideOpen, setGuideOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [accountSetupOpen, setAccountSetupOpen] = useState(false); + const settingsDialogMounted = useHasEverBeenTrue(settingsOpen); + const accountSetupDialogMounted = useHasEverBeenTrue(accountSetupOpen); const [recentQueries, setRecentQueries] = useState([]); const [commandScopes, setCommandScopes] = useState([]); const [bottomSearchScrollHidden, setBottomSearchScrollHidden] = useState(false); @@ -507,16 +523,20 @@ function GlobalMockupSearchShellClient({ setGuideOpen(false)} /> - setSettingsOpen(false)} - identity={sidebarIdentity} - theme={theme} - onToggleTheme={toggleTheme} - onSignOut={auth.signOut} - onOpenGuide={openGuide} - /> - setAccountSetupOpen(false)} /> + {settingsDialogMounted ? ( + setSettingsOpen(false)} + identity={sidebarIdentity} + theme={theme} + onToggleTheme={toggleTheme} + onSignOut={auth.signOut} + onOpenGuide={openGuide} + /> + ) : null} + {accountSetupDialogMounted ? ( + setAccountSetupOpen(false)} /> + ) : null}