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/49] 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 ef0db729bd..2a987d0ac4 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 8d608f2a11..8148f80f2a 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 5850b63be7..8643c16db3 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 9140030b2c..1af3a53064 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 3f82128ac3..1a37db4d66 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/49] 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 adc7b8eeb4..58fdb831b6 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 0000000000..ad1cd4c3cc --- /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 cbfb2f301b..c3573704dc 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 0000000000..6ffa12b248 --- /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 c115eb7d0e..438f9733b4 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 0000000000..5fafba6de7 --- /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/49] 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 30ce1c45c7..31cc310b23 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 5d90778715..cfe0091a88 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/49] 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 cfe0091a88..cceef86cf9 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/49] 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 cceef86cf9..ef0025b516 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/49] 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 ad1cd4c3cc..4697d38a38 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 6ffa12b248..2c5afe1264 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 0000000000..e0095f8b67 --- /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 8dd8c0675e..d8317ff843 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 7fc0f91903..4e5cb27269 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 1d6af02075..46503a4e8d 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 24e9f9d2b1..ccc03a193e 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 0000000000..c414d5f96e --- /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 0000000000..6d291518d9 --- /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 3bfaaa123b..5143ab6b16 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 0000000000..bd324500d9 --- /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 0000000000..a0aa0b166c --- /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 0000000000..b6da91806d --- /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 8a155b33b5..2ebc5637cc 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 0000000000..ea580c5ae7 --- /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 0000000000..013a786038 --- /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 0000000000..6bb3c3846a --- /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 0f0fa36cbf..462b9148fb 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 8e2915e08e..bc4fbff80d 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 8643c16db3..fa98f53c3e 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 2e759b8f21..e8d5b8b763 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 bbe3efd663..188b38846f 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 9c40ed1ac8..7e4a21ca23 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 b6484c8bca..16f5ef541c 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 0b216efa5a..9cf2a52f19 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/49] 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 435354a416..ddaa773c94 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 5ca27a12ca..b28e41a8f2 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 7e4a21ca23..54f649e58a 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 16f5ef541c..50d239aa68 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 3ca75ab3a9..6ded2c96d0 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 6e5afbdea6..64b28e2b24 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 a5a28c6aab..00d1238da9 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 0000000000..72c6af2986 --- /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/49] 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 ddaa773c94..83b2b5d93e 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 df05a09f19..8f522e6873 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 54f649e58a..c2fa9da540 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 dd5f356318..6a7794ee88 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 5999a40cd6..74d4dc2a62 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/49] 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 5310bb94a3..e64d7b7f50 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 f4bb56bd7c..d2aa683b4d 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 c165c85054..2aa31d6f9f 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/49] 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 bf1831292c..b08aed0ee5 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 1492c615ae..e518fd8759 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 83d55b8295..5cb02a0ca5 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 0000000000..de239bdaf5 --- /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 f517de330c..083dfc7ff8 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 3fdb04a16a..a0948833d9 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 c1f9e46eb3..89a8aa2a41 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 2aa31d6f9f..ace9c252e7 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/49] 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 8f522e6873..459ad736df 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/49] 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 cbed281a53..6038292b47 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 d9b194cbef..68d65230c4 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 0d24dd0c19..5133931e08 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 859ff6b928..c91750c901 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 0a2e0cde2d..8fdc8c15e1 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 a0948833d9..45787a6cd7 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/49] 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 9394eac026..1d2f57dede 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 e9e8d400c2..7e7c587e4d 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 45787a6cd7..7f2c48c1e6 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/49] 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 4c83345a4c..62cee8871e 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 e7c9c090f7..e9fb3322ac 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 f1f8803fb0..2fb85c7b6c 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 0000000000..4503331c57 --- /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 655fde0c56..d2b177e4cf 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 f413eb0158..4bd643e1a8 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 7e7c587e4d..48c530e9b6 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 a0505bda02..e38e98c7f9 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 01fa3376a6..1963e82f40 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 1fd3b2899c..cddf96f423 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 5dfb6b4e94..5c9ad9a06c 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 bbfb7de1cd..ac5d8dbf57 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/49] 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 5133931e08..f5a983f02b 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 8fdc8c15e1..c60ba8fe21 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/49] 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 0000000000..3e8d87f818 --- /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 0000000000..1e7783f748 --- /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 0000000000..38d3dd0daf --- /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 e9dcda623f..6d5a4643d2 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 0000000000..6fb198db26 --- /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 05a9569965..c2ec0686b7 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 93e7841bb7e70328df213597366f982402323e0e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:26:31 +0800 Subject: [PATCH 38/49] 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 | 1160 +---------------- .../answer-result-surface.tsx | 3 +- .../clinical-dashboard/document-results.tsx | 81 +- .../clinical-dashboard/evidence-panels.tsx | 63 +- .../clinical-dashboard/visual-evidence.tsx | 2 +- src/lib/answer-feedback.ts | 9 + tests/rendered-text-formatting.test.ts | 3 +- tests/ui-smoke.spec.ts | 2 + 8 files changed, 85 insertions(+), 1238 deletions(-) create mode 100644 src/lib/answer-feedback.ts diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 68d65230c4..bb9622bf69 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -12,7 +12,6 @@ import { ChevronRight, CircleUserRound, Clock3, - ClipboardCheck, Copy, ExternalLink, FileImage, @@ -40,7 +39,6 @@ import { SlidersHorizontal, Sparkles, Stethoscope, - Tag, UploadCloud, UserRound, WifiOff, @@ -104,6 +102,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"; @@ -169,6 +168,12 @@ import { MasterSearchHeader } from "@/components/clinical-dashboard/master-searc import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { emptyStates, errorCopy } from "@/lib/ui-copy"; import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; +import { + DrawerGroupLabel, + type DocumentDrawerMode, + type DocumentDrawerStatusFilter, +} from "@/components/clinical-dashboard/document-admin"; + const DifferentialsHome = dynamic( () => import("@/components/clinical-dashboard/differentials-home").then((m) => m.DifferentialsHome), @@ -189,6 +194,11 @@ export const ApplicationsLauncherWorkspace = dynamic( () => import("@/components/applications-launcher-page").then((m) => m.ApplicationsLauncherWorkspace), { ssr: false }, ); +const DocumentDrawer = dynamic( + () => import("@/components/clinical-dashboard/document-admin/document-drawer").then((m) => m.DocumentDrawer), + { ssr: false }, +); + import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results"; import { isWeakRelevance, QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; import { @@ -237,11 +247,6 @@ import { } from "@/lib/source-governance"; import { smartEvidenceTags } from "@/lib/evidence-tags"; import { - documentLabelReviewStatus, - documentLabelTier, - formatDocumentLabelDisplay, - normalizeDocumentLabelForStorage, - reviewDocumentTagQuality, tagSearchText, type SmartDocumentTag, type SmartDocumentTagFacet, @@ -327,15 +332,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 +1067,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. @@ -1206,1084 +1148,10 @@ function PriorAnswerTurnSurface({ ); } -const tagQualityTone: Record = { - noisy: toneDanger, - duplicate: toneWarning, - low_confidence: toneInfo, - overused: toneNeutral, -}; - -const labelTierTone: Record = { - primary: toneSuccess, - secondary: toneNeutral, - ranking: toneInfo, -}; - -const documentLabelTypeOptions: Array<{ value: DocumentLabelType; label: string }> = [ - { value: "site", label: "Site" }, - { value: "topic", label: "Topic" }, - { value: "document_type", label: "Document type" }, - { value: "medication", label: "Medication" }, - { value: "risk", label: "Risk" }, - { value: "setting", label: "Setting" }, - { value: "workflow", label: "Workflow" }, - { value: "population", label: "Population" }, - { value: "service", label: "Service" }, - { value: "clinical_action", label: "Clinical action" }, - { value: "care_phase", label: "Care phase" }, - { value: "document_intent", label: "Document intent" }, - { value: "content_feature", label: "Content feature" }, - { value: "custom", label: "Manual" }, -]; - -function tagQualityLabel(kind: SmartDocumentTagQualityIssueKind) { - if (kind === "low_confidence") return "low confidence"; - return kind; -} - -function normalizedLabelReviewRow(label: DocumentLabel) { - const normalized = normalizeDocumentLabelForStorage(label); - const fallbackLabelType = documentLabelTypeOptions.some((option) => option.value === label.label_type) - ? label.label_type - : "custom"; - const labelType = normalized?.label_type ?? fallbackLabelType; - const labelText = normalized?.label ?? label.label?.trim() ?? ""; - const tier: SmartDocumentTagTier = normalized - ? documentLabelTier(normalized.label, normalized.label_type) - : "secondary"; - const reviewStatus = documentLabelReviewStatus(label); - return { - id: label.id, - label: labelText, - displayLabel: labelText ? formatDocumentLabelDisplay(labelText, labelType) : "Unreviewed label", - labelType, - tier, - reviewStatus, - source: label.source, - confidence: normalized?.confidence ?? label.confidence ?? 0, - }; -} - -function labelTypeDisplay(value: DocumentLabelType) { - return documentLabelTypeOptions.find((option) => option.value === value)?.label ?? value.replaceAll("_", " "); -} - -type LabelReviewMutationBody = - { labelId: string; action: "approve" | "hide" | "restore" } | { label: string; label_type: DocumentLabelType }; - -function DocumentLabelReviewPanel({ - documents, - canManage, - onMutateLabel, -}: { - documents: ClinicalDocument[]; - canManage: boolean; - onMutateLabel: (documentId: string, method: "POST" | "PATCH", body: LabelReviewMutationBody) => Promise; -}) { - const [busyAction, setBusyAction] = useState(null); - const [overrideDrafts, setOverrideDrafts] = useState>( - {}, - ); - - const items = useMemo(() => { - return documents - .map((document) => { - const rows = (document.labels ?? []) - .map((label) => normalizedLabelReviewRow(label)) - .filter((row): row is NonNullable> => Boolean(row)); - const visible = rows.filter((row) => row.reviewStatus !== "hidden" && row.tier !== "ranking"); - const ranking = rows.filter((row) => row.reviewStatus !== "hidden" && row.tier === "ranking"); - const hidden = rows.filter((row) => row.reviewStatus === "hidden"); - const needsReview = rows.some((row) => row.reviewStatus === "new" && row.source === "generated"); - return { document, rows, visible, ranking, hidden, needsReview }; - }) - .filter((item) => item.rows.length) - .sort((a, b) => Number(b.needsReview) - Number(a.needsReview) || b.ranking.length - a.ranking.length) - .slice(0, 8); - }, [documents]); - - if (!items.length) return null; - - async function mutate(documentId: string, method: "POST" | "PATCH", body: LabelReviewMutationBody, actionId: string) { - setBusyAction(actionId); - try { - return await onMutateLabel(documentId, method, body); - } finally { - setBusyAction(null); - } - } - - function draftFor(documentId: string) { - return overrideDrafts[documentId] ?? { label: "", labelType: "topic" as DocumentLabelType }; - } - - function setDraft(documentId: string, next: { label: string; labelType: DocumentLabelType }) { - setOverrideDrafts((current) => ({ ...current, [documentId]: next })); - } - - return ( -
- - - - - - - Label review - - Visible labels, ranking labels, hidden labels, confidence, and manual overrides - - - - - -
- {items.map((item) => { - const draft = draftFor(item.document.id); - return ( -
-
-
- - {documentDisplayTitle(item.document)} - -

- {item.visible.length} visible · {item.ranking.length} ranking · {item.hidden.length} hidden -

-
- {item.needsReview ? ( - Needs review - ) : ( - Reviewed - )} -
- - {( - [ - { title: "Visible", rows: item.visible }, - { title: "Ranking", rows: item.ranking }, - { title: "Hidden", rows: item.hidden }, - ] satisfies Array<{ title: string; rows: typeof item.rows }> - ).map(({ title, rows: labelRows }) => { - if (!labelRows.length) return null; - return ( -
-

- {title} -

-
- {labelRows.slice(0, 8).map((label) => ( -
-
-
- - {label.displayLabel} - - - {label.tier} - - - {labelTypeDisplay(label.labelType)} - -
-

- {label.source} · {Math.round(label.confidence * 100)}% · {label.reviewStatus} -

-
-
- {label.reviewStatus === "hidden" ? ( - - ) : ( - <> - - - - )} -
-
- ))} -
-
- ); - })} - - { - event.preventDefault(); - const trimmed = draft.label.trim(); - if (!trimmed) return; - void mutate( - item.document.id, - "POST", - { label: trimmed, label_type: draft.labelType }, - `override:${item.document.id}`, - ).then((ok) => { - if (ok) setDraft(item.document.id, { label: "", labelType: draft.labelType }); - }); - }} - > - setDraft(item.document.id, { ...draft, label: event.target.value })} - disabled={!canManage || busyAction !== null} - placeholder="Manual override label" - aria-label="Manual override label" - className={fieldControlPlain} - /> - - - -
- ); - })} -
-
- ); -} - -function DocumentTagQualityPanel({ documents }: { documents: ClinicalDocument[] }) { - const issues = useMemo(() => reviewDocumentTagQuality(documents), [documents]); - const counts = issues.reduce>( - (current, issue) => ({ ...current, [issue.kind]: current[issue.kind] + 1 }), - { noisy: 0, duplicate: 0, low_confidence: 0, overused: 0 }, - ); - - return ( -
- - - - - - - Tag quality review - - {issues.length - ? `${issues.length} issue${issues.length === 1 ? "" : "s"} across loaded documents` - : "No obvious tag cleanup issues in loaded documents"} - - - - - -
-
- {(Object.keys(counts) as SmartDocumentTagQualityIssueKind[]).map((kind) => ( - - {tagQualityLabel(kind)}: {counts[kind]} - - ))} -
- {issues.length ? ( -
- {issues.slice(0, 12).map((issue, index) => ( -
-
- - {tagQualityLabel(issue.kind)} - -

{issue.label}

- {issue.count > 1 ? ( - {issue.count} hits - ) : null} -
-

{issue.reason}

- {issue.examples.length || issue.documentTitles.length ? ( -

- {[ - issue.examples.length ? `examples: ${issue.examples.join(", ")}` : "", - issue.documentTitles.length ? `docs: ${issue.documentTitles.join(", ")}` : "", - ] - .filter(Boolean) - .join(" · ")} -

- ) : null} -
- ))} -
- ) : ( -

Loaded tags are clean enough for the current smart-tag rules.

- )} -
-
- ); -} - -function DocumentIndexRepairPanel({ documents }: { documents: ClinicalDocument[] }) { - const items = useMemo(() => { - return documents - .map((document) => { - const metadata = document.metadata && typeof document.metadata === "object" ? document.metadata : {}; - const score = Number((metadata as Record).index_quality_score ?? 1); - const issues = Array.isArray((metadata as Record).index_quality_issues) - ? ((metadata as Record).index_quality_issues as unknown[]).map(String) - : []; - const sectionCount = Number((metadata as Record).section_count ?? 0); - const memoryCardCount = Number((metadata as Record).memory_card_count ?? 0); - const extractionQuality = String((metadata as Record).extraction_quality ?? "unknown"); - const needsRepair = - score < 0.72 || - issues.length > 0 || - sectionCount === 0 || - memoryCardCount === 0 || - extractionQuality === "poor" || - extractionQuality === "partial"; - return { document, score, issues, sectionCount, memoryCardCount, extractionQuality, needsRepair }; - }) - .filter((item) => item.needsRepair) - .sort((a, b) => a.score - b.score || b.issues.length - a.issues.length) - .slice(0, 10); - }, [documents]); - - if (!items.length) return null; - - return ( -
- - - - - - - Index repair queue - - {items.length} loaded document{items.length === 1 ? "" : "s"} need quality review or reindexing - - - - - -
- {items.map((item) => ( -
-
-

{item.document.title}

- - index {Number.isFinite(item.score) ? item.score.toFixed(2) : "n/a"} - -
-
- extraction:{item.extractionQuality} - sections:{item.sectionCount} - memory:{item.memoryCardCount} - {item.issues.slice(0, 4).map((issue) => ( - - {issue} - - ))} -
-
- ))} -
-
- ); -} - -function DocumentDrawer({ - documents, - pagination, - loadingMoreDocuments, - mode, - selectedDocumentIds, - statusFilter, - onToggleScope, - onLoadMoreDocuments, - onDocumentRenamed, - onDocumentDeleted, - onBulkReindex, - onBulkAssignCollection, - onBulkMetadataUpdate, - bulkActionStatus, - bulkActionBusy, - canManageDocuments, - onTagSearch, - onMutateLabel, -}: { - documents: ClinicalDocument[]; - pagination: DocumentPagination | null; - loadingMoreDocuments: boolean; - mode: DocumentDrawerMode; - selectedDocumentIds: string[]; - statusFilter: DocumentDrawerStatusFilter; - onToggleScope: (documentId: string) => void; - onLoadMoreDocuments: () => void; - onDocumentRenamed: (document: ClinicalDocument) => void; - onDocumentDeleted: (result: DocumentDeleteResult) => void; - onBulkReindex: (mode: "enrichment" | "full" | "retry_failed") => void; - onBulkAssignCollection: (collection: string) => void; - onBulkMetadataUpdate: (metadata: Record) => void; - bulkActionStatus: string | null; - bulkActionBusy: boolean; - canManageDocuments: boolean; - onTagSearch: (tag: SmartDocumentTag) => void; - onMutateLabel: (documentId: string, method: "POST" | "PATCH", body: LabelReviewMutationBody) => Promise; -}) { - const [filter, setFilter] = useState(""); - const [selectedType, setSelectedType] = useState("all"); - const [selectedSite, setSelectedSite] = useState("all"); - const [selectedTopic, setSelectedTopic] = useState("all"); - const [selectedPopulation, setSelectedPopulation] = useState("all"); - const [showNeedsReviewOnly, setShowNeedsReviewOnly] = useState(false); - - const [collectionDraft, setCollectionDraft] = useState(""); - const [metadataDraft, setMetadataDraft] = useState({ - sourceStatus: "", - validationStatus: "", - extractionQuality: "", - reviewDate: "", - publicationDate: "", - jurisdiction: "", - sourceType: "", - category: "", - }); - - const allTypes = useMemo(() => { - const types = new Set(); - for (const doc of documents) { - const typeLabel = doc.labels?.find((l) => l.label_type === "document_type" && l.confidence >= 0.5)?.label; - if (typeLabel) types.add(typeLabel); - const profile = documentOrganizationProfile(doc); - if (profile?.document_type?.label && profile.document_type.label !== "unknown") { - types.add(profile.document_type.label); - } - } - return Array.from(types).sort(); - }, [documents]); - - const allSites = useMemo(() => { - const sites = new Set(); - for (const doc of documents) { - const siteLabels = doc.labels?.filter((l) => l.label_type === "site" && l.confidence >= 0.5) ?? []; - for (const l of siteLabels) sites.add(l.label); - const profile = documentOrganizationProfile(doc); - if (profile?.site?.label) sites.add(profile.site.label); - } - return Array.from(sites).sort(); - }, [documents]); - - const allTopics = useMemo(() => { - const topics = new Set(); - for (const doc of documents) { - const topicLabels = - doc.labels?.filter((l) => (l.label_type === "topic" || l.label_type === "custom") && l.confidence >= 0.5) ?? []; - for (const l of topicLabels) topics.add(l.label); - const profile = documentOrganizationProfile(doc); - if (profile?.secondary_facets?.topic) { - for (const t of profile.secondary_facets.topic) topics.add(t); - } - } - return Array.from(topics).sort(); - }, [documents]); - - const allPopulations = useMemo(() => { - const populations = new Set(); - for (const doc of documents) { - const popLabels = doc.labels?.filter((l) => l.label_type === "population" && l.confidence >= 0.5) ?? []; - for (const l of popLabels) populations.add(l.label); - const profile = documentOrganizationProfile(doc); - if (profile?.secondary_facets?.population) { - for (const p of profile.secondary_facets.population) populations.add(p); - } - } - return Array.from(populations).sort(); - }, [documents]); - - const isAdminMode = mode === "admin" && canManageDocuments; - 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) => { - if (!documentStatusMatchesFilter(document, statusFilter)) return false; - if (mode === "source") { - const typeText = `${document.file_type} ${document.file_name}`.toLowerCase(); - if (!typeText.includes("pdf")) return false; - } - - // Filter by Type - if (selectedType !== "all") { - const typeLabel = document.labels?.find((l) => l.label_type === "document_type" && l.confidence >= 0.5)?.label; - const profile = documentOrganizationProfile(document); - const hasTypeMatch = typeLabel === selectedType || profile?.document_type?.label === selectedType; - if (!hasTypeMatch) return false; - } - - // Filter by Site - if (selectedSite !== "all") { - const siteLabels = document.labels?.filter((l) => l.label_type === "site" && l.confidence >= 0.5) ?? []; - const profile = documentOrganizationProfile(document); - const hasSiteMatch = siteLabels.some((l) => l.label === selectedSite) || profile?.site?.label === selectedSite; - if (!hasSiteMatch) return false; - } - - // Filter by Topic - if (selectedTopic !== "all") { - const topicLabels = - document.labels?.filter( - (l) => (l.label_type === "topic" || l.label_type === "custom") && l.confidence >= 0.5, - ) ?? []; - const profile = documentOrganizationProfile(document); - const hasTopicMatch = - topicLabels.some((l) => l.label === selectedTopic) || - profile?.secondary_facets?.topic?.includes(selectedTopic); - if (!hasTopicMatch) return false; - } - - // Filter by Population - if (selectedPopulation !== "all") { - const popLabels = document.labels?.filter((l) => l.label_type === "population" && l.confidence >= 0.5) ?? []; - const profile = documentOrganizationProfile(document); - const hasPopMatch = - popLabels.some((l) => l.label === selectedPopulation) || - profile?.secondary_facets?.population?.includes(selectedPopulation); - if (!hasPopMatch) return false; - } - - // Filter by Needs Review - if (showNeedsReviewOnly) { - const profile = documentOrganizationProfile(document); - if (profile?.review_status !== "needs_review") return false; - } - - const labelText = tagSearchText(document); - const summaryText = document.summary?.summary ?? ""; - const haystack = `${document.title} ${document.file_name} ${labelText} ${summaryText}`.toLowerCase(); - return haystack.includes(filterValue); - }) - .sort((left, right) => { - if (mode !== "recent") return 0; - return new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime(); - }); - 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 ( -
-
- - -
-

{statusTitle}

-

{statusHelper}

-
- - {filtered.length.toLocaleString()} shown - -
- - - {/* Dynamic Browse Library Filters */} -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - {/* Admin Queue Toggle */} - {isAdminMode ? ( -
- setShowNeedsReviewOnly(e.target.checked)} - className="rounded border-[color:var(--border)] text-[color:var(--clinical-accent)] focus:ring-[color:var(--focus)] h-4 w-4" - /> - -
- ) : null} - {pagination && pagination.total > documents.length ? ( -

- Showing {documents.length} of {pagination.total} documents. Load more to manage older files. -

- ) : null} - {isAdminMode ? ( - - ) : null} - {isAdminMode ? : null} - {isAdminMode ? : null} - {isAdminMode && selectedDocumentIds.length ? ( -
-
-
-

- {selectedDocumentIds.length} selected document{selectedDocumentIds.length === 1 ? "" : "s"} -

-

Bulk actions apply only to explicitly selected documents.

-
-
- - - -
-
-
- setCollectionDraft(event.target.value)} - placeholder="Collection name for selected documents" - aria-label="Collection name for selected documents" - className={fieldControlPlain} - /> - -
-
- - Bulk metadata editor - -
- - - - setMetadataDraft((current) => ({ ...current, reviewDate: event.target.value }))} - className={fieldControlPlain} - aria-label="Bulk review date" - /> - - setMetadataDraft((current) => ({ ...current, publicationDate: event.target.value })) - } - className={fieldControlPlain} - aria-label="Bulk publication date" - /> - setMetadataDraft((current) => ({ ...current, jurisdiction: event.target.value }))} - placeholder="Jurisdiction/locality" - aria-label="Bulk edit jurisdiction/locality" - className={fieldControlPlain} - /> - setMetadataDraft((current) => ({ ...current, sourceType: event.target.value }))} - placeholder="Source type" - aria-label="Bulk edit source type" - className={fieldControlPlain} - /> - setMetadataDraft((current) => ({ ...current, category: event.target.value }))} - placeholder="Category" - aria-label="Bulk edit category" - className={fieldControlPlain} - /> -
- -
- {bulkActionStatus ?

{bulkActionStatus}

: null} -
- ) : null} -
- {filtered.length === 0 ? ( - - ) : ( - filtered.slice(0, 12).map((document) => { - const selected = selectedDocumentIds.includes(document.id); - return ( -
-
- - {documentDisplayTitle(document)} - - - -

- {document.page_count} pages · {document.chunk_count} chunks · {document.image_count} images -

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

- -

- )} - - -
-
- - - {isAdminMode ? ( - - ) : null} - -
-
- ); - }) - )} -
- {pagination?.hasMore ? ( - - ) : null} -
- ); -} - type LibraryHealthTarget = "documents" | "setup" | "indexing" | "failures"; -type DocumentDrawerMode = "recent" | "library" | "source" | "admin"; -type DocumentDrawerStatusFilter = "all" | "indexed" | "indexing" | "failed"; type IndexingMonitorFilter = "all" | "active" | "failed"; type UploadIndexingTab = "setup" | "upload" | "jobs" | "quality"; -function documentStatusMatchesFilter(document: ClinicalDocument, filter: DocumentDrawerStatusFilter) { - if (filter === "all") return true; - if (filter === "indexed") return document.status === "indexed"; - if (filter === "indexing") return document.status === "queued" || document.status === "processing"; - return document.status === "failed"; -} - -function statusFilterLabel(filter: DocumentDrawerStatusFilter) { - if (filter === "indexed") return "Indexed documents"; - if (filter === "indexing") return "Indexing documents"; - if (filter === "failed") return "Failed documents"; - return "All documents"; -} - -function DrawerGroupLabel({ title }: { title: string }) { - return ( -

{title}

- ); -} - export function SettingsDialog({ open, onClose, diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index 032ef0683b..b9793db7e7 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 { @@ -282,6 +282,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 459ad736df..384ebd2ce6 100644 --- a/src/components/clinical-dashboard/document-results.tsx +++ b/src/components/clinical-dashboard/document-results.tsx @@ -1,96 +1,23 @@ "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 { 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 +43,7 @@ export function RelatedDocumentsPanel({
{documentDisplayTitle(document)} @@ -129,7 +56,7 @@ export function RelatedDocumentsPanel({ diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index 5cb02a0ca5..f576d88c6e 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, @@ -209,11 +209,16 @@ export function AnswerSupportSummaryCard({
+ + ); + return row.href ? ( + + {rowContent} + + ) : ( +
+ {rowContent}
); })} diff --git a/src/components/clinical-dashboard/visual-evidence.tsx b/src/components/clinical-dashboard/visual-evidence.tsx index 7c43336114..3c2927a1c7 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 0000000000..9e2561c3d4 --- /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 13d5da9d51..eed7c90e83 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 7f2c48c1e6..07a07c98ff 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1060,6 +1060,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); From 56d6f408a9ea51d65c4ed5fa8c492a29800f8453 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:06:12 +0800 Subject: [PATCH 39/49] fix(ui): edge-to-edge mobile layout for mode homes and footer dock (#410) - Unify mobile shell to h-dvh flex column with --mobile-composer-reserve - Full-bleed mode home backgrounds; edge-to-edge action cards on phone - Resolve footer dock CSS precedence over inset document-mobile-search-edge - Trim mobile header gutters to safe-area only; extend dock scrim to bottom Co-authored-by: Cursor Agent Co-authored-by: BigSimmo --- src/app/globals.css | 24 ++++++++++------- src/components/ClinicalDashboard.tsx | 13 ++++++++- .../favourites-home-page.tsx | 6 +++-- .../global-mockup-search-shell.tsx | 27 ++++++++++++------- .../master-search-header.tsx | 18 ++++++++----- src/components/mode-home-template.tsx | 27 +++++++++---------- 6 files changed, 72 insertions(+), 43 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index b08aed0ee5..053aef87ce 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -676,12 +676,13 @@ summary::-webkit-details-marker { transparent 0%, color-mix(in srgb, var(--background) 8%, transparent) 38%, color-mix(in srgb, var(--background) 18%, transparent) 68%, - color-mix(in srgb, var(--background) 32%, transparent) 100% + color-mix(in srgb, var(--background) 32%, transparent) 88%, + color-mix(in srgb, var(--background) 42%, transparent) 100% ); backdrop-filter: blur(2px) saturate(130%); -webkit-backdrop-filter: blur(2px) saturate(130%); - mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 60%, black 100%); - -webkit-mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 60%, black 100%); + mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 55%, black 100%); + -webkit-mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 55%, black 100%); } .answer-footer-search-dock .answer-footer-search-backdrop::before, @@ -702,8 +703,8 @@ summary::-webkit-details-marker { .answer-footer-search-dock .answer-footer-search-backdrop::after { backdrop-filter: blur(22px) saturate(140%); -webkit-backdrop-filter: blur(22px) saturate(140%); - mask-image: linear-gradient(180deg, transparent 0%, transparent 55%, black 72%, black 100%); - -webkit-mask-image: linear-gradient(180deg, transparent 0%, transparent 55%, black 72%, black 100%); + mask-image: linear-gradient(180deg, transparent 0%, transparent 50%, black 68%, black 100%); + -webkit-mask-image: linear-gradient(180deg, transparent 0%, transparent 50%, black 68%, black 100%); } @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { @@ -1307,17 +1308,22 @@ summary::-webkit-details-marker { } @media (max-width: 639px) { - .dashboard-composer-edge.answer-footer-search-edge { + .edge-glass-header { + padding-left: max(0px, var(--safe-area-left)); + padding-right: max(0px, var(--safe-area-right)); + } + + .dashboard-composer-edge.answer-footer-search-edge:not(.answer-footer-search-dock) { width: min(calc(100vw - 8px - var(--safe-area-left) - var(--safe-area-right)), 400px); } - .document-mobile-search-edge { + .document-mobile-search-edge:not(.answer-footer-search-dock) { left: max(0.375rem, var(--safe-area-left)); right: max(0.375rem, var(--safe-area-right)); bottom: max(0.5rem, calc(var(--safe-area-bottom) + 0.375rem)); } - .document-mobile-search-edge.answer-footer-search-edge { + .document-mobile-search-edge.answer-footer-search-edge:not(.answer-footer-search-dock) { left: 50%; right: auto; bottom: max(0.45rem, calc(var(--safe-area-bottom) + 0.35rem)); @@ -1327,7 +1333,7 @@ summary::-webkit-details-marker { /* Compact search/result views: no chip row below the pill, so the pill itself hugs the bottom edge and the scrim shrinks to match. */ - .document-mobile-search-edge.answer-footer-search-edge.document-mobile-search-compact { + .document-mobile-search-edge.answer-footer-search-edge.document-mobile-search-compact:not(.answer-footer-search-dock) { bottom: max(0.4rem, calc(var(--safe-area-bottom) + 0.3rem)); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index bb9622bf69..23d2dd77a8 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -4258,6 +4258,13 @@ export function ClinicalDashboard({ const compactMobileBottomSearch = hasMobileBottomSearch && modeSearchSubmitted; const differentialsCompareAddonActive = searchMode === "differentials" && modeSearchSubmitted && Boolean(query.trim()); + const mobileComposerReserve = !hasMobileBottomSearch + ? "0px" + : compactMobileBottomSearch + ? differentialsCompareAddonActive + ? "calc(8.75rem + env(safe-area-inset-bottom))" + : "calc(5rem + env(safe-area-inset-bottom))" + : "calc(5.25rem + env(safe-area-inset-bottom))"; const renderDegradedNotice = () => ( @@ -4482,6 +4490,7 @@ export function ClinicalDashboard({
) to avoid a needless scrollbar. @@ -4523,7 +4532,9 @@ export function ClinicalDashboard({
- +
+ { setQueryOverride({ source: query, value: "" }); @@ -28,6 +29,7 @@ export function FavouritesHomePage({ query = "" }: FavouritesHomePageProps) { desktopComposerSlotId={modeHomeDesktopComposerSlotId} headingLevel={1} /> +
); } diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index 9cf2a52f19..235130a6c5 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -158,6 +158,13 @@ function GlobalMockupSearchShellClient({ const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow ? true : sidebarCollapsed; const effectiveSidebarWidth = shouldShowDesktopSidebar ? (effectiveSidebarCollapsed ? "5.25rem" : "20rem") : "0px"; const shouldShowSearchComposer = searchComposerVisible && !isDifferentialPresentationWorkflow; + const mobileComposerReserve = !shouldShowSearchComposer + ? "2rem" + : searchMode === "answer" + ? "calc(9rem + env(safe-area-inset-bottom))" + : useCompactBottomSearch + ? "calc(5.5rem + env(safe-area-inset-bottom))" + : "calc(9rem + env(safe-area-inset-bottom))"; useEffect(() => { // Re-derive the mode and query from the URL, but only when the search string @@ -312,7 +319,7 @@ function GlobalMockupSearchShellClient({ return (
@@ -348,7 +356,7 @@ function GlobalMockupSearchShellClient({
) : null} -
+
{/* max-sm:contents lets the header's own `sticky top-0` engage against the document scroll on phones (a plain wrapper div otherwise caps its sticking range at its own height), which the phone @@ -415,17 +423,16 @@ function GlobalMockupSearchShellClient({ id="main-content" tabIndex={-1} className={cn( - // Phone: fill the space under the header exactly (the header is - // taller than the 4rem the calc assumed, which forced a phantom - // scrollbar on every standalone page). sm+ keeps the original calc. - "min-w-0 overflow-x-hidden focus:outline-none max-sm:flex-1 sm:min-h-[calc(100dvh-4rem)]", + // Phone: flex column fills the viewport under the header; composer + // clearance comes from --mobile-composer-reserve on the shell. + "min-w-0 overflow-x-hidden focus:outline-none max-sm:flex max-sm:min-h-0 max-sm:flex-1 max-sm:flex-col max-sm:pb-[var(--mobile-composer-reserve)] sm:min-h-[calc(100dvh-4rem)]", !shouldShowSearchComposer - ? "pb-8" + ? "sm:pb-8" : searchMode === "answer" - ? "pb-[calc(9rem+env(safe-area-inset-bottom))]" + ? "sm:pb-[calc(9rem+env(safe-area-inset-bottom))]" : useCompactBottomSearch - ? "pb-[calc(5.5rem+env(safe-area-inset-bottom))] sm:pb-8" - : "pb-[calc(9rem+env(safe-area-inset-bottom))] sm:pb-8", + ? "sm:pb-[calc(5.5rem+env(safe-area-inset-bottom))] sm:pb-8" + : "sm:pb-[calc(9rem+env(safe-area-inset-bottom))] sm:pb-8", )} > @@ -228,20 +224,23 @@ export function ModeHomeTemplate({
{desktopComposerSlotId ? ( -
+
) : null} {actions.length ? (
{actions.map((action, index) => { const ActionIcon = action.icon; @@ -294,7 +293,7 @@ export function ModeHomeTemplate({ ) : null} {pills?.length ? ( -
+
{pillsTitle || pillsAction ? (
) : null} - {footer ?
{footer}
: null} + {footer ?
{footer}
: null}
); } From cdf2811b70c770513dc694616c8ccd7e58856bc5 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:13:39 +0800 Subject: [PATCH 40/49] Fix differential badge design for mobile search results (#412) --- .../clinical-dashboard/differentials-home.tsx | 106 ++++++++++++------ tests/ui-tools.spec.ts | 96 ++++++++++++++++ 2 files changed, 168 insertions(+), 34 deletions(-) diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index e6fffb028e..7b4aed967b 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -160,14 +160,21 @@ function statusLabel(status: DifferentialRecord["status"]) { } function statusTone(status: DifferentialRecord["status"]) { - if (status === "emergent") - return "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]"; + if (status === "emergent") return "border-transparent bg-[color:var(--danger)] text-white"; if (status === "urgent") { return "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]"; } return "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]"; } +function resultTypeTabCounts(results: DifferentialResult[]) { + return { + all: results.length, + presentations: results.filter((result) => result.href.includes("/presentations")).length, + diagnoses: results.filter((result) => result.href.includes("/diagnoses")).length, + }; +} + function recordIcon(record: DifferentialRecord) { return candidateIconBySlug.find(([fragment]) => record.slug.includes(fragment))?.[1] ?? BrainCircuit; } @@ -230,17 +237,75 @@ function buildDifferentialResults(): DifferentialResult[] { function StatusBadge({ status, className }: { status: DifferentialRecord["status"]; className?: string }) { return ( + {status === "emergent" ? ( + + ) : null} {statusLabel(status)} ); } +function ResultTypeTabs({ results }: { results: DifferentialResult[] }) { + const counts = resultTypeTabCounts(results); + const tabs = [ + { key: "all", label: "All", count: counts.all }, + { key: "presentations", label: "Presentations", count: counts.presentations }, + { key: "diagnoses", label: "Diagnoses", count: counts.diagnoses }, + ] as const; + + return ( +
+ {tabs.map((tab, index) => { + const active = index === 0; + return ( + + ); + })} + +
+ ); +} + function MatchBadge({ label }: { label: string }) { const tone = label === "Best match" @@ -755,39 +820,12 @@ function SearchResultsView({
toggleSelected(best.id)} /> -
- {[ - { label: "All (8)", compact: "All" }, - { label: "Diagnosis (6)", compact: "Dx (6)" }, - { label: "Mimics (2)", compact: "Mimics" }, - ].map((item, index) => ( - - ))} - -
+
- 8 results ·{" "} + + {results.length} result{results.length === 1 ? "" : "s"} + ·{" "} {hasSourceEvidence ? "Ranked by relevance" : "Guided differential view"} +
+
+ + ); + })} +
+
+ ); +} diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index 896d849d6b..a63e156ba5 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -16,6 +16,7 @@ import { import { useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { ModeHomeHero, ModeHomeVerificationFooter } from "@/components/mode-home-template"; +import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-saved-registry-favourites"; import { cn, floatingControl, iconTilePremium, panelSubtle, primaryControl } from "@/components/ui-primitives"; import { favouriteItems, diff --git a/src/components/clinical-dashboard/source-actions.tsx b/src/components/clinical-dashboard/source-actions.tsx index b3654edae2..ef374e88d2 100644 --- a/src/components/clinical-dashboard/source-actions.tsx +++ b/src/components/clinical-dashboard/source-actions.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { ExternalLink, FileText, Filter, Search } from "lucide-react"; import { cn, floatingControl, metadataPill, primaryControl } from "@/components/ui-primitives"; +import type { CrossModeLink } from "@/lib/cross-mode-links"; import type { SearchResult } from "@/lib/types"; export function SourceActionRow({ @@ -79,6 +80,19 @@ export function logSourceOpen(query: string, source: SearchResult) { }).catch(() => undefined); } +export function logCrossModeLinkOpen(query: string, link: Pick) { + if (!query.trim()) return; + void fetch("/api/search/interaction", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query, + crossMode: { mode: link.modeId, slug: link.slug, title: link.title }, + }), + keepalive: true, + }).catch(() => undefined); +} + export function SourcePassageLinks({ heading, sources, diff --git a/src/components/clinical-dashboard/use-medication-catalog.ts b/src/components/clinical-dashboard/use-medication-catalog.ts new file mode 100644 index 0000000000..074812a5c6 --- /dev/null +++ b/src/components/clinical-dashboard/use-medication-catalog.ts @@ -0,0 +1,145 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import type { MedicationRecord, MedicationSearchResult } from "@/lib/medications"; +import { useAuthSession } from "@/lib/supabase/client"; + +type MedicationCatalogMatch = { + medication: MedicationRecord; + result: MedicationSearchResult; + score: number; + reasons: string[]; +}; + +type MedicationCatalogResponse = { + records: MedicationRecord[]; + matches?: MedicationCatalogMatch[]; + total: number; + demoMode?: boolean; +}; + +type MedicationDetailResponse = { + record: MedicationRecord; + governance?: { + sourceStatus: string; + validationStatus: string; + }; + demoMode?: boolean; +}; + +type AsyncState = { + data: T | null; + loading: boolean; + error: string | null; +}; + +async function fetchJson(url: string, headers?: HeadersInit): Promise { + const response = await fetch(url, { cache: "no-store", headers }); + if (!response.ok) { + throw new Error(`Request failed (${response.status})`); + } + return (await response.json()) as T; +} + +export function useMedicationCatalog( + query?: string, + options: { enabled?: boolean; fields?: "index" } = {}, +): AsyncState { + const enabled = options.enabled ?? true; + const fields = options.fields; + const trimmed = query?.trim() ?? ""; + // Auth-aware like use-registry-records: without the header an authenticated owner was + // silently served the public fixture catalogue instead of their seeded records. + const { authorizationHeader } = useAuthSession(); + const [prevQuery, setPrevQuery] = useState(trimmed); + const [prevEnabled, setPrevEnabled] = useState(enabled); + const [state, setState] = useState>({ + data: null, + loading: enabled, + error: null, + }); + + if (trimmed !== prevQuery || enabled !== prevEnabled) { + setPrevQuery(trimmed); + setPrevEnabled(enabled); + setState({ + data: null, + loading: enabled, + error: null, + }); + } + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + const params = new URLSearchParams(); + if (trimmed) params.set("q", trimmed); + if (fields) params.set("fields", fields); + const suffix = params.toString(); + const url = suffix ? `/api/medications?${suffix}` : "/api/medications"; + fetchJson(url, authorizationHeader) + .then((data) => { + if (!cancelled) setState({ data, loading: false, error: null }); + }) + .catch((error) => { + if (!cancelled) { + setState({ + data: null, + loading: false, + error: error instanceof Error ? error.message : "Could not load medications.", + }); + } + }); + return () => { + cancelled = true; + }; + }, [trimmed, enabled, fields, authorizationHeader]); + + return state; +} + +export function useMedicationDetail(slug?: string): AsyncState { + const normalized = slug?.trim().toLowerCase() ?? ""; + const { authorizationHeader } = useAuthSession(); + const [prevSlug, setPrevSlug] = useState(normalized); + const [state, setState] = useState>(() => ({ + data: null, + loading: !!normalized, + error: null, + })); + + if (normalized !== prevSlug) { + setPrevSlug(normalized); + setState({ + data: null, + loading: !!normalized, + error: null, + }); + } + + useEffect(() => { + if (!normalized) { + return; + } + let cancelled = false; + fetchJson(`/api/medications/${encodeURIComponent(normalized)}`, authorizationHeader) + .then((data) => { + if (!cancelled) setState({ data, loading: false, error: null }); + }) + .catch((error) => { + if (!cancelled) { + setState({ + data: null, + loading: false, + error: error instanceof Error ? error.message : "Could not load medication.", + }); + } + }); + return () => { + cancelled = true; + }; + }, [normalized, authorizationHeader]); + + return state; +} diff --git a/src/lib/catalog-search.ts b/src/lib/catalog-search.ts new file mode 100644 index 0000000000..b06a1daa1c --- /dev/null +++ b/src/lib/catalog-search.ts @@ -0,0 +1,173 @@ +// Shared search primitives for the registry catalogs (medications, services, forms, +// differentials, tools). Before this module each domain re-implemented its own text +// normalizer (with divergent regexes, so the same query tokenized differently per domain) +// and its own weighted includes() ranker. The domain rankers are thin wrappers over +// rankCatalogRecords with their historical field weights; the wrapper owns its reason +// labels and match shape so existing API/UI contracts are unchanged. + +import { matchesTermAtWordBoundary } from "@/lib/keyword-query"; + +// Canonical normalizer (the medications implementation — the superset of the retired +// services/forms variants: NFKD + diacritic strip, and `+ . / -` survive so dose strings +// ("5+5", "0.5mg", "IM/PO") and hyphenated clinical terms stay searchable). +export function normalizeSearchText(value: string) { + return value + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9+./\s-]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export function compactSearchText(value: string) { + return value.replace(/\s+/g, ""); +} + +export type CatalogField = { + // Wrapper-facing key used to build human-readable reasons (e.g. "title", "contact"). + id: string; + weight: number; + text: (record: T) => string; +}; + +export type CatalogMatchSignals = { + // Matched term count per field id (only fields with at least one match are present). + fields: Record; + // Matched term count against the full-text haystack. + content: number; + // Matched term count for terms introduced by expandTokens (e.g. symptom + // aliases) that were not part of the raw query. + expanded: number; + compact: boolean; + phrase: boolean; + prefix: boolean; + exact: boolean; + broad: boolean; +}; + +export type CatalogRankedMatch = { + record: T; + score: number; + signals: CatalogMatchSignals; +}; + +export type RankCatalogOptions = { + fields: Array>; + // The widest haystack for the record; also the compact-match haystack. + fullText: (record: T) => string; + contentWeight?: number; + // Compact-query bonus (query with spaces removed found in the compacted haystack). + // 0 disables. compactExtraText widens the compact haystack (e.g. compacted title). + compactBonus?: number; + compactMinLength?: number; + compactExtraText?: (record: T) => string; + // Whole normalized query found in the full text. + phraseBonus?: number; + // Values compared for exact equality with the normalized query (title/slug). + exactValues?: (record: T) => string[]; + exactBonus?: number; + // Values checked for a starts-with match on the normalized query (partial + // typing of a name). 0 disables. + prefixValues?: (record: T) => string[]; + prefixBonus?: number; + prefixMinLength?: number; + // Catalogue-wide "broad intent" terms ("forms", "services") granting a flat bonus. + broadTerms?: string[]; + broadBonus?: number; + // Token expansion hook (differential alias table). Receives the deduped query terms. + expandTokens?: (terms: string[]) => string[]; + limit?: number; + // Defaults to input order (stable) when omitted. + tieBreak?: (left: T, right: T) => number; +}; + +export function rankCatalogRecords( + records: T[], + query: string, + options: RankCatalogOptions, +): Array> { + const normalizedQuery = normalizeSearchText(query); + if (!normalizedQuery) return []; + + const contentWeight = options.contentWeight ?? 2; + const compactBonus = options.compactBonus ?? 0; + const compactMinLength = options.compactMinLength ?? 4; + const phraseBonus = options.phraseBonus ?? 4; + const exactBonus = options.exactBonus ?? 10; + const prefixBonus = options.prefixBonus ?? 0; + const prefixMinLength = options.prefixMinLength ?? 3; + const broadBonus = options.broadBonus ?? 1; + + const compactQuery = compactSearchText(normalizedQuery); + const baseTerms = Array.from(new Set(normalizedQuery.split(/\s+/).filter((term) => term.length > 1))); + const terms = options.expandTokens + ? Array.from(new Set(options.expandTokens(baseTerms).filter((term) => term.length > 1))) + : baseTerms; + const baseTermSet = new Set(baseTerms); + const expandedTerms = terms.filter((term) => !baseTermSet.has(term)); + const broad = Boolean(options.broadTerms?.length && terms.some((term) => options.broadTerms!.includes(term))); + + const ranked = records + .map((record, index) => { + const text = options.fullText(record); + const fields: Record = {}; + let score = 0; + + for (const field of options.fields) { + const haystack = field.text(record); + if (!haystack) continue; + // Fields are the high-weight name/title/tag signals, so a term must + // align with a word boundary — substring hits ("renal" inside + // "adrenaline") stay confined to the low-weight content haystack. + const matched = terms.filter((term) => matchesTermAtWordBoundary(haystack, term)).length; + if (!matched) continue; + fields[field.id] = matched; + score += matched * field.weight; + } + + const content = terms.filter((term) => text.includes(term)).length; + score += content * contentWeight; + + const expanded = expandedTerms.filter((term) => text.includes(term)).length; + + const compact = + compactBonus > 0 && + compactQuery.length >= compactMinLength && + (compactSearchText(text).includes(compactQuery) || + (options.compactExtraText + ? compactSearchText(options.compactExtraText(record)).includes(compactQuery) + : false)); + if (compact) score += compactBonus; + + const phrase = phraseBonus > 0 && text.includes(normalizedQuery); + if (phrase) score += phraseBonus; + + const exact = Boolean(options.exactValues?.(record).some((value) => value === normalizedQuery)); + if (exact) score += exactBonus; + + const prefix = + prefixBonus > 0 && + normalizedQuery.length >= prefixMinLength && + Boolean(options.prefixValues?.(record).some((value) => value.startsWith(normalizedQuery))); + if (prefix) score += prefixBonus; + + if (broad) score += broadBonus; + + return { + record, + index, + score, + signals: { fields, content, expanded, compact, phrase, prefix, exact, broad } satisfies CatalogMatchSignals, + }; + }) + .filter((match) => match.score > 0) + .sort( + (left, right) => + right.score - left.score || + (options.tieBreak ? options.tieBreak(left.record, right.record) : left.index - right.index), + ) + .map(({ record, score, signals }) => ({ record, score, signals })); + + return options.limit !== undefined ? ranked.slice(0, options.limit) : ranked; +} diff --git a/src/lib/cross-mode-differentials.ts b/src/lib/cross-mode-differentials.ts new file mode 100644 index 0000000000..65c57eb59f --- /dev/null +++ b/src/lib/cross-mode-differentials.ts @@ -0,0 +1,40 @@ +import type { CrossModeDifferentialCatalog } from "@/lib/cross-mode-links"; +import { differentialRecords } from "@/lib/differentials"; + +const supplementalDiagnoses = [ + { + slug: "acute-psychosis", + title: "Acute Psychosis", + clinicalHinge: "Acute psychotic symptoms require safety review and rapid medical exclusion.", + }, + { + slug: "aggression-violence-homicidal-ideation", + title: "Aggression / Violence / Homicidal Ideation", + clinicalHinge: "Acute aggression or homicidal intent threatens immediate safety and may signal delirium, intoxication or mania.", + }, +] as const; + +// Load this module with a dynamic import only: it statically pulls the +// differentials catalog, which stays code-split out of the dashboard bundle. +export function crossModeDifferentialCatalog(): CrossModeDifferentialCatalog { + const diagnoses = [ + ...differentialRecords.map((record) => ({ + slug: record.slug, + title: record.title, + clinicalHinge: record.clinicalHinge, + })), + ...supplementalDiagnoses.map((record) => ({ ...record })), + ]; + + return { + diagnoses, + presentations: [], + aliases: { + psychotic: ["psychosis", "schizophrenia"], + psychosis: ["psychotic", "schizophrenia"], + aggression: ["violence", "homicidal"], + violence: ["aggression", "homicidal"], + homicidal: ["aggression", "violence"], + }, + }; +} diff --git a/src/lib/cross-mode-links.ts b/src/lib/cross-mode-links.ts new file mode 100644 index 0000000000..7da1b82ce6 --- /dev/null +++ b/src/lib/cross-mode-links.ts @@ -0,0 +1,273 @@ +import { appModeDefinition, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; +import { rankFormRecords, type FormRecord } from "@/lib/forms"; +import { + medicationIdentityBadges, + medicationIndication, + rankMedicationRecords, + type MedicationRecord, +} from "@/lib/medications"; +import { extractKeywordTerms } from "@/lib/keyword-query"; +import { rankServiceRecords, type ServiceRecord } from "@/lib/services"; + +export type CrossModeLinkModeId = Extract; + +export type CrossModeLinkBadge = { + label: string; + tone?: "clinical" | "success" | "danger" | "warning" | "neutral" | "info"; +}; + +export type CrossModeLink = { + modeId: CrossModeLinkModeId; + modeLabel: string; + slug: string; + title: string; + subtitle: string; + badges: CrossModeLinkBadge[]; + detailHref: string; + modeSearchHref: string; + modeSearchQuery: string; + score: number; + matchReason: string; +}; + +export type CrossModeDifferentialCatalog = { + diagnoses: Array<{ slug: string; title: string; clinicalHinge: string }>; + presentations: Array<{ id: string; title: string; subtitle: string }>; + aliases: Record; +}; + +// The differential catalog is injected (not imported) so this module never +// statically pulls the 1.2 MB differentials snapshot — or the 3.4 MB +// medications snapshot — into the dashboard bundle. +export type CrossModeCatalogs = { + medications?: MedicationRecord[]; + services?: ServiceRecord[]; + forms?: FormRecord[]; + differentials?: CrossModeDifferentialCatalog; +}; + +export type CrossModeLinkOptions = { + maxPerMode?: number; + maxTotal?: number; +}; + +// The gate for every mode is "the query names the entity" (a name/title-level +// match), not raw score: question filler like "dose" or "patient" survives +// keyword extraction and content-matches nearly every record for ~2 points per +// term, so content-only scores can never be trusted on their own. +const MEDICATION_MIN_SCORE = 10; // one name-term hit: 8 (name) + 2 (content echo) +const SERVICE_MIN_SCORE = 8; // one title-term hit: 6 (title) + 2 (content echo) +const DIFFERENTIAL_TITLE_TERM_SCORE = 8; + +const RANKER_CANDIDATE_LIMIT = 5; + +const modePriority: Record = { + prescribing: 0, + services: 1, + forms: 2, + differentials: 3, +}; + +function crossModeLinkBase(modeId: CrossModeLinkModeId, title: string) { + return { + modeId, + modeLabel: appModeDefinition(modeId).label, + title, + modeSearchHref: appModeHomeHref(modeId, { query: title, focus: true, run: true }), + modeSearchQuery: title, + }; +} + +function serviceChipBadges(record: ServiceRecord): CrossModeLinkBadge[] { + const badges: CrossModeLinkBadge[] = []; + for (const chip of record.statusChips ?? []) { + const label = chip.label?.trim(); + if (!label) continue; + badges.push({ label, tone: chip.tone ?? undefined }); + if (badges.length === 2) break; + } + return badges; +} + +// The rankers match name/title terms by substring, which lets query words hide +// inside entity names ("renal" inside "adrenaline"). A term only counts as +// naming an entity when it aligns with a word boundary; prefixes are accepted +// for longer terms so plural/possessive drift still matches. +function hasWordBoundaryMatch(value: string, terms: string[]) { + const words = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim() + .split(" ") + .filter(Boolean); + return terms.some((term) => words.some((word) => word === term || (term.length >= 5 && word.startsWith(term)))); +} + +function medicationLinks(query: string, terms: string[], records: MedicationRecord[]): CrossModeLink[] { + return rankMedicationRecords(records, query, RANKER_CANDIDATE_LIMIT) + .filter( + (match) => + match.score >= MEDICATION_MIN_SCORE && + (match.reasons.includes("name") || match.reasons.includes("exact name")) && + hasWordBoundaryMatch(`${match.medication.name} ${match.medication.slug}`, terms), + ) + .map((match) => ({ + ...crossModeLinkBase("prescribing", match.medication.name), + slug: match.medication.slug, + subtitle: medicationIndication(match.medication), + badges: medicationIdentityBadges(match.medication).slice(0, 2), + detailHref: `/medications/${match.medication.slug}`, + score: match.score, + matchReason: match.reasons.join(" · "), + })); +} + +function registryLinks( + modeId: Extract, + query: string, + terms: string[], + records: ServiceRecord[], +): CrossModeLink[] { + const ranker = modeId === "services" ? rankServiceRecords : rankFormRecords; + return ranker(records, query, RANKER_CANDIDATE_LIMIT) + .filter( + (match) => + match.score >= SERVICE_MIN_SCORE && + match.reasons.includes("title") && + hasWordBoundaryMatch(`${match.service.title} ${match.service.slug}`, terms), + ) + .map((match) => ({ + ...crossModeLinkBase(modeId, match.service.title), + slug: match.service.slug, + subtitle: match.service.subtitle?.trim() || match.service.route?.trim() || "", + badges: serviceChipBadges(match.service), + detailHref: `/${modeId}/${match.service.slug}`, + score: match.score, + matchReason: match.reasons.join(" · "), + })); +} + +function differentialTitleScore(title: string, terms: string[], aliasDerived: Set) { + // Word-boundary matching keeps substring junk out; a matching term must + // also be at least 4 chars unless it came from a curated alias. + const matches = terms.filter( + (term) => (term.length >= 4 || aliasDerived.has(term)) && hasWordBoundaryMatch(title, [term]), + ); + return matches.length * DIFFERENTIAL_TITLE_TERM_SCORE; +} + +function differentialLinks(terms: string[], catalog: CrossModeDifferentialCatalog): CrossModeLink[] { + if (terms.length === 0) return []; + + const aliasDerived = new Set(); + const expanded = new Set(terms); + for (const term of terms) { + for (const alias of catalog.aliases[term] ?? []) { + const normalizedAlias = alias.toLowerCase(); + if (!expanded.has(normalizedAlias)) aliasDerived.add(normalizedAlias); + expanded.add(normalizedAlias); + } + } + const expandedTerms = [...expanded]; + + const candidates: CrossModeLink[] = []; + for (const record of catalog.diagnoses) { + const score = differentialTitleScore(record.title, expandedTerms, aliasDerived); + if (score < DIFFERENTIAL_TITLE_TERM_SCORE) continue; + candidates.push({ + ...crossModeLinkBase("differentials", record.title), + slug: record.slug, + subtitle: record.clinicalHinge, + badges: [], + detailHref: `/differentials/diagnoses/${record.slug}`, + score, + matchReason: "title", + }); + } + for (const presentation of catalog.presentations) { + const score = differentialTitleScore(presentation.title, expandedTerms, aliasDerived); + if (score < DIFFERENTIAL_TITLE_TERM_SCORE) continue; + candidates.push({ + ...crossModeLinkBase("differentials", presentation.title), + slug: presentation.id, + subtitle: presentation.subtitle, + badges: [], + detailHref: `/differentials/presentations/${presentation.id}`, + score, + matchReason: "title", + }); + } + + return candidates + .sort((left, right) => right.score - left.score || left.title.localeCompare(right.title)) + .slice(0, 1); +} + +// Follow-ups often drop the entity name ("what about renal impairment?"), so +// an answer thread resolves links from its newest turn that names an entity — +// walking all the way back, not just one turn, keeps the entity's card alive +// through consecutive entity-free follow-ups. Queries are ordered oldest first. +export function buildCrossModeLinksForThread( + queries: Array, + catalogs: CrossModeCatalogs, + options: CrossModeLinkOptions = {}, +): CrossModeLink[] { + for (let index = queries.length - 1; index >= 0; index -= 1) { + const query = queries[index]?.trim(); + if (!query) continue; + const links = buildCrossModeLinks(query, catalogs, options); + if (links.length > 0) return links; + } + return []; +} + +export function buildCrossModeLinks( + query: string, + catalogs: CrossModeCatalogs, + options: CrossModeLinkOptions = {}, +): CrossModeLink[] { + const maxPerMode = options.maxPerMode ?? 2; + const maxTotal = options.maxTotal ?? 4; + + const terms = extractKeywordTerms(query); + if (terms.length === 0) return []; + const keywordQuery = terms.join(" "); + + const candidates = [ + ...medicationLinks(keywordQuery, terms, catalogs.medications ?? []), + ...registryLinks("services", keywordQuery, terms, catalogs.services ?? []), + ...registryLinks("forms", keywordQuery, terms, catalogs.forms ?? []), + ...(catalogs.differentials ? differentialLinks(terms, catalogs.differentials) : []), + ]; + + candidates.sort( + (left, right) => + right.score - left.score || + modePriority[left.modeId] - modePriority[right.modeId] || + left.title.localeCompare(right.title), + ); + + const seenKeys = new Set(); + // A slug shared between the services and forms registries is the same + // record surfaced twice; keep only the higher-scoring occurrence. + const seenRegistrySlugs = new Set(); + const perModeCounts: Partial> = {}; + const links: CrossModeLink[] = []; + + for (const candidate of candidates) { + if (links.length >= maxTotal) break; + const key = `${candidate.modeId}:${candidate.slug}`; + if (seenKeys.has(key)) continue; + if (candidate.modeId === "services" || candidate.modeId === "forms") { + if (seenRegistrySlugs.has(candidate.slug)) continue; + seenRegistrySlugs.add(candidate.slug); + } + const modeCount = perModeCounts[candidate.modeId] ?? 0; + if (modeCount >= maxPerMode) continue; + seenKeys.add(key); + perModeCounts[candidate.modeId] = modeCount + 1; + links.push(candidate); + } + + return links; +} diff --git a/src/lib/keyword-query.ts b/src/lib/keyword-query.ts new file mode 100644 index 0000000000..ac22366516 --- /dev/null +++ b/src/lib/keyword-query.ts @@ -0,0 +1,104 @@ +export const keywordStopWords = new Set([ + "a", + "about", + "all", + "an", + "and", + "are", + "as", + "at", + "be", + "before", + "both", + "by", + "can", + "could", + "did", + "do", + "does", + "for", + "from", + "get", + "had", + "has", + "have", + "her", + "his", + "how", + "if", + "in", + "is", + "it", + "its", + "into", + "me", + "may", + "more", + "my", + "no", + "not", + "of", + "on", + "or", + "our", + "out", + "should", + "so", + "such", + "that", + "the", + "their", + "them", + "there", + "these", + "they", + "this", + "those", + "to", + "when", + "where", + "which", + "who", + "why", + "with", + "would", + "you", +]); + +export function extractKeywordTerms(query: string, options: { maxTerms?: number } = {}): string[] { + const maxTerms = options.maxTerms ?? 12; + const normalized = query + .normalize("NFKD") + .toLowerCase() + .replace(/[^\w\s]+/g, " ") + .replace(/_/g, " ") + .trim(); + const tokens = normalized.split(/\s+/).filter((token) => token.length >= 3 && !keywordStopWords.has(token)); + const terms: string[] = []; + const seen = new Set(); + + for (const token of tokens) { + if (seen.has(token)) continue; + seen.add(token); + terms.push(token); + } + + return terms.slice(0, maxTerms); +} + +export function keywordQueryFromNaturalLanguage(query: string) { + return extractKeywordTerms(query, { maxTerms: 7 }).join(" "); +} + +// A query term only counts against a name/title when it aligns with a word +// boundary — exact word, or word prefix to keep search-as-you-type working — +// so terms cannot hide inside words ("renal" inside "adrenaline"). Words are +// split on every non-alphanumeric so tokens like "im/po" or "co-codamol" +// match on their parts. +export function matchesTermAtWordBoundary(text: string, term: string) { + if (!term) return false; + return text + .toLowerCase() + .split(/[^a-z0-9]+/) + .some((word) => word === term || word.startsWith(term)); +} diff --git a/src/lib/medication-badges.ts b/src/lib/medication-badges.ts new file mode 100644 index 0000000000..668591ebec --- /dev/null +++ b/src/lib/medication-badges.ts @@ -0,0 +1,356 @@ +import { SEMANTIC_TONE_PRIORITY, type SemanticIconKey, type SemanticTone } from "@/lib/semantic-tone"; +import type { + MedicationPatientMetadata, + MedicationRecord, + MedicationSectionRow, + MedicationStat, +} from "@/lib/medications"; + +export type MedicationGovernance = { + sourceStatus?: string; + validationStatus?: string; +}; + +export type MedicationBadge = { + id: string; + label: string; + tone: SemanticTone; + // Optional semantic icon key resolved by ClinicalBadge (e.g. controlled-drug + // lock). Danger/warning badges already get a default icon from their tone, so + // this is only set where a specific icon is meaningful. + iconKey?: SemanticIconKey; +}; + +const TAG_TONES: Record = { + PBS: "success", + TGA: "info", + OFF: "warning", +}; + +const FACTOR_LABELS: Record = { + renal: "Renal", + hepatic: "Hepatic", + pregnancy: "Pregnancy", + lactation: "Breastfeeding", + elderly: "Elderly", + paediatric: "Paediatric", +}; + +function sectionByType(record: MedicationRecord, type: string) { + return record.sections.find((section) => section.type === type); +} + +function firstRowValue(record: MedicationRecord, type: string, keyIncludes?: string) { + const section = sectionByType(record, type); + if (!section) return ""; + const row = keyIncludes + ? section.rows.find((item) => item.key.toLowerCase().includes(keyIncludes.toLowerCase())) + : section.rows[0]; + return row?.val?.trim() ?? ""; +} + +function firstRow(record: MedicationRecord, type: string, keyIncludes?: string) { + const section = sectionByType(record, type); + if (!section) return undefined; + return keyIncludes + ? section.rows.find((item) => item.key.toLowerCase().includes(keyIncludes.toLowerCase())) + : section.rows[0]; +} + +function quickValue(record: MedicationRecord, labelIncludes: string) { + const row = record.quick.find((item) => item.label.toLowerCase().includes(labelIncludes.toLowerCase())); + return row?.value?.trim() ?? ""; +} + +function pushBadge(badges: MedicationBadge[], badge: MedicationBadge) { + if (!badges.some((existing) => existing.id === badge.id)) { + badges.push(badge); + } +} + +export function dedupeBadges(badges: MedicationBadge[]): MedicationBadge[] { + const seen = new Set(); + return badges.filter((badge) => { + if (seen.has(badge.id)) return false; + seen.add(badge.id); + return true; + }); +} + +export function sortBadgesByPriority(badges: MedicationBadge[]): MedicationBadge[] { + return [...badges].sort((a, b) => SEMANTIC_TONE_PRIORITY[b.tone] - SEMANTIC_TONE_PRIORITY[a.tone]); +} + +function formulationShortLabel(value: string): string | null { + const cleaned = value + .replace(/\*\*/g, "") + .replace(/\s*\([^)]*\)\s*$/, "") + .trim(); + if (!cleaned) return null; + + const mgMatch = cleaned.match(/(\d+)\s*mg/i); + if (mgMatch) { + const mg = mgMatch[1]; + const isEc = /enteric/i.test(cleaned); + const isTab = /tablet/i.test(cleaned); + if (isEc && isTab) return `${mg} mg EC tablet`; + if (isTab) return `${mg} mg tablet`; + if (isEc) return `${mg} mg EC`; + return `${mg} mg`; + } + + const firstSentence = cleaned.split(".")[0]?.trim() ?? ""; + if (!firstSentence) return null; + return firstSentence.length > 28 ? `${firstSentence.slice(0, 28).trim()}…` : firstSentence; +} + +function parsePbsBadges(pbsText: string, badges: MedicationBadge[]) { + const upper = pbsText.toUpperCase(); + if (upper.includes("STREAMLINED PBS")) { + pushBadge(badges, { id: "identity-pbs-streamlined", label: "PBS streamlined", tone: "success" }); + } else if (/AUTHORITY REQUIRED/i.test(pbsText)) { + pushBadge(badges, { id: "identity-pbs-authority", label: "Authority required", tone: "warning" }); + } + + const itemMatch = pbsText.match(/\bitem\s+(\d{4}[A-Z])\b/i) ?? pbsText.match(/\b(\d{4}[A-Z])\b/); + if (itemMatch?.[1]) { + pushBadge(badges, { id: `identity-pbs-item-${itemMatch[1]}`, label: itemMatch[1], tone: "neutral" }); + } +} + +function isReviewed(record: MedicationRecord, governance?: MedicationGovernance) { + if (governance?.validationStatus === "locally_reviewed" || governance?.validationStatus === "approved") { + return true; + } + const sourceText = firstRowValue(record, "src", "source review").toLowerCase(); + return sourceText.includes("checked"); +} + +function patientBadges(patient: MedicationPatientMetadata, prefix: string, badges: MedicationBadge[]) { + const match = patient.match ?? {}; + const scr = match.scr as { gt?: number } | undefined; + if (typeof scr?.gt === "number") { + pushBadge(badges, { + id: `${prefix}-scr-gt-${scr.gt}`, + label: `Cr >${scr.gt} avoid`, + tone: "danger", + }); + } + + const age = match.age as { lt?: number; gt?: number } | undefined; + if (typeof age?.lt === "number") { + pushBadge(badges, { + id: `${prefix}-age-lt-${age.lt}`, + label: `Avoid <${age.lt} years`, + tone: "warning", + }); + } + if (typeof age?.gt === "number") { + pushBadge(badges, { + id: `${prefix}-age-gt-${age.gt}`, + label: `Avoid >${age.gt} years`, + tone: "warning", + }); + } + + const action = patient.action ?? ""; + const severity = patient.severity === "danger" ? "danger" : action === "contraindication" ? "danger" : "warning"; + const factorTone: SemanticTone = + action === "monitor" || action === "dose-adjust" ? "clinical" : severity === "danger" ? "danger" : "warning"; + + for (const factor of patient.factors ?? []) { + const label = FACTOR_LABELS[factor] ?? factor.charAt(0).toUpperCase() + factor.slice(1); + pushBadge(badges, { + id: `${prefix}-factor-${factor}`, + label, + tone: action === "contraindication" ? "danger" : factorTone, + }); + } +} + +function textHeuristicBadges( + row: MedicationSectionRow, + sectionType: string, + prefix: string, + badges: MedicationBadge[], +) { + const val = row.val.replace(/\*\*/g, ""); + const keyLower = row.key.toLowerCase(); + const combined = `${row.key} ${val}`.toLowerCase(); + + if (/^critical\b/i.test(val) || /^contraindicated\b/i.test(val)) { + pushBadge(badges, { id: `${prefix}-contraindicated`, label: "Contraindicated", tone: "danger" }); + } + + if (sectionType === "risk") { + const severityMatch = val.match(/^(HIGH|MODERATE|LOW)\b/i); + if (severityMatch?.[1]) { + const level = severityMatch[1].toUpperCase(); + pushBadge(badges, { + id: `${prefix}-severity-${level}`, + label: level.charAt(0) + level.slice(1).toLowerCase(), + tone: level === "HIGH" ? "warning" : "neutral", + }); + } + } + + if (combined.includes("<60 kg") || combined.includes("< 60 kg")) { + pushBadge(badges, { id: `${prefix}-reduce-60kg`, label: "Reduce <60 kg", tone: "warning" }); + } + if (combined.includes("child-pugh c")) { + pushBadge(badges, { id: `${prefix}-child-pugh-c`, label: "Child-Pugh C", tone: "danger" }); + } + if (combined.includes("do not crush")) { + pushBadge(badges, { id: `${prefix}-do-not-crush`, label: "Do not crush", tone: "warning" }); + } + if (combined.includes("take with food") || combined.includes("with meals")) { + pushBadge(badges, { id: `${prefix}-with-food`, label: "Take with food", tone: "clinical" }); + } + + if (sectionType === "dose" && keyLower.includes("renal")) { + pushBadge(badges, { id: `${prefix}-renal-adjust`, label: "Renal adjustment", tone: "warning" }); + } +} + +export function medicationIdentityBadges( + record: MedicationRecord, + governance?: MedicationGovernance, +): MedicationBadge[] { + const badges: MedicationBadge[] = []; + + if (record.tag) { + pushBadge(badges, { id: "identity-tag", label: record.tag, tone: "neutral" }); + } + if (record.schedule) { + // S8 (controlled drug) is a regulatory classification, not a clinical stop + // state. Give it a dedicated "controlled" treatment — warning tone + lock + // icon — so red stays reserved for true contraindications/do-not-use. + const isControlled = record.schedule === "S8"; + pushBadge(badges, { + id: "identity-schedule", + label: record.schedule, + tone: isControlled ? "warning" : "info", + ...(isControlled ? { iconKey: "controlled" as const } : {}), + }); + } + + const brand = firstRowValue(record, "form", "brand"); + if (brand) { + pushBadge(badges, { id: "identity-brand", label: brand.replace(/\*\*/g, ""), tone: "neutral" }); + } + + const formulation = formulationShortLabel(quickValue(record, "route / formulation")); + if (formulation) { + pushBadge(badges, { id: "identity-formulation", label: formulation, tone: "neutral" }); + } + + const primaryRow = firstRow(record, "ind", "primary"); + for (const tag of primaryRow?.tags ?? []) { + pushBadge(badges, { + id: `identity-ind-tag-${tag}`, + label: tag, + tone: TAG_TONES[tag] ?? "neutral", + }); + } + + const pbsText = firstRowValue(record, "form", "prescribing & pbs"); + if (pbsText) { + parsePbsBadges(pbsText, badges); + } + + if (isReviewed(record, governance)) { + pushBadge(badges, { id: "identity-reviewed", label: "Reviewed", tone: "success" }); + } + + if (governance?.sourceStatus === "review_due") { + pushBadge(badges, { id: "identity-review-due", label: "Review due", tone: "warning" }); + } else if (governance?.sourceStatus === "outdated") { + pushBadge(badges, { id: "identity-outdated", label: "Outdated", tone: "danger" }); + } + + return sortBadgesByPriority(dedupeBadges(badges)); +} + +export function medicationRowBadges(row: MedicationSectionRow, sectionType: string): MedicationBadge[] { + const badges: MedicationBadge[] = []; + const prefix = `row-${sectionType}-${row.key}`.replace(/\s+/g, "-").toLowerCase(); + + if (row.patient) { + patientBadges(row.patient, prefix, badges); + } + + for (const tag of row.tags ?? []) { + pushBadge(badges, { + id: `${prefix}-tag-${tag}`, + label: tag, + tone: TAG_TONES[tag] ?? "info", + }); + } + + textHeuristicBadges(row, sectionType, prefix, badges); + + const limit = sectionType === "contra" || badges.some((badge) => badge.tone === "danger") ? 4 : 3; + return sortBadgesByPriority(dedupeBadges(badges)).slice(0, limit); +} + +export function medicationAccessBadges(record: MedicationRecord): MedicationBadge[] { + const badges: MedicationBadge[] = []; + const brand = firstRowValue(record, "form", "brand"); + if (brand) { + pushBadge(badges, { id: "access-brand", label: brand.replace(/\*\*/g, ""), tone: "neutral" }); + } + + const pbsText = firstRowValue(record, "form", "prescribing & pbs"); + if (pbsText) { + parsePbsBadges(pbsText, badges); + const itemMatch = pbsText.match(/\bitem\s+(\d{4}[A-Z])\b/i); + if (itemMatch?.[1]) { + pushBadge(badges, { id: `access-item-${itemMatch[1]}`, label: `Item ${itemMatch[1]}`, tone: "neutral" }); + } + } + + const routes = firstRowValue(record, "form", "oral routes"); + if (routes) { + const short = formulationShortLabel(routes); + if (short) { + pushBadge(badges, { id: "access-formulation", label: short, tone: "neutral" }); + } + } + + return sortBadgesByPriority(dedupeBadges(badges)).slice(0, 4); +} + +export function medicationStatTone(stat: MedicationStat): SemanticTone { + const cls = stat.cls?.toLowerCase() ?? ""; + const flag = stat.flag?.toLowerCase() ?? ""; + if (cls === "hi" || flag === "hi") return "danger"; + if (cls === "warn" || flag === "warn") return "warning"; + if (cls === "good") return "success"; + return "neutral"; +} + +export function medicationAccessFields(record: MedicationRecord): Array<{ label: string; value: string }> { + const fields: Array<{ label: string; value: string }> = []; + const brand = firstRowValue(record, "form", "brand"); + if (brand) fields.push({ label: "Brand", value: brand.replace(/\*\*/g, "") }); + + const pbsText = firstRowValue(record, "form", "prescribing & pbs"); + if (pbsText) { + if (/STREAMLINED PBS/i.test(pbsText)) { + fields.push({ label: "PBS status", value: "PBS streamlined" }); + } else if (/AUTHORITY REQUIRED/i.test(pbsText)) { + fields.push({ label: "PBS status", value: "Authority required" }); + } + const itemMatch = pbsText.match(/\bitem\s+(\d{4}[A-Z])\b/i); + if (itemMatch?.[1]) { + fields.push({ label: "PBS item", value: itemMatch[1] }); + } + } + + const routes = firstRowValue(record, "form", "oral routes"); + if (routes) { + fields.push({ label: "Formulation", value: routes.replace(/\*\*/g, "").split(".")[0]?.trim() ?? routes }); + } + + return fields; +} diff --git a/src/lib/medication-fixtures.ts b/src/lib/medication-fixtures.ts new file mode 100644 index 0000000000..6df806434f --- /dev/null +++ b/src/lib/medication-fixtures.ts @@ -0,0 +1,5 @@ +import { loadMedicationSnapshot } from "@/lib/medication-snapshot"; + +export function defaultMedicationRecords() { + return loadMedicationSnapshot(); +} diff --git a/src/lib/medication-records.ts b/src/lib/medication-records.ts new file mode 100644 index 0000000000..a7a2c17a31 --- /dev/null +++ b/src/lib/medication-records.ts @@ -0,0 +1,50 @@ +import type { MedicationRecord } from "@/lib/medications"; + +export type MedicationSourceStatus = "current" | "review_due" | "outdated" | "unknown"; +export type MedicationValidationStatus = "unverified" | "locally_reviewed" | "approved"; + +const sourceStatuses: readonly MedicationSourceStatus[] = ["current", "review_due", "outdated", "unknown"]; +const validationStatuses: readonly MedicationValidationStatus[] = ["unverified", "locally_reviewed", "approved"]; + +export function normalizeMedicationSlug(value: string) { + return value.trim().toLowerCase(); +} + +export function medicationSourceStatus(value: string | null | undefined): MedicationSourceStatus { + return sourceStatuses.find((status) => status === value) ?? "unknown"; +} + +export function medicationValidationStatus(value: string | null | undefined): MedicationValidationStatus { + return validationStatuses.find((status) => status === value) ?? "unverified"; +} + +export function deriveGovernanceFromSections(record: MedicationRecord): { + source_status: MedicationSourceStatus; + validation_status: MedicationValidationStatus; +} { + const sourceSection = record.sections.find((section) => section.type === "src"); + const sourceText = + sourceSection?.rows + .map((row) => row.val) + .join(" ") + .toLowerCase() ?? ""; + const sourceStatus: MedicationSourceStatus = sourceText.includes("checked") + ? "current" + : sourceText.includes("review") + ? "review_due" + : "unknown"; + return { + source_status: sourceStatus, + validation_status: "locally_reviewed", + }; +} + +export function rowGovernance(row: { + source_status: string | null; + validation_status: string | null; +}) { + return { + sourceStatus: medicationSourceStatus(row.source_status), + validationStatus: medicationValidationStatus(row.validation_status), + }; +} diff --git a/src/lib/medication-seed.ts b/src/lib/medication-seed.ts new file mode 100644 index 0000000000..7f073fdf58 --- /dev/null +++ b/src/lib/medication-seed.ts @@ -0,0 +1,3 @@ +import { defaultMedicationRecords } from "@/lib/medication-fixtures"; + +export { defaultMedicationRecords }; diff --git a/src/lib/medication-snapshot.ts b/src/lib/medication-snapshot.ts new file mode 100644 index 0000000000..42acf02b0b --- /dev/null +++ b/src/lib/medication-snapshot.ts @@ -0,0 +1,17 @@ +import medicationsSnapshot from "../../data/medications-snapshot.json"; + +import { normalizeMedicationSlug, normalizeRecord, type MedicationRecord } from "@/lib/medications"; + +let cachedSnapshot: MedicationRecord[] | null = null; + +export function loadMedicationSnapshot(): MedicationRecord[] { + if (cachedSnapshot) return cachedSnapshot; + const raw = medicationsSnapshot as MedicationRecord[]; + cachedSnapshot = raw.map(normalizeRecord).sort((left, right) => left.name.localeCompare(right.name)); + return cachedSnapshot; +} + +export function getMedicationRecord(slug: string): MedicationRecord | undefined { + const normalized = normalizeMedicationSlug(slug); + return loadMedicationSnapshot().find((record) => record.slug === normalized); +} diff --git a/src/lib/medications.ts b/src/lib/medications.ts new file mode 100644 index 0000000000..56965521c6 --- /dev/null +++ b/src/lib/medications.ts @@ -0,0 +1,271 @@ +import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search"; + +export type MedicationPatientMetadata = { + factors?: string[]; + action?: string; + severity?: string; + match?: Record; + note?: string; +}; + +export type MedicationSectionRow = { + key: string; + val: string; + tags?: string[]; + patient?: MedicationPatientMetadata | null; +}; + +export type MedicationSection = { + title: string; + type: string; + rows: MedicationSectionRow[]; +}; + +export type MedicationStat = { + label: string; + value: string; + cls?: string; + flag?: string; +}; + +export type MedicationQuickRow = { + label: string; + value: string; +}; + +export type MedicationRecord = { + slug: string; + name: string; + class: string; + subclass: string; + category: string; + accent: string; + tag: string; + schedule: string; + stats: MedicationStat[]; + sections: MedicationSection[]; + quick: MedicationQuickRow[]; +}; + +export type MedicationSearchMatch = { + medication: MedicationRecord; + score: number; + reasons: string[]; +}; + +export type MedicationResultTone = "teal" | "blue" | "slate"; + +export type MedicationSearchResult = { + id: string; + name: string; + indication: string; + match: string; + dose: string; + ceiling: string; + action: string; + tone: MedicationResultTone; + href: string; +}; + +export function normalizeMedicationSlug(value: string) { + return value.trim().toLowerCase(); +} + +export { normalizeSearchText }; + +export function normalizeRecord(record: MedicationRecord): MedicationRecord { + return { + ...record, + slug: normalizeMedicationSlug(record.slug), + name: record.name.trim(), + class: record.class?.trim() ?? "", + subclass: record.subclass?.trim() ?? "", + category: record.category?.trim() ?? "", + accent: record.accent?.trim() || "#0f766e", + tag: record.tag?.trim() ?? "", + schedule: record.schedule?.trim() ?? "", + stats: Array.isArray(record.stats) ? record.stats : [], + sections: Array.isArray(record.sections) ? record.sections : [], + quick: Array.isArray(record.quick) ? record.quick : [], + }; +} + +function sectionByType(record: MedicationRecord, type: string) { + return record.sections.find((section) => section.type === type); +} + +function firstRowValue(record: MedicationRecord, type: string, keyIncludes?: string) { + const section = sectionByType(record, type); + if (!section) return ""; + const row = keyIncludes + ? section.rows.find((item) => item.key.toLowerCase().includes(keyIncludes.toLowerCase())) + : section.rows[0]; + return row?.val?.trim() ?? ""; +} + +function statValue(record: MedicationRecord, labelIncludes: string) { + const stat = record.stats.find((item) => item.label.toLowerCase().includes(labelIncludes.toLowerCase())); + return stat?.value?.trim() ?? ""; +} + +function quickValue(record: MedicationRecord, labelIncludes: string) { + const row = record.quick.find((item) => item.label.toLowerCase().includes(labelIncludes.toLowerCase())); + return row?.value?.trim() ?? ""; +} + +export function medicationSearchText(record: MedicationRecord) { + const sectionText = record.sections + .flatMap((section) => [section.title, ...section.rows.flatMap((row) => [row.key, row.val, ...(row.tags ?? [])])]) + .join(" "); + const quickText = record.quick.map((row) => `${row.label} ${row.value}`).join(" "); + const statText = record.stats.map((stat) => `${stat.label} ${stat.value}`).join(" "); + return normalizeSearchText( + [ + record.name, + record.slug, + record.class, + record.subclass, + record.category, + record.tag, + record.schedule, + sectionText, + quickText, + statText, + ].join(" "), + ); +} + +export function medicationIndication(record: MedicationRecord) { + return ( + firstRowValue(record, "ind", "primary") || + firstRowValue(record, "summary", "overview") || + record.subclass || + record.category + ); +} + +export function medicationUsualDose(record: MedicationRecord) { + const quickDose = quickValue(record, "usual dose"); + if (quickDose) return quickDose.replace(/\*\*/g, "").split(".")[0]?.trim() ?? quickDose; + const doseRow = sectionByType(record, "dose")?.rows[0]; + return doseRow?.val?.replace(/\*\*/g, "").split(".")[0]?.trim() ?? "See dosing"; +} + +export function medicationCeiling(record: MedicationRecord) { + return statValue(record, "max dose") || statValue(record, "ceiling") || "See reference"; +} + +export function medicationAction(record: MedicationRecord) { + return ( + quickValue(record, "avoid") || + firstRowValue(record, "contra", "absolute") || + firstRowValue(record, "summary", "clinical focus") || + firstRowValue(record, "mon", "laboratory") || + "Review full prescribing reference." + ) + .replace(/\*\*/g, "") + .split(".")[0] + ?.trim(); +} + +export function medicationResultTone(record: MedicationRecord, score: number): MedicationResultTone { + if (score >= 12) return "teal"; + if (score >= 6) return "blue"; + return "slate"; +} + +export function medicationToSearchResult(match: MedicationSearchMatch): MedicationSearchResult { + const { medication, score } = match; + return { + id: medication.slug, + name: medication.name, + indication: medicationIndication(medication), + match: score >= 12 ? "Exact clinical fit" : score >= 6 ? "Good clinical fit" : "Related match", + dose: medicationUsualDose(medication), + ceiling: medicationCeiling(medication), + action: medicationAction(medication) ?? "Review full prescribing reference.", + tone: medicationResultTone(medication, score), + href: `/medications/${medication.slug}`, + }; +} + +export function rankMedicationRecords( + records: MedicationRecord[], + query: string, + limit = 50, + // Low-weight synonym/acronym/alias terms (e.g. from analyzeClinicalQuery) threaded into the + // shared ranker's expanded lane: they add recall via the content haystack without competing + // with exact name/prefix scoring. Empty by default so existing callers are unchanged. + expansions: string[] = [], +): MedicationSearchMatch[] { + return rankCatalogRecords(records, query, { + fields: [ + { + id: "name", + weight: 8, + text: (medication) => normalizeSearchText(`${medication.name} ${medication.slug}`), + }, + { + id: "taxonomy", + weight: 3, + text: (medication) => + normalizeSearchText( + [medication.class, medication.subclass, medication.category, medication.tag, medication.schedule].join(" "), + ), + }, + ], + fullText: medicationSearchText, + contentWeight: 2, + compactBonus: 6, + compactExtraText: (medication) => normalizeSearchText(medication.name), + phraseBonus: 4, + exactValues: (medication) => [normalizeSearchText(medication.name), normalizeSearchText(medication.slug)], + exactBonus: 10, + prefixValues: (medication) => [normalizeSearchText(medication.name), normalizeSearchText(medication.slug)], + prefixBonus: 5, + expandTokens: expansions.length ? (terms) => [...terms, ...expansions] : undefined, + limit, + tieBreak: (left, right) => left.name.localeCompare(right.name), + }).map(({ record, score, signals }) => ({ + medication: record, + score, + reasons: [ + signals.fields.name ? "name" : "", + signals.prefix ? "name prefix" : "", + signals.compact ? "exact name" : "", + signals.fields.taxonomy ? "class/category" : "", + signals.content ? "content" : "", + ].filter(Boolean), + })); +} + +export { medicationIdentityBadges } from "@/lib/medication-badges"; + +export function medicationDetailTiles(record: MedicationRecord) { + const usualDose = medicationUsualDose(record); + const ceiling = medicationCeiling(record); + const avoid = medicationAction(record); + return [ + { + label: "Prescribing answer", + value: medicationIndication(record).split(".")[0] ?? record.name, + meta: record.subclass || record.category, + }, + { + label: "Dosing", + value: usualDose, + meta: record.stats[0]?.label ?? "Usual dose", + }, + { + label: "Dose ceiling", + value: ceiling, + meta: "MAX", + }, + { + label: "Avoid", + value: avoid?.split(",")[0] ?? "Review contraindications", + meta: record.schedule === "S8" ? "Controlled" : "Safety", + danger: true, + }, + ]; +} diff --git a/src/lib/semantic-tone.ts b/src/lib/semantic-tone.ts new file mode 100644 index 0000000000..36ebd1d553 --- /dev/null +++ b/src/lib/semantic-tone.ts @@ -0,0 +1,98 @@ +// Canonical semantic tone system for badges, chips, and compact status labels. +// +// This is the single source of truth for the six clinical badge tones described +// in `docs/clinical-badge-system-guide.md`. It is intentionally framework-free +// (no JSX, no lucide runtime) so server code, the worker, and tests can import +// tone priority/meaning without pulling in the React render layer. The render +// layer (`src/components/clinical-dashboard/clinical-badge.tsx`) maps these tones +// to token classes and icons. + +export type SemanticTone = "neutral" | "clinical" | "success" | "warning" | "danger" | "info"; + +// Highest urgency first. Used to order badge clusters so the most important +// safety signal is never truncated away behind passive metadata. +export const SEMANTIC_TONE_PRIORITY: Record = { + danger: 6, + warning: 5, + clinical: 4, + success: 3, + neutral: 2, + info: 1, +}; + +// Tones in descending priority order (danger → info). Handy for legends/tests. +export const SEMANTIC_TONES: readonly SemanticTone[] = ["danger", "warning", "clinical", "success", "neutral", "info"]; + +// Semantic icon keys resolved to Lucide components at the render boundary. Kept +// as strings here so data modules (medication badges, the flag catalogue) can +// name an icon without depending on lucide-react. +export const SEMANTIC_ICON_KEYS = ["danger", "warning", "controlled"] as const; +export type SemanticIconKey = (typeof SEMANTIC_ICON_KEYS)[number]; + +export type SemanticToneMeta = { + /** Human-facing tone name for legends and docs. */ + label: string; + /** What the tone means / when to use it. */ + meaning: string; + /** + * Short prefix announced to assistive tech before the badge label so meaning + * survives without colour, e.g. "Do not use: Cr >120 avoid". Empty for tones + * whose label already reads plainly and carry no urgency. + */ + ariaPrefix: string; + /** + * Whether the tone renders a default status icon so it is distinguishable + * without colour (forced-colors / colour-blind). Only the two safety tones + * opt in; the rest stay quiet per the guide ("icon only when useful"). + */ + defaultIcon: boolean; +}; + +export const SEMANTIC_TONE_META: Record = { + danger: { + label: "Danger", + meaning: "Stop, avoid, contraindicated, failed, outdated, or unsafe.", + ariaPrefix: "Do not use", + defaultIcon: true, + }, + warning: { + label: "Warning", + meaning: "Pause, check, adjust, review, or interpret with caution.", + ariaPrefix: "Caution", + defaultIcon: true, + }, + clinical: { + label: "Clinical", + meaning: "A clinical action or instruction to carry out. Not a trust or safety signal.", + ariaPrefix: "", + defaultIcon: false, + }, + success: { + label: "Success", + meaning: "Confirmed, current, reviewed, available, or source-backed. Not clinical safety.", + ariaPrefix: "", + defaultIcon: false, + }, + neutral: { + label: "Neutral", + meaning: "Reference metadata or a passive fact that needs no action.", + ariaPrefix: "", + defaultIcon: false, + }, + info: { + label: "Info", + meaning: "System or process state. Rare in clinical content.", + ariaPrefix: "", + defaultIcon: false, + }, +}; + +/** + * Stable, priority-sorted copy (highest urgency first). Insertion order is + * preserved within a tone, matching the previous medication badge behaviour. + */ +export function sortBySemanticTonePriority(items: T[]): T[] { + return [...items].sort( + (left, right) => SEMANTIC_TONE_PRIORITY[right.tone ?? "neutral"] - SEMANTIC_TONE_PRIORITY[left.tone ?? "neutral"], + ); +} diff --git a/tests/catalog-search.test.ts b/tests/catalog-search.test.ts new file mode 100644 index 0000000000..c788eff509 --- /dev/null +++ b/tests/catalog-search.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { compactSearchText, normalizeSearchText, rankCatalogRecords } from "../src/lib/catalog-search"; + +type Item = { title: string; slug: string; tags: string[]; body: string }; + +const items: Item[] = [ + { title: "Clozapine Monitoring", slug: "clozapine-monitoring", tags: ["antipsychotic"], body: "ANC FBC thresholds" }, + { title: "Lithium Levels", slug: "lithium-levels", tags: ["mood stabiliser"], body: "Serum level monitoring" }, + { title: "Transfer Checklist", slug: "transfer-checklist", tags: ["transport"], body: "Receiving service details" }, +]; + +function rank(query: string, overrides: Partial>[2]> = {}) { + return rankCatalogRecords(items, query, { + fields: [ + { id: "title", weight: 6, text: (item) => normalizeSearchText(`${item.title} ${item.slug}`) }, + { id: "tags", weight: 3, text: (item) => normalizeSearchText(item.tags.join(" ")) }, + ], + fullText: (item) => normalizeSearchText(`${item.title} ${item.tags.join(" ")} ${item.body}`), + ...overrides, + }); +} + +describe("normalizeSearchText (shared)", () => { + it("keeps dose-string characters that the retired per-domain normalizers disagreed on", () => { + expect(normalizeSearchText("0.5mg IM/PO 5+5 co-located")).toBe("0.5mg im/po 5+5 co-located"); + }); + + it("strips diacritics and collapses punctuation to single spaces", () => { + expect(normalizeSearchText("Sérum; Lévels!")).toBe("serum levels"); + }); + + it("compacts whitespace for compact-query matching", () => { + expect(compactSearchText("clozapine monitoring")).toBe("clozapinemonitoring"); + }); +}); + +describe("rankCatalogRecords", () => { + it("returns nothing for an empty or whitespace query", () => { + expect(rank("")).toEqual([]); + expect(rank(" ")).toEqual([]); + }); + + it("weights field matches by their configured weight plus the content weight", () => { + const [top] = rank("clozapine"); + expect(top.record.slug).toBe("clozapine-monitoring"); + // title (6) + content (2) + whole-query phrase (4) — a single-term query IS its own + // phrase, matching the historical per-domain rankers. + expect(top.score).toBe(12); + expect(top.signals.fields.title).toBe(1); + expect(top.signals.content).toBe(1); + expect(top.signals.phrase).toBe(true); + }); + + it("drops records with no matching signal", () => { + const results = rank("clozapine"); + expect(results.some((match) => match.record.slug === "lithium-levels")).toBe(false); + }); + + it("applies the whole-phrase bonus on top of term matches", () => { + const [top] = rank("clozapine monitoring"); + // 2 title terms (12) + 2 content terms (4) + phrase (4). + expect(top.score).toBe(20); + expect(top.signals.phrase).toBe(true); + }); + + it("grants the exact bonus only on strict equality with a configured exact value", () => { + const options = { + exactValues: (item: Item) => [normalizeSearchText(item.title), normalizeSearchText(item.slug)], + exactBonus: 10, + }; + const exact = rank("clozapine monitoring", options)[0]; + const partial = rank("clozapine", options)[0]; + expect(exact.signals.exact).toBe(true); + expect(partial.signals.exact).toBe(false); + expect(exact.score).toBe(30); + }); + + it("grants the compact bonus when the de-spaced query appears in the compacted haystack", () => { + const [top] = rank("clozapinemonitoring", { compactBonus: 6 }); + expect(top.signals.compact).toBe(true); + // The de-spaced term matches nothing term-wise; only the compact bonus scores it, + // which is exactly how a run-together query survived in the historical rankers. + expect(top.score).toBe(6); + }); + + it("adds the broad-catalogue bonus to every record when a broad term is present", () => { + const results = rank("transport checklist", { broadTerms: ["transport"], broadBonus: 1 }); + expect(results[0].record.slug).toBe("transfer-checklist"); + for (const match of results) expect(match.signals.broad).toBe(true); + }); + + it("expands query terms through the expandTokens hook", () => { + const results = rank("cloz", { + expandTokens: (terms) => (terms.includes("cloz") ? [...terms, "clozapine"] : terms), + }); + expect(results[0].record.slug).toBe("clozapine-monitoring"); + }); + + it("breaks score ties by input order unless a tieBreak is supplied", () => { + const tied: Item[] = [ + { title: "Zeta Monitoring", slug: "zeta", tags: [], body: "" }, + { title: "Alpha Monitoring", slug: "alpha", tags: [], body: "" }, + ]; + const byInput = rankCatalogRecords(tied, "monitoring", { + fields: [{ id: "title", weight: 6, text: (item) => normalizeSearchText(item.title) }], + fullText: (item) => normalizeSearchText(item.title), + }); + expect(byInput.map((match) => match.record.slug)).toEqual(["zeta", "alpha"]); + + const byTitle = rankCatalogRecords(tied, "monitoring", { + fields: [{ id: "title", weight: 6, text: (item) => normalizeSearchText(item.title) }], + fullText: (item) => normalizeSearchText(item.title), + tieBreak: (left, right) => left.title.localeCompare(right.title), + }); + expect(byTitle.map((match) => match.record.slug)).toEqual(["alpha", "zeta"]); + }); + + it("applies the limit after ranking", () => { + const results = rank("monitoring checklist", { limit: 1 }); + expect(results).toHaveLength(1); + }); +}); diff --git a/tests/cross-mode-links.test.ts b/tests/cross-mode-links.test.ts new file mode 100644 index 0000000000..cc79b5c871 --- /dev/null +++ b/tests/cross-mode-links.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; + +import { crossModeDifferentialCatalog } from "@/lib/cross-mode-differentials"; +import { buildCrossModeLinks, buildCrossModeLinksForThread } from "@/lib/cross-mode-links"; +import { extractKeywordTerms, keywordQueryFromNaturalLanguage } from "@/lib/keyword-query"; +import { defaultMedicationRecords } from "@/lib/medication-fixtures"; +import type { ServiceRecord } from "@/lib/services"; + +const medications = defaultMedicationRecords(); +const differentials = crossModeDifferentialCatalog(); + +const homeTreatmentTeam: ServiceRecord = { + slug: "adult-home-treatment-team", + title: "Adult Home Treatment Team", + subtitle: "Intensive home-based acute care", + statusChips: [{ label: "Acute", tone: "info" }], + tags: ["home treatment"], +}; + +// Matches "adult" and "treatment" via tags only — must stay below the +// title-reason gate no matter how many tag/content points it accumulates. +const tagOnlyService: ServiceRecord = { + slug: "crisis-line", + title: "Crisis Line", + tags: ["adult", "treatment"], +}; + +describe("extractKeywordTerms", () => { + it("normalizes, strips stop words, and dedupes", () => { + expect(extractKeywordTerms("What is the max dose of clozapine?")).toEqual(["what", "max", "dose", "clozapine"]); + expect(extractKeywordTerms("dose dose DOSE")).toEqual(["dose"]); + expect(extractKeywordTerms("the of and to a is")).toEqual([]); + }); + + it("caps terms and keeps the legacy 7-term keyword query behavior", () => { + const long = Array.from({ length: 15 }, (_, index) => `token${index}`).join(" "); + expect(extractKeywordTerms(long)).toHaveLength(12); + expect(keywordQueryFromNaturalLanguage(long).split(" ")).toHaveLength(7); + }); +}); + +describe("buildCrossModeLinks", () => { + it("links a full question to the named medication", () => { + const links = buildCrossModeLinks("what is the max dose of clozapine", { medications }); + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ + modeId: "prescribing", + slug: "clozapine", + detailHref: "/medications/clozapine", + modeSearchQuery: "Clozapine", + }); + expect(links[0]!.modeLabel).toBe("Medication"); + expect(links[0]!.matchReason).toContain("name"); + }); + + it("returns nothing for question filler that only content-matches records", () => { + expect(buildCrossModeLinks("what is the maximum dose", { medications, differentials })).toEqual([]); + }); + + it("links services on title matches and rejects tag-only matches", () => { + const links = buildCrossModeLinks("how do I refer to the adult home treatment team", { + services: [homeTreatmentTeam, tagOnlyService], + }); + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ + modeId: "services", + slug: "adult-home-treatment-team", + detailHref: "/services/adult-home-treatment-team", + subtitle: "Intensive home-based acute care", + }); + expect(links[0]!.badges).toEqual([{ label: "Acute", tone: "info" }]); + expect(links[0]!.modeSearchHref).toContain("/services?"); + expect(links[0]!.modeSearchHref).toContain("run=1"); + }); + + it("links differentials via alias expansion", () => { + const links = buildCrossModeLinks("how do I manage an acutely psychotic patient", { differentials }); + expect(links).toHaveLength(1); + expect(links[0]!.modeId).toBe("differentials"); + expect(links[0]!.title.toLowerCase()).toMatch(/psychosis|psychotic/); + expect(links[0]!.detailHref).toMatch(/^\/differentials\/(diagnoses|presentations)\//); + }); + + it("does not surface differentials for queries that only name a medication", () => { + const links = buildCrossModeLinks("acamprosate renal dosing", { medications, differentials }); + expect(links.length).toBeGreaterThan(0); + expect(links.every((link) => link.modeId === "prescribing")).toBe(true); + }); + + it("caps per-mode and total results", () => { + const sleepClinic = (slug: string, title: string): ServiceRecord => ({ slug, title }); + const services = [ + sleepClinic("sleep-clinic-north", "Sleep Clinic North"), + sleepClinic("sleep-clinic-south", "Sleep Clinic South"), + sleepClinic("sleep-clinic-east", "Sleep Clinic East"), + ]; + const forms = [ + sleepClinic("sleep-referral-form", "Sleep Clinic Referral"), + sleepClinic("sleep-review-form", "Sleep Clinic Review"), + sleepClinic("sleep-audit-form", "Sleep Clinic Audit"), + ]; + + const links = buildCrossModeLinks("sleep clinic", { services, forms }); + expect(links).toHaveLength(4); + expect(links.filter((link) => link.modeId === "services")).toHaveLength(2); + expect(links.filter((link) => link.modeId === "forms")).toHaveLength(2); + + const capped = buildCrossModeLinks("sleep clinic", { services, forms }, { maxTotal: 3 }); + expect(capped).toHaveLength(3); + }); + + it("dedupes a slug shared between the services and forms registries", () => { + const shared: ServiceRecord = { slug: "shared-pathway", title: "Shared Pathway" }; + const links = buildCrossModeLinks("shared pathway", { services: [shared], forms: [shared] }); + expect(links).toHaveLength(1); + expect(links[0]!.modeId).toBe("services"); + }); + + it("keeps entity links alive across multiple entity-free follow-up turns", () => { + const thread = ["what is the max dose of clozapine", "what about renal impairment", "and in elderly patients"]; + const links = buildCrossModeLinksForThread(thread, { medications }); + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ modeId: "prescribing", slug: "clozapine" }); + }); + + it("prefers the newest turn that names an entity", () => { + const thread = ["what is the max dose of clozapine", "tell me about acamprosate"]; + const links = buildCrossModeLinksForThread(thread, { medications }); + expect(links).toHaveLength(1); + expect(links[0]!.slug).toBe("acamprosate"); + + expect(buildCrossModeLinksForThread([], { medications })).toEqual([]); + expect(buildCrossModeLinksForThread(["what about renal impairment", null], { medications })).toEqual([]); + }); + + it("returns nothing for empty or stop-word-only queries and empty catalogs", () => { + expect(buildCrossModeLinks("", { medications })).toEqual([]); + expect(buildCrossModeLinks("the of and", { medications })).toEqual([]); + expect(buildCrossModeLinks("clozapine dose", {})).toEqual([]); + }); +}); diff --git a/tests/medication-badges.test.ts b/tests/medication-badges.test.ts new file mode 100644 index 0000000000..447c3d0f6b --- /dev/null +++ b/tests/medication-badges.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; + +import { getMedicationRecord, loadMedicationSnapshot } from "@/lib/medication-snapshot"; +import { + medicationAccessBadges, + medicationIdentityBadges, + medicationRowBadges, + medicationStatTone, +} from "@/lib/medication-badges"; +import { deriveGovernanceFromSections } from "@/lib/medication-records"; +import type { MedicationRecord } from "@/lib/medications"; + +describe("medication badge mappers", () => { + const acamprosate = getMedicationRecord("acamprosate"); + if (!acamprosate) throw new Error("acamprosate fixture missing"); + + it("maps acamprosate identity badges from snapshot fields", () => { + const governance = deriveGovernanceFromSections(acamprosate); + const badges = medicationIdentityBadges(acamprosate, { + sourceStatus: governance.source_status, + validationStatus: governance.validation_status, + }); + const labels = badges.map((badge) => badge.label); + + expect(labels).toContain("AUD"); + expect(labels).toContain("S4"); + expect(labels).toContain("Campral"); + expect(labels).toContain("333 mg EC tablet"); + expect(labels).toContain("PBS streamlined"); + expect(labels).toContain("PBS"); + expect(labels).toContain("TGA"); + expect(labels).toContain("Reviewed"); + expect(badges.some((badge) => badge.label === "8357W")).toBe(true); + }); + + it("maps contra absolute row badges from patient metadata", () => { + const contraSection = acamprosate.sections.find((section) => section.type === "contra"); + const absoluteRow = contraSection?.rows.find((row) => row.key === "Absolute"); + expect(absoluteRow).toBeTruthy(); + + const badges = medicationRowBadges(absoluteRow!, "contra"); + expect(badges.some((badge) => badge.label === "Cr >120 avoid" || badge.label === "Renal")).toBe(true); + expect(badges.every((badge) => badge.tone === "danger" || badge.tone === "warning")).toBe(true); + }); + + it("maps dose renal impairment row badges", () => { + const doseSection = acamprosate.sections.find((section) => section.type === "dose"); + const renalRow = doseSection?.rows.find((row) => row.key === "Renal Impairment"); + expect(renalRow).toBeTruthy(); + + const badges = medicationRowBadges(renalRow!, "dose"); + expect(badges.some((badge) => badge.label === "Renal adjustment" || badge.label === "Contraindicated")).toBe(true); + }); + + it("maps risk gastrointestinal severity badge", () => { + const riskSection = acamprosate.sections.find((section) => section.type === "risk"); + const giRow = riskSection?.rows.find((row) => row.key === "Gastrointestinal"); + expect(giRow).toBeTruthy(); + + const badges = medicationRowBadges(giRow!, "risk"); + expect(badges.some((badge) => badge.label === "High")).toBe(true); + expect(badges.find((badge) => badge.label === "High")?.tone).toBe("warning"); + }); + + it("maps access badges for acamprosate", () => { + const badges = medicationAccessBadges(acamprosate); + expect(badges.some((badge) => badge.label === "Campral")).toBe(true); + expect(badges.some((badge) => badge.label.includes("8357W"))).toBe(true); + expect(badges.some((badge) => badge.label === "PBS streamlined")).toBe(true); + }); + + it("maps stat cls and flag to tones", () => { + const maxDose = acamprosate.stats.find((stat) => stat.label.includes("Max Dose")); + const renalAdj = acamprosate.stats.find((stat) => stat.label.includes("Renal")); + expect(maxDose).toBeTruthy(); + expect(renalAdj).toBeTruthy(); + expect(medicationStatTone(maxDose!)).toBe("danger"); + expect(medicationStatTone(renalAdj!)).toBe("warning"); + }); + + it("keeps badge lists stable across the full snapshot corpus", () => { + const records = loadMedicationSnapshot(); + + for (const record of records) { + const governance = deriveGovernanceFromSections(record); + const identityBadges = medicationIdentityBadges(record, { + sourceStatus: governance.source_status, + validationStatus: governance.validation_status, + }); + + expect(identityBadges.length).toBeLessThanOrEqual(12); + expect(new Set(identityBadges.map((badge) => badge.id)).size).toBe(identityBadges.length); + + for (const section of record.sections) { + for (const row of section.rows) { + const rowBadges = medicationRowBadges(row, section.type); + expect(rowBadges.length).toBeLessThanOrEqual(4); + expect(new Set(rowBadges.map((badge) => badge.id)).size).toBe(rowBadges.length); + } + } + } + }); +}); + +describe("medications catalogue regression", () => { + it("exposes PBS streamlined on acamprosate identity badges", () => { + const record = getMedicationRecord("acamprosate"); + expect(record).toBeTruthy(); + const badges = medicationIdentityBadges(record!); + expect(badges.some((badge) => badge.label === "PBS streamlined")).toBe(true); + }); +}); + +describe("controlled-drug (S8) schedule badge", () => { + const baseRecord: MedicationRecord = { + slug: "test-schedule", + name: "Test Schedule", + class: "", + subclass: "", + category: "", + accent: "#0f766e", + tag: "", + schedule: "S8", + stats: [], + sections: [], + quick: [], + }; + + it("shows S8 as a controlled warning with a lock icon, never danger", () => { + const badges = medicationIdentityBadges(baseRecord); + const scheduleBadge = badges.find((badge) => badge.label === "S8"); + expect(scheduleBadge).toBeTruthy(); + expect(scheduleBadge?.tone).toBe("warning"); + expect(scheduleBadge?.iconKey).toBe("controlled"); + // Regulatory scheduling must not consume the danger tone reserved for stops. + expect(badges.every((badge) => badge.tone !== "danger")).toBe(true); + }); + + it("keeps non-S8 schedules as plain info metadata", () => { + const badges = medicationIdentityBadges({ ...baseRecord, schedule: "S4" }); + const scheduleBadge = badges.find((badge) => badge.label === "S4"); + expect(scheduleBadge?.tone).toBe("info"); + expect(scheduleBadge?.iconKey).toBeUndefined(); + }); +}); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 4b9f0c788a..4dc11cfaf1 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1,6 +1,9 @@ import type { Route } from "playwright-core"; import { expect, test, type Locator, type Page } from "playwright/test"; import { demoAnswer, demoDocuments, getDemoDocument, getDemoDocumentPayload } from "../src/lib/demo-data"; +import { deriveGovernanceFromSections } from "../src/lib/medication-records"; +import { getMedicationRecord, loadMedicationSnapshot } from "../src/lib/medication-snapshot"; +import { medicationToSearchResult, rankMedicationRecords } from "../src/lib/medications"; const dashboardViewports = [ { name: "small-mobile", width: 320, height: 720 }, @@ -196,6 +199,48 @@ async function mockDemoApi(page: Page, options: { answerOverride?: DemoAnswerOve }, }); }); + await page.route(/\/api\/medications(?:\/([^/?]+))?(?:\?.*)?$/, async (route) => { + const url = new URL(route.request().url()); + const slug = url.pathname.match(/\/api\/medications\/([^/]+)$/)?.[1]; + if (slug) { + const record = getMedicationRecord(decodeURIComponent(slug)); + if (!record) { + await route.fulfill({ status: 404, json: { error: `No medication found for "${slug}".` } }); + return; + } + const governance = deriveGovernanceFromSections(record); + await route.fulfill({ + json: { + record, + governance: { + sourceStatus: governance.source_status, + validationStatus: governance.validation_status, + }, + demoMode: true, + }, + }); + return; + } + + const query = url.searchParams.get("q")?.trim() || undefined; + const limit = Number(url.searchParams.get("limit") ?? "50"); + const records = loadMedicationSnapshot(); + const matches = query ? rankMedicationRecords(records, query, limit) : undefined; + await route.fulfill({ + json: { + records, + matches: matches?.map((match) => ({ + medication: match.medication, + result: medicationToSearchResult(match), + score: match.score, + reasons: match.reasons, + })), + total: records.length, + governance: {}, + demoMode: true, + }, + }); + }); await page.route(/\/api\/ingestion\/jobs(?:\?.*)?$/, async (route) => { await route.fulfill({ json: { jobs: [], demoMode: true } }); }); @@ -1159,6 +1204,40 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); + test("answer results surface cross-mode quick links", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockDemoApi(page); + const question = "What is the maximum dose of clozapine?"; + await page.goto(`/?mode=answer&q=${encodeURIComponent(question)}&run=1`, { + waitUntil: "domcontentloaded", + }); + await expect(page.getByTestId("plain-answer-response")).toBeVisible({ timeout: uiAssertionTimeoutMs }); + + await expect(page.getByTestId("cross-mode-links")).toHaveCount(1, { timeout: 15_000 }); + + const answerSurface = page.locator('[data-dashboard-stage="answer-surface"]'); + const strip = answerSurface.getByTestId("cross-mode-links"); + await expect(strip).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(strip.getByText("Medication", { exact: true })).toBeVisible(); + await expect(strip.getByRole("button", { name: "Search Clozapine in Medication" })).toBeVisible(); + + const followUps = answerSurface.getByTestId("answer-follow-up-suggestions"); + if (await followUps.isVisible()) { + const stripBox = await strip.boundingBox(); + const followUpBox = await followUps.boundingBox(); + expect(stripBox).toBeTruthy(); + expect(followUpBox).toBeTruthy(); + expect(stripBox!.y).toBeLessThan(followUpBox!.y); + } + + const medicationLink = strip.getByRole("link", { name: "Clozapine", exact: true }); + await expect(medicationLink).toHaveAttribute("href", "/medications/clozapine"); + await medicationLink.click(); + await expect(page).toHaveURL(/\/medications\/clozapine/, { timeout: 15_000 }); + await expectNoPageHorizontalOverflow(page); + }); + test("answer mode keeps prior turns visible for follow-up questions", async ({ page }) => { await page.setViewportSize({ width: 390, height: 820 }); await mockDemoApi(page); From d0b1be72f847c83a61788147cebfb04a403cf108 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 03:57:30 +0000 Subject: [PATCH 43/49] docs: refresh site map after cross-mode and medications routes Co-authored-by: BigSimmo --- docs/site-map.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/site-map.md b/docs/site-map.md index 6da7971140..70570e79d3 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -154,6 +154,8 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/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/medications` - Route discovered from app directory Source: `src/app/api/medications/route.ts`. +- `/api/medications/[slug]` - Route discovered from app directory Source: `src/app/api/medications/[slug]/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`. From 0a353d39ecb585f0a35e37af2bd38aa816f5f075 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:01:34 +0800 Subject: [PATCH 44/49] fix(ui): top-align favourites/tools on mobile mode homes (#422) --- src/components/ClinicalDashboard.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 83a39421f3..c023ec9db8 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -4539,7 +4539,10 @@ export function ClinicalDashboard({
Date: Thu, 9 Jul 2026 04:59:29 +0000 Subject: [PATCH 45/49] fix: resolve all merge conflicts with origin/main --- .../skills/cursor-codebase-indexing/SKILL.md | 27 - .env.example | 6 - .gitignore | 3 - README.md | 3 - docs/archive/COLOR_REDESIGN_PLAN.md | 4 - docs/codebase-index.md | 187 -- docs/site-map.md | 11 - docs/supabase-migration-reconciliation.md | 20 - public/llms.txt | 4 - scripts/generate-site-map.ts | 4 - src/app/api/answer/route.ts | 20 - src/app/api/answer/stream/route.ts | 14 - src/app/api/medications/[slug]/route.ts | 23 - src/app/api/medications/route.ts | 25 - src/app/api/search/route.ts | 23 - src/app/favourites/page.tsx | 4 - src/app/globals.css | 45 - src/app/home-page-client.tsx | 10 - .../mockups/answer-evidence-popups/page.tsx | 15 - src/app/mockups/mockups-layout-client.tsx | 7 - src/app/page.tsx | 6 - src/components/ClinicalDashboard.tsx | 1770 ----------------- src/components/applications-launcher-page.tsx | 57 - src/components/client-hydration-boundary.tsx | 11 - .../clinical-dashboard/ClinicalSidebar.tsx | 16 - .../clinical-dashboard/answer-content.tsx | 56 - .../answer-follow-up-suggestions.tsx | 45 - .../answer-result-surface.tsx | 70 - .../clinical-dashboard/cross-mode-links.tsx | 74 - .../clinical-dashboard/differentials-home.tsx | 98 - .../clinical-dashboard/document-results.tsx | 9 - .../document-search-results.tsx | 29 - .../clinical-dashboard/evidence-panels.tsx | 49 - .../favourites-command-library-page.tsx | 225 --- .../clinical-dashboard/favourites-hub.tsx | 14 - .../favourites-library-nav.tsx | 55 - .../global-mockup-search-shell.tsx | 87 - .../master-search-header.tsx | 292 --- .../medication-prescribing-workspace.tsx | 286 --- .../clinical-dashboard/mode-action-popup.tsx | 32 - .../search-results-header-band.tsx | 12 - .../search-results-layout.tsx | 14 - .../universal-search-command-surface.tsx | 230 --- .../clinical-dashboard/use-hide-on-scroll.ts | 65 - .../clinical-dashboard/visual-evidence.tsx | 4 - .../forms/forms-search-results-page.tsx | 113 +- .../master-document-flow-mockups.tsx | 21 - src/components/mode-home-template.tsx | 43 - .../services/services-navigator-page.tsx | 107 - .../tools-page-mockups/tool-fixtures.ts | 9 - src/components/ui-primitives.tsx | 28 - .../universal-search-command-mockups.tsx | 233 --- .../universal-search-redesign-mockups.tsx | 101 - src/lib/api-rate-limit.ts | 12 - src/lib/cross-mode-differentials.ts | 39 - src/lib/medication-fixtures.ts | 6 - src/lib/medication-records.ts | 16 - src/lib/medication-seed.ts | 6 - src/lib/public-api-access.ts | 9 - src/lib/search-command-surface.ts | 10 - src/lib/supabase/auth.ts | 14 - src/proxy.ts | 17 - tests/answer-follow-up.test.ts | 20 - tests/private-access-routes.test.ts | 6 - tests/private-rag-access.test.ts | 3 - tests/rendered-text-formatting.test.ts | 4 - tests/ui-smoke.spec.ts | 60 - tests/ui-stress.spec.ts | 13 - tests/ui-tools.spec.ts | 146 -- 69 files changed, 1 insertion(+), 5096 deletions(-) diff --git a/.cursor/skills/cursor-codebase-indexing/SKILL.md b/.cursor/skills/cursor-codebase-indexing/SKILL.md index bae6ae1b23..68bc3c8d90 100644 --- a/.cursor/skills/cursor-codebase-indexing/SKILL.md +++ b/.cursor/skills/cursor-codebase-indexing/SKILL.md @@ -14,20 +14,12 @@ version: 1.0.0 license: MIT author: Jeremy Longshore tags: -<<<<<<< HEAD -- saas -- cursor -- cursor-codebase -compatibility: Designed for Claude Code, also compatible with Codex and OpenClaw ---- -======= - saas - cursor - cursor-codebase compatibility: Designed for Claude Code, also compatible with Codex and OpenClaw --- ->>>>>>> origin/main # 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. @@ -156,21 +148,12 @@ Ask semantic questions about your entire codebase: ### @Codebase vs @Files vs Text Search -<<<<<<< HEAD -| 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) | -======= | 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) | ->>>>>>> origin/main Use `@Codebase` for discovery, then switch to `@Files` once you know where the code lives. @@ -236,15 +219,6 @@ sudo sysctl -p ## Troubleshooting -<<<<<<< HEAD -| 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 | -======= | Symptom | Cause | Fix | | ---------------------------- | ----------------------------------- | -------------------------------- | | @Codebase returns no results | Index not built | Wait for "Indexed" in status bar | @@ -252,7 +226,6 @@ sudo sysctl -p | 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 | ->>>>>>> origin/main ## Resources diff --git a/.env.example b/.env.example index d35c87db63..cdf6c9c015 100644 --- a/.env.example +++ b/.env.example @@ -6,12 +6,9 @@ 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 -<<<<<<< HEAD -======= # Direct Postgres connection string for scripts/migrations that need SQL access. # Server-only; never expose in client code or NEXT_PUBLIC_ vars. SUPABASE_DB_URL=postgresql://postgres:password@db.sjrfecxgysukkwxsowpy.supabase.co:5432/postgres ->>>>>>> origin/main # 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 @@ -80,12 +77,9 @@ 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 -<<<<<<< HEAD -======= # Design-exploration mockup routes (/mockups/*) 404 in production builds unless # explicitly opted in. Always reachable in dev/test. #NEXT_PUBLIC_MOCKUPS_ENABLED=false ->>>>>>> origin/main # 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). diff --git a/.gitignore b/.gitignore index df1d1385bb..805ef4bdfc 100644 --- a/.gitignore +++ b/.gitignore @@ -69,11 +69,8 @@ next-env.d.ts # agent/QA artifacts .codex-screenshots/ -<<<<<<< HEAD -======= /worktrees/ .worktrees/ ->>>>>>> origin/main /docs/mockups/ # design/UX review scratch dumps (favourites-review, tools-page-review, etc.) — never commit artifacts/ diff --git a/README.md b/README.md index 0ebdaaf5fa..a1da1c1154 100644 --- a/README.md +++ b/README.md @@ -127,8 +127,6 @@ set in `.env.local`. and TGA Software as a Medical Device screening where applicable. - See `docs/clinical-governance.md` for the deployment governance checklist. -<<<<<<< HEAD -======= ## Cursor Supabase MCP This repo ships workspace Supabase MCP config in `.cursor/mcp.json` and agent @@ -162,7 +160,6 @@ a fresh agent session. Never put `SUPABASE_SERVICE_ROLE_KEY` or other secrets into MCP config. The hosted Supabase MCP server uses OAuth, not repo secrets. ->>>>>>> origin/main ## Documentation - `docs/process-hardening.md` — verification gates, CI expectations, known limits diff --git a/docs/archive/COLOR_REDESIGN_PLAN.md b/docs/archive/COLOR_REDESIGN_PLAN.md index 553d79a327..a25d91a3fe 100644 --- a/docs/archive/COLOR_REDESIGN_PLAN.md +++ b/docs/archive/COLOR_REDESIGN_PLAN.md @@ -1,9 +1,5 @@ > **SUPERSEDED — historical exploration only.** Do not implement from this file. -<<<<<<< HEAD:COLOR_REDESIGN_PLAN.md -> Active design direction: [`docs/redesign/02-design-direction.md`](docs/redesign/02-design-direction.md) -======= > Active design direction: [`docs/redesign/02-design-direction.md`](../redesign/02-design-direction.md) ->>>>>>> origin/main:docs/archive/COLOR_REDESIGN_PLAN.md > (Clinical White / Aegean Graphite). # Luxury Black-First Color Redesign Plan (Global UI Polish) diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 5dfb8a84ad..bc47dabb47 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -9,15 +9,6 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map ## Quick start -<<<<<<< HEAD -| 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` | -======= | Step | Command | | --------------------------------- | -------------------------------- | | Confirm Supabase target | `npm run check:supabase-project` | @@ -25,24 +16,11 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map | Start ingestion worker | `npm run worker` | | Cheap verification gate | `npm run verify:cheap` | | UI verification gate | `npm run verify:ui` | ->>>>>>> origin/main --- ## Top-level layout -<<<<<<< HEAD -| 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) | -======= | Path | Purpose | | ----------- | ---------------------------------------------------------------- | | `src/` | Next.js App Router UI, API routes, shared lib, components | @@ -53,7 +31,6 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map | `docs/` | Runbooks, governance, search/RAG plans, generated sitemap | | `public/` | Static assets (`public/llms.txt`) | | `.github/` | CI workflows, PR template (clinical governance preflight) | ->>>>>>> origin/main **Do not commit:** `.next/`, `node_modules/`, `coverage/`, `.env*`, `sample-documents/`, logs. @@ -71,34 +48,6 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map ### Product pages (`src/app/`) -<<<<<<< HEAD -| 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/` | -======= | Route | File | | ---------------------------------------------------- | -------------------------------------- | | `/` | `src/app/page.tsx` | @@ -125,7 +74,6 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map | 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/` | ->>>>>>> origin/main --- @@ -133,59 +81,6 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map ### RAG, retrieval, answers -<<<<<<< HEAD -| 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 | -======= | Module | Role | | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | | `rag.ts` | Main answer pipeline orchestrator | @@ -237,7 +132,6 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map | `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 | ->>>>>>> origin/main --- @@ -258,17 +152,6 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map ### Migration themes -<<<<<<< HEAD -| 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` | -======= | Theme | Examples | | --------------------------------- | --------------------------------------------------------------------------------------------------------------- | | Bulk ingestion and job queue | `20260527000000_bulk_ingestion.sql`, `20260616001000_ingestion_job_state_rpcs.sql` | @@ -278,7 +161,6 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map | 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` | ->>>>>>> origin/main ### Key RPCs @@ -289,13 +171,8 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map ### Edge Functions -<<<<<<< HEAD -| Function | Path | -|----------|------| -======= | Function | Path | | ----------------- | ----------------------------------------------- | ->>>>>>> origin/main | 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`. @@ -304,16 +181,6 @@ Cron-triggered agent for indexing v3 completion gates. Auth via `INDEXING_V3_AGE ## Worker (`worker/`) -<<<<<<< HEAD -| 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) | -======= | File | Role | | ------------------------------ | ------------------------------------------------------------------------ | | `index.ts` | Bootstrap → `main.ts` | @@ -322,7 +189,6 @@ Cron-triggered agent for indexing v3 completion gates. Auth via `INDEXING_V3_AGE | `table-facts.ts` | Table fact extraction | | `prerequisites.ts` | Python/PDF OCR checks | | `python/extract_pdf_assets.py` | PDF asset extraction (PyMuPDF/Tesseract) | ->>>>>>> origin/main **Flow:** Upload → Storage + job queue → worker parses (PDF/DOCX/XLSX/TXT) → OCR fallback → image captioning → chunking → OpenAI embeddings → pgvector. @@ -332,16 +198,6 @@ Cron-triggered agent for indexing v3 completion gates. Auth via `INDEXING_V3_AGE ## Scripts (grouped) -<<<<<<< HEAD -| 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` | -======= | Group | Key scripts | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | Dev/server | `ensure-local-server.mjs`, `dev-free-port.mjs`, `check-runtime.ts` | @@ -350,7 +206,6 @@ Cron-triggered agent for indexing v3 completion gates. Auth via `INDEXING_V3_AGE | 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` | ->>>>>>> origin/main Golden retrieval fixture: `scripts/fixtures/rag-retrieval-golden.json` @@ -358,19 +213,11 @@ Golden retrieval fixture: `scripts/fixtures/rag-retrieval-golden.json` ## Tests -<<<<<<< HEAD -| 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` | -======= | 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` | ->>>>>>> origin/main **Domain clusters in `tests/`:** RAG/answers, retrieval, ingestion/indexing, source governance, API routes, Supabase schema, shell/routing, UI formatting guards. @@ -403,8 +250,6 @@ Golden retrieval fixture: `scripts/fixtures/rag-retrieval-golden.json` - Registry modes: services, forms, medications, differentials - Demo mode: synthetic data when Supabase unavailable (`demo-data.ts`, `isDemoMode()` in `env.ts`) -<<<<<<< HEAD -======= ### Global search composer placement rules One shared composer (`master-search-header.tsx`) serves every mode. Placement: @@ -415,26 +260,10 @@ One shared composer (`master-search-header.tsx`) serves every mode. Placement: - **Intentionally composer-free routes**: `/differentials/presentations/*` (comparison workflow owns its chrome), `/documents/[id]` viewer (has its own in-document ask composer), `/documents/source/*` (document flow owns mobile chrome). Do not re-flag these in search-consistency audits. - **Local filter fields** (sidebar "Search chats", document drawer "Find a document"/"Find a source PDF") are scoped filters, not global search; they share the `fieldControlWithIcon`/`fieldIcon` primitives. ->>>>>>> origin/main --- ## Key config files -<<<<<<< HEAD -| 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 | -======= | File | Role | | ----------------------------------- | ------------------------------------------- | | `package.json` | Scripts, deps, Node 24 / npm 11 | @@ -448,26 +277,11 @@ One shared composer (`master-search-header.tsx`) serves every mode. Placement: | `docs/clinical-governance.md` | Clinical safety governance | | `docs/reindex-runbook.md` | Reindex operations | | `docs/retrieval-quality-runbook.md` | Retrieval tuning | ->>>>>>> origin/main --- ## Related docs -<<<<<<< HEAD -| 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.* -======= | Topic | Doc | | ----------------------- | --------------------------------------------- | | Routes and modes | `docs/site-map.md` | @@ -480,4 +294,3 @@ One shared composer (`master-search-header.tsx`) serves every mode. Placement: --- _Generated for agent onboarding. Update when adding major modules, API surfaces, or migration themes._ ->>>>>>> origin/main diff --git a/docs/site-map.md b/docs/site-map.md index 67bb5451da..6012d1938f 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -9,10 +9,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/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`. -<<<<<<< HEAD -======= - `/differentials/presentations/[slug]` - Route discovered from app directory Source: `src/app/differentials/presentations/[slug]/page.tsx`. ->>>>>>> origin/main - `/documents/search` - Documents search command centre. Source: `src/app/documents/search/page.tsx`. - `/documents/source` - Master document reader with demo PDF content and evidence navigation. Source: `src/app/documents/source/page.tsx`. - `/documents/source/evidence` - Evidence detail page for document flow. Source: `src/app/documents/source/evidence/page.tsx`. @@ -44,11 +41,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | 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. | -<<<<<<< HEAD -| Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | `/applications` launcher and tool detail panels inside tools mode. | -======= | Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | Tool launcher and detail panels inside dashboard tools mode (`/?mode=tools`). | ->>>>>>> origin/main ## Documents flow index @@ -610,9 +603,5 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | Differentials | `src/app/differentials, src/lib/differentials.ts` | | Medications | `src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx` | | Documents | `src/app/documents, src/lib/document-flow-routes.ts` | -<<<<<<< HEAD -| Applications and tools | `src/app/applications, src/components/applications-launcher-page.tsx` | -======= | Tools | `src/components/applications-launcher-page.tsx` | ->>>>>>> origin/main | Mockups | `src/app/mockups` | diff --git a/docs/supabase-migration-reconciliation.md b/docs/supabase-migration-reconciliation.md index 57e21d8491..5d3b675c52 100644 --- a/docs/supabase-migration-reconciliation.md +++ b/docs/supabase-migration-reconciliation.md @@ -1,10 +1,6 @@ # Supabase Migration Reconciliation -<<<<<<< HEAD -Last reviewed: 2026-07-04 -======= Last reviewed: 2026-07-07 ->>>>>>> origin/main Target project: Clinical KB Database (`sjrfecxgysukkwxsowpy`) @@ -15,11 +11,6 @@ Target project: Clinical KB Database (`sjrfecxgysukkwxsowpy`) - Use `supabase migration repair --linked --status applied ` only when live database evidence proves the migration effect already exists. - 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. -<<<<<<< HEAD - -## Verified Applied (through June 2026) - -======= - **History presence is not effect presence.** `20260703030000` is recorded as applied on live while its index changes are absent. After every apply, verify object state with `npm run check:drift` (and `search_schema_health()`), not the history table. - Any PR that changes `supabase/schema.sql` regenerates `supabase/drift-manifest.json` in the same PR (`npm run drift:manifest`, Docker required); `tests/drift-detection.test.ts` fails otherwise. This doubles as a from-scratch replay proof of schema.sql. @@ -79,7 +70,6 @@ eval:retrieval:quality` per the standing merge gate. ## Verified Applied (through June 2026) ->>>>>>> origin/main 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. @@ -93,9 +83,6 @@ These previously local-only versions were verified in the live project history b ## Current Status (July 2026) -<<<<<<< HEAD -The repo now includes additional July 2026 migrations beyond the June checkpoint above, including: -======= Migration `20260705230000_reconcile_live_database_drift.sql` codifies live-only drift discovered 2026-07-05: - `indexing_v3_agent_jobs` table and claim/update RPCs (recorded as applied in history but absent on live at inspection time) @@ -105,7 +92,6 @@ Migration `20260705230000_reconcile_live_database_drift.sql` codifies live-only `supabase/schema.sql` has been reconciled to match. Apply the migration through the normal linked workflow when ready; do not use raw dashboard SQL for retrieval RPCs. The repo also includes additional July 2026 migrations beyond the June checkpoint above, including: ->>>>>>> origin/main - Retrieval RPC codification and hybrid execution smoke (`20260701140631`, related July 1 fixes) - Legacy vector index drops and `search_schema_health()` reconciliation (`20260702014803`, `20260702021604`) @@ -115,8 +101,6 @@ The repo also includes additional July 2026 migrations beyond the June checkpoin 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. -<<<<<<< HEAD -======= **2026-07-07 full-inventory audit:** the standing drift check ([database-drift-detection.md](database-drift-detection.md)) measured live against both repo lineages. Pending on live as of the audit: `20260705210000` @@ -128,14 +112,11 @@ its statements under a new version, with approval. The complete reconciliation backlog (index estate, grant posture, remaining live-only functions) lives in the drift doc. ->>>>>>> origin/main 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. -<<<<<<< HEAD -======= ## Supabase Preview / fresh replay rules @@ -146,7 +127,6 @@ GitHub Supabase Preview replays the full migration chain on branch databases. Ke - Duplicate migration stems that already ran on live should be neutralized as documented no-ops rather than re-appplied. Regression tests for these guards live in `tests/supabase-schema.test.ts` under "Supabase Preview replay guards". ->>>>>>> origin/main ## Verification Commands diff --git a/public/llms.txt b/public/llms.txt index 449f8452a0..a908f6dc0f 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -5,11 +5,7 @@ Purpose: Clinical Guide is a local clinical knowledge-base interface for searchi Agent / codebase orientation: docs/codebase-index.md (module map, APIs, Supabase, worker). Route index: docs/site-map.md. Key routes: -<<<<<<< HEAD -- / opens the main dashboard. Use ?mode=answer, ?mode=documents, ?mode=tools, ?mode=favourites, ?mode=differentials, or ?mode=prescribing to choose the workspace. -======= - / opens the main dashboard. Use ?mode=answer, ?mode=documents, ?mode=tools, ?mode=differentials, or ?mode=prescribing to choose the workspace. ?mode=favourites redirects to /favourites. ->>>>>>> origin/main - /documents/search opens the documents search command centre after submitting a documents-mode query. - /documents/:id opens an indexed source document. - /services opens source-backed service records. diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index e8c98ee033..3e60cb961e 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -96,11 +96,7 @@ const routeOwnershipRows = [ ["Differentials", "src/app/differentials, src/lib/differentials.ts"], ["Medications", "src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx"], ["Documents", "src/app/documents, src/lib/document-flow-routes.ts"], -<<<<<<< HEAD - ["Applications and tools", "src/app/applications, src/components/applications-launcher-page.tsx"], -======= ["Tools", "src/components/applications-launcher-page.tsx"], ->>>>>>> origin/main ["Mockups", "src/app/mockups"], ] as const; diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 27848ec53a..3dc0f25bd2 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -4,15 +4,11 @@ import { demoAnswer } from "@/lib/demo-data"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { answerQuestionWithScope } from "@/lib/rag"; import { jsonError, PublicApiError } from "@/lib/http"; -<<<<<<< HEAD -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; -======= import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse, } from "@/lib/api-rate-limit"; ->>>>>>> origin/main import { publicAccessContext } from "@/lib/public-api-access"; import { classifyRagQuery } from "@/lib/clinical-search"; import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; @@ -83,10 +79,7 @@ export async function POST(request: Request) { const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); -<<<<<<< HEAD -======= const publicOnly = !access.authenticated && !isLocalNoAuthMode(); ->>>>>>> origin/main const rateLimit = await consumeSubjectApiRateLimit({ supabase, @@ -101,14 +94,9 @@ export async function POST(request: Request) { const scope = await resolveSearchScope({ supabase, ownerId: access.ownerId, -<<<<<<< HEAD - documentIds: body.documentIds ?? (body.documentId ? [body.documentId] : undefined), - filters: body.filters, -======= publicOnly, documentIds: answerBody.documentIds ?? (answerBody.documentId ? [answerBody.documentId] : undefined), filters: answerBody.filters, ->>>>>>> origin/main }); if (scope.documentIds?.length === 0) { return NextResponse.json({ @@ -132,13 +120,6 @@ export async function POST(request: Request) { documentId: singleDocumentScope ? answerBody.documentId : undefined, documentIds: singleDocumentScope ? undefined -<<<<<<< HEAD - : (scope.documentIds ?? body.documentIds ?? (body.documentId ? [body.documentId] : undefined)), - ownerId: access.ownerId, - allowGlobalSearch: !access.ownerId, - queryMode: body.queryMode, - skipCache: body.skipCache, -======= : (scope.documentIds ?? answerBody.documentIds ?? (answerBody.documentId ? [answerBody.documentId] : undefined)), @@ -146,7 +127,6 @@ export async function POST(request: Request) { allowGlobalSearch: !access.ownerId, queryMode: answerBody.queryMode, skipCache: answerBody.skipCache, ->>>>>>> origin/main signal: request.signal, }); const warnings = sourceGovernanceWarnings({ diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index da57f9f8a2..ac64e8b276 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -2,15 +2,11 @@ import { z } from "zod"; import { demoAnswer } from "@/lib/demo-data"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { PublicApiError, jsonError } from "@/lib/http"; -<<<<<<< HEAD -import { consumeSubjectApiRateLimit, type ApiRateLimitResult } from "@/lib/api-rate-limit"; -======= import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, type ApiRateLimitResult, } from "@/lib/api-rate-limit"; ->>>>>>> origin/main import { publicAccessContext } from "@/lib/public-api-access"; import { answerQuestionWithScope, type AnswerProgressEvent } from "@/lib/rag"; import { classifyRagQuery } from "@/lib/clinical-search"; @@ -24,11 +20,8 @@ import { sourceGovernanceWarnings, } from "@/lib/source-governance"; import { createAdminClient } from "@/lib/supabase/admin"; -<<<<<<< HEAD -======= import { logAnswerDiagnostics } from "@/lib/answer-telemetry"; import { isSupabaseApiKeyConfigurationError, nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors"; ->>>>>>> origin/main import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { logger } from "@/lib/logger"; import { parseJsonBody } from "@/lib/validation/body"; @@ -270,10 +263,7 @@ export async function POST(request: Request) { const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); -<<<<<<< HEAD -======= const publicOnly = !access.authenticated && !isLocalNoAuthMode(); ->>>>>>> origin/main const rateLimit = await consumeSubjectApiRateLimit({ supabase, @@ -283,11 +273,7 @@ export async function POST(request: Request) { }); if (rateLimit.limited) return rateLimitStream(rateLimit); -<<<<<<< HEAD - return streamAnswer(body, access.ownerId, request.signal); -======= return streamAnswer(body, access.ownerId, request.signal, publicOnly); ->>>>>>> origin/main } catch (error) { if (error instanceof AuthenticationError) { return unauthorizedResponse(error); diff --git a/src/app/api/medications/[slug]/route.ts b/src/app/api/medications/[slug]/route.ts index 9ceff019f8..83ba38e405 100644 --- a/src/app/api/medications/[slug]/route.ts +++ b/src/app/api/medications/[slug]/route.ts @@ -1,11 +1,5 @@ import { NextResponse } from "next/server"; -<<<<<<< HEAD -import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; -import { jsonError } from "@/lib/http"; -import { getMedicationRecord } from "@/lib/medication-snapshot"; -import { deriveGovernanceFromSections, normalizeMedicationSlug } from "@/lib/medication-records"; -======= import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, @@ -25,7 +19,6 @@ import { import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; ->>>>>>> origin/main export const runtime = "nodejs"; @@ -53,21 +46,6 @@ function publicMedicationDetailPayload(slug: string) { }; } -<<<<<<< HEAD -export async function GET(_request: Request, context: { params: Promise<{ slug: string }> }) { - try { - const { slug } = await context.params; - const normalizedSlug = normalizeMedicationSlug(slug); - const payload = publicMedicationDetailPayload(normalizedSlug); - if (!payload) return notFoundResponse(normalizedSlug); - - return medicationResponse({ - ...payload, - demoMode: isDemoMode() || isLocalNoAuthMode(), - publicAccess: true, - }); - } catch (error) { -======= export async function GET(request: Request, context: { params: Promise<{ slug: string }> }) { try { const { slug } = await context.params; @@ -150,7 +128,6 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (error instanceof AuthenticationError) { return unauthorizedResponse(); } ->>>>>>> origin/main return jsonError(error); } } diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index deb5e90cf0..cb996d8fa3 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -1,12 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -<<<<<<< HEAD -import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; -import { jsonError } from "@/lib/http"; -import { defaultMedicationRecords } from "@/lib/medication-seed"; -import { medicationSourceStatus, medicationValidationStatus } from "@/lib/medication-records"; -======= import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, @@ -21,28 +15,21 @@ import { rowGovernance, rowToMedicationRecord, } from "@/lib/medication-records"; ->>>>>>> origin/main import { medicationToSearchResult, rankMedicationRecords, type MedicationRecord, type MedicationSearchMatch, } from "@/lib/medications"; -<<<<<<< HEAD -======= import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; ->>>>>>> origin/main import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; export const runtime = "nodejs"; -<<<<<<< HEAD -======= const MEDICATION_MAX_RECORDS = 500; ->>>>>>> origin/main const medicationListQuerySchema = z.object({ q: z .string() @@ -54,13 +41,10 @@ const medicationListQuerySchema = z.object({ fields: z.enum(["index"]).optional(), }); -<<<<<<< HEAD -======= // `fields=index` strips the heavy per-record content (stats/sections/quick are // ~99% of the ~3.4 MB catalog) for callers that only need identity-level // ranking, e.g. the answer surface's cross-mode links. The records keep the // full MedicationRecord shape so rankers and badge helpers work unchanged. ->>>>>>> origin/main function toIndexRecords(records: MedicationRecord[]): MedicationRecord[] { return records.map((record) => ({ slug: record.slug, @@ -114,14 +98,6 @@ export async function GET(request: Request) { try { const { q, limit, fields } = parseRequestQuery(request, medicationListQuerySchema, "Invalid medication query."); -<<<<<<< HEAD - return medicationResponse({ - ...publicMedicationPayload(q, limit, fields), - demoMode: isDemoMode() || isLocalNoAuthMode(), - publicAccess: true, - }); - } catch (error) { -======= if (isDemoMode() || isLocalNoAuthMode()) { return medicationResponse({ ...publicMedicationPayload(q, limit, fields), @@ -171,7 +147,6 @@ export async function GET(request: Request) { if (error instanceof AuthenticationError) { return unauthorizedResponse(); } ->>>>>>> origin/main return jsonError(error); } } diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index f9045e1b35..044ab1e4ce 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -15,15 +15,11 @@ import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; import { SOURCE_ONLY_EMBEDDING_SKIP_REASON } from "@/lib/rag-provider"; import { createAdminClient } from "@/lib/supabase/admin"; import * as serverAuth from "@/lib/supabase/auth"; -<<<<<<< HEAD -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; -======= import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse, } from "@/lib/api-rate-limit"; ->>>>>>> origin/main import { publicAccessContext } from "@/lib/public-api-access"; import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; import { parseJsonBody } from "@/lib/validation/body"; @@ -76,16 +72,10 @@ function isSourceLibrarySearchMode(mode: SearchRequestBody["mode"]) { return mode === "documents" || mode === "differentials"; } -<<<<<<< HEAD -function scopedSearchKey(body: SearchRequestBody, ownerId?: string | null) { - return JSON.stringify({ - ownerId: ownerId ?? undefined, -======= function scopedSearchKey(body: SearchRequestBody, ownerId?: string | null, publicOnly = false) { return JSON.stringify({ ownerId: ownerId ?? undefined, publicOnly, ->>>>>>> origin/main query: body.query.toLowerCase().replace(/\s+/g, " ").trim(), topK: body.topK ?? null, documentId: body.documentId ?? null, @@ -691,10 +681,7 @@ async function buildScopedSearchPayload( body: SearchRequestBody, supabase: ReturnType, ownerId?: string | null, -<<<<<<< HEAD -======= publicOnly = false, ->>>>>>> origin/main ) { const searchFocusQuery = queryForClinicalMode(body.query, body.queryMode); const effectiveQueryClass = @@ -702,10 +689,7 @@ async function buildScopedSearchPayload( const scope = await resolveSearchScope({ supabase, ownerId: ownerId ?? undefined, -<<<<<<< HEAD -======= publicOnly, ->>>>>>> origin/main documentIds: body.documentIds ?? (body.documentId ? [body.documentId] : undefined), filters: body.filters, }); @@ -922,10 +906,7 @@ export async function POST(request: Request) { supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); ownerId = access.ownerId ?? null; -<<<<<<< HEAD -======= const publicOnly = !access.authenticated && !isLocalNoAuthMode(); ->>>>>>> origin/main const rateLimit = await consumeSubjectApiRateLimit({ supabase, @@ -942,11 +923,7 @@ export async function POST(request: Request) { const key = scopedSearchKey(searchBody, ownerId, publicOnly); const { payload, coalesced } = await coalesceScopedSearch(key, () => -<<<<<<< HEAD - buildScopedSearchPayload(body, supabase!, ownerId), -======= buildScopedSearchPayload(searchBody, supabase!, ownerId, publicOnly), ->>>>>>> origin/main ); return NextResponse.json({ ...payload, diff --git a/src/app/favourites/page.tsx b/src/app/favourites/page.tsx index d946bcdd45..6048c35e0b 100644 --- a/src/app/favourites/page.tsx +++ b/src/app/favourites/page.tsx @@ -14,11 +14,7 @@ export default async function FavouritesPage({ searchParams }: FavouritesPagePro const params = searchParams ? await searchParams : {}; const query = firstSearchParam(params.q)?.trim() ?? ""; -<<<<<<< HEAD - return ; -======= // No key={query} remount: query is a pure prop, and remounting on query // change wiped the set/type/view/sort selections when clearing a search. return ; ->>>>>>> origin/main } diff --git a/src/app/globals.css b/src/app/globals.css index 41ada550e9..e21b1b4ac0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -662,7 +662,6 @@ summary::-webkit-details-marker { /* Phone dock: full-bleed footer with progressive blur scrim behind pill + chips. */ .answer-footer-search-dock { --footer-scrim-height: max(10rem, calc(var(--safe-area-bottom) + 8.5rem)); -<<<<<<< HEAD } .answer-footer-search-dock[data-footer-variant="compact"] { @@ -784,8 +783,6 @@ summary::-webkit-details-marker { 0 1px 2px rgb(16 24 40 / 5%), 0 8px 20px rgb(16 24 40 / 9%), 0 24px 56px rgb(16 24 40 / 14%); -======= ->>>>>>> origin/main } .answer-footer-search-dock[data-footer-variant="compact"] { @@ -1259,24 +1256,14 @@ summary::-webkit-details-marker { .answer-footer-search-action, .answer-footer-search-send { -<<<<<<< HEAD - height: 2.05rem; - width: 2.05rem; -======= height: 2.75rem; width: 2.75rem; ->>>>>>> origin/main } .answer-footer-search-action svg, .answer-footer-search-send svg { -<<<<<<< HEAD - height: 1rem; - width: 1rem; -======= height: 1.1rem; width: 1.1rem; ->>>>>>> origin/main } } @@ -1341,27 +1328,10 @@ summary::-webkit-details-marker { @media (max-width: 430px) { .answer-footer-search-action, .answer-footer-search-send { -<<<<<<< HEAD - height: 2.05rem !important; - width: 2.05rem !important; - } - - .answer-footer-search-action svg, - .answer-footer-search-send svg { - height: 1rem; - width: 1rem; - } -} - -@media (prefers-reduced-motion: no-preference) { - .answer-footer-search-send:active { - transform: scale(0.96); -======= height: 2.75rem !important; width: 2.75rem !important; min-height: 2.75rem; min-width: 2.75rem; ->>>>>>> origin/main } .answer-footer-search-action svg, @@ -1742,11 +1712,7 @@ summary::-webkit-details-marker { /* Compact search/result views: no chip row below the pill, so the pill itself hugs the bottom edge and the scrim shrinks to match. */ -<<<<<<< HEAD - .document-mobile-search-edge.answer-footer-search-edge.document-mobile-search-compact:not(.answer-footer-search-dock) { -======= .document-mobile-search-edge.answer-footer-search-edge.document-mobile-search-compact { ->>>>>>> origin/main bottom: max(0.4rem, calc(var(--safe-area-bottom) + 0.3rem)); } @@ -1792,8 +1758,6 @@ summary::-webkit-details-marker { padding-bottom: max(0.45rem, var(--safe-area-bottom)); } -<<<<<<< HEAD -======= /* Must beat the edge-to-edge dock rule above (transform: none) so scroll-hide actually slides the bar off-screen once data-scroll-hidden is set. */ .answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge[data-scroll-hidden="true"], @@ -1811,7 +1775,6 @@ summary::-webkit-details-marker { pointer-events: none; } ->>>>>>> origin/main .answer-footer-search-dock .answer-footer-search-pill { border-color: var(--border-strong); background: var(--surface); @@ -1858,20 +1821,12 @@ summary::-webkit-details-marker { } .dashboard-composer-edge.answer-footer-search-edge { -<<<<<<< HEAD - left: calc( - var(--clinical-sidebar-width-md, 0px) + (100vw - var(--clinical-sidebar-width-md, 0px)) / 2 - ); - right: auto; - width: min(calc(100vw - var(--clinical-sidebar-width-md, 0px) - 48px - var(--safe-area-left) - var(--safe-area-right)), 680px); -======= left: calc(var(--clinical-sidebar-width-md, 0px) + (100vw - var(--clinical-sidebar-width-md, 0px)) / 2); right: auto; width: min( calc(100vw - var(--clinical-sidebar-width-md, 0px) - 48px - var(--safe-area-left) - var(--safe-area-right)), 680px ); ->>>>>>> origin/main } } diff --git a/src/app/home-page-client.tsx b/src/app/home-page-client.tsx index 07522838c8..97278e45dc 100644 --- a/src/app/home-page-client.tsx +++ b/src/app/home-page-client.tsx @@ -5,16 +5,6 @@ import type { ReactNode } from "react"; import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; import type { AppModeId } from "@/lib/app-modes"; -<<<<<<< HEAD -export function HomePageClient({ - initialMode, - children, -}: { - initialMode: AppModeId; - children?: ReactNode; -}) { -======= export function HomePageClient({ initialMode, children }: { initialMode: AppModeId; children?: ReactNode }) { ->>>>>>> origin/main return {children ?? null}; } diff --git a/src/app/mockups/answer-evidence-popups/page.tsx b/src/app/mockups/answer-evidence-popups/page.tsx index f04ba95cd9..831b4050db 100644 --- a/src/app/mockups/answer-evidence-popups/page.tsx +++ b/src/app/mockups/answer-evidence-popups/page.tsx @@ -600,22 +600,11 @@ function MobileEvidencePanel({ selected }: { selected: string }) { function DesktopEvidenceModal() { return (
-<<<<<<< HEAD -
- )} - - {(documentsDrawerOpen || uploadDrawerOpen) && } -
-======= {(documentsDrawerOpen || uploadDrawerOpen) && }
->>>>>>> origin/main diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index 78589300e2..72751025dd 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -63,16 +63,6 @@ const statusLabels: Record = { // legible in dark mode and forced-colors; "safety" is genuinely semantic and // uses the danger triad. const iconToneClasses: Record = { -<<<<<<< HEAD - assessment: "border-cyan-200 bg-cyan-50 text-cyan-700", - reference: "border-emerald-200 bg-emerald-50 text-emerald-700", - care: "border-sky-200 bg-sky-50 text-sky-700", - coordination: "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", - saved: "border-blue-200 bg-blue-50 text-blue-700", - safety: "border-red-200 bg-red-50 text-red-600", - medication: "border-amber-200 bg-amber-50 text-amber-600", - differentials: "border-violet-200 bg-violet-50 text-violet-700", -======= assessment: "border-[color:var(--type-service-border)] bg-[color:var(--type-service-soft)] text-[color:var(--type-service)]", reference: "border-[color:var(--type-table-border)] bg-[color:var(--type-table-soft)] text-[color:var(--type-table)]", @@ -84,7 +74,6 @@ const iconToneClasses: Record>>>>>> origin/main }; // Presentation-only mapping: the shared tools catalog is icon-free so it can be used by @@ -683,17 +672,9 @@ export function ApplicationsLauncherWorkspace({ const searchCommand = useSearchCommand(); const [localQuery, setLocalQuery] = useState(""); const [activeFilter, setActiveFilter] = useState("all"); -<<<<<<< HEAD - const [selectedId, setSelectedId] = useState(() => initialToolId(controlledQuery)); - const isDashboardTools = variant === "dashboard-tools"; - const [detailOpen, setDetailOpen] = useState(!isDashboardTools && showDetailPanel === true); - const copy = isDashboardTools ? dashboardToolsLauncherCopy : standaloneLauncherCopy; - const query = controlledQuery ?? uncontrolledQuery; -======= const [detailOpen, setDetailOpen] = useState(false); const copy = toolsLauncherCopy; const query = controlledQuery ?? searchCommand?.query ?? localQuery; ->>>>>>> origin/main const normalizedQuery = query.trim().toLowerCase(); const queryDerivedId = useMemo(() => initialToolId(query), [query]); const [selection, setSelection] = useState(() => ({ @@ -748,13 +729,8 @@ export function ApplicationsLauncherWorkspace({ return (
>>>>>> origin/main className={cn( "mx-auto w-full max-w-[90rem] overflow-x-hidden px-4 pb-8 text-[color:var(--text)] sm:px-6 lg:px-8", "pb-[calc(12rem+env(safe-area-inset-bottom))] sm:pb-8", @@ -763,26 +739,6 @@ export function ApplicationsLauncherWorkspace({ )} >
- - - -
-

- {copy.heading} -

-

- {copy.description} -

-
-======= aria-label="Tools home" data-testid="tools-home" className="mx-auto grid max-w-5xl justify-items-center gap-3.5 text-center sm:gap-6" @@ -795,16 +751,11 @@ export function ApplicationsLauncherWorkspace({ headingLevel={1} compact /> ->>>>>>> origin/main {desktopComposerSlotId ? (
>>>>>> origin/main /> ) : ( )} -<<<<<<< HEAD -
-=======
->>>>>>> origin/main
@@ -832,11 +779,7 @@ export function ApplicationsLauncherWorkspace({
>>>>>> origin/main className="mx-auto mt-8 grid max-w-[86rem] gap-4 sm:mt-10" >
diff --git a/src/components/client-hydration-boundary.tsx b/src/components/client-hydration-boundary.tsx index b6f5b926e8..ac11c64c52 100644 --- a/src/components/client-hydration-boundary.tsx +++ b/src/components/client-hydration-boundary.tsx @@ -8,23 +8,12 @@ function subscribeNoop() { /** Renders children only after the client has mounted to avoid SSR hydration * mismatches when dev tooling injects attributes into the pre-hydration DOM. */ -<<<<<<< HEAD -export function ClientHydrationBoundary({ - children, - fallback = null, -}: { - children: ReactNode; - fallback?: ReactNode; -}) { - const ready = useSyncExternalStore(subscribeNoop, () => true, () => false); -======= export function ClientHydrationBoundary({ children, fallback = null }: { children: ReactNode; fallback?: ReactNode }) { const ready = useSyncExternalStore( subscribeNoop, () => true, () => false, ); ->>>>>>> origin/main if (!ready) return fallback; return children; } diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index fd11e48a73..e8e6f8aacf 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -22,9 +22,6 @@ import { } from "lucide-react"; import { appModeIcons } from "@/lib/app-mode-icons"; import { BrandMark } from "@/components/clinical-dashboard/brand"; -<<<<<<< HEAD -import { cn, sidebarItem, statusDotReady, textMuted } from "@/components/ui-primitives"; -======= import { cn, fieldControlWithIcon, @@ -33,7 +30,6 @@ import { statusDotReady, textMuted, } from "@/components/ui-primitives"; ->>>>>>> origin/main function useClientMounted() { return useSyncExternalStore( @@ -172,22 +168,14 @@ export function ClinicalSidebarContent({ pinned. */}
@@ -287,15 +275,11 @@ export function ClinicalSidebarContent({ className={sidebarItem} aria-label={themeUiReady ? themeToggleLabel : "Toggle theme"} > -<<<<<<< HEAD - {themeUiReady ? : } -======= {themeUiReady ? ( ) : ( )} ->>>>>>> origin/main {themeUiReady ? nextThemeLabel : "Theme"} - {sourceOnlyNoticeOpen ? ( -
-

- This answer was assembled from your documents without the AI model, so it may be less complete. - Verify dose, threshold, route, timing, monitoring, and risk details against the cited passages below. -

-
- ) : null} -
- ) : null} - {sourceCapsuleButton} - {sourcePreviewOpen && canOpenSourcePreview && !usePreviewSheet ? ( -
{sourceOnly ? (
setSourcePreviewOpen(false)} anchorRef={sourceCapsuleRef} ->>>>>>> origin/main > >>>>>> origin/main export function AnswerFollowUpSuggestions({ suggestions, onPick, disabled = false, -<<<<<<< HEAD -======= className, testId = "answer-follow-up-suggestions", layout = "wrap", ->>>>>>> origin/main }: { suggestions: string[]; onPick: (suggestion: string) => void; disabled?: boolean; -<<<<<<< HEAD -}) { - if (!suggestions.length) return null; - - return ( -
-
- - -
-
- {suggestions.map((suggestion) => ( - - ))} -
-
-======= className?: string; testId?: string; layout?: "wrap" | "scroll"; @@ -71,6 +27,5 @@ export function AnswerFollowUpSuggestions({ layout={layout} className={className} /> ->>>>>>> origin/main ); } diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index 47223e7287..32a73c7b43 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -1,14 +1,7 @@ -<<<<<<< HEAD -"use client"; - -import Link from "next/link"; -import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; -======= "use client"; import Link from "next/link"; import { memo, type RefObject, useCallback, useEffect, useRef, useState } from "react"; ->>>>>>> origin/main import { ClipboardCheck, ExternalLink, Layers, ShieldAlert } from "lucide-react"; import { type AnswerFeedbackType } from "@/lib/answer-feedback"; @@ -45,11 +38,7 @@ import type { } from "@/lib/types"; import { type AnswerEvidenceMapRow, type AnswerViewMode } from "@/lib/ward-output"; -<<<<<<< HEAD -export function StagedAnswerResultSurface({ -======= function StagedAnswerResultSurfaceImpl({ ->>>>>>> origin/main answer, query, safeAnswerText, @@ -106,14 +95,10 @@ function StagedAnswerResultSurfaceImpl({ }) { const noteCount = clinicalNotesCount(answer); const showClinicalNotes = -<<<<<<< HEAD - safetyFindings.length > 0 || noteCount > 0 || answer.answerQualityTier === "source_only" || answerGrounded === false; -======= safetyFindings.length > 0 || noteCount > 0 || answer.answerQualityTier === "source_only" || answerGrounded === false; ->>>>>>> origin/main const clinicalNoteDisplayCount = clinicalNotesDisplayCountForAnswer( answer, answerViewMode, @@ -169,14 +154,11 @@ function StagedAnswerResultSurfaceImpl({ setEvidenceInitialTab(null); restoreFocusToTrigger(evidenceTriggerRef); } -<<<<<<< HEAD -======= function handleQuoteFollowUp(quote: QuoteCard) { setEvidenceOpen(false); setEvidenceInitialTab(null); onFollowUpQuote?.(quote); } ->>>>>>> origin/main function openTableEvidence() { setClinicalNotesOpen(false); setSafetyFindingsOpen(false); @@ -263,13 +245,6 @@ function StagedAnswerResultSurfaceImpl({ ) : null} {followUpSuggestions?.length && onPickFollowUpSuggestion ? ( -<<<<<<< HEAD - -=======
->>>>>>> origin/main ) : null}
@@ -301,11 +275,7 @@ function StagedAnswerResultSurfaceImpl({ } titleAccessory={ -<<<<<<< HEAD - -======= ->>>>>>> origin/main {clinicalNoteDisplayCount} } @@ -321,18 +291,11 @@ function StagedAnswerResultSurfaceImpl({ ) : null } headerClassName="gap-2 p-2.5 sm:p-3" -<<<<<<< HEAD - 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" -======= titleClassName="text-base-minus 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" desktopBackdropClassName="sm:bg-black/50" ->>>>>>> origin/main returnFocusRef={clinicalNotesTriggerRef} portal > @@ -355,27 +318,16 @@ function StagedAnswerResultSurfaceImpl({ onClose={closeEvidenceReview} title="Evidence" description="Review by evidence type." -<<<<<<< HEAD - titleAccessory={ - {evidenceTrustLabel} - } -======= titleAccessory={{evidenceTrustLabel}} ->>>>>>> origin/main closeLabel="Close evidence" headerLeading={ } -<<<<<<< HEAD - 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" -======= contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-3xl" bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" desktopBackdropClassName="sm:bg-black/50" ->>>>>>> origin/main returnFocusRef={evidenceTriggerRef} portal > @@ -383,10 +335,6 @@ function StagedAnswerResultSurfaceImpl({ answer={answer} sources={sources} renderModel={renderModel} -<<<<<<< HEAD - query={query} -======= ->>>>>>> origin/main visualEvidence={renderModel.visualEvidence} answerEvidenceMapRows={answerEvidenceMapRows} sourceGovernanceWarnings={sourceGovernanceWarnings} @@ -396,11 +344,7 @@ function StagedAnswerResultSurfaceImpl({ copiedQuotes={copiedQuotes} onCopyQuotes={copyQuotes} onSubmitFeedback={onSubmitFeedback} -<<<<<<< HEAD - onFollowUpQuote={onFollowUpQuote} -======= onFollowUpQuote={handleQuoteFollowUp} ->>>>>>> origin/main onScopeDocument={onScopeDocument} /> @@ -419,27 +363,16 @@ function StagedAnswerResultSurfaceImpl({ } titleAccessory={ -<<<<<<< HEAD - -======= ->>>>>>> origin/main {safetyFindings.length} } headerClassName="gap-2 p-2.5 sm:p-3" -<<<<<<< HEAD - 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" -======= titleClassName="text-base-minus 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" desktopBackdropClassName="sm:bg-black/50" ->>>>>>> origin/main returnFocusRef={safetyTriggerRef} portal > @@ -450,8 +383,6 @@ function StagedAnswerResultSurfaceImpl({
); } -<<<<<<< HEAD -======= // Memoized so keystrokes in the follow-up composer (which live in the parent // ClinicalDashboard's `query` state) no longer re-render this 385-line answer + @@ -460,4 +391,3 @@ function StagedAnswerResultSurfaceImpl({ // passes is `latestAnswerQuery ?? query`, which is non-null and stable once an // answer exists. export const StagedAnswerResultSurface = memo(StagedAnswerResultSurfaceImpl); ->>>>>>> origin/main diff --git a/src/components/clinical-dashboard/cross-mode-links.tsx b/src/components/clinical-dashboard/cross-mode-links.tsx index de9ef6ac44..0030ad7e5f 100644 --- a/src/components/clinical-dashboard/cross-mode-links.tsx +++ b/src/components/clinical-dashboard/cross-mode-links.tsx @@ -3,11 +3,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; -<<<<<<< HEAD -import { Search } from "lucide-react"; -======= import { Search, type LucideIcon } from "lucide-react"; ->>>>>>> origin/main import { cn, @@ -37,8 +33,6 @@ function badgeChipTone(tone: CrossModeLinkBadge["tone"]): SemanticChipTone | nul return tone === "clinical" ? "info" : tone; } -<<<<<<< HEAD -======= type CrossModeLinkCardProps = { link: CrossModeLink; Icon: LucideIcon; @@ -139,7 +133,6 @@ function CrossModeLinkCard({ link, Icon, query, onModeSearch }: CrossModeLinkCar ); } ->>>>>>> origin/main // Self-contained cross-mode links surface: owns the catalog fetching (same // owner-scoped APIs the modes use; fixtures in demo mode), entity matching, // and the strip. Mount it under any search-results surface and pass the @@ -212,17 +205,10 @@ export function CrossModeLinksStrip({ return (
-

-======= className="max-w-[68ch] border-t border-[color:var(--border)] pt-3" data-testid="cross-mode-links" >

->>>>>>> origin/main Also in your library {links.length > 1 ? ( @@ -231,65 +217,6 @@ export function CrossModeLinksStrip({ ) : null}

-<<<<<<< HEAD -
1 && "sm:grid-cols-2")}> - {links.map((link) => { - const Icon = appModeIcons[link.modeId]; - return ( -
-
- - - -
-
- logCrossModeLinkOpen(query, link)} - className="inline-flex min-h-9 min-w-0 flex-1 items-center text-sm font-semibold leading-5 text-[color:var(--text-heading)] transition hover:text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - > - {link.title} - - {link.modeLabel} -
- {link.subtitle ? ( -

{link.subtitle}

- ) : null} - {link.badges.length > 0 ? ( -
- {link.badges.map((badge) => ( - - {badge.label} - - ))} -
- ) : null} - -
-
-
-=======
->>>>>>> origin/main ); })}
diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 1d0fea54b5..30f3ebe1ec 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -27,10 +27,7 @@ import { import { ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; -<<<<<<< HEAD -======= import { useDifferentialSearch } from "@/components/clinical-dashboard/use-differential-catalog"; ->>>>>>> origin/main import { cn } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; import { differentialsMobileCompareAddonSlotId } from "@/lib/mode-home-composer"; @@ -129,13 +126,7 @@ function DifferentialsMobileCompareAddon({ selectedCount, query }: { selectedCou useEffect(() => { const phoneMediaQuery = window.matchMedia("(max-width: 1023px)"); const sync = () => { -<<<<<<< HEAD - setHost( - phoneMediaQuery.matches ? document.getElementById(differentialsMobileCompareAddonSlotId) : null, - ); -======= setHost(phoneMediaQuery.matches ? document.getElementById(differentialsMobileCompareAddonSlotId) : null); ->>>>>>> origin/main }; sync(); phoneMediaQuery.addEventListener("change", sync); @@ -170,35 +161,18 @@ function statusLabel(status: DifferentialRecord["status"]) { } function statusTone(status: DifferentialRecord["status"]) { -<<<<<<< HEAD - if (status === "emergent") return "border-transparent bg-[color:var(--danger)] text-white"; -======= if (status === "emergent") { return "border-transparent bg-[color:var(--danger-solid)] text-[color:var(--danger-solid-contrast)]"; } ->>>>>>> origin/main if (status === "urgent") { return "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]"; } return "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]"; } -<<<<<<< HEAD -function resultTypeTabCounts(results: DifferentialResult[]) { - return { - all: results.length, - presentations: results.filter((result) => result.href.includes("/presentations")).length, - diagnoses: results.filter((result) => result.href.includes("/diagnoses")).length, - }; -} - -function recordIcon(record: DifferentialRecord) { - return candidateIconBySlug.find(([fragment]) => record.slug.includes(fragment))?.[1] ?? BrainCircuit; -======= function resultIcon(kind: DifferentialResult["kind"], slug: string) { if (kind === "presentation") return BrainCircuit; return candidateIconBySlug.find(([fragment]) => slug.includes(fragment))?.[1] ?? Stethoscope; ->>>>>>> origin/main } function tagText(value: string) { @@ -234,26 +208,13 @@ function StatusBadge({ status, className }: { status: DifferentialRecord["status )} > {status === "emergent" ? ( -<<<<<<< HEAD - -======= ->>>>>>> origin/main ) : null} {statusLabel(status)} ); } -<<<<<<< HEAD -function ResultTypeTabs({ results }: { results: DifferentialResult[] }) { - const counts = resultTypeTabCounts(results); - const tabs = [ - { key: "all", label: "All", count: counts.all }, - { key: "presentations", label: "Presentations", count: counts.presentations }, - { key: "diagnoses", label: "Diagnoses", count: counts.diagnoses }, - ] as const; -======= type KindFilter = "all" | "presentation" | "diagnosis"; const resultTypeTabFocusRing = @@ -277,25 +238,10 @@ function ResultTypeTabs({ { id: "presentation" as const, label: "Presentations", count: presentationCount }, { id: "diagnosis" as const, label: "Diagnoses", count: diagnosisCount }, ]; ->>>>>>> origin/main return (
- {tabs.map((tab, index) => { - const active = index === 0; - return ( -

-<<<<<<< HEAD - -
- toggleSelected(best.id)} /> - -
- - - {results.length} result{results.length === 1 ? "" : "s"} - ·{" "} - {hasSourceEvidence ? "Ranked by relevance" : "Guided differential view"} - - -
- {!hasSourceEvidence ? ( -
-

- Showing guided local differential records. Source-library evidence has not been checked for this query - yet. -

-=======
) : (
@@ -963,7 +870,6 @@ function SearchResultsView({
->>>>>>> origin/main
) @@ -780,14 +764,10 @@ export function ClinicalNotesChecklistPanel({ key={tab.id} type="button" role="tab" -<<<<<<< HEAD - aria-selected={selected} -======= id={tabButtonId(tab.id)} aria-selected={selected} aria-controls={notesPanelId} tabIndex={selected ? 0 : -1} ->>>>>>> origin/main aria-label={`${tab.label} (${tab.count})`} onClick={() => setRequestedTab(tab.id)} className={cn( @@ -800,11 +780,7 @@ export function ClinicalNotesChecklistPanel({ {tab.label} >>>>>> origin/main selected ? "bg-[color:var(--surface-raised)] text-[color:var(--clinical-accent)]" : "bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]", @@ -833,12 +809,9 @@ export function ClinicalNotesChecklistPanel({ ) : null}
>>>>>> origin/main className={cn( "overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)]", showTabStrip || (tableEvidenceCount > 0 && onOpenTables) ? "mt-3" : "mt-0", @@ -865,11 +838,7 @@ export function ClinicalNotesChecklistPanel({ {row.title}

{!isWarnRow ? ( -<<<<<<< HEAD - -======= ->>>>>>> origin/main {activeTab === "actions" ? "Action" : "Source"} ) : null} @@ -880,15 +849,9 @@ export function ClinicalNotesChecklistPanel({
{isWarnRow ? ( -<<<<<<< HEAD - Review - ) : ( - -======= Review ) : ( ->>>>>>> origin/main S{row.sourceIndex} )} @@ -1010,11 +973,7 @@ export function SafetyFindingsListContent({ findings }: { findings: SafetyFindin
-<<<<<<< HEAD - -======= ->>>>>>> origin/main {finding.label} {retrievalGateBlocked ? ( -<<<<<<< HEAD -

-=======

->>>>>>> origin/main Retrieval confidence gate was triggered. Expand evidence details before using this result.

) : null} {demoMode ? ( -<<<<<<< HEAD -

-=======

->>>>>>> origin/main Synthetic demo only: this is not clinical guidance.

) : null} diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index d34508ca5f..db6685efa9 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -37,12 +37,6 @@ import { type FavouriteItem as PrototypeFavouriteItem, } from "@/components/clinical-dashboard/favourites-prototype-data"; import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-saved-registry-favourites"; -<<<<<<< HEAD -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"; -======= import { SearchResultsEmptyState, SearchResultsHeaderBand, @@ -51,7 +45,6 @@ import { useSearchCommand } from "@/components/clinical-dashboard/search-command import { favouriteMatchesCommandScopes } from "@/lib/search-command-surface"; import { appModeIcons } from "@/lib/app-mode-icons"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; ->>>>>>> origin/main type FavouriteType = "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form"; type ViewMode = FavouritesViewMode; @@ -96,22 +89,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)]", -<<<<<<< HEAD - 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)]", ->>>>>>> origin/main 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)]", -<<<<<<< HEAD - 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)]", ->>>>>>> origin/main Form: "border-[color:var(--type-form-border)] bg-[color:var(--type-form-soft)] text-[color:var(--type-form)]", }; @@ -158,14 +143,10 @@ function isSourceBacked(item: FavouriteItem): boolean { } function toCommandItem(item: PrototypeFavouriteItem): FavouriteItem { -<<<<<<< HEAD - const type = typeByPrototypeType[item.type] ?? (item.primaryAction === "Run" ? "Saved search" : "Source"); -======= const type = item.type === "sources" && item.primaryAction === "Run" ? "Saved search" : (typeByPrototypeType[item.type] ?? "Source"); ->>>>>>> origin/main return { id: item.id, title: item.title, @@ -193,14 +174,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) => ({ -<<<<<<< HEAD - id: title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""), -======= id: title .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""), ->>>>>>> origin/main title, count: items.filter((item) => item.set === title).length, })); @@ -248,12 +225,8 @@ function filterAndSortItems( ) .sort((first, second) => { if (effectiveSort === "title") return first.title.localeCompare(second.title); -<<<<<<< HEAD - 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); ->>>>>>> origin/main return lastUsedScore(second.lastUsed) - lastUsedScore(first.lastUsed); }); } @@ -325,11 +298,7 @@ function ActiveFilterChips({ type="button" onClick={chip.onClear} className={cn( -<<<<<<< HEAD - "inline-flex h-8 max-w-full items-center gap-1.5 rounded-full border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-3 text-xs font-bold text-[color:var(--clinical-accent)] hover:bg-[color:var(--clinical-accent-soft)]/80", -======= "inline-flex h-8 max-w-full items-center gap-1.5 rounded-full border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-3 text-2xs font-semibold text-[color:var(--clinical-accent)] hover:bg-[color:var(--clinical-accent-soft)]/80", ->>>>>>> origin/main focusRing, )} > @@ -342,17 +311,7 @@ function ActiveFilterChips({ ); } -<<<<<<< HEAD -function ContinueStrip({ - item, - onSelect, -}: { - item: FavouriteItem; - onSelect: (id: string) => void; -}) { -======= function ContinueStrip({ item, onSelect }: { item: FavouriteItem; onSelect: (id: string) => void }) { ->>>>>>> origin/main const Icon = item.icon; return (
-<<<<<<< HEAD -

Continue

-

{item.title}

-
-

-=======

Continue

@@ -384,7 +337,6 @@ function ContinueStrip({ item, onSelect }: { item: FavouriteItem; onSelect: (id:

->>>>>>> origin/main {item.set} · last opened {item.lastUsed}

@@ -392,11 +344,7 @@ function ContinueStrip({ item, onSelect }: { item: FavouriteItem; onSelect: (id: >>>>>> origin/main focusRing, )} > @@ -505,22 +453,12 @@ function FavouriteMobileCard({ onSelect(item.id); } }} -<<<<<<< HEAD -======= aria-pressed={selected} ->>>>>>> origin/main className={cn( "min-w-0 max-w-full rounded-lg border bg-[color:var(--surface)] p-3 shadow-[var(--shadow-tight)]", selected ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)]/35 shadow-[inset_3px_0_0_var(--clinical-accent)]" : "border-[color:var(--border)]", -<<<<<<< HEAD - )} - > -
-

{item.title}

-

-======= focusRing, )} > @@ -529,7 +467,6 @@ function FavouriteMobileCard({ {item.title}

->>>>>>> origin/main {item.description}

@@ -542,11 +479,7 @@ function FavouriteMobileCard({
-<<<<<<< HEAD -
-=======
->>>>>>> origin/main
@@ -564,11 +497,7 @@ function FavouriteMobileCard({ >>>>>> origin/main focusRing, )} > @@ -617,13 +546,8 @@ function FavouritesTable({ return (
-<<<<<<< HEAD -
-

-=======

->>>>>>> origin/main {tableRows.length} {tableRows.length === 1 ? "item" : "items"} {tableRows.length !== items.length ? ` of ${items.length}` : ""}

@@ -631,17 +555,11 @@ function FavouritesTable({ -
- - {showFooterSearchChips && (trustFooterChip || hasScopeFooterChip || secondaryFooterChip) ? ( -
- {trustFooterChip ? ( - - ) : null} - {hasScopeFooterChip ? ( - - ) : null} - {!hasScopeFooterChip && secondaryFooterChip ? ( - - ) : null} -=======
{/* Scope popover is a form sibling so the "+" menu's "Set scope" action can @@ -1603,8 +1326,6 @@ export function MasterSearchHeader({ }, } : undefined; -<<<<<<< HEAD -======= const composerFocusProps = hideOnScroll ? { onFocusCapture: () => setComposerChromeFocused(true), @@ -1613,21 +1334,12 @@ export function MasterSearchHeader({ }, } : undefined; ->>>>>>> origin/main const headerAndComposer = ( <>
); @@ -414,15 +356,10 @@ function PathwayPanel() {
->>>>>>> origin/main )} ) : null} @@ -783,26 +683,15 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) {
diff --git a/src/components/master-document-flow-mockups.tsx b/src/components/master-document-flow-mockups.tsx index b6e24c4233..dee63477df 100644 --- a/src/components/master-document-flow-mockups.tsx +++ b/src/components/master-document-flow-mockups.tsx @@ -251,21 +251,12 @@ const monitoringTableRows = [ ["> 1 year", "4 weekly", "Full Blood Count (ANC)", "ANC < 1.0 x10^9/L"], ] as const; -<<<<<<< HEAD -function documentHref(document: DocumentFixture, query = defaultQuery) { - return documentReaderHref({ - document: document.slug, - query, - page: String(document.page), - chunk: document.chunk, -======= function documentHref(document: DocumentFixture, query = defaultQuery, evidence?: EvidenceFixture) { return documentReaderHref({ document: document.slug, query, page: String(evidence?.page ?? document.page), chunk: evidence?.id ?? document.chunk, ->>>>>>> origin/main }); } @@ -549,17 +540,6 @@ function SearchResultMobileCard({ Open document
); diff --git a/src/components/mode-home-template.tsx b/src/components/mode-home-template.tsx index 3da953fb3b..10cd68ec41 100644 --- a/src/components/mode-home-template.tsx +++ b/src/components/mode-home-template.tsx @@ -75,11 +75,7 @@ export function ModeHomeHero({ return (
>>>>>> origin/main aria-labelledby={`${testId ?? "mode-home"}-title`} >

>>>>>> origin/main compact ? "leading-5" : "leading-6", )} > @@ -140,14 +132,7 @@ export function ModeHomeMain({

>>>>>> origin/main className, )} > @@ -239,35 +224,20 @@ export function ModeHomeTemplate({
>>>>>> origin/main className, )} > {desktopComposerSlotId ? ( -<<<<<<< HEAD -
-=======
->>>>>>> origin/main ) : null} {actions?.length ? (
>>>>>> origin/main > {actions.map((action, index) => { const ActionIcon = action.icon; @@ -280,11 +250,7 @@ export function ModeHomeTemplate({ {action.title} -<<<<<<< HEAD - -======= ->>>>>>> origin/main {action.description} @@ -295,13 +261,8 @@ export function ModeHomeTemplate({ ); const actionClassName = cn( -<<<<<<< HEAD - "mode-home-action group grid min-h-[4rem] w-full grid-cols-[2.5rem_minmax(0,1fr)_1.25rem] items-center gap-3 bg-[color:var(--surface)] px-4 py-2.5 text-left transition sm:min-h-[4.8rem] sm:py-3 hover:bg-[color:var(--surface-subtle)] focus-visible:relative focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] disabled:cursor-wait disabled:opacity-60 lg:min-h-[8.4rem] lg:grid-cols-[3.5rem_minmax(0,1fr)_1.5rem] lg:gap-4 lg:rounded-lg lg:border lg:border-[color:var(--border)] lg:px-6 lg:py-5 lg:shadow-[var(--shadow-card)]", - index > 0 && "border-t border-[color:var(--border)] lg:border-t-[color:var(--border)]", -======= "mode-home-action group grid min-h-[4.8rem] w-full grid-cols-[2.5rem_minmax(0,1fr)_1.25rem] items-center gap-3 bg-[color:var(--surface)] px-4 py-3 text-left transition hover:bg-[color:var(--surface-subtle)] focus-visible:relative focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] disabled:cursor-wait disabled:opacity-60 sm:min-h-[8rem] sm:grid-cols-[3.5rem_minmax(0,1fr)_1.5rem] sm:gap-4 sm:rounded-lg sm:border sm:border-[color:var(--border)] sm:px-5 sm:py-5 sm:shadow-[var(--shadow-card)] lg:min-h-[8.4rem] lg:px-6", index > 0 && "border-t border-[color:var(--border)] sm:border-t-[color:var(--border)]", ->>>>>>> origin/main ); if (action.href) { @@ -329,14 +290,10 @@ export function ModeHomeTemplate({ ) : null} {pills?.length ? ( -<<<<<<< HEAD -
-=======
->>>>>>> origin/main {pillsTitle || pillsAction ? (
{pillsTitle ?

{pillsTitle}

: } diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index e6154cc0e0..6fe93282ff 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -23,10 +23,6 @@ import { import { useMemo, useState } from "react"; import { cn } from "@/components/ui-primitives"; -<<<<<<< HEAD -import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; -import { SearchResultsEmptyState, SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; -======= import { ModeHomeStatusNotice } from "@/components/mode-home-template"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; import { @@ -34,7 +30,6 @@ import { SearchResultsHeaderBand, SearchResultsSkeleton, } from "@/components/clinical-dashboard/search-results-header-band"; ->>>>>>> origin/main import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { appModeHomeHref } from "@/lib/app-modes"; import { recordMatchesCommandScopes } from "@/lib/search-command-surface"; @@ -321,11 +316,7 @@ function RightRail({ return (
-<<<<<<< HEAD -
-=======
->>>>>>> origin/main

Referral decision

- -
-
-
-
- {["Best fit", "Crisis", "ATSI-specific", "Phone referral", "Free", "WA"].map((chip, index) => ( - - ))} -
- -
-
-=======
->>>>>>> origin/main
{scopedMatches.map((service, index) => ( >>>>>> origin/main export type ToolStatus = "ready" | "review_due" | "recent"; export type ToolArea = "reference" | "assessment" | "care" | "coordination" | "personal"; @@ -75,12 +72,6 @@ const fixtureExtras: ToolFixtureExtras[] = [ }, { id: "services", -<<<<<<< HEAD - title: "Services", - description: "Open source-backed service records, referral routes, and eligibility.", - href: "/services", -======= ->>>>>>> origin/main icon: appModeIcons.services, area: "coordination", status: "review_due", diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx index 18a4441fa1..9fc8159daf 100644 --- a/src/components/ui-primitives.tsx +++ b/src/components/ui-primitives.tsx @@ -156,8 +156,6 @@ export const searchResultsSection = export const searchFocusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; -<<<<<<< HEAD -======= export type NoticeTone = "success" | "warning" | "danger" | "info" | "neutral"; function noticeToneClass(tone: NoticeTone) { @@ -215,7 +213,6 @@ export function InlineNotice({ ); } ->>>>>>> origin/main export type SemanticChipTone = "danger" | "info" | "warning" | "success" | "neutral"; export function semanticChipTone(tone: SemanticChipTone | undefined | null) { @@ -229,36 +226,12 @@ export function semanticChipTone(tone: SemanticChipTone | undefined | null) { export function ToggleSwitch({ enabled, className, -<<<<<<< HEAD -======= onToggle, disabled = false, ->>>>>>> origin/main "aria-label": ariaLabel, }: { enabled: boolean; className?: string; -<<<<<<< HEAD - "aria-label"?: string; -}) { - return ( - - -======= // When provided the switch is an operable control; when omitted it renders as a // read-only presentational indicator (no interactive role is advertised). onToggle?: () => void; @@ -304,7 +277,6 @@ export function ToggleSwitch({ return ( {knob} ->>>>>>> origin/main ); } diff --git a/src/components/universal-search-command-mockups.tsx b/src/components/universal-search-command-mockups.tsx index df029f8ec4..37df17229c 100644 --- a/src/components/universal-search-command-mockups.tsx +++ b/src/components/universal-search-command-mockups.tsx @@ -22,11 +22,6 @@ import { X, type LucideIcon, } from "lucide-react"; -<<<<<<< HEAD -import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; - -import { chatComposerIconButton, chatComposerInput, chatComposerShell, chatSendButton, cn } from "@/components/ui-primitives"; -======= import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { @@ -37,7 +32,6 @@ import { cn, } from "@/components/ui-primitives"; import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; ->>>>>>> origin/main const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; @@ -467,12 +461,8 @@ const redFlagTerms = ["confusion", "overdose", "suicid", "chest pain", "unrespon /* ------------------------------------------------------------------ */ const badgeToneClasses: Record = { -<<<<<<< HEAD - accent: "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", -======= accent: "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", ->>>>>>> origin/main success: "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]", warning: "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]", danger: "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]", @@ -481,16 +471,12 @@ const badgeToneClasses: Record = { function Badge({ tone = "neutral", children }: { tone?: BadgeTone; children: ReactNode }) { return ( -<<<<<<< HEAD - -======= ->>>>>>> origin/main {children} ); @@ -507,81 +493,12 @@ function matchesScopes(row: MatchRow, activeScopes: string[]) { return activeScopes.every((scope) => row.scopes.includes(scope)); } -<<<<<<< HEAD -function subscribeReducedMotion(onChange: () => void) { - const media = window.matchMedia("(prefers-reduced-motion: reduce)"); - media.addEventListener("change", onChange); - return () => media.removeEventListener("change", onChange); -} - -function usePrefersReducedMotion() { - return useSyncExternalStore( - subscribeReducedMotion, - () => window.matchMedia("(prefers-reduced-motion: reduce)").matches, - () => false, - ); -} - -/* ------------------------------------------------------------------ */ -/* Layer 1 — context hint row with rotating examples */ -/* ------------------------------------------------------------------ */ - -function ContextHintRow({ mode, onPickExample }: { mode: ModeConfig; onPickExample: (example: string) => void }) { - const reducedMotion = usePrefersReducedMotion(); - const [index, setIndex] = useState(0); - - // The demo remounts per mode (key={mode.id}), so index starts at 0 for each mode. - useEffect(() => { - const timer = window.setInterval(() => { - setIndex((current) => (current + 1) % mode.examples.length); - }, 4500); - return () => window.clearInterval(timer); - }, [mode]); - - const example = mode.examples[index % mode.examples.length]; - const ModeIcon = mode.icon; - - return ( -
- - - - - Searching {mode.label.toLowerCase()} - - - - Try: - - - - Press - / - to search - - -
- ); -======= /* ------------------------------------------------------------------ */ /* Layer 1 — context hint row with compact example chips */ /* ------------------------------------------------------------------ */ function ContextHintRow({ mode, onPickExample }: { mode: ModeConfig; onPickExample: (example: string) => void }) { return ; ->>>>>>> origin/main } /* ------------------------------------------------------------------ */ @@ -690,28 +607,20 @@ function SmartDropdown({
) : null} -<<<<<<< HEAD -
-=======
->>>>>>> origin/main {sections.map((section) => section.items.length ? (
{section.heading ? ( -<<<<<<< HEAD -
-=======
->>>>>>> origin/main {section.heading}
) : null} @@ -760,24 +669,14 @@ function SmartDropdown({ )} {!hasAnyItems ? (
-<<<<<<< HEAD - No suggestions for “{query}”{activeScopes.length ? " with the current scope filters" : ""}. Press Enter to run the full - search. -======= No suggestions for “{query}”{activeScopes.length ? " with the current scope filters" : ""}. Press Enter to run the full search. ->>>>>>> origin/main
) : null}
-<<<<<<< HEAD - ↑↓ navigate - open / search - esc close -======= ↑↓ {" "} @@ -790,7 +689,6 @@ function SmartDropdown({ esc {" "} close ->>>>>>> origin/main Enter with nothing highlighted runs the full search
@@ -890,29 +788,21 @@ function ResultsHeaderBand({
-<<<<<<< HEAD -
-=======
->>>>>>> origin/main