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

- {cleanDisplayTitle(source.title)} -

-

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

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

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

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

Claims

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

Tables

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

- {compactClinicalTableCaption(item)} -

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

Images

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

Gaps

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

Key monitoring items

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

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

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

What was found

-

{found}

-
-
-

- What was not found -

-

{missing}

-
-
-

Closest sources

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

No nearby indexed sources were returned.

- )} -
-
-

- Suggested next search/upload -

-

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

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

{item.value}

-

{item.label}

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

Source status

- -
- - {weakEvidence ? ( -

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

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

- {check.label} -

-
-

{check.value}

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

{scopePreview}

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

{scopePreview}

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

- Document mode UX -

-
-

- Three runnable document search directions -

-

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

-
-
-

- Open a direction -

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

- {concept.eyebrow} -

-
-

- {concept.title} -

-

{concept.body}

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

{modeLabel}

-

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

+
+ + +
+

{statusTitle}

+

{statusHelper}

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

-
+
+

+ Clinical Guide account +

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

- Clinical context -

-

+

{identity.displayName}

-

- Consultant psychiatrist, Western Australia +

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

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

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

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

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

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

+ return ( +
+
+ + + +
+

{copy.heading}

-

+

{copy.description}

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

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

{copy.allSectionLabel}

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

{copy.emptyTitle}

-

{copy.emptyBody}

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

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

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

Recent activity

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

{copy.allSectionLabel}

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

{copy.emptyTitle}

+

{copy.emptyBody}

+ ) : ( + <> +
+ {filteredApps.map((app) => ( + + ))} +
+
+ {filteredApps.map((app) => ( + + ))} +
+ )} -
- setMobileDetailOpen(false)} - labelledBy="selected-application-sheet-heading" - closeLabel={copy.closeSelectedLabel} - contentClassName="lg:hidden rounded-t-[1.75rem] bg-[color:var(--surface-lux)]" - bodyClassName="px-5 pb-6 pt-4 sm:px-5" - portal - > - setMobileDetailOpen(false)} - headingId="selected-application-sheet-heading" - copy={copy} - testId="selected-application-sheet-panel" - variant="sheet" - /> - - - ); +

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

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

+ Set up your workspace +

+

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

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

+ Source preferences +

+ +
+

+ Choose the sources you rely on most. +

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

+ Security summary +

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

+ {statusMessage} +

+ ) : null} + +

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

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

{priority.title}

-

{priority.detail}

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

{priority.title}

+

{priority.detail}

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

{finding.text}

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

{title}

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

Continue

- -

Acamprosate renal screen

-
-

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

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

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

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

- Next action -

-

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

- - Open item - - -

Last opened Today 08:44

-
- -
-
-

- Sources (3) -

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

Last opened {item.lastUsed}

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

+ Sources (3) +

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

+ Personal note +

- ))} -
- -
+
+
+

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

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

- Personal note -

+
+

More

+
-
-
-

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

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

- Actions -

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

+ Browse sets +

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

+

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

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

Selected services ({selected.length})

+

Selected services ({selected.length})

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

Checklist

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

Source confidence

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

- Referral matches -

-

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

-

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

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

+ Referral matches +

+

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

+

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

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

Evidence review

-

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

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

Pinned source

-

Clozapine physical health protocol

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

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

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

{row.title}

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

{row.detail}

+

{row.detail}

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

+
{sourceOnly ? (
+
- {sourceOnlyNoticeOpen ? ( -
setSourceOnlyNoticeOpen((current) => !current)} + className="inline-flex min-h-7 w-full max-w-[68ch] items-center gap-1.5 px-2 py-1 text-left transition hover:bg-[color:var(--warning-soft)]/55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]" + aria-expanded={sourceOnlyNoticeOpen} + aria-controls="source-only-disclosure-detail" > -

- 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} + + Source-only + · verify passages + + + {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}
{canOpenSourcePreview && !usePreviewSheet ? ( Date: Thu, 9 Jul 2026 03:59:23 +0000 Subject: [PATCH 42/43] fix(ui): keep sources capsule at min-h-tap for WCAG touch targets The sm:min-h-8 override shrank the button to 36px at tablet+ widths, failing expectMinTouchTarget (44px) in ui-smoke. Compact styling is preserved via tighter horizontal padding and smaller typography. Co-authored-by: BigSimmo --- src/components/ui-primitives.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx index 1a8cb9757..9fc8159da 100644 --- a/src/components/ui-primitives.tsx +++ b/src/components/ui-primitives.tsx @@ -75,7 +75,7 @@ export const chatActionRow = export const chatMicroAction = "inline-flex min-h-tap min-w-tap items-center justify-center gap-1.5 rounded-md px-2 text-xs font-semibold text-[color:var(--text-muted)] transition hover:bg-[color:var(--clinical-accent-soft)] hover:text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50"; export const sourceCapsule = - "source-capsule-hover focus-ring-premium inline-flex min-h-tap items-center gap-1 rounded-md border border-[color:var(--border)] bg-[color-mix(in_srgb,var(--clinical-accent-soft)_55%,var(--surface))] px-2 py-1 text-[11px] font-medium text-[color:var(--clinical-accent)] transition hover:border-[color:var(--clinical-accent-border)] sm:min-h-8 sm:px-2.5"; + "source-capsule-hover focus-ring-premium inline-flex min-h-tap items-center gap-1 rounded-md border border-[color:var(--border)] bg-[color-mix(in_srgb,var(--clinical-accent-soft)_55%,var(--surface))] px-2 text-[11px] font-medium text-[color:var(--clinical-accent)] transition hover:border-[color:var(--clinical-accent-border)] sm:px-2.5"; export const sourceCapsuleCountBadge = "nums inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-[color:var(--surface-raised)] px-1.5 text-2xs font-semibold text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)]"; export const evidenceRow = From 01765a188b19807d864864cd0f11960156d77c72 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 03:59:45 +0000 Subject: [PATCH 43/43] chore: fix Prettier formatting in ui-primitives recipes doc Co-authored-by: BigSimmo --- docs/redesign/09-ui-primitives-recipes.md | 28 +++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/redesign/09-ui-primitives-recipes.md b/docs/redesign/09-ui-primitives-recipes.md index c2f929de7..b2553ce0e 100644 --- a/docs/redesign/09-ui-primitives-recipes.md +++ b/docs/redesign/09-ui-primitives-recipes.md @@ -43,20 +43,20 @@ Values are tokens; the target sizes follow the `-11`/`-12` spacing scale (L6). ### Interactive controls (tap targets) -| Recipe | Purpose | Tap | Hover | Active | Focus-visible | Disabled | -| ------------------------ | ------------------------------------- | ---------------------------------------- | ----------------- | ------ | ------------------------- | -------- | -| `controlBase` | Base for buttons (composed by others) | `min-h-11` | — (subclass adds) | ✅ | ✅ | ✅ | -| `primaryControl` | Primary command button | ✅ | ✅ | ✅ | ✅ | ✅ | -| `floatingControl` | Secondary / floating button | `min-h-11` | ✅ | ❌ | ✅ | ✅ | -| `toolbarButton` | Square icon button | `h-11 w-11` | ✅ | ❌ | ✅ | ✅ | -| `navPill` | Nav / segmented pill | `min-h-11` | ✅ | ❌ | ✅ | ✅ | -| `chatMicroAction` | Small chat action (copy, retry…) | `min-h-11 min-w-11` | ✅ | ❌ | ✅ | ✅ | -| `sourceCapsule` | Inline citation capsule | `min-h-11` mobile / `sm:min-h-8` desktop | ✅ | ❌ | ✅ (`focus-ring-premium`) | ❌ | -| `chatComposerIconButton` | Composer icon button | `h-11 w-11` | ✅ | ❌ | ✅ | ✅ | -| `chatSendButton` | Composer send (accent) | `h-11 w-11` | ✅ | ❌ | ✅ | ✅ | -| `sidebarItem` | Sidebar nav row | `min-h-11` | ✅ | ❌ | ✅ | ✅ | -| `sidebarToolTile` | Sidebar tool tile | `min-h-[64px]` | ✅ | ❌ | ✅ | ❌ | -| `shellChip` | Filter / mode chip | `min-h-11` | ✅ | ❌ | ❌ | ❌ | +| Recipe | Purpose | Tap | Hover | Active | Focus-visible | Disabled | +| ------------------------ | ------------------------------------- | ------------------- | ----------------- | ------ | ------------------------- | -------- | +| `controlBase` | Base for buttons (composed by others) | `min-h-11` | — (subclass adds) | ✅ | ✅ | ✅ | +| `primaryControl` | Primary command button | ✅ | ✅ | ✅ | ✅ | ✅ | +| `floatingControl` | Secondary / floating button | `min-h-11` | ✅ | ❌ | ✅ | ✅ | +| `toolbarButton` | Square icon button | `h-11 w-11` | ✅ | ❌ | ✅ | ✅ | +| `navPill` | Nav / segmented pill | `min-h-11` | ✅ | ❌ | ✅ | ✅ | +| `chatMicroAction` | Small chat action (copy, retry…) | `min-h-11 min-w-11` | ✅ | ❌ | ✅ | ✅ | +| `sourceCapsule` | Inline citation capsule | `min-h-11` | ✅ | ❌ | ✅ (`focus-ring-premium`) | ❌ | +| `chatComposerIconButton` | Composer icon button | `h-11 w-11` | ✅ | ❌ | ✅ | ✅ | +| `chatSendButton` | Composer send (accent) | `h-11 w-11` | ✅ | ❌ | ✅ | ✅ | +| `sidebarItem` | Sidebar nav row | `min-h-11` | ✅ | ❌ | ✅ | ✅ | +| `sidebarToolTile` | Sidebar tool tile | `min-h-[64px]` | ✅ | ❌ | ✅ | ❌ | +| `shellChip` | Filter / mode chip | `min-h-11` | ✅ | ❌ | ❌ | ❌ | ### Form fields