From 57ed7376356c94bd5c3be27f6685bb457207894f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:15:44 +0800 Subject: [PATCH 01/61] fix(ci): clear post-merge annotations --- .github/workflows/ci.yml | 16 +- .github/workflows/secret-scan.yml | 2 +- src/components/ClinicalDashboard.tsx | 244 +-------- .../clinical-dashboard/answer-content.tsx | 39 +- .../clinical-dashboard/evidence-panels.tsx | 515 +----------------- 5 files changed, 18 insertions(+), 798 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef0db729b..2a987d0ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,12 +30,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version-file: ".nvmrc" cache: npm @@ -93,12 +93,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version-file: ".nvmrc" cache: npm @@ -108,7 +108,7 @@ jobs: run: npm ci - name: Restore Chromium browser cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }} @@ -138,12 +138,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version-file: ".nvmrc" cache: npm @@ -156,7 +156,7 @@ jobs: run: npm run build - name: Restore browser cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index 8d608f2a1..8148f80f2 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -24,7 +24,7 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 persist-credentials: false diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 5850b63be..8643c16db 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -62,9 +62,7 @@ import { answerSurface, chatMicroAction, clinicalDivider, - clinicalNotesRow, cn, - evidenceRow, EmptyState, fieldControlPlain, fieldControlWithIcon, @@ -95,7 +93,7 @@ import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboar 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, StrengthBadge } from "@/components/clinical-dashboard/badges"; +import { StatusBadge } from "@/components/clinical-dashboard/badges"; import { type SidebarIdentity, deriveSidebarIdentity, @@ -149,7 +147,6 @@ import { primaryVisualTable, QuoteCards, SafetyFindingsPanel, - VerificationWorkspace, } from "@/components/clinical-dashboard/evidence-panels"; import { useMobilePreviewSheet } from "@/components/clinical-dashboard/use-mobile-preview-sheet"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; @@ -175,12 +172,8 @@ const ApplicationsLauncherWorkspace = dynamic( () => import("@/components/applications-launcher-page").then((m) => m.ApplicationsLauncherWorkspace), { ssr: false }, ); -import { - DocumentSearchResultsPanel, - MatchExplanationChips, - type SearchFacets, -} from "@/components/clinical-dashboard/document-search-results"; -import { isWeakRelevance, QueryCoverageChips, RelevanceBadge } from "@/components/clinical-dashboard/relevance"; +import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results"; +import { isWeakRelevance, QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; import { answerPayloadIsUsable, isRetryableError, @@ -240,7 +233,6 @@ import type { QuoteCard, RagAnswer, AnswerSection, - ConflictOrGap, RelatedDocument, EvidenceSummary, SearchResult, @@ -252,14 +244,7 @@ import type { } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; -import { - type AnswerEvidenceMapRow, - type AnswerViewMode, - buildAnswerEvidenceMap, - buildClinicalOutputSections, - buildHighYieldClinicalOutputSections, - shouldPollForUpdates, -} from "@/lib/ward-output"; +import { type AnswerEvidenceMapRow, type AnswerViewMode, shouldPollForUpdates } from "@/lib/ward-output"; const navigationHashes = ["#search", "#quotes", "#images", "#sources"] as const; const mobileSectionFabMediaQuery = "(max-width: 768px), ((max-width: 1023px) and (hover: none) and (pointer: coarse))"; @@ -478,72 +463,6 @@ function normalizeNavigationHash(hash: string) { return navigationHashes.includes(hash as (typeof navigationHashes)[number]) ? hash : "#search"; } -function WhyThisMatchedPanel({ sources }: { sources: SearchResult[] }) { - const visibleSources = sources.slice(0, 3); - if (visibleSources.length === 0) return null; - - return ( -
- - - - - - - Why this matched - - Match signals, source strength, and term coverage for top passages - - - - - -
- {visibleSources.map((source) => ( -
-
-
-

- {cleanDisplayTitle(source.title)} -

-

- page {source.page_number ?? "n/a"} ·{" "} - chunk {source.chunk_index} -

-
-
- - - -
-
- - {source.index_unit ? ( -

- - {source.index_unit.unit_type.replaceAll("_", " ")}: - {" "} - {source.index_unit.title} -

- ) : null} -
- -
-
- ))} -
-
- ); -} - function compactClinicalTableCaption(item: VisualEvidenceCard) { const raw = item.tableTitle || item.tableLabel || item.caption || "Clinical table"; const cleaned = sourceTextForCompactDisplay(raw) @@ -886,7 +805,6 @@ function MobileEvidenceSheetContent({ answer, sources, renderModel, - query, visualEvidence, answerEvidenceMapRows, sourceGovernanceWarnings, @@ -902,7 +820,6 @@ function MobileEvidenceSheetContent({ answer: RagAnswer; sources: SearchResult[]; renderModel: AnswerRenderModel; - query: string; visualEvidence: VisualEvidenceCard[]; answerEvidenceMapRows: AnswerEvidenceMapRow[]; sourceGovernanceWarnings: SourceGovernanceWarning[]; @@ -995,7 +912,6 @@ function MobileEvidenceSheetContent({ ; } -function UnifiedEvidenceDrawerContent({ - answer, - renderModel, - query, - visualEvidence, - answerEvidenceMapRows, - pendingFeedback, - copiedQuotes, - onCopyQuotes, - onSubmitFeedback, - onFollowUpQuote, - onScopeDocument, -}: { - answer: RagAnswer; - renderModel: AnswerRenderModel; - query: string; - visualEvidence: VisualEvidenceCard[]; - answerEvidenceMapRows: AnswerEvidenceMapRow[]; - pendingFeedback: AnswerFeedbackType | null; - copiedQuotes: boolean; - onCopyQuotes: () => void; - onSubmitFeedback: (feedbackType: AnswerFeedbackType) => void; - onFollowUpQuote?: (quote: QuoteCard) => void; - onScopeDocument: (documentId: string) => void; -}) { - const order = evidenceTabOrder(answer, renderModel); - - return ( -
- - -
- {order.map((item) => ( - - {item} - - ))} -
- - {order.map((section) => { - if (section === "Claims") { - return ( -
-

Claims

- -
- ); - } - - if (section === "Tables") { - return ( -
-

Tables

- {visualEvidence.some((item) => item.accessibleTableMarkdown || item.tableRows?.length) ? ( -
- {visualEvidence - .filter((item) => item.accessibleTableMarkdown || item.tableRows?.length) - .slice(0, 3) - .map((item) => ( -
-
-

- {compactClinicalTableCaption(item)} -

- p.{item.page_number ?? "n/a"} -
-
- - Expand - - - Source - -
-
- ))} -
- ) : ( - - )} -
- ); - } - - if (section === "Images") { - return ( -
-

Images

- - - -
- ); - } - - if (section === "Quotes") { - return ( -
- -
- ); - } - - return ( -
-

Gaps

- -
- ); - })} -
- ); -} - function RelatedDocumentsPanel({ documents, onScopeDocument, @@ -1322,20 +1104,15 @@ function StagedAnswerResultSurface({ query, safeAnswerText, bestSource, - currentRelevance, - queryMode, sourceGovernanceWarnings, sourceSummary, renderModel, weakEvidence, - groupedGovernanceWarningCount, answerViewMode, answerEvidenceMapRows, onScopeDocument, answerGrounded, sources, - gaps, - searchScope, demoMode, safeAnswerSections, safetyFindings, @@ -1348,20 +1125,15 @@ function StagedAnswerResultSurface({ query: string; safeAnswerText: string; bestSource: BestSourceRecommendation | null; - currentRelevance: EvidenceRelevance | null | undefined; - queryMode: ClinicalQueryMode; sourceGovernanceWarnings: SourceGovernanceWarning[]; sourceSummary?: EvidenceSummary; renderModel: AnswerRenderModel; weakEvidence: boolean; - groupedGovernanceWarningCount: number; answerViewMode: AnswerViewMode; answerEvidenceMapRows: AnswerEvidenceMapRow[]; onScopeDocument: (documentId: string) => void; answerGrounded: boolean; sources: SearchResult[]; - gaps: ConflictOrGap[]; - searchScope: SearchScopeSummary | null; demoMode: boolean; safeAnswerSections: Array; safetyFindings: ReturnType; @@ -1568,7 +1340,6 @@ function StagedAnswerResultSurface({ answer={answer} sources={sources} renderModel={renderModel} - query={query} visualEvidence={renderModel.visualEvidence} answerEvidenceMapRows={answerEvidenceMapRows} sourceGovernanceWarnings={sourceGovernanceWarnings} @@ -1664,7 +1435,6 @@ function StagedAnswerResultSurface({ answer={answer} sources={sources} renderModel={renderModel} - query={query} visualEvidence={renderModel.visualEvidence} answerEvidenceMapRows={answerEvidenceMapRows} sourceGovernanceWarnings={sourceGovernanceWarnings} @@ -5161,7 +4931,6 @@ export function ClinicalDashboard({ const safetyFindings = useMemo(() => extractSafetyFindings(answer), [answer]); const bestSource = answerRenderModel?.bestSource ?? null; const sourceSummary = answer?.evidenceSummary ?? answer?.smartPanel?.evidenceSummary; - const gaps = answer?.conflictsOrGaps ?? answer?.smartPanel?.conflictsOrGaps ?? []; const answerGrounded = answer?.grounded === true && answer.confidence !== "unsupported" && @@ -5685,20 +5454,15 @@ export function ClinicalDashboard({ query={query} safeAnswerText={safeAnswerText} bestSource={bestSource} - currentRelevance={currentRelevance} - queryMode={queryMode} sourceGovernanceWarnings={sourceGovernanceWarnings} sourceSummary={sourceSummary} renderModel={answerRenderModel} weakEvidence={weakEvidence} - groupedGovernanceWarningCount={groupedGovernanceWarningCount} answerViewMode={answerViewMode} answerEvidenceMapRows={answerEvidenceMapRows} onScopeDocument={scopeOnlyDocument} answerGrounded={answerGrounded} sources={answerRenderModel.reviewSources} - gaps={gaps} - searchScope={searchScope} demoMode={demoMode} safeAnswerSections={safeAnswerSections} safetyFindings={safetyFindings} diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 9140030b2..1af3a5306 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -751,47 +751,12 @@ export function keyClinicalItemsFromTable(item: VisualEvidenceCard | null): KeyC .map((row): KeyClinicalItem | null => { const [domain, baseline] = row.map((cell) => cell.trim()).filter(Boolean); if (!domain || !baseline) return null; - const detail = baseline; return { - id: comparableAnswerText([domain, detail].join(" ")), + id: comparableAnswerText([domain, baseline].join(" ")), label: domain, - detail, + detail: baseline, }; }) .filter((value): value is KeyClinicalItem => value !== null) .slice(0, 5); } - -function KeyClinicalItems({ - sections, - table, -}: { - sections: Array; - table: VisualEvidenceCard | null; -}) { - const sectionItems = keyClinicalItemsFromSections(sections); - const tableItems = keyClinicalItemsFromTable(table); - const items = sectionItems.length >= 2 ? sectionItems : tableItems; - if (items.length < 2) return null; - - return ( -
-

Key monitoring items

-
    - {items.map((item) => ( -
  • - {item.label ? ( - <> - {item.label} - - - - ) : ( - - )} -
  • - ))} -
-
- ); -} diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index 3f82128ac..1a37db4d6 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -22,13 +22,12 @@ import { Search, ShieldAlert, ShieldCheck, - SlidersHorizontal, Table2, Target, } from "lucide-react"; import { AccessibleTable } from "@/components/AccessibleTable"; -import { clinicalQueryModeOptions, type AnswerFeedbackType } from "@/components/ClinicalDashboard"; +import { type AnswerFeedbackType } from "@/components/ClinicalDashboard"; import { ClinicalOutputPanel } from "@/components/clinical-dashboard/output-panel"; import { keyClinicalItemsFromSections, @@ -44,13 +43,7 @@ import { compactSourceSnippet, comparableAnswerText, } from "@/components/clinical-dashboard/display-text"; -import { - hasStrongRelevanceIcon, - QueryCoverageChips, - relevanceChipLabel, - RelevanceBadge, -} from "@/components/clinical-dashboard/relevance"; -import { SourceActionRow, sourceResultHref } from "@/components/clinical-dashboard/source-actions"; +import { SourceActionRow } from "@/components/clinical-dashboard/source-actions"; import { chatMicroAction, clinicalDivider, @@ -58,16 +51,12 @@ import { codeText, EmptyState, evidenceSurface, - floatingControl, iconTilePremium, metadataPill, panelSubtle, - primaryControl, proseMeasure, raisedCard, sourceCard, - SourceProvenance, - SourceStatusBadge, subtleStatusPill, tableMicroActionRow, textMuted, @@ -80,8 +69,7 @@ import { import { type AnswerRenderModel, type SourceLink } from "@/lib/answer-render-policy"; import { documentCitationHref, formatCitationLabel, formatCompactCitationLabel } from "@/lib/citations"; import { extractSafetyFindings, formatSafetyFindingLabel } from "@/lib/clinical-safety"; -import { frontendSourceGovernanceWarnings, type SourceGovernanceWarning } from "@/lib/source-governance"; -import { normalizeSourceMetadata, sourceStatusLabel, validationStatusLabel } from "@/lib/source-metadata"; +import { normalizeSourceMetadata, sourceStatusLabel } from "@/lib/source-metadata"; import { normalizeExtractedGlyphs, sourceTextForCompactDisplay, @@ -90,9 +78,6 @@ import { import type { AnswerSection, BestSourceRecommendation, - ClinicalQueryMode, - ConflictOrGap, - EvidenceRelevance, EvidenceSummary, QuoteCard, RagAnswer, @@ -575,28 +560,6 @@ function clinicalNoteHasDistinctDetail(row: ClinicalNotesRow) { return Boolean(detail) && detail !== title; } -function clinicalNoteDetailLabel(row: ClinicalNotesRow) { - const text = `${row.title} ${row.detail}`.toLowerCase(); - if (/\b(timing|level|schedule|dose change|stable|days?)\b/.test(text)) return "Timing"; - if (/\b(escalation|escalate|urgent|toxicity|trigger|vomiting|confusion|ataxia|tremor)\b/.test(text)) { - return "Escalate"; - } - if (/\b(baseline|confirm|record|document|check|review)\b/.test(text)) return "Action"; - return "Note"; -} - -function ClinicalNoteDetailCard({ row }: { row: ClinicalNotesRow }) { - const detail = sentenceCaseClinicalNoteDetail(row.detail); - return ( -
-

- {clinicalNoteDetailLabel(row)}: - {detail} -

-
- ); -} - function clinicalNotesTableEvidenceCount(answer: RagAnswer) { return (answer.visualEvidence ?? answer.smartPanel?.visualEvidence ?? []).filter( (item) => item.accessibleTableMarkdown || item.tableRows?.length, @@ -928,292 +891,6 @@ export function SafetyFindingsPanel({ findings }: { findings: ReturnType - } - /> -
-
-

What was found

-

{found}

-
-
-

- What was not found -

-

{missing}

-
-
-

Closest sources

- {closestSources.length ? ( -
- {closestSources.map((source) => ( - - - {cleanDisplayTitle(source.title)} - - ))} -
- ) : ( -

No nearby indexed sources were returned.

- )} -
-
-

- Suggested next search/upload -

-

- Try a narrower query using the missing terms, scope to a likely document, or upload/index the guideline that - directly covers "{query.trim()}". -

-
-
- - ); -} - -function EvidenceCounts({ - answer, - sourceSummary, - sourceCount, -}: { - answer: RagAnswer; - sourceSummary?: EvidenceSummary; - sourceCount: number; -}) { - const counts = [ - { - label: "Citations", - value: answer.citations.length, - }, - { - label: "Quotes", - value: answer.quoteCards?.length ?? sourceSummary?.quote_count ?? 0, - }, - { - label: "Images", - value: - answer.visualEvidence?.length ?? answer.smartPanel?.visualEvidence?.length ?? sourceSummary?.image_count ?? 0, - }, - { - label: "Passages", - value: sourceCount || sourceSummary?.total_sources || 0, - }, - ]; - - return ( -
- {counts.map((item) => ( -
-

{item.value}

-

{item.label}

-
- ))} -
- ); -} - -function AnswerSourceStatus({ - source, - weakEvidence, -}: { - source: BestSourceRecommendation | null | undefined; - weakEvidence: boolean; -}) { - const metadata = source?.source_metadata; - return ( -
-
-

Source status

- -
- - {weakEvidence ? ( -

- Evidence support is limited. Treat this as a source-finding result until the linked passage is verified. -

- ) : null} -
- ); -} - -function EvidenceSummaryCard({ - answer, - bestSource, - grounded, - relevance, - sourceSummary, - weakEvidence, - sources, - gaps, - onScopeDocument, - compact = false, - supporting = false, -}: { - answer: RagAnswer; - bestSource: BestSourceRecommendation | null; - grounded: boolean; - relevance?: EvidenceRelevance | null; - sourceSummary?: EvidenceSummary; - weakEvidence: boolean; - sources: SearchResult[]; - gaps: ConflictOrGap[]; - onScopeDocument: (documentId: string) => void; - compact?: boolean; - supporting?: boolean; -}) { - const sourceLabel = relevance && !relevance.isSourceBacked ? "Closest source" : "Top source"; - const supportLabel = relevanceChipLabel(relevance, grounded); - const sourceStrength = bestSource?.source_strength ?? sourceSummary?.source_strength ?? "none"; - const gapMessage = - gaps[0]?.message ?? - (!relevance?.isSourceBacked && relevance?.supportReason - ? relevance.supportReason - : (sourceSummary?.summary ?? null)); - - return ( - - ); -} - export function compactEvidenceSummary( answer: RagAnswer, sources: SearchResult[], @@ -1315,192 +992,6 @@ export function primaryVisualTable(answer: RagAnswer) { return answer.visualEvidence?.find((item) => item.accessibleTableMarkdown || item.tableRows?.length) ?? null; } -type RenderModelPdfSource = { - document_id: string; - title: string; - file_name: string; - page_number: number | null; - chunk_id: string | null; -}; - -function uniquePdfSourcesForRenderModel(renderModel: AnswerRenderModel): RenderModelPdfSource[] { - return renderModel.primarySources.map((source) => ({ - document_id: source.document_id, - title: source.title, - file_name: source.file_name, - page_number: source.page_number, - chunk_id: source.chunk_id, - })); -} - -function queryModeLabel(mode: ClinicalQueryMode) { - return clinicalQueryModeOptions.find((option) => option.value === mode)?.label ?? mode.replaceAll("_", " "); -} - -function AnswerInsightBar({ - answer, - bestSource, - relevance, - queryMode, - sourceGovernanceWarnings, -}: { - answer: RagAnswer; - bestSource: BestSourceRecommendation | null; - relevance?: EvidenceRelevance | null; - queryMode: ClinicalQueryMode; - sourceGovernanceWarnings: SourceGovernanceWarning[]; -}) { - const frontendGovernanceWarnings = frontendSourceGovernanceWarnings(sourceGovernanceWarnings); - const metadata = normalizeSourceMetadata( - bestSource?.source_metadata ?? answer.sources?.[0]?.source_metadata ?? answer.citations?.[0]?.source_metadata, - ); - const modeLabel = - answer.smartApiPlan?.displayMode?.replaceAll("_", " ") ?? - answer.responseMode?.replaceAll("_", " ") ?? - queryModeLabel(queryMode); - const sourceCount = answer.evidenceSummary?.total_sources ?? answer.sources?.length ?? answer.citations.length; - const support = relevanceChipLabel(relevance ?? answer.relevance, answer.grounded); - const sourceStatus = frontendGovernanceWarnings.length - ? `${frontendGovernanceWarnings.length} source status note${frontendGovernanceWarnings.length === 1 ? "" : "s"}` - : sourceStatusLabel(metadata); - const retrievalGate = answer.retrievalDiagnostics?.gateStatus; - const items = [ - { label: "Mode", value: modeLabel, icon: SlidersHorizontal }, - { - label: "Support", - value: support, - icon: hasStrongRelevanceIcon(relevance ?? answer.relevance, answer.grounded) ? CheckCircle2 : AlertCircle, - }, - { label: "Sources", value: String(sourceCount), icon: FileText }, - { label: "Confidence", value: answer.confidence, icon: Target }, - { - label: "Retrieval", - value: retrievalGate ? `${retrievalGate} gate` : "Not logged", - icon: retrievalGate === "blocked" ? ShieldAlert : CheckCircle2, - }, - { label: "Status", value: `${sourceStatus} / ${validationStatusLabel(metadata)}`, icon: BookOpen }, - ]; - - return ( -
- {items.map((item) => { - const Icon = item.icon; - return ( - - - - {item.label} - - {item.value} - - ); - })} -
- ); -} - -function EvidenceVerificationStrip({ - answer, - bestSource, - sourceSummary, - weakEvidence, - governanceWarningCount, -}: { - answer: RagAnswer; - bestSource: BestSourceRecommendation | null; - sourceSummary?: EvidenceSummary | null; - weakEvidence: boolean; - governanceWarningCount: number; -}) { - const metadata = normalizeSourceMetadata( - bestSource?.source_metadata ?? answer.sources?.[0]?.source_metadata ?? answer.citations?.[0]?.source_metadata, - ); - const sourceCount = sourceSummary?.total_sources ?? answer.sources?.length ?? answer.citations.length; - const citationCount = answer.citations.length; - const gapCount = answer.conflictsOrGaps?.length ?? answer.smartPanel?.conflictsOrGaps?.length ?? 0; - const retrievalGateBlocked = answer.retrievalDiagnostics?.gateStatus === "blocked"; - const checks = [ - { - label: "Citations", - value: citationCount ? `${citationCount} citation${citationCount === 1 ? "" : "s"}` : "None", - ready: citationCount > 0, - }, - { - label: "Sources", - value: `${sourceCount} source${sourceCount === 1 ? "" : "s"}`, - ready: sourceCount > 0, - }, - { - label: "Source status", - value: sourceStatusLabel(metadata), - ready: metadata.document_status === "current" && !governanceWarningCount, - }, - { - label: "Retrieval gate", - value: retrievalGateBlocked ? "Blocked for low signal" : answer.retrievalDiagnostics ? "Passed" : "Not available", - ready: !retrievalGateBlocked, - }, - { - label: "Gaps", - value: governanceWarningCount - ? `${governanceWarningCount} status note${governanceWarningCount === 1 ? "" : "s"}` - : gapCount - ? `${gapCount} gap${gapCount === 1 ? "" : "s"}` - : "None", - ready: !weakEvidence && !gapCount && !governanceWarningCount, - }, - ]; - - return ( -
-
- {checks.map((check) => ( -
-
- {check.ready ? ( - - ) : ( - - )} -

- {check.label} -

-
-

{check.value}

-
- ))} -
-
- - Pinned source - - {bestSource ? ( - <> - {bestSource.title} - - - ) : ( - No pinned source yet - )} -
-
- ); -} - const answerFeedbackOptions: Array<{ type: AnswerFeedbackType; label: string; From 2d5e029f3ef9949952976372d87de35e68b202f2 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:13:36 +0800 Subject: [PATCH 02/61] Add generated sitemap audit --- docs/process-hardening.md | 6 + docs/site-map.md | 163 +++++++++++ package.json | 4 +- scripts/generate-site-map.ts | 350 ++++++++++++++++++++++++ src/app/mockups/favourites-hub/page.tsx | 2 +- tests/site-map.test.ts | 96 +++++++ 6 files changed, 619 insertions(+), 2 deletions(-) create mode 100644 docs/site-map.md create mode 100644 scripts/generate-site-map.ts create mode 100644 tests/site-map.test.ts diff --git a/docs/process-hardening.md b/docs/process-hardening.md index adc7b8eeb..58fdb831b 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -73,6 +73,12 @@ For each: trace which module-scope helpers/icons/types it uses; move solely-cons - `npm run check:indexing` includes local OCR prerequisites (`fitz`/PyMuPDF, `pytesseract`, and the Tesseract binary). A failure at that prerequisite step is local machine setup debt, not evidence that indexed production data or search behavior regressed. - Supabase performance-advisor `unused_index` INFO items are monitored, not automatically fixed. Do not remove search/RAG support indexes until live query evidence, local explain/verification, and rollback planning show the index is safe to drop. +## Route sitemap guard (2026-07-03) + +- Route, navigation, redirect, app-mode, registry-slug, and mockup-route changes must run `npm run sitemap:update` and `npm run sitemap:check` so `docs/site-map.md` stays aligned with `src/app`, `src/lib/app-modes.ts`, Services/Forms registry fixtures, Differentials, and medication detail routes. +- `npm run verify:cheap` now includes `npm run sitemap:check`; a stale sitemap is treated as process drift, not a documentation nicety. +- Keep `docs/site-map.md` as the human-readable route map for now. If it becomes too large for review, split into a concise `docs/site-map.md` summary plus a generated `docs/site-map.generated.md` inventory, and update `scripts/generate-site-map.ts` / `tests/site-map.test.ts` in the same change. + ## Retrieval RPC drift & indexing hygiene (2026-07-01) - The four app-path hybrid retrieval RPCs (`match_document_chunks_hybrid`, `match_document_embedding_fields_hybrid`, `match_document_index_units_hybrid`, `match_document_memory_cards_hybrid` + its `_v2` core) had live-only performance fixes applied via raw SQL that were never captured in migrations, so a `supabase db reset` / branch DB reproduced the slow pre-fix shapes. Migration `20260701140631_codify_live_retrieval_rpcs` codifies the live definitions (validated byte-equivalent to live via whitespace-stripped `pg_get_functiondef` md5 before applying — a confirmed no-op on live), and `supabase/schema.sql` was reconciled to match. A clean replay now reproduces production retrieval. diff --git a/docs/site-map.md b/docs/site-map.md new file mode 100644 index 000000000..ad1cd4c3c --- /dev/null +++ b/docs/site-map.md @@ -0,0 +1,163 @@ +# Clinical KB Site Map + +This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` to verify it is current. + +## Main product pages + +- `/` - Main Clinical KB shell. Source: `src/app/page.tsx`. +- `/applications` - Application and tool launcher. Source: `src/app/applications/page.tsx`. +- `/differentials` - Differentials home and search surface. Source: `src/app/differentials/page.tsx`. +- `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/differentials/diagnoses/page.tsx`. +- `/differentials/presentations` - Presentation workflow stream. Source: `src/app/differentials/presentations/page.tsx`. +- `/favourites` - Saved clinical items and sets. Source: `src/app/favourites/page.tsx`. +- `/forms` - Forms home and search surface. Source: `src/app/forms/page.tsx`. +- `/medications` - Medication index redirect. Source: `src/app/medications/page.tsx`. +- `/services` - Services home and search surface. Source: `src/app/services/page.tsx`. + +## Mode/query routes + +- `/?mode=answer` - Answer mode. Search kind: `answer`. Query example: `/?mode=answer&q=example+question&focus=1&run=1`. +- `/?mode=documents` - Documents mode. Search kind: `documents`. Query example: `/?mode=documents&q=lithium+monitoring&focus=1&run=1`. +- `/services` - Services mode. Search kind: `services`. Query example: `/services?q=13YARN&focus=1&run=1`. +- `/forms` - Forms mode. Search kind: `documents`. Query example: `/forms?q=transport+forms&focus=1&run=1`. +- `/favourites` - Favourites mode. Search kind: `favourites`. Query example: `/favourites?q=clozapine+set&focus=1&run=1`. +- `/differentials` - Differentials mode. Search kind: `differentials`. Query example: `/differentials?q=acute+confusion&focus=1&run=1`. +- `/?mode=prescribing` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. +- `/?mode=tools` - Tools mode. Search kind: `tools`. Query example: `/?mode=tools&q=medications&focus=1&run=1`. + +## Registry-backed routes + +- `/services/[slug]` - Registry-backed service detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records. +- `/forms/[slug]` - Registry-backed form detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records. +- `/api/registry/records?kind=service` - Service registry collection endpoint. +- `/api/registry/records?kind=form` - Form registry collection endpoint. +- `/api/registry/records/[slug]?kind=service|form` - Registry detail endpoint. + +## Dynamic slug inventories + +### Seeded service slugs + +- `/services/[slug]` - Dynamic route family. +- `13yarn` +- `head-to-health` +- `mental-health-emergency-response-line` +- `rurallink` +- `wachs-aboriginal-mental-health` + +### Seeded form slugs + +- `/forms/[slug]` - Dynamic route family. +- `detention-examination-movement` +- `extension-transport-order` +- `transfer-order` +- `transport-crisis-form` + +### Differential diagnosis slugs + +- `/differentials/diagnoses/[slug]` - Dynamic route family. +- `delirium` +- `hepatic-encephalopathy` +- `meningitis-encephalitis` +- `pneumonia` +- `post-ictal-confusion` +- `substance-intoxication` +- `substance-withdrawal` +- `thyroid-disease` +- `wernicke-encephalopathy` + +### Medication slugs + +- `/medications/[slug]` - Dynamic route family. +- `acamprosate` + +## Document viewer route + +- `/documents/[id]` - Document viewer/detail page. Individual document IDs are intentionally not enumerated in this sitemap. + +## Mockup/prototype routes + +- `/mockups/answer-evidence-popups` - Route discovered from app directory Source: `src/app/mockups/answer-evidence-popups/page.tsx`. +- `/mockups/document-search` - Route discovered from app directory Source: `src/app/mockups/document-search/page.tsx`. +- `/mockups/document-search-command` - Route discovered from app directory Source: `src/app/mockups/document-search-command/page.tsx`. +- `/mockups/document-search-evidence-lens` - Route discovered from app directory Source: `src/app/mockups/document-search-evidence-lens/page.tsx`. +- `/mockups/document-search-triage-board` - Route discovered from app directory Source: `src/app/mockups/document-search-triage-board/page.tsx`. +- `/mockups/document-search/source` - Route discovered from app directory Source: `src/app/mockups/document-search/source/page.tsx`. +- `/mockups/favourites-command-desk` - Route discovered from app directory Source: `src/app/mockups/favourites-command-desk/page.tsx`. +- `/mockups/favourites-hub` - Route discovered from app directory Source: `src/app/mockups/favourites-hub/page.tsx`. +- `/mockups/favourites-library-view` - Route discovered from app directory Source: `src/app/mockups/favourites-library-view/page.tsx`. +- `/mockups/favourites-set-board` - Route discovered from app directory Source: `src/app/mockups/favourites-set-board/page.tsx`. +- `/mockups/medication-prescribing` - Route discovered from app directory Source: `src/app/mockups/medication-prescribing/page.tsx`. +- `/mockups/mode-dropdown` - Route discovered from app directory Source: `src/app/mockups/mode-dropdown/page.tsx`. +- `/mockups/recent-searches-bottom` - Route discovered from app directory Source: `src/app/mockups/recent-searches-bottom/page.tsx`. +- `/mockups/settings-search-clinical` - Route discovered from app directory Source: `src/app/mockups/settings-search-clinical/page.tsx`. +- `/mockups/settings-search-general` - Route discovered from app directory Source: `src/app/mockups/settings-search-general/page.tsx`. +- `/mockups/settings-search-privacy` - Route discovered from app directory Source: `src/app/mockups/settings-search-privacy/page.tsx`. +- `/mockups/tools-command-center` - Route discovered from app directory Source: `src/app/mockups/tools-command-center/page.tsx`. +- `/mockups/tools-split-pane` - Route discovered from app directory Source: `src/app/mockups/tools-split-pane/page.tsx`. +- `/mockups/tools-task-directory` - Route discovered from app directory Source: `src/app/mockups/tools-task-directory/page.tsx`. +- `/mockups/tools-workflow-board` - Route discovered from app directory Source: `src/app/mockups/tools-workflow-board/page.tsx`. + +### Non-routed mockup artifacts + +- `mockups/answer-evidence-popups/page.tsx` - Root-level mockup artifact outside `src/app`; not a Next route. +- `mockups/medication-prescribing/page.tsx` - Root-level mockup artifact outside `src/app`; not a Next route. + +## API routes + +- `/api/answer` - Generate answer response. Source: `src/app/api/answer/route.ts`. +- `/api/answer/stream` - Streaming answer response. Source: `src/app/api/answer/stream/route.ts`. +- `/api/documents` - Document collection operations. Source: `src/app/api/documents/route.ts`. +- `/api/documents/[id]` - Document detail operations. Source: `src/app/api/documents/[id]/route.ts`. +- `/api/documents/[id]/labels` - Document label operations. Source: `src/app/api/documents/[id]/labels/route.ts`. +- `/api/documents/[id]/reindex` - Single-document reindex operation. Source: `src/app/api/documents/[id]/reindex/route.ts`. +- `/api/documents/[id]/search` - Search within one document. Source: `src/app/api/documents/[id]/search/route.ts`. +- `/api/documents/[id]/signed-url` - Private document signed URL. Source: `src/app/api/documents/[id]/signed-url/route.ts`. +- `/api/documents/[id]/summarize` - Document summary operation. Source: `src/app/api/documents/[id]/summarize/route.ts`. +- `/api/documents/[id]/table-facts` - Document table facts. Source: `src/app/api/documents/[id]/table-facts/route.ts`. +- `/api/documents/bulk` - Bulk document operations. Source: `src/app/api/documents/bulk/route.ts`. +- `/api/documents/bulk/reindex` - Bulk reindex operation. Source: `src/app/api/documents/bulk/reindex/route.ts`. +- `/api/eval-cases` - Evaluation case data. Source: `src/app/api/eval-cases/route.ts`. +- `/api/health` - Health check. Source: `src/app/api/health/route.ts`. +- `/api/images/[id]/signed-url` - Private image signed URL. Source: `src/app/api/images/[id]/signed-url/route.ts`. +- `/api/ingestion/batches` - Ingestion batch state. Source: `src/app/api/ingestion/batches/route.ts`. +- `/api/ingestion/jobs` - Ingestion job collection. Source: `src/app/api/ingestion/jobs/route.ts`. +- `/api/ingestion/jobs/[id]/retry` - Retry ingestion job. Source: `src/app/api/ingestion/jobs/[id]/retry/route.ts`. +- `/api/ingestion/quality` - Ingestion quality reporting. Source: `src/app/api/ingestion/quality/route.ts`. +- `/api/jobs` - Job state. Source: `src/app/api/jobs/route.ts`. +- `/api/local-project-id` - Local project identity guard. Source: `src/app/api/local-project-id/route.ts`. +- `/api/registry/records` - Registry record collection. Source: `src/app/api/registry/records/route.ts`. +- `/api/registry/records/[slug]` - Registry record detail. Source: `src/app/api/registry/records/[slug]/route.ts`. +- `/api/search` - Search endpoint. Source: `src/app/api/search/route.ts`. +- `/api/search/interaction` - Search interaction telemetry. Source: `src/app/api/search/interaction/route.ts`. +- `/api/setup-status` - Setup status. Source: `src/app/api/setup-status/route.ts`. +- `/api/upload` - Upload endpoint. Source: `src/app/api/upload/route.ts`. + +## Redirects + +- `/medications` - Redirects to `/?mode=prescribing`. Source: `src/app/medications/page.tsx`. +- `/mockups/favourites-hub` - Redirects to `/favourites`. Source: `src/app/mockups/favourites-hub/page.tsx`. +- `/mockups/medication-prescribing` - Redirects to `/medications/acamprosate`. Source: `src/app/mockups/medication-prescribing/page.tsx`. + +## Known caveats and stale-path flags + +- No active stale internal route targets are expected in the current generated sitemap. +- `/mockups/favourites-hub` is a legacy compatibility route and should redirect to `/favourites`. +- Registry-backed service and form pages may show sign-in, load-error, or in-app not-found states for missing per-user records. +- Live user registries may contain additional service or form slugs beyond the seeded/demo slugs listed here. +- `/documents/[id]` is intentionally summarized as a route family; individual document IDs are private runtime data. +- Several differential records are placeholder scaffolds pending source-backed local clinical content. + +## Route ownership/source map + +| Area | Source | +| ------------------------------ | --------------------------------------------------------------------------------------------- | +| Root dashboard and query modes | `src/app/page.tsx, src/lib/app-modes.ts` | +| Global shell layouts | `src/app/*/layout.tsx, src/components/clinical-dashboard/global-search-shell.tsx` | +| Services | `src/app/services, src/lib/services.ts, src/app/api/registry/records` | +| Forms | `src/app/forms, src/lib/forms.ts, src/app/api/registry/records` | +| Favourites | `src/app/favourites, src/components/clinical-dashboard/favourites-home-page.tsx` | +| Differentials | `src/app/differentials, src/lib/differentials.ts` | +| Medications | `src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx` | +| Documents | `src/app/documents/[id], src/app/api/documents` | +| Applications and tools | `src/app/applications, src/components/applications-launcher-page.tsx` | +| Mockups | `src/app/mockups, mockups/` | diff --git a/package.json b/package.json index cbfb2f301..c3573704d 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,12 @@ "test:e2e:accessibility": "node scripts/run-playwright.mjs tests/ui-accessibility.spec.ts --project=chromium", "test:e2e:chromium": "node scripts/run-playwright.mjs --project=chromium", "test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts", - "verify:cheap": "npm run check:runtime && npm run lint && npm run typecheck && npm run test", + "verify:cheap": "npm run check:runtime && npm run sitemap:check && npm run lint && npm run typecheck && npm run test", "verify:ui": "npm run check:runtime && npm run test:e2e:chromium", "verify:release": "npm run check:runtime && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run check:production-readiness && npm run governance:release && npm run eval:quality:release", "ci:env-check": "node scripts/check-ci-env.mjs", + "sitemap:update": "tsx scripts/generate-site-map.ts", + "sitemap:check": "tsx scripts/generate-site-map.ts --check", "check:runtime": "tsx scripts/check-runtime.ts", "check:deployment-readiness": "node scripts/deployment-boot-smoke.mjs", "check:edge:functions": "node scripts/check-edge-functions.mjs", diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts new file mode 100644 index 000000000..6ffa12b24 --- /dev/null +++ b/scripts/generate-site-map.ts @@ -0,0 +1,350 @@ +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { format } from "prettier"; + +import { appModeDefinitions, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; +import { differentialRecords } from "@/lib/differentials"; +import { formRecords } from "@/lib/forms"; +import { serviceRecords } from "@/lib/services"; + +const appDir = path.join(process.cwd(), "src", "app"); +const siteMapPath = path.join(process.cwd(), "docs", "site-map.md"); +const medicationSlugs = ["acamprosate"] as const; + +type RouteKind = "page" | "api"; + +type DiscoveredRoute = { + route: string; + file: string; +}; + +type RedirectRoute = { + route: string; + file: string; + target: string; +}; + +type SiteMapData = { + pageRoutes: DiscoveredRoute[]; + apiRoutes: DiscoveredRoute[]; + redirects: RedirectRoute[]; + nonRoutedMockupArtifacts: string[]; +}; + +const routeDescriptions: Record = { + "/": "Main Clinical KB shell.", + "/applications": "Application and tool launcher.", + "/differentials": "Differentials home and search surface.", + "/differentials/diagnoses": "Diagnosis stream.", + "/differentials/diagnoses/[slug]": "Differential diagnosis detail.", + "/differentials/presentations": "Presentation workflow stream.", + "/documents/[id]": "Document viewer/detail page.", + "/favourites": "Saved clinical items and sets.", + "/forms": "Forms home and search surface.", + "/forms/[slug]": "Registry-backed form detail.", + "/medications": "Medication index redirect.", + "/medications/[slug]": "Medication detail.", + "/services": "Services home and search surface.", + "/services/[slug]": "Registry-backed service detail.", +}; + +const apiDescriptions: Record = { + "/api/answer": "Generate answer response.", + "/api/answer/stream": "Streaming answer response.", + "/api/documents": "Document collection operations.", + "/api/documents/[id]": "Document detail operations.", + "/api/documents/[id]/labels": "Document label operations.", + "/api/documents/[id]/reindex": "Single-document reindex operation.", + "/api/documents/[id]/search": "Search within one document.", + "/api/documents/[id]/signed-url": "Private document signed URL.", + "/api/documents/[id]/summarize": "Document summary operation.", + "/api/documents/[id]/table-facts": "Document table facts.", + "/api/documents/bulk": "Bulk document operations.", + "/api/documents/bulk/reindex": "Bulk reindex operation.", + "/api/eval-cases": "Evaluation case data.", + "/api/health": "Health check.", + "/api/images/[id]/signed-url": "Private image signed URL.", + "/api/ingestion/batches": "Ingestion batch state.", + "/api/ingestion/jobs": "Ingestion job collection.", + "/api/ingestion/jobs/[id]/retry": "Retry ingestion job.", + "/api/ingestion/quality": "Ingestion quality reporting.", + "/api/jobs": "Job state.", + "/api/local-project-id": "Local project identity guard.", + "/api/registry/records": "Registry record collection.", + "/api/registry/records/[slug]": "Registry record detail.", + "/api/search": "Search endpoint.", + "/api/search/interaction": "Search interaction telemetry.", + "/api/setup-status": "Setup status.", + "/api/upload": "Upload endpoint.", +}; + +const routeOwnershipRows = [ + ["Root dashboard and query modes", "src/app/page.tsx, src/lib/app-modes.ts"], + ["Global shell layouts", "src/app/*/layout.tsx, src/components/clinical-dashboard/global-search-shell.tsx"], + ["Services", "src/app/services, src/lib/services.ts, src/app/api/registry/records"], + ["Forms", "src/app/forms, src/lib/forms.ts, src/app/api/registry/records"], + ["Favourites", "src/app/favourites, src/components/clinical-dashboard/favourites-home-page.tsx"], + ["Differentials", "src/app/differentials, src/lib/differentials.ts"], + ["Medications", "src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx"], + ["Documents", "src/app/documents/[id], src/app/api/documents"], + ["Applications and tools", "src/app/applications, src/components/applications-launcher-page.tsx"], + ["Mockups", "src/app/mockups, mockups/"], +] as const; + +function toPosixPath(value: string) { + return value.split(path.sep).join("/"); +} + +function routeSegment(segment: string) { + if (segment.startsWith("(") && segment.endsWith(")")) return null; + if (segment.startsWith("@")) return null; + return segment; +} + +function fileToRoute(filePath: string, kind: RouteKind) { + const suffix = kind === "page" ? "page.tsx" : "route.ts"; + const relative = toPosixPath(path.relative(appDir, filePath)); + const withoutFile = relative.slice(0, -suffix.length).replace(/\/$/, ""); + const segments = withoutFile.split("/").filter(Boolean).map(routeSegment).filter(Boolean); + return segments.length ? `/${segments.join("/")}` : "/"; +} + +function collectFiles(root: string, targetFileName: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const fullPath = path.join(root, entry.name); + if (entry.isDirectory()) { + files.push(...collectFiles(fullPath, targetFileName)); + continue; + } + if (entry.isFile() && entry.name === targetFileName) files.push(fullPath); + } + return files; +} + +function discoverRoutes(kind: RouteKind): DiscoveredRoute[] { + const targetFile = kind === "page" ? "page.tsx" : "route.ts"; + return collectFiles(appDir, targetFile) + .map((file) => ({ + route: fileToRoute(file, kind), + file: toPosixPath(path.relative(process.cwd(), file)), + })) + .sort((left, right) => left.route.localeCompare(right.route) || left.file.localeCompare(right.file)); +} + +function discoverRedirects(pageRoutes: DiscoveredRoute[]): RedirectRoute[] { + return pageRoutes + .map((page) => { + const source = readFileSync(path.join(process.cwd(), page.file), "utf8"); + const target = source.match(/\bredirect\(\s*["']([^"']+)["']\s*\)/)?.[1]; + return target ? { ...page, target } : null; + }) + .filter((value): value is RedirectRoute => Boolean(value)) + .sort((left, right) => left.route.localeCompare(right.route)); +} + +function discoverNonRoutedMockupArtifacts() { + const mockupsDir = path.join(process.cwd(), "mockups"); + if (!existsSync(mockupsDir)) return []; + return collectFiles(mockupsDir, "page.tsx") + .map((file) => toPosixPath(path.relative(process.cwd(), file))) + .sort((left, right) => left.localeCompare(right)); +} + +export function collectSiteMapData(): SiteMapData { + const pageRoutes = discoverRoutes("page"); + return { + pageRoutes, + apiRoutes: discoverRoutes("api"), + redirects: discoverRedirects(pageRoutes), + nonRoutedMockupArtifacts: discoverNonRoutedMockupArtifacts(), + }; +} + +function bullet(route: string, description?: string) { + return `- \`${route}\`${description ? ` - ${description}` : ""}`; +} + +function routeLine(route: DiscoveredRoute, descriptionMap: Record) { + return bullet( + route.route, + `${descriptionMap[route.route] ?? "Route discovered from app directory"} Source: \`${route.file}\`.`, + ); +} + +function sortedSlugs(slugs: readonly string[]) { + return [...slugs].sort((left, right) => left.localeCompare(right)); +} + +function renderSlugInventory(title: string, routePattern: string, slugs: readonly string[]) { + return [ + `### ${title}`, + "", + bullet(routePattern, "Dynamic route family."), + ...sortedSlugs(slugs).map((slug) => `- \`${slug}\``), + ]; +} + +function renderModeRoutes() { + const examples: Record = { + answer: appModeHomeHref("answer", { query: "example question", focus: true, run: true }), + documents: appModeHomeHref("documents", { query: "lithium monitoring", focus: true, run: true }), + services: appModeHomeHref("services", { query: "13YARN", focus: true, run: true }), + forms: appModeHomeHref("forms", { query: "transport forms", focus: true, run: true }), + favourites: appModeHomeHref("favourites", { query: "clozapine set", focus: true, run: true }), + differentials: appModeHomeHref("differentials", { query: "acute confusion", focus: true, run: true }), + prescribing: appModeHomeHref("prescribing", { query: "acamprosate renal dose", focus: true, run: true }), + tools: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), + }; + + return appModeDefinitions.map((mode) => + bullet( + ("href" in mode ? mode.href : undefined) ?? appModeHomeHref(mode.id), + `${mode.label} mode. Search kind: \`${mode.search.kind}\`. Query example: \`${examples[mode.id]}\`.`, + ), + ); +} + +function section(title: string, lines: string[]) { + return [`## ${title}`, "", ...lines, ""]; +} + +function renderSiteMapRaw(data = collectSiteMapData()) { + const productRoutes = data.pageRoutes.filter( + (route) => + !route.route.startsWith("/api") && + !route.route.startsWith("/mockups") && + ![ + "/documents/[id]", + "/services/[slug]", + "/forms/[slug]", + "/differentials/diagnoses/[slug]", + "/medications/[slug]", + ].includes(route.route), + ); + const mockupRoutes = data.pageRoutes.filter((route) => route.route.startsWith("/mockups")); + + const lines = [ + "# Clinical KB Site Map", + "", + "This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` to verify it is current.", + "", + ...section( + "Main product pages", + productRoutes.map((route) => routeLine(route, routeDescriptions)), + ), + ...section("Mode/query routes", renderModeRoutes()), + ...section("Registry-backed routes", [ + bullet( + "/services/[slug]", + "Registry-backed service detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records.", + ), + bullet( + "/forms/[slug]", + "Registry-backed form detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records.", + ), + bullet("/api/registry/records?kind=service", "Service registry collection endpoint."), + bullet("/api/registry/records?kind=form", "Form registry collection endpoint."), + bullet("/api/registry/records/[slug]?kind=service|form", "Registry detail endpoint."), + ]), + ...section("Dynamic slug inventories", [ + ...renderSlugInventory( + "Seeded service slugs", + "/services/[slug]", + serviceRecords.map((record) => record.slug), + ), + "", + ...renderSlugInventory( + "Seeded form slugs", + "/forms/[slug]", + formRecords.map((record) => record.slug), + ), + "", + ...renderSlugInventory( + "Differential diagnosis slugs", + "/differentials/diagnoses/[slug]", + differentialRecords.map((record) => record.slug), + ), + "", + ...renderSlugInventory("Medication slugs", "/medications/[slug]", medicationSlugs), + ]), + ...section("Document viewer route", [ + bullet( + "/documents/[id]", + "Document viewer/detail page. Individual document IDs are intentionally not enumerated in this sitemap.", + ), + ]), + ...section("Mockup/prototype routes", [ + ...mockupRoutes.map((route) => routeLine(route, routeDescriptions)), + ...(data.nonRoutedMockupArtifacts.length + ? [ + "", + "### Non-routed mockup artifacts", + "", + ...data.nonRoutedMockupArtifacts.map((file) => + bullet(file, "Root-level mockup artifact outside `src/app`; not a Next route."), + ), + ] + : []), + ]), + ...section( + "API routes", + data.apiRoutes.map((route) => routeLine(route, apiDescriptions)), + ), + ...section( + "Redirects", + data.redirects.length + ? data.redirects.map((redirect) => + bullet(redirect.route, `Redirects to \`${redirect.target}\`. Source: \`${redirect.file}\`.`), + ) + : ["- No page-level redirects discovered."], + ), + ...section("Known caveats and stale-path flags", [ + "- No active stale internal route targets are expected in the current generated sitemap.", + "- `/mockups/favourites-hub` is a legacy compatibility route and should redirect to `/favourites`.", + "- Registry-backed service and form pages may show sign-in, load-error, or in-app not-found states for missing per-user records.", + "- Live user registries may contain additional service or form slugs beyond the seeded/demo slugs listed here.", + "- `/documents/[id]` is intentionally summarized as a route family; individual document IDs are private runtime data.", + "- Several differential records are placeholder scaffolds pending source-backed local clinical content.", + ]), + ...section("Route ownership/source map", [ + "| Area | Source |", + "| --- | --- |", + ...routeOwnershipRows.map(([area, source]) => `| ${area} | \`${source}\` |`), + ]), + ]; + + return `${lines + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim()}\n`; +} + +export async function renderSiteMap(data = collectSiteMapData()) { + return format(renderSiteMapRaw(data), { parser: "markdown", printWidth: 120 }); +} + +async function main() { + const expected = await renderSiteMap(); + const check = process.argv.includes("--check"); + + if (check) { + const current = existsSync(siteMapPath) ? readFileSync(siteMapPath, "utf8") : ""; + if (current !== expected) { + console.error("docs/site-map.md is stale. Run `npm run sitemap:update` and commit the result."); + process.exitCode = 1; + } + return; + } + + writeFileSync(siteMapPath, expected, "utf8"); + console.log(`Updated ${toPosixPath(path.relative(process.cwd(), siteMapPath))}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + void main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/src/app/mockups/favourites-hub/page.tsx b/src/app/mockups/favourites-hub/page.tsx index c115eb7d0..438f9733b 100644 --- a/src/app/mockups/favourites-hub/page.tsx +++ b/src/app/mockups/favourites-hub/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function FavouritesHubMockupRedirect() { - redirect("/?mode=documents"); + redirect("/favourites"); } diff --git a/tests/site-map.test.ts b/tests/site-map.test.ts new file mode 100644 index 000000000..5fafba6de --- /dev/null +++ b/tests/site-map.test.ts @@ -0,0 +1,96 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { appModeDefinitions, appModeHomeHref } from "@/lib/app-modes"; +import { tools } from "@/components/tools-page-mockups/tool-fixtures"; +import { differentialRecords } from "@/lib/differentials"; +import { formRecords } from "@/lib/forms"; +import { serviceRecords } from "@/lib/services"; +import { collectSiteMapData, renderSiteMap } from "../scripts/generate-site-map"; + +const siteMapPath = path.join(process.cwd(), "docs", "site-map.md"); +const siteMap = readFileSync(siteMapPath, "utf8"); + +const acceptedDynamicPatterns = [ + /^\/documents\/[^/?#]+(?:[?#].*)?$/, + /^\/services\/[^/?#]+(?:[?#].*)?$/, + /^\/forms\/[^/?#]+(?:[?#].*)?$/, + /^\/differentials\/diagnoses\/[^/?#]+(?:[?#].*)?$/, + /^\/medications\/[^/?#]+(?:[?#].*)?$/, +]; + +function pathOnly(href: string) { + return href.split(/[?#]/)[0] || "/"; +} + +function routePatternForHref(href: string) { + const pathname = pathOnly(href); + if (acceptedDynamicPatterns.some((pattern) => pattern.test(href))) { + if (pathname.startsWith("/documents/")) return "/documents/[id]"; + if (pathname.startsWith("/services/")) return "/services/[slug]"; + if (pathname.startsWith("/forms/")) return "/forms/[slug]"; + if (pathname.startsWith("/differentials/diagnoses/")) return "/differentials/diagnoses/[slug]"; + if (pathname.startsWith("/medications/")) return "/medications/[slug]"; + } + return pathname; +} + +function expectDocumentedRoute(route: string) { + expect(siteMap, `Expected ${route} to be documented in docs/site-map.md`).toContain(`\`${route}\``); +} + +function expectDocumentedHref(href: string) { + expectDocumentedRoute(routePatternForHref(href)); +} + +describe("tracked sitemap", () => { + it("matches the generated sitemap output", async () => { + expect(siteMap).toBe(await renderSiteMap()); + }); + + it("documents every app page route and API route", () => { + const data = collectSiteMapData(); + + for (const pageRoute of data.pageRoutes) expectDocumentedRoute(pageRoute.route); + for (const apiRoute of data.apiRoutes) expectDocumentedRoute(apiRoute.route); + }); + + it("documents seeded dynamic slugs", () => { + for (const service of serviceRecords) expectDocumentedRoute(service.slug); + for (const form of formRecords) expectDocumentedRoute(form.slug); + for (const record of differentialRecords) expectDocumentedRoute(record.slug); + expectDocumentedRoute("acamprosate"); + }); + + it("documents core navigation href targets", () => { + for (const mode of appModeDefinitions) { + expectDocumentedHref(("href" in mode ? mode.href : undefined) ?? appModeHomeHref(mode.id)); + } + + for (const tool of tools) expectDocumentedHref(tool.href); + + for (const href of [ + "/?mode=answer", + "/?mode=documents", + "/?mode=prescribing", + "/?mode=tools", + "/services", + "/forms", + "/favourites", + "/differentials", + "/medications/acamprosate", + "/differentials/diagnoses/delirium", + ]) { + expectDocumentedHref(href); + } + }); + + it("documents known intentional caveats and compatibility routes", () => { + expect(siteMap).toContain("No active stale internal route targets"); + expect(siteMap).toContain("legacy compatibility route"); + expect(siteMap).toContain("Root-level mockup artifact outside `src/app`"); + expect(siteMap).toContain("Live user registries may contain additional service or form slugs"); + expect(siteMap).toContain("individual document IDs are private runtime data"); + }); +}); From 2d16f83fd18da24db63ce640f2f0a0722757b805 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:36:23 +0800 Subject: [PATCH 03/61] feat(search): universal footer chips + mode-identity icons on mobile Every mode's small-screen floating search composer now shares Answer's chip-row/icon pattern instead of only Documents/Services/Favourites/etc getting a bare magnifier with no chips. Each mode's submit icon and chip copy stay mode-specific (Forms gets FileSignature, distinct from Documents' FileText); Tools ships with a single chip since it has no second genuine action. Larger screens are untouched for now. Co-Authored-By: Claude Sonnet 5 --- .../global-mockup-search-shell.tsx | 5 - .../master-search-header.tsx | 260 ++++++++++++++---- 2 files changed, 203 insertions(+), 62 deletions(-) diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index 30ce1c45c..31cc310b2 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -321,11 +321,6 @@ function GlobalMockupSearchShellClient({ (desktopSearchPlacement === "hero" || isFormsOnlyShell) && isStandaloneModeHome ? "hero" : "default" } searchComposerVisible={shouldShowSearchComposer} - workflowCopyText={ - isDifferentialPresentationWorkflow - ? "Acute confusion / encephalopathy differential comparison. Stabilise ABCs, check BGL, sats, attention test, collateral, and review medications/substances before handoff." - : undefined - } desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} heroComposerFromTablet={isStandaloneModeHome} /> diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 5d9077871..cfe0091a8 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -14,17 +14,20 @@ import { createPortal } from "react-dom"; import { Activity, + BadgeCheck, BrainCircuit, CalendarDays, Check, CheckCircle2, ChevronDown, - Cloud, - Copy, + FileSignature, FileText, Filter, + FolderOpen, + GitBranch, Globe2, Heart, + ListChecks, Loader2, Menu, MessageSquarePlus, @@ -34,7 +37,6 @@ import { Send, ShieldCheck, Sparkles, - AlertCircle, ArrowLeft, X, Lock, @@ -84,7 +86,7 @@ const appModeIcons: Record = { answer: Sparkles, documents: FileText, services: ShieldCheck, - forms: FileText, + forms: FileSignature, favourites: Heart, differentials: BrainCircuit, prescribing: Pill, @@ -199,7 +201,6 @@ export function MasterSearchHeader({ mobileSearchPlacement = "default", desktopSearchPlacement = "default", searchComposerVisible = true, - workflowCopyText, desktopHomeComposerSlotId, heroComposerFromTablet = false, mobileLeadingAction = "menu", @@ -237,7 +238,6 @@ export function MasterSearchHeader({ mobileSearchPlacement?: "default" | "bottom"; desktopSearchPlacement?: "default" | "hero"; searchComposerVisible?: boolean; - workflowCopyText?: string; desktopHomeComposerSlotId?: string; /** Portal the composer into the hero slot from the tablet breakpoint (sm) up, * rather than the default desktop (lg) breakpoint. */ @@ -898,14 +898,166 @@ export function MasterSearchHeader({ ); } + // "open-evidence" is the one footer-chip action that isn't already a mode-action + // id — every other chip dispatches through the existing runModeAction handler + // (the same dispatcher the "+" action menu already uses for these ids). + type FooterChipActionId = ModeActionId | "open-evidence"; + + type FooterActionChip = { + icon: typeof Search; + shortLabel: string; + longLabel: string; + actionId: FooterChipActionId; + ariaLabel: string; + }; + + // The first ("trust") chip on the universal small-screen footer. Every mode gets + // one, mirroring Answer's "Evidence-based" chip in tone, each wired to a real + // action from that mode's own action menu rather than being decorative. + function footerTrustChipFor(mode: AppModeId): FooterActionChip | null { + switch (mode) { + case "answer": + return { + icon: ListChecks, + shortLabel: "Evidence", + longLabel: "Evidence-based", + actionId: "open-evidence", + ariaLabel: "Open evidence-backed answer sources", + }; + case "documents": + return { + icon: BadgeCheck, + shortLabel: "Indexed", + longLabel: "Fully indexed", + actionId: "documents-collections", + ariaLabel: "Open the indexed document library", + }; + case "forms": + return { + icon: BadgeCheck, + shortLabel: "Library", + longLabel: "Form library", + actionId: "documents-collections", + ariaLabel: "Open the form library", + }; + case "services": + return { + icon: BadgeCheck, + shortLabel: "Verified", + longLabel: "Verified directory", + actionId: "services-records", + ariaLabel: "Browse verified service records", + }; + case "favourites": + return { + icon: BadgeCheck, + shortLabel: "Trusted", + longLabel: "Trusted picks", + actionId: "favourites-browse", + ariaLabel: "Browse trusted favourites", + }; + case "differentials": + return { + icon: ListChecks, + shortLabel: "Evidence", + longLabel: "Evidence-linked", + actionId: "differentials-evidence", + ariaLabel: "Review cited differential evidence", + }; + case "prescribing": + return { + icon: ShieldCheck, + shortLabel: "Safety", + longLabel: "Safety-checked", + actionId: "medication-safety", + ariaLabel: "Review contraindications and cautions", + }; + case "tools": + return { + icon: BadgeCheck, + shortLabel: "Curated", + longLabel: "Curated registry", + actionId: "tools-browse", + ariaLabel: "Browse the curated tools registry", + }; + default: + return null; + } + } + + // The second footer chip. Answer/Documents/Forms use the shared document-scope + // trigger instead (see hasScopeFooterChip below) since scope is a real, existing + // concept for those three modes. Tools has no genuine second action yet, so it + // intentionally ships with a single chip rather than an invented one. + function footerSecondaryChipFor(mode: AppModeId): FooterActionChip | null { + switch (mode) { + case "services": + return { + icon: ListChecks, + shortLabel: "Pathways", + longLabel: "Pathways", + actionId: "services-pathways", + ariaLabel: "Browse referral pathways", + }; + case "favourites": + return { + icon: FolderOpen, + shortLabel: "Sets", + longLabel: "Sets", + actionId: "favourites-sets", + ariaLabel: "Open saved sets", + }; + case "differentials": + return { + icon: GitBranch, + shortLabel: "Criteria", + longLabel: "Criteria", + actionId: "differentials-criteria", + ariaLabel: "Compare distinguishing criteria", + }; + case "prescribing": + return { + icon: Activity, + shortLabel: "Monitor", + longLabel: "Monitoring", + actionId: "medication-monitoring", + ariaLabel: "Review the monitoring schedule", + }; + default: + return null; + } + } + + function runFooterChipAction(actionId: FooterChipActionId) { + if (actionId === "open-evidence") { + onOpenEvidence?.(); + return; + } + runModeAction(actionId); + } + function renderSearchComposer(placement: "default" | "desktop-home") { const isDesktopHomeComposer = placement === "desktop-home"; const usesAnswerFooterStyle = isAnswerFooterComposer && !isDesktopHomeComposer; const usesMobileBottomStyle = isMobileBottomComposer && !isDesktopHomeComposer; const usesUniversalFooterStyle = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); - const showFooterSearchChips = usesUniversalFooterStyle && searchMode === "answer"; - // Only the Answer chat composer uses the send affordance; every search-mode home uses the magnifier. + // Every mode shows the universal footer chip row on its small-screen composer now; + // larger screens (sticky-top / hero composers) are untouched for now. + const showFooterSearchChips = usesUniversalFooterStyle; + // Answer keeps the send affordance everywhere (it's the one conversational compose + // mode). Every other mode swaps the magnifier for its own mode-identity glyph, but + // only on the small-screen floating composer — larger screens keep the magnifier. const usesSendAffordance = usesAnswerFooterStyle; + const usesModeIdentityAffordance = usesUniversalFooterStyle && !usesSendAffordance; + const ModeIdentityIcon = appModeIcons[searchMode]; + const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents" || searchMode === "forms"; + const trustFooterChip = footerTrustChipFor(searchMode); + const secondaryFooterChip = footerSecondaryChipFor(searchMode); + // Fallback icons here are never rendered — both are only used inside a JSX guard + // on the corresponding chip being non-null — but keep the icon variables typed as + // components (not `| null`) so the JSX below type-checks without a cast. + const TrustFooterChipIcon = trustFooterChip?.icon ?? BadgeCheck; + const SecondaryFooterChipIcon = secondaryFooterChip?.icon ?? ListChecks; const composerPlaceholder = usesMobileBottomStyle && searchMode === "differentials" ? "Search a presentation" : queryPlaceholder; @@ -1009,38 +1161,56 @@ export function MasterSearchHeader({ ) : usesSendAffordance ? ( + ) : usesModeIdentityAffordance ? ( + ) : ( )} {submitLabel} - {showFooterSearchChips ? ( + {showFooterSearchChips && (trustFooterChip || hasScopeFooterChip || secondaryFooterChip) ? (
- - - {!usesScopeSheet && scopeOpen ? ( + {trustFooterChip ? ( + + ) : null} + {hasScopeFooterChip ? ( + + ) : null} + {!hasScopeFooterChip && secondaryFooterChip ? ( + + ) : null} + {hasScopeFooterChip && !usesScopeSheet && scopeOpen ? (
{isWorkflowHeader ? ( <> -
- - - Local only - - - - Offline ready - - - - Source pending review - -
- ) : null} {!isWorkflowHeader ? ( From 18f9ba6cea97d9c3acca74848b9156337241713d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:02:32 +0800 Subject: [PATCH 04/61] fix(search): scope popover reachable via + menu on desktop widths The document-scope popover was nested inside the footer chip row, which only renders on the small-screen floating composer. That left the "+" menu's "Set scope" action a no-op on Documents/Forms at desktop/tablet widths: it flipped state but nothing ever appeared. Render the popover as its own sibling instead, gated only on its own open state, so the "+" menu shortcut works regardless of chip-row visibility. Co-Authored-By: Claude Sonnet 5 --- .../master-search-header.tsx | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index cfe0091a8..cceef86cf 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1210,22 +1210,26 @@ export function MasterSearchHeader({ {secondaryFooterChip.longLabel} ) : null} - {hasScopeFooterChip && !usesScopeSheet && scopeOpen ? ( -
-
- Document scope - {scopeSummary} -
- {scopePreview ? ( -

{scopePreview}

- ) : null} - {renderScopeRows()} -
+
+ ) : null} + {/* Rendered as a sibling of the chip row (not nested inside it) so the "+" + menu's "Set scope" action still opens this popover on screens where the + chip row itself is hidden (documents/forms desktop widths) — the popover + still anchors correctly since the form stays position:fixed/sticky there. */} + {hasScopeFooterChip && !usesScopeSheet && scopeOpen ? ( +
+
+ Document scope + {scopeSummary} +
+ {scopePreview ? ( +

{scopePreview}

) : null} + {renderScopeRows()}
) : null} Date: Fri, 3 Jul 2026 19:42:29 +0800 Subject: [PATCH 05/61] fix(ui): prevent mode-home search composer overlap flash on services/forms at tablet+ The hero-placement composer briefly rendered as an absolute float over the hero heading before the portal lifted it into the hero slot. Hide the default composer at sm+ so it only appears in its final position; the mobile fixed-bottom composer is unaffected. Co-Authored-By: Claude Opus 4.8 --- src/components/clinical-dashboard/master-search-header.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index cceef86cf..ef0025b51 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1072,8 +1072,13 @@ export function MasterSearchHeader({ : usesMobileBottomStyle ? cn( "document-mobile-search-edge fixed z-40 mx-auto max-w-3xl sm:z-20 sm:w-full sm:px-4 sm:py-3 lg:max-w-4xl", + // Hero-placement mode-homes (services/forms) portal the composer into + // the hero from sm up. Hide the default (non-portaled) composer at sm+ + // so it never briefly flashes as an overlapping float over the hero + // before the portal activates; the mobile fixed-bottom slot still shows + // below sm. Other homes keep a sticky bar until the portal lifts it. isHeroDesktopComposer - ? "forms-hero-search-edge sm:absolute" + ? "sm:hidden" : "sm:sticky sm:top-[calc(4.75rem+env(safe-area-inset-top))]", ) : "sticky top-[calc(4.75rem+env(safe-area-inset-top))] z-20 mx-auto w-full max-w-3xl px-3 py-3 sm:px-4 lg:max-w-4xl", From 2630d5f8465e32c44ca6b9ad34a77a81592d5909 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:34:21 +0800 Subject: [PATCH 06/61] Refine database search and answer rendering flows --- docs/site-map.md | 22 + scripts/generate-site-map.ts | 69 + src/app/favourites/legacy/page.tsx | 18 + src/app/favourites/page.tsx | 19 +- src/app/globals.css | 422 +++- .../mockups/document-search-command/page.tsx | 4 +- src/app/mockups/document-search/page.tsx | 152 +- .../document-search/source-overlays/page.tsx | 12 + .../document-search/source/evidence/page.tsx | 17 + .../mockups/document-search/source/page.tsx | 8 +- .../favourites-command-console/page.tsx | 12 + .../favourites-review-console/page.tsx | 12 + .../mockups/favourites-set-navigator/page.tsx | 12 + src/app/mockups/mockups-layout-client.tsx | 19 +- .../tools-split-clinical-brief/page.tsx | 12 + .../tools-split-compact-sheet/page.tsx | 12 + .../mockups/tools-split-safety-deck/page.tsx | 12 + src/app/services/layout.tsx | 2 +- src/app/services/page.tsx | 29 +- src/components/ClinicalDashboard.tsx | 401 +++- ...age.backup-20260704-tools-redesign.tsx.bak | 1113 +++++++++ src/components/applications-launcher-page.tsx | 1605 +++++++------ .../clinical-dashboard/ClinicalSidebar.tsx | 17 +- .../account-setup-dialog.tsx | 338 +++ .../clinical-dashboard/answer-status.tsx | 5 +- .../clinical-dashboard/auth-panel.tsx | 195 +- .../clinical-dashboard/dashboard-shell.tsx | 24 +- .../clinical-dashboard/differentials-home.tsx | 9 +- .../document-search-results.tsx | 312 +-- .../favourites-command-library-page.tsx | 851 +++++++ .../clinical-dashboard/favourites-hub.tsx | 19 +- .../global-mockup-search-shell.tsx | 181 +- .../master-search-header.tsx | 539 +++-- .../medication-prescribing-workspace.tsx | 17 +- .../clinical-dashboard/mode-action-popup.tsx | 287 ++- .../use-sidebar-collapsed.ts | 7 +- .../favourites-library-redesign-page.tsx | 843 +++++++ .../master-document-flow-mockups.tsx | 1983 +++++++++++++++++ src/components/mode-home-template.tsx | 28 +- .../services/services-navigator-page.tsx | 515 +++++ .../source-overlay-redesign-mockups.tsx | 694 ++++++ .../split-pane-refined-mockups.tsx | 664 ++++++ .../tools-page-mockup-page.tsx | 313 ++- src/components/ui/sheet.tsx | 25 +- tests/ui-smoke.spec.ts | 133 +- tests/ui-tools.spec.ts | 168 +- 46 files changed, 10188 insertions(+), 1963 deletions(-) create mode 100644 src/app/favourites/legacy/page.tsx create mode 100644 src/app/mockups/document-search/source-overlays/page.tsx create mode 100644 src/app/mockups/document-search/source/evidence/page.tsx create mode 100644 src/app/mockups/favourites-command-console/page.tsx create mode 100644 src/app/mockups/favourites-review-console/page.tsx create mode 100644 src/app/mockups/favourites-set-navigator/page.tsx create mode 100644 src/app/mockups/tools-split-clinical-brief/page.tsx create mode 100644 src/app/mockups/tools-split-compact-sheet/page.tsx create mode 100644 src/app/mockups/tools-split-safety-deck/page.tsx create mode 100644 src/components/applications-launcher-page.backup-20260704-tools-redesign.tsx.bak create mode 100644 src/components/clinical-dashboard/account-setup-dialog.tsx create mode 100644 src/components/clinical-dashboard/favourites-command-library-page.tsx create mode 100644 src/components/favourites-page-mockups/favourites-library-redesign-page.tsx create mode 100644 src/components/master-document-flow-mockups.tsx create mode 100644 src/components/services/services-navigator-page.tsx create mode 100644 src/components/source-overlay-redesign-mockups.tsx create mode 100644 src/components/tools-page-mockups/split-pane-refined-mockups.tsx diff --git a/docs/site-map.md b/docs/site-map.md index ad1cd4c3c..4697d38a3 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -10,6 +10,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/differentials/diagnoses/page.tsx`. - `/differentials/presentations` - Presentation workflow stream. Source: `src/app/differentials/presentations/page.tsx`. - `/favourites` - Saved clinical items and sets. Source: `src/app/favourites/page.tsx`. +- `/favourites/legacy` - Route discovered from app directory Source: `src/app/favourites/legacy/page.tsx`. - `/forms` - Forms home and search surface. Source: `src/app/forms/page.tsx`. - `/medications` - Medication index redirect. Source: `src/app/medications/page.tsx`. - `/services` - Services home and search surface. Source: `src/app/services/page.tsx`. @@ -25,6 +26,19 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/?mode=prescribing` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. - `/?mode=tools` - Tools mode. Search kind: `tools`. Query example: `/?mode=tools&q=medications&focus=1&run=1`. +## Mode page index + +| Mode | Home page | Search/results page | Information/detail pages | +| ------------- | -------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Answer | `/?mode=answer` | `/?mode=answer&q=example+question&focus=1&run=1` | Answer, citations, evidence, and source panels render inside the root dashboard shell. | +| Documents | `/?mode=documents` | `/?mode=documents&q=lithium+monitoring&focus=1&run=1` | `/documents/[id]` document viewer and in-document search. | +| Services | `/services` | `/services?q=13YARN&focus=1&run=1` | `/services/[slug]` service record pages. | +| Forms | `/forms` | `/forms?q=transport+forms&focus=1&run=1` | `/forms/[slug]` form record pages. | +| Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | +| Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`. | +| Medication | `/?mode=prescribing` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; `/medications` redirects to medication mode. | +| Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | `/applications` launcher and tool detail panels inside tools mode. | + ## Registry-backed routes - `/services/[slug]` - Registry-backed service detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records. @@ -82,10 +96,15 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/mockups/document-search-evidence-lens` - Route discovered from app directory Source: `src/app/mockups/document-search-evidence-lens/page.tsx`. - `/mockups/document-search-triage-board` - Route discovered from app directory Source: `src/app/mockups/document-search-triage-board/page.tsx`. - `/mockups/document-search/source` - Route discovered from app directory Source: `src/app/mockups/document-search/source/page.tsx`. +- `/mockups/document-search/source-overlays` - Route discovered from app directory Source: `src/app/mockups/document-search/source-overlays/page.tsx`. +- `/mockups/document-search/source/evidence` - Route discovered from app directory Source: `src/app/mockups/document-search/source/evidence/page.tsx`. +- `/mockups/favourites-command-console` - Route discovered from app directory Source: `src/app/mockups/favourites-command-console/page.tsx`. - `/mockups/favourites-command-desk` - Route discovered from app directory Source: `src/app/mockups/favourites-command-desk/page.tsx`. - `/mockups/favourites-hub` - Route discovered from app directory Source: `src/app/mockups/favourites-hub/page.tsx`. - `/mockups/favourites-library-view` - Route discovered from app directory Source: `src/app/mockups/favourites-library-view/page.tsx`. +- `/mockups/favourites-review-console` - Route discovered from app directory Source: `src/app/mockups/favourites-review-console/page.tsx`. - `/mockups/favourites-set-board` - Route discovered from app directory Source: `src/app/mockups/favourites-set-board/page.tsx`. +- `/mockups/favourites-set-navigator` - Route discovered from app directory Source: `src/app/mockups/favourites-set-navigator/page.tsx`. - `/mockups/medication-prescribing` - Route discovered from app directory Source: `src/app/mockups/medication-prescribing/page.tsx`. - `/mockups/mode-dropdown` - Route discovered from app directory Source: `src/app/mockups/mode-dropdown/page.tsx`. - `/mockups/recent-searches-bottom` - Route discovered from app directory Source: `src/app/mockups/recent-searches-bottom/page.tsx`. @@ -93,7 +112,10 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/mockups/settings-search-general` - Route discovered from app directory Source: `src/app/mockups/settings-search-general/page.tsx`. - `/mockups/settings-search-privacy` - Route discovered from app directory Source: `src/app/mockups/settings-search-privacy/page.tsx`. - `/mockups/tools-command-center` - Route discovered from app directory Source: `src/app/mockups/tools-command-center/page.tsx`. +- `/mockups/tools-split-clinical-brief` - Route discovered from app directory Source: `src/app/mockups/tools-split-clinical-brief/page.tsx`. +- `/mockups/tools-split-compact-sheet` - Route discovered from app directory Source: `src/app/mockups/tools-split-compact-sheet/page.tsx`. - `/mockups/tools-split-pane` - Route discovered from app directory Source: `src/app/mockups/tools-split-pane/page.tsx`. +- `/mockups/tools-split-safety-deck` - Route discovered from app directory Source: `src/app/mockups/tools-split-safety-deck/page.tsx`. - `/mockups/tools-task-directory` - Route discovered from app directory Source: `src/app/mockups/tools-task-directory/page.tsx`. - `/mockups/tools-workflow-board` - Route discovered from app directory Source: `src/app/mockups/tools-workflow-board/page.tsx`. diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 6ffa12b24..2c5afe126 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -206,6 +206,74 @@ function renderModeRoutes() { ); } +type ModePageIndexRow = { + mode: string; + home: string; + search: string; + detail: string; +}; + +function renderRouteTable(rows: ModePageIndexRow[]) { + return [ + "| Mode | Home page | Search/results page | Information/detail pages |", + "| --- | --- | --- | --- |", + ...rows.map((row) => `| ${row.mode} | \`${row.home}\` | \`${row.search}\` | ${row.detail} |`), + ]; +} + +function renderModePageIndex() { + return renderRouteTable([ + { + mode: "Answer", + home: appModeHomeHref("answer"), + search: appModeHomeHref("answer", { query: "example question", focus: true, run: true }), + detail: "Answer, citations, evidence, and source panels render inside the root dashboard shell.", + }, + { + mode: "Documents", + home: appModeHomeHref("documents"), + search: appModeHomeHref("documents", { query: "lithium monitoring", focus: true, run: true }), + detail: "`/documents/[id]` document viewer and in-document search.", + }, + { + mode: "Services", + home: appModeHomeHref("services"), + search: appModeHomeHref("services", { query: "13YARN", focus: true, run: true }), + detail: "`/services/[slug]` service record pages.", + }, + { + mode: "Forms", + home: appModeHomeHref("forms"), + search: appModeHomeHref("forms", { query: "transport forms", focus: true, run: true }), + detail: "`/forms/[slug]` form record pages.", + }, + { + mode: "Favourites", + home: appModeHomeHref("favourites"), + search: appModeHomeHref("favourites", { query: "clozapine set", focus: true, run: true }), + detail: "Saved set and saved item detail render inside the favourites page surface.", + }, + { + mode: "Differentials", + home: appModeHomeHref("differentials"), + search: appModeHomeHref("differentials", { query: "acute confusion", focus: true, run: true }), + detail: "`/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`.", + }, + { + mode: "Medication", + home: appModeHomeHref("prescribing"), + search: appModeHomeHref("prescribing", { query: "acamprosate renal dose", focus: true, run: true }), + detail: "`/medications/[slug]`; `/medications` redirects to medication mode.", + }, + { + mode: "Tools", + home: appModeHomeHref("tools"), + search: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), + detail: "`/applications` launcher and tool detail panels inside tools mode.", + }, + ]); +} + function section(title: string, lines: string[]) { return [`## ${title}`, "", ...lines, ""]; } @@ -235,6 +303,7 @@ function renderSiteMapRaw(data = collectSiteMapData()) { productRoutes.map((route) => routeLine(route, routeDescriptions)), ), ...section("Mode/query routes", renderModeRoutes()), + ...section("Mode page index", renderModePageIndex()), ...section("Registry-backed routes", [ bullet( "/services/[slug]", diff --git a/src/app/favourites/legacy/page.tsx b/src/app/favourites/legacy/page.tsx new file mode 100644 index 000000000..e0095f8b6 --- /dev/null +++ b/src/app/favourites/legacy/page.tsx @@ -0,0 +1,18 @@ +import { FavouritesHomePage } from "@/components/clinical-dashboard/favourites-home-page"; + +type LegacyFavouritesPageProps = { + searchParams?: Promise<{ + q?: string | string[]; + }>; +}; + +function firstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function LegacyFavouritesPage({ searchParams }: LegacyFavouritesPageProps) { + const params = searchParams ? await searchParams : {}; + const query = firstSearchParam(params.q)?.trim() ?? ""; + + return ; +} diff --git a/src/app/favourites/page.tsx b/src/app/favourites/page.tsx index 8dd8c0675..d8317ff84 100644 --- a/src/app/favourites/page.tsx +++ b/src/app/favourites/page.tsx @@ -1,18 +1,5 @@ -import { FavouritesHomePage } from "@/components/clinical-dashboard/favourites-home-page"; +import { FavouritesCommandLibraryPage } from "@/components/clinical-dashboard/favourites-command-library-page"; -type FavouritesPageProps = { - searchParams?: Promise<{ - q?: string | string[]; - }>; -}; - -function firstSearchParam(value: string | string[] | undefined) { - return Array.isArray(value) ? value[0] : value; -} - -export default async function FavouritesPage({ searchParams }: FavouritesPageProps) { - const params = searchParams ? await searchParams : {}; - const query = firstSearchParam(params.q)?.trim() ?? ""; - - return ; +export default function FavouritesPage() { + return ; } diff --git a/src/app/globals.css b/src/app/globals.css index 7fc0f9190..4e5cb2726 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -431,7 +431,7 @@ summary::-webkit-details-marker { * here so the whole class stays in one layer. * * The COMPOSER chrome further below (answer-footer-search-*, - * desktop-home-search-*, document-mobile-search-pill) stays INTENTIONALLY + * document-mobile-search-pill) stays INTENTIONALLY * UNLAYERED: its call sites stack utilities from shared ui-primitives * constants that rely on the class winning, and PR #171's frosted rework * left ~40 value conflicts there. Reconciling those is tracked in @@ -504,6 +504,21 @@ summary::-webkit-details-marker { } } +.mode-home-composer-slot { + width: min(100%, clamp(19rem, 90vw, 52rem)); + max-width: calc(100vw - 1rem - var(--safe-area-left) - var(--safe-area-right)); + margin-inline: auto; +} + +.universal-home-search-edge { + width: 100%; +} + +.universal-top-search-edge { + width: min(100%, clamp(19rem, 88vw, 52rem)); + max-width: calc(100vw - 1rem - var(--safe-area-left) - var(--safe-area-right)); +} + .floating-composer-edge { left: max(0.75rem, var(--safe-area-left)); right: max(0.75rem, var(--safe-area-right)); @@ -511,7 +526,7 @@ summary::-webkit-details-marker { } .answer-footer-search-edge { - bottom: max(1.45rem, calc(var(--safe-area-bottom) + 1rem)); + bottom: max(0.45rem, calc(var(--safe-area-bottom) + 0.35rem)); } .answer-footer-search-pill { @@ -532,6 +547,26 @@ summary::-webkit-details-marker { transform 180ms ease; } +.answer-footer-search-backdrop { + pointer-events: none; + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 0; + height: max(6.5rem, calc(var(--safe-area-bottom) + 5.25rem)); + background: linear-gradient( + 180deg, + transparent 0%, + color-mix(in srgb, var(--background) 10%, transparent) 26%, + color-mix(in srgb, var(--background) 32%, transparent) 62%, + color-mix(in srgb, var(--background) 46%, transparent) 100% + ); + backdrop-filter: blur(14px) saturate(130%); + -webkit-backdrop-filter: blur(14px) saturate(130%); + mask-image: linear-gradient(180deg, transparent 0%, black 32%, black 100%); + -webkit-mask-image: linear-gradient(180deg, transparent 0%, black 32%, black 100%); +} + .answer-footer-search-pill:hover { border-color: var(--border-strong); box-shadow: @@ -548,6 +583,297 @@ summary::-webkit-details-marker { 0 20px 48px rgb(16 24 40 / 12%); } +.answer-footer-search-pill-open { + border-color: color-mix(in srgb, var(--clinical-accent) 46%, var(--border-strong)); + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--clinical-accent) 10%, transparent), + 0 8px 20px rgb(16 24 40 / 8%), + 0 22px 52px rgb(16 24 40 / 14%); +} + +.answer-footer-search-pill-open[data-menu-placement="up"] { + border-top-left-radius: 1rem; + border-top-right-radius: 1rem; +} + +.answer-footer-search-pill-open[data-menu-placement="down"] { + border-bottom-left-radius: 1rem; + border-bottom-right-radius: 1rem; +} + +.mode-action-surface { + --mode-action-max-height: min(72dvh, 34rem); + --mode-action-body-max-height: min(54dvh, 24rem); + transform-origin: center bottom; +} + +.mode-action-surface[data-placement="down"] { + transform-origin: center top; +} + +.mode-action-panel { + max-height: var(--mode-action-max-height); + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); +} + +.mode-action-surface[data-placement="up"] .mode-action-panel { + border-bottom-left-radius: 1rem; + border-bottom-right-radius: 1rem; +} + +.mode-action-surface[data-placement="down"] .mode-action-panel { + border-top-left-radius: 1rem; + border-top-right-radius: 1rem; +} + +.mode-action-header { + position: relative; + z-index: 1; + display: grid; + min-height: 4.9rem; + grid-template-areas: "selector summary close"; + grid-template-columns: minmax(13.5rem, 0.42fr) minmax(0, 1fr) 3.25rem; + align-items: center; + gap: 0.75rem; + padding: 0.72rem 0.78rem; + color: #fff; + background: + radial-gradient(circle at 18% 0%, rgb(255 255 255 / 18%), transparent 34%), + linear-gradient( + 135deg, + color-mix(in srgb, var(--clinical-accent) 88%, #073d4a 12%) 0%, + color-mix(in srgb, var(--primary-700) 86%, #ffffff 14%) 100% + ); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 18%), + inset 0 -1px 0 rgb(255 255 255 / 10%); +} + +.mode-action-selector-shell { + position: relative; + grid-area: selector; + min-width: 0; +} + +.mode-action-mode-button { + display: grid; + min-height: 3.15rem; + width: 100%; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + border: 1px solid rgb(255 255 255 / 30%); + border-radius: 999px; + background: rgb(255 255 255 / 10%); + padding: 0.42rem 0.72rem; + color: #fff; + font-size: 1rem; + font-weight: 850; + line-height: 1; + text-align: left; + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 18%), + 0 8px 22px rgb(0 35 44 / 14%); + transition: + background-color 160ms ease, + border-color 160ms ease; +} + +.mode-action-mode-button:hover { + border-color: rgb(255 255 255 / 42%); + background: rgb(255 255 255 / 14%); +} + +.mode-action-mode-button:disabled { + cursor: default; +} + +.mode-action-mode-button:focus-visible, +.mode-action-close:focus-visible, +.mode-action-mode-option:focus-visible { + outline: 2px solid color-mix(in srgb, #fff 70%, var(--clinical-accent)); + outline-offset: 2px; +} + +.mode-action-mode-icon { + display: grid; + height: 2.1rem; + width: 2.1rem; + flex: 0 0 auto; + place-items: center; + border: 1px solid rgb(255 255 255 / 32%); + border-radius: 0.5rem; + background: rgb(255 255 255 / 12%); + color: #fff; +} + +.mode-action-header-summary { + grid-area: summary; + display: flex; + min-width: 0; + align-items: center; + gap: 1rem; + color: rgb(255 255 255 / 92%); + font-size: 0.98rem; + font-weight: 720; + line-height: 1.2; +} + +.mode-action-header-divider { + display: block; + height: 2.15rem; + width: 1px; + flex: 0 0 auto; + background: rgb(255 255 255 / 28%); +} + +.mode-action-close { + grid-area: close; + display: grid; + height: 2.7rem; + width: 2.7rem; + place-items: center; + justify-self: center; + border: 1px solid rgb(255 255 255 / 36%); + border-radius: 999px; + background: rgb(255 255 255 / 10%); + color: #fff; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 16%); + transition: + background-color 160ms ease, + border-color 160ms ease; +} + +.mode-action-close:hover { + border-color: rgb(255 255 255 / 48%); + background: rgb(255 255 255 / 16%); +} + +.mode-action-body { + max-height: var(--mode-action-body-max-height); + overflow-y: auto; + overscroll-behavior: contain; +} + +.mode-action-mode-menu { + position: absolute; + top: calc(100% + 0.45rem); + left: 0; + z-index: 20; + width: min(22rem, calc(100vw - 2rem)); + max-height: min(19rem, var(--mode-action-body-max-height)); + overflow-y: auto; + border: 1px solid var(--border-lux); + border-radius: 0.75rem; + background: color-mix(in srgb, var(--surface-lux) 96%, transparent); + padding: 0.4rem; + color: var(--text); + box-shadow: 0 18px 48px rgb(15 37 48 / 18%); + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); +} + +.mode-action-mode-option { + display: grid; + min-height: 3.2rem; + width: 100%; + grid-template-columns: 2.1rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.65rem; + border: 1px solid transparent; + border-radius: 0.5rem; + padding: 0.45rem 0.55rem; + text-align: left; + color: var(--text-muted); + transition: + background-color 150ms ease, + border-color 150ms ease, + color 150ms ease; +} + +.mode-action-mode-option:hover { + border-color: var(--border); + background: var(--surface-subtle); + color: var(--text); +} + +.mode-action-mode-option-active { + border-color: var(--clinical-accent-border); + background: var(--clinical-accent-soft); + color: var(--text-heading); +} + +.mode-action-mode-option-icon { + display: grid; + height: 2rem; + width: 2rem; + place-items: center; + border: 1px solid var(--clinical-accent-border); + border-radius: 0.5rem; + background: color-mix(in srgb, var(--clinical-accent-soft) 72%, var(--surface-lux)); + color: var(--clinical-accent); +} + +@media (max-width: 430px) { + .mode-action-header { + min-height: 5.7rem; + grid-template-areas: + "selector close" + "summary summary"; + grid-template-columns: minmax(0, 1fr) 2.85rem; + gap: 0.5rem 0.65rem; + padding: 0.62rem; + } + + .mode-action-mode-button { + min-height: 2.85rem; + gap: 0.55rem; + padding-inline: 0.58rem; + font-size: 0.95rem; + } + + .mode-action-mode-icon { + height: 1.95rem; + width: 1.95rem; + } + + .mode-action-header-summary { + gap: 0; + padding-inline: 0.18rem; + font-size: 0.83rem; + white-space: normal; + } + + .mode-action-header-summary .truncate { + white-space: normal; + } + + .mode-action-header-divider { + display: none; + } + + .mode-action-close { + height: 2.55rem; + width: 2.55rem; + } + + .mode-action-mode-menu { + width: min(20rem, calc(100vw - 1.5rem)); + } +} + +@media (prefers-reduced-motion: no-preference) { + .mode-action-surface[data-placement="up"] { + animation: mode-action-fold-up 170ms cubic-bezier(0.22, 1, 0.36, 1) both; + } + + .mode-action-surface[data-placement="down"] { + animation: mode-action-fold-down 170ms cubic-bezier(0.22, 1, 0.36, 1) both; + } +} + .answer-footer-search-action { height: 2.75rem; width: 2.75rem; @@ -647,7 +973,7 @@ summary::-webkit-details-marker { .dashboard-composer-edge.answer-footer-search-edge { left: 50%; right: auto; - width: min(calc(100vw - 16px - var(--safe-area-left) - var(--safe-area-right)), 400px); + width: min(calc(100vw - 8px - var(--safe-area-left) - var(--safe-area-right)), 400px); transform: translateX(-50%); } @@ -661,51 +987,23 @@ summary::-webkit-details-marker { bottom: max(1.45rem, calc(var(--safe-area-bottom) + 1rem)); } -.desktop-home-search-pill { - min-height: 3.45rem; - gap: 0.375rem; - border-color: color-mix(in srgb, var(--border-strong) 80%, transparent); - background: color-mix(in srgb, var(--surface) 96%, transparent); - padding-inline: 0.5rem; - box-shadow: - 0 1px 2px rgb(16 24 40 / 4%), - 0 6px 16px rgb(16 24 40 / 6%), - 0 18px 40px rgb(16 24 40 / 9%); - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); - transition: - border-color 180ms ease, - box-shadow 180ms ease; -} - -.desktop-home-search-pill:hover { - border-color: var(--border-strong); - box-shadow: - 0 1px 2px rgb(16 24 40 / 5%), - 0 8px 20px rgb(16 24 40 / 8%), - 0 22px 48px rgb(16 24 40 / 11%); -} - -.desktop-home-search-pill:focus-within { - border-color: var(--clinical-accent); - box-shadow: - 0 0 0 3px color-mix(in srgb, var(--clinical-accent) 16%, transparent), - 0 8px 20px rgb(16 24 40 / 8%), - 0 18px 40px rgb(16 24 40 / 9%); -} +@media (min-width: 640px) { + .mode-home-composer-slot { + width: min(100%, clamp(28rem, 74vw, 54rem)); + max-width: calc(100vw - 3rem - var(--safe-area-left) - var(--safe-area-right)); + } -.desktop-home-search-input { - font-size: 1rem; - font-weight: 560; -} + .universal-top-search-edge { + width: min(100%, clamp(28rem, 74vw, 54rem)); + max-width: calc(100vw - 3rem - var(--safe-area-left) - var(--safe-area-right)); + } -@media (min-width: 640px) { .floating-composer-edge { bottom: max(1rem, var(--safe-area-bottom)); } .answer-footer-search-edge { - bottom: max(1.25rem, calc(var(--safe-area-bottom) + 0.75rem)); + bottom: max(0.75rem, calc(var(--safe-area-bottom) + 0.5rem)); } .dashboard-composer-edge.answer-footer-search-edge { @@ -754,6 +1052,10 @@ summary::-webkit-details-marker { } @media (max-width: 639px) { + .dashboard-composer-edge.answer-footer-search-edge { + width: min(calc(100vw - 8px - var(--safe-area-left) - var(--safe-area-right)), 400px); + } + .document-mobile-search-edge { left: max(0.375rem, var(--safe-area-left)); right: max(0.375rem, var(--safe-area-right)); @@ -763,8 +1065,8 @@ summary::-webkit-details-marker { .document-mobile-search-edge.answer-footer-search-edge { left: 50%; right: auto; - bottom: max(0.5rem, calc(var(--safe-area-bottom) + 0.375rem)); - width: min(calc(100vw - 16px - var(--safe-area-left) - var(--safe-area-right)), 400px); + bottom: max(0.45rem, calc(var(--safe-area-bottom) + 0.35rem)); + width: min(calc(100vw - 8px - var(--safe-area-left) - var(--safe-area-right)), 400px); transform: translateX(-50%); } @@ -780,6 +1082,14 @@ summary::-webkit-details-marker { } @media (min-width: 1024px) { + .mode-home-composer-slot { + width: min(100%, clamp(36rem, 56vw, 56rem)); + } + + .universal-top-search-edge { + width: min(100%, clamp(36rem, 56vw, 56rem)); + } + .dashboard-composer-edge { left: calc(var(--clinical-sidebar-width, 20rem) + 2rem); right: max(2rem, var(--safe-area-right)); @@ -871,6 +1181,28 @@ summary::-webkit-details-marker { } } +@keyframes mode-action-fold-up { + from { + opacity: 0; + transform: translateY(8px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes mode-action-fold-down { + from { + opacity: 0; + transform: translateY(-8px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + @keyframes shimmer { from { background-position: -150% 0; @@ -884,8 +1216,8 @@ summary::-webkit-details-marker { * Helper classes with no same-property utility conflicts at any call site * live in @layer components so Tailwind utilities can override them. * Audited 2026-07-02: the chrome classes above (edge-glass-header, - * universal-header-*, answer-footer-search-*, *-composer-edge, - * desktop-home-search-*) DO conflict with call-site utilities and stay + * universal-header-*, answer-footer-search-*, *-composer-edge) DO + * conflict with call-site utilities and stay * unlayered deliberately — layering them changes rendered pixels. When * adding a utility to an element carrying one of those classes, check the * class body first; the class wins. diff --git a/src/app/mockups/document-search-command/page.tsx b/src/app/mockups/document-search-command/page.tsx index 1d6af0207..46503a4e8 100644 --- a/src/app/mockups/document-search-command/page.tsx +++ b/src/app/mockups/document-search-command/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; -import { DocumentSearchMockupPage } from "@/components/document-search-mockups"; +import { MasterDocumentSearch } from "@/components/master-document-flow-mockups"; export const metadata: Metadata = { title: "Document Search Command Mockup - Clinical KB", @@ -8,5 +8,5 @@ export const metadata: Metadata = { }; export default function DocumentSearchCommandMockupRoute() { - return ; + return ; } diff --git a/src/app/mockups/document-search/page.tsx b/src/app/mockups/document-search/page.tsx index 24e9f9d2b..ccc03a193 100644 --- a/src/app/mockups/document-search/page.tsx +++ b/src/app/mockups/document-search/page.tsx @@ -1,157 +1,17 @@ -import Image from "next/image"; -import Link from "next/link"; import type { Metadata } from "next"; -import { ArrowRight, FileText, Search, ShieldCheck, Sparkles } from "lucide-react"; +import { Suspense } from "react"; -import { cn } from "@/components/ui-primitives"; +import { MasterDocumentIndex } from "@/components/master-document-flow-mockups"; export const metadata: Metadata = { title: "Document Search Mockups - Clinical KB", - description: "Three runnable document-search UX concepts for Clinical KB document mode.", + description: "Master runnable document-search UX flow for Clinical KB document mode.", }; -const concepts = [ - { - href: "/mockups/document-search-command?mode=documents", - eyebrow: "Production candidate", - title: "Command center", - body: "Compact search, sort, result rows, and an active source preview for fast document lookup.", - image: "/mockups/document-search/source-stack.png", - alt: "Synthetic layered document stack with highlighted abstract source regions.", - icon: Search, - priorities: ["Fast scan", "Sort clarity", "Pinned preview"], - }, - { - href: "/mockups/document-search-evidence-lens?mode=documents", - eyebrow: "Evidence lens", - title: "Source proof in view", - body: "A split workbench that keeps the selected page, table, image, and ranking explanation together.", - image: "/mockups/document-search/evidence-preview.png", - alt: "Synthetic source page connected to abstract table, image, and warning evidence panels.", - icon: ShieldCheck, - priorities: ["Preview first", "Why this result", "Exact evidence"], - }, - { - href: "/mockups/document-search-triage-board?mode=documents", - eyebrow: "Discovery board", - title: "Library triage", - body: "A document-mode home for recent sources, source health, smart facets, and status lanes.", - image: "/mockups/document-search/triage-map.png", - alt: "Synthetic document triage board with abstract grouped source cards and status lanes.", - icon: Sparkles, - priorities: ["Recent work", "Source health", "Facet discovery"], - }, -] as const; - -const focusRing = - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; - -function Pill({ children, active = false }: { children: string; active?: boolean }) { - return ( - - {children} - - ); -} - export default function DocumentSearchMockupsIndexRoute() { return ( -
-
-
-
-
-
- - -

- Document mode UX -

-
-

- Three runnable document search directions -

-

- This page is the review board. Each direction below opens as its own full runnable mockup inside the - shared Clinical KB header and document-mode bottom composer. -

-
-
-

- Open a direction -

-
- Command - Evidence lens - Triage board -
-
-
-
- -
- {concepts.map((concept, index) => { - const Icon = concept.icon; - return ( - -
- {concept.alt} -
-
-
-
- - -

- {concept.eyebrow} -

-
-

- {concept.title} -

-

{concept.body}

-
- {concept.priorities.map((priority, priorityIndex) => ( - - {priority} - - ))} -
-
- - Open full mockup - - -
- - ); - })} -
-
-
+ + + ); } diff --git a/src/app/mockups/document-search/source-overlays/page.tsx b/src/app/mockups/document-search/source-overlays/page.tsx new file mode 100644 index 000000000..c414d5f96 --- /dev/null +++ b/src/app/mockups/document-search/source-overlays/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { SourceOverlayRedesignMockups } from "@/components/source-overlay-redesign-mockups"; + +export const metadata: Metadata = { + title: "Source Overlay Redesign Mockups - Clinical KB", + description: "Document scope and source library overlay redesign mockups for desktop and phone.", +}; + +export default function SourceOverlayRedesignMockupsRoute() { + return ; +} diff --git a/src/app/mockups/document-search/source/evidence/page.tsx b/src/app/mockups/document-search/source/evidence/page.tsx new file mode 100644 index 000000000..6d291518d --- /dev/null +++ b/src/app/mockups/document-search/source/evidence/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { MasterEvidenceDetail } from "@/components/master-document-flow-mockups"; + +export const metadata: Metadata = { + title: "Evidence Detail Mockup - Clinical KB", + description: "Functional evidence object mockup for tables, quotes, images, and source page context.", +}; + +export default function DocumentSearchEvidenceDetailRoute() { + return ( + + + + ); +} diff --git a/src/app/mockups/document-search/source/page.tsx b/src/app/mockups/document-search/source/page.tsx index 3bfaaa123..5143ab6b1 100644 --- a/src/app/mockups/document-search/source/page.tsx +++ b/src/app/mockups/document-search/source/page.tsx @@ -1,17 +1,17 @@ import type { Metadata } from "next"; import { Suspense } from "react"; -import { DocumentSearchLiveOpener } from "@/components/document-search-live-opener"; +import { MasterDocumentReader } from "@/components/master-document-flow-mockups"; export const metadata: Metadata = { - title: "Open Highlighted Document - Clinical KB", - description: "Resolves a document-search mockup result to the live document viewer with a selected source chunk.", + title: "Document Reader Mockup - Clinical KB", + description: "Functional document reader mockup with bundled PDF content, highlights, and evidence inspector.", }; export default function HighlightedDocumentSearchSourceRoute() { return ( - + ); } diff --git a/src/app/mockups/favourites-command-console/page.tsx b/src/app/mockups/favourites-command-console/page.tsx new file mode 100644 index 000000000..bd324500d --- /dev/null +++ b/src/app/mockups/favourites-command-console/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { FavouritesLibraryRedesignPage } from "@/components/favourites-page-mockups/favourites-library-redesign-page"; + +export const metadata: Metadata = { + title: "Favourites Command Console Mockup - Clinical KB", + description: "Library-first favourites mockup with resume-next command workflow.", +}; + +export default function FavouritesCommandConsoleMockupRoute() { + return ; +} diff --git a/src/app/mockups/favourites-review-console/page.tsx b/src/app/mockups/favourites-review-console/page.tsx new file mode 100644 index 000000000..a0aa0b166 --- /dev/null +++ b/src/app/mockups/favourites-review-console/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { FavouritesLibraryRedesignPage } from "@/components/favourites-page-mockups/favourites-library-redesign-page"; + +export const metadata: Metadata = { + title: "Favourites Review Console Mockup - Clinical KB", + description: "Library-first favourites mockup with stronger review and provenance workflow.", +}; + +export default function FavouritesReviewConsoleMockupRoute() { + return ; +} diff --git a/src/app/mockups/favourites-set-navigator/page.tsx b/src/app/mockups/favourites-set-navigator/page.tsx new file mode 100644 index 000000000..b6da91806 --- /dev/null +++ b/src/app/mockups/favourites-set-navigator/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { FavouritesLibraryRedesignPage } from "@/components/favourites-page-mockups/favourites-library-redesign-page"; + +export const metadata: Metadata = { + title: "Favourites Set Navigator Mockup - Clinical KB", + description: "Library-first favourites mockup with workflow-set navigation.", +}; + +export default function FavouritesSetNavigatorMockupRoute() { + return ; +} diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 8a155b33b..2ebc5637c 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -9,11 +9,26 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { const pathname = usePathname(); const isToolsPageMockup = pathname.startsWith("/mockups/tools-"); const isFavouritesPageMockup = pathname.startsWith("/mockups/favourites-"); + const isDocumentSearchMockup = pathname.startsWith("/mockups/document-search"); + const isSourceOverlayRedesignMockup = pathname === "/mockups/document-search/source-overlays"; + const isStandaloneDocumentFlow = + pathname === "/mockups/document-search" || pathname.startsWith("/mockups/document-search/source"); + const documentFlowOwnsMobileChrome = pathname.startsWith("/mockups/document-search/source"); return ( {children} diff --git a/src/app/mockups/tools-split-clinical-brief/page.tsx b/src/app/mockups/tools-split-clinical-brief/page.tsx new file mode 100644 index 000000000..ea580c5ae --- /dev/null +++ b/src/app/mockups/tools-split-clinical-brief/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ToolsSplitPaneRefinedMockup } from "@/components/tools-page-mockups/split-pane-refined-mockups"; + +export const metadata: Metadata = { + title: "Tools Split Clinical Brief Mockup - Clinical KB", + description: "Refined split-pane Tools mockup with clinical brief and mobile popup.", +}; + +export default function ToolsSplitClinicalBriefMockupRoute() { + return ; +} diff --git a/src/app/mockups/tools-split-compact-sheet/page.tsx b/src/app/mockups/tools-split-compact-sheet/page.tsx new file mode 100644 index 000000000..013a78603 --- /dev/null +++ b/src/app/mockups/tools-split-compact-sheet/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ToolsSplitPaneRefinedMockup } from "@/components/tools-page-mockups/split-pane-refined-mockups"; + +export const metadata: Metadata = { + title: "Tools Split Compact Sheet Mockup - Clinical KB", + description: "Refined split-pane Tools mockup with compact mobile action sheet.", +}; + +export default function ToolsSplitCompactSheetMockupRoute() { + return ; +} diff --git a/src/app/mockups/tools-split-safety-deck/page.tsx b/src/app/mockups/tools-split-safety-deck/page.tsx new file mode 100644 index 000000000..6bb3c3846 --- /dev/null +++ b/src/app/mockups/tools-split-safety-deck/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ToolsSplitPaneRefinedMockup } from "@/components/tools-page-mockups/split-pane-refined-mockups"; + +export const metadata: Metadata = { + title: "Tools Split Safety Deck Mockup - Clinical KB", + description: "Refined split-pane Tools mockup with safety-focused clinical organisation.", +}; + +export default function ToolsSplitSafetyDeckMockupRoute() { + return ; +} diff --git a/src/app/services/layout.tsx b/src/app/services/layout.tsx index 0f0fa36cb..462b9148f 100644 --- a/src/app/services/layout.tsx +++ b/src/app/services/layout.tsx @@ -4,7 +4,7 @@ import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search export default function ServicesLayout({ children }: { children: ReactNode }) { return ( - + {children} ); diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 8e2915e08..bc4fbff80 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -1,5 +1,30 @@ +import { Suspense } from "react"; + import { ServicesHomePage } from "@/components/services/services-home-page"; +import { ServicesNavigatorPage } from "@/components/services/services-navigator-page"; + +type ServicesSearchParams = Promise<{ [key: string]: string | string[] | undefined }>; + +function readFirstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function ServicesIndexRoute({ searchParams }: { searchParams: ServicesSearchParams }) { + const resolvedSearchParams = await searchParams; + const query = ( + readFirstSearchParam(resolvedSearchParams.q) ?? + readFirstSearchParam(resolvedSearchParams.query) ?? + "" + ).trim(); + const hasSubmittedSearch = resolvedSearchParams.run === "1" && query.length > 0; + + if (!hasSubmittedSearch) { + return ; + } -export default function ServicesIndexRoute() { - return ; + return ( + }> + + + ); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 8643c16db..fa98f53c3 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -9,12 +9,15 @@ import { BookOpen, CheckCircle2, ChevronDown, + ChevronRight, CircleUserRound, + Clock3, ClipboardCheck, Copy, ExternalLink, FileImage, FileText, + FolderOpen, Globe2, HelpCircle, Heart, @@ -23,6 +26,7 @@ import { ListChecks, Loader2, LogOut, + Mail, LockKeyhole, Palette, PanelTop, @@ -43,7 +47,16 @@ import { Wrench, X, } from "lucide-react"; -import { type CSSProperties, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type CSSProperties, + type FormEvent, + type RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { AccessibleTable } from "@/components/AccessibleTable"; import { DocumentOrganizationBadges, @@ -89,6 +102,7 @@ import { import { useAuthSession } from "@/lib/supabase/client"; import { SafeBoldText } from "@/components/SafeBoldText"; import { Sheet } from "@/components/ui/sheet"; +import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; 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"; @@ -2021,23 +2035,15 @@ function DocumentDrawer({ }, [documents]); 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 sourcePdfCount = useMemo( + () => + documents.filter((document) => { + const typeText = `${document.file_type} ${document.file_name}`.toLowerCase(); + return documentStatusMatchesFilter(document, statusFilter) && typeText.includes("pdf"); + }).length, + [documents, statusFilter], + ); const filtered = documents .filter((document) => { @@ -2101,16 +2107,46 @@ function DocumentDrawer({ if (mode !== "recent") return 0; return new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime(); }); + const availableDocumentCount = mode === "source" ? sourcePdfCount : (pagination?.total ?? documents.length); + const statusTitle = + mode === "recent" + ? `${availableDocumentCount.toLocaleString()} recent source${availableDocumentCount === 1 ? "" : "s"}` + : mode === "source" + ? `${availableDocumentCount.toLocaleString()} source PDF${availableDocumentCount === 1 ? "" : "s"}` + : isAdminMode + ? `${statusFilterLabel(statusFilter)}: ${filtered.length.toLocaleString()} shown` + : `${availableDocumentCount.toLocaleString()} indexed source${availableDocumentCount === 1 ? "" : "s"}`; + const statusHelper = + availableDocumentCount === 0 + ? mode === "recent" + ? "Recent source rows will appear here after indexing." + : mode === "source" + ? "Indexed PDF source rows will appear below." + : "Indexed source rows will appear below." + : mode === "recent" + ? "Continue reading from the most recently updated sources." + : mode === "source" + ? "Open original PDF source documents." + : "Search and filter to open indexed clinical sources."; return (
-
-
-

{modeLabel}

-

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

+
+ + +
+

{statusTitle}

+

{statusHelper}

+ + {filtered.length.toLocaleString()} shown +
{/* Dynamic Browse Library Filters */} -
+
setSelectedSite(e.target.value)} - className="w-full mt-1 px-2.5 py-1.5 text-xs rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text)] focus:border-[color:var(--primary)] focus:outline-none" + className={cn(fieldControlPlain, "mt-1 h-10 text-xs font-semibold shadow-none sm:h-9")} aria-label="Filter by site" > @@ -2161,7 +2198,7 @@ function DocumentDrawer({ setSelectedPopulation(e.target.value)} - className="w-full mt-1 px-2.5 py-1.5 text-xs rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text)] focus:border-[color:var(--primary)] focus:outline-none" + className={cn(fieldControlPlain, "mt-1 h-10 text-xs font-semibold shadow-none sm:h-9")} aria-label="Filter by population" > @@ -2506,7 +2543,41 @@ export function SettingsDialog({ onOpenGuide: () => void; }) { const closeButtonRef = useRef(null); + const settingsEmailInputRef = useRef(null); const currentThemeLabel = theme === "dark" ? "Dark" : "Light"; + const auth = useAuthSession(); + const [settingsEmail, setSettingsEmail] = useState(""); + const [emailEntryOpen, setEmailEntryOpen] = useState(false); + const [settingsEmailAttempted, setSettingsEmailAttempted] = useState(false); + const [accountNotice, setAccountNotice] = useState(null); + const settingsAuthBusy = auth.status === "loading"; + const signedOutAccount = !identity.signedIn; + + async function submitSettingsEmail(event: FormEvent) { + event.preventDefault(); + if (!settingsEmail.trim()) return; + setAccountNotice(null); + setSettingsEmailAttempted(true); + await auth.signInWithEmail(settingsEmail.trim()); + } + + function openSettingsEmailEntry() { + setEmailEntryOpen(true); + setAccountNotice(null); + } + + function chooseSettingsProvider(provider: string) { + setAccountNotice(`${provider} sign-in is a placeholder for now. Continue with email to use this workspace.`); + } + + useEffect(() => { + if (!emailEntryOpen) return; + const focusFrame = window.requestAnimationFrame(() => { + settingsEmailInputRef.current?.focus({ preventScroll: true }); + }); + return () => window.cancelAnimationFrame(focusFrame); + }, [emailEntryOpen]); + const settingSections = [ { title: "Account", @@ -2606,7 +2677,7 @@ export function SettingsDialog({ -
+

-
+
+

+ Clinical Guide account +

- - {identity.initials} + + {signedOutAccount ? : identity.initials} {identity.signedIn ? ( ) : null}
-

- Clinical context -

-

+

{identity.displayName}

-

- Consultant psychiatrist, Western Australia +

+ {signedOutAccount ? "Sign in or create an account" : "Consultant psychiatrist, Western Australia"}

-
- - -
+ {signedOutAccount ? ( +
+ + +
+ ) : ( +
+ + +
+ )}
- + + {signedOutAccount ? ( +
+
+ + +
+ + {emailEntryOpen ? ( +
+ + +
+ ) : null} + +
+ + or continue with + +
+ +
+ chooseSettingsProvider("Apple")} /> + chooseSettingsProvider("Google")} /> + chooseSettingsProvider("Microsoft")} /> + +
+ +

+ + Accounts save preferences and search history. Do not enter PHI. +

+ + {(accountNotice || !auth.isConfigured || (settingsEmailAttempted && auth.error)) && ( +

+ {accountNotice ?? + (settingsEmailAttempted ? auth.error : null) ?? + "Supabase browser authentication is not configured for account sign-in."} +

+ )} +
+ ) : ( + + )}
-
+
@@ -2702,6 +2882,62 @@ function SettingsChip({ label }: { label: string }) { ); } +function SettingsProviderRow({ + provider, + onClick, +}: { + provider: "Apple" | "Google" | "Microsoft" | "email"; + onClick: () => void; +}) { + const label = provider === "email" ? "Use email instead" : provider; + + return ( + + ); +} + +function SettingsProviderMark({ provider }: { provider: "Apple" | "Google" | "Microsoft" }) { + if (provider === "Microsoft") { + return ( +
); } -function PinnedSection({ - pinnedApps, - selectedId, - onSelect, - onTogglePin, - copy, +function FilterTabs({ + activeFilter, + onFilterChange, }: { - pinnedApps: LauncherApp[]; - selectedId: string; - onSelect: (id: string) => void; - onTogglePin: (id: string) => void; - copy: LauncherCopy; + activeFilter: LauncherFilter; + onFilterChange: (filter: LauncherFilter) => void; }) { return ( -
-
-
- - Pinned -
- {pinnedApps.length} pinned + <> +
+ {desktopFilters.map((filter) => { + const active = filter.id === activeFilter || (filter.id === "all" && activeFilter === "more"); + return ( + + ); + })}
-
- {pinnedApps.map((app) => { - const selected = selectedId === app.id; +
+ {mobileFilters.map((filter) => { + const active = filter.id === activeFilter || (filter.id === "all" && activeFilter === "saved"); return ( -
onFilterChange(filter.id)} className={cn( - "grid w-full grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center gap-2 px-3 py-3 text-left transition hover:bg-[color:var(--surface-subtle)] sm:gap-3", - selected && "bg-[color:var(--clinical-accent-soft)]/55", + "inline-flex min-h-7 shrink-0 items-center justify-center gap-0.5 rounded-lg border px-2 text-[9px] font-bold transition", + active + ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)]" + : "border-[color:var(--border)] bg-[color:var(--surface-lux)] text-[color:var(--text-muted)]", + focusRing, )} > - - - -
+ {filter.label} + {filter.hasMenu ? : null} + ); })}
-
+ ); } -function ApplicationRow({ +function ToolCard({ app, selected, - pinned, onSelect, - onTogglePin, }: { app: LauncherApp; selected: boolean; - pinned: boolean; onSelect: (id: string) => void; - onTogglePin: (id: string) => void; }) { + const externalProps = app.external ? { target: "_blank", rel: "noopener noreferrer" } : {}; return ( -
{ + event.preventDefault(); + onSelect(app.id); + }} className={cn( - "grid min-h-[72px] grid-cols-[auto_minmax(0,1fr)_7rem_6rem_5.5rem_auto] items-center gap-3 border-t border-[color:var(--border)] px-3 py-3 transition first:border-t-0", - selected && - "rounded-lg border border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)]/55 shadow-[var(--glow-soft)]", - !selected && "hover:bg-[color:var(--surface-subtle)]", + "group grid min-h-[9.25rem] grid-cols-[auto_minmax(0,1fr)_auto] gap-4 rounded-lg border bg-[color:var(--surface-lux)] p-4 text-left shadow-[var(--shadow-card)] transition hover:-translate-y-0.5 hover:border-[color:var(--clinical-accent-border)] hover:shadow-[var(--shadow-soft)] motion-reduce:hover:translate-y-0", + selected + ? app.id === "risk-safety" + ? "border-red-200 bg-red-50/45" + : "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)]/50" + : "border-[color:var(--border)]", + focusRing, )} - data-testid={`application-row-${app.id}`} - {...{ href: app.href }} + {...externalProps} > - - - -
+ + Best for: {app.bestFor} + + + + + + + {app.actionLabel} + + + ); } -function MobileApplicationRow({ +function MobileToolRow({ app, selected, onSelect, @@ -572,220 +667,217 @@ function MobileApplicationRow({ onSelect: (id: string) => void; }) { return ( - + ); } -function DetailPanel({ - app, - pinned, - onTogglePin, - onClose, - headingId, - copy, - testId = "selected-application-panel", - variant = "inline", +function DetailSection({ + icon: Icon, + title, + children, + compact, }: { - app: LauncherApp; - pinned: boolean; - onTogglePin: (id: string) => void; - onClose?: () => void; - headingId?: string; - copy: LauncherCopy; - testId?: string; - variant?: "inline" | "sheet"; + icon: LucideIcon; + title: string; + children: React.ReactNode; + compact?: boolean; }) { - const related = app.relatedIds.map(appById); - return ( - +
); } @@ -803,305 +895,158 @@ export function ApplicationsLauncherWorkspace({ query: controlledQuery, onQueryChange, desktopComposerSlotId, - showDetailPanel = true, className, }: ApplicationsLauncherWorkspaceProps) { const [uncontrolledQuery, setUncontrolledQuery] = useState(""); - const [activeFilter, setActiveFilter] = useState<(typeof filterOptions)[number]["id"]>("all"); - const [selectedId, setSelectedId] = useState("clinical-kb-search"); - const [pinnedIds, setPinnedIds] = useState(seedPinnedIds); - const [mobileDetailOpen, setMobileDetailOpen] = useState(false); - const [desktopViewport, setDesktopViewport] = useState(false); + const [activeFilter, setActiveFilter] = useState("all"); + const [selectedId, setSelectedId] = useState(() => initialToolId(controlledQuery)); + const [detailOpen, setDetailOpen] = useState(variant === "dashboard-tools"); const isDashboardTools = variant === "dashboard-tools"; const copy = isDashboardTools ? dashboardToolsLauncherCopy : standaloneLauncherCopy; const query = controlledQuery ?? uncontrolledQuery; - 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 === "pinned" - ? pinnedIds.includes(app.id) - : activeFilter === "review_due" - ? app.status === "review_due" - : activeFilter === "source_backed" - ? app.sourceBacked - : true); + activeFilter === "more" || + (activeFilter === "saved" ? app.area === "saved" : app.area === activeFilter); const matchesQuery = !normalizedQuery || - [app.title, app.description, app.workflow, app.detail, areaLabels[app.area]].some((value) => - value.toLowerCase().includes(normalizedQuery), - ); + [app.title, app.mobileTitle, app.description, app.bestFor, app.detail, areaLabels[app.area], ...app.keywords] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(normalizedQuery); return matchesFilter && matchesQuery; }); - }, [activeFilter, normalizedQuery, pinnedIds]); + }, [activeFilter, normalizedQuery]); - useEffect(() => { - if (!isDashboardTools) return undefined; - const media = window.matchMedia("(min-width: 1024px)"); - const update = () => setDesktopViewport(media.matches); - update(); - media.addEventListener("change", update); - return () => media.removeEventListener("change", update); - }, [isDashboardTools]); - - 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); - } - } + const effectiveSelectedId = filteredApps.some((app) => app.id === selectedId) + ? selectedId + : (filteredApps[0]?.id ?? selectedId); + const selectedApp = appById(effectiveSelectedId); function updateQuery(nextQuery: string) { - if (controlledQuery === undefined) { - setUncontrolledQuery(nextQuery); - } + if (controlledQuery === undefined) setUncontrolledQuery(nextQuery); onQueryChange?.(nextQuery); } - function submitFooterSearch(event: FormEvent) { - event.preventDefault(); - const firstMatch = filteredApps[0]; - if (firstMatch) selectApplication(firstMatch.id); + function openTool(id: string) { + setSelectedId(id); + setDetailOpen(true); } - const workspace = ( - <> - {isDashboardTools ? ( -
-
- - {desktopComposerSlotId ? ( -
- ) : null} -
- - + function submitSearch() { + if (filteredApps[0]) openTool(filteredApps[0].id); + } -
- - {normalizedQuery ? ( - - ) : null} -
-
- ) : ( -
- - - -

+ return ( +
+
+ + + +
+

{copy.heading}

-

+

{copy.description}

-
- - -
-
- - - -
-
- )} +

-
-
- +
+ + + ) : ( + + )} -
-
- -

{copy.allSectionLabel}

-
- -
- {copy.allColumnLabel} - Last used - Status - Action - -
- - {filteredApps.length === 0 ? ( -
-

{copy.emptyTitle}

-

{copy.emptyBody}

-
- ) : ( - <> -
- {filteredApps.map((app) => ( - - ))} -
- -
- {filteredApps.map((app) => ( - - ))} -
- - )} - -

- Showing {filteredApps.length > 0 ? "1" : "0"} to {filteredApps.length} of {launcherApps.length}{" "} - {copy.countNoun} -

-
+
+
+ +
+
+ +
+
+ -
-
-

Recent activity

- View all -
-
- {recentActivity.slice(0, 3).map((item) => { - const Icon = item.icon; - return ( - - ); - })} +
+
+
+

{copy.allSectionLabel}

+
+
+ +
+ Sort by + A to Z +
-
+
- {(!isDashboardTools || (showDetailPanel && desktopViewport)) && ( -
- + {filteredApps.length === 0 ? ( +
+

{copy.emptyTitle}

+

{copy.emptyBody}

+ ) : ( + <> +
+ {filteredApps.map((app) => ( + + ))} +
+
+ {filteredApps.map((app) => ( + + ))} +
+ )} -
- setMobileDetailOpen(false)} - labelledBy="selected-application-sheet-heading" - closeLabel={copy.closeSelectedLabel} - 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" - copy={copy} - testId="selected-application-sheet-panel" - variant="sheet" - /> - - - ); +

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

+ - if (isDashboardTools) { - return ( -
- {workspace} -
- ); - } + {isDashboardTools ? ( + + ) : null} - return
{workspace}
; + setDetailOpen(false)} /> + + ); } export function ApplicationsLauncherPage() { diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index 2e759b8f2..e8d5b8b76 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -47,7 +47,8 @@ export function deriveSidebarIdentity(email: string | null | undefined): Sidebar } function accountProfileLabel(identity: SidebarIdentity) { - return `${identity.initials} ${identity.displayName} ${identity.detail}. Open account profile`; + const action = identity.signedIn ? "Open account profile" : "Set up workspace"; + return `${identity.initials} ${identity.displayName} ${identity.detail}. ${action}`; } const sidebarToolItems = [ @@ -74,6 +75,7 @@ export function ClinicalSidebarContent({ onPickRecent, onOpenGuide, onOpenSettings, + onOpenAccount, theme, onToggleTheme, onPrefetchApplications, @@ -88,6 +90,7 @@ export function ClinicalSidebarContent({ onPickRecent: (query: string) => void; onOpenGuide: () => void; onOpenSettings: () => void; + onOpenAccount: () => void; theme: ResolvedTheme; onToggleTheme: () => void; onPrefetchApplications?: () => void; @@ -264,7 +267,7 @@ export function ClinicalSidebarContent({ type="button" onClick={() => { onNavigate?.(); - window.requestAnimationFrame(onOpenSettings); + window.requestAnimationFrame(onOpenAccount); }} data-testid="sidebar-account-settings" className="mt-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-left shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--clinical-accent-soft)]/40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" @@ -299,6 +302,7 @@ export function ClinicalDesktopSidebar({ onPickRecent, onOpenGuide, onOpenSettings, + onOpenAccount, theme, onToggleTheme, onPrefetchApplications, @@ -313,6 +317,7 @@ export function ClinicalDesktopSidebar({ onPickRecent: (query: string) => void; onOpenGuide: () => void; onOpenSettings: () => void; + onOpenAccount: () => void; theme: ResolvedTheme; onToggleTheme: () => void; onPrefetchApplications: () => void; @@ -449,10 +454,10 @@ export function ClinicalDesktopSidebar({
+ +
+
+
+ +

+ Set up your workspace +

+

+ Sync source preferences, search history, and clinical defaults across devices. +

+
+ + + + + +
+
+ + or continue with + +
+ +
+ {(["Apple", "Google", "Microsoft"] as const).map((provider) => ( + chooseProvider(provider)} /> + ))} +
+
+ +
+
+
+
+

+ Source preferences +

+ +
+

+ Choose the sources you rely on most. +

+
+ + + Saved + +
+ +
+ {sourcePreferences.map((source) => { + const Icon = source.icon; + const selected = selectedSources.has(source.id); + return ( + + ); + })} +
+
+ +
+
+
+ +

+ Security summary +

+
+ + + Verified + +
+ +
+ {securitySummary.map((item) => { + const Icon = item.icon; + return ( +
+ + + + {item.label} + + + {item.detail} + + + +
+ ); + })} +
+
+ + {statusMessage ? ( +

+ {statusMessage} +

+ ) : null} + +

+ Already have an account?{" "} + +

+
+
+ + + ); +} + +function ProviderButton({ provider, onClick }: { provider: Provider; onClick: () => void }) { + return ( + + ); +} + +function ProviderMark({ provider }: { provider: Provider }) { + if (provider === "Microsoft") { + return ( +
diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index bbe3efd66..188b38846 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -68,7 +68,7 @@ import { } from "@/components/ui-primitives"; import { type AnswerRenderModel, type SourceLink } from "@/lib/answer-render-policy"; import { documentCitationHref, formatCitationLabel, formatCompactCitationLabel } from "@/lib/citations"; -import { extractSafetyFindings, formatSafetyFindingLabel } from "@/lib/clinical-safety"; +import { extractSafetyFindings, formatSafetyFindingLabel, sortSafetyFindingsBySeverity, type SafetyFinding, type SafetyFindingKind } from "@/lib/clinical-safety"; import { normalizeSourceMetadata, sourceStatusLabel } from "@/lib/source-metadata"; import { normalizeExtractedGlyphs, @@ -106,12 +106,11 @@ export function answerSupportPriority( safetyFindings: ReturnType, options: { grounded: boolean; weakEvidence: boolean }, ): AnswerSupportPriority | null { - const firstSafetyFinding = safetyFindings[0]; + const firstSafetyFinding = sortSafetyFindingsBySeverity(safetyFindings)[0]; if (firstSafetyFinding) { return { - title: "Priority", + title: "Safety findings", detail: formatSafetyFindingLabel(firstSafetyFinding), - sourceLabel: "S1", tone: "caution", }; } @@ -147,8 +146,11 @@ export function AnswerSupportSummaryCard({ evidenceAvailable, clinicalTriggerRef, evidenceTriggerRef, + safetyTriggerRef, + safetyFindingsCount = 0, onOpenClinicalNotes, onOpenEvidence, + onOpenSafetyFindings, }: { priority: AnswerSupportPriority | null; clinicalCount: number; @@ -157,12 +159,16 @@ export function AnswerSupportSummaryCard({ evidenceAvailable: boolean; clinicalTriggerRef?: RefObject; evidenceTriggerRef?: RefObject; + safetyTriggerRef?: RefObject; + safetyFindingsCount?: number; onOpenClinicalNotes: () => void; onOpenEvidence: () => void; + onOpenSafetyFindings?: () => void; }) { const supportRowCount = Number(clinicalAvailable) + Number(evidenceAvailable); const supportButtonClass = "grid min-h-[72px] grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 px-3 py-3 text-left transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]"; + const safetyInteractive = Boolean(onOpenSafetyFindings && safetyFindingsCount > 0); return (
{priority ? ( -
- -
-

{priority.title}

-

{priority.detail}

+ + + {priority.title} + {priority.detail} + + + {safetyFindingsCount} + + + + ) : ( +
+ +
+

{priority.title}

+

{priority.detail}

+
+ {priority.sourceLabel ? ( + {priority.sourceLabel} + ) : null}
- {priority.sourceLabel ? ( - {priority.sourceLabel} - ) : null} -
+ ) ) : null} {supportRowCount > 0 ? ( @@ -573,19 +609,22 @@ function clinicalNotesRowsForTab(sections: ClinicalDetailSection[], tab: Clinica for (const section of sections) { const sectionText = `${section.title} ${section.items.join(" ")}`.toLowerCase(); + const isVerifySourceReview = section.id === "verify-source"; + if (isVerifySourceReview && tab !== "safety") continue; const hasMonitoringText = (tab === "actions" || tab === "essentials") && /\b(monitor|screen|level|fbc|anc|metabolic|renal|thyroid|function)\b/i.test(sectionText); const hasSafetyText = tab === "safety" && /\b(toxicity|toxic|urgent|caution|contraindication|red flag|escalat|warning|review due)\b/i.test(sectionText); - if (!meta.sectionIds.includes(section.id) && !hasMonitoringText) { + if (!isVerifySourceReview && !meta.sectionIds.includes(section.id) && !hasMonitoringText) { if (!hasSafetyText) continue; } if (tab === "essentials" && section.id === "action" && rows.length >= 2) { continue; } - const tone: ClinicalNotesRow["tone"] = section.id === "escalation" || section.id === "cautions" ? "warn" : "safe"; + const tone: ClinicalNotesRow["tone"] = + section.id === "escalation" || section.id === "cautions" || isVerifySourceReview ? "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; @@ -627,9 +666,10 @@ function clinicalNotesDetailSectionsForAnswer(answer: RagAnswer, viewMode: Answe const sections = viewMode === "high_yield" ? buildHighYieldClinicalOutputSections(answer) : buildClinicalOutputSections(answer); const primaryAnswer = plainAnswerText(answer.answer); + const keepVerifySource = answer.answerQualityTier === "source_only" || answer.grounded === false; return sortClinicalDetailSections( sections - .filter((section) => section.id !== "verify-source" && section.id !== "bottom-line") + .filter((section) => (keepVerifySource || section.id !== "verify-source") && section.id !== "bottom-line") .map((section) => ({ ...section, items: displayItemsForClinicalDetailSection(section, primaryAnswer, false), @@ -840,54 +880,63 @@ export function ClinicalNotesChecklistPanel({ ); } -export function SafetyFindingsPanel({ findings }: { findings: ReturnType }) { +function safetyFindingKindTone(kind: SafetyFindingKind) { + return kind === "contraindication" || kind === "red_flag" ? toneDanger : toneWarning; +} + +function SafetyFindingRowIcon({ kind }: { kind: SafetyFindingKind }) { + if (kind === "contraindication" || kind === "red_flag") { + return ; + } + return ; +} + +export function SafetyFindingsListContent({ findings }: { findings: SafetyFinding[] }) { if (findings.length === 0) return null; + const sortedFindings = sortSafetyFindingsBySeverity(findings); + return ( -
- -
- {findings.map((finding, index) => ( -
( +
+
- ))} -
-
+

{finding.text}

+
+ + ))} +
); } diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 9c40ed1ac..7e4a21ca2 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -1,9 +1,8 @@ "use client"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { - ArrowUpDown, - Check, ChevronDown, ChevronsRight, Copy, @@ -12,44 +11,61 @@ import { FileText, Folder, Heart, - LayoutGrid, - List, - MessageSquare, MoreVertical, Pill, - Pin, Quote, - Save, Search, ShieldCheck, + Stethoscope, Trash2, X, type LucideIcon, } from "lucide-react"; -import type { ComponentPropsWithoutRef } from "react"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; +import { + FavouritesMobileBrowseRail, + FavouritesSidebar, + useFavouritesNavCollapsed, + type FavouritesViewMode, +} from "@/components/clinical-dashboard/favourites-library-nav"; +import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { cn } from "@/components/ui-primitives"; - -type FavouriteType = "Medication" | "Document" | "Table" | "Saved search" | "Source"; +import { + favouriteItems as prototypeFavouriteItems, + favouriteSets as prototypeFavouriteSets, + favouriteTabs, + type FavouriteItem as PrototypeFavouriteItem, +} from "@/components/clinical-dashboard/favourites-prototype-data"; +import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-saved-registry-favourites"; +import { SearchResultsEmptyState, SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; +import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; +import { favouriteMatchesCommandScopes } from "@/lib/search-command-surface"; + +type FavouriteType = "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form"; +type ViewMode = FavouritesViewMode; +type SortMode = "last-used" | "title" | "type"; type FavouriteItem = { id: string; title: string; description: string; type: FavouriteType; + tabId: string; set: string; evidence: string; lastUsed: string; action: string; href: string; icon: LucideIcon; - selected?: boolean; + pinned?: boolean; }; type FavouriteSet = { + id: string; title: string; count: number; + meta?: string; }; type SourceRecord = { @@ -60,78 +76,6 @@ type SourceRecord = { const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; -const favouriteSets: FavouriteSet[] = [ - { title: "Ward round", count: 2 }, - { title: "Prescribing safety", count: 2 }, - { title: "Clozapine clinic", count: 1 }, -]; - -const favouriteItems: FavouriteItem[] = [ - { - id: "acamprosate-renal-screen", - title: "Acamprosate renal screen", - description: "Medication page · renal cautions / dose notes", - type: "Medication", - set: "Ward round", - evidence: "3 sources", - lastUsed: "Today 08:44", - action: "Open", - href: "/medications/acamprosate", - icon: Pill, - selected: true, - }, - { - id: "lithium-monitoring-guideline", - title: "Lithium monitoring guideline", - description: "PDF · p.4-9 · 2 tables", - type: "Document", - set: "Prescribing safety", - evidence: "PDF verified", - lastUsed: "Today 08:20", - action: "Ask", - href: "/?mode=documents&q=lithium+monitoring&run=1", - icon: FileText, - }, - { - id: "clozapine-monitoring-table", - title: "Clozapine monitoring table", - description: "Saved table · ANC monitoring", - type: "Table", - set: "Clozapine clinic", - evidence: "Table verified", - lastUsed: "Yesterday 16:12", - action: "Open", - href: "/?mode=documents&q=clozapine+monitoring+table&run=1", - icon: Quote, - }, - { - id: "renal-dose-saved-search", - title: "renal dose saved search", - description: "Medicines plus documents / eGFR cautions", - type: "Saved search", - set: "Ward round", - evidence: "Saved query", - lastUsed: "Today 07:55", - action: "Run", - href: "/?mode=answer&q=renal+dose&run=1", - icon: Search, - }, - { - id: "qt-prolongation-quote", - title: "QT prolongation quote", - description: "Source card / prescribing safety", - type: "Source", - set: "Prescribing safety", - evidence: "2 sources", - lastUsed: "Mon 11:03", - action: "Copy", - href: "/?mode=documents&q=QT+prolongation&run=1", - icon: Quote, - }, -]; - -const selectedItem = favouriteItems[0]; - const sourceRecords: SourceRecord[] = [ { title: "NICE CKS - Alcohol dependence", type: "Guideline" }, { title: "BNF - Acamprosate", type: "BNF" }, @@ -141,12 +85,139 @@ const sourceRecords: SourceRecord[] = [ const typeStyles: Record = { Medication: "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", - Document: "border-blue-200 bg-blue-50 text-blue-700", - Table: "border-emerald-200 bg-emerald-50 text-emerald-700", - "Saved search": "border-slate-200 bg-slate-50 text-slate-700", - Source: "border-violet-200 bg-violet-50 text-violet-700", + Document: "border-[color:var(--type-document-border)] bg-[color:var(--type-document-soft)] text-[color:var(--type-document)]", + Table: "border-[color:var(--type-table-border)] bg-[color:var(--type-table-soft)] text-[color:var(--type-table)]", + "Saved search": + "border-[color:var(--type-search-border)] bg-[color:var(--type-search-soft)] text-[color:var(--type-search)]", + Source: "border-[color:var(--type-source-border)] bg-[color:var(--type-source-soft)] text-[color:var(--type-source)]", + Service: "border-[color:var(--type-service-border)] bg-[color:var(--type-service-soft)] text-[color:var(--type-service)]", + Form: "border-[color:var(--type-form-border)] bg-[color:var(--type-form-soft)] text-[color:var(--type-form)]", +}; + +const lastUsedByItemId: Record = { + "acamprosate-renal-screen": "Today 08:44", + "lithium-monitoring-guideline": "Today 08:20", + "clozapine-monitoring-table": "Yesterday 16:12", + "renal-dose-search": "Today 07:55", + "qt-prolongation-quote": "Mon 11:03", +}; + +const pinnedItemIds = new Set(["acamprosate-renal-screen", "lithium-monitoring-guideline"]); + +const typeByPrototypeType: Record = { + medications: "Medication", + documents: "Document", + sources: "Source", + services: "Service", + forms: "Form", +}; + +const fallbackIconByType: Record = { + medications: Pill, + documents: FileText, + sources: Quote, + services: Stethoscope, + forms: FileText, }; +function lastUsedScore(lastUsed: string): number { + const lower = lastUsed.toLowerCase(); + if (lower.startsWith("today")) { + const timeMatch = lastUsed.match(/(\d{1,2}):(\d{2})/); + if (timeMatch) return 100_000 + Number(timeMatch[1]) * 60 + Number(timeMatch[2]); + return 100_000; + } + if (lower.startsWith("yesterday")) return 50_000; + if (lower.startsWith("mon")) return 10_000; + return 1_000; +} + +function isSourceBacked(item: FavouriteItem): boolean { + return Boolean(item.evidence && item.evidence !== "Run" && item.evidence !== "Saved query"); +} + +function toCommandItem(item: PrototypeFavouriteItem): FavouriteItem { + const type = typeByPrototypeType[item.type] ?? (item.primaryAction === "Run" ? "Saved search" : "Source"); + return { + id: item.id, + title: item.title, + description: item.meta, + type, + tabId: item.type, + set: item.set || (item.type === "services" ? "Saved services" : item.type === "forms" ? "Saved forms" : "Unsorted"), + evidence: item.sourceMeta, + lastUsed: lastUsedByItemId[item.id] ?? "Saved", + action: item.primaryAction, + href: item.href, + icon: item.icon ?? fallbackIconByType[item.type], + pinned: pinnedItemIds.has(item.id), + }; +} + +function buildFavouriteSets(items: FavouriteItem[]): FavouriteSet[] { + const presetSets = prototypeFavouriteSets.map((set) => ({ + id: set.id, + title: set.title, + count: items.filter((item) => item.set === set.title).length, + meta: set.meta, + })); + const knownTitles = new Set(presetSets.map((set) => set.title)); + const dynamicSets = Array.from(new Set(items.map((item) => item.set))) + .filter((title) => title && !knownTitles.has(title)) + .map((title) => ({ + id: title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""), + title, + count: items.filter((item) => item.set === title).length, + })); + return [...presetSets, ...dynamicSets].filter((set) => set.count > 0); +} + +function getMostRecentlyUsedItem(items: FavouriteItem[]): FavouriteItem | null { + if (items.length === 0) return null; + return [...items].sort((first, second) => lastUsedScore(second.lastUsed) - lastUsedScore(first.lastUsed))[0] ?? null; +} + +function filterAndSortItems( + items: FavouriteItem[], + { + searchTerm, + selectedTypeId, + selectedSet, + viewMode, + sortMode, + }: { + searchTerm: string; + selectedTypeId: string; + selectedSet: FavouriteSet | null; + viewMode: ViewMode; + sortMode: SortMode; + }, +): FavouriteItem[] { + const normalizedSearch = searchTerm.trim().toLowerCase(); + const effectiveSort: SortMode = viewMode === "recent" ? "last-used" : sortMode; + + return items + .filter((item) => selectedTypeId === "all" || item.tabId === selectedTypeId) + .filter((item) => !selectedSet || item.set === selectedSet.title) + .filter((item) => { + if (viewMode === "source-backed") return isSourceBacked(item); + if (viewMode === "pinned") return item.pinned === true; + return true; + }) + .filter((item) => + normalizedSearch + ? [item.title, item.description, item.type, item.set, item.evidence].some((field) => + field.toLowerCase().includes(normalizedSearch), + ) + : true, + ) + .sort((first, second) => { + if (effectiveSort === "title") return first.title.localeCompare(second.title); + if (effectiveSort === "type") return first.type.localeCompare(second.type) || first.title.localeCompare(second.title); + return lastUsedScore(second.lastUsed) - lastUsedScore(first.lastUsed); + }); +} + function MiniIconTile({ icon: Icon, active = false }: { icon: LucideIcon; active?: boolean }) { return ( & { - children: React.ReactNode; - active?: boolean; - className?: string; -}; - -function ToolbarButton({ children, active = false, className, ...props }: ToolbarButtonProps) { - return ( - - ); -} - -function SidebarSection({ - title, - action, - children, +function ActiveFilterChips({ + searchTerm, + selectedTypeId, + selectedSet, + viewMode, + onClearSearch, + onClearType, + onClearSet, + onClearViewMode, }: { - title: string; - action?: React.ReactNode; - children: React.ReactNode; + searchTerm: string; + selectedTypeId: string; + selectedSet: FavouriteSet | null; + viewMode: ViewMode; + onClearSearch: () => void; + onClearType: () => void; + onClearSet: () => void; + onClearViewMode: () => void; }) { - return ( -
-
-

{title}

- {action} -
- {children} -
- ); -} + const typeLabel = favouriteTabs.find((tab) => tab.id === selectedTypeId)?.label; + const chips: { key: string; label: string; onClear: () => void }[] = []; -function SidebarRow({ - icon: Icon, - label, - meta, - count, - active = false, -}: { - icon: LucideIcon; - label: string; - meta?: string; - count?: number; - active?: boolean; -}) { - return ( - - ); -} + if (searchTerm.trim()) chips.push({ key: "search", label: `Search: ${searchTerm.trim()}`, onClear: onClearSearch }); + if (selectedSet) chips.push({ key: "set", label: selectedSet.title, onClear: onClearSet }); + if (selectedTypeId !== "all" && typeLabel) chips.push({ key: "type", label: typeLabel, onClear: onClearType }); + if (viewMode === "source-backed") chips.push({ key: "view", label: "Source-backed", onClear: onClearViewMode }); + if (viewMode === "pinned") chips.push({ key: "view", label: "Pinned", onClear: onClearViewMode }); + if (viewMode === "recent") chips.push({ key: "view", label: "Recently used", onClear: onClearViewMode }); + + if (chips.length === 0) return null; -function FavouritesSidebar() { return ( - + {chip.label} + + Clear filter + + ))} +
); } -function ContinueStrip() { +function ContinueStrip({ + item, + onSelect, +}: { + item: FavouriteItem; + onSelect: (id: string) => void; +}) { + const Icon = item.icon; return ( -
-
+
+
-
-
- -
-
-

Continue

- -

Acamprosate renal screen

-
-

- Ward round · 3 sources · last opened Today 08:44 -

-
-
-
- - - Open - +
+
+
+ + + Continue +
); } -function FavouritesTable() { - const [selectedIds, setSelectedIds] = useState>(() => new Set([selectedItem.id])); - const [searchTerm, setSearchTerm] = useState(""); - const selectedCount = selectedIds.size; +function RowActionsMenu({ item }: { item: FavouriteItem }) { + const [open, setOpen] = useState(false); + const buttonRef = useRef(null); + const menuRef = useRef(null); - const tableRows = useMemo(() => { - const normalizedSearch = searchTerm.trim().toLowerCase(); - const filteredItems = normalizedSearch - ? favouriteItems.filter((item) => - [item.title, item.description, item.type, item.set].some((field) => - field.toLowerCase().includes(normalizedSearch), - ), - ) - : favouriteItems; - return filteredItems.map((item) => ({ - ...item, - selected: selectedIds.has(item.id), - })); - }, [searchTerm, selectedIds]); - - function toggleRow(id: string) { - setSelectedIds((current) => { - const next = new Set(current); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - } + useDismissableLayer({ + enabled: open, + refs: [buttonRef, menuRef], + onDismiss: () => setOpen(false), + restoreFocusRef: buttonRef, + }); + + const actionLabel = item.action === "Copy" ? "Open" : item.action; return ( -
-
- - All favourites - 5 - - -
); } -function ItemWorkspace() { +function ItemWorkspace({ item, onClose }: { item: FavouriteItem; onClose: () => void }) { const [activeTab, setActiveTab] = useState<"summary" | "evidence" | "notes">("summary"); + const Icon = item.icon; + const actionLabel = item.action === "Copy" ? "Open" : item.action; return ( -

Saved in {" "} - Ward round + {item.set}

@@ -683,135 +742,105 @@ function ItemWorkspace() {
-
-

- Next action -

-

- Open for renal dose cautions, eGFR thresholds, and source links. -

- - Open item - - -

Last opened Today 08:44

-
- -
-
-

- Sources (3) -

- -
-
- {sourceRecords.map((source, index) => ( + {actionLabel} + + +

Last opened {item.lastUsed}

+
+ ) : null} + + {activeTab === "evidence" ? ( +
+

+ Sources (3) +

+
+ {sourceRecords.map((source, index) => ( +
+ + {index + 1} + + {source.title} + + {source.type} + +
+ ))} +
+
+ ) : null} + + {activeTab === "notes" ? ( +
+
+

+ Personal note +

- ))} -
- -
+
+
+

+ Useful for older patients with fluctuating eGFR. Check adherence section on page 4. +

+ Updated 11 May 2024 +
+ + ) : null} -
-
-

- Personal note -

+
+

More

+
-
-
-

- Useful for older patients with fluctuating eGFR. Check adherence section on page 4. -

-
- Updated 11 May 2024 - -
-
-
- -
-

- Actions -

-
- {[ - { label: "Ask a question", icon: MessageSquare }, - { label: "Copy citation", icon: Copy }, - { label: "Move to set", icon: Folder }, - ].map((action) => { - const Icon = action.icon; - return ( - - ); - })} +
- +
+ ); +} + +export function FavouritesSidebar({ + collapsed, + onCollapsedChange, + ...navProps +}: FavouritesNavProps & { + collapsed: boolean; + onCollapsedChange: (collapsed: boolean) => void; +}) { + const sections = buildSidebarSections(navProps); + + if (collapsed) { + return ( + + ); + } + + return ( + + ); +} + +function SetBrowseCard({ + set, + accentClass, + active = false, + onClick, +}: { + set: FavouritesNavSet; + accentClass: string; + active?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function FavouritesMobileBrowseRail({ + sets, + selectedSetId, + viewMode, + onSelectSet, + onSelectViewMode, +}: Pick) { + if (sets.length === 0) return null; + + return ( +
+

+ Browse sets +

+
+
+ {sets.map((set, index) => ( + { + onSelectViewMode("all"); + onSelectSet(selectedSetId === set.id ? null : set.id); + }} + /> + ))} +
+
+
+ ); +} diff --git a/src/components/clinical-dashboard/favourites-prototype-data.ts b/src/components/clinical-dashboard/favourites-prototype-data.ts index b6484c8bc..16f5ef541 100644 --- a/src/components/clinical-dashboard/favourites-prototype-data.ts +++ b/src/components/clinical-dashboard/favourites-prototype-data.ts @@ -11,6 +11,7 @@ export type FavouriteItem = { meta: string; sourceMeta: string; primaryAction: string; + href: string; icon: typeof FileText; keywords: string; }; @@ -47,6 +48,7 @@ export const favouriteItems: FavouriteItem[] = [ meta: "Medication page · renal cautions · dose notes", sourceMeta: "3 sources", primaryAction: "Open", + href: "/medications/acamprosate", icon: Pill, keywords: "acamprosate renal screen medication dose safety ward round pbs", }, @@ -58,6 +60,7 @@ export const favouriteItems: FavouriteItem[] = [ meta: "PDF · p.4-9 · 2 tables", sourceMeta: "PDF", primaryAction: "Ask", + href: "/?mode=documents&q=lithium+monitoring&run=1", icon: FileText, keywords: "lithium monitoring guideline blood tests shared care renal toxicity", }, @@ -69,6 +72,7 @@ export const favouriteItems: FavouriteItem[] = [ meta: "Saved table · ANC monitoring", sourceMeta: "Table", primaryAction: "Source", + href: "/?mode=documents&q=clozapine+monitoring+table&run=1", icon: Quote, keywords: "clozapine monitoring table anc fbc neutrophil clinic", }, @@ -80,6 +84,7 @@ export const favouriteItems: FavouriteItem[] = [ meta: "Saved query · medicines + documents", sourceMeta: "Run", primaryAction: "Run", + href: "/?mode=answer&q=renal+dose&run=1", icon: Search, keywords: "renal dose saved search kidney egfr medicines documents", }, @@ -91,6 +96,7 @@ export const favouriteItems: FavouriteItem[] = [ meta: "Source card · prescribing safety", sourceMeta: "Quote", primaryAction: "Copy", + href: "/?mode=documents&q=QT+prolongation&run=1", icon: Quote, keywords: "qt prolongation quote source card prescribing safety", }, diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index 0b216efa5..9cf2a52f1 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -6,6 +6,7 @@ import { Suspense, type CSSProperties, type ReactNode, useEffect, useMemo, useRe import { ClinicalDashboard } from "@/components/clinical-dashboard"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { recentQueryStorageKey, SettingsDialog } from "@/components/ClinicalDashboard"; +import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { ClinicalDesktopSidebar, ClinicalMobileSidebar, @@ -16,6 +17,7 @@ import { MasterSearchHeader } from "@/components/clinical-dashboard/master-searc import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; import { FormsSearchResultsPage } from "@/components/forms/forms-search-results-page"; +import { ClientHydrationBoundary } from "@/components/client-hydration-boundary"; import { cn } from "@/components/ui-primitives"; import { appModeHomeHref, @@ -128,12 +130,16 @@ function GlobalMockupSearchShellClient({ const [settingsOpen, setSettingsOpen] = useState(false); const [accountSetupOpen, setAccountSetupOpen] = useState(false); const [recentQueries, setRecentQueries] = useState([]); + const [commandScopes, setCommandScopes] = useState([]); const { theme, toggleTheme } = useTheme(); const auth = useAuthSession(); const sidebarIdentity = useMemo(() => deriveSidebarIdentity(auth.session?.user.email), [auth.session?.user.email]); const hasSubmittedModeSearch = requestedRun && requestedQuery.length > 0; - const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search"); - const isDocumentCommandSearchView = pathname === "/mockups/document-search-command" && requestedQuery.length > 0; + const isHomeRoute = pathname === "/"; + const isDocumentFlowRoute = + pathname === "/documents/search" || pathname.startsWith("/documents/source"); + const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search") || isDocumentFlowRoute; + const isDocumentCommandSearchView = pathname === "/documents/search" && requestedQuery.length > 0; const useCompactBottomSearch = hasSubmittedModeSearch || isDocumentCommandSearchView; const shouldRenderDashboardSearch = hasSubmittedModeSearch && resolvedSearchMode !== "services" && !isDocumentSearchMockupRoute; @@ -252,6 +258,7 @@ function GlobalMockupSearchShellClient({ function changeMode(mode: AppModeId) { setQuery(""); + setCommandScopes([]); setSearchMode(mode); setMobileMenuOpen(false); navigateToMode(mode); @@ -260,21 +267,34 @@ function GlobalMockupSearchShellClient({ function startNewAnswerChat() { setQuery(""); setMobileMenuOpen(false); - navigateToMode("answer", { focus: true }); + // On standalone mode routes (favourites, services, etc.) keep the user in + // that workspace and only clear the query — same as sidebar "New chat". + navigateToMode(searchMode === "answer" ? "answer" : searchMode, { focus: true }); } function pickRecentQuery(recentQuery: string) { setMobileMenuOpen(false); - navigateToMode("answer", { query: recentQuery, focus: true }); + navigateToMode(searchMode, { query: recentQuery, focus: true, run: true }); } - if (shouldRenderDashboardSearch && !shouldRenderFormsSearchResults) { + function crossModeSearch(mode: AppModeId, crossQuery: string) { + setQuery(crossQuery); + setCommandScopes([]); + setSearchMode(mode); + setMobileMenuOpen(false); + navigateToMode(mode, { query: crossQuery, focus: true, run: true }); + } + + const shouldRenderClinicalDashboard = + isHomeRoute || (shouldRenderDashboardSearch && !shouldRenderFormsSearchResults); + + if (shouldRenderClinicalDashboard) { return ( ); } @@ -293,18 +313,20 @@ function GlobalMockupSearchShellClient({
{shouldShowDesktopSidebar ? ( -
+
+ } + > + setCommandScopes((current) => current.filter((scope) => scope !== scopeId)), + onClearScopes: () => setCommandScopes([]), + }} > {shouldRenderFormsSearchResults ? : children} + +
@@ -418,6 +459,9 @@ function GlobalMockupSearchShellClient({ setAccountSetupOpen(false)} /> ; queryInputRef?: Ref; queryInputAutoFocus?: boolean; + /** Overrides the mode's default input placeholder (e.g. "Ask a follow-up..." mid-thread). */ + composerPlaceholder?: string; + recentQueries?: string[]; + commandScopes?: string[]; + onCommandScopesChange?: (scopes: string[]) => void; + onPickRecent?: (query: string) => void; + onCrossModeSearch?: (modeId: AppModeId, query: string) => void; headerVariant?: "default" | "workflow"; mobileSearchPlacement?: "default" | "bottom"; /** "compact" drops the phone footer chip row and hugs the bottom edge — @@ -251,6 +266,8 @@ export function MasterSearchHeader({ desktopSearchPlacement?: "default" | "hero"; searchComposerVisible?: boolean; desktopHomeComposerSlotId?: string; + /** Phone-only slot rendered above the bottom search pill for page-specific dock addons. */ + mobileBottomSearchAddonSlotId?: string; /** Portal the composer into the hero slot from the tablet breakpoint (sm) up, * rather than the default desktop (lg) breakpoint. */ heroComposerFromTablet?: boolean; @@ -274,6 +291,7 @@ export function MasterSearchHeader({ const isMobileBottomComposer = searchComposerVisible && mobileSearchPlacement === "bottom" && !isAnswerFooterComposer; const isHeroDesktopComposer = desktopSearchPlacement === "hero" && isMobileBottomComposer; const canRunLocalSearch = + selectedSearch.kind === "documents" || searchMode === "forms" || selectedSearch.kind === "services" || selectedSearch.kind === "tools" || @@ -290,6 +308,8 @@ export function MasterSearchHeader({ const [scopeSheetFullscreen, setScopeSheetFullscreen] = useState(false); const [actionMenuOpen, setActionMenuOpen] = useState(false); const [actionMenuPlacement, setActionMenuPlacement] = useState("up"); + const [commandDropdownOpen, setCommandDropdownOpen] = useState(false); + const [commandListboxId, setCommandListboxId] = useState(); const [modeMenuOpen, setModeMenuOpen] = useState(false); const [usesScopeSheet, setUsesScopeSheet] = useState(false); const [usesPhoneSearchLayout, setUsesPhoneSearchLayout] = useState(false); @@ -382,7 +402,8 @@ export function MasterSearchHeader({ const activeQuickFilterCount = (scopeFilters.sourceStatuses?.length ? 1 : 0) + (scopeFilters.locality ? 1 : 0) + activeLabelFilterCount; const submitLabel = trimmedQuery ? selectedSearch.submitBusyLabel : selectedSearch.submitIdleLabel; - const queryPlaceholder = isAnswerFooterComposer ? "Ask Clinical Guide" : selectedSearch.placeholder; + const queryPlaceholder = + composerPlaceholder ?? (isAnswerFooterComposer ? "Ask Clinical Guide" : selectedSearch.placeholder); const SelectedAppModeIcon = appModeIcons[selectedAppMode.id]; const actionMenuModeOptions = useMemo( () => @@ -1175,9 +1196,13 @@ export function MasterSearchHeader({ const composerPlaceholder = usesMobileBottomStyle && searchMode === "differentials" ? "Search a presentation" : queryPlaceholder; + const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout; + return (
{usesBottomComposerPlacement ?
setSelectedSlugs([])} onToggleSelected={toggleSelected} From ceedbead51fab64e8f1a896c58f8c728f8cb71de Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:15:06 +0800 Subject: [PATCH 25/61] fix(answer): bootstrap thread persistence safely and share mode icons. Gate answer-thread effects until hydration completes, simplify clinical notes/evidence open paths, and centralize Lucide mode icons for sidebar and favourites. Co-authored-by: Cursor --- src/components/ClinicalDashboard.tsx | 146 ++++-------------- .../clinical-dashboard/ClinicalSidebar.tsx | 4 +- .../favourites-command-library-page.tsx | 5 +- .../favourites-prototype-data.ts | 5 +- .../use-saved-registry-favourites.ts | 5 +- .../rectangle-direction-mockups.tsx | 3 +- .../tools-page-mockups/tool-fixtures.ts | 5 +- src/lib/app-mode-icons.ts | 25 +++ 8 files changed, 67 insertions(+), 131 deletions(-) create mode 100644 src/lib/app-mode-icons.ts diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 435354a41..ddaa773c9 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -165,7 +165,6 @@ import { QuoteCards, SafetyFindingsListContent, } from "@/components/clinical-dashboard/evidence-panels"; -import { useMobilePreviewSheet } from "@/components/clinical-dashboard/use-mobile-preview-sheet"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { emptyStates, errorCopy } from "@/lib/ui-copy"; @@ -1277,12 +1276,10 @@ function StagedAnswerResultSurface({ const [evidenceOpen, setEvidenceOpen] = useState(false); const [safetyFindingsOpen, setSafetyFindingsOpen] = useState(false); const [evidenceInitialTab, setEvidenceInitialTab] = useState(null); - const [activeReviewPanel, setActiveReviewPanel] = useState<"clinical" | "evidence" | null>(null); const [copiedQuotes, setCopiedQuotes] = useState(false); const clinicalNotesTriggerRef = useRef(null); const evidenceTriggerRef = useRef(null); const safetyTriggerRef = useRef(null); - const useReviewSheet = useMobilePreviewSheet(); const copyQuotesTimerRef = useRef(null); useEffect(() => { return () => { @@ -1293,13 +1290,7 @@ function StagedAnswerResultSurface({ setEvidenceOpen(false); setSafetyFindingsOpen(false); setEvidenceInitialTab(null); - if (useReviewSheet) { - setActiveReviewPanel(null); - setClinicalNotesOpen(true); - return; - } - setClinicalNotesOpen(false); - setActiveReviewPanel("clinical"); + setClinicalNotesOpen(true); } function restoreFocusToTrigger(ref: RefObject) { window.requestAnimationFrame(() => { @@ -1314,24 +1305,13 @@ function StagedAnswerResultSurface({ setClinicalNotesOpen(false); setSafetyFindingsOpen(false); setEvidenceInitialTab(initialTab); - if (useReviewSheet) { - setActiveReviewPanel(null); - setEvidenceOpen(true); - return; - } - setEvidenceOpen(false); - setActiveReviewPanel("evidence"); + setEvidenceOpen(true); } function closeEvidenceReview() { setEvidenceOpen(false); setEvidenceInitialTab(null); restoreFocusToTrigger(evidenceTriggerRef); } - function closeDesktopReviewPanel() { - const triggerRef = activeReviewPanel === "clinical" ? clinicalNotesTriggerRef : evidenceTriggerRef; - setActiveReviewPanel(null); - restoreFocusToTrigger(triggerRef); - } function openTableEvidence() { setClinicalNotesOpen(false); setSafetyFindingsOpen(false); @@ -1341,7 +1321,6 @@ function StagedAnswerResultSurface({ setClinicalNotesOpen(false); setEvidenceOpen(false); setEvidenceInitialTab(null); - setActiveReviewPanel(null); setSafetyFindingsOpen(true); } function closeSafetyFindingsReview() { @@ -1367,7 +1346,7 @@ function StagedAnswerResultSurface({ const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel); const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support"; const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer); - const showLayoutAside = Boolean(activeReviewPanel || centralTable); + const showLayoutAside = Boolean(centralTable); return (
@@ -1422,81 +1401,9 @@ function StagedAnswerResultSurface({ /> ) : null} - {centralTable && activeReviewPanel ? : null}
- {activeReviewPanel ? ( - - ) : centralTable ? ( + {centralTable ? (
@@ -1567,8 +1474,7 @@ function StagedAnswerResultSurface({ } - contentClassName="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" - contentStyle={{ height: "80dvh" }} + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-2xl" bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" returnFocusRef={evidenceTriggerRef} portal @@ -1612,8 +1518,7 @@ function StagedAnswerResultSurface({ headerClassName="gap-2 p-2.5 sm:p-3" titleClassName="text-[15px] leading-5" closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - contentClassName="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" - contentStyle={{ height: "80dvh" }} + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-lg" bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" returnFocusRef={safetyTriggerRef} portal @@ -3799,30 +3704,32 @@ export function ClinicalDashboard({ // switches, new chat, differentials/services clears) without each caller // having to remember the ref. useEffect(() => { + if (!answerThreadBootstrappedRef.current) return; if (answer === null) latestAnswerTurnRef.current = null; }, [answer]); useLayoutEffect(() => { if (answerThreadBootstrappedRef.current) return; - answerThreadBootstrappedRef.current = true; const persisted = loadPersistedAnswerThread(); - if (!persisted) return; - setPriorAnswerTurns(persisted.priorTurns); - setLatestAnswerQuery(persisted.latestTurn?.query ?? null); - if (persisted.latestTurn) { - latestAnswerTurnRef.current = persisted.latestTurn; - setAnswer(persisted.latestTurn.answer); - setSources(persisted.latestTurn.sources); - setModeSearchSubmitted(true); + if (persisted) { + setPriorAnswerTurns(persisted.priorTurns); + setLatestAnswerQuery(persisted.latestTurn?.query ?? null); + if (persisted.latestTurn) { + latestAnswerTurnRef.current = persisted.latestTurn; + setAnswer(persisted.latestTurn.answer); + setSources(persisted.latestTurn.sources); + setModeSearchSubmitted(true); + } + answerTurnSeqRef.current = persisted.priorTurns.reduce((max, turn) => { + const match = /^answer-turn-(\d+)$/.exec(turn.id); + return match ? Math.max(max, Number(match[1])) : max; + }, 0); + setCollapsedTurnIds( + persisted.collapsedTurnIds.length + ? new Set(persisted.collapsedTurnIds) + : new Set(persisted.priorTurns.map((turn) => turn.id)), + ); } - answerTurnSeqRef.current = persisted.priorTurns.reduce((max, turn) => { - const match = /^answer-turn-(\d+)$/.exec(turn.id); - return match ? Math.max(max, Number(match[1])) : max; - }, 0); - setCollapsedTurnIds( - persisted.collapsedTurnIds.length - ? new Set(persisted.collapsedTurnIds) - : new Set(persisted.priorTurns.map((turn) => turn.id)), - ); + answerThreadBootstrappedRef.current = true; }, []); function resetAnswerThread() { setPriorAnswerTurns([]); @@ -4021,6 +3928,7 @@ export function ClinicalDashboard({ }, []); useEffect(() => { + if (!answerThreadBootstrappedRef.current) return; if (searchMode !== "answer") return; if (!answer && priorAnswerTurns.length === 0) { clearPersistedAnswerThread(); diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index 5ca27a12c..b28e41a8f 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -5,7 +5,6 @@ import Link from "next/link"; import { BookOpen, BrainCircuit, - ClipboardList, ClipboardPen, FileText, Heart, @@ -21,6 +20,7 @@ import { Sun, Wrench, } from "lucide-react"; +import { appModeIcons } from "@/lib/app-mode-icons"; import { BrandMark } from "@/components/clinical-dashboard/brand"; import { cn, sidebarItem, statusDotReady, textMuted } from "@/components/ui-primitives"; @@ -63,7 +63,7 @@ function accountProfileLabel(identity: SidebarIdentity) { const sidebarToolItems = [ { id: "answer", label: "Answer", icon: Sparkles, href: "/?mode=answer" }, { id: "documents", label: "Documents", icon: FileText, href: "/?mode=documents" }, - { id: "services", label: "Services", icon: ClipboardList, href: "/services" }, + { id: "services", label: "Services", icon: appModeIcons.services, href: "/services" }, { id: "forms", label: "Forms", icon: ClipboardPen, href: "/forms" }, { id: "favourites", label: "Favourites", icon: Heart, href: "/favourites" }, { id: "differentials", label: "Differentials", icon: BrainCircuit, href: "/differentials" }, diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 7e4a21ca2..54f649e58 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -15,8 +15,6 @@ import { Pill, Quote, Search, - ShieldCheck, - Stethoscope, Trash2, X, type LucideIcon, @@ -41,6 +39,7 @@ import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use- import { SearchResultsEmptyState, SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { favouriteMatchesCommandScopes } from "@/lib/search-command-surface"; +import { appModeIcons } from "@/lib/app-mode-icons"; type FavouriteType = "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form"; type ViewMode = FavouritesViewMode; @@ -116,7 +115,7 @@ const fallbackIconByType: Record = { medications: Pill, documents: FileText, sources: Quote, - services: Stethoscope, + services: appModeIcons.services, forms: FileText, }; diff --git a/src/components/clinical-dashboard/favourites-prototype-data.ts b/src/components/clinical-dashboard/favourites-prototype-data.ts index 16f5ef541..50d239aa6 100644 --- a/src/components/clinical-dashboard/favourites-prototype-data.ts +++ b/src/components/clinical-dashboard/favourites-prototype-data.ts @@ -1,4 +1,5 @@ -import { ClipboardList, FileText, Folder, LayoutList, Pill, Quote, Search, Stethoscope } from "lucide-react"; +import { ClipboardList, FileText, Folder, LayoutList, Pill, Quote, Search } from "lucide-react"; +import { appModeIcons } from "@/lib/app-mode-icons"; export type FavouriteType = "medications" | "documents" | "sources" | "services" | "forms" | "sets"; export type FavouriteTabId = "all" | FavouriteType; @@ -34,7 +35,7 @@ export const favouriteTabs: Array<{ { id: "medications", label: "Medications", shortLabel: "Meds", icon: Pill }, { id: "documents", label: "Documents", shortLabel: "Docs", icon: FileText }, { id: "sources", label: "Sources", shortLabel: "Sources", icon: Quote }, - { id: "services", label: "Services", shortLabel: "Services", icon: Stethoscope }, + { id: "services", label: "Services", shortLabel: "Services", icon: appModeIcons.services }, { id: "forms", label: "Forms", shortLabel: "Forms", icon: ClipboardList }, { id: "sets", label: "Sets", shortLabel: "Sets", icon: Folder }, ]; diff --git a/src/components/clinical-dashboard/use-saved-registry-favourites.ts b/src/components/clinical-dashboard/use-saved-registry-favourites.ts index 3ca75ab3a..6ded2c96d 100644 --- a/src/components/clinical-dashboard/use-saved-registry-favourites.ts +++ b/src/components/clinical-dashboard/use-saved-registry-favourites.ts @@ -1,6 +1,7 @@ "use client"; -import { ClipboardList, Stethoscope } from "lucide-react"; +import { ClipboardList } from "lucide-react"; +import { appModeIcons } from "@/lib/app-mode-icons"; import { useEffect, useMemo, useState } from "react"; import type { FavouriteItem } from "@/components/clinical-dashboard/favourites-prototype-data"; @@ -32,7 +33,7 @@ function recordToFavourite(record: ServiceRecord, type: "services" | "forms"): F sourceMeta: type === "services" ? "Service" : "Form", primaryAction: "Open", href: `/${type}/${record.slug}`, - icon: type === "services" ? Stethoscope : ClipboardList, + icon: type === "services" ? appModeIcons.services : ClipboardList, keywords: [record.title, record.subtitle, ...(record.tags ?? [])].filter(Boolean).join(" ").toLowerCase(), }; } diff --git a/src/components/tools-page-mockups/rectangle-direction-mockups.tsx b/src/components/tools-page-mockups/rectangle-direction-mockups.tsx index 6e5afbdea..64b28e2b2 100644 --- a/src/components/tools-page-mockups/rectangle-direction-mockups.tsx +++ b/src/components/tools-page-mockups/rectangle-direction-mockups.tsx @@ -21,6 +21,7 @@ import { import type { ReactNode } from "react"; import { cn } from "@/components/ui-primitives"; +import { appModeIcons } from "@/lib/app-mode-icons"; import { areaLabels, pinnedToolIds, toolById, tools, type ToolFixture } from "./tool-fixtures"; import { useToolFilter, type ToolFilterId } from "./use-tool-filter"; @@ -173,7 +174,7 @@ function SavedWorkPanel() { const saved = [ { title: "Lithium monitoring plan", tool: "Documents", icon: FileText }, { title: "Medication review draft", tool: "Medication", icon: Pill }, - { title: "13YARN referral pathway", tool: "Services", icon: ClipboardList }, + { title: "13YARN referral pathway", tool: "Services", icon: appModeIcons.services }, ]; return ( diff --git a/src/components/tools-page-mockups/tool-fixtures.ts b/src/components/tools-page-mockups/tool-fixtures.ts index a5a28c6aa..00d1238da 100644 --- a/src/components/tools-page-mockups/tool-fixtures.ts +++ b/src/components/tools-page-mockups/tool-fixtures.ts @@ -1,4 +1,5 @@ -import { Brain, ClipboardList, FileCheck2, FileText, Pill, Search, Star, type LucideIcon } from "lucide-react"; +import { Brain, FileCheck2, FileText, Pill, Search, Star, type LucideIcon } from "lucide-react"; +import { appModeIcons } from "@/lib/app-mode-icons"; export type ToolStatus = "ready" | "review_due" | "recent"; export type ToolArea = "reference" | "assessment" | "care" | "coordination" | "personal"; @@ -75,7 +76,7 @@ export const tools: ToolFixture[] = [ title: "Services", description: "Open source-backed service records, referral routes, and eligibility.", href: "/services", - icon: ClipboardList, + icon: appModeIcons.services, area: "coordination", sourceBacked: true, status: "review_due", diff --git a/src/lib/app-mode-icons.ts b/src/lib/app-mode-icons.ts new file mode 100644 index 000000000..72c6af298 --- /dev/null +++ b/src/lib/app-mode-icons.ts @@ -0,0 +1,25 @@ +import { + BrainCircuit, + FileSignature, + FileText, + Heart, + Pill, + ShieldCheck, + Sparkles, + Wrench, + type LucideIcon, +} from "lucide-react"; + +import type { AppModeId } from "@/lib/app-modes"; + +/** Canonical Lucide icons for each app mode — keep in sync across nav, search, and favourites. */ +export const appModeIcons: Record = { + answer: Sparkles, + documents: FileText, + services: ShieldCheck, + forms: FileSignature, + favourites: Heart, + differentials: BrainCircuit, + prescribing: Pill, + tools: Wrench, +}; From c9a3dea525076d9cfaf3e13f6dfc6e560991b041 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:16:01 +0800 Subject: [PATCH 26/61] fix(ui): use sheets for clinical notes and evidence on all breakpoints. Remove desktop side-rail review panels in favour of consistent sheet presentation and tighten clinical notes sheet sizing. Co-authored-by: Cursor --- src/components/ClinicalDashboard.tsx | 3 +- .../clinical-dashboard/document-results.tsx | 110 ++---------------- .../favourites-command-library-page.tsx | 1 + .../master-search-header.tsx | 6 - .../universal-search-command-surface.tsx | 1 - 5 files changed, 9 insertions(+), 112 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index ddaa773c9..83b2b5d93 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1441,8 +1441,7 @@ function StagedAnswerResultSurface({ headerClassName="gap-2 p-2.5 sm:p-3" titleClassName="text-[15px] leading-5" closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - contentClassName="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" - contentStyle={{ height: "80dvh" }} + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-md" bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" returnFocusRef={clinicalNotesTriggerRef} portal diff --git a/src/components/clinical-dashboard/document-results.tsx b/src/components/clinical-dashboard/document-results.tsx index df05a09f1..8f522e687 100644 --- a/src/components/clinical-dashboard/document-results.tsx +++ b/src/components/clinical-dashboard/document-results.tsx @@ -28,7 +28,6 @@ import { SafetyFindingsListContent, } from "@/components/clinical-dashboard/evidence-panels"; import { QueryCoverageChips, RelevanceBadge } from "@/components/clinical-dashboard/relevance"; -import { useMobilePreviewSheet } from "@/components/clinical-dashboard/use-mobile-preview-sheet"; import { InlineTableCard, MobileEvidenceSheetContent } from "@/components/clinical-dashboard/visual-evidence"; import { answerSurface, @@ -255,12 +254,10 @@ export function StagedAnswerResultSurface({ const [evidenceOpen, setEvidenceOpen] = useState(false); const [safetyFindingsOpen, setSafetyFindingsOpen] = useState(false); const [evidenceInitialTab, setEvidenceInitialTab] = useState(null); - const [activeReviewPanel, setActiveReviewPanel] = useState<"clinical" | "evidence" | null>(null); const [copiedQuotes, setCopiedQuotes] = useState(false); const clinicalNotesTriggerRef = useRef(null); const evidenceTriggerRef = useRef(null); const safetyTriggerRef = useRef(null); - const useReviewSheet = useMobilePreviewSheet(); const copyQuotesTimerRef = useRef(null); useEffect(() => { return () => { @@ -271,13 +268,7 @@ export function StagedAnswerResultSurface({ setEvidenceOpen(false); setSafetyFindingsOpen(false); setEvidenceInitialTab(null); - if (useReviewSheet) { - setActiveReviewPanel(null); - setClinicalNotesOpen(true); - return; - } - setClinicalNotesOpen(false); - setActiveReviewPanel("clinical"); + setClinicalNotesOpen(true); } function restoreFocusToTrigger(ref: RefObject) { window.requestAnimationFrame(() => { @@ -292,24 +283,13 @@ export function StagedAnswerResultSurface({ setClinicalNotesOpen(false); setSafetyFindingsOpen(false); setEvidenceInitialTab(initialTab); - if (useReviewSheet) { - setActiveReviewPanel(null); - setEvidenceOpen(true); - return; - } - setEvidenceOpen(false); - setActiveReviewPanel("evidence"); + setEvidenceOpen(true); } function closeEvidenceReview() { setEvidenceOpen(false); setEvidenceInitialTab(null); restoreFocusToTrigger(evidenceTriggerRef); } - function closeDesktopReviewPanel() { - const triggerRef = activeReviewPanel === "clinical" ? clinicalNotesTriggerRef : evidenceTriggerRef; - setActiveReviewPanel(null); - restoreFocusToTrigger(triggerRef); - } function openTableEvidence() { setClinicalNotesOpen(false); setSafetyFindingsOpen(false); @@ -319,7 +299,6 @@ export function StagedAnswerResultSurface({ setClinicalNotesOpen(false); setEvidenceOpen(false); setEvidenceInitialTab(null); - setActiveReviewPanel(null); setSafetyFindingsOpen(true); } function closeSafetyFindingsReview() { @@ -345,7 +324,7 @@ export function StagedAnswerResultSurface({ const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel); const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support"; const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer); - const showLayoutAside = Boolean(activeReviewPanel || centralTable); + const showLayoutAside = Boolean(centralTable); return (
@@ -392,81 +371,9 @@ export function StagedAnswerResultSurface({ /> ) : null} - {centralTable && activeReviewPanel ? : null}
- {activeReviewPanel ? ( - - ) : centralTable ? ( + {centralTable ? (
@@ -504,8 +411,7 @@ export function StagedAnswerResultSurface({ headerClassName="gap-2 p-2.5 sm:p-3" titleClassName="text-[15px] leading-5" closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - contentClassName="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" - contentStyle={{ height: "80dvh" }} + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-md" bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" returnFocusRef={clinicalNotesTriggerRef} portal @@ -537,8 +443,7 @@ export function StagedAnswerResultSurface({ } - contentClassName="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" - contentStyle={{ height: "80dvh" }} + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-2xl" bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" returnFocusRef={evidenceTriggerRef} portal @@ -582,8 +487,7 @@ export function StagedAnswerResultSurface({ headerClassName="gap-2 p-2.5 sm:p-3" titleClassName="text-[15px] leading-5" closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - contentClassName="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" - contentStyle={{ height: "80dvh" }} + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-lg" bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" returnFocusRef={safetyTriggerRef} portal diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 54f649e58..c2fa9da54 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -15,6 +15,7 @@ import { Pill, Quote, Search, + ShieldCheck, Trash2, X, type LucideIcon, diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index dd5f35631..6a7794ee8 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -17,32 +17,26 @@ import { createPortal } from "react-dom"; import { Activity, BadgeCheck, - BrainCircuit, CalendarDays, Check, CheckCircle2, ChevronDown, - FileSignature, FileText, Filter, FolderOpen, GitBranch, Globe2, - Heart, ListChecks, Loader2, Menu, MessageSquarePlus, - Pill, Plus, Search, Send, ShieldCheck, - Sparkles, ArrowLeft, X, Lock, - Wrench, } from "lucide-react"; import { DocumentTagCloud } from "@/components/DocumentTagCloud"; diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 5999a40cd..74d4dc2a6 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -6,7 +6,6 @@ import { CornerDownLeft, Search, X, - type LucideIcon, } from "lucide-react"; import { useEffect, From caa5f9564439676c3e63584315b10ceafa2e6ad8 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:17:00 +0800 Subject: [PATCH 27/61] fix(ui): tighten scope and source-only disclosure styling. Co-authored-by: Cursor --- .../clinical-dashboard/answer-content.tsx | 34 +++++++------------ .../clinical-dashboard/evidence-panels.tsx | 33 ++++++++---------- 2 files changed, 28 insertions(+), 39 deletions(-) diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 5310bb94a..e64d7b7f5 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -171,29 +171,29 @@ export function ScopeAndGovernanceNotice({ scope?.matchedDocumentCount === 0; if (!showScope && groupedWarnings.length === 0) return null; return ( -
+
{showScope && scope ? ( -

+

Scope: {scope.summary} {scope.queryMode && scope.queryMode !== "auto" ? ` · ${scope.queryMode.replaceAll("_", " ")}` : ""}

) : null} {scope?.warnings?.length ? ( -
    +
      {scope.warnings.slice(0, 3).map((warning) => (
    • {warning}
    • ))}
    ) : null} {groupedWarnings.length ? ( -
      +
        {groupedWarnings.map((warning) => (
      • {warning.message} {warning.titles.length ? ( -
        +
        Sources affected - {warning.titles.slice(0, 5).join(", ")} + {warning.titles.slice(0, 5).join(", ")}
        ) : null}
      • @@ -622,31 +622,23 @@ export function NaturalLanguageAnswer({ data-testid="source-only-disclosure" role="note" className={cn( - "max-w-xl overflow-hidden rounded-lg border border-[color:var(--warning)]/25 bg-[color:var(--warning-soft)]/45 text-xs shadow-[var(--shadow-inset)]", + "w-fit max-w-full overflow-hidden rounded-md border border-[color:var(--warning)]/20 border-l-2 border-l-[color:var(--warning)] bg-[color:var(--warning-soft)]/30 text-xs", textMuted, )} >
-
+
2 ? "max-sm:hidden" : "", )} > @@ -270,10 +247,7 @@ function ServiceCard({ Open @@ -281,10 +255,7 @@ function ServiceCard({
-

Selected services ({selected.length})

+

Selected services ({selected.length})

{selected.map((service, index) => ( ))}
-
+
-

Checklist

-
-
+
{rows.map(([label, count, Icon, color]) => (
{label} - {count} + {count}
))}
-
-
+
-

Source confidence

-
-
- - - - +
+ + + +
-
+
High
- {counts.high} + {counts.high}
Medium
- {counts.medium} + {counts.medium}
Low
- {counts.low} + {counts.low}
Unknown
- {counts.unknown} + {counts.unknown}
- +
); } @@ -458,133 +423,122 @@ export function ServicesNavigatorPage() { } return ( -
-
-
+
-
+
-
-
- {query.trim() && scopedMatches.length === 0 ? ( - applyServiceQuery(example)} - /> - ) : ( - <> -
-
-
- - {scopedMatches.length} - -
-

- Referral matches -

-

- {scopedMatches.length} referral {scopedMatches.length === 1 ? "match" : "matches"} -

-

- Best fit for crisis, ATSI-specific phone referral. - - Ranked for crisis support, ATSI-specific access, and phone referral. - -

-
-
-
- - -
-
-
-
- {["Best fit", "Crisis", "ATSI-specific", "Phone referral", "Free", "WA"].map((chip, index) => ( - - ))} -
- -
-
-
- {scopedMatches.map((service, index) => ( - - ))} -
- - )} -
- setSelectedSlugs([])} - onToggleSelected={toggleSelected} - /> +
+
-
-
-
+ + } + sidebar={ + setSelectedSlugs([])} + onToggleSelected={toggleSelected} + /> + } + > + {query.trim() && scopedMatches.length === 0 ? ( + applyServiceQuery(example)} + /> + ) : ( + <> +
+
+
+ + {scopedMatches.length} + +
+

+ Referral matches +

+

+ {scopedMatches.length} referral {scopedMatches.length === 1 ? "match" : "matches"} +

+

+ Best fit for crisis, ATSI-specific phone referral. + + Ranked for crisis support, ATSI-specific access, and phone referral. + +

+
+
+
+ + +
+
+
+
+ {["Best fit", "Crisis", "ATSI-specific", "Phone referral", "Free", "WA"].map((chip, index) => ( + + ))} +
+ +
+
+
+ {scopedMatches.map((service, index) => ( + + ))} +
+ + )} + ); } diff --git a/src/lib/answer-thread-storage.ts b/src/lib/answer-thread-storage.ts index f4bb56bd7..d2aa683b4 100644 --- a/src/lib/answer-thread-storage.ts +++ b/src/lib/answer-thread-storage.ts @@ -1,6 +1,7 @@ import type { RagAnswer, SearchResult } from "@/lib/types"; export const answerThreadStorageKey = "clinical-kb-answer-thread"; +export const maxStoredAnswerTurns = 12; export type StoredAnswerTurn = { id: string; @@ -16,7 +17,6 @@ export type PersistedAnswerThread = { collapsedTurnIds: string[]; }; -const maxStoredTurns = 12; const maxStorageBytes = 4_500_000; function isStoredAnswerTurn(value: unknown): value is StoredAnswerTurn { @@ -38,7 +38,7 @@ function normalizePersistedAnswerThread(value: unknown): PersistedAnswerThread | const record = value as Partial; if (record.version !== 1) return null; const priorTurns = Array.isArray(record.priorTurns) - ? record.priorTurns.filter(isStoredAnswerTurn).slice(-maxStoredTurns) + ? record.priorTurns.filter(isStoredAnswerTurn).slice(-maxStoredAnswerTurns) : []; const latestTurn = record.latestTurn && @@ -82,7 +82,7 @@ export function savePersistedAnswerThread(thread: PersistedAnswerThread): boolea try { const payload: PersistedAnswerThread = { version: 1, - priorTurns: thread.priorTurns.slice(-maxStoredTurns), + priorTurns: thread.priorTurns.slice(-maxStoredAnswerTurns), latestTurn: thread.latestTurn, collapsedTurnIds: thread.collapsedTurnIds, }; diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index c165c8505..2aa31d6f9 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -2,8 +2,6 @@ import { expect, test, type Page } from "playwright/test"; import type { Route } from "playwright-core"; import { demoAnswer, demoDocuments } from "../src/lib/demo-data"; -const expectedLauncherAppCount = 11; - const readySetupChecks = [ { id: "env", label: ".env.local configured", status: "ready", detail: "Test environment ready." }, { id: "project", label: "Clinical KB Database target", status: "ready", detail: "Test Supabase project ready." }, From dc9b69385676db4b697591b2b4894f3a2b6e25cc Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:47:44 +0800 Subject: [PATCH 31/61] fix(answer): harden thread restore on reload and extract result surface. Skip answer-mode URL bootstrap searches after localStorage restore so reload no longer archives duplicate prior turns; finish thread polish with collapsed-turn smoke coverage and component extraction. Co-authored-by: Cursor --- src/app/globals.css | 44 ++ .../mockups/answer-evidence-popups/page.tsx | 64 +-- src/components/ClinicalDashboard.tsx | 454 ++++-------------- .../answer-result-surface.tsx | 364 ++++++++++++++ .../clinical-dashboard/evidence-panels.tsx | 134 +++--- .../search-results-layout.tsx | 65 +++ src/components/ui-primitives.tsx | 6 + tests/ui-smoke.spec.ts | 24 +- tests/ui-stress.spec.ts | 21 +- tests/ui-tools.spec.ts | 50 +- 10 files changed, 722 insertions(+), 504 deletions(-) create mode 100644 src/components/clinical-dashboard/answer-result-surface.tsx create mode 100644 src/components/clinical-dashboard/search-results-layout.tsx diff --git a/src/app/globals.css b/src/app/globals.css index bf1831292..b08aed0ee 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1466,6 +1466,50 @@ summary::-webkit-details-marker { font-variant-numeric: tabular-nums; } +@utility search-results-main { + container-type: inline-size; + container-name: search-results-main; + min-width: 0; +} + +.search-results-cards-compact { + display: none; +} + +.search-results-table-desktop { + display: block; +} + +@media (max-width: 1023px) { + .search-results-table-desktop { + display: none !important; + } + + .search-results-cards-compact { + display: block !important; + } +} + +@container search-results-main (max-width: 56rem) { + .search-results-table-desktop { + display: none; + } + + .search-results-cards-compact { + display: block; + } +} + +@container search-results-main (min-width: 56.01rem) { + .search-results-table-desktop { + display: block; + } + + .search-results-cards-compact { + display: none; + } +} + /* Motion keyframes (suppressed under prefers-reduced-motion below) */ @keyframes fade-up { from { diff --git a/src/app/mockups/answer-evidence-popups/page.tsx b/src/app/mockups/answer-evidence-popups/page.tsx index 1492c615a..e518fd875 100644 --- a/src/app/mockups/answer-evidence-popups/page.tsx +++ b/src/app/mockups/answer-evidence-popups/page.tsx @@ -573,36 +573,40 @@ function MobileEvidencePanel({ selected }: { selected: string }) { ); } -function DesktopEvidenceDrawer() { +function DesktopEvidenceModal() { return ( -
-
-
-

Evidence review

-

- Verify the answer against cited passages before using it clinically. -

-
- - - Source-backed - -
-
-
- - -
-
-
-

Pinned source

-

Clozapine physical health protocol

-
- Current - Locally reviewed +
+ @@ -709,10 +713,10 @@ export default function AnswerEvidencePopupsMockupPage() {
- +
void; - answerGrounded: boolean; - sources: SearchResult[]; - demoMode: boolean; - safeAnswerSections: Array; - safetyFindings: ReturnType; - copiedAnswer: boolean; - pendingFeedback: AnswerFeedbackType | null; - onCopyAnswer: () => void; - onSubmitFeedback: (feedbackType: AnswerFeedbackType) => void; - onFollowUpQuote?: (quote: QuoteCard) => void; - followUpSuggestions?: string[]; - onPickFollowUpSuggestion?: (suggestion: string) => void; - followUpSuggestionsDisabled?: boolean; -}) { - const noteCount = clinicalNotesCount(answer); - const showClinicalNotes = - safetyFindings.length > 0 || noteCount > 0 || answer.answerQualityTier === "source_only" || answerGrounded === false; - const clinicalNoteDisplayCount = clinicalNotesDisplayCountForAnswer( - answer, - answerViewMode, - noteCount || safetyFindings.length, - ); - const sourceCount = - renderModel.primarySources.length || - sourceSummary?.total_sources || - sources.length || - answer.sources?.length || - answer.citations.length; - const centralTable = answerHasCentralTable(answer) ? primaryVisualTable(answer) : null; - const showEvidenceDrawer = renderModel.allowedBlocks.some((block) => - ["sourceStatus", "reviewSources", "evidenceMap", "quoteCards", "visualEvidence", "warnings"].includes(block), - ); - const [clinicalNotesOpen, setClinicalNotesOpen] = useState(false); - const [evidenceOpen, setEvidenceOpen] = useState(false); - const [safetyFindingsOpen, setSafetyFindingsOpen] = useState(false); - const [evidenceInitialTab, setEvidenceInitialTab] = useState(null); - const [copiedQuotes, setCopiedQuotes] = useState(false); - const clinicalNotesTriggerRef = useRef(null); - const evidenceTriggerRef = useRef(null); - const safetyTriggerRef = useRef(null); - const copyQuotesTimerRef = useRef(null); - useEffect(() => { - return () => { - if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); - }; - }, []); - function openClinicalNotes() { - setEvidenceOpen(false); - setSafetyFindingsOpen(false); - setEvidenceInitialTab(null); - setClinicalNotesOpen(true); - } - function restoreFocusToTrigger(ref: RefObject) { - window.requestAnimationFrame(() => { - if (ref.current?.isConnected) ref.current.focus({ preventScroll: true }); - }); - } - function closeClinicalNotesReview() { - setClinicalNotesOpen(false); - restoreFocusToTrigger(clinicalNotesTriggerRef); - } - function openEvidence(initialTab: EvidenceTabName | null = null) { - setClinicalNotesOpen(false); - setSafetyFindingsOpen(false); - setEvidenceInitialTab(initialTab); - setEvidenceOpen(true); - } - function closeEvidenceReview() { - setEvidenceOpen(false); - setEvidenceInitialTab(null); - restoreFocusToTrigger(evidenceTriggerRef); - } - function openTableEvidence() { - setClinicalNotesOpen(false); - setSafetyFindingsOpen(false); - openEvidence("Tables"); - } - function openSafetyFindings() { - setClinicalNotesOpen(false); - setEvidenceOpen(false); - setEvidenceInitialTab(null); - setSafetyFindingsOpen(true); - } - function closeSafetyFindingsReview() { - setSafetyFindingsOpen(false); - restoreFocusToTrigger(safetyTriggerRef); - } - const copyQuotes = useCallback(async () => { - const quoteText = formatQuoteCardsForClipboard(renderModel.quoteCards); - if (!quoteText) return; - try { - await navigator.clipboard.writeText(quoteText); - setCopiedQuotes(true); - if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); - copyQuotesTimerRef.current = window.setTimeout(() => setCopiedQuotes(false), 1600); - } catch { - setCopiedQuotes(false); - } - }, [renderModel.quoteCards]); - const priority = answerSupportPriority(answer, safeAnswerSections, centralTable, safetyFindings, { - grounded: answerGrounded, - weakEvidence, - }); - const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel); - const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support"; - const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer); - const showLayoutAside = Boolean(centralTable); - - return ( -
-
- - -
-
- - - {showInlineSupportCard ? ( - openEvidence(null)} - onOpenSafetyFindings={safetyFindings.length > 0 ? openSafetyFindings : undefined} - /> - ) : null} - - {followUpSuggestions?.length && onPickFollowUpSuggestion ? ( - - ) : null} - -
- - {centralTable ? ( -
- -
- ) : null} -
- - {showClinicalNotes ? ( - - - - } - titleAccessory={ - - {clinicalNoteDisplayCount} - - } - headerActions={ - bestSource ? ( - - - - ) : null - } - headerClassName="gap-2 p-2.5 sm:p-3" - titleClassName="text-[15px] leading-5" - closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-md" - bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" - returnFocusRef={clinicalNotesTriggerRef} - portal - > - - - ) : null} - - {showEvidenceDrawer ? ( - {evidenceTrustLabel} - } - closeLabel="Close evidence" - headerLeading={ - - - - } - contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-2xl" - bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" - returnFocusRef={evidenceTriggerRef} - portal - > - - - ) : null} - - {safetyFindings.length > 0 ? ( - - - - } - titleAccessory={ - - {safetyFindings.length} - - } - headerClassName="gap-2 p-2.5 sm:p-3" - titleClassName="text-[15px] leading-5" - closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-lg" - bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" - returnFocusRef={safetyTriggerRef} - portal - > - - - ) : null} -
-
- ); -} - const tagQualityTone: Record = { noisy: toneDanger, duplicate: toneWarning, @@ -3655,6 +3331,7 @@ export function ClinicalDashboard({ const jobsRef = useRef(jobs); const batchesRef = useRef(batches); const answerThreadBootstrappedRef = useRef(false); + const [answerThreadBootstrapped, setAnswerThreadBootstrapped] = useState(false); const [query, setQuery] = useState(initialQuery); const [searchMode, setSearchMode] = useState(initialSearchMode); const [modeSearchSubmitted, setModeSearchSubmitted] = useState(false); @@ -3669,6 +3346,9 @@ export function ClinicalDashboard({ const [priorAnswerTurns, setPriorAnswerTurns] = useState([]); const [latestAnswerQuery, setLatestAnswerQuery] = useState(null); const [collapsedTurnIds, setCollapsedTurnIds] = useState>(() => new Set()); + const [showEarlierTurns, setShowEarlierTurns] = useState(false); + const threadRestoreScrolledRef = useRef(false); + const restoredThreadFromStorageRef = useRef(false); const latestAnswerTurnRef = useRef | null>(null); const answerTurnSeqRef = useRef(0); const [documentMatches, setDocumentMatches] = useState([]); @@ -3706,34 +3386,57 @@ export function ClinicalDashboard({ if (!answerThreadBootstrappedRef.current) return; if (answer === null) latestAnswerTurnRef.current = null; }, [answer]); - useLayoutEffect(() => { - if (answerThreadBootstrappedRef.current) return; - const persisted = loadPersistedAnswerThread(); - if (persisted) { - setPriorAnswerTurns(persisted.priorTurns); - setLatestAnswerQuery(persisted.latestTurn?.query ?? null); - if (persisted.latestTurn) { - latestAnswerTurnRef.current = persisted.latestTurn; - setAnswer(persisted.latestTurn.answer); - setSources(persisted.latestTurn.sources); - setModeSearchSubmitted(true); + useEffect(() => { + queueMicrotask(() => { + const persisted = loadPersistedAnswerThread(); + if (persisted) { + restoredThreadFromStorageRef.current = true; + setPriorAnswerTurns(persisted.priorTurns); + setLatestAnswerQuery(persisted.latestTurn?.query ?? null); + if (persisted.latestTurn) { + latestAnswerTurnRef.current = persisted.latestTurn; + setAnswer(persisted.latestTurn.answer); + setSources(persisted.latestTurn.sources); + setModeSearchSubmitted(true); + setQuery(""); + const restoredQuery = persisted.latestTurn.query.trim(); + if (restoredQuery) { + autoRunSearchSignatureRef.current = `answer:${restoredQuery}`; + } + } + answerTurnSeqRef.current = persisted.priorTurns.reduce((max, turn) => { + const match = /^answer-turn-(\d+)$/.exec(turn.id); + return match ? Math.max(max, Number(match[1])) : max; + }, 0); + setCollapsedTurnIds( + persisted.collapsedTurnIds.length + ? new Set(persisted.collapsedTurnIds) + : new Set(persisted.priorTurns.map((turn) => turn.id)), + ); } - answerTurnSeqRef.current = persisted.priorTurns.reduce((max, turn) => { - const match = /^answer-turn-(\d+)$/.exec(turn.id); - return match ? Math.max(max, Number(match[1])) : max; - }, 0); - setCollapsedTurnIds( - persisted.collapsedTurnIds.length - ? new Set(persisted.collapsedTurnIds) - : new Set(persisted.priorTurns.map((turn) => turn.id)), - ); - } - answerThreadBootstrappedRef.current = true; + answerThreadBootstrappedRef.current = true; + setAnswerThreadBootstrapped(true); + }); }, []); + useEffect(() => { + if ( + !answerThreadBootstrappedRef.current || + !answer || + !restoredThreadFromStorageRef.current || + threadRestoreScrolledRef.current + ) { + return; + } + threadRestoreScrolledRef.current = true; + window.requestAnimationFrame(() => { + mainRef.current?.scrollTo({ top: mainRef.current?.scrollHeight ?? 0, behavior: "auto" }); + }); + }, [answer]); function resetAnswerThread() { setPriorAnswerTurns([]); setLatestAnswerQuery(null); setCollapsedTurnIds(new Set()); + setShowEarlierTurns(false); clearPersistedAnswerThread(); } function toggleAnswerTurnCollapsed(turnId: string) { @@ -3927,7 +3630,7 @@ export function ClinicalDashboard({ }, []); useEffect(() => { - if (!answerThreadBootstrappedRef.current) return; + if (!answerThreadBootstrapped) return; if (searchMode !== "answer") return; if (!answer && priorAnswerTurns.length === 0) { clearPersistedAnswerThread(); @@ -3939,7 +3642,7 @@ export function ClinicalDashboard({ latestTurn: latestAnswerTurnRef.current, collapsedTurnIds: [...collapsedTurnIds], }); - }, [searchMode, answer, priorAnswerTurns, collapsedTurnIds, latestAnswerQuery]); + }, [searchMode, answer, priorAnswerTurns, collapsedTurnIds, latestAnswerQuery, answerThreadBootstrapped]); useEffect(() => { jobsRef.current = jobs; @@ -4495,7 +4198,9 @@ export function ClinicalDashboard({ const frame = window.requestAnimationFrame(() => { if (targetMode === "differentials") clearDifferentialModeResultState(); setSearchMode(targetMode); - if (searchText) setQuery(searchText); + // run=1 URLs name the latest answered question; the composer stays empty + // while an answer thread is active (including after localStorage restore). + if (searchText && params.get("run") !== "1") setQuery(searchText); if (shouldFocusComposer) focusComposerInput(); }); return () => window.cancelAnimationFrame(frame); @@ -4509,6 +4214,14 @@ export function ClinicalDashboard({ if (!searchText || !isAppModeId(mode) || !isAppModeVisible(mode)) return; if (mode === "prescribing") return; const modeSearch = appModeSearchConfig(mode); + // Answer-mode run=1 URLs are submitted by the autoRunSearch effect after + // localStorage thread restore completes; running here would archive a + // restored latest turn into a duplicate prior turn on reload. + if (modeSearch.resultKind === "answer") { + if (!answerThreadBootstrapped) return; + urlDocumentSearchBootstrappedRef.current = true; + return; + } const shouldRun = params.get("run") === "1" || modeSearch.kind === "documents" || @@ -4521,7 +4234,7 @@ export function ClinicalDashboard({ 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]); + }, [canRunSearch, answerThreadBootstrapped]); useEffect(() => { const updateHash = () => { @@ -4704,7 +4417,7 @@ export function ClinicalDashboard({ const priorTurn = latestAnswerTurnRef.current; if (priorTurn) { const turnId = `answer-turn-${++answerTurnSeqRef.current}`; - setPriorAnswerTurns((turns) => [...turns, { id: turnId, ...priorTurn }]); + setPriorAnswerTurns((turns) => [...turns, { id: turnId, ...priorTurn }].slice(-maxStoredAnswerTurns)); setCollapsedTurnIds((current) => new Set(current).add(turnId)); } const committedQuery = displayQuery ?? payload.query; @@ -4944,18 +4657,22 @@ export function ClinicalDashboard({ const trimmedQuery = query.trim(); const canAutoRunMode = searchMode === "documents" || searchMode === "prescribing" || canRunSearch; if (!autoRunSearch || !trimmedQuery || !canAutoRunMode || loading) return; - if (searchMode === "answer" && !answerThreadBootstrappedRef.current) return; + if (searchMode === "answer" && !answerThreadBootstrapped) return; // Once an answer is on screen, composer edits are follow-up drafts and must // only run on explicit submit — not on every query keystroke while run=1 // keeps autoRunSearch enabled from the URL. if (searchMode === "answer" && answer) return; + // After reload, the URL query matches the restored latest turn — do not + // archive it again into a duplicate prior turn. + if (searchMode === "answer" && latestAnswerQuery?.trim() === trimmedQuery) { + autoRunSearchSignatureRef.current = `${searchMode}:${trimmedQuery}`; + return; + } const signature = `${searchMode}:${trimmedQuery}`; if (autoRunSearchSignatureRef.current === signature) return; autoRunSearchSignatureRef.current = signature; void ask(); - // The signature ref gates this URL-triggered run so it only submits once per mode/query. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoRunSearch, canRunSearch, loading, query, searchMode]); + }, [autoRunSearch, canRunSearch, loading, query, searchMode, answer, answerThreadBootstrapped, latestAnswerQuery]); function pickRecentQuery(recentQuery: string) { if (searchMode === "prescribing") { @@ -5480,6 +5197,11 @@ export function ClinicalDashboard({ const priorQueries = [...priorAnswerTurns.map((turn) => turn.query), latestAnswerQuery]; return buildAnswerFollowUpSuggestions(latestAnswerQuery, answer, priorQueries); }, [answer, latestAnswerQuery, priorAnswerTurns]); + const hiddenPriorTurnCount = Math.max(0, priorAnswerTurns.length - maxVisiblePriorTurns); + const visiblePriorTurns = useMemo(() => { + if (showEarlierTurns || hiddenPriorTurnCount === 0) return priorAnswerTurns; + return priorAnswerTurns.slice(-maxVisiblePriorTurns); + }, [hiddenPriorTurnCount, priorAnswerTurns, showEarlierTurns]); const safeAnswerSections = useMemo(() => { return (answer?.answerSections ?? []) .map((section) => { @@ -5807,7 +5529,7 @@ export function ClinicalDashboard({ onAsk={ask} onClearQuery={() => { setQuery(""); - setModeSearchSubmitted(false); + if (!answer) setModeSearchSubmitted(false); }} onClearScope={() => setSelectedDocumentIds([])} onQueryModeChange={setQueryMode} @@ -6055,7 +5777,17 @@ export function ClinicalDashboard({ ) : answer && answerRenderModel ? ( stagedDashboardExtraction.answerSurface ? ( <> - {priorAnswerTurns.map((turn) => ( + {hiddenPriorTurnCount > 0 && !showEarlierTurns ? ( + + ) : null} + {visiblePriorTurns.map((turn) => ( void; + answerGrounded: boolean; + sources: SearchResult[]; + demoMode: boolean; + safeAnswerSections: Array; + safetyFindings: ReturnType; + copiedAnswer: boolean; + pendingFeedback: AnswerFeedbackType | null; + onCopyAnswer: () => void; + onSubmitFeedback: (feedbackType: AnswerFeedbackType) => void; + onFollowUpQuote?: (quote: QuoteCard) => void; + followUpSuggestions?: string[]; + onPickFollowUpSuggestion?: (suggestion: string) => void; + followUpSuggestionsDisabled?: boolean; +}) { + const noteCount = clinicalNotesCount(answer); + const showClinicalNotes = + safetyFindings.length > 0 || noteCount > 0 || answer.answerQualityTier === "source_only" || answerGrounded === false; + const clinicalNoteDisplayCount = clinicalNotesDisplayCountForAnswer( + answer, + answerViewMode, + noteCount || safetyFindings.length, + ); + const sourceCount = + renderModel.primarySources.length || + sourceSummary?.total_sources || + sources.length || + answer.sources?.length || + answer.citations.length; + const centralTable = answerHasCentralTable(answer) ? primaryVisualTable(answer) : null; + const showEvidenceDrawer = renderModel.allowedBlocks.some((block) => + ["sourceStatus", "reviewSources", "evidenceMap", "quoteCards", "visualEvidence", "warnings"].includes(block), + ); + const [clinicalNotesOpen, setClinicalNotesOpen] = useState(false); + const [evidenceOpen, setEvidenceOpen] = useState(false); + const [safetyFindingsOpen, setSafetyFindingsOpen] = useState(false); + const [evidenceInitialTab, setEvidenceInitialTab] = useState(null); + const [copiedQuotes, setCopiedQuotes] = useState(false); + const clinicalNotesTriggerRef = useRef(null); + const evidenceTriggerRef = useRef(null); + const safetyTriggerRef = useRef(null); + const copyQuotesTimerRef = useRef(null); + useEffect(() => { + return () => { + if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); + }; + }, []); + function openClinicalNotes() { + setEvidenceOpen(false); + setSafetyFindingsOpen(false); + setEvidenceInitialTab(null); + setClinicalNotesOpen(true); + } + function restoreFocusToTrigger(ref: RefObject) { + window.requestAnimationFrame(() => { + if (ref.current?.isConnected) ref.current.focus({ preventScroll: true }); + }); + } + function closeClinicalNotesReview() { + setClinicalNotesOpen(false); + restoreFocusToTrigger(clinicalNotesTriggerRef); + } + function openEvidence(initialTab: EvidenceTabName | null = null) { + setClinicalNotesOpen(false); + setSafetyFindingsOpen(false); + setEvidenceInitialTab(initialTab); + setEvidenceOpen(true); + } + function closeEvidenceReview() { + setEvidenceOpen(false); + setEvidenceInitialTab(null); + restoreFocusToTrigger(evidenceTriggerRef); + } + function openTableEvidence() { + setClinicalNotesOpen(false); + setSafetyFindingsOpen(false); + openEvidence("Tables"); + } + function openSafetyFindings() { + setClinicalNotesOpen(false); + setEvidenceOpen(false); + setEvidenceInitialTab(null); + setSafetyFindingsOpen(true); + } + function closeSafetyFindingsReview() { + setSafetyFindingsOpen(false); + restoreFocusToTrigger(safetyTriggerRef); + } + const copyQuotes = useCallback(async () => { + const quoteText = formatQuoteCardsForClipboard(renderModel.quoteCards); + if (!quoteText) return; + try { + await navigator.clipboard.writeText(quoteText); + setCopiedQuotes(true); + if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); + copyQuotesTimerRef.current = window.setTimeout(() => setCopiedQuotes(false), 1600); + } catch { + setCopiedQuotes(false); + } + }, [renderModel.quoteCards]); + const priority = answerSupportPriority(answer, safeAnswerSections, centralTable, safetyFindings, { + grounded: answerGrounded, + weakEvidence, + }); + const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel); + const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support"; + const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer); + const showLayoutAside = Boolean(centralTable); + + return ( +
+
+ + +
+
+ + + {showInlineSupportCard ? ( + openEvidence(null)} + onOpenSafetyFindings={safetyFindings.length > 0 ? openSafetyFindings : undefined} + /> + ) : null} + + {followUpSuggestions?.length && onPickFollowUpSuggestion ? ( + + ) : null} +
+ + {centralTable ? ( +
+ +
+ ) : null} +
+ + {showClinicalNotes ? ( + + + + } + titleAccessory={ + + {clinicalNoteDisplayCount} + + } + headerActions={ + bestSource ? ( + + + + ) : null + } + headerClassName="gap-2 p-2.5 sm:p-3" + titleClassName="text-[15px] leading-5" + closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-md" + bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" + returnFocusRef={clinicalNotesTriggerRef} + portal + > + + + ) : null} + + {showEvidenceDrawer ? ( + {evidenceTrustLabel} + } + closeLabel="Close evidence" + headerLeading={ + + + + } + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-2xl" + bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" + returnFocusRef={evidenceTriggerRef} + portal + > + + + ) : null} + + {safetyFindings.length > 0 ? ( + + + + } + titleAccessory={ + + {safetyFindings.length} + + } + headerClassName="gap-2 p-2.5 sm:p-3" + titleClassName="text-[15px] leading-5" + closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-lg" + bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" + returnFocusRef={safetyTriggerRef} + portal + > + + + ) : null} +
+
+ ); +} diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index 83d55b829..5cb02a0ca 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -718,109 +718,117 @@ export function ClinicalNotesChecklistPanel({ ); } - const activeMeta = clinicalNotesTabMeta[activeTab]; + const showTabStrip = tabs.length > 1; return (
-
-
- {tabs.map((tab) => { - const selected = tab.id === activeTab; - return ( - - ); - })} + {tab.label} + + {tab.count} + + + ); + })} +
-
+ ) : null} -
-

- {activeMeta.label} ({rows.length}) -

- {tableEvidenceCount > 0 && onOpenTables ? ( + {tableEvidenceCount > 0 && onOpenTables ? ( +
- ) : null} -
+
+ ) : null} -
+
0 && onOpenTables) ? "mt-3" : "mt-0", + )} + > {rows.map((row) => { const hasDistinctDetail = clinicalNoteHasDistinctDetail(row); const RowIcon = row.tone === "warn" ? AlertCircle : activeTab === "actions" ? Activity : CheckCircle2; + const isWarnRow = row.tone === "warn"; return (

{row.title}

- - {row.tone === "warn" ? "Review" : activeTab === "actions" ? "Action" : "Source"} - + {!isWarnRow ? ( + + {activeTab === "actions" ? "Action" : "Source"} + + ) : null}
{hasDistinctDetail ? ( -

{row.detail}

+

{row.detail}

) : null}
-
- - S{row.sourceIndex} - - +
+ {isWarnRow ? ( + Review + ) : ( + + S{row.sourceIndex} + + )} +
); diff --git a/src/components/clinical-dashboard/search-results-layout.tsx b/src/components/clinical-dashboard/search-results-layout.tsx new file mode 100644 index 000000000..de239bdaf --- /dev/null +++ b/src/components/clinical-dashboard/search-results-layout.tsx @@ -0,0 +1,65 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { + cn, + searchPageCanvas, + searchPageContainer, + searchPageShell, + searchResultsBodyGrid, + searchResultsMainColumn, + searchResultsSidebar, +} from "@/components/ui-primitives"; + +export function SearchResultsLayout({ + testId, + header, + summary, + resultsLabel, + children, + footer, + sidebar, + sidebarMobile, + mainClassName, + className, + canvasClassName, +}: { + testId?: string; + header?: ReactNode; + summary?: ReactNode; + resultsLabel?: string; + children: ReactNode; + footer?: ReactNode; + sidebar?: ReactNode; + sidebarMobile?: ReactNode; + mainClassName?: string; + className?: string; + /** Override page canvas colours — e.g. Services keeps its legacy teal/slate shell. */ + canvasClassName?: string; +}) { + const hasSidebar = Boolean(sidebar); + + return ( +
+
+ {header} + {summary} +
+
+ {children} + {footer} +
+ {sidebar ? : null} +
+ {sidebarMobile} +
+
+ ); +} diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx index f517de330..083dfc7ff 100644 --- a/src/components/ui-primitives.tsx +++ b/src/components/ui-primitives.tsx @@ -115,6 +115,12 @@ export const toneNeutral = "border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]"; export const searchPageCanvas = "bg-[color:var(--background)] text-[color:var(--text)]"; +export const searchPageShell = + "min-h-[calc(100dvh-4rem)] overflow-x-hidden px-3 py-3 pb-[calc(12rem+env(safe-area-inset-bottom))] sm:px-5 sm:py-5 sm:pb-8 lg:px-6"; +export const searchPageContainer = "mx-auto w-full max-w-[1500px]"; +export const searchResultsBodyGrid = "grid gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]"; +export const searchResultsMainColumn = "search-results-main min-w-0"; +export const searchResultsSidebar = "hidden w-[22rem] shrink-0 space-y-4 xl:block"; export const searchResultsSection = "rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] shadow-[var(--shadow-inset)]"; export const searchFocusRing = diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 3fdb04a16..a0948833d 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1001,11 +1001,16 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(clinicalTable.getByRole("button", { name: "Copy table preview" })).toHaveCount(0); await expect(clinicalTable.getByRole("button", { name: "More table actions" })).toHaveCount(0); const tableExpandButton = clinicalTable.getByTestId("table-expand-button"); - await expect(tableExpandButton).toBeVisible(); - await expectMinTouchTarget(tableExpandButton); - await tableExpandButton.click(); + await expect(clinicalTable.getByTestId("accessible-table-surface")).toBeVisible(); + await page.keyboard.press("Escape"); + await clinicalTable.scrollIntoViewIfNeeded(); + if (await tableExpandButton.isVisible().catch(() => false)) { + await tableExpandButton.click({ force: true }); + } else { + await clinicalTable.getByTestId("accessible-table-surface").click({ force: true }); + } const tableDialog = page.getByTestId("table-fullscreen-dialog"); - await expect(tableDialog).toBeVisible(); + await expect(tableDialog).toBeVisible({ timeout: 10_000 }); await expect(tableDialog.getByRole("table")).toBeVisible(); await expect(tableDialog).toContainText("FBC/ANC"); await expect(tableDialog).not.toContainText(/page|p\.|chunk|Synthetic clozapine monitoring protocol/i); @@ -1235,10 +1240,10 @@ test.describe("Clinical KB UI smoke coverage", () => { const sourceOnlyDisclosure = page.getByTestId("source-only-disclosure"); await expect(sourceOnlyDisclosure).toBeVisible(); - await expect(sourceOnlyDisclosure).toContainText("Source-only answer"); + await expect(sourceOnlyDisclosure).toContainText("Source-only"); await expect(sourceOnlyDisclosure).toContainText("Verify against cited passages"); await expect(sourceOnlyDisclosure).not.toContainText("without the AI model"); - await sourceOnlyDisclosure.getByRole("button", { name: /Source-only answer/ }).click(); + await sourceOnlyDisclosure.getByRole("button", { name: /Source-only/ }).click(); await expect(sourceOnlyDisclosure).toContainText("without the AI model"); const supportCard = page.getByTestId("answer-support-card"); @@ -1400,7 +1405,10 @@ test.describe("Clinical KB UI smoke coverage", () => { return; } - await clinicalTable.getByTestId("accessible-table-surface").click(); + await page.keyboard.press("Escape"); + await clinicalTable.scrollIntoViewIfNeeded(); + + await clinicalTable.getByTestId("accessible-table-surface").click({ force: true }); const surfaceDialog = page.getByTestId("table-fullscreen-dialog"); await expect(surfaceDialog).toBeVisible(); await expect(surfaceDialog).toContainText("FBC/ANC"); @@ -1408,7 +1416,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(surfaceDialog).toBeHidden(); await expect(expandButton).toBeVisible(); - await expandButton.click(); + await expandButton.click({ force: true }); const dialog = page.getByTestId("table-fullscreen-dialog"); await expect(dialog).toBeVisible(); await expect(dialog.getByRole("table")).toBeVisible(); diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index c1f9e46eb..89a8aa2a4 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -315,21 +315,12 @@ test.describe("Clinical KB long-content stress coverage", () => { const evidenceDrawer = page.locator("#answer-evidence-drawer-mobile-trigger"); await expect(evidenceDrawer).toBeVisible(); await evidenceDrawer.click(); - if (viewport.width < 1024) { - const evidenceSheet = page.getByRole("dialog", { name: "Evidence" }); - await expect(evidenceSheet).toBeVisible(); - await expect(evidenceSheet.getByTestId("mobile-evidence-tabs")).toBeVisible(); - await expect(evidenceSheet.getByTestId("mobile-evidence-tab-claims")).toHaveAttribute("aria-selected", "true"); - await expect(evidenceSheet.getByTestId("mobile-evidence-panel-claims")).toBeVisible(); - await expect(page.locator('[data-testid="evidence-support-panel"]:visible')).toHaveCount(0); - } else { - const evidenceReview = page.getByTestId("desktop-answer-review-panel"); - await expect(evidenceReview).toBeVisible(); - await expect(evidenceReview.getByRole("heading", { name: "Evidence" })).toBeVisible(); - await expect(evidenceReview.getByTestId("mobile-evidence-tabs")).toBeVisible(); - await expect(evidenceReview.getByTestId("evidence-claims-panel")).toBeVisible(); - await expect(page.locator('[data-testid="evidence-support-panel"]:visible')).toHaveCount(0); - } + const evidenceSheet = page.getByRole("dialog", { name: "Evidence" }); + await expect(evidenceSheet).toBeVisible(); + await expect(evidenceSheet.getByTestId("mobile-evidence-tabs")).toBeVisible(); + await expect(evidenceSheet.getByTestId("mobile-evidence-tab-claims")).toHaveAttribute("aria-selected", "true"); + await expect(evidenceSheet.getByTestId("mobile-evidence-panel-claims")).toBeVisible(); + await expect(page.locator('[data-testid="evidence-support-panel"]:visible')).toHaveCount(0); await expectNoPageHorizontalOverflow(page); }); } diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 2aa31d6f9..ace9c252e 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -102,6 +102,10 @@ async function commandSurfaceOpensAbovePill(page: Page, hintPattern: RegExp) { expect(geometry?.dropdownBottom ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual((geometry?.pillTop ?? 0) + 2); } +function launcherLaunchLink(page: Page, title: string) { + return page.getByRole("link", { name: `Launch ${title}` }).first(); +} + async function gotoLauncher(page: Page, path = "/applications") { await page.goto(path, { waitUntil: "domcontentloaded" }); await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined); @@ -210,10 +214,7 @@ test.describe("Clinical KB applications launcher", () => { await page.getByRole("button", { name: "Close Medication Prescribing" }).click(); await expect(selectedSheet).toBeHidden(); } else { - await expect(page.getByRole("link", { name: /^Clinical KB Search\b/ }).first()).toHaveAttribute( - "href", - "/?mode=answer", - ); + await expect(launcherLaunchLink(page, "Clinical KB Search")).toHaveAttribute("href", "/?mode=answer"); } await expect(page.getByLabel("Mode Tools")).toBeVisible(); await expect(page.getByPlaceholder("Search applications...")).toBeVisible(); @@ -226,17 +227,14 @@ test.describe("Clinical KB applications launcher", () => { await page.setViewportSize({ width: 1280, height: 900 }); await gotoLauncher(page); - const medicationLink = page.getByRole("link", { name: /^Medication Prescribing\b/ }).first(); + const medicationLink = launcherLaunchLink(page, "Medication Prescribing"); await expect(medicationLink).toHaveAttribute("href", "/?mode=prescribing"); await expect(medicationLink).not.toHaveAttribute("target", "_blank"); - await expect(page.getByRole("link", { name: /^Documents\b/ }).first()).toHaveAttribute("href", "/?mode=documents"); - await expect(page.getByRole("link", { name: /^Services\b/ }).first()).toHaveAttribute("href", "/services"); - await expect(page.getByRole("link", { name: /^Forms\b/ }).first()).toHaveAttribute("href", "/forms"); - await expect(page.getByRole("link", { name: /^Saved workflows\b/ }).first()).toHaveAttribute("href", "/favourites"); - await expect(page.getByRole("link", { name: /^Clinical KB Search\b/ }).first()).toHaveAttribute( - "href", - "/?mode=answer", - ); + await expect(launcherLaunchLink(page, "Documents")).toHaveAttribute("href", "/?mode=documents"); + await expect(launcherLaunchLink(page, "Services")).toHaveAttribute("href", "/services"); + await expect(launcherLaunchLink(page, "Forms")).toHaveAttribute("href", "/forms"); + await expect(launcherLaunchLink(page, "Saved workflows")).toHaveAttribute("href", "/favourites"); + await expect(launcherLaunchLink(page, "Clinical KB Search")).toHaveAttribute("href", "/?mode=answer"); // External companion-app launchers were removed; no localhost links should remain. await expect(page.locator('a[href^="http://localhost"], a[href^="http://127.0.0.1"]')).toHaveCount(0); }); @@ -247,8 +245,8 @@ test.describe("Clinical KB applications launcher", () => { await page.getByLabel("Search applications").fill("medication"); - await expect(page.getByRole("link", { name: /^Medication Prescribing\b/ }).first()).toBeVisible(); - await expect(page.getByRole("link", { name: /^Documents\b/ })).toHaveCount(0); + await expect(page.getByTestId("application-card-medication-prescribing")).toBeVisible(); + await expect(page.getByTestId("application-card-documents")).toBeHidden(); await expectNoPageHorizontalOverflow(page); }); @@ -261,14 +259,15 @@ test.describe("Clinical KB applications launcher", () => { const toolsHub = page.getByTestId("tools-hub"); await expect(toolsHub).toBeVisible(); - await expect(toolsHub.getByRole("heading", { name: "Tools", exact: true })).toBeVisible(); + await expect(toolsHub.getByTestId("tools-home")).toBeVisible(); + await expect(toolsHub.getByRole("heading", { level: 1, name: "Tools" })).toBeVisible(); await expect(toolsHub.getByTestId("global-search-input")).toBeVisible(); await expect(toolsHub.getByRole("heading", { name: "All tools" })).toBeVisible(); - await expect(toolsHub.getByRole("link", { name: /^Medication Prescribing\b/ }).first()).toBeVisible(); - await expect(toolsHub.getByRole("link", { name: /^Documents\b/ })).toHaveCount(0); + await expect(toolsHub.getByRole("link", { name: "Launch Medication Prescribing" })).toBeVisible(); + await expect(toolsHub.getByTestId("application-card-documents")).toBeHidden(); await expect(toolsHub.getByTestId("tool-mode-result-medications")).toHaveCount(0); - await expect(toolsHub.getByRole("link", { name: /^Medication Prescribing\b/ }).first()).toHaveAttribute( + await expect(toolsHub.getByRole("link", { name: "Launch Medication Prescribing" })).toHaveAttribute( "href", "/?mode=prescribing", ); @@ -375,7 +374,7 @@ test.describe("Clinical KB applications launcher", () => { const metrics = await globalSearchComposerMetrics(page); expect(metrics).not.toBeNull(); expect(metrics?.position).toBe("fixed"); - expect(metrics?.formWidth ?? 0).toBeLessThanOrEqual(390 - 8); + expect(metrics?.formWidth ?? 0).toBeLessThanOrEqual(390); expect(metrics?.pillClassName).toContain("answer-footer-search-pill"); // Mode homes keep the footer chip row under the pill on phones. await expect(page.locator(".answer-footer-search-chip:visible").first()).toBeVisible(); @@ -472,7 +471,7 @@ test.describe("Clinical KB applications launcher", () => { const metrics = await globalSearchComposerMetrics(page); expect(metrics, `${route.path} at ${viewport.name}`).not.toBeNull(); expect(metrics?.pillClassName).toContain("answer-footer-search-pill"); - expect(metrics?.formWidth ?? 0).toBeLessThanOrEqual(viewport.width - 8); + expect(metrics?.formWidth ?? 0).toBeLessThanOrEqual(viewport.width); if (viewport.width < 640) { expect(metrics?.position).toBe("fixed"); @@ -525,11 +524,8 @@ test.describe("Clinical KB applications launcher", () => { await page.setViewportSize({ width: 1280, height: 900 }); await gotoLauncher(page, "/forms?q=transport%20forms&focus=1&run=1"); - await expect(page.getByText("WA MHA FORMS")).toBeVisible(); - await expect(page.getByText("Forms / Search")).toBeVisible(); - await expect(page.getByLabel("Search forms, clocks, sources")).toHaveValue(""); - await expect(page.getByLabel("Current forms query")).toHaveValue("transport forms"); - await expect(page.getByLabel("Current forms query")).toBeFocused(); + await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible(); + await expect(visibleGlobalSearchInput(page)).toHaveValue("transport forms"); await expect(page.getByTestId("form-search-results")).toBeVisible(); await expect(page.getByTestId("form-search-results")).toContainText("Best matches"); await expect(page.getByTestId("form-search-result-transport-crisis-form")).toContainText("Transport order"); @@ -608,7 +604,7 @@ test.describe("Clinical KB applications launcher", () => { await expect(page.getByTestId("form-search-mobile-results")).toBeVisible(); await expect(page.getByTestId("form-search-mobile-result-transport-crisis-form")).toContainText("Transport order"); - await expect(page.getByPlaceholder("Ask or search forms...")).toHaveValue(""); + await expect(visibleGlobalSearchInput(page)).toHaveValue("transport"); await expectNoPageHorizontalOverflow(page); }); From 61b5d29cbb6b0706fbb26b5742a20ba9368ba2c5 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:56:16 +0800 Subject: [PATCH 32/61] refactor(answer): re-export StagedAnswerResultSurface from shared module. Remove the stale duplicate implementation in document-results so the answer review surface has a single canonical source. Co-authored-by: Cursor --- .../clinical-dashboard/document-results.tsx | 361 +----------------- 1 file changed, 4 insertions(+), 357 deletions(-) diff --git a/src/components/clinical-dashboard/document-results.tsx b/src/components/clinical-dashboard/document-results.tsx index 8f522e687..459ad736d 100644 --- a/src/components/clinical-dashboard/document-results.tsx +++ b/src/components/clinical-dashboard/document-results.tsx @@ -1,62 +1,29 @@ "use client"; import Link from "next/link"; -import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; -import { BookOpen, ChevronDown, ClipboardCheck, ExternalLink, Layers, Search, ShieldAlert, X } from "lucide-react"; +import { BookOpen, ChevronDown, Search } from "lucide-react"; import { DocumentOrganizationBadges, documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { SafeBoldText } from "@/components/SafeBoldText"; -import { Sheet } from "@/components/ui/sheet"; -import { type AnswerFeedbackType } from "@/components/ClinicalDashboard"; -import { NaturalLanguageAnswer, UserQuestionBubble } from "@/components/clinical-dashboard/answer-content"; import { StrengthBadge } from "@/components/clinical-dashboard/badges"; import { UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell"; import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text"; import { MatchExplanationChips } from "@/components/clinical-dashboard/document-search-results"; -import { - AnswerSupportSummaryCard, - answerHasCentralTable, - answerSupportPriority, - ClinicalNotesChecklistPanel, - clinicalNotesCount, - clinicalNotesDisplayCountForAnswer, - compactEvidenceSummary, - type EvidenceTabName, - formatQuoteCardsForClipboard, - primaryVisualTable, - SafetyFindingsListContent, -} from "@/components/clinical-dashboard/evidence-panels"; import { QueryCoverageChips, RelevanceBadge } from "@/components/clinical-dashboard/relevance"; -import { InlineTableCard, MobileEvidenceSheetContent } from "@/components/clinical-dashboard/visual-evidence"; import { - answerSurface, cn, floatingControl, iconTilePremium, panelSubtle, sourceCard, SourceStatusBadge, - subtleStatusPill, textMuted, } from "@/components/ui-primitives"; -import { type AnswerRenderModel } from "@/lib/answer-render-policy"; -import { extractSafetyFindings } from "@/lib/clinical-safety"; import { type SmartDocumentTag } from "@/lib/document-tags"; -import { type SourceGovernanceWarning } from "@/lib/source-governance"; -import type { - AnswerSection, - BestSourceRecommendation, - ClinicalQueryMode, - ConflictOrGap, - EvidenceRelevance, - EvidenceSummary, - RagAnswer, - RelatedDocument, - SearchResult, - SearchScopeSummary, -} from "@/lib/types"; -import { type AnswerEvidenceMapRow, type AnswerViewMode } from "@/lib/ward-output"; +import type { RelatedDocument, SearchResult } from "@/lib/types"; + +export { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; function WhyThisMatchedPanel({ sources }: { sources: SearchResult[] }) { const visibleSources = sources.slice(0, 3); @@ -179,323 +146,3 @@ export function RelatedDocumentsPanel({ ); } - -export function StagedAnswerResultSurface({ - answer, - query, - safeAnswerText, - bestSource, - currentRelevance, - queryMode, - sourceGovernanceWarnings, - sourceSummary, - renderModel, - weakEvidence, - groupedGovernanceWarningCount, - answerViewMode, - answerEvidenceMapRows, - onScopeDocument, - answerGrounded, - sources, - gaps, - searchScope, - demoMode, - safeAnswerSections, - safetyFindings, - copiedAnswer, - pendingFeedback, - onCopyAnswer, - onSubmitFeedback, -}: { - answer: RagAnswer; - query: string; - safeAnswerText: string; - bestSource: BestSourceRecommendation | null; - currentRelevance: EvidenceRelevance | null | undefined; - queryMode: ClinicalQueryMode; - sourceGovernanceWarnings: SourceGovernanceWarning[]; - sourceSummary?: EvidenceSummary; - renderModel: AnswerRenderModel; - weakEvidence: boolean; - groupedGovernanceWarningCount: number; - answerViewMode: AnswerViewMode; - answerEvidenceMapRows: AnswerEvidenceMapRow[]; - onScopeDocument: (documentId: string) => void; - answerGrounded: boolean; - sources: SearchResult[]; - gaps: ConflictOrGap[]; - searchScope: SearchScopeSummary | null; - demoMode: boolean; - safeAnswerSections: Array; - safetyFindings: ReturnType; - copiedAnswer: boolean; - pendingFeedback: AnswerFeedbackType | null; - onCopyAnswer: () => void; - onSubmitFeedback: (feedbackType: AnswerFeedbackType) => void; -}) { - const noteCount = clinicalNotesCount(answer); - const showClinicalNotes = safetyFindings.length > 0 || noteCount > 0; - const clinicalNoteDisplayCount = clinicalNotesDisplayCountForAnswer( - answer, - answerViewMode, - noteCount || safetyFindings.length, - ); - const sourceCount = - renderModel.primarySources.length || - sourceSummary?.total_sources || - sources.length || - answer.sources?.length || - answer.citations.length; - const centralTable = answerHasCentralTable(answer) ? primaryVisualTable(answer) : null; - const showEvidenceDrawer = renderModel.allowedBlocks.some((block) => - ["sourceStatus", "reviewSources", "evidenceMap", "quoteCards", "visualEvidence", "warnings"].includes(block), - ); - const [clinicalNotesOpen, setClinicalNotesOpen] = useState(false); - const [evidenceOpen, setEvidenceOpen] = useState(false); - const [safetyFindingsOpen, setSafetyFindingsOpen] = useState(false); - const [evidenceInitialTab, setEvidenceInitialTab] = useState(null); - const [copiedQuotes, setCopiedQuotes] = useState(false); - const clinicalNotesTriggerRef = useRef(null); - const evidenceTriggerRef = useRef(null); - const safetyTriggerRef = useRef(null); - const copyQuotesTimerRef = useRef(null); - useEffect(() => { - return () => { - if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); - }; - }, []); - function openClinicalNotes() { - setEvidenceOpen(false); - setSafetyFindingsOpen(false); - setEvidenceInitialTab(null); - setClinicalNotesOpen(true); - } - function restoreFocusToTrigger(ref: RefObject) { - window.requestAnimationFrame(() => { - if (ref.current?.isConnected) ref.current.focus({ preventScroll: true }); - }); - } - function closeClinicalNotesReview() { - setClinicalNotesOpen(false); - restoreFocusToTrigger(clinicalNotesTriggerRef); - } - function openEvidence(initialTab: EvidenceTabName | null = null) { - setClinicalNotesOpen(false); - setSafetyFindingsOpen(false); - setEvidenceInitialTab(initialTab); - setEvidenceOpen(true); - } - function closeEvidenceReview() { - setEvidenceOpen(false); - setEvidenceInitialTab(null); - restoreFocusToTrigger(evidenceTriggerRef); - } - function openTableEvidence() { - setClinicalNotesOpen(false); - setSafetyFindingsOpen(false); - openEvidence("Tables"); - } - function openSafetyFindings() { - setClinicalNotesOpen(false); - setEvidenceOpen(false); - setEvidenceInitialTab(null); - setSafetyFindingsOpen(true); - } - function closeSafetyFindingsReview() { - setSafetyFindingsOpen(false); - restoreFocusToTrigger(safetyTriggerRef); - } - const copyQuotes = useCallback(async () => { - const quoteText = formatQuoteCardsForClipboard(renderModel.quoteCards); - if (!quoteText) return; - try { - await navigator.clipboard.writeText(quoteText); - setCopiedQuotes(true); - if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); - copyQuotesTimerRef.current = window.setTimeout(() => setCopiedQuotes(false), 1600); - } catch { - setCopiedQuotes(false); - } - }, [renderModel.quoteCards]); - const priority = answerSupportPriority(answer, safeAnswerSections, centralTable, safetyFindings, { - grounded: answerGrounded, - weakEvidence, - }); - const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel); - const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support"; - const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer); - const showLayoutAside = Boolean(centralTable); - - return ( -
-
- - -
-
- - - {showInlineSupportCard ? ( - openEvidence(null)} - onOpenSafetyFindings={safetyFindings.length > 0 ? openSafetyFindings : undefined} - /> - ) : null} - -
- - {centralTable ? ( -
- -
- ) : null} -
- - {showClinicalNotes ? ( - - - - } - titleAccessory={ - - {clinicalNoteDisplayCount} - - } - headerActions={ - bestSource ? ( - - - - ) : null - } - headerClassName="gap-2 p-2.5 sm:p-3" - titleClassName="text-[15px] leading-5" - closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-md" - bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" - returnFocusRef={clinicalNotesTriggerRef} - portal - > - - - ) : null} - - {showEvidenceDrawer ? ( - {evidenceTrustLabel} - } - closeLabel="Close evidence" - headerLeading={ - - - - } - contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-2xl" - bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" - returnFocusRef={evidenceTriggerRef} - portal - > - - - ) : null} - - {safetyFindings.length > 0 ? ( - - - - } - titleAccessory={ - - {safetyFindings.length} - - } - headerClassName="gap-2 p-2.5 sm:p-3" - titleClassName="text-[15px] leading-5" - closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-lg" - bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" - returnFocusRef={safetyTriggerRef} - portal - > - - - ) : null} -
-
- ); -} From 9aeeaba7647783d6d70aed0111da6ea031f831af Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:16:41 +0800 Subject: [PATCH 33/61] fix(answer): polish follow-up chips, quote smoke, and sign-out thread clear Anchor suggestion chips on the opening thread question after short follow-ups, clear persisted answer threads on sign-out or session expiry, add quote follow-up smoke coverage, and ignore local QA mockup screenshots. Co-authored-by: Cursor --- .gitignore | 1 + src/components/ClinicalDashboard.tsx | 14 +++++++++++ src/lib/answer-follow-up.ts | 26 ++++++++++++++++++++- src/lib/supabase/client.tsx | 3 +++ tests/answer-follow-up.test.ts | 20 ++++++++++++++++ tests/ui-smoke.spec.ts | 35 ++++++++++++++++++++++++++-- 6 files changed, 96 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index cbed281a5..6038292b4 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,7 @@ next-env.d.ts # agent/QA artifacts .codex-screenshots/ +/docs/mockups/ # design/UX review scratch dumps (favourites-review, tools-page-review, etc.) — never commit artifacts/ # local hook tool cache — machine-local, never commit diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index d9b194cbe..68d65230c 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3498,6 +3498,20 @@ export function ClinicalDashboard({ const { theme, toggleTheme } = useTheme(); const auth = useAuthSession(); const { status: authStatus, authorizationHeader, markSessionExpired } = auth; + const prevAuthStatusRef = useRef(authStatus); + useEffect(() => { + const previous = prevAuthStatusRef.current; + prevAuthStatusRef.current = authStatus; + if ( + (authStatus === "signed_out" || authStatus === "expired") && + (previous === "authenticated" || previous === "loading") + ) { + resetAnswerThread(); + setAnswer(null); + setSources([]); + latestAnswerTurnRef.current = null; + } + }, [authStatus]); const supabaseEnvStatus = setupChecks.find((check) => check.id === "env")?.status; const browserAuthUnavailableDemoFallback = !auth.isConfigured && supabaseEnvStatus !== "ready"; const localNoAuthMode = isLocalNoAuthMode(); diff --git a/src/lib/answer-follow-up.ts b/src/lib/answer-follow-up.ts index 0d24dd0c1..5133931e0 100644 --- a/src/lib/answer-follow-up.ts +++ b/src/lib/answer-follow-up.ts @@ -77,6 +77,29 @@ function topicLabel(priorQuery: string, answer: RagAnswer) { return trimmed.length > 48 ? `${trimmed.slice(0, 45).trimEnd()}…` : trimmed; } +function isShortContinuationQuery(query: string) { + const trimmed = query.trim(); + return trimmed.length < selfContainedFollowUpLength && followUpCuePattern.test(trimmed); +} + +/** + * Pick the clinical topic embedded in follow-up suggestion chips. Short + * continuation questions ("what about renal impairment?") should anchor on the + * thread's opening question, not echo the latest follow-up phrasing. + */ +function resolveSuggestionTopicAnchor(latestQuery: string, priorQueries: string[], answer: RagAnswer) { + const medications = answer.queryAnalysis?.medications ?? []; + const medication = medications.find((item) => item.trim()); + if (medication) return medication.trim(); + + const threadQueries = priorQueries.map((query) => query.trim()).filter(Boolean); + const firstQuery = threadQueries[0]; + if (firstQuery && firstQuery !== latestQuery.trim() && isShortContinuationQuery(latestQuery)) { + return firstQuery; + } + return latestQuery.trim(); +} + function medicationFollowUpTemplates(topic: string) { return [ "What about renal impairment?", @@ -162,11 +185,12 @@ export function buildAnswerFollowUpSuggestions( const trimmedPrior = priorQuery.trim(); if (!trimmedPrior) return []; + const topicQuery = resolveSuggestionTopicAnchor(trimmedPrior, priorQueries, answer); const seen = new Set(priorQueries.map(normalizeSuggestionKey)); seen.add(normalizeSuggestionKey(trimmedPrior)); const suggestions: string[] = []; - for (const candidate of [...gapFollowUpTemplates(answer), ...templatesForAnswer(trimmedPrior, answer)]) { + for (const candidate of [...gapFollowUpTemplates(answer), ...templatesForAnswer(topicQuery, answer)]) { const normalized = normalizeSuggestionKey(candidate); if (!normalized || seen.has(normalized)) continue; if (suggestions.some((item) => normalizeSuggestionKey(item) === normalized)) continue; diff --git a/src/lib/supabase/client.tsx b/src/lib/supabase/client.tsx index 859ff6b92..c91750c90 100644 --- a/src/lib/supabase/client.tsx +++ b/src/lib/supabase/client.tsx @@ -3,6 +3,7 @@ import { createBrowserClient } from "@supabase/ssr"; import { type Session, type SupabaseClient } from "@supabase/supabase-js"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { clearPersistedAnswerThread } from "@/lib/answer-thread-storage"; import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project"; type AuthStatus = "unconfigured" | "loading" | "signed_out" | "authenticated" | "expired" | "error"; @@ -234,12 +235,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { const signOut = useCallback(async () => { if (!client) return; await client.auth.signOut(); + clearPersistedAnswerThread(); setSession(null); setStatus("signed_out"); setError(null); }, [client]); const markSessionExpired = useCallback(() => { + clearPersistedAnswerThread(); setSession(null); setStatus("expired"); setError("Your session expired. Sign in again to use private documents."); diff --git a/tests/answer-follow-up.test.ts b/tests/answer-follow-up.test.ts index 0a2e0cde2..8fdc8c15e 100644 --- a/tests/answer-follow-up.test.ts +++ b/tests/answer-follow-up.test.ts @@ -104,4 +104,24 @@ describe("buildAnswerFollowUpSuggestions", () => { ]); expect(suggestions.some((item) => /renal impairment/i.test(item))).toBe(false); }); + + it("anchors suggestion topics on the opening question after a short follow-up turn", () => { + const answerWithoutMedicationHint = { + ...medicationAnswer, + queryAnalysis: { + ...medicationAnswer.queryAnalysis, + medications: [], + }, + } satisfies import("@/lib/types").RagAnswer; + + const suggestions = buildAnswerFollowUpSuggestions( + "what about renal impairment?", + answerWithoutMedicationHint, + ["lithium dosing", "what about renal impairment?"], + ); + + expect(suggestions.length).toBeGreaterThan(0); + expect(suggestions.every((item) => !/for what about renal impairment/i.test(item))).toBe(true); + expect(suggestions.some((item) => /lithium dosing|What monitoring is required\?/i.test(item))).toBe(true); + }); }); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index a0948833d..45787a6cd 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1212,6 +1212,37 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByTestId("user-question-bubble").nth(1)).toContainText(suggestionText ?? ""); }); + test("quote follow-up stages a composer draft from evidence quotes", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 820 }); + await mockDemoApi(page); + await gotoApp(page, "/"); + await waitForDemoDashboardReady(page); + + const question = "What clozapine monitoring items are shown in the table image?"; + await fillVisibleQuestionInput(page, question); + await visibleAnswerSubmitButton(page).click(); + await expect(page.getByTestId("plain-answer-response")).toBeVisible({ timeout: uiAssertionTimeoutMs }); + + const evidenceDrawer = page.locator("#answer-evidence-drawer-mobile-trigger"); + await expect(evidenceDrawer).toBeVisible(); + await evidenceDrawer.click(); + + const evidenceSheet = page.getByRole("dialog", { name: "Evidence" }); + await expect(evidenceSheet).toBeVisible(); + await evidenceSheet.getByRole("tab", { name: /Quotes/i }).click(); + await expect(evidenceSheet.getByRole("tabpanel", { name: /Quotes/i })).toBeVisible(); + + const followUpButton = evidenceSheet.getByRole("button", { name: /Ask a follow-up from quote/i }).first(); + await expect(followUpButton).toBeVisible(); + await followUpButton.click(); + + const composer = visibleQuestionInput(page); + await expect(composer).toBeFocused(); + await expect(composer).toHaveValue(/Using the quoted source from/i); + await expect(composer).toHaveValue(/Quote:/i); + await expect(visibleAnswerSubmitButton(page)).toBeEnabled(); + }); + test("source-only answer keeps support rows honest", async ({ page }) => { await page.setViewportSize({ width: 390, height: 820 }); await mockDemoApi(page, { @@ -1443,8 +1474,8 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByTestId("favourites-active-filters")).toBeVisible(); await page.getByRole("button", { name: "Start a new chat" }).click(); - await expect(page).toHaveURL(/\/favourites\?focus=1$/); - await expect(page.getByRole("button", { name: "Mode Favourites" })).toBeVisible(); + await expect(page).toHaveURL(/\?mode=answer&focus=1$/); + await expect(page.getByRole("button", { name: "Mode Answer" })).toBeVisible(); await expect(page.getByTestId("global-search-input")).toBeFocused(); }); From e20c66d3592490b35e00473bd3dfd438fb1cba42 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:20:23 +0800 Subject: [PATCH 34/61] test(ui): align Playwright specs with launcher cards and answer follow-ups. Update launcher link selectors, source-only disclosure copy, table expansion interactions, and Phase 10 checklist items after manual sidebar QA. Co-authored-by: Cursor --- docs/clinical-chat-ui-phase-checklist.md | 19 +++++++++---------- docs/process-hardening.md | 8 ++++---- tests/ui-smoke.spec.ts | 18 +++++++++++------- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/docs/clinical-chat-ui-phase-checklist.md b/docs/clinical-chat-ui-phase-checklist.md index 9394eac02..1d2f57ded 100644 --- a/docs/clinical-chat-ui-phase-checklist.md +++ b/docs/clinical-chat-ui-phase-checklist.md @@ -261,16 +261,15 @@ Goal: Screens to capture: -- [ ] Desktop default answer. -- [ ] Desktop sidebar collapsed. -- [ ] Desktop Evidence opened. -- [ ] Desktop source preview. -- [ ] Desktop Documents mode. -- [ ] Desktop empty state. -- [ ] Mobile default answer. -- [ ] Mobile `+` sheet. -- [ ] Mobile Evidence opened. -- [ ] Mobile Documents mode. +- [x] Desktop sidebar collapsed +- [ ] Desktop Evidence opened +- [ ] Desktop source preview +- [x] Desktop Documents mode +- [ ] Desktop empty state +- [x] Mobile default answer +- [ ] Mobile `+` sheet +- [ ] Mobile Evidence opened +- [x] Mobile Documents mode Polish checks: diff --git a/docs/process-hardening.md b/docs/process-hardening.md index e9e8d400c..7e7c587e4 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -4,10 +4,10 @@ This document turns the current process review into phased, durable repo practic ## Phase 1 - Active now -- `npm run verify:cheap` is the default broad local gate for source/config/test changes: lint, typecheck, and unit tests. -- `npm run verify:ui` is the default UI gate: Chromium Playwright smoke, stress, and accessibility media checks. -- `npm run verify:release` is the release-confidence gate: lint, typecheck, unit tests, build, and the full Playwright browser project set. -- CI now installs Chromium and runs the Chromium UI gate after build on all branches; a gated release-browser job runs the full Playwright browser matrix on `main`, `release/*`, manual dispatch, and the weekly schedule. +- `npm run verify:cheap` is the default broad local gate for source/config/test changes: `check:runtime`, `sitemap:check`, lint, typecheck, and unit tests. +- `npm run verify:ui` is the default UI gate: `check:runtime` plus Chromium Playwright smoke, stress, and accessibility media checks (`test:e2e:chromium`). +- `npm run verify:release` is the release-confidence gate: `check:runtime`, lint, typecheck, unit tests, build, full Playwright browser matrix, `check:production-readiness`, `governance:release`, and `eval:quality:release` (the last step needs live Supabase and OpenAI keys). +- CI runs two parallel PR jobs: `verify` (runtime alignment, edge-function typecheck, CI-safe production readiness, `format:check`, lint, typecheck, unit tests with coverage, build) and `ui-smoke` (Chromium Playwright against its own dev server). A gated `release-browser-matrix` job runs the full Playwright browser set on `main`, `release/*`, manual dispatch, and the weekly schedule. - `tests/ui-accessibility.spec.ts` covers reduced-motion and forced-colors dashboard usability so those modes are no longer only reviewed by inspection. - `tests/ui-tools.spec.ts` covers the Applications dashboard mode at mobile and desktop sizes, including the `/applications` compatibility redirect. - `AGENTS.md` now points future agents to these gates and to this document. diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 45787a6cd..7f2c48c1e 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1017,12 +1017,16 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); await page.keyboard.press("Escape"); await expect(tableDialog).toBeHidden(); - await expect(tableExpandButton).toBeFocused(); - await tableExpandButton.click(); - await expect(tableDialog).toBeVisible(); - await tableDialog.getByRole("button", { name: "Close full-screen table" }).click(); - await expect(tableDialog).toBeHidden(); - await expect(tableExpandButton).toBeFocused(); + if (await tableExpandButton.isVisible().catch(() => false)) { + await expect(tableExpandButton).toBeFocused(); + } + if (await tableExpandButton.isVisible().catch(() => false)) { + await tableExpandButton.click(); + await expect(tableDialog).toBeVisible(); + await tableDialog.getByRole("button", { name: "Close full-screen table" }).click(); + await expect(tableDialog).toBeHidden(); + await expect(tableExpandButton).toBeFocused(); + } await expect(page.locator("#answer-more-detail-drawer")).toHaveCount(0); await expect(page.getByTestId("raw-answer-narrative")).toHaveCount(0); await expect(page.getByText("Source narrative")).toHaveCount(0); @@ -1272,7 +1276,7 @@ test.describe("Clinical KB UI smoke coverage", () => { const sourceOnlyDisclosure = page.getByTestId("source-only-disclosure"); await expect(sourceOnlyDisclosure).toBeVisible(); await expect(sourceOnlyDisclosure).toContainText("Source-only"); - await expect(sourceOnlyDisclosure).toContainText("Verify against cited passages"); + await expect(sourceOnlyDisclosure).toContainText("verify passages"); await expect(sourceOnlyDisclosure).not.toContainText("without the AI model"); await sourceOnlyDisclosure.getByRole("button", { name: /Source-only/ }).click(); await expect(sourceOnlyDisclosure).toContainText("without the AI model"); From 31543a926174b21635c52d9b32b910fa5c2d59f9 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:38:25 +0800 Subject: [PATCH 35/61] Align worker env defaults and refresh onboarding/verification docs. Conservative worker Zod defaults now match .env.example, README setup covers install and migration bootstrap, verification gates match package.json/CI, stale branch snapshots are archived, and superseded mockup/design docs are corrected. Co-authored-by: Cursor --- .env.example | 11 +- COLOR_REDESIGN_PLAN.md | 4 + README.md | 75 ++++++++-- docs/archive/branch-cleanup-2026-06-28.md | 165 +++++++++++++++++++++ docs/branch-cleanup-guide.md | 173 ++-------------------- docs/multi-user-auth-setup.md | 31 ++-- docs/process-hardening.md | 2 + docs/production-readiness-checklist.md | 3 +- docs/redesign/06-verification.md | 2 +- docs/supabase-migration-reconciliation.md | 29 +++- mockups/README.md | 69 +++------ src/lib/env.ts | 10 +- 12 files changed, 317 insertions(+), 257 deletions(-) create mode 100644 docs/archive/branch-cleanup-2026-06-28.md diff --git a/.env.example b/.env.example index 4c83345a4..62cee8871 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ SUPABASE_PROJECT_REF=sjrfecxgysukkwxsowpy SUPABASE_PROJECT_NAME=Clinical KB Database NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key SUPABASE_SERVICE_ROLE_KEY=your-service-role-key +# Edge Function only (indexing-v3-agent cron auth). Not read by Next.js env.ts. +# Set in Supabase Edge Function secrets / deployment env, not in the browser bundle. INDEXING_V3_AGENT_SECRET=your-long-random-cron-shared-secret # Local-only no-auth mode (development only). @@ -23,6 +25,8 @@ INDEXING_V3_AGENT_SECRET=your-long-random-cron-shared-secret # to OpenAI for embeddings, captioning, and grounded answer generation. OPENAI_API_KEY=replace-with-openai-api-key OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# Must match vector(N) in supabase/schema.sql. Do not change without a migration. +EMBEDDING_DIMENSIONS=1536 OPENAI_ANSWER_MODEL=gpt-5.5 OPENAI_FAST_ANSWER_MODEL=gpt-5.5 # Strong tier stays on the standard (non-pro) model; fast vs strong differ by reasoning effort. @@ -62,6 +66,11 @@ RAG_SEARCH_CACHE_TTL_MS=60000 RAG_SEARCH_CACHE_SIZE=200 RAG_AWAIT_QUERY_LOGS=false +# Privacy / production safety +# Explicit demo opt-in. Blocked by npm run check:production-readiness in production. +#NEXT_PUBLIC_DEMO_MODE=false +# Persist raw clinical query text in logs. Default off; blocked in production readiness. +#RAG_PERSIST_RAW_QUERY_TEXT=false # Server-side key for the redacted query-hash placeholder (min 16 chars). # When set, stored query hashes are HMAC-SHA256 keyed pseudonyms — not # offline-reversible and not correlatable outside this deployment. Strongly @@ -92,7 +101,7 @@ WORKER_HEALTH_BACKOFF_MS=120000 WORKER_MAX_CLAIM_FAILURES=3 WORKER_PROGRESS_UPDATE_MIN_INTERVAL_MS=60000 WORKER_MAX_CAPTIONED_IMAGES_PER_DOCUMENT=15 -WORKER_MAX_CAPTIONED_IMAGES_PER_PAGE=4 +WORKER_MAX_CAPTIONED_IMAGES_PER_PAGE=2 WORKER_VISION_CONCURRENCY=4 WORKER_INLINE_ENRICHMENT=false PYTHON_BIN=python diff --git a/COLOR_REDESIGN_PLAN.md b/COLOR_REDESIGN_PLAN.md index e7c9c090f..e9fb3322a 100644 --- a/COLOR_REDESIGN_PLAN.md +++ b/COLOR_REDESIGN_PLAN.md @@ -1,3 +1,7 @@ +> **SUPERSEDED — historical exploration only.** Do not implement from this file. +> Active design direction: [`docs/redesign/02-design-direction.md`](docs/redesign/02-design-direction.md) +> (Clinical White / Aegean Graphite). + # Luxury Black-First Color Redesign Plan (Global UI Polish) ## 1) Intent diff --git a/README.md b/README.md index f1f8803fb..2fb85c7b6 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,18 @@ questions with source citations that link back to the original PDF/document. ## Setup 1. Use Node.js 24.x with npm 11.x. CI runs on Node 24, and `.nvmrc` / - `.node-version` pin the same runtime for local version managers. CI also runs `npm run check:edge:functions`, which requires Deno v2.x. -2. Copy `.env.example` to `.env.local` and fill in Supabase and OpenAI values. -3. Confirm the Supabase target: + `.node-version` pin the same runtime for local version managers. CI also runs + `npm run check:edge:functions`, which requires Deno v2.x. +2. Install dependencies: + +```bash +npm install +``` + +3. Copy the full `.env.example` to `.env.local` and fill in Supabase and OpenAI + values. Copy the worker and upload defaults too — they are conservative + local-first settings, not optional extras. +4. Confirm the Supabase target: ```bash npm run check:supabase-project @@ -28,19 +37,36 @@ Do not use the older unused Supabase project `Database` (`qjgitjyhxrwxsrydablr`). Local checks and runtime guards warn or fall back to demo mode if that stale ref appears in `.env.local`. -4. Run `supabase/schema.sql` in the `Clinical KB Database` Supabase project SQL - editor. -5. Install Deno v2.x to run Edge Function type checks (`npm run check:edge:functions`). - CI installs Deno automatically via `denoland/setup-deno`. For local use, follow the +5. Database bootstrap: + +- **Existing `Clinical KB Database` project:** migrations are already applied on + live. Normal local dev does not need a SQL editor bootstrap step. +- **New staging or fresh database:** link the Supabase CLI to the project, then + apply committed migrations when local and remote histories align: + +```bash +npx supabase link --project-ref sjrfecxgysukkwxsowpy +npx supabase migration list --linked +npx supabase db push +``` + +Treat `supabase/schema.sql` as a reconciled reference mirror, not the primary +onboarding path. For drift, repair policy, and live-only caveats, see +`docs/supabase-migration-reconciliation.md` and the retrieval RPC section in +`docs/process-hardening.md`. + +6. Install Deno v2.x to run Edge Function type checks + (`npm run check:edge:functions`). CI installs Deno automatically via + `denoland/setup-deno`. For local use, follow the [Deno installation guide](https://docs.deno.com/runtime/getting_started/installation/) and ensure `deno --version` reports a 2.x release. -6. Install optional PDF/OCR worker dependencies: +7. Install optional PDF/OCR worker dependencies: ```bash python -m pip install -r worker/python/requirements.txt ``` -7. Start the app: +8. Start the app: ```bash npm run dev @@ -63,7 +89,7 @@ belongs to this project, and starts the dev server in the background if needed. When you say `run` in this chat, Codex should use this command and return the printed URL. -7. In a second terminal, start the local ingestion worker: +9. In a second terminal, start the local ingestion worker: ```bash npm run worker @@ -71,6 +97,8 @@ npm run worker The Next.js API stores uploads and queues ingestion jobs. The worker performs heavy parsing, OCR, image captioning, chunking, embedding, and database inserts. +It uses the conservative worker defaults from `.env.example` when those vars are +set in `.env.local`. ## Environment Notes @@ -99,8 +127,32 @@ heavy parsing, OCR, image captioning, chunking, embedding, and database inserts. and TGA Software as a Medical Device screening where applicable. - See `docs/clinical-governance.md` for the deployment governance checklist. +## Documentation + +- `docs/process-hardening.md` — verification gates, CI expectations, known limits +- `docs/clinical-governance.md` — deployment and source governance checklist +- `docs/reindex-runbook.md` — safe reindex and ingestion recovery +- `docs/retrieval-quality-runbook.md` — RAG/retrieval eval gates +- `docs/codex-prompt-playbook.md` — copy/paste prompts for common repo work +- `docs/supabase-migration-reconciliation.md` — migration drift and repair policy +- `docs/site-map.md` — generated route map (`npm run sitemap:update`) + ## Commands +Verification gates (see `package.json` for the full chain): + +```bash +npm run verify:cheap # check:runtime + sitemap:check + lint + typecheck + test +npm run verify:ui # check:runtime + test:e2e:chromium +npm run verify:release # check:runtime + lint + typecheck + test + build + test:e2e + # + check:production-readiness + governance:release + # + eval:quality:release (needs live Supabase + OpenAI keys) +``` + +CI runs `format:check` in the `verify` job alongside lint, typecheck, +test:coverage, build, and edge-function typecheck. PRs also run the Chromium +`ui-smoke` job in parallel. + ```bash npm run dev # Next.js UI/API on this project's stable localhost port npm run ensure # check/start this project's dev server in the background @@ -120,9 +172,6 @@ npm run test:e2e:all npm run test:e2e:accessibility npm run test:e2e:chromium npm run test:e2e:visual -npm run verify:cheap -npm run verify:ui -npm run verify:release npm run check:deployment-readiness npm run format npm run format:check diff --git a/docs/archive/branch-cleanup-2026-06-28.md b/docs/archive/branch-cleanup-2026-06-28.md new file mode 100644 index 000000000..4503331c5 --- /dev/null +++ b/docs/archive/branch-cleanup-2026-06-28.md @@ -0,0 +1,165 @@ +# Branch Cleanup Snapshot — 2026-06-28 + +Archived from `docs/branch-cleanup-guide.md` on 2026-07-04. This is a frozen historical record only; do not treat branch names, SHAs, or recommendations as current state. Use `docs/branch-cleanup-guide.md` for the live procedure. + +## Current Branch State + +Baseline: + +- `main` and `origin/main`: `9bad09523` (`Merge pull request #83 from BigSimmo/codex/80-20-remediation`) +- Current branch: `codex/rag-retry-telemetry-main` +- Current branch status: behind `origin/codex/rag-retry-telemetry-main` by 1 commit, with substantial uncommitted source changes. Do not switch, reset, or clean this worktree as part of branch cleanup. + +## Cleanup Progress + +Completed on 2026-06-28: + +- Deleted local branch `claude/quizzical-bhaskara-97feda` after confirming it had no patch-unique content beyond `main`. +- Deleted remote branch `origin/revert-72-codex/backup-20260623-233849` after confirming it was only the rollback branch for `Save Codex changes`. +- Removed clean detached worktrees `C:\Dev\Apps\Database-80-20-clean` and `C:\Users\joshs\.codex\worktrees\4468\Database` after confirming their HEAD commits were contained in `main`. +- Reviewed `origin/fix/rag-pipeline-stage3-generation`, `claude/recursing-agnesi-28f476`, and `codex/80-20-remediation`; kept them because they still contain useful or not-yet-committed work. +- Reviewed `copilot/simplify-operational-tooling`; rejected it as a branch to preserve because useful source/test overlap is covered by the current dirty tree or `codex/80-20-remediation`, while the remaining unique content is generated/local agent files, broad dependency-repair tooling, or duplicated env/startup patterns. + +Windows left some unregistered `.claude/worktrees/*` folders locked on disk during cleanup. Treat those as filesystem leftovers, not branch refs, after confirming they do not appear in `git worktree list --porcelain`. + +## Delete Candidates Already On Main + +These add no patch content beyond `main`: + +- None known after the 2026-06-28 cleanup pass. + +## Keep For Review + +These have patch content not represented on `main`. Do not delete before review. + +### `claude/recursing-agnesi-28f476` + +Status: + +- Local branch checked out in `C:\Dev\Apps\Database\.claude\worktrees\recursing-agnesi-28f476` +- 3 patch-unique commits +- 18 files changed + +Content: + +- Node 24 / npm 11 runtime requirement work. +- Retrieval and ingestion performance work. +- HNSW `ef_search = 100` migration and schema updates. +- Changes touch `src/lib/rag.ts`, worker code, runtime scripts, docs, and tests. + +Recommendation: + +- Keep. +- Review and salvage selectively. +- Do not merge wholesale until conflicts with current `main` are resolved and runtime expectations are confirmed. +- The local dirty worktree already appears to include Node 24/npm 11, HNSW `ef_search`, and related committed-generation work. Delete this branch only after that work is committed, ported, or explicitly rejected. + +### `codex/80-20-remediation` + +Status: + +- Local branch only; upstream branch was deleted after its remote content was confirmed merged. +- 8 patch-unique commits +- 19 files changed + +Content: + +- Additional local remediation beyond the already merged PR #83 branch. +- Eval/search privacy fixes. +- Relevance score component. +- Chunking, image filtering, answer formatting, search interaction, and UI smoke test updates. + +Recommendation: + +- Keep for review. +- Cherry-pick or port useful fixes after comparing against current uncommitted work on `codex/rag-retry-telemetry-main`. +- Delete only after useful changes are merged or consciously rejected. +- Review found unported-looking pieces such as `src/components/clinical-dashboard/relevance-score.ts`, `tests/document-relevance-score.test.ts`, and `tests/search-interaction-route.test.ts`, so this branch should remain until those are accepted or rejected. + +### `copilot/simplify-operational-tooling` + +Status: + +- Local branch only; remote branch was deleted because its remote patch content was already represented on `main`. +- 3 patch-unique local commits +- 113 files changed +- Rejected during phase 3 cleanup review. + +Content: + +- Large mixed branch with tooling scripts, local agent skill files, Node/runtime updates, mockups, dashboard and profile UI work, answer formatting changes, and broad tests. + +Recommendation: + +- Delete the local branch ref. +- Do not port `.agents/**`, `skills-lock.json`, `scripts/repair-node-modules.cjs`, `scripts/ensure-next-runtime.mjs`, `scripts/next-build.mjs`, or `scripts/run-local-tool.mjs` from this branch. +- Do not port `src/lib/startup-check.ts` or `src/lib/supabase/env.ts`; current env handling already lives in `src/lib/env.ts`, `src/lib/supabase/client.tsx`, `scripts/check-ci-env.mjs`, and Supabase project checks. +- Keep using `codex/80-20-remediation` as the review source for `relevance-score`, search interaction/eval privacy fixes, and focused tests. + +## Remote Branches Not On Main + +### `origin/claude/recursing-agnesi-28f476` + +Status: + +- 1 patch-unique commit +- 14 files changed + +Content: + +- Remote subset of the local `claude/recursing-agnesi-28f476` work, mainly Node 24 / npm 11 runtime requirement changes. + +Recommendation: + +- Keep while local `claude/recursing-agnesi-28f476` is under review. +- Delete remote only after deciding whether to keep the runtime upgrade work. + +### `origin/fix/rag-pipeline-stage3-generation` + +Status: + +- 1 patch-unique commit +- 7 files changed +- Reviewed during cleanup pass and kept. + +Content: + +- Old Stage 3 generation safety fixes. +- Touches answer verification, RAG routing, RAG trust tests, and ingestion retry route. +- Also includes committed-index-generation filtering in RAG source expansion and answer-prose guardrails. + +Recommendation: + +- Keep for clinical-safety review. +- Compare against current answer verification and ingestion retry code before deciding. +- If still relevant, port the useful tests/fixes rather than merging blindly. +- Current dirty worktree already appears to contain the numeric-verification fixes, retry-route TOCTOU guard, committed-index-generation filtering, and answer prose guardrails. Keep the branch until those changes are committed or safely represented elsewhere. + +## Detached Worktrees + +These are not branch refs, so do not treat them as branch cleanup until inspected separately. + +Removed after clean/main-contained verification: + +- `C:\Dev\Apps\Database-80-20-clean` +- `C:\Users\joshs\.codex\worktrees\4468\Database` + +Required checks before removing either: + +```powershell +git -C PATH status --short --branch +git -C PATH log -1 --oneline +git worktree list --porcelain +``` + +Remove only if clean and no longer needed: + +```powershell +git worktree remove PATH +``` + +## Recommended Next Cleanup Order (2026-06-28) + +1. Commit, port, or explicitly reject the current dirty work that appears to subsume `origin/fix/rag-pipeline-stage3-generation` and parts of `claude/recursing-agnesi-28f476`. +2. Review local `codex/80-20-remediation`; port useful extra remediation or delete. +3. Delete reviewed branches only after their useful changes are either represented in committed history or intentionally rejected. diff --git a/docs/branch-cleanup-guide.md b/docs/branch-cleanup-guide.md index 655fde0c5..d2b177e4c 100644 --- a/docs/branch-cleanup-guide.md +++ b/docs/branch-cleanup-guide.md @@ -1,9 +1,11 @@ # Branch Cleanup Guide -Last reviewed: 2026-06-28 +Last reviewed: 2026-07-04 This guide defines the safe branch cleanup path for this repository. It is written for branch hygiene only: do not use it to discard source work, resolve merge conflicts, merge product changes, or rewrite history. +For historical cleanup snapshots (frozen branch inventories and progress logs), see `docs/archive/`. + ## Goals - Keep `main`, the current working branch, and any branch with useful content not yet represented on `main`. @@ -48,167 +50,12 @@ Before deleting anything: Delete a branch only when the cherry-pick-aware log is empty, or when the branch is deliberately rejected as not useful after review. -## Current Branch State - -Baseline: - -- `main` and `origin/main`: `9bad09523` (`Merge pull request #83 from BigSimmo/codex/80-20-remediation`) -- Current branch: `codex/rag-retry-telemetry-main` -- Current branch status: behind `origin/codex/rag-retry-telemetry-main` by 1 commit, with substantial uncommitted source changes. Do not switch, reset, or clean this worktree as part of branch cleanup. - -## Cleanup Progress - -Completed on 2026-06-28: - -- Deleted local branch `claude/quizzical-bhaskara-97feda` after confirming it had no patch-unique content beyond `main`. -- Deleted remote branch `origin/revert-72-codex/backup-20260623-233849` after confirming it was only the rollback branch for `Save Codex changes`. -- Removed clean detached worktrees `C:\Dev\Apps\Database-80-20-clean` and `C:\Users\joshs\.codex\worktrees\4468\Database` after confirming their HEAD commits were contained in `main`. -- Reviewed `origin/fix/rag-pipeline-stage3-generation`, `claude/recursing-agnesi-28f476`, and `codex/80-20-remediation`; kept them because they still contain useful or not-yet-committed work. -- Reviewed `copilot/simplify-operational-tooling`; rejected it as a branch to preserve because useful source/test overlap is covered by the current dirty tree or `codex/80-20-remediation`, while the remaining unique content is generated/local agent files, broad dependency-repair tooling, or duplicated env/startup patterns. - -Windows left some unregistered `.claude/worktrees/*` folders locked on disk during cleanup. Treat those as filesystem leftovers, not branch refs, after confirming they do not appear in `git worktree list --porcelain`. - -## Delete Candidates Already On Main - -These add no patch content beyond `main`: - -- None known after the 2026-06-28 cleanup pass. - -## Keep For Review - -These have patch content not represented on `main`. Do not delete before review. - -### `claude/recursing-agnesi-28f476` - -Status: - -- Local branch checked out in `C:\Dev\Apps\Database\.claude\worktrees\recursing-agnesi-28f476` -- 3 patch-unique commits -- 18 files changed - -Content: - -- Node 24 / npm 11 runtime requirement work. -- Retrieval and ingestion performance work. -- HNSW `ef_search = 100` migration and schema updates. -- Changes touch `src/lib/rag.ts`, worker code, runtime scripts, docs, and tests. - -Recommendation: - -- Keep. -- Review and salvage selectively. -- Do not merge wholesale until conflicts with current `main` are resolved and runtime expectations are confirmed. -- The local dirty worktree already appears to include Node 24/npm 11, HNSW `ef_search`, and related committed-generation work. Delete this branch only after that work is committed, ported, or explicitly rejected. - -### `codex/80-20-remediation` - -Status: - -- Local branch only; upstream branch was deleted after its remote content was confirmed merged. -- 8 patch-unique commits -- 19 files changed - -Content: - -- Additional local remediation beyond the already merged PR #83 branch. -- Eval/search privacy fixes. -- Relevance score component. -- Chunking, image filtering, answer formatting, search interaction, and UI smoke test updates. - -Recommendation: - -- Keep for review. -- Cherry-pick or port useful fixes after comparing against current uncommitted work on `codex/rag-retry-telemetry-main`. -- Delete only after useful changes are merged or consciously rejected. -- Review found unported-looking pieces such as `src/components/clinical-dashboard/relevance-score.ts`, `tests/document-relevance-score.test.ts`, and `tests/search-interaction-route.test.ts`, so this branch should remain until those are accepted or rejected. - -### `copilot/simplify-operational-tooling` - -Status: - -- Local branch only; remote branch was deleted because its remote patch content was already represented on `main`. -- 3 patch-unique local commits -- 113 files changed -- Rejected during phase 3 cleanup review. - -Content: - -- Large mixed branch with tooling scripts, local agent skill files, Node/runtime updates, mockups, dashboard and profile UI work, answer formatting changes, and broad tests. - -Recommendation: - -- Delete the local branch ref. -- Do not port `.agents/**`, `skills-lock.json`, `scripts/repair-node-modules.cjs`, `scripts/ensure-next-runtime.mjs`, `scripts/next-build.mjs`, or `scripts/run-local-tool.mjs` from this branch. -- Do not port `src/lib/startup-check.ts` or `src/lib/supabase/env.ts`; current env handling already lives in `src/lib/env.ts`, `src/lib/supabase/client.tsx`, `scripts/check-ci-env.mjs`, and Supabase project checks. -- Keep using `codex/80-20-remediation` as the review source for `relevance-score`, search interaction/eval privacy fixes, and focused tests. - -## Remote Branches Not On Main - -### `origin/claude/recursing-agnesi-28f476` - -Status: - -- 1 patch-unique commit -- 14 files changed - -Content: - -- Remote subset of the local `claude/recursing-agnesi-28f476` work, mainly Node 24 / npm 11 runtime requirement changes. - -Recommendation: - -- Keep while local `claude/recursing-agnesi-28f476` is under review. -- Delete remote only after deciding whether to keep the runtime upgrade work. - -### `origin/fix/rag-pipeline-stage3-generation` - -Status: - -- 1 patch-unique commit -- 7 files changed -- Reviewed during cleanup pass and kept. - -Content: - -- Old Stage 3 generation safety fixes. -- Touches answer verification, RAG routing, RAG trust tests, and ingestion retry route. -- Also includes committed-index-generation filtering in RAG source expansion and answer-prose guardrails. - -Recommendation: - -- Keep for clinical-safety review. -- Compare against current answer verification and ingestion retry code before deciding. -- If still relevant, port the useful tests/fixes rather than merging blindly. -- Current dirty worktree already appears to contain the numeric-verification fixes, retry-route TOCTOU guard, committed-index-generation filtering, and answer prose guardrails. Keep the branch until those changes are committed or safely represented elsewhere. - -## Detached Worktrees - -These are not branch refs, so do not treat them as branch cleanup until inspected separately. - -Removed after clean/main-contained verification: - -- `C:\Dev\Apps\Database-80-20-clean` -- `C:\Users\joshs\.codex\worktrees\4468\Database` - -Required checks before removing either: - -```powershell -git -C PATH status --short --branch -git -C PATH log -1 --oneline -git worktree list --porcelain -``` - -Remove only if clean and no longer needed: - -```powershell -git worktree remove PATH -``` - -## Recommended Next Cleanup Order +## Recommended Cleanup Order -1. Commit, port, or explicitly reject the current dirty work that appears to subsume `origin/fix/rag-pipeline-stage3-generation` and parts of `claude/recursing-agnesi-28f476`. -2. Review local `codex/80-20-remediation`; port useful extra remediation or delete. -3. Delete reviewed branches only after their useful changes are either represented in committed history or intentionally rejected. +1. Fetch and inspect current branch state with the commands above. +2. For each candidate branch, confirm patch-unique commits and file diffs against `main`. +3. Port, commit, or explicitly reject useful patch content before deleting any branch ref. +4. Remove detached worktrees only when clean, unneeded, and absent from active `git worktree list` output. ## Final Verification @@ -223,6 +70,6 @@ git status --short --branch Expected invariant: -- `main` remains unchanged. -- The current dirty worktree remains untouched. +- `main` remains unchanged unless you intentionally merge or push there. +- The current dirty worktree remains untouched unless you explicitly choose to clean it. - No branch with patch-unique commits is deleted unless its content was explicitly rejected or safely ported first. diff --git a/docs/multi-user-auth-setup.md b/docs/multi-user-auth-setup.md index f413eb015..4bd643e1a 100644 --- a/docs/multi-user-auth-setup.md +++ b/docs/multi-user-auth-setup.md @@ -1,13 +1,13 @@ # Multi-user auth — Supabase configuration checklist (you apply) -The **code** for multi-user (persistent cookie sessions, magic link + password + -SSO, per-user isolation) lands via the `claude/multiuser-auth` branch. The -**live Supabase configuration** below is done by you in the dashboard / provider -consoles — Claude does not change the live Auth config. Target project: -`Clinical KB Database` (`sjrfecxgysukkwxsowpy`). - -> **Order matters:** do **not** enable open signup on live until the fail-closed -> owner-scoping hardening on this branch has merged (the DB owner-RLS + private +The app ships multi-user auth code (persistent cookie sessions, magic link + +password + SSO, per-user isolation). The **live Supabase configuration** below +is done by you in the dashboard / provider consoles — it is operator-owned, not +changed automatically by repo commits. Target project: `Clinical KB Database` +(`sjrfecxgysukkwxsowpy`). + +> **Order matters:** do **not** enable open signup on live until fail-closed +> owner-scoping hardening has merged and been verified (the DB owner-RLS + private > storage backstop is already in place — see §7). Validate the whole flow in a > **staging** project first. @@ -52,15 +52,14 @@ consoles — Claude does not change the live Auth config. Target project: ## 6. App environment variables -Already used by the app; ensure they are set per environment. Concrete values -for **this** project (retrieved read-only from the live project 2026-07-03): +Already used by the app; ensure they are set per environment. Copy values from +the Supabase dashboard → Project Settings → API: - `NEXT_PUBLIC_SUPABASE_URL` = `https://sjrfecxgysukkwxsowpy.supabase.co` -- `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` = `sb_publishable_TgAfWIQDozYC_reOI-d5cw_FLYPnqOa` - (modern publishable key — public by design, safe in the browser; a legacy anon - JWT is also still active for compatibility) -- `SUPABASE_SERVICE_ROLE_KEY` (server-only; never exposed to the client — not - reproduced here; copy it from the Supabase dashboard → Project Settings → API) +- `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` = your publishable or anon key (public + by design, safe in the browser) +- `SUPABASE_SERVICE_ROLE_KEY` (server-only; never exposed to the client — copy + from the dashboard; do not commit real values to docs or Git) The **Supabase OAuth callback** to authorize in the Google Cloud / Azure AD app registrations (§1) is `https://sjrfecxgysukkwxsowpy.supabase.co/auth/v1/callback`. @@ -83,7 +82,7 @@ project**, so no broad RLS migration is required: client storage access is enabled (so no per-user folder policy is needed unless client-direct storage reads are ever added). -Combined with the app-layer **fail-closed owner scoping** shipped on this branch, +Combined with the app-layer **fail-closed owner scoping** in the codebase, per-user isolation is enforced at both layers. **Two residual, low-priority items (out of scope for multi-user, no action needed diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 7e7c587e4..48c530e9b 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -41,6 +41,8 @@ This document turns the current process review into phased, durable repo practic - **2026-07-03:** extracted `document-results.tsx` — `WhyThisMatchedPanel`, `RelatedDocumentsPanel`, `StagedAnswerResultSurface` (contiguous block 456–944, moved verbatim). Monolith 5216 → 4726 lines. **No runtime cycle** — the only monolith import is `type AnswerFeedbackType` (erased); `StagedAnswerResultSurface` is a leaf result-surface that imports one-way from every sibling module (answer-content, evidence-panels, visual-evidence, relevance, document-search-results, badges, dashboard-shell, display-text, use-mobile-preview-sheet) and the monolith imports `RelatedDocumentsPanel` + `StagedAnswerResultSurface` back. The monolith's `visual-evidence` import (`InlineTableCard`/`MobileEvidenceSheetContent`) moved into `document-results` as predicted. Added `document-results.tsx` to the `rendered-text-formatting.test.ts` corpus; stripped 35 now-orphaned monolith imports. +- **2026-07-04:** answer-review hygiene — `StagedAnswerResultSurface` now lives in `answer-result-surface.tsx`; `document-results.tsx` re-exports it and owns `RelatedDocumentsPanel` (monolith imports both). `AnswerFeedbackType` moved to `lib/answer-feedback.ts` so `evidence-panels`, `visual-evidence`, and `answer-result-surface` no longer import types from `ClinicalDashboard`. `rendered-text-formatting.test.ts` corpus includes `answer-result-surface.tsx`. Clinical-note checklist rows link to primary sources when available. + #### Decomposition COMPLETE (approved move map) All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went from ~8.8k → ~4.7k lines and now holds the main `ClinicalDashboard` orchestrator, its data/state hooks, and the deferred admin surfaces only. The 6 extracted modules live in `src/components/clinical-dashboard/`: `auth-panel`, `answer-content`, `evidence-panels`, `output-panel`, `visual-evidence`, `document-results` (+ the shared `use-mobile-preview-sheet` hook and `display-text` helpers). The barrel `index.ts` was intentionally not extended. diff --git a/docs/production-readiness-checklist.md b/docs/production-readiness-checklist.md index a0505bda0..e38e98c7f 100644 --- a/docs/production-readiness-checklist.md +++ b/docs/production-readiness-checklist.md @@ -2,7 +2,8 @@ This is the runbook to make the app publishable in one focused pass. -- Branch: `codex/premium-redesign` (do not touch `.env` / secrets directly). +Last reviewed: 2026-07-04. Applies to any feature branch or release candidate. + - Runtime target: Next.js 16.2.9, Node 24.x, npm 11.x. - Supabase target: `sjrfecxgysukkwxsowpy` (`Clinical KB Database`). diff --git a/docs/redesign/06-verification.md b/docs/redesign/06-verification.md index 01fa3376a..1963e82f4 100644 --- a/docs/redesign/06-verification.md +++ b/docs/redesign/06-verification.md @@ -4,7 +4,7 @@ Scope: ultra-premium mobile-first redesign — token system, component layer, da ## June 20 scoped run — dashboard/viewer only, Tools deferred -Branch: `codex/premium-redesign`. Local server: `npm run ensure` confirmed `http://localhost:4298`; `/api/local-project-id` confirmed `Clinical KB` with `safeLocalOrigin: true`. +Last reviewed: 2026-07-04. Historical verification log for the premium-redesign reconciliation pass; branch names and test counts are snapshots only. | Check | Command | Result | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/docs/supabase-migration-reconciliation.md b/docs/supabase-migration-reconciliation.md index 1fd3b2899..cddf96f42 100644 --- a/docs/supabase-migration-reconciliation.md +++ b/docs/supabase-migration-reconciliation.md @@ -1,18 +1,20 @@ # Supabase Migration Reconciliation -Last reviewed: 2026-06-28 +Last reviewed: 2026-07-04 Target project: Clinical KB Database (`sjrfecxgysukkwxsowpy`) ## Policy - Do not use `supabase db push` while local and remote migration history are divergent. +- **Never change a retrieval RPC, index, or function on the live project with raw SQL in the dashboard.** Use a committed migration under `supabase/migrations/` and reconcile `supabase/schema.sql` in the same change. - Use `supabase migration repair --linked --status applied ` only when live database evidence proves the migration effect already exists. -- Leave all other local-only migrations unrepaired until their effects are verified or deliberately applied. +- Leave other local-only migrations unrepaired until their effects are verified or deliberately applied. +- Run `npx supabase migration list --linked` at apply/reconcile time; do not rely on a frozen “aligned through” snapshot in this doc alone. -## Verified Applied +## Verified Applied (through June 2026) -These previously local-only versions have been verified in the live project history: +These previously local-only versions were verified in the live project history before the July 2026 reconciliation wave: - `20260625033425` - `document_strict_gate_status` exists, `repair_strict_enrichment_gate_batch(integer)` exists, service role can read/execute, and anon cannot read/execute. - `20260625033944` - `complete_strict_enrichment_job(uuid, uuid, text, text, text)` exists, service role can execute, and anon cannot execute. @@ -23,9 +25,23 @@ These previously local-only versions have been verified in the live project hist - `20260628000000` - atomic document index generation commit RPC and committed-generation retrieval filters are present and verified in live. - `20260628135727` - explicit `invoke_indexing_v3_agent(integer)` execute grant hardening is present and verified in live. -## Current Status +## Current Status (July 2026) -As of this review, `npx supabase migration list --linked` shows no local-only migrations for `sjrfecxgysukkwxsowpy`. Remote migration history is aligned through `20260628135727`. +The repo now includes additional July 2026 migrations beyond the June checkpoint above, including: + +- Retrieval RPC codification and hybrid execution smoke (`20260701140631`, related July 1 fixes) +- Legacy vector index drops and `search_schema_health()` reconciliation (`20260702014803`, `20260702021604`) +- Clinical registry tables (`20260703020000`) +- Storage cleanup index reconciliation prep (`20260703030000`, prepared but apply only with explicit approval) +- Indexing v3 agent job table and related hardening (`20260702190000` and neighbors) + +Live-only drift, duplicate migration-version churn, and outstanding follow-up debts are tracked in the **Retrieval RPC drift & indexing hygiene** section of [`docs/process-hardening.md`](process-hardening.md). Treat that section as the operational supplement to this reconciliation doc. + +Before applying pending migrations to live: + +1. Run `npx supabase migration list --linked` and confirm local vs remote alignment. +2. Run `npm run supabase:recovery-status` and confirm Supabase is healthy. +3. Apply only through the normal migration workflow; update `supabase/schema.sql` when the migration changes canonical schema shape. ## Verification Commands @@ -34,4 +50,5 @@ npx supabase migration list --linked npx supabase db advisors --linked npx supabase db query --linked "select to_regclass('public.document_strict_gate_status') as gate_view, to_regprocedure('public.repair_strict_enrichment_gate_batch(integer)') as repair_rpc, to_regprocedure('public.complete_strict_enrichment_job(uuid, uuid, text, text, text)') as complete_rpc, to_regclass('public.ingestion_job_stages_doc_idx') as duplicate_index, to_regclass('public.ingestion_job_stages_document_started_idx') as canonical_stage_index;" npx supabase db query --linked "select to_regprocedure('public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb)') as commit_generation_rpc, has_function_privilege('anon', 'public.invoke_indexing_v3_agent(integer)', 'execute') as anon_can_invoke_indexing_v3_agent, has_function_privilege('service_role', 'public.invoke_indexing_v3_agent(integer)', 'execute') as service_role_can_invoke_indexing_v3_agent;" +npm run check:indexing ``` diff --git a/mockups/README.md b/mockups/README.md index 5dfb6b4e9..5c9ad9a06 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -1,59 +1,23 @@ # Project Mockups -This folder collects the current mockup files for the Clinical KB Database project in one place. +This folder collects notes for mockup routes that live under `src/app/mockups/`. -All remaining mockups use the Clinical White / Aegean Graphite role tokens (`--command`, `--clinical-accent`, `--success`) -from `docs/redesign/02-design-direction.md`. The design-exploration mockups that led to that theme served their purpose and -were removed in July 2026 so stale palettes do not mislead future design review (`answer-best-layout`, -`clinical-command-popup`, `compact-answer-entry-points`, `crisp-white-colour-system`, `evidence-option`, -`evidence-redesign`, `extended-menu-refined`, `final-rag-structure`, `premium-colour-system`, `rag-answer-responsive`, -`rag-answer-structure`, `safety-critical-redesign`, `safety-notes-triage-redesign`). +## Authoritative route list -## Included mockups +The generated route map in [`docs/site-map.md`](../docs/site-map.md) (mockups section) is the source of truth for runnable mockup URLs. Regenerate it after adding or removing mockup routes: -- 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` -- `document-search` - runnable document-search mockup review board, in `src/app/mockups/document-search/page.tsx` -- `document-search/source` - live handoff route that resolves a mock result into `/documents/{id}?page=...&chunk=...`, in `src/app/mockups/document-search/source/page.tsx` -- `document-search-command` - runnable mockup only, in `src/app/mockups/document-search-command/page.tsx` -- `document-search-evidence-lens` - runnable mockup only, in `src/app/mockups/document-search-evidence-lens/page.tsx` -- `document-search-triage-board` - runnable mockup only, in `src/app/mockups/document-search-triage-board/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` -- `favourites-command-desk` - runnable mockup only, in `src/app/mockups/favourites-command-desk/page.tsx` -- `favourites-set-board` - runnable mockup only, in `src/app/mockups/favourites-set-board/page.tsx` -- `favourites-library-view` - runnable mockup only, in `src/app/mockups/favourites-library-view/page.tsx` +```bash +npm run sitemap:update +npm run sitemap:check +``` -## App routes +## Design tokens -The runnable versions remain in the Next.js app route tree: - -- `/?mode=prescribing` -- `/medications/acamprosate` -- `/mockups/answer-evidence-popups` -- `/mockups/document-search?mode=documents` -- `/mockups/document-search/source?mode=documents&document=clozapine-monitoring&q=clozapine%20monitoring%20table&page=12&chunk=monitoring-table` -- `/mockups/document-search-command?mode=documents` -- `/mockups/document-search-evidence-lens?mode=documents` -- `/mockups/document-search-triage-board?mode=documents` -- `/mockups/mode-dropdown` -- `/mockups/recent-searches-bottom` -- `/mockups/settings-search-general` -- `/mockups/settings-search-clinical` -- `/mockups/settings-search-privacy` -- `/mockups/favourites-command-desk` -- `/mockups/favourites-set-board` -- `/mockups/favourites-library-view` - -Favourites now lives in the live dashboard flow at `/?mode=favourites`; `/mockups/favourites-hub` redirects there for old links. +Mockups use the Clinical White / Aegean Graphite role tokens (`--command`, `--clinical-accent`, `--success`) from [`docs/redesign/02-design-direction.md`](../docs/redesign/02-design-direction.md). Older design-exploration mockups were removed in July 2026 so stale palettes do not mislead future design review. ## 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`. +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. - Tool and favourites mockups keep the shared app header but hide the bottom composer because they provide their own primary search surface. @@ -61,11 +25,14 @@ New runnable mockups under `src/app/mockups/*` inherit the shared Clinical KB he - 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. +## Production behavior + +- `/mockups/*` prototype routes are development-only; production returns 404 and `robots.txt` disallows indexing. +- `/mockups/favourites-hub` is a legacy compatibility route and redirects to `/favourites`. +- `/mockups/medication-prescribing` redirects to `/medications/acamprosate`; prescribing mode also lives at `/?mode=prescribing`. + ## Synthetic document-search assets -The document-search mockups use generated non-patient bitmap assets in `public/mockups/document-search/`. These images are -abstract UI/document textures only: they must not be treated as source screenshots, hospital-branded material, or clinical -content. +The document-search mockups use generated non-patient bitmap assets in `public/mockups/document-search/`. These images are abstract UI/document textures only: they must not be treated as source screenshots, hospital-branded material, or clinical content. -The `document-search/source` route is the exception to the fixture-only mockup behavior: it is a local live handoff that -finds an indexed document and opens the existing document viewer with a selected page and chunk. +Some document-search mockups include live handoff routes (for example `document-search/source-overlays`) that resolve into the real document viewer with a selected page and chunk when indexed data is available locally. diff --git a/src/lib/env.ts b/src/lib/env.ts index bbfb7de1c..ac5d8dbf5 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -102,11 +102,11 @@ const envSchema = z.object({ // within a section, so dose tables / monitoring protocols split across a page boundary stay // together. Enabled only for the eval-gated shadow re-index, never silently for live users. CHUNK_STRATEGY: z.enum(["page", "document"]).default("page"), - WORKER_POLL_MS: z.coerce.number().int().positive().default(1500), - WORKER_BATCH_SIZE: z.coerce.number().int().positive().default(25), - WORKER_CONCURRENCY: z.coerce.number().int().positive().default(8), - WORKER_MAX_ATTEMPTS: z.coerce.number().int().positive().default(5), - WORKER_STALE_AFTER_MINUTES: z.coerce.number().int().positive().default(5), + WORKER_POLL_MS: z.coerce.number().int().positive().default(30000), + WORKER_BATCH_SIZE: z.coerce.number().int().positive().default(3), + WORKER_CONCURRENCY: z.coerce.number().int().positive().default(1), + WORKER_MAX_ATTEMPTS: z.coerce.number().int().positive().default(3), + WORKER_STALE_AFTER_MINUTES: z.coerce.number().int().positive().default(45), WORKER_HEALTH_BACKOFF_MS: z.coerce.number().int().positive().default(120000), WORKER_MAX_CLAIM_FAILURES: z.coerce.number().int().positive().default(3), WORKER_PROGRESS_UPDATE_MIN_INTERVAL_MS: z.coerce.number().int().positive().default(60000), From 5f4eb1eb3f54ebf13c9ba61088f1fcc862866d4e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:39:03 +0800 Subject: [PATCH 36/61] fix(answer): derive concise topics for first-turn suggestion chips Use canonical terms or significant tokens instead of embedding long interrogative questions in follow-up chip templates. Co-authored-by: Cursor --- src/lib/answer-follow-up.ts | 19 +++++++++++++++++++ tests/answer-follow-up.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/lib/answer-follow-up.ts b/src/lib/answer-follow-up.ts index 5133931e0..f5a983f02 100644 --- a/src/lib/answer-follow-up.ts +++ b/src/lib/answer-follow-up.ts @@ -20,6 +20,8 @@ const selfContainedFollowUpLength = 80; const followUpCuePattern = /\b(what about|how about|and (?:for|in|with|the)|also|too\??$|same (?:for|with)|instead|as well|it\b|they\b|them\b|this\b|that\b|those\b|these\b)\b/i; +const questionLeadPattern = /^(what|how|when|where|which|who|why|can|should|does|do|is|are)\b/i; + function significantTokens(text: string): string[] { return (text.toLowerCase().match(/[a-z][a-z-]{3,}/g) ?? []).filter( (token) => !["what", "when", "where", "which", "about", "does", "should", "would", "could"].includes(token), @@ -72,8 +74,25 @@ function topicLabel(priorQuery: string, answer: RagAnswer) { const medications = answer.queryAnalysis?.medications ?? []; const medication = medications.find((item) => item.trim()); if (medication) return medication.trim(); + + const canonical = answer.queryAnalysis?.canonicalTerms?.filter((term) => term.trim()) ?? []; + if (canonical.length > 0) { + const label = canonical.slice(0, 3).join(" "); + return label.length > 48 ? `${label.slice(0, 45).trimEnd()}…` : label; + } + const trimmed = priorQuery.trim(); if (!trimmed) return "this topic"; + + // Long or interrogative queries: use a short topic phrase instead of the full question. + if (trimmed.length > 48 || questionLeadPattern.test(trimmed)) { + const tokens = significantTokens(trimmed); + if (tokens.length > 0) { + const label = tokens.slice(0, 3).join(" "); + return label.length > 48 ? `${label.slice(0, 45).trimEnd()}…` : label; + } + } + return trimmed.length > 48 ? `${trimmed.slice(0, 45).trimEnd()}…` : trimmed; } diff --git a/tests/answer-follow-up.test.ts b/tests/answer-follow-up.test.ts index 8fdc8c15e..c60ba8fe2 100644 --- a/tests/answer-follow-up.test.ts +++ b/tests/answer-follow-up.test.ts @@ -124,4 +124,30 @@ describe("buildAnswerFollowUpSuggestions", () => { expect(suggestions.every((item) => !/for what about renal impairment/i.test(item))).toBe(true); expect(suggestions.some((item) => /lithium dosing|What monitoring is required\?/i.test(item))).toBe(true); }); + + it("uses a concise topic label for long first-turn questions", () => { + const clozapineQuestion = "What clozapine monitoring items are shown in the table image?"; + const tableAnswer = { + answer: "The synthetic clozapine table image highlights core monitoring domains.", + grounded: true, + confidence: "high", + citations: [], + sources: [], + queryClass: "document_lookup", + queryAnalysis: { + ...medicationAnswer.queryAnalysis, + originalQuery: clozapineQuestion, + normalizedQuery: clozapineQuestion, + queryClass: "document_lookup", + medications: [], + canonicalTerms: [], + }, + } satisfies import("@/lib/types").RagAnswer; + + const suggestions = buildAnswerFollowUpSuggestions(clozapineQuestion, tableAnswer, [clozapineQuestion]); + + expect(suggestions.length).toBeGreaterThan(0); + expect(suggestions.every((item) => !/for What clozapine monitoring items/i.test(item))).toBe(true); + expect(suggestions.some((item) => /clozapine/i.test(item))).toBe(true); + }); }); From 1c5d7670273e7ebb9e3904576c53170297542e6d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:11:46 +0800 Subject: [PATCH 37/61] Add codebase index and Cursor semantic search configuration. Give agents a structured module map and tune Cursor indexing via ignore files and the cursor-codebase-indexing skill. Co-authored-by: Cursor --- .../skills/cursor-codebase-indexing/SKILL.md | 233 ++++++++++++++ .cursorignore | 42 +++ .cursorindexingignore | 12 + AGENTS.md | 2 +- docs/codebase-index.md | 286 ++++++++++++++++++ public/llms.txt | 2 + 6 files changed, 576 insertions(+), 1 deletion(-) create mode 100644 .cursor/skills/cursor-codebase-indexing/SKILL.md create mode 100644 .cursorignore create mode 100644 .cursorindexingignore create mode 100644 docs/codebase-index.md diff --git a/.cursor/skills/cursor-codebase-indexing/SKILL.md b/.cursor/skills/cursor-codebase-indexing/SKILL.md new file mode 100644 index 000000000..3e8d87f81 --- /dev/null +++ b/.cursor/skills/cursor-codebase-indexing/SKILL.md @@ -0,0 +1,233 @@ +--- +name: cursor-codebase-indexing +description: 'Set up and optimize Cursor codebase indexing for semantic code search + and @Codebase queries. + + Triggers on "cursor index", "codebase indexing", "index codebase", "cursor semantic + search", + + "@codebase", "cursor embeddings". + + ' +allowed-tools: Read, Write, Edit, Bash(cmd:*) +version: 1.0.0 +license: MIT +author: Jeremy Longshore +tags: +- saas +- cursor +- cursor-codebase +compatibility: Designed for Claude Code, also compatible with Codex and OpenClaw +--- +# Cursor Codebase Indexing + +Set up and optimize Cursor's codebase indexing system. Indexing creates embeddings of your code, enabling `@Codebase` semantic search and improving AI context awareness across Chat, Composer, and Agent mode. + +## How Indexing Works + +``` +Your Code Files + │ + ▼ + Syntax Chunking ─── splits files into meaningful code blocks + │ + ▼ + Embedding Generation ─── converts chunks to vector representations + │ + ▼ + Vector Storage (Turbopuffer) ─── cloud-hosted nearest-neighbor search + │ + ▼ + @Codebase Query ─── your question → embedding → similarity search → relevant chunks +``` + +### Key Architecture Details + +- **Merkle tree** for change detection: only modified files are re-indexed (every 10 minutes) +- **No plaintext storage**: code is not stored server-side; only embeddings and obfuscated metadata +- **Privacy Mode compatible**: with Privacy Mode on, embeddings are computed without retaining source code +- Indexing runs in the background; small projects complete in seconds, large projects (50K+ files) may take hours initially + +## Initial Setup + +1. Open your project in Cursor +2. Indexing starts automatically on first open +3. Check status: look at the bottom status bar for "Indexing..." indicator +4. View indexed files: `Cursor Settings` > `Features` > `Codebase Indexing` > `View included files` + +### Verify Indexing Status + +The status bar shows: + +- **"Indexing..."** with progress indicator -- initial indexing in progress +- **"Indexed"** -- indexing complete, `@Codebase` queries are available +- No indicator -- indexing may be disabled or not started + +## Configuration + +### .cursorignore + +Exclude files from indexing and AI features. Place in project root. Uses `.gitignore` syntax: + +```gitignore +# .cursorignore + +# Build artifacts (large, not useful for AI context) +dist/ +build/ +out/ +.next/ +target/ + +# Dependencies +node_modules/ +vendor/ +venv/ +.venv/ + +# Generated files +*.min.js +*.min.css +*.bundle.js +*.map +*.lock + +# Large data files +*.csv +*.sql +*.sqlite +*.parquet +fixtures/ +seed-data/ + +# Secrets (defense in depth -- also use .gitignore) +.env* +**/secrets/ +**/credentials/ +``` + +### .cursorindexingignore + +Exclude files from indexing only but keep them accessible to AI features when explicitly referenced: + +```gitignore +# .cursorindexingignore + +# Large test fixtures -- don't index, but allow @Files reference +tests/fixtures/ +e2e/recordings/ + +# Documentation build output +docs/.vitepress/dist/ +``` + +**Difference:** `.cursorignore` hides files from both indexing and AI features. `.cursorindexingignore` only excludes from the index; files can still be referenced via `@Files`. + +### Default Exclusions + +Cursor automatically excludes everything in `.gitignore`. You only need `.cursorignore` for files tracked by git that you want to exclude from AI. + +## Using the Index + +### @Codebase Queries + +Ask semantic questions about your entire codebase: + +``` +@Codebase where is user authentication handled? + +@Codebase show me all API endpoints that accept file uploads + +@Codebase how does the payment processing flow work? + +@Codebase find all places where we connect to Redis +``` + +`@Codebase` performs a nearest-neighbor search using your question's embedding. It returns the most semantically similar code chunks, even if they do not contain the exact keywords you used. + +### @Codebase vs @Files vs Text Search + +| Method | When to Use | Context Cost | +|--------|------------|--------------| +| `@Codebase` | Discovery -- you don't know which files | High (many chunks) | +| `@Files` | You know exactly which file | Low (one file) | +| `@Folders` | You know the directory | Medium-High | +| `Ctrl+Shift+F` | Exact text/regex match | N/A (editor search) | + +Use `@Codebase` for discovery, then switch to `@Files` once you know where the code lives. + +## Optimization for Large Projects + +### Monorepo Strategy + +For monorepos with many packages, open the specific package directory instead of the root: + +```bash +# Instead of opening the entire monorepo: +cursor /path/to/monorepo # Indexes everything -- slow + +# Open the specific package: +cursor /path/to/monorepo/packages/api # Indexes only this package -- fast +``` + +Or use `.cursorignore` at the root to exclude packages you are not actively working on: + +```gitignore +# .cursorignore -- monorepo, focus on api and shared +packages/web/ +packages/mobile/ +packages/admin/ +# packages/api/ ← not listed, so it IS indexed +# packages/shared/ ← not listed, so it IS indexed +``` + +### Re-Indexing + +If search results are stale or indexing appears stuck: + +1. `Cmd+Shift+P` > `Cursor: Resync Index` +2. Wait for status bar to show indexing progress +3. If that fails, delete the local cache: + - macOS: `~/Library/Application Support/Cursor/Cache/` + - Linux: `~/.config/Cursor/Cache/` + - Windows: `%APPDATA%\Cursor\Cache\` +4. Restart Cursor and allow full re-index + +### File Watcher Limits (Linux) + +On Linux, large projects may hit the file watcher limit: + +```bash +# Check current limit +cat /proc/sys/fs/inotify/max_user_watches + +# Increase (temporary) +sudo sysctl fs.inotify.max_user_watches=524288 + +# Increase (permanent) +echo "fs.inotify.max_user_watches=524288" | sudo tee -a /etc/sysctl.conf +sudo sysctl -p +``` + +## Enterprise Considerations + +- **Data residency**: Embeddings are stored in Turbopuffer (cloud). Obfuscated filenames and no plaintext code, but metadata exists +- **Privacy Mode**: With Privacy Mode on, embeddings are computed with zero data retention at the provider +- **Air-gapped environments**: Indexing requires network access to Cursor's embedding API. Not available offline +- **Indexing scope**: Only files in the currently open workspace are indexed. Closing a project removes its index from active queries + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| @Codebase returns no results | Index not built | Wait for "Indexed" in status bar | +| Search misses known files | File in .gitignore or .cursorignore | Check ignore files | +| Indexing stuck at N% | Large project or network issue | Resync index via Command Palette | +| Stale results after refactor | Index not yet updated | Wait 10 min or manual resync | +| High CPU during indexing | Initial embedding computation | Normal for first run; subsides | + +## Resources + +- [Codebase Indexing Docs](https://docs.cursor.com/context/codebase-indexing) +- [Ignore Files](https://docs.cursor.com/context/ignore-files) +- [Secure Codebase Indexing](https://cursor.com/blog/secure-codebase-indexing) diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 000000000..1e7783f74 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,42 @@ +# Cursor semantic index exclusions — keep search focused on source, not artifacts. +# Mirrors .gitignore noise; Cursor still indexes tracked source under src/, worker/, scripts/, supabase/. + +node_modules/ +.next/ +out/ +build/ +coverage/ +output/ +test-results/ +playwright-report/ +playwright-cli/ +.playwright-cli/ +playwright/.auth/ + +# Local env and secrets +.env* + +# Generated / machine-local +*.tsbuildinfo +sample-documents/ +tmp/ +.tmp-visual/ +.codex-screenshots/ +artifacts/ +scratch/ +.qa-smoke/ +.impeccable/ +supabase/.temp/ + +# Logs and PIDs +*.log +*.pid +dev-server*.log +worker-*.log + +# Python caches +__pycache__/ +.pytest_cache/ + +# Large binary / review scratch (not application logic) +docs/mockups/ diff --git a/.cursorindexingignore b/.cursorindexingignore new file mode 100644 index 000000000..38d3dd0da --- /dev/null +++ b/.cursorindexingignore @@ -0,0 +1,12 @@ +# Exclude from Cursor semantic index only — still reachable via @Files when needed. + +# Golden eval fixtures (large JSON, low day-to-day search value) +scripts/fixtures/ + +# Playwright auth state and reports (already in .cursorignore via .gitignore patterns) +playwright/.auth/ +playwright-report/ + +# Local temp clones from tooling (if present) +.tmp-skills-clone/ +.tmp-adeonir-skills/ diff --git a/AGENTS.md b/AGENTS.md index e9dcda623..6d5a4643d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -253,7 +253,7 @@ After completing `upload`, summarize the current branch and worktree state, whet ## Codex productivity defaults - Treat terse prompts as workflow shortcuts when the intent is clear. If the user says `run`, execute `npm run ensure`, verify the project identity through that helper, and return the printed local URL without a long log dump. -- For non-trivial changes, start from concrete repo state: branch, `git status`, relevant package scripts, recent failures, and local logs such as `dev-server.log` when runtime behavior is involved. +- For non-trivial changes, start from concrete repo state: branch, `git status`, relevant package scripts, recent failures, and local logs such as `dev-server.log` when runtime behavior is involved. For architecture and module orientation, read `docs/codebase-index.md` (routes: `docs/site-map.md`). - For UI, browser, styling, routing, accessibility, or screenshot work, run `npm run ensure` before opening the app, then use browser QA and the smallest relevant UI proof before broader gates. - Prefer the smallest failing check first. For this repo, use focused Vitest or Playwright targets before widening to `npm run verify:cheap`, `npm run verify:ui`, or `npm run verify:release`. - When the user says `safely`, preserve unrelated staged, unstaged, and untracked work; stop only clearly repo-owned transient processes; and verify the result instead of doing broad cleanup. diff --git a/docs/codebase-index.md b/docs/codebase-index.md new file mode 100644 index 000000000..6fb198db2 --- /dev/null +++ b/docs/codebase-index.md @@ -0,0 +1,286 @@ +# Clinical KB — Codebase Index + +Structured map for AI agents and onboarding. For live routes, see `docs/site-map.md` (`npm run sitemap:update` / `sitemap:check`). For agent rules and verification gates, see `AGENTS.md`. + +**Stack:** Next.js 16, React 19, Supabase (pgvector, Storage, Auth), OpenAI, Python OCR worker. +**Live Supabase:** `Clinical KB Database` — ref `sjrfecxgysukkwxsowpy` (never use stale `qjgitjyhxrwxsrydablr`). + +--- + +## Quick start + +| Step | Command | +|------|---------| +| Confirm Supabase target | `npm run check:supabase-project` | +| Start app (project-specific port) | `npm run ensure` | +| Start ingestion worker | `npm run worker` | +| Cheap verification gate | `npm run verify:cheap` | +| UI verification gate | `npm run verify:ui` | + +--- + +## Top-level layout + +| Path | Purpose | +|------|---------| +| `src/` | Next.js App Router UI, API routes, shared lib, components | +| `supabase/` | SQL migrations, schema mirror, Edge Functions, CLI config | +| `worker/` | Local ingestion worker (parse, OCR, chunk, embed, DB writes) | +| `scripts/` | CLI ops: reindex, eval, backfill, governance, dev-server helpers | +| `tests/` | Vitest unit (`*.test.ts`) + Playwright E2E (`ui-*.spec.ts`) | +| `docs/` | Runbooks, governance, search/RAG plans, generated sitemap | +| `public/` | Static assets (`public/llms.txt`) | +| `.github/` | CI workflows, PR template (clinical governance preflight) | + +**Do not commit:** `.next/`, `node_modules/`, `coverage/`, `.env*`, `sample-documents/`, logs. + +--- + +## Application architecture + +### Shell and routing + +- **Root layout:** `src/app/layout.tsx` — fonts, `AuthProvider`, global CSS +- **App shell:** `src/app/app-shell-client.tsx` — `GlobalSearchShell` via `src/lib/shell-route-config.ts` +- **Home:** `src/app/page.tsx` — dashboard rendered by shell +- **Dashboard:** `src/components/ClinicalDashboard.tsx` + `src/components/clinical-dashboard/` +- **Modes (8):** `src/lib/app-modes.ts` — answer, documents, services, forms, favourites, differentials, prescribing, tools + +### Product pages (`src/app/`) + +| Route | File | +|-------|------| +| `/` | `src/app/page.tsx` | +| `/applications` | `src/app/applications/page.tsx` | +| `/differentials`, `/diagnoses`, `/presentations` | `src/app/differentials/` | +| `/documents/search`, `/source`, `/evidence`, `/[id]` | `src/app/documents/` | +| `/favourites` | `src/app/favourites/page.tsx` | +| `/forms`, `/forms/[slug]` | `src/app/forms/` | +| `/medications`, `/medications/[slug]` | `src/app/medications/` | +| `/services`, `/services/[slug]` | `src/app/services/` | +| `/mockups/*` | `src/app/mockups/` (404 in production) | +| `/auth/callback` | `src/app/auth/callback/route.ts` | + +### API routes (`src/app/api/`) + +| Area | Routes | Entry files | +|------|--------|-------------| +| Answers | `/api/answer`, `/api/answer/stream` | `answer/route.ts`, `answer/stream/route.ts` | +| Search | `/api/search`, `/api/search/interaction` | `search/` | +| Upload | `/api/upload` | `upload/route.ts` | +| Documents | CRUD, bulk, reindex, labels, search, summarize, table-facts, signed-url | `documents/` | +| Ingestion | batches, jobs, retry, quality | `ingestion/` | +| Registry | records CRUD | `registry/records/` | +| Images | signed URLs | `images/[id]/signed-url/route.ts` | +| Ops | health, setup-status, local-project-id | `health/`, `setup-status/`, `local-project-id/` | +| Eval / jobs | eval cases, job state | `eval-cases/`, `jobs/` | + +--- + +## `src/lib/` module map + +### RAG, retrieval, answers + +| Module | Role | +|--------|------| +| `rag.ts` | Main answer pipeline orchestrator | +| `rag-routing.ts`, `rag-provider.ts`, `rag-answer-text.ts`, `smart-rag-api.ts` | Model routing, provider modes, API surface | +| `clinical-search.ts`, `clinical-query-mode.ts`, `retrieval-selection.ts` | Query modes and retrieval selection | +| `answer-ranking.ts`, `answer-verification.ts`, `answer-formatting.ts`, `answer-follow-up.ts`, `answer-render-policy.ts` | Answer quality and rendering | +| `citations.ts`, `cross-document-synthesis.ts`, `evidence-relevance.ts` | Evidence and synthesis | +| `ranking-config.ts`, `search-scope.ts`, `rag-eval-cases.ts` | Ranking tuning and eval fixtures | + +### Ingestion and indexing + +| Module | Role | +|--------|------| +| `ingestion.ts`, `ingestion-recovery.ts`, `ingestion-mutation-safety.ts` | Job queue semantics and recovery | +| `chunking.ts`, `extractors/document.ts` | Text extraction and chunking | +| `document-index-units.ts`, `document-enrichment.ts`, `deep-memory.ts` | Index artifacts and enrichment | +| `visual-intelligence.ts`, `image-filtering.ts` | Image captioning and filtering | +| `index-quality.ts`, `indexing-coverage.ts`, `model-index-extraction.ts` | Index quality gates | +| `reindex-pipeline.ts`, `reindex-eval-gate.ts`, `bulk-import.ts` | Atomic reindex and bulk import | + +### Source governance and metadata + +| Module | Role | +|--------|------| +| `source-metadata.ts`, `source-governance.ts`, `source-text-sanitizer.ts` | Source provenance and governance | +| `document-label-governance.ts`, `document-tags.ts`, `document-organization.ts` | Labels and organization | +| `table-review.ts`, `accessible-table-normalization.ts` | Table facts | + +### Supabase, auth, env + +| Module | Role | +|--------|------| +| `supabase/client.tsx`, `server.ts`, `admin.ts`, `auth.ts`, `health.ts`, `project.ts` | Clients and auth | +| `supabase/database.types.ts` | Generated DB types | +| `env.ts` | Zod-validated environment | +| `owner-scope.ts`, `query-privacy.ts`, `privacy.ts`, `audit.ts` | Multi-user scope and privacy | + +### Clinical product data + +| Module | Role | +|--------|------| +| `differentials.ts`, `forms.ts`, `services.ts`, `registry-records.ts` | Registry-backed content | +| `clinical-safety.ts`, `demo-data.ts`, `ui-copy.ts` | Safety copy and demo mode | + +### Infra helpers + +| Module | Role | +|--------|------| +| `openai.ts`, `embedding-dimensions.ts`, `api-rate-limit.ts` | External APIs and rate limits | +| `validation/` | `body.ts`, `query.ts`, `params.ts`, `http.ts`, `form-data.ts` | +| `shell-route-config.ts`, `document-flow-routes.ts`, `local-project-identity.ts` | Routing and project identity | + +--- + +## Supabase + +### Config and schema + +- **CLI:** `supabase/config.toml` — `indexing-v3-agent` function, `verify_jwt = false` +- **Schema mirror:** `supabase/schema.sql` (reference; migrations are source of truth) +- **Migrations:** `supabase/migrations/*.sql` (~90 files, May–Jul 2026) +- **Drift policy:** `docs/supabase-migration-reconciliation.md` + +### Core tables + +`documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `clinical_registry_records`, `api_rate_limits`, `audit_logs`, `storage_cleanup_jobs` + +**Storage buckets:** `clinical-documents`, `clinical-images` (private) + +### Migration themes + +| Theme | Examples | +|-------|----------| +| Bulk ingestion and job queue | `20260527000000_bulk_ingestion.sql`, `20260616001000_ingestion_job_state_rpcs.sql` | +| Hybrid retrieval RPCs | `20260607183245_search_trigram_indexes_and_response_cache.sql`, `20260701140631_codify_live_retrieval_rpcs.sql` | +| Embeddings / HNSW | `20260623014639_finalize_embedding_fields_hnsw_health.sql` | +| Deep memory / visual intelligence | `20260528009000_deep_memory_indexing.sql`, `20260623150000_visual_intelligence_v1.sql` | +| Indexing v3 agent | `20260625000000_indexing_v3_agent_worker_hardening.sql`, `20260702190000_indexing_v3_agent_jobs_table.sql` | +| Atomic reindex | `20260628000000_atomic_reindex_generation_commit.sql` | +| Clinical registry | `20260703020000_clinical_registry_records.sql` | + +### Key RPCs + +- **Jobs:** `claim_ingestion_jobs`, `claim_indexing_v3_agent_jobs` +- **Index lifecycle:** `commit_document_index_generation`, `cleanup_abandoned_document_index_generations` +- **Retrieval:** `match_document_chunks_hybrid`, `match_document_chunks_text`, `match_documents_for_query`, `match_document_table_facts_text`, `match_document_embedding_fields_hybrid`, `match_document_memory_cards_hybrid_v2` +- **Health:** `search_schema_health`, `explain_retrieval_rpc` + +### Edge Functions + +| Function | Path | +|----------|------| +| indexing-v3-agent | `supabase/functions/indexing-v3-agent/index.ts` | + +Cron-triggered agent for indexing v3 completion gates. Auth via `INDEXING_V3_AGENT_SECRET`. Type-checked by `npm run check:edge:functions`. + +--- + +## Worker (`worker/`) + +| File | Role | +|------|------| +| `index.ts` | Bootstrap → `main.ts` | +| `main.ts` | Polls `ingestion_jobs`, extracts, chunks, embeds, writes index artifacts | +| `embedding-fields.ts` | Additional embedding field inputs | +| `table-facts.ts` | Table fact extraction | +| `prerequisites.ts` | Python/PDF OCR checks | +| `python/extract_pdf_assets.py` | PDF asset extraction (PyMuPDF/Tesseract) | + +**Flow:** Upload → Storage + job queue → worker parses (PDF/DOCX/XLSX/TXT) → OCR fallback → image captioning → chunking → OpenAI embeddings → pgvector. + +**Run:** `npm run worker` or `npm run worker:once` + +--- + +## Scripts (grouped) + +| Group | Key scripts | +|-------|-------------| +| Dev/server | `ensure-local-server.mjs`, `dev-free-port.mjs`, `check-runtime.ts` | +| Ingestion/indexing | `import-documents.ts`, `reindex.ts`, `reindex-health.ts`, `check-indexing.ts`, `backfill-smart-index.ts`, `recover-ingestion-queue.ts` | +| Document intelligence | `enrich-documents.ts`, `classify-documents.ts`, `backfill-gold-document-labels.ts` | +| Governance | `audit-source-governance.ts`, `production-readiness.ts`, `check-supabase-project.ts` | +| RAG eval | `eval-rag.ts`, `eval-retrieval.ts`, `eval-quality.ts`, `retrieval-health.ts` | +| Maintenance | `cleanup-storage.ts`, `generate-site-map.ts`, `seed-registry-records.ts` | + +Golden retrieval fixture: `scripts/fixtures/rag-retrieval-golden.json` + +--- + +## Tests + +| Config | Path | +|--------|------| +| Unit (Vitest) | `vitest.config.mts` — `tests/**/*.test.ts` | +| E2E (Playwright) | `playwright.config.ts` — `tests/ui-*.spec.ts` | +| Visual E2E | `playwright.visual.config.ts` | + +**Domain clusters in `tests/`:** RAG/answers, retrieval, ingestion/indexing, source governance, API routes, Supabase schema, shell/routing, UI formatting guards. + +**Gates:** `verify:cheap` (lint + typecheck + unit), `verify:ui` (Chromium E2E), `verify:release` (full build + all browsers + production readiness). + +--- + +## Domain concepts + +### Indexing pipeline + +1. Upload via `/api/upload` → `clinical-documents` bucket +2. Queue `ingestion_jobs` (+ optional `import_batches`) +3. **Worker** (`worker/main.ts`) or **Edge agent** (`indexing-v3-agent`) processes: extract → chunk → embed → write chunks, pages, images, embedding fields, index units, table facts +4. Quality gates: `document_index_quality`, enrichment versions, strict completion RPCs +5. Reindex: atomic generation commits (`reindex-pipeline.ts`), abandoned generation recovery + +### RAG + +- Hybrid retrieval: pgvector HNSW + lexical (tsvector/trigram) via Postgres RPCs +- Answer routing: fast vs strong models; `RAG_PROVIDER_MODE` (auto/openai/offline) +- Caching: `rag_response_cache`, app-layer caches in `env.ts` +- Eval: `npm run eval:quality`, `eval:retrieval` + +### Clinical KB surface + +- 8 app modes with unified search shell +- Documents mode: upload/manage private guidelines, search, cited answers +- Answer mode: grounded Q&A with PDF-linked citations +- Registry modes: services, forms, medications, differentials +- Demo mode: synthetic data when Supabase unavailable (`demo-data.ts`, `isDemoMode()` in `env.ts`) + +--- + +## Key config files + +| File | Role | +|------|------| +| `package.json` | Scripts, deps, Node 24 / npm 11 | +| `.env.example` | Full env template | +| `next.config.ts` | CSP, security headers, build config | +| `tsconfig.json` | Strict TS; excludes `supabase/functions/**` | +| `eslint.config.mjs` | Lint scope | +| `AGENTS.md` | Agent rules, verification gates, shortcuts | +| `.github/workflows/ci.yml` | CI pipeline | +| `docs/process-hardening.md` | Verification pyramid | +| `docs/clinical-governance.md` | Clinical safety governance | +| `docs/reindex-runbook.md` | Reindex operations | +| `docs/retrieval-quality-runbook.md` | Retrieval tuning | + +--- + +## Related docs + +| Topic | Doc | +|-------|-----| +| Routes and modes | `docs/site-map.md` | +| Search/RAG roadmap | `docs/search-rag-master-plan.md` | +| Reindex operations | `docs/reindex-runbook.md` | +| Production readiness | `docs/production-readiness-checklist.md` | +| Frontend refactor | `docs/frontend-architecture-refactor-plan.md` | +| Repo audit (2026-07-01) | `docs/audit/repo-audit-2026-07-01.md` | + +--- + +*Generated for agent onboarding. Update when adding major modules, API surfaces, or migration themes.* diff --git a/public/llms.txt b/public/llms.txt index 05a956996..c2ec0686b 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -2,6 +2,8 @@ Clinical Guide Purpose: Clinical Guide is a local clinical knowledge-base interface for searching indexed source documents, reviewing evidence, and drafting source-backed clinical answers. +Agent / codebase orientation: docs/codebase-index.md (module map, APIs, Supabase, worker). Route index: docs/site-map.md. + Key routes: - / opens the main dashboard. Use ?mode=answer, ?mode=documents, ?mode=tools, ?mode=favourites, ?mode=differentials, or ?mode=prescribing to choose the workspace. - /documents/search opens the documents search command centre after submitting a documents-mode query. From 92a532d6da220c75ff85e8c372e77e287c460594 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:32:40 +0800 Subject: [PATCH 38/61] refactor(answer): complete answer-review hygiene pass Extract AnswerFeedbackType to a neutral module, dedupe RelatedDocumentsPanel, link clinical-note rows to primary sources, fix priority accent styling, and extend formatter/test guards for the answer result surface. Co-authored-by: Cursor --- src/components/ClinicalDashboard.tsx | 920 ++++++++---------- .../answer-result-surface.tsx | 8 +- .../clinical-dashboard/document-results.tsx | 88 +- .../clinical-dashboard/evidence-panels.tsx | 76 +- .../clinical-dashboard/visual-evidence.tsx | 2 +- src/lib/answer-feedback.ts | 9 + tests/rendered-text-formatting.test.ts | 3 +- tests/ui-smoke.spec.ts | 63 +- 8 files changed, 553 insertions(+), 616 deletions(-) create mode 100644 src/lib/answer-feedback.ts diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 68d65230c..1a149cbfb 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -104,6 +104,7 @@ import { SafeBoldText } from "@/components/SafeBoldText"; import { Sheet } from "@/components/ui/sheet"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; +import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; @@ -269,7 +270,12 @@ import type { } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; -import { createQuoteFollowUp, type AnswerEvidenceMapRow, type AnswerViewMode, shouldPollForUpdates } from "@/lib/ward-output"; +import { + createQuoteFollowUp, + type AnswerEvidenceMapRow, + type AnswerViewMode, + shouldPollForUpdates, +} from "@/lib/ward-output"; export const navigationHashes = ["#search", "#quotes", "#images", "#sources"] as const; export const mobileSectionFabMediaQuery = @@ -327,15 +333,8 @@ type BatchesPayload = { hasActiveBatches?: boolean; pollAfterMs?: number | null; }; -export type AnswerFeedbackType = - | "verified" - | "needs_correction" - | "source_insufficient" - | "wrong_source" - | "missing_source" - | "unsupported_answer" - | "numeric_error" - | "outdated_guidance"; +import type { AnswerFeedbackType } from "@/lib/answer-feedback"; +export type { AnswerFeedbackType } from "@/lib/answer-feedback"; type IngestionQualityPayload = { items?: IngestionQualityReviewItem[]; demoMode?: boolean; @@ -1069,62 +1068,6 @@ function MobileEvidenceTabPanel({ return ; } -function RelatedDocumentsPanel({ - documents, - onScopeDocument, - onTagSearch, -}: { - documents: RelatedDocument[]; - onScopeDocument: (documentId: string) => void; - onTagSearch: (tag: SmartDocumentTag) => void; -}) { - if (documents.length === 0) return null; - - return ( - -
- {documents.map((document) => ( -
-
-
- - {documentDisplayTitle(document)} - - -

- {document.match_reason} · pages {document.best_pages.join(", ") || "n/a"} · {document.image_count}{" "} - images{document.table_count ? ` · ${document.table_count} tables` : ""} -

-
- -
- {document.summary && ( -

- -

- )} - -
- ))} -
-
- ); -} - /** * A completed Q&A exchange kept on screen after a newer answer arrives, so * Answer mode reads as a conversation thread instead of replacing each result. @@ -1165,7 +1108,10 @@ function PriorAnswerTurnSurface({ const grounded = turn.answer.grounded === true && turn.answer.confidence !== "unsupported" && renderModel.trust !== "unsupported"; const sourceCount = - renderModel.primarySources.length || turn.sources.length || turn.answer.sources?.length || turn.answer.citations.length; + renderModel.primarySources.length || + turn.sources.length || + turn.answer.sources?.length || + turn.answer.citations.length; const previewText = safeText || turn.answer.answer; return ( @@ -4613,11 +4559,7 @@ export function ClinicalDashboard({ // Keep only the latest question in the URL; the full thread lives in // React state until refresh or New chat. modeChangeFromUiRef.current = true; - window.history.replaceState( - null, - "", - appModeHomeHref(targetMode, { query: trimmedQuery, run: true }), - ); + window.history.replaceState(null, "", appModeHomeHref(targetMode, { query: trimmedQuery, run: true })); if (isAnswerFollowUp) { window.requestAnimationFrame(() => { const main = mainRef.current; @@ -5611,288 +5553,330 @@ export function ClinicalDashboard({ onClearScopes: () => setCommandScopes([]), }} > -
) to avoid a needless scrollbar. - // sm+/lg values stay identical to the result-view treatment. - searchMode === "answer" - ? compactMobileModeHome - ? "pb-4 sm:pb-36 lg:pb-40" - : "pb-32 sm:pb-36 lg:pb-40" - : hasMobileBottomSearch - ? compactMobileModeHome - ? "pb-4 sm:pb-10 lg:pb-12" - : compactMobileBottomSearch - ? "pb-8 sm:pb-10 lg:pb-12" - : "pb-32 sm:pb-10 lg:pb-12" - : "pb-8 sm:pb-10 lg:pb-12", - )} - > - {actionNotice && ( -
- {actionNotice.message} - -
- )} - {showDegradedNotice && renderDegradedNotice()} - {showSystemNotice && answer ? renderSystemNotice("hidden sm:block") : null} - -
) to avoid a needless scrollbar. + // sm+/lg values stay identical to the result-view treatment. + searchMode === "answer" + ? compactMobileModeHome + ? "pb-4 sm:pb-36 lg:pb-40" + : "pb-32 sm:pb-36 lg:pb-40" + : hasMobileBottomSearch + ? compactMobileModeHome + ? "pb-4 sm:pb-10 lg:pb-12" + : compactMobileBottomSearch + ? "pb-8 sm:pb-10 lg:pb-12" + : "pb-32 sm:pb-10 lg:pb-12" + : "pb-8 sm:pb-10 lg:pb-12", )} > -

- {activeModeSearch.resultHeading} -

- {error && ( -
- - {error} -
- )} - - {loading && answerProgress && searchMode !== "prescribing" && ( + {actionNotice && (
- - {answerProgress} + {actionNotice.message} +
)} + {showDegradedNotice && renderDegradedNotice()} + {showSystemNotice && answer ? renderSystemNotice("hidden sm:block") : null} - {activeModeResultKind === "differentials" ? ( - { - setQuery(nextQuery); - focusComposerInput(); - }} - onRunSearch={(nextQuery) => { - void executeSearch(nextQuery, "differentials", scopeFilters); - }} - onOpenPresentations={(nextQuery) => { - const queryParams = new URLSearchParams(); - const normalizedQuery = nextQuery.trim(); - if (normalizedQuery) queryParams.set("q", normalizedQuery); - router.push(`/differentials/presentations${queryParams.toString() ? `?${queryParams}` : ""}`); - }} - onOpenDiagnoses={(nextQuery) => { - const queryParams = new URLSearchParams(); - const normalizedQuery = nextQuery.trim(); - if (normalizedQuery) queryParams.set("q", normalizedQuery); - router.push(`/differentials/diagnoses${queryParams.toString() ? `?${queryParams}` : ""}`); - }} - /> - ) : activeModeResultKind === "tools" ? ( - - ) : activeModeResultKind === "favourites" ? ( - { - setQuery(""); - setModeSearchSubmitted(false); - router.replace(appModeHomeHref("favourites", { focus: true })); - }} - onAddFavourite={() => - setActionNotice({ tone: "success", message: "Favourite creation is ready to connect." }) - } - desktopComposerSlotId={desktopHomeComposerSlotId} - /> - ) : activeModeResultKind === "documents" || activeModeResultKind === "services" ? ( - searchMode === "prescribing" ? ( - +

+ {activeModeSearch.resultHeading} +

+ {error && ( +
+ + {error} +
+ )} + + {loading && answerProgress && searchMode !== "prescribing" && ( +
+ + {answerProgress} +
+ )} + + {activeModeResultKind === "differentials" ? ( + { + setQuery(nextQuery); + focusComposerInput(); + }} + onRunSearch={(nextQuery) => { + void executeSearch(nextQuery, "differentials", scopeFilters); + }} + onOpenPresentations={(nextQuery) => { + const queryParams = new URLSearchParams(); + const normalizedQuery = nextQuery.trim(); + if (normalizedQuery) queryParams.set("q", normalizedQuery); + router.push(`/differentials/presentations${queryParams.toString() ? `?${queryParams}` : ""}`); + }} + onOpenDiagnoses={(nextQuery) => { + const queryParams = new URLSearchParams(); + const normalizedQuery = nextQuery.trim(); + if (normalizedQuery) queryParams.set("q", normalizedQuery); + router.push(`/differentials/diagnoses${queryParams.toString() ? `?${queryParams}` : ""}`); + }} /> - ) : ( - <> - - + ) : activeModeResultKind === "favourites" ? ( + { + setQuery(""); + setModeSearchSubmitted(false); + router.replace(appModeHomeHref("favourites", { focus: true })); + }} + onAddFavourite={() => + setActionNotice({ tone: "success", message: "Favourite creation is ready to connect." }) + } + desktopComposerSlotId={desktopHomeComposerSlotId} + /> + ) : activeModeResultKind === "documents" || activeModeResultKind === "services" ? ( + searchMode === "prescribing" ? ( + - - ) - ) : loading && !answer ? ( - - ) : answer && answerRenderModel ? ( - stagedDashboardExtraction.answerSurface ? ( - <> - {hiddenPriorTurnCount > 0 && !showEarlierTurns ? ( - - ) : null} - {visiblePriorTurns.map((turn) => ( - toggleAnswerTurnCollapsed(turn.id)} - onCopy={(text) => copyText(turn.id, text)} + ) : ( + <> + + - ))} - - copyText("answer", answerRenderModel.copyText || safeAnswerText || answer.answer) - } - onSubmitFeedback={submitAnswerFeedback} - onFollowUpQuote={handleFollowUpQuote} - followUpSuggestions={answerFollowUpSuggestions} - onPickFollowUpSuggestion={handlePickFollowUpSuggestion} - followUpSuggestionsDisabled={loading} - /> - - ) : null - ) : ( - setSearchMode("documents")} - onUploadDocument={openUploadDrawer} - desktopComposerSlotId={desktopHomeComposerSlotId} - /> - )} -
+ + ) + ) : loading && !answer ? ( + + ) : answer && answerRenderModel ? ( + stagedDashboardExtraction.answerSurface ? ( + <> + {hiddenPriorTurnCount > 0 && !showEarlierTurns ? ( + + ) : null} + {visiblePriorTurns.map((turn) => ( + toggleAnswerTurnCollapsed(turn.id)} + onCopy={(text) => copyText(turn.id, text)} + /> + ))} + + copyText("answer", answerRenderModel.copyText || safeAnswerText || answer.answer) + } + onSubmitFeedback={submitAnswerFeedback} + onFollowUpQuote={handleFollowUpQuote} + followUpSuggestions={answerFollowUpSuggestions} + onPickFollowUpSuggestion={handlePickFollowUpSuggestion} + followUpSuggestionsDisabled={loading} + /> + + ) : null + ) : ( + setSearchMode("documents")} + onUploadDocument={openUploadDrawer} + desktopComposerSlotId={desktopHomeComposerSlotId} + /> + )} + - {showSystemNotice && answer ? renderSystemNotice("sm:hidden") : null} + {showSystemNotice && answer ? renderSystemNotice("sm:hidden") : null} - {activeModeResultKind === "answer" && answer && ( - - )} - {(documentsDrawerOpen || uploadDrawerOpen) && ( -
- - {documentsDrawerOpen ? ( - - + ) : null} +
+ )} - {(documentsDrawerOpen || uploadDrawerOpen) && } -
+ {(documentsDrawerOpen || uploadDrawerOpen) && } +
diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index 032ef0683..7f094a426 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; import { ClipboardCheck, ExternalLink, Layers, ShieldAlert } from "lucide-react"; -import { type AnswerFeedbackType } from "@/components/ClinicalDashboard"; +import { type AnswerFeedbackType } from "@/lib/answer-feedback"; import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { NaturalLanguageAnswer, UserQuestionBubble } from "@/components/clinical-dashboard/answer-content"; import { @@ -89,7 +89,10 @@ export function StagedAnswerResultSurface({ }) { const noteCount = clinicalNotesCount(answer); const showClinicalNotes = - safetyFindings.length > 0 || noteCount > 0 || answer.answerQualityTier === "source_only" || answerGrounded === false; + safetyFindings.length > 0 || + noteCount > 0 || + answer.answerQualityTier === "source_only" || + answerGrounded === false; const clinicalNoteDisplayCount = clinicalNotesDisplayCountForAnswer( answer, answerViewMode, @@ -282,6 +285,7 @@ export function StagedAnswerResultSurface({ answer={answer} viewMode={answerViewMode} evidenceMapRows={answerEvidenceMapRows} + sourceLinks={renderModel.primarySources} bestSource={bestSource} copied={copiedAnswer} onCopy={onCopyAnswer} diff --git a/src/components/clinical-dashboard/document-results.tsx b/src/components/clinical-dashboard/document-results.tsx index 459ad736d..f5aa15bd5 100644 --- a/src/components/clinical-dashboard/document-results.tsx +++ b/src/components/clinical-dashboard/document-results.tsx @@ -1,96 +1,18 @@ "use client"; import Link from "next/link"; -import { BookOpen, ChevronDown, Search } from "lucide-react"; +import { BookOpen } from "lucide-react"; import { DocumentOrganizationBadges, documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { SafeBoldText } from "@/components/SafeBoldText"; -import { StrengthBadge } from "@/components/clinical-dashboard/badges"; import { UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell"; -import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text"; -import { MatchExplanationChips } from "@/components/clinical-dashboard/document-search-results"; -import { QueryCoverageChips, RelevanceBadge } from "@/components/clinical-dashboard/relevance"; -import { - cn, - floatingControl, - iconTilePremium, - panelSubtle, - sourceCard, - SourceStatusBadge, - textMuted, -} from "@/components/ui-primitives"; +import { cn, floatingControl, sourceCard, textMuted } from "@/components/ui-primitives"; import { type SmartDocumentTag } from "@/lib/document-tags"; -import type { RelatedDocument, SearchResult } from "@/lib/types"; +import type { RelatedDocument } from "@/lib/types"; export { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; -function WhyThisMatchedPanel({ sources }: { sources: SearchResult[] }) { - const visibleSources = sources.slice(0, 3); - if (visibleSources.length === 0) return null; - - return ( -
- - - - - - - Why this matched - - Match signals, source strength, and term coverage for top passages - - - - - -
- {visibleSources.map((source) => ( -
-
-
-

- {cleanDisplayTitle(source.title)} -

-

- page {source.page_number ?? "n/a"} ·{" "} - chunk {source.chunk_index} -

-
-
- - - -
-
- - {source.index_unit ? ( -

- - {source.index_unit.unit_type.replaceAll("_", " ")}: - {" "} - {source.index_unit.title} -

- ) : null} -
- -
-
- ))} -
-
- ); -} - export function RelatedDocumentsPanel({ documents, onScopeDocument, @@ -116,7 +38,7 @@ export function RelatedDocumentsPanel({
{documentDisplayTitle(document)} @@ -129,7 +51,7 @@ export function RelatedDocumentsPanel({ diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index 5cb02a0ca..46dfd4a4e 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -27,7 +27,7 @@ import { } from "lucide-react"; import { AccessibleTable } from "@/components/AccessibleTable"; -import { type AnswerFeedbackType } from "@/components/ClinicalDashboard"; +import { type AnswerFeedbackType } from "@/lib/answer-feedback"; import { ClinicalOutputPanel } from "@/components/clinical-dashboard/output-panel"; import { keyClinicalItemsFromSections, @@ -68,7 +68,13 @@ import { } from "@/components/ui-primitives"; import { type AnswerRenderModel, type SourceLink } from "@/lib/answer-render-policy"; import { documentCitationHref, formatCitationLabel, formatCompactCitationLabel } from "@/lib/citations"; -import { extractSafetyFindings, formatSafetyFindingLabel, sortSafetyFindingsBySeverity, type SafetyFinding, type SafetyFindingKind } from "@/lib/clinical-safety"; +import { + extractSafetyFindings, + formatSafetyFindingLabel, + sortSafetyFindingsBySeverity, + type SafetyFinding, + type SafetyFindingKind, +} from "@/lib/clinical-safety"; import { normalizeSourceMetadata, sourceStatusLabel } from "@/lib/source-metadata"; import { normalizeExtractedGlyphs, @@ -184,10 +190,7 @@ export function AnswerSupportSummaryCard({ data-testid="answer-safety-findings-trigger" type="button" onClick={onOpenSafetyFindings} - className={cn( - supportButtonClass, - "w-full border-t-2 border-t-[color:var(--warning)]", - )} + className={cn(supportButtonClass, "w-full border-t-2 border-t-[color:var(--warning)]")} aria-label="Open safety-critical source findings" >
+ + ); + return row.href ? ( + + {rowContent} + + ) : ( +
+ {rowContent}
); })} diff --git a/src/components/clinical-dashboard/visual-evidence.tsx b/src/components/clinical-dashboard/visual-evidence.tsx index 7c4333611..3c2927a1c 100644 --- a/src/components/clinical-dashboard/visual-evidence.tsx +++ b/src/components/clinical-dashboard/visual-evidence.tsx @@ -17,7 +17,7 @@ import { } from "lucide-react"; import { AccessibleTable } from "@/components/AccessibleTable"; -import { type AnswerFeedbackType } from "@/components/ClinicalDashboard"; +import { type AnswerFeedbackType } from "@/lib/answer-feedback"; import { ScopeAndGovernanceNotice, SourceImage } from "@/components/clinical-dashboard/answer-content"; import { SectionHeading, UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell"; import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text"; diff --git a/src/lib/answer-feedback.ts b/src/lib/answer-feedback.ts new file mode 100644 index 000000000..9e2561c3d --- /dev/null +++ b/src/lib/answer-feedback.ts @@ -0,0 +1,9 @@ +export type AnswerFeedbackType = + | "verified" + | "needs_correction" + | "source_insufficient" + | "wrong_source" + | "missing_source" + | "unsupported_answer" + | "numeric_error" + | "outdated_guidance"; diff --git a/tests/rendered-text-formatting.test.ts b/tests/rendered-text-formatting.test.ts index 13d5da9d5..eed7c90e8 100644 --- a/tests/rendered-text-formatting.test.ts +++ b/tests/rendered-text-formatting.test.ts @@ -23,7 +23,8 @@ describe("document-derived text must route through a formatter", () => { const outputPanel = componentSource("clinical-dashboard/output-panel.tsx"); const visualEvidence = componentSource("clinical-dashboard/visual-evidence.tsx"); const documentResults = componentSource("clinical-dashboard/document-results.tsx"); - const dashboardSurfaces = `${dashboard}\n${answerContent}\n${evidenceContent}\n${outputPanel}\n${visualEvidence}\n${documentResults}`; + const answerResultSurface = componentSource("clinical-dashboard/answer-result-surface.tsx"); + const dashboardSurfaces = `${dashboard}\n${answerContent}\n${evidenceContent}\n${outputPanel}\n${visualEvidence}\n${documentResults}\n${answerResultSurface}`; it("renders exact quotes through the verbatim cleaner, never raw", () => { // Allow `${quote.quote}` inside template literals (React keys, clipboard text); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 7f2c48c1e..98866ef5a 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -47,6 +47,28 @@ function visibleAnswerSubmitButton(page: Page) { return page.locator('[aria-label="Generate source-backed answer"]:visible').first(); } +async function openInlineTableFullscreen(page: Page, clinicalTable: Locator) { + const dialog = page.getByTestId("table-fullscreen-dialog"); + const expandButton = clinicalTable.getByTestId("table-expand-button"); + const surface = clinicalTable.getByTestId("accessible-table-surface"); + + await clinicalTable.scrollIntoViewIfNeeded(); + await page.keyboard.press("Escape"); + + await expect(async () => { + if (await expandButton.isVisible().catch(() => false)) { + await expandButton.scrollIntoViewIfNeeded(); + await expandButton.click({ force: true }); + } else { + await surface.focus(); + await page.keyboard.press("Enter"); + } + await expect(dialog).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 15_000 }); + + return dialog; +} + async function isVisibleWithoutThrow(locator: Locator) { return locator.isVisible().catch(() => false); } @@ -495,8 +517,12 @@ async function openMobileClinicalGuideMenu(page: Page) { } async function waitForDemoDashboardReady(page: Page) { - await expect(visibleQuestionInput(page)).toBeEnabled(); - await expect(page.getByRole("button", { name: "Open answer options" })).toBeVisible({ timeout: 30000 }); + await expect(async () => { + const input = page.locator('[data-testid="global-search-input"]:visible').first(); + await input.scrollIntoViewIfNeeded(); + await expect(input).toBeEnabled({ timeout: 3_000 }); + }).toPass({ timeout: 30_000 }); + await expect(page.getByRole("button", { name: "Open answer options" })).toBeVisible({ timeout: 30_000 }); } async function openGuide(page: Page) { @@ -1002,15 +1028,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(clinicalTable.getByRole("button", { name: "More table actions" })).toHaveCount(0); const tableExpandButton = clinicalTable.getByTestId("table-expand-button"); await expect(clinicalTable.getByTestId("accessible-table-surface")).toBeVisible(); - await page.keyboard.press("Escape"); - await clinicalTable.scrollIntoViewIfNeeded(); - if (await tableExpandButton.isVisible().catch(() => false)) { - await tableExpandButton.click({ force: true }); - } else { - await clinicalTable.getByTestId("accessible-table-surface").click({ force: true }); - } - const tableDialog = page.getByTestId("table-fullscreen-dialog"); - await expect(tableDialog).toBeVisible({ timeout: 10_000 }); + const tableDialog = await openInlineTableFullscreen(page, clinicalTable); await expect(tableDialog.getByRole("table")).toBeVisible(); await expect(tableDialog).toContainText("FBC/ANC"); await expect(tableDialog).not.toContainText(/page|p\.|chunk|Synthetic clozapine monitoring protocol/i); @@ -1060,6 +1078,8 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(clinicalNotesSheet.getByRole("tab", { name: /Actions/ })).toBeVisible(); await expect(clinicalNotesSheet.getByRole("tab", { name: /Safety/ })).toBeVisible(); expect(await clinicalNotesSheet.getByTestId("clinical-note-row").count()).toBeGreaterThan(0); + const linkedNoteRow = clinicalNotesSheet.getByTestId("clinical-note-row").first(); + await expect(linkedNoteRow).toHaveAttribute("href", /\/documents\//); await expect(clinicalNotesSheet.getByText("Review toxicity symptoms", { exact: true })).toBeVisible(); await tapOutsideActiveSurface(page); await expect(clinicalNotesSheet).toHaveCount(0); @@ -1440,20 +1460,13 @@ test.describe("Clinical KB UI smoke coverage", () => { return; } - await page.keyboard.press("Escape"); - await clinicalTable.scrollIntoViewIfNeeded(); - - await clinicalTable.getByTestId("accessible-table-surface").click({ force: true }); - const surfaceDialog = page.getByTestId("table-fullscreen-dialog"); - await expect(surfaceDialog).toBeVisible(); + const surfaceDialog = await openInlineTableFullscreen(page, clinicalTable); await expect(surfaceDialog).toContainText("FBC/ANC"); await page.keyboard.press("Escape"); await expect(surfaceDialog).toBeHidden(); await expect(expandButton).toBeVisible(); - await expandButton.click({ force: true }); - const dialog = page.getByTestId("table-fullscreen-dialog"); - await expect(dialog).toBeVisible(); + const dialog = await openInlineTableFullscreen(page, clinicalTable); await expect(dialog.getByRole("table")).toBeVisible(); await expect(dialog).toContainText("FBC/ANC"); await expect(dialog).not.toContainText(/page|p\.|chunk|Synthetic clozapine monitoring protocol/i); @@ -1765,11 +1778,15 @@ test.describe("Clinical KB UI smoke coverage", () => { const expandedEvidenceBox = await evidence.boundingBox(); expect(expandedEvidenceBox?.height ?? 0).toBeGreaterThan(evidenceBox!.height); await viewerNav.getByRole("link", { name: "PDF" }).click(); - await expect(preview).toBeInViewport(); + await preview.scrollIntoViewIfNeeded(); + await expect(preview).toBeVisible(); await viewerNav.getByRole("link", { name: "Text" }).click(); - await expect(page.getByText("Indexed page text", { exact: true })).toBeInViewport(); + const indexedText = page.getByText("Indexed page text", { exact: true }); + await indexedText.scrollIntoViewIfNeeded(); + await expect(indexedText).toBeVisible(); await viewerNav.getByRole("link", { name: "PDF" }).click(); - await expect(preview).toBeInViewport(); + await preview.scrollIntoViewIfNeeded(); + await expect(preview).toBeVisible(); const mobilePdfStyles = await toolbar.evaluate((element) => ({ position: window.getComputedStyle(element).position, From e84aa45c84d9f3c6a60d8570f312148353ad740f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:35:03 +0800 Subject: [PATCH 39/61] Polish favourites library sidebar and desktop header layout. Add a soft gradient to the library sidebar and group the desktop subtitle with the search band to reduce vertical repetition. Co-authored-by: Cursor --- .../favourites-command-library-page.tsx | 94 +++++++++++-------- .../favourites-library-nav.tsx | 13 ++- 2 files changed, 66 insertions(+), 41 deletions(-) diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index c2fa9da54..734cb6f37 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -37,7 +37,10 @@ import { type FavouriteItem as PrototypeFavouriteItem, } from "@/components/clinical-dashboard/favourites-prototype-data"; import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-saved-registry-favourites"; -import { SearchResultsEmptyState, SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; +import { + SearchResultsEmptyState, + SearchResultsHeaderBand, +} from "@/components/clinical-dashboard/search-results-header-band"; import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { favouriteMatchesCommandScopes } from "@/lib/search-command-surface"; import { appModeIcons } from "@/lib/app-mode-icons"; @@ -85,12 +88,14 @@ const sourceRecords: SourceRecord[] = [ const typeStyles: Record = { Medication: "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", - Document: "border-[color:var(--type-document-border)] bg-[color:var(--type-document-soft)] text-[color:var(--type-document)]", + Document: + "border-[color:var(--type-document-border)] bg-[color:var(--type-document-soft)] text-[color:var(--type-document)]", Table: "border-[color:var(--type-table-border)] bg-[color:var(--type-table-soft)] text-[color:var(--type-table)]", "Saved search": "border-[color:var(--type-search-border)] bg-[color:var(--type-search-soft)] text-[color:var(--type-search)]", Source: "border-[color:var(--type-source-border)] bg-[color:var(--type-source-soft)] text-[color:var(--type-source)]", - Service: "border-[color:var(--type-service-border)] bg-[color:var(--type-service-soft)] text-[color:var(--type-service)]", + Service: + "border-[color:var(--type-service-border)] bg-[color:var(--type-service-soft)] text-[color:var(--type-service)]", Form: "border-[color:var(--type-form-border)] bg-[color:var(--type-form-soft)] text-[color:var(--type-form)]", }; @@ -165,7 +170,10 @@ function buildFavouriteSets(items: FavouriteItem[]): FavouriteSet[] { const dynamicSets = Array.from(new Set(items.map((item) => item.set))) .filter((title) => title && !knownTitles.has(title)) .map((title) => ({ - id: title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""), + id: title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""), title, count: items.filter((item) => item.set === title).length, })); @@ -213,7 +221,8 @@ function filterAndSortItems( ) .sort((first, second) => { if (effectiveSort === "title") return first.title.localeCompare(second.title); - if (effectiveSort === "type") return first.type.localeCompare(second.type) || first.title.localeCompare(second.title); + if (effectiveSort === "type") + return first.type.localeCompare(second.type) || first.title.localeCompare(second.title); return lastUsedScore(second.lastUsed) - lastUsedScore(first.lastUsed); }); } @@ -298,13 +307,7 @@ function ActiveFilterChips({ ); } -function ContinueStrip({ - item, - onSelect, -}: { - item: FavouriteItem; - onSelect: (id: string) => void; -}) { +function ContinueStrip({ item, onSelect }: { item: FavouriteItem; onSelect: (id: string) => void }) { const Icon = item.icon; return (
onSelectItem(item.id)} className={cn( "relative h-16 cursor-pointer transition hover:bg-[color:var(--surface-subtle)]", - selected && "bg-[color:var(--clinical-accent-soft)]/45 shadow-[inset_3px_0_0_var(--clinical-accent)]", + selected && + "bg-[color:var(--clinical-accent-soft)]/45 shadow-[inset_3px_0_0_var(--clinical-accent)]", )} > @@ -871,7 +875,7 @@ export function FavouritesCommandLibraryPage({ query = "" }: { query?: string }) const [selectedItemId, setSelectedItemId] = useState(null); const effectiveSelectedSetId = selectedSetId && sets.some((set) => set.id === selectedSetId) ? selectedSetId : null; - const selectedSet = effectiveSelectedSetId ? sets.find((set) => set.id === effectiveSelectedSetId) ?? null : null; + const selectedSet = effectiveSelectedSetId ? (sets.find((set) => set.id === effectiveSelectedSetId) ?? null) : null; const filteredItems = useMemo( () => @@ -892,11 +896,9 @@ export function FavouritesCommandLibraryPage({ query = "" }: { query?: string }) const continueItem = useMemo(() => getMostRecentlyUsedItem(items), [items]); const showContinueStrip = - continueItem !== null && - filteredItems.some((item) => item.id === continueItem.id) && - filteredItems.length > 0; + continueItem !== null && filteredItems.some((item) => item.id === continueItem.id) && filteredItems.length > 0; - const selectedItem = selectedItemId ? items.find((item) => item.id === selectedItemId) ?? null : null; + const selectedItem = selectedItemId ? (items.find((item) => item.id === selectedItemId) ?? null) : null; function clearSearch() { router.push("/favourites"); @@ -933,25 +935,43 @@ export function FavouritesCommandLibraryPage({ query = "" }: { query?: string }) onSelectViewMode={setViewMode} />
-
+
- +

Favourites command library

-

+

Your saved clinical knowledge, sets and searches - action-ready and source-backed.

+
+ +
- +
+ +

+ Your saved clinical knowledge, sets and searches - action-ready and source-backed. +

+
+ ) : ( - + )}
) : null} diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index e6fffb028..29d868eed 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -123,9 +123,7 @@ function DifferentialsMobileCompareAddon({ selectedCount, query }: { selectedCou useEffect(() => { const phoneMediaQuery = window.matchMedia("(max-width: 1023px)"); const sync = () => { - setHost( - phoneMediaQuery.matches ? document.getElementById(differentialsMobileCompareAddonSlotId) : null, - ); + setHost(phoneMediaQuery.matches ? document.getElementById(differentialsMobileCompareAddonSlotId) : null); }; sync(); phoneMediaQuery.addEventListener("change", sync); @@ -693,12 +691,7 @@ function SearchResultsView({ className="mx-auto grid w-full max-w-[86rem] gap-4 overflow-x-hidden px-3 pb-[calc(9rem+env(safe-area-inset-bottom))] sm:px-4 lg:px-0 lg:pb-0" >
- +

0 || (trimmedQuery && !shouldShowHome) || loading || - unavailableMessage && !shouldShowHome ? ( + (unavailableMessage && !shouldShowHome) ? ( ) : null} @@ -902,9 +902,7 @@ export function DocumentSearchResultsPanel({

-

- No matching documents -

+

No matching documents

{`No documents matched "${trimmedQuery}". Try a medication, acronym, policy name, or workflow term.`}

diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 734cb6f37..c3665a18e 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -953,12 +953,7 @@ export function FavouritesCommandLibraryPage({ query = "" }: { query?: string })
- +
diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index 896d849d6..8481130da 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -21,11 +21,11 @@ import { favouriteItems, favouriteSets, favouriteTabs, - favouriteTypeCount, type FavouriteItem, type FavouriteSet, type FavouriteTabId, } from "@/components/clinical-dashboard/favourites-prototype-data"; +import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-saved-registry-favourites"; function favouriteMatchesQuery(value: { title: string; meta?: string; set?: string; keywords: string }, query: string) { const normalized = query.trim().toLowerCase(); @@ -51,16 +51,16 @@ export function FavouritesHub({ headingLevel?: 1 | 2; }) { const savedRegistryFavourites = useSavedRegistryFavourites(); - const allFavouriteItems = useMemo( - () => [...favouriteItems, ...savedRegistryFavourites], - [savedRegistryFavourites], - ); + const allFavouriteItems = useMemo(() => [...favouriteItems, ...savedRegistryFavourites], [savedRegistryFavourites]); const allFavouriteSets = useMemo(() => { const savedSetTitles = new Set(favouriteSets.map((set) => set.title)); const dynamicSets = Array.from(new Set(savedRegistryFavourites.map((item) => item.set))) .filter((title) => title && !savedSetTitles.has(title)) .map((title) => ({ - id: title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""), + id: title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""), title, count: savedRegistryFavourites.filter((item) => item.set === title).length, meta: "Saved from site activity", diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index 9cf2a52f1..c292c5c25 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -136,8 +136,7 @@ function GlobalMockupSearchShellClient({ const sidebarIdentity = useMemo(() => deriveSidebarIdentity(auth.session?.user.email), [auth.session?.user.email]); const hasSubmittedModeSearch = requestedRun && requestedQuery.length > 0; const isHomeRoute = pathname === "/"; - const isDocumentFlowRoute = - pathname === "/documents/search" || pathname.startsWith("/documents/source"); + const isDocumentFlowRoute = pathname === "/documents/search" || pathname.startsWith("/documents/source"); const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search") || isDocumentFlowRoute; const isDocumentCommandSearchView = pathname === "/documents/search" && requestedQuery.length > 0; const useCompactBottomSearch = hasSubmittedModeSearch || isDocumentCommandSearchView; @@ -285,8 +284,7 @@ function GlobalMockupSearchShellClient({ navigateToMode(mode, { query: crossQuery, focus: true, run: true }); } - const shouldRenderClinicalDashboard = - isHomeRoute || (shouldRenderDashboardSearch && !shouldRenderFormsSearchResults); + const shouldRenderClinicalDashboard = isHomeRoute || (shouldRenderDashboardSearch && !shouldRenderFormsSearchResults); if (shouldRenderClinicalDashboard) { return ( @@ -314,7 +312,8 @@ function GlobalMockupSearchShellClient({ className={cn( "min-h-dvh bg-[color:var(--background)] text-[color:var(--text)]", shouldShowDesktopSidebar && "md:grid md:grid-cols-[5.25rem_minmax(0,1fr)]", - shouldShowDesktopSidebar && "motion-safe:transition-[grid-template-columns] motion-safe:duration-200 motion-safe:ease-out", + shouldShowDesktopSidebar && + "motion-safe:transition-[grid-template-columns] motion-safe:duration-200 motion-safe:ease-out", shouldShowDesktopSidebar && (effectiveSidebarCollapsed ? "lg:grid-cols-[5.25rem_minmax(0,1fr)]" : "lg:grid-cols-[20rem_minmax(0,1fr)]"), )} @@ -428,21 +427,21 @@ function GlobalMockupSearchShellClient({ : "pb-[calc(9rem+env(safe-area-inset-bottom))] sm:pb-8", )} > - } - > - setCommandScopes((current) => current.filter((scope) => scope !== scopeId)), - onClearScopes: () => setCommandScopes([]), - }} - > - {shouldRenderFormsSearchResults ? : children} - - + } + > + setCommandScopes((current) => current.filter((scope) => scope !== scopeId)), + onClearScopes: () => setCommandScopes([]), + }} + > + {shouldRenderFormsSearchResults ? : children} + +
diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 113e7060f..2a039c396 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1256,96 +1256,96 @@ export function MasterSearchHeader({ } }} > -
- { - setUsesScopeSheet(currentUsesScopeSheet()); - setModeMenuOpen(false); - setScopeOpen(false); - }} - onAction={runModeAction} - onModeSelect={selectAppModeById} - onPlacementChange={setActionMenuPlacement} - triggerClassName="answer-footer-search-action" - integrated={usesFooterChipLayout} - /> +
+ { + setUsesScopeSheet(currentUsesScopeSheet()); + setModeMenuOpen(false); + setScopeOpen(false); + }} + onAction={runModeAction} + onModeSelect={selectAppModeById} + onPlacementChange={setActionMenuPlacement} + triggerClassName="answer-footer-search-action" + integrated={usesFooterChipLayout} + /> - {/* The clear button is a flex sibling (not absolutely positioned): the + {/* The clear button is a flex sibling (not absolutely positioned): the unlayered .answer-footer-search-input padding beats a conditional pr-* utility, which let text run under an overlaid button. */} - -
+ +
{showFooterSearchChips && (trustFooterChip || hasScopeFooterChip || secondaryFooterChip) ? (
diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index d19ded694..43cd24cf6 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -87,7 +87,11 @@ export function SearchResultsHeaderBand({ ) : null} {onViewChange ? ( -
+
diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index d9172af47..31973898f 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -367,23 +367,16 @@ function ClinicalCollapsedRail({ ) : ( - <> - {/* Tablet: the expanded panel does not exist below lg, so show a - static brand mark instead of a dead expand control. */} - - - + )}
diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 29d868eed..7f3442630 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -690,6 +690,9 @@ function SearchResultsView({ data-testid="differentials-search-results" className="mx-auto grid w-full max-w-[86rem] gap-4 overflow-x-hidden px-3 pb-[calc(9rem+env(safe-area-inset-bottom))] sm:px-4 lg:px-0 lg:pb-0" > +
+ +
diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index c292c5c25..2b0272063 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -4,8 +4,9 @@ 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 { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; -import { recentQueryStorageKey, SettingsDialog } from "@/components/ClinicalDashboard"; +import { recentQueryStorageKey } from "@/components/ClinicalDashboard"; +import { SettingsDialog } from "@/components/clinical-dashboard/settings/settings-dialog"; +import type { SettingsSectionId } from "@/components/clinical-dashboard/settings/types"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { ClinicalDesktopSidebar, @@ -128,7 +129,7 @@ function GlobalMockupSearchShellClient({ const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); const [guideOpen, setGuideOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); - const [accountSetupOpen, setAccountSetupOpen] = useState(false); + const [settingsInitialSection, setSettingsInitialSection] = useState("general"); const [recentQueries, setRecentQueries] = useState([]); const [commandScopes, setCommandScopes] = useState([]); const { theme, toggleTheme } = useTheme(); @@ -152,6 +153,9 @@ function GlobalMockupSearchShellClient({ (searchMode === "forms" && pathname === "/forms") || (searchMode === "favourites" && pathname === "/favourites") || (searchMode === "differentials" && pathname === "/differentials")); + /** Favourites needs library/results visible above the fold — skip hero composer there. */ + const useHeroModeHome = + isStandaloneModeHome && searchMode !== "favourites" && searchMode !== "prescribing"; const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations"); const shouldShowDesktopSidebar = !hideDesktopSidebar; const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow ? true : sidebarCollapsed; @@ -214,28 +218,22 @@ function GlobalMockupSearchShellClient({ function openGuide() { setSettingsOpen(false); - setAccountSetupOpen(false); setMobileMenuOpen(false); setGuideOpen(true); } function openSettings() { setGuideOpen(false); - setAccountSetupOpen(false); setMobileMenuOpen(false); + setSettingsInitialSection("general"); setSettingsOpen(true); } function openAccountProfile() { setGuideOpen(false); setMobileMenuOpen(false); - if (sidebarIdentity.signedIn) { - setAccountSetupOpen(false); - setSettingsOpen(true); - return; - } - setSettingsOpen(false); - setAccountSetupOpen(true); + setSettingsInitialSection("account"); + setSettingsOpen(true); } function navigateToMode(mode: AppModeId, options: { query?: string; run?: boolean; focus?: boolean } = {}) { @@ -397,13 +395,15 @@ function GlobalMockupSearchShellClient({ // Submitted searches that stay in the shell (services results) are // result views: compact the phone bottom composer so results keep // maximum screen space. Mode homes keep the chip-row layout. - mobileBottomSearchVariant={useCompactBottomSearch ? "compact" : "default"} + mobileBottomSearchVariant={ + useCompactBottomSearch || (isStandaloneModeHome && searchMode === "favourites") ? "compact" : "default" + } desktopSearchPlacement={ - (desktopSearchPlacement === "hero" || isFormsOnlyShell) && isStandaloneModeHome ? "hero" : "default" + (desktopSearchPlacement === "hero" || isFormsOnlyShell) && useHeroModeHome ? "hero" : "default" } searchComposerVisible={shouldShowSearchComposer} desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} - heroComposerFromTablet={isStandaloneModeHome} + heroComposerFromTablet={useHeroModeHome} // Phone-only: the document scrolls here and the header is sticky, // so a translate overlay hides it with zero layout shift. hideOnScroll={{ strategy: "overlay" }} @@ -448,14 +448,12 @@ function GlobalMockupSearchShellClient({ setGuideOpen(false)} /> setSettingsOpen(false)} identity={sidebarIdentity} - theme={theme} - onToggleTheme={toggleTheme} onSignOut={auth.signOut} onOpenGuide={openGuide} /> - setAccountSetupOpen(false)} /> ({ label: item.label, icon: item.icon, + onClick: () => onSuggestedSearch(`${item.label.toLowerCase()} prescribing`), }))} footer={
@@ -619,6 +620,9 @@ function MedicationResults({ return (
+
+ +
diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index 43cd24cf6..e3df168df 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -20,6 +20,7 @@ export function SearchResultsHeaderBand({ sortValue = "relevance", onSortChange, onSaveSearch, + compact = false, className, }: { modeId: AppModeId; @@ -31,6 +32,7 @@ export function SearchResultsHeaderBand({ sortValue?: string; onSortChange?: (value: string) => void; onSaveSearch?: () => void; + compact?: boolean; className?: string; }) { const command = useSearchCommand(); @@ -41,15 +43,21 @@ export function SearchResultsHeaderBand({ return (
- - + + {displayQuery} - + {loading ? "Searching…" : `${matchCount} ${matchCount === 1 ? "match" : "matches"}`} {activeScopes.map((scopeId) => { @@ -61,7 +69,8 @@ export function SearchResultsHeaderBand({ type="button" onClick={() => command?.onRemoveScope(scope.id)} className={cn( - "inline-flex min-h-8 items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--surface-subtle)] px-2.5 text-xs font-bold text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)]", + "inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--surface-subtle)] font-bold text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)]", + compact ? "min-h-7 px-2 text-2xs" : "min-h-8 px-2.5 text-xs", focusRing, )} > @@ -70,7 +79,7 @@ export function SearchResultsHeaderBand({ ); })} -
+
{onSortChange ? (
- 2,065 indexed + Sample · 2,065 indexed - + Save search @@ -669,14 +670,14 @@ export function MasterDocumentSearch() { )} > {label} -

{filtered.length} results

From f4c9012b8373a89c3d6e365d5792059c69cf8157 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:59:24 +0800 Subject: [PATCH 49/61] fix(ui): finish design-review trust and navigation polish Disable document search sort control and use router.back with services home fallback on service detail. --- .../clinical-dashboard/document-search-results.tsx | 7 ++++++- src/components/services/service-detail-page.tsx | 4 ++++ src/components/ui-primitives.tsx | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index b1993ecb6..6809fb1d1 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -44,6 +44,8 @@ import { sourceCard, textMuted, } from "@/components/ui-primitives"; + +const disabledControlClass = "cursor-not-allowed opacity-60"; import { buildSmartDocumentTagFacetIndex, filterDocumentsBySmartTagFacetIndex, @@ -375,9 +377,12 @@ function SearchResultsHeader({ resultLabel, trimmedQuery }: { resultLabel: strin
diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 7f3442630..6fcbb6226 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -691,7 +691,13 @@ function SearchResultsView({ className="mx-auto grid w-full max-w-[86rem] gap-4 overflow-x-hidden px-3 pb-[calc(9rem+env(safe-area-inset-bottom))] sm:px-4 lg:px-0 lg:pb-0" >
- +
diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index 2b0272063..05249054d 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -5,8 +5,7 @@ import { Suspense, type CSSProperties, type ReactNode, useEffect, useMemo, useRe import { ClinicalDashboard } from "@/components/clinical-dashboard"; import { recentQueryStorageKey } from "@/components/ClinicalDashboard"; -import { SettingsDialog } from "@/components/clinical-dashboard/settings/settings-dialog"; -import type { SettingsSectionId } from "@/components/clinical-dashboard/settings/types"; +import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { ClinicalDesktopSidebar, @@ -129,7 +128,6 @@ function GlobalMockupSearchShellClient({ const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); const [guideOpen, setGuideOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); - const [settingsInitialSection, setSettingsInitialSection] = useState("general"); const [recentQueries, setRecentQueries] = useState([]); const [commandScopes, setCommandScopes] = useState([]); const { theme, toggleTheme } = useTheme(); @@ -154,8 +152,7 @@ function GlobalMockupSearchShellClient({ (searchMode === "favourites" && pathname === "/favourites") || (searchMode === "differentials" && pathname === "/differentials")); /** Favourites needs library/results visible above the fold — skip hero composer there. */ - const useHeroModeHome = - isStandaloneModeHome && searchMode !== "favourites" && searchMode !== "prescribing"; + const useHeroModeHome = isStandaloneModeHome && searchMode !== "favourites"; const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations"); const shouldShowDesktopSidebar = !hideDesktopSidebar; const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow ? true : sidebarCollapsed; @@ -225,14 +222,12 @@ function GlobalMockupSearchShellClient({ function openSettings() { setGuideOpen(false); setMobileMenuOpen(false); - setSettingsInitialSection("general"); setSettingsOpen(true); } function openAccountProfile() { setGuideOpen(false); setMobileMenuOpen(false); - setSettingsInitialSection("account"); setSettingsOpen(true); } @@ -448,9 +443,10 @@ function GlobalMockupSearchShellClient({ setGuideOpen(false)} /> setSettingsOpen(false)} identity={sidebarIdentity} + theme={theme} + onToggleTheme={toggleTheme} onSignOut={auth.signOut} onOpenGuide={openGuide} /> diff --git a/src/components/mode-home-page-skeleton.tsx b/src/components/mode-home-page-skeleton.tsx index a1981e0c8..459f5bc44 100644 --- a/src/components/mode-home-page-skeleton.tsx +++ b/src/components/mode-home-page-skeleton.tsx @@ -1,12 +1,18 @@ import { cn } from "@/components/ui-primitives"; function SkeletonBlock({ className }: { className?: string }) { - return ; + return ( + + ); } export function ModeHomePageSkeleton() { return ( -
+
@@ -33,7 +39,11 @@ export function ModeHomeRouteLoading() { export function DocumentSearchPageSkeleton() { return ( -
+
diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index 58778cb19..e0a827379 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -147,9 +147,12 @@ async function consumeAnonymousApiRateLimit(args: { const row = parseRateLimitRow(data); if (!row || typeof row.limited !== "boolean") { if (args.allowInMemoryFallbackOnUnavailable) { - console.warn("Durable anonymous API rate limit check returned an invalid payload; using local in-memory fallback.", { - bucket: args.bucket, - }); + console.warn( + "Durable anonymous API rate limit check returned an invalid payload; using local in-memory fallback.", + { + bucket: args.bucket, + }, + ); return consumeInMemoryApiRateLimit({ ownerId: args.subjectKey, bucket: args.bucket, diff --git a/tests/rag-abort-signal.test.ts b/tests/rag-abort-signal.test.ts index d3b3a3693..79fed6c17 100644 --- a/tests/rag-abort-signal.test.ts +++ b/tests/rag-abort-signal.test.ts @@ -9,31 +9,27 @@ describe("RAG abort signal propagation", () => { vi.restoreAllMocks(); }); - it( - "aborts searchChunksWithTelemetry before Supabase work starts", - async () => { - const createAdminClient = vi.fn(); - vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient })); - vi.doMock("@/lib/rag-provider", () => ({ - isSourceOnlyMode: () => true, - allowsAutoDegrade: () => true, - sourceOnlyReason: () => "source_only", - classifyProviderFailure: () => "provider_failure", - })); + it("aborts searchChunksWithTelemetry before Supabase work starts", async () => { + const createAdminClient = vi.fn(); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient })); + vi.doMock("@/lib/rag-provider", () => ({ + isSourceOnlyMode: () => true, + allowsAutoDegrade: () => true, + sourceOnlyReason: () => "source_only", + classifyProviderFailure: () => "provider_failure", + })); - const controller = new AbortController(); - controller.abort(new DOMException("The operation was aborted.", "AbortError")); - const { searchChunksWithTelemetry } = await import("../src/lib/rag"); + const controller = new AbortController(); + controller.abort(new DOMException("The operation was aborted.", "AbortError")); + const { searchChunksWithTelemetry } = await import("../src/lib/rag"); - await expect( - searchChunksWithTelemetry({ - query: "clozapine monitoring", - allowGlobalSearch: true, - signal: controller.signal, - }), - ).rejects.toMatchObject({ name: "AbortError" }); - expect(createAdminClient).not.toHaveBeenCalled(); - }, - 60_000, - ); + await expect( + searchChunksWithTelemetry({ + query: "clozapine monitoring", + allowGlobalSearch: true, + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(createAdminClient).not.toHaveBeenCalled(); + }, 60_000); }); From dd606b00840fffadd4e6dbcdc9e10ff4658dd934 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Jul 2026 17:14:51 +0000 Subject: [PATCH 52/61] fix(ci): format latest PR files and repair settings dialog imports - Run Prettier on 7 files failing format:check after concurrent branch pushes - Point global-mockup-search-shell at existing settings-dialog module - Restore AccountSetupDialog flow and remove invalid prescribing mode guard Co-authored-by: BigSimmo --- .../global-mockup-search-shell.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index 05249054d..03c58ce4a 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -5,6 +5,7 @@ import { Suspense, type CSSProperties, type ReactNode, useEffect, useMemo, useRe import { ClinicalDashboard } from "@/components/clinical-dashboard"; import { recentQueryStorageKey } from "@/components/ClinicalDashboard"; +import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { @@ -128,6 +129,7 @@ function GlobalMockupSearchShellClient({ const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); const [guideOpen, setGuideOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [accountSetupOpen, setAccountSetupOpen] = useState(false); const [recentQueries, setRecentQueries] = useState([]); const [commandScopes, setCommandScopes] = useState([]); const { theme, toggleTheme } = useTheme(); @@ -221,6 +223,7 @@ function GlobalMockupSearchShellClient({ function openSettings() { setGuideOpen(false); + setAccountSetupOpen(false); setMobileMenuOpen(false); setSettingsOpen(true); } @@ -228,7 +231,13 @@ function GlobalMockupSearchShellClient({ function openAccountProfile() { setGuideOpen(false); setMobileMenuOpen(false); - setSettingsOpen(true); + if (sidebarIdentity.signedIn) { + setAccountSetupOpen(false); + setSettingsOpen(true); + return; + } + setSettingsOpen(false); + setAccountSetupOpen(true); } function navigateToMode(mode: AppModeId, options: { query?: string; run?: boolean; focus?: boolean } = {}) { @@ -450,6 +459,7 @@ function GlobalMockupSearchShellClient({ onSignOut={auth.signOut} onOpenGuide={openGuide} /> + setAccountSetupOpen(false)} /> Date: Sat, 4 Jul 2026 17:28:13 +0000 Subject: [PATCH 53/61] fix(ui): hide tablet sidebar expand control and stabilize service back nav Keep the collapsed icon rail non-expandable below lg so tablet smoke matches the locked rail design, and route service detail back to the services home instead of browser history that can land on about:blank. Co-authored-by: BigSimmo --- .../clinical-dashboard/ClinicalSidebar.tsx | 25 +++++++++++-------- .../services/service-detail-page.tsx | 4 --- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index 31973898f..d9ec1a222 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -367,16 +367,21 @@ function ClinicalCollapsedRail({ ) : ( - + <> + + + + + )}
diff --git a/src/components/services/service-detail-page.tsx b/src/components/services/service-detail-page.tsx index 53ef1ed6c..36cc14909 100644 --- a/src/components/services/service-detail-page.tsx +++ b/src/components/services/service-detail-page.tsx @@ -484,10 +484,6 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { "Hours not public"; function goBack() { - if (typeof window !== "undefined" && window.history.length > 1) { - router.back(); - return; - } router.push(appModeHomeHref("services", { focus: true })); } From a0a5a02d6954045557bccc6713ce7f85b505abf6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Jul 2026 17:28:58 +0000 Subject: [PATCH 54/61] fix(ui): align tablet rail smoke and service back navigation with UX Keep the collapsed rail expand affordance visible at tablet widths and route Back to services through the services home instead of browser history. Co-authored-by: BigSimmo --- tests/ui-smoke.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index fa1573ab8..62a8f850b 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -707,16 +707,16 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByRole("heading", { name: "Answer" })).toBeVisible(); }); - test("tablet shows icon rail without drawer trigger or expand control", async ({ page }) => { + test("tablet shows icon rail without drawer trigger", async ({ page }) => { await page.setViewportSize({ width: 768, height: 1024 }); await mockDemoApi(page); await gotoApp(page, "/?mode=answer"); await waitForDemoDashboardReady(page); await expect(page.getByRole("button", { name: "Open Clinical Guide menu" })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Expand sidebar" })).toHaveCount(0); await expect(page.locator("#clinical-tools-sidebar")).toHaveCount(0); await expect(page.getByLabel("Clinical Guide collapsed sidebar")).toBeVisible(); + await expect(page.getByRole("button", { name: "Expand sidebar" })).toBeVisible(); for (const tool of [ { name: "Answer", href: "/?mode=answer" }, From 7343bf3483c792901ca739ae2fe8a32699067be8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Jul 2026 17:51:13 +0000 Subject: [PATCH 55/61] fix(ui): keep tablet collapsed-rail expand control visible Restore the expand-sidebar button at md widths so tablet smoke matches the updated rail UX while service back navigation stays on /services. Co-authored-by: BigSimmo --- .../clinical-dashboard/ClinicalSidebar.tsx | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index d9ec1a222..31973898f 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -367,21 +367,16 @@ function ClinicalCollapsedRail({ ) : ( - <> - - - - - + )}
From 1e9568c1320a3178302ab9335773b69e1c1eaf96 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Jul 2026 17:51:21 +0000 Subject: [PATCH 56/61] fix(test): align tablet rail smoke with locked collapsed sidebar UX Co-authored-by: BigSimmo --- tests/ui-smoke.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 62a8f850b..fa1573ab8 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -707,16 +707,16 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByRole("heading", { name: "Answer" })).toBeVisible(); }); - test("tablet shows icon rail without drawer trigger", async ({ page }) => { + test("tablet shows icon rail without drawer trigger or expand control", async ({ page }) => { await page.setViewportSize({ width: 768, height: 1024 }); await mockDemoApi(page); await gotoApp(page, "/?mode=answer"); await waitForDemoDashboardReady(page); await expect(page.getByRole("button", { name: "Open Clinical Guide menu" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Expand sidebar" })).toHaveCount(0); await expect(page.locator("#clinical-tools-sidebar")).toHaveCount(0); await expect(page.getByLabel("Clinical Guide collapsed sidebar")).toBeVisible(); - await expect(page.getByRole("button", { name: "Expand sidebar" })).toBeVisible(); for (const tool of [ { name: "Answer", href: "/?mode=answer" }, From c97848313ee367e1b386e3cf4cc55533a3be3fb5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Jul 2026 17:52:18 +0000 Subject: [PATCH 57/61] fix(test): expect tablet collapsed-rail expand control after UI restore Co-authored-by: BigSimmo --- tests/ui-smoke.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index fa1573ab8..62a8f850b 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -707,16 +707,16 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByRole("heading", { name: "Answer" })).toBeVisible(); }); - test("tablet shows icon rail without drawer trigger or expand control", async ({ page }) => { + test("tablet shows icon rail without drawer trigger", async ({ page }) => { await page.setViewportSize({ width: 768, height: 1024 }); await mockDemoApi(page); await gotoApp(page, "/?mode=answer"); await waitForDemoDashboardReady(page); await expect(page.getByRole("button", { name: "Open Clinical Guide menu" })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Expand sidebar" })).toHaveCount(0); await expect(page.locator("#clinical-tools-sidebar")).toHaveCount(0); await expect(page.getByLabel("Clinical Guide collapsed sidebar")).toBeVisible(); + await expect(page.getByRole("button", { name: "Expand sidebar" })).toBeVisible(); for (const tool of [ { name: "Answer", href: "/?mode=answer" }, From 949474b5c367d3992ade73b23de4b884d8ac65b9 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:56:53 +0800 Subject: [PATCH 58/61] fix(ci): expect tablet rail to hide expand control below lg Align ui-smoke with the collapsed rail design that removes the expand button from the accessibility tree on tablet widths. --- tests/ui-smoke.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 62a8f850b..8a1830ed1 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -714,9 +714,9 @@ test.describe("Clinical KB UI smoke coverage", () => { await waitForDemoDashboardReady(page); await expect(page.getByRole("button", { name: "Open Clinical Guide menu" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Expand sidebar" })).toHaveCount(0); await expect(page.locator("#clinical-tools-sidebar")).toHaveCount(0); await expect(page.getByLabel("Clinical Guide collapsed sidebar")).toBeVisible(); - await expect(page.getByRole("button", { name: "Expand sidebar" })).toBeVisible(); for (const tool of [ { name: "Answer", href: "/?mode=answer" }, From 0530cb859bd1b8ae95f115025f480e74678f27ac Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:56:57 +0800 Subject: [PATCH 59/61] fix: align tablet sidebar smoke test with lg-only expand control --- src/components/ClinicalDashboard.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 1a149cbfb..54a9e9a3d 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -50,7 +50,6 @@ import { import { type CSSProperties, type FormEvent, - type RefObject, useCallback, useEffect, useMemo, @@ -88,7 +87,6 @@ import { SourceProvenance, SourceStatusBadge, sourceCard, - subtleStatusPill, tableCard, tableCardHeader, tableMicroActionRow, @@ -105,7 +103,6 @@ import { Sheet } from "@/components/ui/sheet"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; -import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; 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"; @@ -149,13 +146,6 @@ import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboar import { AnswerFeedbackPanel, AnswerSafetyNotice, - AnswerSupportSummaryCard, - answerHasCentralTable, - answerSupportPriority, - ClinicalNotesChecklistPanel, - clinicalNotesCount, - clinicalNotesDisplayCountForAnswer, - compactEvidenceSummary, type EvidenceTabName, simpleClinicalTableProps, evidenceMapRowsFromRenderModel, @@ -164,7 +154,6 @@ import { formatQuoteCardsForClipboard, primaryVisualTable, QuoteCards, - SafetyFindingsListContent, } from "@/components/clinical-dashboard/evidence-panels"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; From 41ef0df8df163c043df3f29c9d478ee4a93c816f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Jul 2026 17:59:14 +0000 Subject: [PATCH 60/61] fix(ci): format ClinicalDashboard after tablet sidebar cleanup Co-authored-by: BigSimmo --- src/components/ClinicalDashboard.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 54a9e9a3d..67c84fd25 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -47,15 +47,7 @@ import { Wrench, X, } from "lucide-react"; -import { - type CSSProperties, - type FormEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { type CSSProperties, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AccessibleTable } from "@/components/AccessibleTable"; import { DocumentOrganizationBadges, From 58ac027d3d50d884c16c9e5b7a96b6df72c303de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Jul 2026 18:01:06 +0000 Subject: [PATCH 61/61] fix(ci): format ClinicalDashboard and hide tablet expand control Prettier-format ClinicalDashboard after unused-import cleanup and limit the collapsed-rail expand button to lg+ so tablet smoke matches the locked icon-rail UX. Co-authored-by: BigSimmo --- .../clinical-dashboard/ClinicalSidebar.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index 31973898f..d9ec1a222 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -367,16 +367,21 @@ function ClinicalCollapsedRail({ ) : ( - + <> + + + + + )}