diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 161f81fcc4..c0f5b6f48a 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1597,8 +1597,8 @@ "src/components/caring-contacts/workspace/overlays/overlay-host.tsx", "src/components/clinical-dashboard/ClinicalSidebar.tsx", "src/components/clinical-dashboard/account-setup-dialog.tsx", - "src/components/clinical-dashboard/answer-content.tsx", "src/components/clinical-dashboard/answer-result-surface.tsx", + "src/components/clinical-dashboard/answer-source-drawer.tsx", "src/components/clinical-dashboard/clinical-ask-workspace.tsx", "src/components/clinical-dashboard/dashboard-shell.tsx", "src/components/clinical-dashboard/guide-dialog.tsx", @@ -1628,8 +1628,8 @@ "src/components/caring-contacts/workspace/overlays/overlay-host.tsx", "src/components/clinical-dashboard/ClinicalSidebar.tsx", "src/components/clinical-dashboard/account-setup-dialog.tsx", - "src/components/clinical-dashboard/answer-content.tsx", "src/components/clinical-dashboard/answer-result-surface.tsx", + "src/components/clinical-dashboard/answer-source-drawer.tsx", "src/components/clinical-dashboard/clinical-ask-workspace.tsx", "src/components/clinical-dashboard/dashboard-shell.tsx", "src/components/clinical-dashboard/guide-dialog.tsx", diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index fb1ba96d22..54ca728255 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -95,7 +95,6 @@ import { } from "@/components/clinical-dashboard/answer-progress"; import { AnswerEvidencePreview } from "@/components/clinical-dashboard/answer-evidence-preview"; import { requestAnswerStream } from "@/components/clinical-dashboard/answer-request"; -import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-map-model"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { PhoneFooterLayerFrame } from "@/components/clinical-dashboard/phone-footer-layer-portal"; import { @@ -461,7 +460,10 @@ function ClinicalDashboardContent({ const [scopeFilters, setScopeFilters] = useState(initialSearchNavigationContext.scopeFilters); const [searchScope, setSearchScope] = useState(null); const [sourceGovernanceWarnings, setSourceGovernanceWarnings] = useState([]); - const [answerViewMode, setAnswerViewMode] = useState("high_yield"); + // Write-only for now: the clinical-notes panel was its only reader and the source + // drawer replaced that panel. The state and its resets stay until the panel itself + // is removed (handover §8), so re-wiring a view mode does not have to be rebuilt. + const [, setAnswerViewMode] = useState("high_yield"); const [bulkActionStatus, setBulkActionStatus] = useState(null); const [bulkActionBusy, setBulkActionBusy] = useState(false); const [loading, setLoading] = useState(false); @@ -2891,11 +2893,6 @@ function ClinicalDashboardContent({ }) .filter((section): section is AnswerSection & { citationSources: SearchResult[] } => section !== null); }, [answer?.answerSections, answerPreformatted, sourceLookup]); - const answerEvidenceMapRows = useMemo(() => { - if (!answerRenderModel?.allowedBlocks.includes("evidenceMap")) return []; - return evidenceMapRowsFromRenderModel(answerRenderModel).slice(0, answerRenderModel.trust === "high" ? 8 : 6); - }, [answerRenderModel]); - const showSystemNotice = Boolean(setupWarning && !demoMode); const groupedGovernanceWarningCount = useMemo( () => @@ -3790,12 +3787,8 @@ function ClinicalDashboardContent({ sourceSummary={sourceSummary} renderModel={answerRenderModel} weakEvidence={weakEvidence} - answerViewMode={answerViewMode} - answerEvidenceMapRows={answerEvidenceMapRows} - onScopeDocument={handleScopeDocument} answerGrounded={answerGrounded} sources={answerRenderModel.reviewSources} - demoMode={demoMode} safeAnswerSections={safeAnswerSections} safetyFindings={safetyFindings} copiedAnswer={copiedAction === "answer"} diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 632e471162..2e4c7617d2 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -1,39 +1,15 @@ "use client"; -import Link from "next/link"; -import { memo, useEffect, useRef, useState } from "react"; -import { CircleAlert, ChevronDown, Copy, ExternalLink, Layers, ShieldCheck, Sparkles } from "lucide-react"; +import { memo, useState } from "react"; +import { CircleAlert, ChevronDown, Copy, ShieldCheck } from "lucide-react"; import { SafeBoldText } from "@/components/SafeBoldText"; -import { Sheet } from "@/components/ui/sheet"; -import { - chatActionRow, - chatAnswerText, - chatMicroAction, - cn, - sourceCapsule, - sourceCapsuleCountBadge, - sourceCapsuleHit, - StatusDotMarker, - statusDotMuted, - statusDotReady, - statusDotReview, - type StatusDotTone, - subtleStatusPill, - textMuted, -} from "@/components/ui-primitives"; -import { sourceResultHref, logSourceOpen } from "@/components/clinical-dashboard/source-actions"; -import { - cleanDisplayTitle, - comparableAnswerText, - sanitizeAnswerDisplayText, - sourceQuoteDisplayText, -} from "@/components/clinical-dashboard/display-text"; +import { chatActionRow, chatAnswerText, chatMicroAction, cn, textMuted } from "@/components/ui-primitives"; +import { comparableAnswerText, sanitizeAnswerDisplayText } from "@/components/clinical-dashboard/display-text"; import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences"; -import { useMobilePreviewSheet } from "@/components/clinical-dashboard/use-mobile-preview-sheet"; -import { SourcePreviewPopover } from "@/components/clinical-dashboard/source-preview-popover"; +import { AnswerSourceRail } from "@/components/clinical-dashboard/answer-source-rail"; +import { buildAnswerSourceRows } from "@/components/clinical-dashboard/answer-source-rows"; import { SignedImage } from "@/components/clinical-dashboard/signed-image"; -import { normalizeSourceMetadata, sourceStatusLabel } from "@/lib/source-metadata"; import { clinicalProseUsefulness } from "@/lib/source-text-sanitizer"; import { type SourceLink } from "@/lib/answer-render-policy"; import type { @@ -200,287 +176,20 @@ export function primaryAnswerDisplayText(value: string, options: AnswerDisplayTe return selected.join(" ") || cleaned; } -// One compact "Sources" pill in every state: the amber Source-only pill and the -// "Review source match" banner already carry the verify-first caveat, so the -// capsule label no longer restates grounding strength. -// With the compact-citations preference on, the pill drops its text label to -// icon + count; the "No direct source found" warning always stays worded — -// compact mode must never hide a missing-source signal. -export function sourceCapsuleDisplay({ sourceCount, compact = false }: { sourceCount: number; compact?: boolean }): { - label: string; - showLabelText: boolean; - showCountBadge: boolean; -} { - if (sourceCount <= 0) return { label: "No direct source found", showLabelText: true, showCountBadge: false }; - return { label: "Sources", showLabelText: !compact, showCountBadge: true }; -} - -export function sourceStatusDotTone( - metadata: ReturnType | null | undefined, -): StatusDotTone { - if (!metadata) return "muted"; - if (metadata.document_status === "current") return "ready"; - if (metadata.document_status === "review_due" || metadata.document_status === "outdated") return "review"; - return "muted"; -} - -export function sourceStatusDotClass(metadata: ReturnType | null | undefined) { - const tone = sourceStatusDotTone(metadata); - if (tone === "ready") return statusDotReady; - if (tone === "review") return statusDotReview; - return statusDotMuted; -} - -export function sourceStatusShortLabel(metadata: ReturnType) { - if (metadata.document_status === "review_due") return "Review due"; - if (metadata.document_status === "outdated") return "Outdated"; - if (metadata.document_status === "current") return "Current"; - return sourceStatusLabel(metadata); -} - -type CapsulePreviewSource = { - id: string; - documentId: string; - title: string; - fileName?: string; - pageNumber: number | null; - metadata: ReturnType; - sourceMetadata?: SearchResult["source_metadata"]; - score: number; - href: string; - snippet?: string; - sourceStrength?: - SourceLink["sourceStrength"] | BestSourceRecommendation["source_strength"] | SearchResult["source_strength"]; -}; - -function sourceBadgeLabel(index: number) { - return `S${index + 1}`; -} - -function sourceBadgeToneClass(metadata: ReturnType, index: number) { - if (metadata.document_status === "review_due" || metadata.document_status === "outdated") { - return "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]"; - } - if (index === 0) { - return "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)]"; - } - return "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"; -} - -function sourceSupportLabel(source: CapsulePreviewSource, index: number) { - if (!source.sourceStrength || source.sourceStrength === "none") return "Unsupported"; - if (source.sourceStrength === "limited") return "Partial"; - if (source.sourceStrength === "moderate") return "Partial"; - if (index === 0 || source.sourceStrength === "strong") return "Direct"; - return "Partial"; -} - -function sourcePreviewPageCountLabel(previewSources: CapsulePreviewSource[]) { - const uniquePages = new Set(previewSources.map((source) => source.pageNumber).filter((page) => page !== null)); - const count = uniquePages.size || previewSources.length; - return `${count} page${count === 1 ? "" : "s"}`; -} - -function capsulePreviewSources( - bestSource: BestSourceRecommendation | null, - sources: SearchResult[], - sourceLinks: SourceLink[] = [], -) { - const rows: CapsulePreviewSource[] = []; - const seen = new Set(); - const pushRow = (row: CapsulePreviewSource) => { - const key = `${row.id}:${row.title}:${row.pageNumber ?? "n/a"}`; - if (seen.has(key)) return; - seen.add(key); - rows.push(row); - }; - - sourceLinks.slice(0, 5).forEach((source) => { - pushRow({ - id: source.chunk_id, - documentId: source.document_id, - title: source.title || source.file_name || "Source", - fileName: source.file_name, - pageNumber: source.page_number, - metadata: normalizeSourceMetadata(source.sourceMetadata), - sourceMetadata: source.sourceMetadata, - score: source.score ?? 0, - href: source.href, - snippet: source.snippet, - sourceStrength: source.sourceStrength, - }); - }); - - if (bestSource) { - pushRow({ - id: bestSource.chunk_id, - documentId: bestSource.document_id, - title: bestSource.title || bestSource.file_name || "Source", - fileName: bestSource.file_name, - pageNumber: bestSource.page_number, - metadata: normalizeSourceMetadata(bestSource.source_metadata), - sourceMetadata: bestSource.source_metadata, - score: bestSource.score, - href: bestSource.viewer_href, - sourceStrength: bestSource.source_strength, - }); - } - - sources.slice(0, 5).forEach((source) => { - pushRow({ - id: source.id, - documentId: source.document_id, - title: source.title || source.file_name || "Source", - fileName: source.file_name, - pageNumber: source.page_number, - metadata: normalizeSourceMetadata(source.source_metadata), - sourceMetadata: source.source_metadata, - score: source.hybrid_score ?? source.similarity ?? source.lexical_score ?? 0, - href: sourceResultHref(source), - sourceStrength: source.source_strength, - }); - }); - - return rows.slice(0, 4); -} - -function SourcePreviewContent({ - query, - previewSources, - quoteText, - copiedQuote, - onCopyQuote, - showHeader = true, -}: { - query?: string; - previewSources: CapsulePreviewSource[]; - quoteText?: string | null; - copiedQuote: boolean; - onCopyQuote: () => void; - showHeader?: boolean; -}) { - const primaryPreviewSource = previewSources[0] ?? null; - - return ( - <> - {showHeader ? ( -
-
-
-

Sources

- - {sourcePreviewPageCountLabel(previewSources)} - -
-

Check the answer against the cited PDF passage.

-
-
- ) : null} -
- {previewSources.map((source, index) => ( -
- {index === 0 ? ( -

-

- ) : index === 1 ? ( -

Also used

- ) : null} -
- - {sourceBadgeLabel(index)} - - - query && logSourceOpen(query, source)} - data-testid="source-capsule-preview-row" - className="flex min-h-12 items-center rounded-md text-sm font-semibold leading-5 text-[color:var(--text-heading)] transition hover:text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - aria-label={`Open source ${cleanDisplayTitle(source.title)}, page ${source.pageNumber ?? "not available"}`} - > - {cleanDisplayTitle(source.title)} - - - p. {source.pageNumber ?? "n/a"} - · - {sourceSupportLabel(source, index)} - - - - query && logSourceOpen(query, source)} - className={cn( - index === 0 - ? "inline-flex min-h-12 items-center gap-1.5 rounded-md border border-[color:var(--border)] bg-[color:var(--surface-raised)] px-2.5 text-xs font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent-border)]" - : "grid h-12 w-12 place-items-center rounded-md text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--clinical-accent)]", - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", - )} - aria-label={`Open ${sourceBadgeLabel(index)} source page`} - > -
-
- ))} -
- {quoteText ? ( -
-

Cited passage

-
- “{quoteText}” -
-
- ) : null} -
- {primaryPreviewSource ? ( - query && logSourceOpen(query, primaryPreviewSource)} - className={chatMicroAction} - aria-label={`Open source page for ${primaryPreviewSource.title}`} - > -
- - ); -} +/** + * The cited-source derivations moved to `answer-source-rows`, which the rail, + * the drawer, and this module all read. They are re-exported here so existing + * import paths (and the tests that pin them) keep resolving. + */ +export { + buildAnswerSourceRows, + sourceCapsuleDisplay, + sourceStatusDotClass, + sourceStatusDotTone, + sourceStatusShortLabel, + type AnswerSourceRow, + type CapsulePreviewSource, +} from "@/components/clinical-dashboard/answer-source-rows"; /** * Displays a sanitized clinical answer with source status, source previews, and copy actions. @@ -488,7 +197,6 @@ function SourcePreviewContent({ * @param text - The raw answer text to display. * @param query - The user's query context for logging. * @param preformatted - Whether to preserve the supplied formatting during display processing. - * @param sourceCount - The number of direct sources associated with the answer. * @param sourceOnly - Whether to show a notice that the answer was assembled solely from source passages. * @param bestSource - The highest-priority source recommendation, when available. * @param sources - Search results used to build the source preview. @@ -501,84 +209,37 @@ export function NaturalLanguageAnswer({ text, query, preformatted = false, - sourceCount, sourceOnly, bestSource, sources, sourceLinks, copied, onCopy, + onOpenSource, }: { // Raw answer text (server bold intact); this component owns display // sanitization so can render the high-yield emphasis. text: string; query?: string; preformatted?: boolean; - sourceCount: number; sourceOnly: boolean; bestSource: BestSourceRecommendation | null; sources: SearchResult[]; sourceLinks: SourceLink[]; copied: boolean; onCopy: () => void; + /** + * Opens the source drawer at the given rail row. The drawer is mounted by the + * answer surface rather than here, so the rail reports the row and the surface + * owns which one is open. + */ + onOpenSource?: (index: number) => void; }) { - const [sourcePreviewOpen, setSourcePreviewOpen] = useState(false); const [sourceOnlyNoticeOpen, setSourceOnlyNoticeOpen] = useState(false); - const [copiedSourceQuote, setCopiedSourceQuote] = useState(false); const { preferences } = useAppPreferences(); - const sourceCapsuleRef = useRef(null); - const copySourceQuoteTimerRef = useRef(null); - const usePreviewSheet = useMobilePreviewSheet(); - useEffect(() => { - return () => { - if (copySourceQuoteTimerRef.current !== null) window.clearTimeout(copySourceQuoteTimerRef.current); - }; - }, []); const cleaned = primaryAnswerDisplayText(text, { preformatted, preserveBold: true }); if (!cleaned) return null; - const capsuleDisplay = sourceCapsuleDisplay({ sourceCount, compact: preferences.compactCitations }); - const previewSources = capsulePreviewSources(bestSource, sources, sourceLinks); - const rawQuoteText = - sourceLinks.find((source) => source.snippet)?.snippet || bestSource?.quote || bestSource?.snippet || ""; - const quoteText = sourceQuoteDisplayText(rawQuoteText); - const canOpenSourcePreview = previewSources.length > 0; - async function copySourceQuote() { - if (!quoteText) return; - try { - await navigator.clipboard.writeText(quoteText); - setCopiedSourceQuote(true); - if (copySourceQuoteTimerRef.current !== null) window.clearTimeout(copySourceQuoteTimerRef.current); - copySourceQuoteTimerRef.current = window.setTimeout(() => setCopiedSourceQuote(false), 1600); - } catch { - setCopiedSourceQuote(false); - } - } - const sourceCapsuleButton = ( - - ); - + const railSources = buildAnswerSourceRows(bestSource, sources, sourceLinks); return (
) : null} - {sourceCapsuleButton} - {canOpenSourcePreview && !usePreviewSheet ? ( - setSourcePreviewOpen(false)} - anchorRef={sourceCapsuleRef} - > - - - ) : null} - setSourcePreviewOpen(false)} - title="Sources" - description="Check the answer against the cited PDF passage." - titleAccessory={ - - {sourcePreviewPageCountLabel(previewSources)} - - } - closeLabel="Close answer sources" - contentClassName="sm:max-w-xl" - returnFocusRef={sourceCapsuleRef} - portal - > -
- -
-
+
+ {numbered ? ( + + {sources.map((row, index) => ( + + ))} + + ) : ( + + {(openIndex ?? 0) + 1} of {sources.length} + + )} + + + ) : null + } + footerClassName="px-3 py-2 sm:px-3" + > + {source ? ( +
+

+ {sourceSupportSentence(source, activeSupportIndex)} +

+ + {stale ? ( +

+

+ ) : null} + + {passage ? ( +
+

Cited passage

+
+ “{passage}” +
+ {onFollowUpQuote && quoteCard ? ( + + ) : null} +
+ ) : null} + + {sourceTables.length ? ( +
+

+ {sourceTables.length === 1 ? "Table" : "Tables"} +

+ +
+ ) : null} + + {sourceImages.length ? ( +
+

+ {sourceImages.length === 1 ? "Image" : "Images"} +

+ {sourceImages.slice(0, 3).map((card) => ( + + ))} +
+ ) : null} + + query && logSourceOpen(query, source)} + className="inline-flex min-h-12 items-center gap-1.5 justify-self-start rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-xs font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent-border)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" + > +
+ ) : null} + + ); +} + +const pagerStepClass = + "grid h-12 w-12 shrink-0 place-items-center rounded-md border border-[color:var(--border)] text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] disabled:opacity-40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; diff --git a/src/components/clinical-dashboard/answer-source-rail.tsx b/src/components/clinical-dashboard/answer-source-rail.tsx new file mode 100644 index 0000000000..01487dd058 --- /dev/null +++ b/src/components/clinical-dashboard/answer-source-rail.tsx @@ -0,0 +1,228 @@ +"use client"; + +import Link from "next/link"; +import { useId, useState } from "react"; +import { ChevronDown, ExternalLink, Layers } from "lucide-react"; + +import { + cn, + sourceCapsule, + sourceCapsuleCountBadge, + sourceCapsuleHit, + StatusDotMarker, + textMuted, +} from "@/components/ui-primitives"; +import { logSourceOpen } from "@/components/clinical-dashboard/source-actions"; +import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text"; +import { + answerSourceRailRowId, + type AnswerSourceRow, + sourceBadgeLabel, + sourceBadgeToneClass, + sourceCapsuleDisplay, + sourceStatusDotTone, + sourceStatusShortLabel, + sourceSupportLabel, +} from "@/components/clinical-dashboard/answer-source-rows"; + +/** + * The numbered list of cited documents under an answer. + * + * This is the single source-chrome surface: it replaces the "Sources" capsule + * and its popover/sheet pair, the Evidence sheet's Claims and Quotes tabs, the + * Clinical notes sheet, and the wide-screen table column. One row per document, + * and one drawer behind every row. + * + * Two behaviours are load-bearing rather than decorative: + * + * - Rows are `min-h-12` (48 px). Do **not** reduce them to `min-h-11` to satisfy + * generic 44 px tap-target guidance; 44 px reintroduced a sub-pixel rounding + * flake in `ui-smoke`. + * - The `compactCitations` preference collapses the rail to one chip, but the + * zero-source case stays worded in every mode — compact must never hide a + * missing-source signal. + */ +export function AnswerSourceRail({ + sources, + query, + onOpenSource, + compact = false, +}: { + sources: AnswerSourceRow[]; + query?: string; + /** Opens the source drawer at this row. Omitted while the drawer is unavailable. */ + onOpenSource?: (index: number) => void; + compact?: boolean; +}) { + const [expanded, setExpanded] = useState(false); + const rowListId = useId(); + const display = sourceCapsuleDisplay({ sourceCount: sources.length, compact }); + + if (!sources.length) { + return ( +

+ {display.label} +

+ ); + } + + // Collapsed: one chip carrying the count, expanded on tap. The rows below are + // the same rows either way, so nothing is unreachable in compact mode. + const collapsed = compact && !expanded; + + return ( +
+ {compact ? ( + + ) : null} + + {/* `role="list"` on a div rather than a real
    : the rail sits inside + `plain-answer-response`, where a ui-smoke guard asserts the primary + answer renders as prose and not as a bullet list. The source-capsule + preview used the same idiom for the same reason. */} + {collapsed ? null : ( +
    + {sources.map((source, index) => ( +
    +
    + + {/* Title and metadata share one tap target rather than stacking a + 48 px control on top of a separate caption line: the rail lists + every cited source inline where the old capsule was a single + chip, so each row's height is phone scroll budget. + A historical thread turn mounts no drawer, so its rows link + straight to the document rather than advertising a panel that + will not open. */} + {onOpenSource ? ( + + ) : ( + query && logSourceOpen(query, source)} + className={railRowLabelClass} + aria-label={`${railRowLabel(source, index)} — open source`} + > + {cleanDisplayTitle(source.title)} + + p. {source.pageNumber ?? "n/a"} + · + {sourceSupportLabel(source, index)} + + + + )} + query && logSourceOpen(query, source)} + className="grid h-12 w-12 place-items-center rounded-md text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" + aria-label={`Open ${sourceBadgeLabel(index)} source page`} + > +
    +
    + ))} +
    + )} +
+ ); +} + +/** + * The row's accessible name. It has to restate the support level and review + * status, because an `aria-label` replaces the row's own text: without them a + * screen reader would hear the title and page but never that the document is + * outdated or that it only partly supports the claim. + */ +function railRowLabel(source: AnswerSourceRow, index: number) { + return [ + `${sourceBadgeLabel(index)}: ${cleanDisplayTitle(source.title)}`, + `page ${source.pageNumber ?? "not available"}`, + sourceSupportLabel(source, index), + sourceStatusShortLabel(source.metadata), + ].join(", "); +} + +const railRowLabelClass = + "flex min-h-12 w-full min-w-0 flex-col justify-center rounded-md py-1 text-left text-sm font-semibold leading-5 text-[color:var(--text-heading)] transition hover:text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; diff --git a/src/components/clinical-dashboard/answer-source-rows.ts b/src/components/clinical-dashboard/answer-source-rows.ts new file mode 100644 index 0000000000..16a30f3ad3 --- /dev/null +++ b/src/components/clinical-dashboard/answer-source-rows.ts @@ -0,0 +1,187 @@ +import { statusDotMuted, statusDotReady, statusDotReview, type StatusDotTone } from "@/components/ui-primitives"; +import { sourceResultHref } from "@/components/clinical-dashboard/source-actions"; +import { normalizeSourceMetadata, sourceStatusLabel } from "@/lib/source-metadata"; +import { type SourceLink } from "@/lib/answer-render-policy"; +import type { BestSourceRecommendation, SearchResult } from "@/lib/types"; + +/** + * One cited document as the answer surface shows it: the shape the source rail + * lists and the source drawer pages through. + * + * This module is a leaf on purpose. The rail, the drawer, and `answer-content` + * all need these helpers, and `answer-content` re-exports the public ones, so + * keeping the derivations here is what stops those three from importing each + * other in a cycle. + */ +export type AnswerSourceRow = { + id: string; + documentId: string; + title: string; + fileName?: string; + pageNumber: number | null; + metadata: ReturnType; + sourceMetadata?: SearchResult["source_metadata"]; + score: number; + href: string; + snippet?: string; + sourceStrength?: + SourceLink["sourceStrength"] | BestSourceRecommendation["source_strength"] | SearchResult["source_strength"]; +}; + +/** Back-compat alias for the capsule-era name. */ +export type CapsulePreviewSource = AnswerSourceRow; + +/** + * The one "Sources" summary shown when the rail is collapsed. + * + * With the compact-citations preference on, the chip drops its text label to + * icon + count; the "No direct source found" warning always stays worded — + * compact mode must never hide a missing-source signal. + */ +export function sourceCapsuleDisplay({ sourceCount, compact = false }: { sourceCount: number; compact?: boolean }): { + label: string; + showLabelText: boolean; + showCountBadge: boolean; +} { + if (sourceCount <= 0) return { label: "No direct source found", showLabelText: true, showCountBadge: false }; + return { label: "Sources", showLabelText: !compact, showCountBadge: true }; +} + +export function sourceStatusDotTone( + metadata: ReturnType | null | undefined, +): StatusDotTone { + if (!metadata) return "muted"; + if (metadata.document_status === "current") return "ready"; + if (metadata.document_status === "review_due" || metadata.document_status === "outdated") return "review"; + return "muted"; +} + +export function sourceStatusDotClass(metadata: ReturnType | null | undefined) { + const tone = sourceStatusDotTone(metadata); + if (tone === "ready") return statusDotReady; + if (tone === "review") return statusDotReview; + return statusDotMuted; +} + +export function sourceStatusShortLabel(metadata: ReturnType) { + if (metadata.document_status === "review_due") return "Review due"; + if (metadata.document_status === "outdated") return "Outdated"; + if (metadata.document_status === "current") return "Current"; + return sourceStatusLabel(metadata); +} + +/** Decision 1 (2026-08-24): staleness is carried by the row and the drawer, never by the reference mark. */ +export function sourceRowIsStale(source: AnswerSourceRow) { + return source.metadata.document_status === "review_due" || source.metadata.document_status === "outdated"; +} + +export function sourceBadgeLabel(index: number) { + return `S${index + 1}`; +} + +export function sourceBadgeToneClass(metadata: ReturnType, index: number) { + if (metadata.document_status === "review_due" || metadata.document_status === "outdated") { + return "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]"; + } + if (index === 0) { + return "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)]"; + } + return "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"; +} + +export function sourceSupportLabel(source: AnswerSourceRow, index: number) { + if (!source.sourceStrength || source.sourceStrength === "none") return "Unsupported"; + if (source.sourceStrength === "limited") return "Partial"; + if (source.sourceStrength === "moderate") return "Partial"; + if (index === 0 || source.sourceStrength === "strong") return "Direct"; + return "Partial"; +} + +/** + * The drawer's support clause. `index === null` means the drawer was opened from + * the source list rather than from a claim, so there is no claim to speak about. + */ +export function sourceSupportSentence(source: AnswerSourceRow | null, index: number | null) { + if (!source || index === null) return "Opened from the source list, so this is the document, not a claim."; + const support = sourceSupportLabel(source, index); + if (support === "Direct") return "This page states the claim directly."; + if (support === "Partial") + return "This page supports part of the claim. Read the passage before relying on the rest."; + return "Related to the question — this page does not state the claim."; +} + +/** + * Builds the rail's ordered, de-duplicated source list from the three shapes the + * answer surface has on hand. + * + * The cap is six rather than the capsule's four: the rail is the whole cited + * list now rather than a preview of it, and `trustCaps` admits six primary + * sources at high trust. The drawer's pager is what absorbs the extra rows. + */ +export function buildAnswerSourceRows( + bestSource: BestSourceRecommendation | null, + sources: SearchResult[], + sourceLinks: SourceLink[] = [], +): AnswerSourceRow[] { + const rows: AnswerSourceRow[] = []; + const seen = new Set(); + const pushRow = (row: AnswerSourceRow) => { + const key = `${row.id}:${row.title}:${row.pageNumber ?? "n/a"}`; + if (seen.has(key)) return; + seen.add(key); + rows.push(row); + }; + + sourceLinks.slice(0, 6).forEach((source) => { + pushRow({ + id: source.chunk_id, + documentId: source.document_id, + title: source.title || source.file_name || "Source", + fileName: source.file_name, + pageNumber: source.page_number, + metadata: normalizeSourceMetadata(source.sourceMetadata), + sourceMetadata: source.sourceMetadata, + score: source.score ?? 0, + href: source.href, + snippet: source.snippet, + sourceStrength: source.sourceStrength, + }); + }); + + if (bestSource) { + pushRow({ + id: bestSource.chunk_id, + documentId: bestSource.document_id, + title: bestSource.title || bestSource.file_name || "Source", + fileName: bestSource.file_name, + pageNumber: bestSource.page_number, + metadata: normalizeSourceMetadata(bestSource.source_metadata), + sourceMetadata: bestSource.source_metadata, + score: bestSource.score, + href: bestSource.viewer_href, + sourceStrength: bestSource.source_strength, + }); + } + + sources.slice(0, 6).forEach((source) => { + pushRow({ + id: source.id, + documentId: source.document_id, + title: source.title || source.file_name || "Source", + fileName: source.file_name, + pageNumber: source.page_number, + metadata: normalizeSourceMetadata(source.source_metadata), + sourceMetadata: source.source_metadata, + score: source.hybrid_score ?? source.similarity ?? source.lexical_score ?? 0, + href: sourceResultHref(source), + sourceStrength: source.source_strength, + }); + }); + + return rows.slice(0, 6); +} + +/** DOM id for a rail row, so a Sheet can resolve its return-focus target late. */ +export function answerSourceRailRowId(index: number) { + return `answer-source-rail-row-${index}`; +} diff --git a/src/components/clinical-dashboard/answer-thread-turn.tsx b/src/components/clinical-dashboard/answer-thread-turn.tsx index 050efd9cd7..6ca329a036 100644 --- a/src/components/clinical-dashboard/answer-thread-turn.tsx +++ b/src/components/clinical-dashboard/answer-thread-turn.tsx @@ -58,11 +58,6 @@ export function PriorAnswerTurnSurface({ () => sanitizeAnswerDisplayText(turn.answer.answer, { preformatted: turnPreformatted }), [turn.answer.answer, turnPreformatted], ); - const sourceCount = - renderModel.primarySources.length || - turn.sources.length || - turn.answer.sources?.length || - turn.answer.citations.length; const previewText = safeText || turn.answer.answer; const needsSourceReview = turn.answer.answerQualityTier === "source_only" || @@ -99,7 +94,6 @@ export function PriorAnswerTurnSurface({ text={turn.answer.answer} query={turn.query} preformatted={turnPreformatted} - sourceCount={sourceCount} sourceOnly={turn.answer.answerQualityTier === "source_only"} bestSource={renderModel.bestSource} sources={renderModel.reviewSources} diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index 897d53ae11..bc9bf2b7b3 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -12,8 +12,8 @@ import { ExternalLink, FileText, Filter, - Layers, Loader2, + MessageSquareWarning, Plus, Quote, RefreshCw, @@ -168,34 +168,39 @@ export function answerSupportPriority( }; } +/** + * The answer-level strip under the prose. + * + * Since the source rail and drawer took over every per-source surface, this card + * carries only what belongs to the answer rather than to any one document: the + * safety/priority row, the evidence gaps, and the feedback control. + * + * The safety row is not optional chrome. `answerSupportPriority` returns a + * safety-findings priority ahead of everything else, and the trigger below is + * the only route to the safety-critical findings sheet — so this card renders + * whenever `priority` is set, and removing it would remove that route. + */ export function AnswerSupportSummaryCard({ priority, - clinicalCount, - evidenceSummary, - clinicalAvailable, - evidenceAvailable, - clinicalTriggerRef, - evidenceTriggerRef, + warnings = [], safetyTriggerRef, safetyFindingsCount = 0, - onOpenClinicalNotes, - onOpenEvidence, onOpenSafetyFindings, + pendingFeedback = null, + onSubmitFeedback, }: { priority: AnswerSupportPriority | null; - clinicalCount: number; - evidenceSummary: string; - clinicalAvailable: boolean; - evidenceAvailable: boolean; - clinicalTriggerRef?: RefObject; - evidenceTriggerRef?: RefObject; + /** Answer-level evidence gaps (`renderModel.warnings`); they belong to no single source. */ + warnings?: string[]; safetyTriggerRef?: RefObject; safetyFindingsCount?: number; - onOpenClinicalNotes: () => void; - onOpenEvidence: () => void; onOpenSafetyFindings?: () => void; + pendingFeedback?: AnswerFeedbackType | null; + onSubmitFeedback?: (feedbackType: AnswerFeedbackType) => void; }) { - const supportRowCount = Number(clinicalAvailable) + Number(evidenceAvailable); + const [gapsOpen, setGapsOpen] = useState(false); + const [feedbackOpen, setFeedbackOpen] = useState(false); + const supportRowCount = Number(warnings.length > 0) + Number(Boolean(onSubmitFeedback)); const supportButtonClass = "grid min-h-[56px] grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2.5 px-3 py-2 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); @@ -268,49 +273,79 @@ export function AnswerSupportSummaryCard({ ) : null} {supportRowCount > 0 ? ( -
- {clinicalAvailable ? ( - +
) : null} - {evidenceAvailable ? ( - + {onSubmitFeedback ? ( +
+ + {feedbackOpen ? ( +
+ +
+ ) : null} +
) : null}
) : null} diff --git a/tests/answer-source-rail.dom.test.tsx b/tests/answer-source-rail.dom.test.tsx new file mode 100644 index 0000000000..e75f51750c --- /dev/null +++ b/tests/answer-source-rail.dom.test.tsx @@ -0,0 +1,302 @@ +import { useState } from "react"; + +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/clinical-dashboard/signed-image", () => ({ + SignedImage: ({ caption }: { caption?: string }) =>

{caption}

, +})); + +import { AnswerSupportSummaryCard } from "@/components/clinical-dashboard/evidence-panels"; +import { AnswerSourceDrawer } from "@/components/clinical-dashboard/answer-source-drawer"; +import { AnswerSourceRail } from "@/components/clinical-dashboard/answer-source-rail"; +import { + type AnswerSourceRow, + answerSourceRailRowId, + sourceCapsuleDisplay, + sourceSupportSentence, +} from "@/components/clinical-dashboard/answer-source-rows"; +import { normalizeSourceMetadata } from "@/lib/source-metadata"; +import type { VisualEvidenceCard } from "@/lib/types"; + +/** + * The rail and the drawer replaced four separate source surfaces (the "Sources" + * capsule and its popover/sheet pair, the Evidence sheet, the Clinical notes + * sheet, and the wide-screen table column), so the behaviours that used to be + * spread across those are now pinned here. + * + * Three of these are contracts rather than preferences: + * + * - Compact citations may hide the "Sources" label but never the missing-source + * warning, and never the rows themselves — only collapse them behind a chip. + * - The drawer's support sentence has a null case. Opened from the rail there is + * no claim, so it must not assert one. + * - Tap targets are 48 px. `min-h-11` (44 px) reintroduces a known ui-smoke flake. + */ + +function row(overrides: Partial & { id: string; title: string }): AnswerSourceRow { + return { + documentId: `doc-${overrides.id}`, + pageNumber: 4, + metadata: normalizeSourceMetadata(null), + score: 0.8, + href: `/documents/${overrides.id}?chunk=${overrides.id}`, + ...overrides, + }; +} + +const SOURCES: AnswerSourceRow[] = [ + row({ id: "s1", title: "Clozapine monitoring protocol", sourceStrength: "strong", snippet: "Check FBC weekly." }), + row({ id: "s2", title: "Metabolic screening standard", pageNumber: 11, sourceStrength: "limited" }), + row({ + id: "s3", + title: "Superseded myocarditis guidance", + pageNumber: 2, + sourceStrength: "moderate", + metadata: normalizeSourceMetadata({ document_status: "outdated" }), + }), +]; + +function visualCard( + overrides: Partial & Pick, +): VisualEvidenceCard { + return { + image_id: overrides.id, + signed_url_endpoint: `/api/images/${overrides.id}`, + caption: "Cited figure", + document_id: "doc-shared", + title: "Shared protocol", + file_name: "protocol.pdf", + page_number: 4, + chunk_index: 0, + viewer_href: "/documents/doc-shared", + ...overrides, + }; +} + +function RailAndDrawer({ + sources = SOURCES, + visualEvidence = [], +}: { + sources?: AnswerSourceRow[]; + visualEvidence?: VisualEvidenceCard[]; +}) { + const [openIndex, setOpenIndex] = useState(null); + return ( + <> + + setOpenIndex(null)} + visualEvidence={visualEvidence} + /> + + ); +} + +describe("answer source rail", () => { + it("lists one row per cited document with its page, support and status", () => { + render(); + const rows = screen.getAllByTestId("answer-source-rail-row"); + expect(rows).toHaveLength(3); + expect(rows[0]).toHaveTextContent("Clozapine monitoring protocol"); + + const rail = screen.getByTestId("answer-source-rail"); + expect(within(rail).getByText("p. 11")).toBeInTheDocument(); + expect(within(rail).getAllByText("Direct").length).toBeGreaterThan(0); + expect(within(rail).getAllByText("Partial").length).toBeGreaterThan(0); + // Decision 1: staleness is carried by the row, not by a second mark colour. + expect(within(rail).getByText("Outdated")).toBeInTheDocument(); + }); + + it("keeps every row reachable and links straight to the document when no drawer is mounted", () => { + render(); + const rows = screen.getAllByTestId("answer-source-rail-row"); + expect(rows).toHaveLength(3); + // A historical thread turn mounts no drawer, so a row must navigate rather + // than advertise a panel that will never open. + expect(rows[0].tagName).toBe("A"); + expect(rows[0]).toHaveAttribute("href", "/documents/s1?chunk=s1"); + }); + + it("does not reuse the live drawer's return-focus ids on a prior-turn rail", () => { + render( + <> + + + , + ); + + const identified = [...document.querySelectorAll("[id^='answer-source-rail-row-']")]; + expect(identified.map((node) => node.id)).toEqual(SOURCES.map((_, index) => answerSourceRailRowId(index))); + expect(document.getElementById(answerSourceRailRowId(0))?.tagName).toBe("BUTTON"); + + const listIds = [...document.querySelectorAll('[role="list"][aria-label="Cited documents"]')].map( + (node) => node.id, + ); + expect(listIds).toHaveLength(2); + expect(new Set(listIds).size).toBe(2); + }); + + it("collapses to a single chip under compact citations and expands back to the same rows", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryAllByTestId("answer-source-rail-row")).toHaveLength(0); + const toggle = screen.getByTestId("answer-source-rail-toggle"); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getAllByTestId("answer-source-rail-row")).toHaveLength(3); + }); + + it("never hides the missing-source warning, compact or not", () => { + const { rerender } = render(); + expect(screen.getByTestId("answer-source-rail-empty")).toHaveTextContent("No direct source found"); + + rerender(); + expect(screen.getByTestId("answer-source-rail-empty")).toHaveTextContent("No direct source found"); + // The shared derivation agrees, which is what the settings toggle reads. + expect(sourceCapsuleDisplay({ sourceCount: 0, compact: true }).showLabelText).toBe(true); + }); +}); + +describe("answer source drawer", () => { + it("opens on the row that was tapped and pages between sources", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getAllByTestId("answer-source-rail-row")[0]); + const drawer = screen.getByTestId("answer-source-drawer"); + expect(within(drawer).getByText("Clozapine monitoring protocol")).toBeInTheDocument(); + + const pager = screen.getByTestId("answer-source-drawer-pager"); + // Three sources still fit the numbered pager; it degrades past four. + expect(pager).toHaveAttribute("data-pager-variant", "numbered"); + await user.click(within(pager).getByRole("button", { name: "Next source" })); + expect( + within(screen.getByTestId("answer-source-drawer")).getByText("Metabolic screening standard"), + ).toBeInTheDocument(); + }); + + it("degrades the pager to prev / n of m above four sources", async () => { + const user = userEvent.setup(); + const many = Array.from({ length: 5 }, (_, index) => row({ id: `m${index}`, title: `Source number ${index + 1}` })); + render(); + + await user.click(screen.getAllByTestId("answer-source-rail-row")[0]); + const pager = screen.getByTestId("answer-source-drawer-pager"); + expect(pager).toHaveAttribute("data-pager-variant", "compact"); + expect(pager).toHaveTextContent("1 of 5"); + }); + + it("does not assert a claim when the drawer was opened from the source list", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getAllByTestId("answer-source-rail-row")[0]); + expect(screen.getByTestId("answer-source-drawer-support")).toHaveTextContent( + "Opened from the source list, so this is the document, not a claim.", + ); + }); + + it("warns on a source that is past its review date", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getAllByTestId("answer-source-rail-row")[2]); + expect(screen.getByTestId("answer-source-drawer-status")).toHaveTextContent("past its review date"); + }); + + it("shows the cited passage when the source carries one", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getAllByTestId("answer-source-rail-row")[0]); + expect(screen.getByTestId("answer-source-drawer-passage")).toHaveTextContent("Check FBC weekly."); + }); + + it("attaches an image only to the rail row that owns its chunk", async () => { + const user = userEvent.setup(); + const sources = [ + row({ id: "chunk-a", documentId: "doc-shared", title: "Page 4 passage" }), + row({ id: "chunk-b", documentId: "doc-shared", title: "Page 11 passage" }), + ]; + render( + , + ); + + await user.click(screen.getAllByTestId("answer-source-rail-row")[1]); + expect(screen.queryByTestId("answer-source-drawer-images")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Previous source" })); + expect(screen.getByTestId("answer-source-drawer-images")).toBeInTheDocument(); + }); + + it("sends an unmatched image to the first source only", async () => { + const user = userEvent.setup(); + const sources = [ + row({ id: "chunk-a", documentId: "doc-shared", title: "Page 4 passage" }), + row({ id: "chunk-b", documentId: "doc-shared", title: "Page 11 passage" }), + ]; + render( + , + ); + + await user.click(screen.getAllByTestId("answer-source-rail-row")[1]); + expect(screen.queryByTestId("answer-source-drawer-images")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Previous source" })); + expect(screen.getByTestId("answer-source-drawer-images")).toBeInTheDocument(); + }); +}); + +describe("evidence gaps stay answer-level", () => { + it("lists the answer's warnings on the card rather than against any one source", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByTestId("answer-evidence-gaps-trigger")); + expect(screen.getByText("Retrieval confidence gate was blocked for low signal.")).toBeInTheDocument(); + }); +}); + +describe("support sentence", () => { + it("speaks about the claim only when a claim pointed at the source", () => { + expect(sourceSupportSentence(SOURCES[0], null)).toContain("not a claim"); + expect(sourceSupportSentence(SOURCES[0], 0)).toBe("This page states the claim directly."); + expect(sourceSupportSentence(SOURCES[1], 1)).toContain("supports part of the claim"); + expect(sourceSupportSentence(row({ id: "x", title: "Unrelated" }), 2)).toContain("does not state the claim"); + expect(sourceSupportSentence(null, 0)).toContain("not a claim"); + }); +}); diff --git a/tests/answer-support-priority.dom.test.tsx b/tests/answer-support-priority.dom.test.tsx index c6f6658297..8c2b1c8595 100644 --- a/tests/answer-support-priority.dom.test.tsx +++ b/tests/answer-support-priority.dom.test.tsx @@ -1,6 +1,10 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { answerSupportPriority } from "@/components/clinical-dashboard/evidence-panels"; +import { AnswerSupportSummaryCard, answerSupportPriority } from "@/components/clinical-dashboard/evidence-panels"; import type { AnswerState } from "@/components/ui/answer-state"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import type { RagAnswer } from "@/lib/types"; @@ -129,3 +133,27 @@ describe("answerSupportPriority · Review source match", () => { expect(priority?.title).toBe("Safety findings"); }); }); + +describe("AnswerSupportSummaryCard · feedback on a clean answer", () => { + it("still hosts Report a problem when priority and warnings are both empty", () => { + render( + undefined} + />, + ); + expect(screen.getByTestId("answer-feedback-trigger")).toBeInTheDocument(); + }); + + it("the answer surface mounts that card for feedback even without priority or warnings", () => { + const surface = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/answer-result-surface.tsx"), + "utf8", + ); + expect(surface).toMatch( + /showInlineSupportCard = Boolean\(priority \|\| renderModel\.warnings\.length > 0 \|\| onSubmitFeedback\)/, + ); + }); +}); diff --git a/tests/rendered-text-formatting.test.ts b/tests/rendered-text-formatting.test.ts index 9045fed3db..d194338ec7 100644 --- a/tests/rendered-text-formatting.test.ts +++ b/tests/rendered-text-formatting.test.ts @@ -35,7 +35,13 @@ describe("document-derived text must route through a formatter", () => { const visualEvidence = componentSource("clinical-dashboard/visual-evidence.tsx"); const documentResults = componentSource("clinical-dashboard/document-results.tsx"); const answerResultSurface = componentSource("clinical-dashboard/answer-result-surface.tsx"); - const dashboardSurfaces = `${dashboard}\n${answerContent}\n${evidenceContent}\n${evidenceMapModel}\n${outputPanel}\n${visualEvidence}\n${documentResults}\n${answerResultSurface}`; + // The source rail and drawer are where cited titles, passages and snippets are + // rendered now — the capsule preview they replaced was scanned here, so they + // inherit the same raw-render guards rather than escaping them by moving. + const answerSourceRail = componentSource("clinical-dashboard/answer-source-rail.tsx"); + const answerSourceDrawer = componentSource("clinical-dashboard/answer-source-drawer.tsx"); + const answerSourceRows = componentSource("clinical-dashboard/answer-source-rows.ts"); + const dashboardSurfaces = `${dashboard}\n${answerContent}\n${evidenceContent}\n${evidenceMapModel}\n${outputPanel}\n${visualEvidence}\n${documentResults}\n${answerResultSurface}\n${answerSourceRail}\n${answerSourceDrawer}\n${answerSourceRows}`; 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 8c6bdf93df..8bf1a1e431 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1967,36 +1967,40 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByText("Demo", { exact: true })).toHaveCount(0); await expect(plainAnswer.locator("ul, ol, li")).toHaveCount(0); await expect(plainAnswer.getByTestId("plain-answer-prose").locator("svg")).toHaveCount(0); - const sourceCapsule = plainAnswer.getByRole("button", { name: "Open answer sources" }); - await expect(sourceCapsule).not.toContainText("Check sources"); - await expectMinTouchTarget(sourceCapsule); - await sourceCapsule.click(); - const sourceSheet = page.getByRole("dialog", { name: "Sources" }); - await expect(sourceSheet).toBeVisible(); - const sourcePreview = page.getByTestId("source-capsule-preview"); - await expect(sourcePreview).toBeVisible(); - await expect(sourcePreview).toContainText("Best match"); - await expect(sourcePreview.getByTestId("source-capsule-preview-row")).toHaveCount(2); - const firstPreviewSource = sourcePreview.getByTestId("source-capsule-preview-row").first(); - await expect(firstPreviewSource).toHaveAttribute("href", /\/documents\/.+chunk=/); - await expectMinTouchTarget(firstPreviewSource); - await expect(sourcePreview.getByRole("link", { name: /Open S1 source page/i })).toBeVisible(); + // The source rail replaced the "Sources" capsule and its popover/sheet pair: + // the cited list is on the page rather than one tap behind a chip, and each + // row opens the per-source drawer. + const sourceRail = page.getByTestId("answer-source-rail"); + await expect(sourceRail).toBeVisible(); + const railRows = sourceRail.getByTestId("answer-source-rail-row"); + await expect(railRows).toHaveCount(2); + const firstRailRow = railRows.first(); + await expectMinTouchTarget(firstRailRow); + await expect(sourceRail.getByRole("link", { name: /Open S1 source page/i })).toHaveAttribute( + "href", + /\/documents\/.+chunk=/, + ); + await firstRailRow.click(); + const sourceDrawer = page.getByTestId("answer-source-drawer"); + await expect(sourceDrawer).toBeVisible(); + await expect(sourceDrawer.getByTestId("answer-source-drawer-support")).toContainText("Opened from the source list"); await expect(page.getByRole("dialog", { name: /PDF|document/i })).toHaveCount(0); - const copyQuoteButton = sourcePreview.getByRole("button", { name: "Copy passage" }); - await expect(copyQuoteButton).toBeVisible(); - await expectMinTouchTarget(copyQuoteButton); + // Paging is the drawer's whole navigation model; at two sources it is numbered. + const pager = sourceDrawer.getByTestId("answer-source-drawer-pager"); + await expect(pager).toHaveAttribute("data-pager-variant", "numbered"); + await pager.getByRole("button", { name: /Source S2/ }).click(); + await expect(sourceDrawer.getByRole("link", { name: "View original PDF" })).toBeVisible(); + await expectNoPageHorizontalOverflow(page); + await page.keyboard.press("Escape"); + await expect(sourceDrawer).toHaveCount(0); + // Focus returns to the row the drawer was showing, not the row it opened from. + await expect(railRows.nth(1)).toBeFocused(); if (browserName === "chromium") { + // The grant used to ride along with the removed "Copy passage" control; the + // clipboard read below still needs it. await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: new URL(page.url()).origin, }); - await copyQuoteButton.click(); - await expect(sourcePreview.getByRole("button", { name: "Copied passage" })).toBeVisible(); - } - await expectNoPageHorizontalOverflow(page); - await page.keyboard.press("Escape"); - await expect(sourceSheet).toHaveCount(0); - await expect(sourceCapsule).toBeFocused(); - if (browserName === "chromium") { const copyWithSources = plainAnswer.getByRole("button", { name: "Copy answer with source status" }); await expect(copyWithSources).toBeVisible(); await expectMinTouchTarget(copyWithSources); @@ -2008,11 +2012,15 @@ test.describe("Clinical KB UI smoke coverage", () => { } await expect(plainAnswer.getByRole("button", { name: "More answer actions" })).toHaveCount(0); + // The support card is the answer-level strip now: priority/safety, evidence + // gaps, and feedback. Everything per-source moved to the rail and drawer, so + // the clinical-notes and evidence rows are gone rather than relocated. const supportCard = page.getByTestId("answer-support-card"); await expect(supportCard).toBeVisible(); - await expect(supportCard).toContainText("Clinical notes"); - await expect(supportCard).toContainText("Evidence"); await expect(supportCard).toContainText(/Safety findings|Priority|FBC\/ANC|Myocarditis|Metabolic/i); + await expect(supportCard).toContainText("Report a problem"); + await expect(supportCard.getByTestId("answer-clinical-notes-trigger")).toHaveCount(0); + await expect(supportCard.getByTestId("answer-evidence-trigger")).toHaveCount(0); await expect(page.getByTestId("safety-findings-panel")).toHaveCount(0); // Safety findings are MANDATORY for this clozapine fixture — the answer is saturated @@ -2033,37 +2041,38 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(safetyFindingsSheet).toHaveCount(0); await expect(safetyFindingsTrigger).toBeFocused(); - const clinicalTable = page.getByLabel("Inline table preview").first(); + // Decision 2 (2026-08-24): tables fold into the source drawer, so they are no + // longer on the answer surface at all — reaching one goes through a rail row. + await expect(page.getByLabel("Inline table preview")).toHaveCount(0); + // A table hangs off the source it was cited from, so open the row that has it + // rather than assuming it is the best match. + const tableDrawer = page.getByTestId("answer-source-drawer"); + const clinicalTable = tableDrawer.getByLabel("Inline table preview").first(); + let foundTable = false; + for (let index = 0; index < (await railRows.count()); index += 1) { + await railRows.nth(index).click(); + await expect(tableDrawer).toBeVisible(); + if (await clinicalTable.count()) { + foundTable = true; + break; + } + await page.keyboard.press("Escape"); + await expect(tableDrawer).toHaveCount(0); + } + expect(foundTable).toBe(true); await expect(clinicalTable).toBeVisible(); await expect(clinicalTable.getByRole("table")).toBeVisible(); await expect(clinicalTable).toContainText("FBC/ANC"); await expect(clinicalTable).not.toContainText(/page|p\.|chunk|Synthetic clozapine monitoring protocol/i); - const openTableSource = clinicalTable.getByRole("link", { name: "Open table source" }); - await expect(openTableSource).toBeVisible(); - await expectMinTouchTarget(openTableSource); 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(clinicalTable.getByTestId("accessible-table-surface")).toBeVisible(); - await page.keyboard.press("Escape"); - const tableDialog = await openMobileTableFullscreen(page, clinicalTable); - await expect(tableDialog.getByRole("table")).toBeVisible(); - await expect(tableDialog).toContainText("FBC/ANC"); - await expect(tableDialog).not.toContainText(/page|p\.|chunk|Synthetic clozapine monitoring protocol/i); await expectNoPageHorizontalOverflow(page); - await page.keyboard.press("Escape"); - await expect(tableDialog).toBeHidden(); - if (await tableExpandButton.isVisible().catch(() => false)) { - await expect(tableExpandButton).toBeFocused(); - } else { - await expect(clinicalTable.getByTestId("accessible-table-surface")).toBeFocused(); - } - if (await tableExpandButton.isVisible().catch(() => false)) { - const reopenedTableDialog = await openMobileTableFullscreen(page, clinicalTable); - await reopenedTableDialog.getByRole("button", { name: "Close full-screen table" }).click(); - await expect(reopenedTableDialog).toBeHidden(); - await expect(tableExpandButton).toBeFocused(); - } + // Full-screen table expansion from inside the drawer is covered on its own by + // the "clinical table mobile expansion" test below, which owns the nested + // dialog case rather than repeating it here. + await tableDrawer.getByRole("button", { name: "Close source detail" }).click(); + await expect(tableDrawer).toHaveCount(0); 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); @@ -2084,110 +2093,56 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByTestId("mobile-section-fab-menu")).toHaveCount(0); await expectDomIntegrity(page, { mobileNav: true }); - const clinicalNotesTrigger = page.locator("#answer-clinical-notes-drawer-mobile-trigger"); - await expect(clinicalNotesTrigger).toBeVisible(); - await expect(clinicalNotesTrigger).toContainText("Clinical notes"); - await expect(clinicalNotesTrigger).toContainText(/notes?/i); - await expectMinTouchTarget(clinicalNotesTrigger); - await clinicalNotesTrigger.click(); - const clinicalNotesSheet = page.getByRole("dialog", { name: "Clinical notes" }); - await expect(clinicalNotesSheet).toBeVisible(); - await expect(clinicalNotesSheet.getByTestId("clinical-notes-checklist")).toBeVisible(); - const essentialsTab = clinicalNotesSheet.getByRole("tab", { name: /Essentials/ }); - const actionsTab = clinicalNotesSheet.getByRole("tab", { name: /Actions/ }); - const safetyTab = clinicalNotesSheet.getByRole("tab", { name: /Safety/ }); - await expect(essentialsTab).toBeVisible(); - await expect(actionsTab).toBeVisible(); - await expect(safetyTab).toBeVisible(); - await expect(actionsTab).toHaveAttribute("aria-selected", "true"); - await actionsTab.focus(); - await page.keyboard.press("ArrowRight"); - await expect(safetyTab).toBeFocused(); - await expect(safetyTab).toHaveAttribute("aria-selected", "true"); - await page.keyboard.press("ArrowLeft"); - await expect(actionsTab).toBeFocused(); - await expect(actionsTab).toHaveAttribute("aria-selected", "true"); - await page.keyboard.press("Home"); - await expect(essentialsTab).toBeFocused(); - await expect(essentialsTab).toHaveAttribute("aria-selected", "true"); - await page.keyboard.press("End"); - await expect(safetyTab).toBeFocused(); - await expect(safetyTab).toHaveAttribute("aria-selected", "true"); - await page.keyboard.press("ArrowLeft"); - await expect(actionsTab).toBeFocused(); - await expect(actionsTab).toHaveAttribute("aria-selected", "true"); - 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); - - const evidenceDrawer = page.locator("#answer-evidence-drawer-mobile-trigger"); - await expect(evidenceDrawer).toBeVisible(); - await expect(evidenceDrawer).toContainText("Evidence"); - await expect(evidenceDrawer).toContainText(/claims?/i); - await expect(evidenceDrawer).toContainText(/quotes?/i); + // The clinical-notes sheet and the five-tab evidence sheet are both gone: the + // rail lists every cited document and the drawer carries that document's + // support, passage, tables and images. Their triggers must not come back. + await expect(page.locator("#answer-clinical-notes-drawer-mobile-trigger")).toHaveCount(0); + await expect(page.locator("#answer-evidence-drawer-mobile-trigger")).toHaveCount(0); + await expect(page.getByTestId("clinical-notes-checklist")).toHaveCount(0); + await expect(page.getByTestId("mobile-evidence-tabs")).toHaveCount(0); await expect(page.getByTestId("evidence-support-panel")).toHaveCount(0); + // Document order on the answer surface: question, prose, the rail that cites + // it, then the answer-level support strip. const hierarchy = await page.evaluate(() => { const question = document.querySelector('[data-testid="answer-card-query"]'); const plainAnswer = document.querySelector('[data-testid="plain-answer-response"]'); + const rail = document.querySelector('[data-testid="answer-source-rail"]'); const support = document.querySelector('[data-testid="answer-support-card"]'); - const table = document.querySelector('[aria-label="Inline table preview"]'); return { questionTop: question?.getBoundingClientRect().top ?? 9999, plainAnswerTop: plainAnswer?.getBoundingClientRect().top ?? 9999, + railTop: rail?.getBoundingClientRect().top ?? 9999, supportTop: support?.getBoundingClientRect().top ?? 9999, - tableTop: table?.getBoundingClientRect().top ?? 9999, }; }); expect(hierarchy.questionTop).toBeLessThan(hierarchy.plainAnswerTop); - expect(hierarchy.plainAnswerTop).toBeLessThan(hierarchy.supportTop); - expect(hierarchy.supportTop).toBeLessThan(hierarchy.tableTop); - - await evidenceDrawer.click(); - const evidenceSheet = page.getByRole("dialog", { name: "Evidence" }); - await expect(evidenceSheet).toBeVisible(); - await expect(evidenceSheet.getByTestId("mobile-evidence-tabs")).toBeVisible(); - const evidenceSheetOrder = await evidenceSheet.evaluate((element) => { - const tabs = element.querySelector('[data-testid="mobile-evidence-tabs"]'); - const claims = element.querySelector('[data-testid="evidence-claims-panel"]'); - return { - tabsTop: tabs?.getBoundingClientRect().top ?? 9999, - claimsTop: claims?.getBoundingClientRect().top ?? 9999, - }; - }); - expect(evidenceSheetOrder.tabsTop).toBeLessThan(evidenceSheetOrder.claimsTop); - await expect(evidenceSheet.getByTestId("mobile-evidence-tab-claims")).toHaveAttribute("aria-selected", "true"); - await expect(evidenceSheet.getByTestId("mobile-evidence-panel-claims")).toBeVisible(); - await expectMinTouchTarget(evidenceSheet.getByTestId("mobile-evidence-tab-claims")); - const sourcePanelLink = evidenceSheet - .getByTestId("mobile-evidence-panel-claims") - .getByTestId("evidence-map-open-source") - .first(); - await expect(sourcePanelLink).toBeVisible(); - await expect(sourcePanelLink).toHaveAttribute("href", /\/documents\/.+chunk=/); - await expectMinTouchTarget(sourcePanelLink); - await evidenceSheet.getByTestId("mobile-evidence-tab-tables").click(); - await expect(evidenceSheet.getByTestId("mobile-evidence-panel-tables")).toBeVisible(); - await expectMinTouchTarget(evidenceSheet.getByTestId("mobile-evidence-tab-tables")); - const gapsTab = evidenceSheet.getByTestId("mobile-evidence-tab-gaps"); - if (await gapsTab.count()) { - await gapsTab.click(); - await expect(evidenceSheet.getByTestId("mobile-evidence-panel-gaps")).toBeVisible(); - await expectMinTouchTarget(gapsTab); - } - await expect(page.locator('[data-testid="evidence-support-panel"]:visible')).toHaveCount(0); + expect(hierarchy.plainAnswerTop).toBeLessThan(hierarchy.railTop); + expect(hierarchy.railTop).toBeLessThan(hierarchy.supportTop); + + // Evidence gaps and feedback are answer-level, so they stayed on the card + // rather than following the per-source content into the drawer. + const feedbackTrigger = supportCard.getByTestId("answer-feedback-trigger"); + await expect(feedbackTrigger).toBeVisible(); + await expectMinTouchTarget(feedbackTrigger); + await feedbackTrigger.click(); + await expect(supportCard.getByTestId("answer-review-panel")).toBeVisible(); + await feedbackTrigger.click(); + await expect(supportCard.getByTestId("answer-review-panel")).toHaveCount(0); await expect(page.getByTestId("answer-section-heading")).toHaveText("Answer"); await expect(page.getByTestId("answer-header-actions")).toHaveCount(0); await expect(page.getByText("Top source detail")).toHaveCount(0); await expect(page.getByText("Retrieval details")).toHaveCount(0); + // Tapping the backdrop dismisses the source drawer and returns focus to the + // rail row that opened it. + await railRows.first().click(); + const dismissDrawer = page.getByTestId("answer-source-drawer"); + await expect(dismissDrawer).toBeVisible(); await tapOutsideActiveSurface(page); - await expect(evidenceSheet).toHaveCount(0); - await expect(evidenceDrawer).toBeFocused(); + await expect(dismissDrawer).toHaveCount(0); + await expect(railRows.first()).toBeFocused(); await openScopeControl(page); const scopePopover = page.getByTestId("scope-command-popover"); @@ -2295,15 +2250,40 @@ test.describe("Clinical KB UI smoke coverage", () => { await fillVisibleQuestionInput(page, "What clozapine monitoring items are shown in the table image?"); await visibleAnswerSubmitButton(page).click(); - const firstTable = page.getByRole("table", { name: "ANC actions" }); - const secondTable = page.getByRole("table", { name: "Metabolic monitoring" }); - await expect(firstTable).toBeVisible({ timeout: uiAssertionTimeoutMs }); - await expect(firstTable).toContainText("1.0–1.5 × 10⁹/L"); - await expect(firstTable).toContainText("Withhold and seek specialist advice"); - await expect(secondTable).toBeVisible({ timeout: uiAssertionTimeoutMs }); - await expect(secondTable).toContainText("HbA1c"); - await expect(secondTable).toContainText("At baseline and review"); - await expect(page.getByTestId("canonical-table-caveat")).toContainText("headers are incomplete"); + // Tables live in the source drawer now, one page at a time. The clipboard + // still carries every table, so each one must remain reachable — a copy that + // quotes a table nothing on screen can show is exactly the drift this test + // was written to catch. + const rail = page.getByTestId("answer-source-rail"); + await expect(rail).toBeVisible({ timeout: uiAssertionTimeoutMs }); + const rows = rail.getByTestId("answer-source-rail-row"); + const rowCount = await rows.count(); + const seenTables: string[] = []; + let sawCaveat = false; + for (let index = 0; index < rowCount; index += 1) { + await rows.nth(index).click(); + const drawer = page.getByTestId("answer-source-drawer"); + await expect(drawer).toBeVisible(); + for (const name of ["ANC actions", "Metabolic monitoring"]) { + const table = drawer.getByRole("table", { name }); + if (await table.count()) { + await expect(table).toBeVisible(); + if (name === "ANC actions") { + await expect(table).toContainText("1.0–1.5 × 10⁹/L"); + await expect(table).toContainText("Withhold and seek specialist advice"); + } else { + await expect(table).toContainText("HbA1c"); + await expect(table).toContainText("At baseline and review"); + } + if (!seenTables.includes(name)) seenTables.push(name); + } + } + if (await drawer.getByTestId("canonical-table-caveat").count()) sawCaveat = true; + await page.keyboard.press("Escape"); + await expect(drawer).toHaveCount(0); + } + expect(seenTables.sort()).toEqual(["ANC actions", "Metabolic monitoring"]); + expect(sawCaveat).toBe(true); const answerSurface = page.getByTestId("plain-answer-response"); await answerSurface.getByRole("button", { name: "Copy answer with source status" }).click(); @@ -2475,6 +2455,7 @@ test.describe("Clinical KB UI smoke coverage", () => { const surface = document.querySelector('[data-dashboard-stage="answer-surface"]'); const alsoMatches = document.querySelector('[data-testid="universal-also-matches"]'); const supportLabel = document.querySelector('[data-testid="answer-card-support"]'); + const sourceRail = document.querySelector('[data-testid="answer-source-rail"]'); // Include vertical margins: the phone bottom clearance (`max-sm:mb-4`) sits // outside getBoundingClientRect().height and still consumes scroll budget. let alsoMatchesHeight = 0; @@ -2495,11 +2476,22 @@ test.describe("Clinical KB UI smoke coverage", () => { box.height + (Number.parseFloat(styles.marginTop) || 0) + (Number.parseFloat(styles.marginBottom) || 0), ); } + // The source rail is real, always-rendered content in the same sense: it is + // the cited source list, which the old capsule hid behind a single chip. + let sourceRailHeight = 0; + if (sourceRail instanceof HTMLElement) { + const box = sourceRail.getBoundingClientRect(); + const styles = window.getComputedStyle(sourceRail); + sourceRailHeight = Math.ceil( + box.height + (Number.parseFloat(styles.marginTop) || 0) + (Number.parseFloat(styles.marginBottom) || 0), + ); + } return { headerBottom: header ? Math.round(header.getBoundingClientRect().bottom) : 0, surfaceTop: surface ? Math.round(surface.getBoundingClientRect().top) : 0, alsoMatchesHeight, supportLabelHeight, + sourceRailHeight, }; }); // Content-sized section => no unexplained phantom scroll. Submitted universal @@ -2513,7 +2505,11 @@ test.describe("Clinical KB UI smoke coverage", () => { // AnswerCard's required support label is likewise real, always-rendered // content (`support` became a required prop), so it is measured and added // the same way rather than absorbed into the flat allowance. - const permittedOverflow = geo.alsoMatchesHeight + geo.supportLabelHeight + 8; + // The rail is measured and added for the same reason the support label is: + // always-rendered content, not phantom scroll. The flat 8px unexplained- + // overflow allowance is deliberately unchanged — that is the part of this + // guard the redesign must not be allowed to spend. + const permittedOverflow = geo.alsoMatchesHeight + geo.supportLabelHeight + geo.sourceRailHeight + 8; expect(scrollGeometry.owner).toBe("document"); expect(scrollGeometry.maxScrollTop).toBeLessThanOrEqual(permittedOverflow); // Top-aligned: the answer sits just under the header, not pushed toward the dock @@ -2679,15 +2675,21 @@ test.describe("Clinical KB UI smoke coverage", () => { // content in this scroll container; account for its measured height the // same way the geometry above accounts for chrome, rather than baking a // stale pre-label constant into the post-collapse ceiling. - const supportLabelHeight = await page.evaluate(() => { - const supportLabel = document.querySelector('[data-testid="answer-card-support"]'); - if (!(supportLabel instanceof HTMLElement)) return 0; - const box = supportLabel.getBoundingClientRect(); - const styles = window.getComputedStyle(supportLabel); - return Math.ceil( - box.height + (Number.parseFloat(styles.marginTop) || 0) + (Number.parseFloat(styles.marginBottom) || 0), - ); - }); + // Measured, not constant: the support label and the source rail are both + // always-rendered content whose height moves the scroll budget. The rail + // joined them when it replaced the single-chip capsule. + const measuredHeight = (selector: string) => + page.evaluate((testId) => { + const element = document.querySelector(`[data-testid="${testId}"]`); + if (!(element instanceof HTMLElement)) return 0; + const box = element.getBoundingClientRect(); + const styles = window.getComputedStyle(element); + return Math.ceil( + box.height + (Number.parseFloat(styles.marginTop) || 0) + (Number.parseFloat(styles.marginBottom) || 0), + ); + }, selector); + const supportLabelHeight = + (await measuredHeight("answer-card-support")) + (await measuredHeight("answer-source-rail")); const geometry = { maxOffset: scrollGeometry.maxScrollTop, collapseBudget, @@ -3036,16 +3038,17 @@ test.describe("Clinical KB UI smoke coverage", () => { 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(); + // The quote cards' follow-up action came across with the passage: it now lives + // in the source drawer, beneath the passage it asks about. + const followUpRail = page.getByTestId("answer-source-rail"); + await expect(followUpRail).toBeVisible(); + await followUpRail.getByTestId("answer-source-rail-row").first().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 followUpDrawer = page.getByTestId("answer-source-drawer"); + await expect(followUpDrawer).toBeVisible(); + await expect(followUpDrawer.getByTestId("answer-source-drawer-passage")).toBeVisible(); - const followUpButton = evidenceSheet.getByRole("button", { name: /Ask a follow-up from quote/i }).first(); + const followUpButton = followUpDrawer.getByTestId("answer-source-drawer-follow-up"); await expect(followUpButton).toBeVisible(); await followUpButton.click(); @@ -3094,24 +3097,22 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(supportCard).toBeVisible(); await expect(supportCard).toContainText("Review source match"); await expect(supportCard).toContainText("Verify cited passages"); - await expect(supportCard).toContainText("Clinical notes"); - await expect(supportCard.getByTestId("answer-evidence-trigger")).toContainText(/sources?|claims?/i); - await expect(supportCard.getByTestId("answer-evidence-trigger")).not.toContainText("0 claims"); - - const clinicalTrigger = page.locator("#answer-clinical-notes-drawer-mobile-trigger"); - await expect(clinicalTrigger).toBeVisible(); - await clinicalTrigger.click(); - const clinicalNotesSheet = page.getByRole("dialog", { name: "Clinical notes" }); - await expect(clinicalNotesSheet).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(clinicalNotesSheet).toHaveCount(0); - - await supportCard.getByTestId("answer-evidence-trigger").click(); - const evidenceSheet = page.getByRole("dialog", { name: "Evidence" }); - await expect(evidenceSheet).toBeVisible(); - await expect(evidenceSheet.getByTestId("mobile-evidence-tabs")).toBeVisible(); + await expect(supportCard.getByTestId("answer-clinical-notes-trigger")).toHaveCount(0); + await expect(supportCard.getByTestId("answer-evidence-trigger")).toHaveCount(0); + + // A source-only answer still cites real documents, so the rail must list them + // and the drawer must open — the degraded path is exactly where a clinician + // most needs the route back to the page. + const sourceOnlyRail = page.getByTestId("answer-source-rail"); + await expect(sourceOnlyRail).toBeVisible(); + const sourceOnlyRow = sourceOnlyRail.getByTestId("answer-source-rail-row").first(); + await expect(sourceOnlyRow).toBeVisible(); + await sourceOnlyRow.click(); + const sourceOnlyDrawer = page.getByTestId("answer-source-drawer"); + await expect(sourceOnlyDrawer).toBeVisible(); + await expect(sourceOnlyDrawer.getByRole("link", { name: "View original PDF" })).toBeVisible(); await page.keyboard.press("Escape"); - await expect(evidenceSheet).toHaveCount(0); + await expect(sourceOnlyDrawer).toHaveCount(0); await expectNoPageHorizontalOverflow(page); }); @@ -3136,62 +3137,32 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(supportCard).toBeVisible(); await expectNoPageHorizontalOverflow(page); - const sourceCapsule = plainAnswer.getByRole("button", { name: "Open answer sources" }); - await expectMinTouchTarget(sourceCapsule); - await sourceCapsule.click(); - const sourceSurface = page.getByRole("dialog", { name: "Sources" }); - await expect(sourceSurface).toBeVisible(); - await expect(sourceSurface.getByTestId("source-capsule-preview-row").first()).toHaveAttribute( + // One source surface at every breakpoint: the rail on the page, the drawer + // behind each row. There is no capsule, no popover/sheet split, and no + // separate clinical-notes or evidence sheet to reach. + const rail = page.getByTestId("answer-source-rail"); + await expect(rail).toBeVisible(); + const railRow = rail.getByTestId("answer-source-rail-row").first(); + await expectMinTouchTarget(railRow); + await expect(rail.getByRole("link", { name: /Open S1 source page/i })).toHaveAttribute( "href", /\/documents\/.+chunk=/, ); - await expectMinTouchTarget(sourceSurface.getByTestId("source-capsule-preview-row").first()); - await page.keyboard.press("Escape"); - await expect(sourceSurface).toHaveCount(0); - await expect(sourceCapsule).toBeFocused(); - if (!viewport.sheet) { - await sourceCapsule.click(); - await expect(sourceSurface).toBeVisible(); - await sourceCapsule.click(); - await expect(sourceSurface).toHaveCount(0); - } - - const clinicalTrigger = page.locator("#answer-clinical-notes-drawer-mobile-trigger"); - await expectMinTouchTarget(clinicalTrigger); - await clinicalTrigger.click(); - const clinicalSurface = page.getByRole("dialog", { name: "Clinical notes" }); - await expect(clinicalSurface).toBeVisible(); - await expect(clinicalSurface.getByTestId("clinical-notes-checklist")).toBeVisible(); - await expect(clinicalSurface.getByRole("tab", { name: /Actions/ })).toBeVisible(); - await expectMinTouchTarget(clinicalSurface.getByRole("link", { name: /^Source$/ }).first()); - const clinicalCopy = clinicalSurface.getByRole("button", { name: /^(Copy|Copied)$/ }).first(); - await expectMinTouchTarget(clinicalCopy); - await clinicalCopy.click(); - await page.keyboard.press("Escape"); - await expect(clinicalSurface).toHaveCount(0); - await expect(clinicalTrigger).toBeVisible(); - - const evidenceTrigger = page.locator("#answer-evidence-drawer-mobile-trigger"); - await expectMinTouchTarget(evidenceTrigger); - await evidenceTrigger.click(); - const evidenceSurface = page.getByRole("dialog", { name: "Evidence" }); - await expect(evidenceSurface).toBeVisible(); - await expect(evidenceSurface.getByTestId("mobile-evidence-tab-claims")).toHaveAttribute("aria-selected", "true"); - await expect(evidenceSurface.getByTestId("mobile-evidence-panel-claims")).toBeVisible(); - await expect(evidenceSurface.getByTestId("evidence-claims-panel")).toBeVisible(); - await expectMinTouchTarget(evidenceSurface.getByRole("link", { name: /^Source$/ }).first()); - const evidenceCopy = evidenceSurface.getByRole("button", { name: /^(Copy|Copied)$/ }).last(); - await expectMinTouchTarget(evidenceCopy); - await evidenceCopy.click(); - const evidenceTablesTab = evidenceSurface.getByTestId("mobile-evidence-tab-tables"); - if (await evidenceTablesTab.count()) { - await evidenceTablesTab.click(); - await expect(evidenceSurface.getByTestId("mobile-evidence-panel-tables")).toBeVisible(); - await expectMinTouchTarget(evidenceTablesTab); + await railRow.click(); + const drawer = page.getByTestId("answer-source-drawer"); + await expect(drawer).toBeVisible(); + await expect(drawer.getByTestId("answer-source-drawer-support")).toBeVisible(); + await expectMinTouchTarget(drawer.getByRole("link", { name: "View original PDF" })); + const drawerPager = drawer.getByTestId("answer-source-drawer-pager"); + if (await drawerPager.count()) { + await expectMinTouchTarget(drawerPager.getByRole("button", { name: "Next source" })); } await page.keyboard.press("Escape"); - await expect(evidenceSurface).toHaveCount(0); - await expect(evidenceTrigger).toBeFocused(); + await expect(drawer).toHaveCount(0); + await expect(railRow).toBeFocused(); + + await expect(page.locator("#answer-clinical-notes-drawer-mobile-trigger")).toHaveCount(0); + await expect(page.locator("#answer-evidence-drawer-mobile-trigger")).toHaveCount(0); await expectNoPageHorizontalOverflow(page); }); @@ -3212,7 +3183,32 @@ test.describe("Clinical KB UI smoke coverage", () => { const submitAnswer = visibleAnswerSubmitButton(page); await submitAnswer.click(); - const clinicalTable = page.getByLabel("Inline table preview").first(); + // Decision 2 (2026-08-24): the wide-screen table column is gone at every + // breakpoint, so a table is only ever reached through the source drawer. + await expect(page.getByLabel("Inline table preview")).toHaveCount(0); + await expect(page.getByTestId("table-specific-answer-layout")).toHaveAttribute( + "data-desktop-table-aside", + "false", + ); + const tableRail = page.getByTestId("answer-source-rail"); + await expect(tableRail).toBeVisible(); + // A table hangs off the source it was cited from, which is not necessarily + // the best match — open the row that actually has it. + const tableRows = tableRail.getByTestId("answer-source-rail-row"); + const tableDrawer = page.getByTestId("answer-source-drawer"); + const clinicalTable = tableDrawer.getByLabel("Inline table preview").first(); + let openedTableRow = false; + for (let index = 0; index < (await tableRows.count()); index += 1) { + await tableRows.nth(index).click(); + await expect(tableDrawer).toBeVisible(); + if (await clinicalTable.count()) { + openedTableRow = true; + break; + } + await page.keyboard.press("Escape"); + await expect(tableDrawer).toHaveCount(0); + } + expect(openedTableRow).toBe(true); await expect(clinicalTable).toBeVisible(); await expect(clinicalTable).toContainText("FBC/ANC"); await expect(clinicalTable).not.toContainText(/page|p\.|chunk|Synthetic clozapine monitoring protocol/i); @@ -3220,31 +3216,9 @@ test.describe("Clinical KB UI smoke coverage", () => { const expandButton = clinicalTable.getByTestId("table-expand-button"); const tableSurface = clinicalTable.getByTestId("accessible-table-surface"); if (!viewport.expands) { - await expect(page.getByRole("button", { name: "Open answer sources" })).toContainText(/sources?/i); - await expect(page.getByTestId("table-specific-answer-layout")).toHaveAttribute( - "data-desktop-table-aside", - "true", - ); - const desktopLayout = await page.evaluate(() => { - const answer = document.querySelector('[data-testid="plain-answer-response"]'); - const support = document.querySelector('[data-testid="answer-support-card"]'); - const table = document.querySelector('[aria-label="Inline table preview"]'); - const answerRect = answer?.getBoundingClientRect(); - const supportRect = support?.getBoundingClientRect(); - const tableRect = table?.getBoundingClientRect(); - return { - answerRight: answerRect?.right ?? 0, - answerTop: answerRect?.top ?? 9999, - supportRight: supportRect?.right ?? 0, - tableLeft: tableRect?.left ?? 0, - tableTop: tableRect?.top ?? 9999, - }; - }); - expect(desktopLayout.tableLeft).toBeGreaterThan( - Math.max(desktopLayout.answerRight, desktopLayout.supportRight), - ); - expect(Math.abs(desktopLayout.tableTop - desktopLayout.answerTop)).toBeLessThan(180); + // Desktop reads the table in place inside the drawer rather than expanding it. await expect(expandButton).toHaveCount(0); + await expect(tableSurface).toBeVisible(); await expectNoPageHorizontalOverflow(page); return; } @@ -3252,7 +3226,6 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(tableSurface).not.toHaveAttribute("role", "button"); await expect(tableSurface).not.toHaveAttribute("tabindex"); await expect(expandButton).toHaveAttribute("aria-expanded", "false"); - await page.keyboard.press("Escape"); const surfaceDialog = await openMobileTableFullscreen(page, clinicalTable); await expect(expandButton).toHaveAttribute("aria-expanded", "true"); await expect(surfaceDialog.getByRole("button", { name: "Close full-screen table" })).toBeFocused(); @@ -3270,12 +3243,15 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(dialog.getByRole("table")).toBeVisible(); await expect(dialog).toContainText("FBC/ANC"); await expect(dialog).not.toContainText(/page|p\.|chunk|Synthetic clozapine monitoring protocol/i); - const modal = page.getByRole("dialog", { name: /clozapine monitoring/i }); - await expect(modal).toBeVisible(); + // The full-screen table now opens from inside the source drawer, which is + // itself a dialog carrying the document's name — so a by-name lookup is + // ambiguous. `openMobileTableFullscreen` already hands back the right + // handle; the focus-trap assertions are unchanged. + await expect(dialog).toBeVisible(); await page.keyboard.press("Shift+Tab"); - expect(await modal.evaluate((element) => element.contains(document.activeElement))).toBe(true); + expect(await dialog.evaluate((element) => element.contains(document.activeElement))).toBe(true); await page.keyboard.press("Tab"); - expect(await modal.evaluate((element) => element.contains(document.activeElement))).toBe(true); + expect(await dialog.evaluate((element) => element.contains(document.activeElement))).toBe(true); await expectNoPageHorizontalOverflow(page); await page.keyboard.press("Escape"); await expect(dialog).toBeHidden(); diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index 94d7923165..12205f264c 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -418,14 +418,18 @@ test.describe("Clinical KB long-content stress coverage", () => { await expect(page.getByRole("button", { name: "Copy answer with citations" })).toHaveCount(0); await expect(page.getByTestId("evidence-rail")).toHaveCount(0); await expect(page.getByTestId("evidence-summary-card")).toHaveCount(0); - 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 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(); + // The evidence sheet gave way to the source rail and its per-source drawer; + // under long titles and narrow viewports neither may overflow. + await expect(page.locator("#answer-evidence-drawer-mobile-trigger")).toHaveCount(0); + const sourceRail = page.getByTestId("answer-source-rail"); + await expect(sourceRail).toBeVisible(); + await sourceRail.getByTestId("answer-source-rail-row").first().click(); + const sourceDrawer = page.getByTestId("answer-source-drawer"); + await expect(sourceDrawer).toBeVisible(); + await expect(sourceDrawer.getByTestId("answer-source-drawer-support")).toBeVisible(); + await expectNoPageHorizontalOverflow(page); + await page.keyboard.press("Escape"); + await expect(sourceDrawer).toHaveCount(0); await expect(page.locator('[data-testid="evidence-support-panel"]:visible')).toHaveCount(0); await expectNoPageHorizontalOverflow(page); });