From 105da8a75128fc0d1798b2f8040b6b32ffb746ed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 03:53:15 +0000 Subject: [PATCH 01/12] Answer page: numbered source marks in the prose, and a card rail under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2362 built the source rail as a vertical list and left the prose unmarked, which is why the approved redesign did not visibly land. This finishes it: the question is a chat bubble, each sentence that has recorded support carries a small numbered mark, and the cited documents sit under the answer as a row of cards rather than a stack of rows. The marks are the substantive part, and the brief was wrong about where their data comes from. `docs/answer-page-redesign-handover.md` §3 assumed `answerSections`, but the generation contract defines that as a SECOND LAYER of structured support written alongside the prose, so a section's body is not text the clinician reads and there is no sentence for its mark to attach to. `RagAnswer.supportedClaims` is the field that does anchor to the prose: `rag-claim-support.ts` builds its top-level entries as `splitClaims(answer.answer)` — literally the sentences on screen — each carrying `supportingChunkIds` and a `supportStatus`. `answer-render-policy.ts` already reads it client-side, so the data was on the client and unused. A mark therefore restates an attribution the answer pipeline already made rather than re-deriving one by matching prose to retrieved chunks after the fact, which is the failure mode ledger #VXB8XA tracks. New `src/lib/answer-claim-marks.ts` holds the resolution, and every rule in it is exact — no similarity scoring, no threshold that could later be nudged to raise coverage. A sentence earns a cluster only when it IS one recorded claim, or is exactly a run of consecutive claims that are all `direct`. Ambiguity resolves to no mark: a rewritten sentence, two recorded claims that disagree, a citation the rail does not list, a sentence the word budget cut short, and `unsupported` all render nothing. Expect partial coverage on real answers; that is the designed degrade, with the rail still carrying every source. `primaryAnswerDisplayText` is now defined as the join of `primaryAnswerDisplayFragments`, so splitting the prose for marks cannot change a character of what is displayed — pinned by a test over every branch of the selector. The mark's geometry (12x12 box, 5px rise, 2.2px clearance, 2.7px between marks) is recorded in the component: two earlier shapes collapsed the box and overlapped adjacent marks, and both are documented so they are not retried. The rail becomes horizontally scrolling cards. Six stacked 48px rows was ~290px of phone scroll spent on chrome, and a vertical list of documents reads as the answer's conclusion rather than its references. Only CITED documents are numbered — a retrieved-but-uncited card takes a dashed em-dash badge, because these are the same numbers the marks use and a number no mark can reach is a promise the prose cannot keep. Other changes: - The current turn's question renders as `UserQuestionBubble`, the same shape every prior turn already used, so the newest exchange stops reading as a document with a subtitle. `AnswerCard`, `VerificationNotice`, the support wording and the degraded banners are untouched: handover §12.1 keeps system-owned verification above the prose and out of scope here. - The drawer gains an overflow menu — copy passage, ask about this passage, search only this document, and a two-step "This page doesn't support the claim" that rides the existing `wrong_source` feedback channel. Once a number points at a specific page, a clinician opening it and finding it does not say that is the highest-value moment to catch a bad citation. - `activeSupportIndex` is wired, so opening from a mark says what that claim's support is instead of the generic fallback, and closing returns focus to the mark rather than dumping the reader on the rail. - `tablesForSource` / `imagesForSource` moved to the shared leaf so a card's attachment marker and the drawer's contents are one rule. - `sourceSupportLabel` no longer takes an index. Its `index === 0` branch was unreachable but read as though the first row were promoted to direct support by position. - `demoAnswer` now emits `supportedClaims`. Without it the demo corpus renders the degraded no-marks path, so the feature would be invisible offline and untestable in the browser gates. No file under `src/lib/rag/**` is touched, and no retrieval or ranking behaviour changes. Raising mark coverage would mean editing the generation contract, which is handover PR 3: owner flag, live eval canary, `RAG impact:` line. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AP3MXGx1bGeEBmFKYkeLA8 --- docs/answer-page-redesign-handover.md | 42 +++ docs/design-system/adoption-manifest.json | 7 +- src/components/ClinicalDashboard.tsx | 1 + .../clinical-dashboard/answer-content.tsx | 284 +++++++++++++++-- .../answer-result-surface.tsx | 79 ++++- .../answer-source-drawer.tsx | 300 +++++++++++++++--- .../clinical-dashboard/answer-source-mark.tsx | 108 +++++++ .../clinical-dashboard/answer-source-rail.tsx | 293 +++++++++-------- .../clinical-dashboard/answer-source-rows.ts | 121 ++++++- src/lib/answer-claim-marks.ts | 241 ++++++++++++++ src/lib/demo-data.ts | 36 +++ tests/answer-claim-marks.test.ts | 193 +++++++++++ tests/answer-content.test.ts | 75 ++++- tests/answer-source-marks.dom.test.tsx | 256 +++++++++++++++ tests/answer-source-rail.dom.test.tsx | 102 +++++- tests/ui-smoke.spec.ts | 93 ++++-- 16 files changed, 1974 insertions(+), 257 deletions(-) create mode 100644 src/components/clinical-dashboard/answer-source-mark.tsx create mode 100644 src/lib/answer-claim-marks.ts create mode 100644 tests/answer-claim-marks.test.ts create mode 100644 tests/answer-source-marks.dom.test.tsx diff --git a/docs/answer-page-redesign-handover.md b/docs/answer-page-redesign-handover.md index 8097260d2d..19635c7a83 100644 --- a/docs/answer-page-redesign-handover.md +++ b/docs/answer-page-redesign-handover.md @@ -60,6 +60,48 @@ case, and it fails closed: no sections, no marks. That is why the rail and drawer ship first, and the marks second. +### 1a. Corrected again, 2026-08-25, when the marks were built: sections are the wrong source + +Everything above is accurate about `answerSections` and still wrong about the marks, and +the reason only shows up when you try to render one. + +`answerSections` is a **second layer**. The generation contract calls it "Second-layer +structured support… distinct source-backed modules that improve scanability" +(`src/lib/rag/rag.ts:340`), and the composition instruction tells the model to write the +prose into `answer` and _then_ use `answerSections` for separate structured support +(`:4313`). A section's body is therefore not text the clinician reads in the prose, so +there is no sentence in the answer for a section's mark to attach to. A mark per section +is only buildable by rendering the sections themselves — which is a different product +decision (more content on the answer surface), not the design that was approved. + +**The field that does anchor to the prose is `RagAnswer.supportedClaims`** +(`src/lib/types.ts:519`). `rag-claim-support.ts:1049` builds its top-level entries as +`splitClaims(answer.answer)` — literally the sentences of the displayed prose — and each +carries `supportingChunkIds` and a `supportStatus` of `direct | partial | unsupported`. +`answer-render-policy.ts` already reads it client-side, so it is on the client today with +no payload change. + +So the marks are built from `supportedClaims`, and §3's requirement is met by a stricter +route than it asked for: attribution is per _sentence_ rather than per section, and it is +the pipeline's own recorded attribution rather than one the render layer derived. The +resolution rules live in `src/lib/answer-claim-marks.ts` and are exact — a sentence either +**is** a recorded claim (or exactly a run of consecutive all-`direct` ones) or it carries +no mark. Every ambiguity resolves to no mark: + +- the display sanitizer rewrote the sentence → no mark; +- two recorded claims disagree about the same sentence → no mark; +- the claim cites a chunk the rail does not list → that citation is dropped, never + renumbered onto a neighbouring card; +- the word budget cut the sentence short → no mark; +- `supportStatus: "unsupported"` → no mark, and no worded tag either (see §12.2 below, + superseded by the owner in design review on 2026-08-25). + +**Expect partial coverage, and do not tune it up.** A sentence the usefulness pass +rewrote, or one holding several claims at different support levels, renders unmarked with +the rail underneath still carrying every source. That is the designed degrade. Raising +coverage means changing what the generation contract emits, which is PR 3 — protected RAG +surface, owner flag, live eval canary. + --- ## 2. What replaces what diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index c0f5b6f48a..48adb2f1ec 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1445,7 +1445,11 @@ "preview": ".design-sync/previews/SafeBoldText.tsx", "previewValid": true }, - "testFiles": ["tests/design-sync-visual-exports.test.ts", "tests/display-text.test.ts"], + "testFiles": [ + "tests/answer-content.test.ts", + "tests/design-sync-visual-exports.test.ts", + "tests/display-text.test.ts" + ], "baseline": { "targetLayer": "v2", "liveLayer": "v2", @@ -1658,6 +1662,7 @@ }, "testFiles": [ "tests/accessible-table.dom.test.tsx", + "tests/answer-source-rail.dom.test.tsx", "tests/caring-contacts-overlay-host.dom.test.tsx", "tests/caring-contacts-overlay-trigger.dom.test.tsx", "tests/design-sync-visual-exports.test.ts", diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 54ca728255..cbdd831a23 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3801,6 +3801,7 @@ function ClinicalDashboardContent({ followUpSuggestionsDisabled={loading} crossModeQueries={crossModeQueries} onCrossModeSearch={handleCrossModeSearch} + onScopeDocument={handleScopeDocument} /> ) : null diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 2e4c7617d2..dc90b8e8f4 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -1,16 +1,26 @@ "use client"; -import { memo, useState } from "react"; +import { Fragment, memo, useState } from "react"; import { CircleAlert, ChevronDown, Copy, ShieldCheck } from "lucide-react"; import { SafeBoldText } from "@/components/SafeBoldText"; import { chatActionRow, chatAnswerText, chatMicroAction, cn, textMuted } from "@/components/ui-primitives"; -import { comparableAnswerText, sanitizeAnswerDisplayText } from "@/components/clinical-dashboard/display-text"; +import { + cleanDisplayTitle, + comparableAnswerText, + sanitizeAnswerDisplayText, +} from "@/components/clinical-dashboard/display-text"; import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences"; import { AnswerSourceRail } from "@/components/clinical-dashboard/answer-source-rail"; -import { buildAnswerSourceRows } from "@/components/clinical-dashboard/answer-source-rows"; +import { AnswerSourceMark, AnswerSourceMarkOverflow } from "@/components/clinical-dashboard/answer-source-mark"; +import { + type AnswerSourceRow, + buildAnswerSourceRows, + sourceSpokenLabel, +} from "@/components/clinical-dashboard/answer-source-rows"; import { SignedImage } from "@/components/clinical-dashboard/signed-image"; import { clinicalProseUsefulness } from "@/lib/source-text-sanitizer"; +import { type ClaimMarkCluster, resolveClaimMarks } from "@/lib/answer-claim-marks"; import { type SourceLink } from "@/lib/answer-render-policy"; import type { AnswerSection, @@ -18,6 +28,7 @@ import type { BestSourceRecommendation, RagAnswer, SearchResult, + SupportedClaim, VisualEvidenceCard, } from "@/lib/types"; @@ -114,15 +125,58 @@ export function plainAnswerText(value: string, options: AnswerDisplayTextOptions * @returns The display-ready answer text */ export function primaryAnswerDisplayText(value: string, options: AnswerDisplayTextOptions = {}) { + return primaryAnswerDisplayFragments(value, options) + .map((fragment) => fragment.display) + .join(" "); +} + +/** + * One displayed sentence of the primary answer. + * + * `raw` exists because the two texts a source mark has to reconcile took + * different routes: the server split its claims from the answer *before* the + * prose-usefulness pass rewrote a sentence for display. Matching on `raw` is + * what lets a mark restate an attribution the pipeline already made, rather + * than re-deriving one from the rewritten text. + */ +export type AnswerDisplayFragment = { + /** What the reader sees. */ + display: string; + /** The same sentence before the usefulness pass — the text `splitClaims` saw. */ + raw: string; + /** True when the word budget cut this sentence short. A cut sentence is not the claim. */ + truncated: boolean; +}; + +/** + * Selects and compacts the primary answer, sentence by sentence, preserving + * safety-critical guidance. + * + * `primaryAnswerDisplayText` is `fragments.map(display).join(" ")` and nothing + * else, so splitting the prose for marks cannot change a single character of + * what is displayed. `tests/answer-content.test.ts` pins that equivalence. + * + * @param value - The answer text to prepare for display + * @param options - Formatting options, including preformatted mode + * @returns The display-ready sentences, in order + */ +export function primaryAnswerDisplayFragments( + value: string, + options: AnswerDisplayTextOptions = {}, +): AnswerDisplayFragment[] { // Deterministic preformatted answers are already concise and display-ready; // the fragment-level usefulness pass below would re-strip the very names/codes - // the preformatted path just preserved, so return them as-is. - if (options.preformatted) return plainAnswerText(value, options); + // the preformatted path just preserved, so return them as-is — one fragment, + // whitespace and all, which is also why they carry no marks. + if (options.preformatted) { + const text = plainAnswerText(value, options); + return text ? [{ display: text, raw: text, truncated: false }] : []; + } // Skip whole-text clinicalProseUsefulness: its 3-token floor drops short // safety sentences ("Stop lithium.") before the fragment-level safety // bypass below can rescue them. const cleaned = sanitizeAndStripSyntheticNotice(value, { preformatted: false, preserveBold: options.preserveBold }); - const fragments = cleaned + const prepared = cleaned .split(/\r?\n+/) .flatMap((line: string) => line.split(/(?<=[.!?])\s+(?=(?:[A-Z]|\*\*|If\b|When\b|Do\b|Use\b|Monitor\b|Escalate\b|Document\b))/), @@ -139,41 +193,149 @@ export function primaryAnswerDisplayText(value: string, options: AnswerDisplayTe // Safety-bearing fragments pass through untouched and are never dropped by // the usefulness/length gate — a short caveat like "Contraindicated in // pregnancy" (under the 8-word floor) must still reach the display. - .map((fragment: string) => - isPrimaryAnswerSafetyFragment(fragment) ? fragment : clinicalProseUsefulness(fragment).text || fragment, - ) - .filter((fragment: string) => { - if (!fragment) return false; - if (isPrimaryAnswerSafetyFragment(fragment)) return true; - const useful = clinicalProseUsefulness(fragment); - return useful.useful || fragment.split(/\s+/).length >= 8; + .map((raw: string) => ({ + raw, + display: isPrimaryAnswerSafetyFragment(raw) ? raw : clinicalProseUsefulness(raw).text || raw, + })) + .filter(({ display }) => { + if (!display) return false; + if (isPrimaryAnswerSafetyFragment(display)) return true; + const useful = clinicalProseUsefulness(display); + return useful.useful || display.split(/\s+/).length >= 8; }); - const uniqueFragments = Array.from(new Set(fragments)); - const selected: string[] = []; + const uniqueFragments: AnswerDisplayFragment[] = []; + const seenDisplay = new Set(); + for (const fragment of prepared) { + if (seenDisplay.has(fragment.display)) continue; + seenDisplay.add(fragment.display); + uniqueFragments.push({ ...fragment, truncated: false }); + } + const selected: AnswerDisplayFragment[] = []; let nonSafetyKept = 0; let wordBudget = 85; for (const fragment of uniqueFragments) { - if (isPrimaryAnswerSafetyFragment(fragment)) { + if (isPrimaryAnswerSafetyFragment(fragment.display)) { selected.push(fragment); continue; } if (nonSafetyKept >= 3 || wordBudget <= 0) continue; nonSafetyKept += 1; - const words = fragment.split(/\s+/).filter(Boolean); + const words = fragment.display.split(/\s+/).filter(Boolean); if (words.length <= wordBudget) { selected.push(fragment); wordBudget -= words.length; } else { - selected.push( - `${words + selected.push({ + ...fragment, + display: `${words .slice(0, wordBudget) .join(" ") .replace(/[;,:-]\s*$/, "")}...`, - ); + truncated: true, + }); wordBudget = 0; } } - return selected.join(" ") || cleaned; + if (selected.length) return selected; + return cleaned ? [{ display: cleaned, raw: cleaned, truncated: false }] : []; +} + +/** + * Splits a sentence at its last word so the word and the whole mark cluster can + * be wrapped together — a number stranded alone on the next line reads as a + * footnote to nothing. + * + * Returns `null` when the split is unsafe. Production prose carries server bold, + * and the last word can sit inside a `**…**` run that a string split would cut + * in half; an odd number of markers in the head is exactly that case, and the + * sentence is then rendered whole rather than mangled. + */ +export function splitTrailingWord(text: string): { head: string; tail: string } | null { + const lastSpace = text.lastIndexOf(" "); + if (lastSpace <= 0) return null; + const head = text.slice(0, lastSpace); + const tail = text.slice(lastSpace + 1); + if (!tail) return null; + if ((head.match(/\*\*/g)?.length ?? 0) % 2 !== 0) return null; + return { head, tail }; +} + +/** The accessible name of a mark. Distinct from the drawer pager's "Show source N…". */ +function markLabel(row: AnswerSourceRow | undefined, index: number, support: ClaimMarkCluster["support"]) { + const strength = support === "partial" ? "partial support" : "direct support"; + if (!row) return `${sourceSpokenLabel(index)} — ${strength}`; + return `${sourceSpokenLabel(index)}: ${cleanDisplayTitle(row.title)}, page ${row.pageNumber ?? "not available"} — ${strength}`; +} + +/** + * The sentence that owns the open source is washed while the drawer is up. The + * drawer covers the lower third of a phone, and this is what stops a clinician + * losing the sentence they were checking. + * + * The left rule is not decoration. Backgrounds are remapped under forced-colors, + * so colour alone would erase the wash for the readers who most need to keep + * their place; the border is painted. `-ml-1`/`pl-1` cancel out, so lighting a + * sentence does not move the text. + */ +const litClaimClass = + "-ml-1 rounded-[var(--radius-xs)] border-l-2 border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)] pl-1"; + +function AnswerProseSentence({ + fragment, + cluster, + rows, + openSourceIndex, + onOpenSource, +}: { + fragment: AnswerDisplayFragment; + cluster: ClaimMarkCluster | null; + rows: AnswerSourceRow[]; + openSourceIndex: number | null; + onOpenSource?: (index: number) => void; +}) { + if (!cluster || !onOpenSource) return ; + + const lit = openSourceIndex !== null && cluster.marks.some((mark) => mark.index === openSourceIndex); + const marks = ( + <> + {cluster.marks.map((mark, position) => ( + + ))} + + + ); + const split = splitTrailingWord(fragment.display); + + return ( + + {split ? ( + <> + {" "} + + + {marks} + + + ) : ( + + + {marks} + + )} + + ); } /** @@ -203,6 +365,8 @@ export { * @param sourceLinks - Source links and snippets associated with the answer. * @param copied - Whether the answer has been copied. * @param onCopy - Callback invoked to copy the answer with source status. + * @param claims - Server-assessed per-sentence support, used to place the numbered source marks. + * @param openSourceIndex - Rail row the drawer is currently showing, or `null` while it is closed. * @returns The rendered answer section, or `null` when the answer has no displayable text. */ export function NaturalLanguageAnswer({ @@ -213,9 +377,13 @@ export function NaturalLanguageAnswer({ bestSource, sources, sourceLinks, + claims, + railRows, copied, onCopy, onOpenSource, + onOpenRailSource, + openSourceIndex = null, }: { // Raw answer text (server bold intact); this component owns display // sanitization so can render the high-yield emphasis. @@ -226,6 +394,19 @@ export function NaturalLanguageAnswer({ bestSource: BestSourceRecommendation | null; sources: SearchResult[]; sourceLinks: SourceLink[]; + /** + * `answer.supportedClaims`. Absent on a historical turn and on any answer the + * pipeline did not assess, which is the degrade case: prose with no marks and + * the rail still carrying every source. + */ + claims?: readonly SupportedClaim[]; + /** + * Pre-built rail rows. The answer surface already derives these (annotated + * with which sources carry a table or an image), so it passes them down rather + * than leaving this component to derive a second, subtly different list from + * the same three inputs. + */ + railRows?: AnswerSourceRow[]; copied: boolean; onCopy: () => void; /** @@ -234,12 +415,38 @@ export function NaturalLanguageAnswer({ * owns which one is open. */ onOpenSource?: (index: number) => void; + /** + * Opens the drawer from a rail card rather than from a claim. Separate from + * `onOpenSource` because the drawer says something different in each case — + * a card is a document, a mark is a claim about a document — and defaulting + * to `onOpenSource` would have every rail tap assert a claim nobody made. + */ + onOpenRailSource?: (index: number) => void; + /** Which rail row the drawer is showing, so the mark and its sentence can light up. */ + openSourceIndex?: number | null; }) { const [sourceOnlyNoticeOpen, setSourceOnlyNoticeOpen] = useState(false); const { preferences } = useAppPreferences(); - const cleaned = primaryAnswerDisplayText(text, { preformatted, preserveBold: true }); - if (!cleaned) return null; - const railSources = buildAnswerSourceRows(bestSource, sources, sourceLinks); + const fragments = primaryAnswerDisplayFragments(text, { preformatted, preserveBold: true }); + if (!fragments.length) return null; + const railSources = railRows ?? buildAnswerSourceRows(bestSource, sources, sourceLinks); + /** + * Marks may only point at CITED rows. `buildAnswerSourceRows` puts those first + * and the also-found rows after, so an index into this prefix is already the + * right index into the rail — and a claim citing a chunk that only made the + * retrieved set resolves to nothing rather than to a card the rail shows + * unnumbered. + */ + const citedSourceIds = railSources.filter((row) => row.cited !== false).map((row) => row.id); + // A historical turn mounts no drawer, so a mark there would advertise a panel + // that never opens. Those turns render the prose unmarked. + const clusters = onOpenSource + ? resolveClaimMarks({ + fragments: fragments.map((fragment) => ({ text: fragment.raw, truncated: fragment.truncated })), + claims, + sourceIds: citedSourceIds, + }) + : fragments.map(() => null); return (

+ {/* One span per sentence, joined by a single space, so the prose's + textContent is byte-identical to the un-marked rendering. */} - + {fragments.map((fragment, index) => ( + + {index > 0 ? " " : null} + + + ))}

@@ -306,7 +526,8 @@ export function NaturalLanguageAnswer({
@@ -335,7 +556,14 @@ export function UserQuestionBubble({ query }: { query: string }) { data-testid="user-question-bubble" className="ml-auto max-w-[min(28rem,86%)] rounded-lg border border-[color:var(--border)] bg-[color:var(--clinical-accent-soft)] px-3 py-2 text-right shadow-[var(--shadow-inset)] sm:max-w-[28rem]" > -

{cleaned}

+

+ {/* Carried over from AnswerCardQueryEcho when the current turn's + question moved out of the card header and into this bubble: without + it a screen reader reads the question as an unlabelled sentence + immediately before the answer. */} + Question: + {cleaned} +

); diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index 82c3410c63..fd39065f47 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -1,13 +1,17 @@ "use client"; import { useRouter } from "next/navigation"; -import { memo, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useMemo, useRef, useState } from "react"; import { ShieldAlert } from "lucide-react"; import { type AnswerFeedbackType } from "@/lib/answer-feedback"; import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { CrossModeLinksSection } from "@/components/clinical-dashboard/cross-mode-links"; -import { isPreformattedGroundedAnswer, NaturalLanguageAnswer } from "@/components/clinical-dashboard/answer-content"; +import { + isPreformattedGroundedAnswer, + NaturalLanguageAnswer, + UserQuestionBubble, +} from "@/components/clinical-dashboard/answer-content"; import { answerStateForAnswer } from "@/components/clinical-dashboard/answer-copy-payload"; import { AnswerSupportSummaryCard, @@ -17,7 +21,7 @@ import { } from "@/components/clinical-dashboard/evidence-panels"; import { AnswerSourceDrawer } from "@/components/clinical-dashboard/answer-source-drawer"; import { CanonicalAnswerTables } from "@/components/clinical-dashboard/visual-evidence"; -import { buildAnswerSourceRows } from "@/components/clinical-dashboard/answer-source-rows"; +import { annotateSourceAttachments, buildAnswerSourceRows } from "@/components/clinical-dashboard/answer-source-rows"; import { citedDocumentHref } from "@/components/clinical-dashboard/source-actions"; import { AnswerCard, type AnswerSupportStrength } from "@/components/ui/answer-card"; import { Sheet } from "@/components/ui/sheet"; @@ -60,6 +64,7 @@ function StagedAnswerResultSurfaceImpl({ followUpSuggestionsDisabled = false, crossModeQueries, onCrossModeSearch, + onScopeDocument, }: { answer: RagAnswer; query: string; @@ -81,6 +86,8 @@ function StagedAnswerResultSurfaceImpl({ followUpSuggestionsDisabled?: boolean; crossModeQueries?: Array; onCrossModeSearch?: (mode: AppModeId, query: string) => void; + /** Narrows the search to one document, from the source drawer's overflow menu. */ + onScopeDocument?: (documentId: string) => void; }) { const router = useRouter(); const sourceCount = @@ -96,8 +103,12 @@ function StagedAnswerResultSurfaceImpl({ * than each re-deriving from `primarySources` and drifting apart. */ const railSources = useMemo( - () => buildAnswerSourceRows(bestSource, sources, renderModel.primarySources), - [bestSource, sources, renderModel.primarySources], + () => + annotateSourceAttachments(buildAnswerSourceRows(bestSource, sources, renderModel.primarySources), { + tables: renderModel.tables, + visualEvidence: renderModel.visualEvidence, + }), + [bestSource, sources, renderModel.primarySources, renderModel.tables, renderModel.visualEvidence], ); // `trust` already distinguishes these; until now only a conditionally-rendered // side card ever showed the difference, so a "medium" answer - which includes @@ -115,6 +126,35 @@ function StagedAnswerResultSurfaceImpl({ const [safetyFindingsOpen, setSafetyFindingsOpen] = useState(false); /** Which rail row the source drawer is showing; `null` while it is closed. */ const [openSourceIndex, setOpenSourceIndex] = useState(null); + /** + * The row a *claim* pointed at, which is not the same question as which row is + * open: opening from the rail, or paging on from a mark, means there is no + * claim to speak about, and the drawer must not assert one. + */ + const [claimSourceIndex, setClaimSourceIndex] = useState(null); + const openSourceFromRail = useCallback((index: number) => { + setClaimSourceIndex(null); + setOpenSourceIndex(index); + }, []); + const openSourceFromClaim = useCallback((index: number) => { + setClaimSourceIndex(index); + setOpenSourceIndex(index); + }, []); + const closeSourceDrawer = useCallback(() => { + setOpenSourceIndex(null); + setClaimSourceIndex(null); + }, []); + /** + * "This page doesn't support the claim", from the drawer's overflow menu. + * + * It rides the answer feedback channel that already exists rather than a new + * one: `wrong_source` is exactly this report in the shipped taxonomy + * (`src/lib/answer-feedback.ts`), and reusing it means the report lands in the + * same place a clinician's other answer feedback does. + */ + const reportSourceMismatch = useCallback(() => { + onSubmitFeedback("wrong_source"); + }, [onSubmitFeedback]); const safetyTriggerRef = useRef(null); function openSafetyFindings() { setSafetyFindingsOpen(true); @@ -165,9 +205,16 @@ function StagedAnswerResultSurfaceImpl({ bestSource={bestSource} sources={sources} sourceLinks={renderModel.primarySources} + // Server-assessed, per-sentence. This is what lets a number in the prose + // restate an attribution the answer pipeline already made rather than one + // this layer invented; where it is absent the prose renders unmarked. + claims={answer.supportedClaims} + railRows={railSources} copied={copiedAnswer} onCopy={onCopyAnswer} - onOpenSource={setOpenSourceIndex} + onOpenSource={openSourceFromClaim} + onOpenRailSource={openSourceFromRail} + openSourceIndex={openSourceIndex} /> ); /** @@ -209,8 +256,16 @@ function StagedAnswerResultSurfaceImpl({ `stale_evidence`/`partial_retrieval` — the two kinds that say something the notice cannot (#227 over #207; see answer-card.tsx). This surface no longer decides that. */} + {/* The question is a chat bubble on the current turn, exactly as it is + on every prior turn. It used to be a muted echo inside the card + header, which made the newest exchange read as a document with a + subtitle while the ones above it read as a conversation. + `AnswerCardQueryEcho`'s sr-only "Question: " prefix travels with + it (see UserQuestionBubble) so the framing change costs a screen + reader nothing. */} + {answerState.kind === "ready" ? ( - + {answerProse} ) : ( @@ -218,7 +273,6 @@ function StagedAnswerResultSurfaceImpl({ state={answerState} verification={answerVerification} support={answerSupport} - query={query} // Navigate to the cited page — do not reuse onScopeDocument. That // handler only replaces selectedDocumentIds and leaves the clinician // on the answer screen with a silent filter change while the button @@ -278,13 +332,18 @@ function StagedAnswerResultSurfaceImpl({ setOpenSourceIndex(null)} + activeSupportIndex={claimSourceIndex} + // Paging past the source a claim pointed at drops the claim, so the + // support sentence stops describing a page the reader is no longer on. + onOpenIndexChange={openSourceFromRail} + onClose={closeSourceDrawer} query={query} tables={centralTables} visualEvidence={renderModel.visualEvidence} quoteCards={renderModel.quoteCards} onFollowUpQuote={onFollowUpQuote} + onScopeDocument={onScopeDocument} + onReportSource={reportSourceMismatch} /> {safetyFindings.length > 0 ? ( diff --git a/src/components/clinical-dashboard/answer-source-drawer.tsx b/src/components/clinical-dashboard/answer-source-drawer.tsx index 26a8fc81fc..d7e58b5401 100644 --- a/src/components/clinical-dashboard/answer-source-drawer.tsx +++ b/src/components/clinical-dashboard/answer-source-drawer.tsx @@ -1,11 +1,20 @@ "use client"; import Link from "next/link"; -import { useCallback } from "react"; -import { ChevronLeft, ChevronRight, ExternalLink, Search, TriangleAlert } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ChevronLeft, + ChevronRight, + Copy, + ExternalLink, + Filter, + MoreHorizontal, + Search, + TriangleAlert, +} from "lucide-react"; import { Sheet } from "@/components/ui/sheet"; -import { cn, subtleStatusPill, textMuted } from "@/components/ui-primitives"; +import { cn, glassOverlaySurface, subtleStatusPill, textMuted } from "@/components/ui-primitives"; import { logSourceOpen } from "@/components/clinical-dashboard/source-actions"; import { cleanDisplayTitle, sourceQuoteDisplayText } from "@/components/clinical-dashboard/display-text"; import { SignedImage } from "@/components/clinical-dashboard/signed-image"; @@ -13,11 +22,15 @@ import { CanonicalAnswerTables } from "@/components/clinical-dashboard/visual-ev import { answerSourceRailRowId, type AnswerSourceRow, + imagesForSource, sourceBadgeLabel, sourceRowIsStale, + sourceSpokenLabel, sourceStatusShortLabel, sourceSupportSentence, + tablesForSource, } from "@/components/clinical-dashboard/answer-source-rows"; +import { copyTextToClipboard } from "@/lib/copy-to-clipboard"; import { type CanonicalAnswerTableRecord } from "@/lib/answer-render-policy"; import type { QuoteCard, VisualEvidenceCard } from "@/lib/types"; @@ -27,42 +40,10 @@ import type { QuoteCard, VisualEvidenceCard } from "@/lib/types"; */ const NUMBERED_PAGER_LIMIT = 4; -/** - * Attaches each table to the source it was cited from. - * - * A table whose `source.chunkId` matches no row still has to be reachable — - * losing the wide-screen table column was an accepted cost, losing the tables - * was not — so anything unmatched falls to the first source. - */ -function tablesForSource(tables: CanonicalAnswerTableRecord[], sources: AnswerSourceRow[], index: number) { - const chunkIds = new Set(sources.map((source) => source.id)); - const source = sources[index]; - if (!source) return []; - return tables.filter((table) => { - const chunkId = table.source?.chunkId; - if (chunkId && chunkIds.has(chunkId)) return chunkId === source.id; - return index === 0; - }); -} - -/** - * Attaches each image to the source it was cited from. - * - * Same rule as `tablesForSource`: a card whose `source_chunk_id` matches a rail - * row stays on that row only. Anything unmatched falls to the first source so - * it stays reachable after the table column was removed — never to every row - * that happens to share a `documentId`. +/* + * `tablesForSource` and `imagesForSource` moved to `answer-source-rows` so the + * rail card's attachment marker and the drawer's contents are one rule, not two. */ -function imagesForSource(visualEvidence: VisualEvidenceCard[], sources: AnswerSourceRow[], index: number) { - const chunkIds = new Set(sources.map((source) => source.id)); - const source = sources[index]; - if (!source) return []; - return visualEvidence.filter((card) => { - const chunkId = card.source_chunk_id; - if (chunkId && chunkIds.has(chunkId)) return chunkId === source.id; - return index === 0; - }); -} function quoteCardForSource(quoteCards: QuoteCard[], source: AnswerSourceRow | null) { if (!source) return null; @@ -98,6 +79,8 @@ export function AnswerSourceDrawer({ visualEvidence = [], quoteCards = [], onFollowUpQuote, + onScopeDocument, + onReportSource, }: { sources: AnswerSourceRow[]; openIndex: number | null; @@ -114,13 +97,34 @@ export function AnswerSourceDrawer({ * action it supported moved with it rather than being dropped. */ onFollowUpQuote?: (quote: QuoteCard) => void; + /** Narrows the search to this document. A prop pass-through — the composer is untouched. */ + onScopeDocument?: (documentId: string) => void; + /** + * "This page doesn't support the claim." + * + * Once a number points at a specific page, the moment a clinician opens it and + * finds it does not say that is the highest-value moment in the product to + * catch a bad citation — and until now the surface had no control for it. + */ + onReportSource?: (source: AnswerSourceRow) => void; }) { - // Resolved late rather than captured on open, so paging to another source - // returns focus to the row that source actually occupies in the rail. + /** + * Resolved late rather than captured on open, so paging to another source + * returns focus to the card that source actually occupies in the rail. + * + * The one case that must NOT go to the rail is a drawer opened from a mark and + * still showing that mark's source: the reader was mid-sentence, and landing on + * the rail loses the sentence they were checking. Returning `null` there hands + * the decision back to `Sheet`, whose last fallback is the element that had + * focus when the drawer opened — the mark itself. Paging clears + * `activeSupportIndex`, so the rail behaviour resumes as soon as the drawer is + * no longer showing the claim's own page. + */ const resolveReturnFocusTarget = useCallback(() => { if (openIndex === null) return null; + if (activeSupportIndex !== null && activeSupportIndex === openIndex) return null; return document.getElementById(answerSourceRailRowId(openIndex)); - }, [openIndex]); + }, [activeSupportIndex, openIndex]); const open = openIndex !== null && openIndex >= 0 && openIndex < sources.length; const source = open ? sources[openIndex] : null; @@ -141,21 +145,36 @@ export function AnswerSourceDrawer({ closeLabel="Close source detail" titleAccessory={ source ? ( - + {sourceBadgeLabel(openIndex ?? 0)} · p. {source.pageNumber ?? "n/a"} ) : null } headerActions={ source ? ( - query && logSourceOpen(query, source)} - className="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - aria-label={`Open ${cleanDisplayTitle(source.title)} in the document viewer`} - > -