diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 58fdb831b6..4f798c3b87 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -36,12 +36,13 @@ This document turns the current process review into phased, durable repo practic - **2026-07-03:** extracted `output-panel.tsx` — `ClinicalOutputPanel` (block 487–688, moved verbatim). Monolith 6084 → 5882 lines. Clean move: empty danger set (no monolith-internal deps) and empty reverse set (the monolith never rendered it — its only consumer is `evidence-panels`'s `ClinicalNotesChecklistPanel`). This **replaced** the evidence-panels↔monolith cycle with an evidence-panels↔output-panel one: `evidence-panels` now imports `ClinicalOutputPanel` from `clinical-dashboard/output-panel` (still imports `clinicalQueryModeOptions` + `type AnswerFeedbackType` from the monolith); `output-panel` imports the detail-helper family + `AnswerViewModeControl`/`EvidenceMapTable` from `evidence-panels`. Retargeted the AST pin (`ClinicalOutputPanel` now resolves via the `scannedFiles` array's `output-panel.tsx` entry) and added `output-panel.tsx` to the `rendered-text-formatting.test.ts` corpus. Stripped 8 now-orphaned monolith imports. +- **2026-07-03:** extracted `visual-evidence.tsx` — `compactClinicalTableCaption`, `visualEvidenceHeader`, `VisualEvidenceStrip`, `InlineTableCard`, `supportDotClass`, `supportLabel`, `claimRowsForEvidencePanel`, `EvidenceClaimsList`, `EvidenceGapsPanel`, `MobileEvidenceSheetContent`, `MobileEvidenceTabPanel`, `UnifiedEvidenceDrawerContent` (contiguous block 547–1259, moved verbatim). Monolith 5953 → 5241 lines. **No runtime cycle** — the only monolith import is `type AnswerFeedbackType` (erased at compile time); the module imports one-way from `evidence-panels`/`answer-content`/siblings, and the monolith imports `InlineTableCard` + `MobileEvidenceSheetContent` back. Added `visual-evidence.tsx` to the `rendered-text-formatting.test.ts` corpus (the `item.tableTextSnippet`/`item.title`/`item.caption` render surfaces moved here). Stripped 22 now-orphaned monolith imports. Done on a **dedicated worktree/branch** `claude/clinical-dashboard-decomp` (off `main`) instead of the shared claude branch, to avoid the squash-ancestry churn. + #### Remaining decomposition — hand-off (do on a stable `main`, one module per commit) -The approved move map (`docs/redesign/04-deferred.md` §2) has 2 modules left. Recommended order: +The approved move map (`docs/redesign/04-deferred.md` §2) has 1 module left: -1. `visual-evidence.tsx` — `VisualEvidenceStrip`, `InlineTableCard`, `MobileEvidenceSheetContent`, `MobileEvidenceTabPanel`, `UnifiedEvidenceDrawerContent` (+ `supportDotClass`/`supportLabel`/`EvidenceClaimsList`/`EvidenceGapsPanel`/`compactClinicalTableCaption`/`visualEvidenceHeader` helpers, currently just after `WhyThisMatchedPanel`). -2. `document-results.tsx` — `WhyThisMatchedPanel`, `RelatedDocumentsPanel`, `StagedAnswerResultSurface`. +1. `document-results.tsx` — `WhyThisMatchedPanel` (~line 481, before visual-evidence was extracted), `RelatedDocumentsPanel`, `StagedAnswerResultSurface`. These are the answer-results surfaces around the former visual-evidence block; check whether `InlineTableCard`/`MobileEvidenceSheetContent` (now imported from `visual-evidence`) are rendered by `StagedAnswerResultSurface` — if so the monolith's `visual-evidence` import moves into `document-results`. For each: trace which module-scope helpers/icons/types it uses; move solely-consumed ones with it, import shared ones; strip newly-orphaned monolith imports (lint flags them); run the per-module gate above; commit immediately. Keep the main `ClinicalDashboard` export in `ClinicalDashboard.tsx` (the barrel/bridge stays). Admin surfaces (`DocumentDrawer`, `SettingsDialog`, `ToolsHub`, `MobileSectionFab`) are out of the approved map — a later pass. diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 8e16a0ceb4..03c49b48e8 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -7,13 +7,10 @@ import { AlertCircle, Bell, BookOpen, - CheckCircle2, ChevronDown, CircleUserRound, ClipboardCheck, - Copy, ExternalLink, - FileImage, FileText, Globe2, HelpCircle, @@ -27,7 +24,6 @@ import { Palette, PanelTop, Plus, - Quote, RefreshCw, Search, Settings as SettingsIcon, @@ -44,7 +40,6 @@ import { X, } from "lucide-react"; import { type CSSProperties, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { AccessibleTable } from "@/components/AccessibleTable"; import { DocumentOrganizationBadges, documentDisplayTitle, @@ -53,15 +48,12 @@ import { import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; -import { formatCompactCitationLabel } from "@/lib/citations"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; import { isLocalNoAuthMode } from "@/lib/env"; import { appBackdrop, answerSurface, - chatMicroAction, - clinicalDivider, clinicalNotesRow, cn, evidenceRow, @@ -78,9 +70,6 @@ import { SourceStatusBadge, sourceCard, subtleStatusPill, - tableCard, - tableCardHeader, - tableMicroActionRow, textMuted, toneDanger, toneInfo, @@ -113,12 +102,7 @@ import { type SetupCheck, type IngestionQualityReviewItem, } from "@/components/clinical-dashboard/DocumentManagerPanel"; -import { - GuideDialog, - GuideTrigger, - SectionHeading, - UtilityDrawer, -} from "@/components/clinical-dashboard/dashboard-shell"; +import { GuideDialog, GuideTrigger, UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell"; import { cleanDisplayTitle, sanitizeAnswerDisplayText, @@ -127,12 +111,9 @@ import { import { NaturalLanguageAnswer, ScopeAndGovernanceNotice, - SourceImage, UserQuestionBubble, } from "@/components/clinical-dashboard/answer-content"; import { - AnswerFeedbackPanel, - AnswerSafetyNotice, AnswerSupportSummaryCard, answerHasCentralTable, answerSupportPriority, @@ -141,17 +122,13 @@ import { clinicalNotesDisplayCountForAnswer, compactEvidenceSummary, type EvidenceTabName, - simpleClinicalTableProps, evidenceMapRowsFromRenderModel, - evidenceTabCount, - evidenceTabOrder, formatQuoteCardsForClipboard, primaryVisualTable, - QuoteCards, SafetyFindingsPanel, - VerificationWorkspace, } from "@/components/clinical-dashboard/evidence-panels"; import { useMobilePreviewSheet } from "@/components/clinical-dashboard/use-mobile-preview-sheet"; +import { InlineTableCard, MobileEvidenceSheetContent } from "@/components/clinical-dashboard/visual-evidence"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { emptyStates, errorCopy } from "@/lib/ui-copy"; import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; @@ -211,13 +188,11 @@ import { rankFormRecords } from "@/lib/forms"; import { rankServiceRecords } from "@/lib/services"; import { useRegistryRecords } from "@/lib/use-registry-records"; import { buildAnswerRenderModel, type AnswerRenderModel } from "@/lib/answer-render-policy"; -import { sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer"; import { frontendSourceGovernanceWarnings, groupSourceGovernanceWarnings, type SourceGovernanceWarning, } from "@/lib/source-governance"; -import { smartEvidenceTags } from "@/lib/evidence-tags"; import { documentLabelReviewStatus, documentLabelTier, @@ -544,719 +519,6 @@ function WhyThisMatchedPanel({ sources }: { sources: SearchResult[] }) { ); } -function compactClinicalTableCaption(item: VisualEvidenceCard) { - const raw = item.tableTitle || item.tableLabel || item.caption || "Clinical table"; - const cleaned = sourceTextForCompactDisplay(raw) - .replace(/\btable\s+\d+\s*[:.-]?\s*/i, "") - .replace(/\b(?:page|p\.)\s*\d+\b/gi, "") - .replace(/\s{2,}/g, " ") - .trim(); - const caption = cleaned || "Clinical table"; - return caption.length <= 72 ? caption : `${caption.slice(0, 69).trim()}...`; -} - -function visualEvidenceHeader(item: VisualEvidenceCard) { - const titleSource = [item.tableLabel, item.tableTitle].filter(Boolean).join(" · "); - const titleText = sourceTextForCompactDisplay(titleSource).trim(); - const captionText = sourceTextForCompactDisplay(item.caption ?? "").trim(); - const normalizedTitle = titleText.toLowerCase(); - const normalizedCaption = captionText.toLowerCase(); - const isDuplicateCaption = - Boolean(normalizedCaption) && - (normalizedCaption.startsWith(normalizedTitle) || normalizedCaption === normalizedTitle); - return { - title: titleText || captionText || "Visual evidence", - caption: isDuplicateCaption ? null : captionText, - }; -} - -function VisualEvidenceStrip({ - evidence, - collapsed = false, - embedded = false, -}: { - evidence: VisualEvidenceCard[]; - collapsed?: boolean; - embedded?: boolean; -}) { - function looksLikeTableText(value?: string | null) { - return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3); - } - - if (collapsed) { - return ( -
- - - -
- ); - } - - const content = ( - <> - - {evidence.length === 0 ? ( - - ) : ( -
- {evidence.map((item) => { - const tableMarkdown = item.accessibleTableMarkdown?.trim() - ? item.accessibleTableMarkdown - : looksLikeTableText(item.tableTextSnippet) - ? item.tableTextSnippet - : null; - const hasStructuredTable = Boolean(tableMarkdown || item.tableRows?.length || item.tableColumns?.length); - const tableCaption = compactClinicalTableCaption(item); - const sourceHeader = visualEvidenceHeader(item); - const displayLabels = smartEvidenceTags( - item.labels, - [[item.tableLabel, item.tableTitle].filter(Boolean).join(": "), item.caption, item.tableTextSnippet] - .filter(Boolean) - .join(" "), - ); - return ( -
-
- -
-
- {!hasStructuredTable ?

{sourceHeader.title}

: null} - {!hasStructuredTable && sourceHeader.caption ?

{sourceHeader.caption}

: null} - - {!hasStructuredTable && item.tableTextSnippet ? ( -

- {sourceTextForCompactDisplay(item.tableTextSnippet)} -

- ) : null} - {displayLabels.length ? ( -
- {displayLabels.map((label) => ( - - {label} - - ))} -
- ) : null} -
-
- - {formatCompactCitationLabel(item)} - - - {cleanDisplayTitle(item.title)}, page {item.page_number ?? "n/a"} - - {item.image_type && ( - - {item.image_type.replaceAll("_", " ")} - - )} - {!hasStructuredTable ? : null} - - - Open source - -
-
- ); - })} -
- )} - - ); - - if (embedded) return
{content}
; - - return
{content}
; -} - -function InlineTableCard({ item }: { item: VisualEvidenceCard }) { - const tableMarkdown = item.accessibleTableMarkdown?.trim() ? item.accessibleTableMarkdown : null; - const title = compactClinicalTableCaption(item); - - return ( -
-
- {title} - {title} -
- - - -
-
-
- -
-
- - Expand - - - Source - -
-
- ); -} - -const evidenceTabIconMap: Record = { - Claims: CheckCircle2, - Quotes: Quote, - Tables: ListChecks, - Images: FileImage, - Gaps: AlertCircle, -}; - -function supportDotClass(supportLevel: string) { - const normalized = supportLevel.toLowerCase(); - if (normalized.includes("unsupported") || normalized.includes("none")) return "bg-[color:var(--danger)]"; - if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) { - return "bg-[color:var(--warning)]"; - } - return "bg-[color:var(--clinical-accent)]"; -} - -function supportLabel(supportLevel: string) { - const normalized = supportLevel.toLowerCase(); - if (normalized.includes("unsupported") || normalized.includes("none")) return "Unsupported"; - if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) - return "Partial"; - return "Direct"; -} - -function claimRowsForEvidencePanel(rows: AnswerEvidenceMapRow[], renderModel: AnswerRenderModel) { - if (rows.length) return rows.slice(0, 6); - return renderModel.primarySources.slice(0, 6).map((source, index) => ({ - id: source.id, - section: source.label || cleanDisplayTitle(source.title || source.file_name) || `Source ${index + 1}`, - detail: source.snippet || source.reason || "Open source passage to review the cited evidence.", - supportLevel: source.sourceStrength === "none" ? "partial" : source.sourceStrength, - citationCount: 1, - sourceStatus: - source.sourceStrength === "none" ? "Source requires review" : `${source.sourceStrength} source support`, - bestSourceLabel: source.label, - bestLinkedPassage: source.snippet || source.reason, - href: source.href, - })); -} - -function EvidenceClaimsList({ rows, renderModel }: { rows: AnswerEvidenceMapRow[]; renderModel: AnswerRenderModel }) { - const claimRows = claimRowsForEvidencePanel(rows, renderModel); - const directCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Direct").length; - const partialCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Partial").length; - - if (!claimRows.length) { - return ; - } - - return ( -
-
-
-

Claims checked

-
- - - Direct - - - - Partial - - - - Unsupported - -
-
-

- {directCount} direct · {partialCount} partial -

-
- -
- {claimRows.map((row, index) => ( - - - - - {row.section} - - {row.detail || row.bestLinkedPassage || row.bestSourceLabel} - - - - - ))} -
-
- ); -} - -function EvidenceGapsPanel({ warnings }: { warnings: string[] }) { - if (!warnings.length) { - return ( - - ); - } - - return ( -
- {warnings.map((warning, index) => ( -
- -
-

Gap {index + 1}

-

{warning}

-
-
- ))} -
- ); -} - -function MobileEvidenceSheetContent({ - answer, - sources, - renderModel, - query, - visualEvidence, - answerEvidenceMapRows, - sourceGovernanceWarnings, - demoMode, - initialTab, - pendingFeedback, - copiedQuotes, - onCopyQuotes, - onSubmitFeedback, - onFollowUpQuote, - onScopeDocument, -}: { - answer: RagAnswer; - sources: SearchResult[]; - renderModel: AnswerRenderModel; - query: string; - visualEvidence: VisualEvidenceCard[]; - answerEvidenceMapRows: AnswerEvidenceMapRow[]; - sourceGovernanceWarnings: SourceGovernanceWarning[]; - demoMode: boolean; - initialTab?: EvidenceTabName | null; - pendingFeedback: AnswerFeedbackType | null; - copiedQuotes: boolean; - onCopyQuotes: () => void; - onSubmitFeedback: (feedbackType: AnswerFeedbackType) => void; - onFollowUpQuote?: (quote: QuoteCard) => void; - onScopeDocument: (documentId: string) => void; -}) { - const order = evidenceTabOrder(answer, renderModel); - const [selectedTab, setSelectedTab] = useState(() => initialTab ?? null); - const activeTab = selectedTab && order.includes(selectedTab) ? selectedTab : order[0]; - const panelIdFor = (tab: EvidenceTabName) => `mobile-evidence-panel-${tab.toLowerCase()}`; - const [added, setAdded] = useState(false); - const primarySourceHref = renderModel.primarySources[0]?.href; - async function copyEvidence() { - if (renderModel.quoteCards.length) { - onCopyQuotes(); - return; - } - try { - await navigator.clipboard.writeText(renderModel.copyText); - } catch { - // Clipboard writes can fail in locked-down browsers; keep the panel usable. - } - } - - return ( -
-
-
- {order.map((tab) => { - const selected = tab === activeTab; - const Icon = evidenceTabIconMap[tab]; - const count = evidenceTabCount({ - tab, - sources, - visualEvidence, - answerEvidenceMapRows, - renderModel, - }); - return ( - - ); - })} -
-
- -
- {order.map((tab) => { - const selected = tab === activeTab; - return ( - - ); - })} -
- - - -
-
- {primarySourceHref ? ( - - - Source - - ) : ( - - - Source - - )} - - -
-
-
- ); -} - -function MobileEvidenceTabPanel({ - tab, - renderModel, - query, - visualEvidence, - answerEvidenceMapRows, - copiedQuotes, - onCopyQuotes, - onFollowUpQuote, - onScopeDocument, -}: { - tab: EvidenceTabName; - renderModel: AnswerRenderModel; - query: string; - visualEvidence: VisualEvidenceCard[]; - answerEvidenceMapRows: AnswerEvidenceMapRow[]; - copiedQuotes: boolean; - onCopyQuotes: () => void; - onFollowUpQuote?: (quote: QuoteCard) => void; - onScopeDocument: (documentId: string) => void; -}) { - if (tab === "Claims") { - return ; - } - - if (tab === "Tables") { - const tableEvidence = visualEvidence.filter((item) => item.accessibleTableMarkdown || item.tableRows?.length); - return tableEvidence.length ? ( -
- {tableEvidence.slice(0, 4).map((item, index) => ( -
- - - -
-

- {compactClinicalTableCaption(item)} -

-

- Table {index + 1} · p.{item.page_number ?? "n/a"} -

-
- - - -
- ))} -
- ) : ( - - ); - } - - if (tab === "Images") { - return visualEvidence.length ? ( - - ) : ( - - ); - } - - if (tab === "Quotes") { - return ( - - ); - } - - return ; -} - -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, diff --git a/src/components/clinical-dashboard/visual-evidence.tsx b/src/components/clinical-dashboard/visual-evidence.tsx new file mode 100644 index 0000000000..286429be5b --- /dev/null +++ b/src/components/clinical-dashboard/visual-evidence.tsx @@ -0,0 +1,769 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { + AlertCircle, + BookOpen, + CheckCircle2, + ChevronDown, + Copy, + ExternalLink, + FileImage, + Layers, + ListChecks, + Plus, + Quote, +} from "lucide-react"; + +import { AccessibleTable } from "@/components/AccessibleTable"; +import { type AnswerFeedbackType } from "@/components/ClinicalDashboard"; +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"; +import { + AnswerFeedbackPanel, + AnswerSafetyNotice, + type EvidenceTabName, + evidenceTabCount, + evidenceTabOrder, + QuoteCards, + simpleClinicalTableProps, + VerificationWorkspace, +} from "@/components/clinical-dashboard/evidence-panels"; +import { QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; +import { + chatMicroAction, + clinicalDivider, + cn, + EmptyState, + floatingControl, + iconTilePremium, + metadataPill, + sourceCard, + tableCard, + tableCardHeader, + tableMicroActionRow, + textMuted, +} from "@/components/ui-primitives"; +import { type AnswerRenderModel } from "@/lib/answer-render-policy"; +import { formatCompactCitationLabel } from "@/lib/citations"; +import { smartEvidenceTags } from "@/lib/evidence-tags"; +import { type SourceGovernanceWarning } from "@/lib/source-governance"; +import { sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer"; +import type { QuoteCard, RagAnswer, SearchResult, VisualEvidenceCard } from "@/lib/types"; +import { emptyStates } from "@/lib/ui-copy"; +import { type AnswerEvidenceMapRow } from "@/lib/ward-output"; + +function compactClinicalTableCaption(item: VisualEvidenceCard) { + const raw = item.tableTitle || item.tableLabel || item.caption || "Clinical table"; + const cleaned = sourceTextForCompactDisplay(raw) + .replace(/\btable\s+\d+\s*[:.-]?\s*/i, "") + .replace(/\b(?:page|p\.)\s*\d+\b/gi, "") + .replace(/\s{2,}/g, " ") + .trim(); + const caption = cleaned || "Clinical table"; + return caption.length <= 72 ? caption : `${caption.slice(0, 69).trim()}...`; +} + +function visualEvidenceHeader(item: VisualEvidenceCard) { + const titleSource = [item.tableLabel, item.tableTitle].filter(Boolean).join(" · "); + const titleText = sourceTextForCompactDisplay(titleSource).trim(); + const captionText = sourceTextForCompactDisplay(item.caption ?? "").trim(); + const normalizedTitle = titleText.toLowerCase(); + const normalizedCaption = captionText.toLowerCase(); + const isDuplicateCaption = + Boolean(normalizedCaption) && + (normalizedCaption.startsWith(normalizedTitle) || normalizedCaption === normalizedTitle); + return { + title: titleText || captionText || "Visual evidence", + caption: isDuplicateCaption ? null : captionText, + }; +} + +function VisualEvidenceStrip({ + evidence, + collapsed = false, + embedded = false, +}: { + evidence: VisualEvidenceCard[]; + collapsed?: boolean; + embedded?: boolean; +}) { + function looksLikeTableText(value?: string | null) { + return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3); + } + + if (collapsed) { + return ( +
+ + + +
+ ); + } + + const content = ( + <> + + {evidence.length === 0 ? ( + + ) : ( +
+ {evidence.map((item) => { + const tableMarkdown = item.accessibleTableMarkdown?.trim() + ? item.accessibleTableMarkdown + : looksLikeTableText(item.tableTextSnippet) + ? item.tableTextSnippet + : null; + const hasStructuredTable = Boolean(tableMarkdown || item.tableRows?.length || item.tableColumns?.length); + const tableCaption = compactClinicalTableCaption(item); + const sourceHeader = visualEvidenceHeader(item); + const displayLabels = smartEvidenceTags( + item.labels, + [[item.tableLabel, item.tableTitle].filter(Boolean).join(": "), item.caption, item.tableTextSnippet] + .filter(Boolean) + .join(" "), + ); + return ( +
+
+ +
+
+ {!hasStructuredTable ?

{sourceHeader.title}

: null} + {!hasStructuredTable && sourceHeader.caption ?

{sourceHeader.caption}

: null} + + {!hasStructuredTable && item.tableTextSnippet ? ( +

+ {sourceTextForCompactDisplay(item.tableTextSnippet)} +

+ ) : null} + {displayLabels.length ? ( +
+ {displayLabels.map((label) => ( + + {label} + + ))} +
+ ) : null} +
+
+ + {formatCompactCitationLabel(item)} + + + {cleanDisplayTitle(item.title)}, page {item.page_number ?? "n/a"} + + {item.image_type && ( + + {item.image_type.replaceAll("_", " ")} + + )} + {!hasStructuredTable ? : null} + + + Open source + +
+
+ ); + })} +
+ )} + + ); + + if (embedded) return
{content}
; + + return
{content}
; +} + +export function InlineTableCard({ item }: { item: VisualEvidenceCard }) { + const tableMarkdown = item.accessibleTableMarkdown?.trim() ? item.accessibleTableMarkdown : null; + const title = compactClinicalTableCaption(item); + + return ( +
+
+ {title} + {title} +
+ + + +
+
+
+ +
+
+ + Expand + + + Source + +
+
+ ); +} + +const evidenceTabIconMap: Record = { + Claims: CheckCircle2, + Quotes: Quote, + Tables: ListChecks, + Images: FileImage, + Gaps: AlertCircle, +}; + +function supportDotClass(supportLevel: string) { + const normalized = supportLevel.toLowerCase(); + if (normalized.includes("unsupported") || normalized.includes("none")) return "bg-[color:var(--danger)]"; + if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) { + return "bg-[color:var(--warning)]"; + } + return "bg-[color:var(--clinical-accent)]"; +} + +function supportLabel(supportLevel: string) { + const normalized = supportLevel.toLowerCase(); + if (normalized.includes("unsupported") || normalized.includes("none")) return "Unsupported"; + if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) + return "Partial"; + return "Direct"; +} + +function claimRowsForEvidencePanel(rows: AnswerEvidenceMapRow[], renderModel: AnswerRenderModel) { + if (rows.length) return rows.slice(0, 6); + return renderModel.primarySources.slice(0, 6).map((source, index) => ({ + id: source.id, + section: source.label || cleanDisplayTitle(source.title || source.file_name) || `Source ${index + 1}`, + detail: source.snippet || source.reason || "Open source passage to review the cited evidence.", + supportLevel: source.sourceStrength === "none" ? "partial" : source.sourceStrength, + citationCount: 1, + sourceStatus: + source.sourceStrength === "none" ? "Source requires review" : `${source.sourceStrength} source support`, + bestSourceLabel: source.label, + bestLinkedPassage: source.snippet || source.reason, + href: source.href, + })); +} + +function EvidenceClaimsList({ rows, renderModel }: { rows: AnswerEvidenceMapRow[]; renderModel: AnswerRenderModel }) { + const claimRows = claimRowsForEvidencePanel(rows, renderModel); + const directCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Direct").length; + const partialCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Partial").length; + + if (!claimRows.length) { + return ; + } + + return ( +
+
+
+

Claims checked

+
+ + + Direct + + + + Partial + + + + Unsupported + +
+
+

+ {directCount} direct · {partialCount} partial +

+
+ +
+ {claimRows.map((row, index) => ( + + + + + {row.section} + + {row.detail || row.bestLinkedPassage || row.bestSourceLabel} + + + + + ))} +
+
+ ); +} + +function EvidenceGapsPanel({ warnings }: { warnings: string[] }) { + if (!warnings.length) { + return ( + + ); + } + + return ( +
+ {warnings.map((warning, index) => ( +
+ +
+

Gap {index + 1}

+

{warning}

+
+
+ ))} +
+ ); +} + +export function MobileEvidenceSheetContent({ + answer, + sources, + renderModel, + query, + visualEvidence, + answerEvidenceMapRows, + sourceGovernanceWarnings, + demoMode, + initialTab, + pendingFeedback, + copiedQuotes, + onCopyQuotes, + onSubmitFeedback, + onFollowUpQuote, + onScopeDocument, +}: { + answer: RagAnswer; + sources: SearchResult[]; + renderModel: AnswerRenderModel; + query: string; + visualEvidence: VisualEvidenceCard[]; + answerEvidenceMapRows: AnswerEvidenceMapRow[]; + sourceGovernanceWarnings: SourceGovernanceWarning[]; + demoMode: boolean; + initialTab?: EvidenceTabName | null; + pendingFeedback: AnswerFeedbackType | null; + copiedQuotes: boolean; + onCopyQuotes: () => void; + onSubmitFeedback: (feedbackType: AnswerFeedbackType) => void; + onFollowUpQuote?: (quote: QuoteCard) => void; + onScopeDocument: (documentId: string) => void; +}) { + const order = evidenceTabOrder(answer, renderModel); + const [selectedTab, setSelectedTab] = useState(() => initialTab ?? null); + const activeTab = selectedTab && order.includes(selectedTab) ? selectedTab : order[0]; + const panelIdFor = (tab: EvidenceTabName) => `mobile-evidence-panel-${tab.toLowerCase()}`; + const [added, setAdded] = useState(false); + const primarySourceHref = renderModel.primarySources[0]?.href; + async function copyEvidence() { + if (renderModel.quoteCards.length) { + onCopyQuotes(); + return; + } + try { + await navigator.clipboard.writeText(renderModel.copyText); + } catch { + // Clipboard writes can fail in locked-down browsers; keep the panel usable. + } + } + + return ( +
+
+
+ {order.map((tab) => { + const selected = tab === activeTab; + const Icon = evidenceTabIconMap[tab]; + const count = evidenceTabCount({ + tab, + sources, + visualEvidence, + answerEvidenceMapRows, + renderModel, + }); + return ( + + ); + })} +
+
+ +
+ {order.map((tab) => { + const selected = tab === activeTab; + return ( + + ); + })} +
+ + + +
+
+ {primarySourceHref ? ( + + + Source + + ) : ( + + + Source + + )} + + +
+
+
+ ); +} + +function MobileEvidenceTabPanel({ + tab, + renderModel, + query, + visualEvidence, + answerEvidenceMapRows, + copiedQuotes, + onCopyQuotes, + onFollowUpQuote, + onScopeDocument, +}: { + tab: EvidenceTabName; + renderModel: AnswerRenderModel; + query: string; + visualEvidence: VisualEvidenceCard[]; + answerEvidenceMapRows: AnswerEvidenceMapRow[]; + copiedQuotes: boolean; + onCopyQuotes: () => void; + onFollowUpQuote?: (quote: QuoteCard) => void; + onScopeDocument: (documentId: string) => void; +}) { + if (tab === "Claims") { + return ; + } + + if (tab === "Tables") { + const tableEvidence = visualEvidence.filter((item) => item.accessibleTableMarkdown || item.tableRows?.length); + return tableEvidence.length ? ( +
+ {tableEvidence.slice(0, 4).map((item, index) => ( +
+ + + +
+

+ {compactClinicalTableCaption(item)} +

+

+ Table {index + 1} · p.{item.page_number ?? "n/a"} +

+
+ + + +
+ ))} +
+ ) : ( + + ); + } + + if (tab === "Images") { + return visualEvidence.length ? ( + + ) : ( + + ); + } + + if (tab === "Quotes") { + return ( + + ); + } + + return ; +} + +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

+ +
+ ); + })} +
+ ); +} diff --git a/tests/rendered-text-formatting.test.ts b/tests/rendered-text-formatting.test.ts index 075f8503d4..89fc328480 100644 --- a/tests/rendered-text-formatting.test.ts +++ b/tests/rendered-text-formatting.test.ts @@ -21,7 +21,8 @@ describe("document-derived text must route through a formatter", () => { const answerContent = componentSource("clinical-dashboard/answer-content.tsx"); const evidenceContent = componentSource("clinical-dashboard/evidence-panels.tsx"); const outputPanel = componentSource("clinical-dashboard/output-panel.tsx"); - const dashboardSurfaces = `${dashboard}\n${answerContent}\n${evidenceContent}\n${outputPanel}`; + const visualEvidence = componentSource("clinical-dashboard/visual-evidence.tsx"); + const dashboardSurfaces = `${dashboard}\n${answerContent}\n${evidenceContent}\n${outputPanel}\n${visualEvidence}`; it("renders exact quotes through the verbatim cleaner, never raw", () => { // Allow `${quote.quote}` inside template literals (React keys, clipboard text);