From d4d2b2c8851898ab3d42a822a89473de5d879f44 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Oct 2026 07:49:00 +0000
Subject: [PATCH 01/11] Answer page tidy-up: warning line under notice, shorter
notice, square chips, rows
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01N75FN1tsEXMbK7qAxe5L9p
---
.../2026-10-17-answer-page-tidy-up.md | 8 +++
src/components/ClinicalDashboard.tsx | 12 +++-
.../answer-provider-notice.tsx | 62 +++++++++++++++--
.../answer-result-surface.tsx | 18 +++--
.../clinical-dashboard/answer-status.tsx | 67 ++++++++++++-------
.../clinical-dashboard/cross-mode-links.tsx | 39 ++++-------
src/components/ui/answer-card.tsx | 8 +--
tests/answer-progress-ui-smoke.spec.ts | 8 ++-
tests/answer-provider-notice.dom.test.tsx | 4 +-
tests/ui-smoke.spec.ts | 7 +-
10 files changed, 160 insertions(+), 73 deletions(-)
create mode 100644 docs/decisions/2026-10-17-answer-page-tidy-up.md
diff --git a/docs/decisions/2026-10-17-answer-page-tidy-up.md b/docs/decisions/2026-10-17-answer-page-tidy-up.md
new file mode 100644
index 0000000000..0e4f85f25b
--- /dev/null
+++ b/docs/decisions/2026-10-17-answer-page-tidy-up.md
@@ -0,0 +1,8 @@
+# Decision: Answer page tidy-up and a shorter overseas notice
+
+- **Status:** decided 2026-10-17
+- **Source:** `src/components/clinical-dashboard/answer-provider-notice.tsx`, find "ANSWER_PROVIDER_NOTICE_TEXT" and "AnswerSummaryUnavailableNotice"
+
+The owner approved a mockup of the Answer page and asked for it to be built. The overseas notice under an answer now reads "Sent to OpenAI overseas. Don't include patient details." and is centred. When the model could not write the answer, an amber "AI summary unavailable" line sits directly under that notice instead of a sentence above the question. Other degraded reasons, which say something about the evidence, keep their sentence above the answer. The limitations chip drops its count when it already says "Source-only" or "Review due", and the support and limitations chips share one square shape and size. "How this answer was built" moved under the answer's actions, and "Also in your library" became a plain row with its count shown at every width.
+
+If this summary and the source ever differ, the source wins.
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index d3912045dc..443a4d14d1 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -71,9 +71,13 @@ import { SystemNotice, DegradedNoticeFrame } from "@/components/clinical-dashboa
import { resolveModeHomeCanvasClass } from "@/components/clinical-dashboard/mode-home-canvas";
import { sanitizeAnswerDisplayText } from "@/components/clinical-dashboard/display-text";
import { AnswerCancelledNotice } from "@/components/clinical-dashboard/answer-cancelled-notice";
-import { AnswerProviderNotice } from "@/components/clinical-dashboard/answer-provider-notice";
+import {
+ AnswerProviderNotice,
+ AnswerSummaryUnavailableNotice,
+} from "@/components/clinical-dashboard/answer-provider-notice";
import { isPreformattedGroundedAnswer } from "@/components/clinical-dashboard/answer-content";
import {
+ AnswerBuildDetails,
AnswerProgress,
AnswerSkeleton,
SearchProgressBanner,
@@ -2988,12 +2992,16 @@ function ClinicalDashboardContent({
restoredSavedAt={restoredAnswerSavedAt}
onRecheckAnswer={handleRecheckRestoredAnswer}
/>
+ {/* Under the answer, not above the question (owner decision, 17 Oct 2026). */}
+ {loading ? null : }
{/* Once per page, under the latest answer: where a question goes (audit S3). */}
{explicitDemoMode ||
answer.demoMode === true ||
answer.fallbackMode === "non_production_demo" ? null : (
-
+
)}
+ {/* Directly under it: the model could not write this answer (owner decision, 17 Oct 2026). */}
+
>
) : null
) : null}
diff --git a/src/components/clinical-dashboard/answer-provider-notice.tsx b/src/components/clinical-dashboard/answer-provider-notice.tsx
index 6e0c273f0b..91cc7783d9 100644
--- a/src/components/clinical-dashboard/answer-provider-notice.tsx
+++ b/src/components/clinical-dashboard/answer-provider-notice.tsx
@@ -1,10 +1,18 @@
-import { Globe } from "lucide-react";
+import { Globe, TriangleAlert } from "lucide-react";
import { cn, textMuted } from "@/components/ui-primitives";
+import { answerUsesDegradedMode, type AnswerStateInput } from "@/components/ui/answer-state";
+import { isProviderGenerationFallbackCode, publicFallbackReason } from "@/lib/rag/rag-fallback-reason";
-/** The overseas-processing line under an answer (audit S3, 2026-10-13). Draft wording: owner approval pending. */
-export const ANSWER_PROVIDER_NOTICE_TEXT =
- "Questions are sent to an overseas AI service (OpenAI) to search and write answers. Don't include patient details.";
+/**
+ * The overseas-processing line under an answer (audit S3, 2026-10-13). Shortened to one phone line by the
+ * owner, 17 Oct 2026 (docs/decisions/2026-10-17-answer-page-tidy-up.md). It still names the service, says
+ * it is overseas and asks for no patient details.
+ */
+export const ANSWER_PROVIDER_NOTICE_TEXT = "Sent to OpenAI overseas. Don't include patient details.";
+
+/** The visible words when the model could not write the answer and the source text is shown instead. */
+export const ANSWER_SUMMARY_UNAVAILABLE_TEXT = "AI summary unavailable";
/**
* One quiet line under the latest answer, and on the Answer home before the first question is sent
@@ -36,3 +44,49 @@ export function AnswerProviderNotice({
);
}
+
+/**
+ * The public reason when this answer is degraded only because the model could not write it, else null.
+ * One test for both places, so the sentence is never shown above the answer and below it at once.
+ */
+export function answerSummaryUnavailableReason(answer: AnswerStateInput): string | null {
+ const code = answer.fallbackReasonCode;
+ if (!answerUsesDegradedMode(answer) || !code || !isProviderGenerationFallbackCode(code)) return null;
+ return publicFallbackReason(code);
+}
+
+/**
+ * Amber line directly under the overseas notice when the model could not write the answer
+ * (owner decision, 17 Oct 2026). It replaced the grey sentence above the question, which pushed the
+ * question and the answer down. Only the provider failures land here: every other degraded reason
+ * says something about the evidence and keeps its full sentence above the answer.
+ *
+ * The visible words are short. The full public reason stays as screen-reader text, so a screen
+ * reader still hears why and that the verified source-backed portion is what is shown.
+ */
+export function AnswerSummaryUnavailableNotice({
+ answer,
+ className,
+}: {
+ answer: AnswerStateInput;
+ className?: string;
+}) {
+ const reason = answerSummaryUnavailableReason(answer);
+ if (!reason) return null;
+ return (
+
+ );
+}
diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx
index 9d26b1977c..203a3386d2 100644
--- a/src/components/clinical-dashboard/answer-result-surface.tsx
+++ b/src/components/clinical-dashboard/answer-result-surface.tsx
@@ -44,6 +44,7 @@ import { isCurrencyReviewWarning, type AnswerRenderModel } from "@/lib/answer-re
import { demoAnswerDisclosure } from "@/lib/answer-client-payload";
import { formatRestoredAnswerAge } from "@/lib/answer-thread-storage";
import { publicFallbackReason } from "@/lib/rag/rag-fallback-reason";
+import { answerSummaryUnavailableReason } from "@/components/clinical-dashboard/answer-provider-notice";
import type {
ClientBestSourceRecommendation,
ClientQuoteCard,
@@ -78,7 +79,7 @@ import { compactVerificationWordingFor, VerificationNotice } from "@/components/
* is the micro floor for count bubbles and legends.
*
* Nothing about what these state has changed: same words, same amber tokens, same icons. */
-const chipShape = "inline-flex min-h-6 items-center gap-1 rounded-full border px-2.5 text-xs font-semibold";
+const chipShape = "inline-flex min-h-6 items-center gap-1 rounded-md border px-2 text-xs font-semibold";
/**
* An interactive chip is a small pill inside a full-size button, not a small
* button. `before:-inset-y-*` hit expansion draws the same 48px region and is
@@ -390,7 +391,7 @@ function StagedAnswerResultSurfaceImpl({
>
0 || answerReviewDue || sourceOnlyAnswer ? (
<>
@@ -480,7 +487,7 @@ function StagedAnswerResultSurfaceImpl({
)}
>
- {answerLimitationsChipLabel}
+ {answerLimitationsChipText}
{/* No chevron. A chevron promises an expansion in place; this opens a sheet, and
the sheet's own arrival is the state change a reader sees. The earlier note
here — that the chip looked identical open and closed, so on a phone the only
@@ -627,7 +634,10 @@ function StagedAnswerResultSurfaceImpl({
{demoAnswerDisclosure}
) : null}
- {degradedAnswer ? (
+ {/* A provider failure says only that no model wrote this, so it moved to an amber
+ line under the overseas notice (owner decision, 17 Oct 2026). Every other reason
+ is about the evidence and keeps its sentence here, above the answer. */}
+ {degradedAnswer && !answerSummaryUnavailableReason(answer) ? (
({ ...event, displayMessage: answerProgressDisplayMessage(event) }))
+ .filter((event, index, all) => index === 0 || event.displayMessage !== all[index - 1]?.displayMessage)
+ .slice(-8);
+ return (
+
+
+
+
+
+ How this answer was built
+
+
+
+ {details.map((event, index) => (
+
+ {event.displayMessage}
+
+ ))}
+
+
+ );
+}
+
export function AnswerProgress({
events,
startedAt,
@@ -451,7 +490,6 @@ export function AnswerProgress({
const finished = latest?.stage === "complete";
const running = active && !finished;
const slow = useSlowNotice(running, startedAt);
- const unusualRoute = answerProgressTookUnusualRoute(events);
const stepIndex = latest ? answerProgressStepIndex(latest.stage) : 0;
// The only number the wait prints, and it counts the cards directly below it — which is
// why it is the rail's visible cap, not the unit's length. A unit may carry up to twelve
@@ -464,10 +502,6 @@ export function AnswerProgress({
const revealedSourceCount = useProgressiveReveal(previewSourceCount, evidencePreview);
const previewMessage = latest ? answerProgressPreviewMessage(revealedSourceCount, latest.stage) : null;
const currentMessage = previewMessage ?? (latest ? answerProgressDisplayMessage(latest) : "Reading your question…");
- const details = events
- .map((event) => ({ ...event, displayMessage: answerProgressDisplayMessage(event) }))
- .filter((event, index, all) => index === 0 || event.displayMessage !== all[index - 1]?.displayMessage)
- .slice(-8);
return (
)}
- {/* A routine answer has nothing to disclose — the retired panel offered the
- same five stages every time. These three stages mean the answer did not
- take the ordinary route, which is the case a reader may actually want to
- read back. */}
- {finished && unusualRoute ? (
-
-
- How this answer was built
-
-
- {details.map((event, index) => (
-
- {event.displayMessage}
-
- ))}
-
-
- ) : null}
+ {/* The build disclosure for a finished answer moved below the answer (owner decision,
+ 17 Oct 2026): see AnswerBuildDetails. This section stays where it is so the
+ "Answer ready." live region is the same node that announced the progress. */}
);
}
diff --git a/src/components/clinical-dashboard/cross-mode-links.tsx b/src/components/clinical-dashboard/cross-mode-links.tsx
index 6423158ad5..387b9aa1d8 100644
--- a/src/components/clinical-dashboard/cross-mode-links.tsx
+++ b/src/components/clinical-dashboard/cross-mode-links.tsx
@@ -335,14 +335,10 @@ function CrossModeLinksLine({
- Also in your library
- {/* Label, rule, count — the same editorial section-header device the
- "Also matches" tray uses, and now at every width. A preview of the
- first three record names used to take the rule's place from sm up;
- it earned that room while the line rested closed and had no other
- way to say what it held. Resting open, it was naming the cards
- immediately below it. */}
-
- {/* The visible count is decorative — it is dropped entirely below sm, and
- `hidden` hides it from assistive tech as well as from the eye. This
- tray has no live region to carry the number, so without the sr-only
- copy the count would never reach a screen reader at any width, and
- the closed control would announce as a door onto an unknown. */}
-
- {countLabel}
+ {/* Sentence case, and the count shows at every width: below sm it used to be hidden,
+ so on a phone the row never said how much it held. */}
+
+ Also in your library
+
+ · {links.length}
+ {countLabel} {
it("says where a question goes and asks for no patient details, as one quiet note", () => {
render();
const note = screen.getByRole("note");
- expect(note).toHaveTextContent(
- "Questions are sent to an overseas AI service (OpenAI) to search and write answers. Don't include patient details.",
- );
+ expect(note).toHaveTextContent("Sent to OpenAI overseas. Don't include patient details.");
expect(note).toHaveTextContent(ANSWER_PROVIDER_NOTICE_TEXT);
expect(note).toHaveAttribute("data-testid", "answer-provider-notice");
});
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index c831eba657..36547e8c9d 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -4000,7 +4000,12 @@ test.describe("PsychSift UI smoke coverage", () => {
).filter((text) => !/^Answer limitations$/i.test(text.trim()));
const gapWarnings = panelWarnings.filter((text) => !/\bdue for review\.$/.test(text.trim()));
expect(panelWarnings.length).toBeGreaterThan(0);
- await expect(gapsChip).toContainText(`${gapWarnings.length} limitation${gapWarnings.length === 1 ? "" : "s"}`);
+ // The count left the visible chip beside a prefix (owner decision, 17 Oct 2026). It is still in
+ // the accessible name, which is where it is pinned now.
+ await expect(gapsChip).toHaveAttribute(
+ "aria-label",
+ new RegExp(`${gapWarnings.length} limitation${gapWarnings.length === 1 ? "" : "s"}$`),
+ );
// The banner is inside the disclosure, not merely somewhere on the page.
await expect(gapsDetail.getByTestId("retrieval-state-stale-toggle")).toBeVisible();
await expect(reviewDueTab).toContainText("Review due");
From 8bf90c0be95d566625ed68f10d614d7a546c3523 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Oct 2026 07:50:51 +0000
Subject: [PATCH 02/11] Test: provider failure moves to the amber line under
the overseas notice
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01N75FN1tsEXMbK7qAxe5L9p
---
...answer-clipboard-product-path.dom.test.tsx | 40 ++++++++++++++++++-
1 file changed, 39 insertions(+), 1 deletion(-)
diff --git a/tests/answer-clipboard-product-path.dom.test.tsx b/tests/answer-clipboard-product-path.dom.test.tsx
index 1446923cc9..3a9e703bb2 100644
--- a/tests/answer-clipboard-product-path.dom.test.tsx
+++ b/tests/answer-clipboard-product-path.dom.test.tsx
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
+import { AnswerSummaryUnavailableNotice } from "@/components/clinical-dashboard/answer-provider-notice";
import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface";
import { buildAnswerRenderModel } from "@/lib/answer-render-policy";
import { toClientAnswerPayload } from "@/lib/answer-client-payload";
@@ -232,8 +233,45 @@ it("R3 retains restored demo disclosure on the current answer", () => {
expect(screen.getByText("Synthetic demo only: this is not clinical guidance.")).toBeInTheDocument();
});
+it("R3 moves a provider failure to the amber line under the overseas notice, full reason kept for screen readers", () => {
+ const answer = toClientAnswerPayload({
+ ...serverAnswer(),
+ answerQualityTier: "source_only",
+ routingMode: "extractive",
+ fallbackReasonCode: "provider_timeout",
+ });
+ const phrase = "Answer generation timed out; the verified source-backed portion is shown.";
+ render(
+ <>
+ {}}
+ answerGrounded
+ sources={answer.sources}
+ demoMode={false}
+ safeAnswerSections={[]}
+ safetyFindings={[]}
+ copiedAnswer={false}
+ pendingFeedback={null}
+ onCopyAnswer={() => {}}
+ onSubmitFeedback={() => {}}
+ />
+
+ >,
+ );
+ const notes = screen.getAllByTestId("current-answer-degradation");
+ expect(notes).toHaveLength(1);
+ expect(notes[0]).toHaveTextContent("AI summary unavailable");
+ expect(notes[0]).toHaveTextContent(phrase);
+});
+
it.each([
- ["provider_timeout", "source_only", "Answer generation timed out; the verified source-backed portion is shown."],
["source_conflict", "model_synthesis", "Current sources contain a material difference that requires review."],
] as const)("R3 displays the current precise %s disclosure", (fallbackReasonCode, answerQualityTier, phrase) => {
const answer = toClientAnswerPayload({
From ef1865035cf6586d9d370fb447be9d5aff1b633c Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Oct 2026 08:00:16 +0000
Subject: [PATCH 03/11] Test: an evidence reason keeps its sentence above a
source-only answer
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01N75FN1tsEXMbK7qAxe5L9p
---
tests/answer-clipboard-product-path.dom.test.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tests/answer-clipboard-product-path.dom.test.tsx b/tests/answer-clipboard-product-path.dom.test.tsx
index 3a9e703bb2..b31b98a928 100644
--- a/tests/answer-clipboard-product-path.dom.test.tsx
+++ b/tests/answer-clipboard-product-path.dom.test.tsx
@@ -272,6 +272,8 @@ it("R3 moves a provider failure to the amber line under the overseas notice, ful
});
it.each([
+ // An evidence reason keeps its full sentence above the answer, even on a source-only answer.
+ ["retrieval_degraded", "source_only", "Search used a reduced retrieval path; review the cited sources carefully."],
["source_conflict", "model_synthesis", "Current sources contain a material difference that requires review."],
] as const)("R3 displays the current precise %s disclosure", (fallbackReasonCode, answerQualityTier, phrase) => {
const answer = toClientAnswerPayload({
From 7a5221d833d35622f6980d2c138d966c361b1ee0 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Oct 2026 08:08:00 +0000
Subject: [PATCH 04/11] Render How this answer was built above the library row,
inside the surface
Addresses review: it rendered after the library row, follow-ups and drawer.
Moved to its own small file so the lazy answer chunk does not pull in answer-status.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01N75FN1tsEXMbK7qAxe5L9p
---
src/components/ClinicalDashboard.tsx | 4 +-
.../answer-build-details.tsx | 46 +++++++++++++++++++
.../answer-result-surface.tsx | 8 ++++
.../clinical-dashboard/answer-status.tsx | 44 +-----------------
4 files changed, 57 insertions(+), 45 deletions(-)
create mode 100644 src/components/clinical-dashboard/answer-build-details.tsx
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index 443a4d14d1..abd57f60ae 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -77,7 +77,6 @@ import {
} from "@/components/clinical-dashboard/answer-provider-notice";
import { isPreformattedGroundedAnswer } from "@/components/clinical-dashboard/answer-content";
import {
- AnswerBuildDetails,
AnswerProgress,
AnswerSkeleton,
SearchProgressBanner,
@@ -2991,9 +2990,8 @@ function ClinicalDashboardContent({
onScopeDocument={handleScopeDocument}
restoredSavedAt={restoredAnswerSavedAt}
onRecheckAnswer={handleRecheckRestoredAnswer}
+ buildEvents={answerProgressEvents}
/>
- {/* Under the answer, not above the question (owner decision, 17 Oct 2026). */}
- {loading ? null : }
{/* Once per page, under the latest answer: where a question goes (audit S3). */}
{explicitDemoMode ||
answer.demoMode === true ||
diff --git a/src/components/clinical-dashboard/answer-build-details.tsx b/src/components/clinical-dashboard/answer-build-details.tsx
new file mode 100644
index 0000000000..d984d952a7
--- /dev/null
+++ b/src/components/clinical-dashboard/answer-build-details.tsx
@@ -0,0 +1,46 @@
+import { ChevronDown, ListTree } from "lucide-react";
+
+import {
+ answerProgressDisplayMessage,
+ answerProgressTookUnusualRoute,
+ type TimedAnswerProgressUpdate,
+} from "@/components/clinical-dashboard/answer-progress";
+
+/**
+ * "How this answer was built", as a row under the answer's actions (owner decision, 17 Oct 2026).
+ * It used to sit above the question as a small triangle link, which put a footnote first.
+ *
+ * Still only offered when the answer did not take the ordinary route. A routine answer has
+ * nothing to disclose, and the retired panel offered the same five stages every time.
+ */
+export function AnswerBuildDetails({ events }: { events: TimedAnswerProgressUpdate[] }) {
+ if (events.at(-1)?.stage !== "complete" || !answerProgressTookUnusualRoute(events)) return null;
+ const details = events
+ .map((event) => ({ ...event, displayMessage: answerProgressDisplayMessage(event) }))
+ .filter((event, index, all) => index === 0 || event.displayMessage !== all[index - 1]?.displayMessage)
+ .slice(-8);
+ return (
+
+
+
+
+
+ How this answer was built
+
+
+
+ {details.map((event, index) => (
+
+ {event.displayMessage}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx
index 203a3386d2..c00921483f 100644
--- a/src/components/clinical-dashboard/answer-result-surface.tsx
+++ b/src/components/clinical-dashboard/answer-result-surface.tsx
@@ -45,6 +45,8 @@ import { demoAnswerDisclosure } from "@/lib/answer-client-payload";
import { formatRestoredAnswerAge } from "@/lib/answer-thread-storage";
import { publicFallbackReason } from "@/lib/rag/rag-fallback-reason";
import { answerSummaryUnavailableReason } from "@/components/clinical-dashboard/answer-provider-notice";
+import { AnswerBuildDetails } from "@/components/clinical-dashboard/answer-build-details";
+import type { TimedAnswerProgressUpdate } from "@/components/clinical-dashboard/answer-progress";
import type {
ClientBestSourceRecommendation,
ClientQuoteCard,
@@ -128,6 +130,7 @@ function StagedAnswerResultSurfaceImpl({
restoredSavedAt = null,
restoredNow,
onRecheckAnswer,
+ buildEvents,
}: {
answer: ClientRagAnswerPayload & { interactionId?: string };
query: string;
@@ -161,6 +164,8 @@ function StagedAnswerResultSurfaceImpl({
restoredSavedAt?: number | null;
/** Optional clock override for deterministic age formatting in tests. */
restoredNow?: number;
+ /** Progress events for "How this answer was built", shown above the library row once generation ends. */
+ buildEvents?: TimedAnswerProgressUpdate[];
/** Re-runs the current answer query against the live source library. */
onRecheckAnswer?: () => void;
}) {
@@ -744,6 +749,9 @@ function StagedAnswerResultSurfaceImpl({
Differentials records behind it. Removing chrome a picture omits
is one thing; removing a navigation route on the same evidence is
another. Still collapsed, still below the caution. */}
+ {/* Under the actions, above the library row (owner decision, 17 Oct 2026). */}
+ {buildEvents && !generating ? : null}
+
{crossModeQueries?.length && onCrossModeSearch ? (
// `universalMode` is what lets this line reach DSM, Formulation,
// Specifiers, Therapy, Dictionary and Tools, which no catalogue in
diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx
index 0cf51fbcbc..fd1c0f8322 100644
--- a/src/components/clinical-dashboard/answer-status.tsx
+++ b/src/components/clinical-dashboard/answer-status.tsx
@@ -1,13 +1,12 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
-import { Check, ChevronDown, ChevronRight, History, ListTree, Square } from "lucide-react";
+import { Check, ChevronRight, History, Square } from "lucide-react";
import {
answerProgressDisplayMessage,
answerProgressPreviewMessage,
answerProgressStepIndex,
- answerProgressTookUnusualRoute,
type TimedAnswerProgressUpdate,
} from "@/components/clinical-dashboard/answer-progress";
import {
@@ -432,45 +431,6 @@ export function SearchProgressBanner({ message, onStop }: { message: string; onS
* rolled back, the line and the prose placeholder are the whole wait. Nothing
* here invents a source to fill the space.
*/
-/**
- * "How this answer was built", as a row under the answer's actions (owner decision, 17 Oct 2026).
- * It used to sit above the question as a small triangle link, which put a footnote first.
- *
- * Still only offered when the answer did not take the ordinary route. A routine answer has
- * nothing to disclose, and the retired panel offered the same five stages every time.
- */
-export function AnswerBuildDetails({ events }: { events: TimedAnswerProgressUpdate[] }) {
- if (events.at(-1)?.stage !== "complete" || !answerProgressTookUnusualRoute(events)) return null;
- const details = events
- .map((event) => ({ ...event, displayMessage: answerProgressDisplayMessage(event) }))
- .filter((event, index, all) => index === 0 || event.displayMessage !== all[index - 1]?.displayMessage)
- .slice(-8);
- return (
-
-
-
-
-
- How this answer was built
-
-
-
- {details.map((event, index) => (
-
- {event.displayMessage}
-
- ))}
-
-
- );
-}
-
export function AnswerProgress({
events,
startedAt,
@@ -597,7 +557,7 @@ export function AnswerProgress({
)}
{/* The build disclosure for a finished answer moved below the answer (owner decision,
- 17 Oct 2026): see AnswerBuildDetails. This section stays where it is so the
+ 17 Oct 2026): see answer-build-details.tsx. This section stays where it is so the
"Answer ready." live region is the same node that announced the progress. */}
);
From d3935ffc4d82f067616e8af14aef7fcc00f1ff25 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Oct 2026 08:09:32 +0000
Subject: [PATCH 05/11] Decision record: one Source anchor in the expected
format
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01N75FN1tsEXMbK7qAxe5L9p
---
docs/decisions/2026-10-17-answer-page-tidy-up.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/decisions/2026-10-17-answer-page-tidy-up.md b/docs/decisions/2026-10-17-answer-page-tidy-up.md
index 0e4f85f25b..f7a4b1ea4e 100644
--- a/docs/decisions/2026-10-17-answer-page-tidy-up.md
+++ b/docs/decisions/2026-10-17-answer-page-tidy-up.md
@@ -1,7 +1,7 @@
# Decision: Answer page tidy-up and a shorter overseas notice
- **Status:** decided 2026-10-17
-- **Source:** `src/components/clinical-dashboard/answer-provider-notice.tsx`, find "ANSWER_PROVIDER_NOTICE_TEXT" and "AnswerSummaryUnavailableNotice"
+- **Source:** `src/components/clinical-dashboard/answer-provider-notice.tsx`, find "AnswerSummaryUnavailableNotice"
The owner approved a mockup of the Answer page and asked for it to be built. The overseas notice under an answer now reads "Sent to OpenAI overseas. Don't include patient details." and is centred. When the model could not write the answer, an amber "AI summary unavailable" line sits directly under that notice instead of a sentence above the question. Other degraded reasons, which say something about the evidence, keep their sentence above the answer. The limitations chip drops its count when it already says "Source-only" or "Review due", and the support and limitations chips share one square shape and size. "How this answer was built" moved under the answer's actions, and "Also in your library" became a plain row with its count shown at every width.
From d4fcad7d86ede4d1e77490f129cdecb249245b1b Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Oct 2026 08:09:47 +0000
Subject: [PATCH 06/11] Regenerate repo-awareness snapshot
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01N75FN1tsEXMbK7qAxe5L9p
---
data/repo-awareness-snapshot.json | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json
index 27376a0901..c62a873461 100644
--- a/data/repo-awareness-snapshot.json
+++ b/data/repo-awareness-snapshot.json
@@ -3609,6 +3609,11 @@
"section": "rag-improvement",
"catalogued": true
},
+ {
+ "path": "docs/decisions/2026-10-17-answer-page-tidy-up.md",
+ "section": "decisions",
+ "catalogued": true
+ },
{
"path": "docs/superpowers/specs/2026-08-20-care-plan-design.md",
"section": "superpowers",
@@ -9169,6 +9174,14 @@
"outcome": "MERGE-READY for product scope once exact-head required checks finish after final main sync. Fixed: Bugbot pathname searchMode (Compiler-safe previous-state sync); docs index; PR_POLICY_BODY/governance; maintainability extract; prescribing Safety filters (no fields=index); Sheet teardown document guard. Unresolved threads: none. Residual: human approving review; title still stale (gh/ManagePullRequest cannot edit); main may move again.",
"checks": "Hosted on prior tip 973d5d88: PR policy/Static/Unit/Build/Production UI/PR required all SUCCESS. Final sync pushed; awaiting exact-head recheck. No OpenAI/live Supabase writes."
},
+ {
+ "date": "2026-10-17",
+ "ref": "claude/rag-ingestion-hardening",
+ "head": "2a9d44e0015358e4e7d197e4cf15f83df0a9ce35",
+ "scope": "RAG retrieval/summary/cache generation isolation and ingestion worker/edge hardening",
+ "outcome": "resolved",
+ "checks": "typecheck:internal, eslint, check:diff-integrity, check:migration-immutability, check:owner-scope, check:rag:fixtures, eval:rag:offline (26 files/979 tests), eval:rag:adversarial:offline (25 tests), vitest (8 suites/254 tests)"
+ },
{
"date": "2026-07-28",
"ref": "PR #1297 / `motion-audit-fixes-clean`",
@@ -21281,6 +21294,14 @@
"outcome": "Already MERGED to main before code changes needed; tip had correct sidebar absence assertion; 0 unresolved threads at close.",
"checks": "Hosted PR required + Production UI SUCCESS on merged tip. No provider-backed checks."
},
+ {
+ "date": "2026-10-17",
+ "ref": "claude/rag-ingestion-hardening",
+ "head": "b8278363b9e55ca218133ac52f6191a2edf60502",
+ "scope": "RAG and Ingestion review follow-ups",
+ "outcome": "Resolved PR review findings, repaired bot-applied regressions, and restored rag.ts maintainability budget",
+ "checks": "npm run typecheck:internal; node scripts/check-maintainability-budgets.mjs; npx eslint; npx vitest run (11 suites)"
+ },
{
"date": "2026-08-10",
"ref": "cursor/same-mode-focus-no-steal-6df8",
From a80c3333db4f49767c7c1f3f45382dee9ea0bb78 Mon Sep 17 00:00:00 2001
From: "qodo-code-review[bot]"
<151058649+qodo-code-review[bot]@users.noreply.github.com>
Date: Sat, 17 Oct 2026 08:44:33 +0000
Subject: [PATCH 07/11] =?UTF-8?q?fix:=202=20findings=20=E2=80=94=20Disting?=
=?UTF-8?q?uish=20retrieval=20and=20generation=20timeouts;=20Associa?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Distinguish retrieval and generation timeouts
- Associate build history with accepted answers
---
src/components/ClinicalDashboard.tsx | 25 +++++++++++++++----
.../answer-provider-notice.tsx | 15 +++++++++++
.../answer-result-surface.tsx | 7 ++++--
3 files changed, 40 insertions(+), 7 deletions(-)
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index abd57f60ae..c4f8f3409e 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -430,6 +430,8 @@ function ClinicalDashboardContent({
const [loading, setLoading] = useState(false);
const [answerProgress, setAnswerProgress] = useState(null);
const [answerProgressEvents, setAnswerProgressEvents] = useState([]);
+ const [answerBuildEvents, setAnswerBuildEvents] = useState([]);
+ const answerProgressEventsRef = useRef([]);
const [answerProgressStartedAt, setAnswerProgressStartedAt] = useState(null);
const [answerEvidencePreview, setAnswerEvidencePreview] = useState(null);
const [answerLifecycle, dispatchAnswerLifecycle] = useReducer(answerLifecycleReducer, initialAnswerLifecycle);
@@ -445,6 +447,7 @@ function ClinicalDashboardContent({
setSourceGovernanceWarnings([]);
setError(null);
setAnswerProgress(null);
+ setAnswerBuildEvents([]);
setAnswerEvidencePreview(null);
setDifferentialEvidenceQuery(null);
}, [
@@ -1180,6 +1183,7 @@ function ClinicalDashboardContent({
setLoading(false);
setAnswerProgress(null);
setAnswerProgressEvents([]);
+ answerProgressEventsRef.current = [];
setAnswerProgressStartedAt(null);
setAnswerEvidencePreview(null);
dispatchAnswerLifecycle({ type: "cancel" });
@@ -1366,9 +1370,12 @@ function ClinicalDashboardContent({
latest.australianSourceCount === progress.australianSourceCount &&
latest.waSourceCount === progress.waSourceCount
) {
+ answerProgressEventsRef.current = current;
return current;
}
- return [...current, { ...progress, receivedAt: Date.now() }].slice(-16);
+ const next = [...current, { ...progress, receivedAt: Date.now() }].slice(-16);
+ answerProgressEventsRef.current = next;
+ return next;
});
};
const onAnswerEvidencePreview = (preview: VerifiedEvidencePreviewUnit | null) => {
@@ -1398,13 +1405,15 @@ function ClinicalDashboardContent({
if (isAnswerRequest) {
const startedAt = Date.now();
setAnswerProgressStartedAt(startedAt);
- setAnswerProgressEvents([
+ const initialProgressEvents = [
{
- stage: "scoping",
+ stage: "scoping" as const,
message: "Preparing the clinical search scope.",
receivedAt: startedAt,
},
- ]);
+ ];
+ answerProgressEventsRef.current = initialProgressEvents;
+ setAnswerProgressEvents(initialProgressEvents);
setAnswerProgress("Preparing the clinical search scope.");
} else {
setAnswerProgressStartedAt(null);
@@ -1532,6 +1541,12 @@ function ClinicalDashboardContent({
}),
);
}
+ if (successfulPayload.kind === "answer") {
+ // Keep the history that produced the accepted answer. A later failed
+ // request may replace the active progress events without replacing
+ // the answer shown on screen.
+ setAnswerBuildEvents(answerProgressEventsRef.current);
+ }
applySearchResult(successfulPayload, trimmedQuery, !replaceExistingAnswer);
if (isDifferentialsMode) setDifferentialEvidenceQuery(trimmedQuery);
if (successfulPayload.kind === "answer") {
@@ -2990,7 +3005,7 @@ function ClinicalDashboardContent({
onScopeDocument={handleScopeDocument}
restoredSavedAt={restoredAnswerSavedAt}
onRecheckAnswer={handleRecheckRestoredAnswer}
- buildEvents={answerProgressEvents}
+ buildEvents={answerBuildEvents}
/>
{/* Once per page, under the latest answer: where a question goes (audit S3). */}
{explicitDemoMode ||
diff --git a/src/components/clinical-dashboard/answer-provider-notice.tsx b/src/components/clinical-dashboard/answer-provider-notice.tsx
index 91cc7783d9..dd0223df32 100644
--- a/src/components/clinical-dashboard/answer-provider-notice.tsx
+++ b/src/components/clinical-dashboard/answer-provider-notice.tsx
@@ -52,9 +52,24 @@ export function AnswerProviderNotice({
export function answerSummaryUnavailableReason(answer: AnswerStateInput): string | null {
const code = answer.fallbackReasonCode;
if (!answerUsesDegradedMode(answer) || !code || !isProviderGenerationFallbackCode(code)) return null;
+ // Retrieval deadlines use the same provider timeout code, but do not establish
+ // that any source-backed answer exists. Only move a timeout below the answer
+ // when the route explicitly records a generation fallback.
+ if (
+ code === "provider_timeout" &&
+ !/generation_fallback|generation_failed/i.test(`${answer.fallbackReason ?? ""} ${answer.routingReason ?? ""}`)
+ ) {
+ return null;
+ }
return publicFallbackReason(code);
}
+export function answerRetrievalDeadlineReason(answer: AnswerStateInput): string | null {
+ return answer.routingReason === "retrieval_deadline_exceeded"
+ ? (answer.degradedMode?.reason ?? "The search reached its time limit before source evidence could be confirmed.")
+ : null;
+}
+
/**
* Amber line directly under the overseas notice when the model could not write the answer
* (owner decision, 17 Oct 2026). It replaced the grey sentence above the question, which pushed the
diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx
index c00921483f..3aab03911d 100644
--- a/src/components/clinical-dashboard/answer-result-surface.tsx
+++ b/src/components/clinical-dashboard/answer-result-surface.tsx
@@ -44,7 +44,10 @@ import { isCurrencyReviewWarning, type AnswerRenderModel } from "@/lib/answer-re
import { demoAnswerDisclosure } from "@/lib/answer-client-payload";
import { formatRestoredAnswerAge } from "@/lib/answer-thread-storage";
import { publicFallbackReason } from "@/lib/rag/rag-fallback-reason";
-import { answerSummaryUnavailableReason } from "@/components/clinical-dashboard/answer-provider-notice";
+import {
+ answerRetrievalDeadlineReason,
+ answerSummaryUnavailableReason,
+} from "@/components/clinical-dashboard/answer-provider-notice";
import { AnswerBuildDetails } from "@/components/clinical-dashboard/answer-build-details";
import type { TimedAnswerProgressUpdate } from "@/components/clinical-dashboard/answer-progress";
import type {
@@ -648,7 +651,7 @@ function StagedAnswerResultSurfaceImpl({
data-testid="current-answer-degradation"
className="text-sm text-[color:var(--text-muted)]"
>
- {publicFallbackReason(answer.fallbackReasonCode ?? "unknown")}
+ {answerRetrievalDeadlineReason(answer) ?? publicFallbackReason(answer.fallbackReasonCode ?? "unknown")}
) : null}
{typeof restoredSavedAt === "number" && Number.isFinite(restoredSavedAt) ? (
From fa458cfce9ba3ca6aa3b82a3ae9cef90d4e64bd9 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Oct 2026 08:45:38 +0000
Subject: [PATCH 08/11] Keep the retrieval deadline warning above the answer
A retrieval deadline reports provider_timeout but confirms no evidence, so
it must not move to the amber 'AI summary unavailable' line, whose screen
reader text claims a verified source-backed portion. The line now needs
at least one source. Adds a regression test built from the real deadline
answer.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01N75FN1tsEXMbK7qAxe5L9p
---
.../answer-provider-notice.tsx | 4 ++
...answer-clipboard-product-path.dom.test.tsx | 37 +++++++++++++++++++
2 files changed, 41 insertions(+)
diff --git a/src/components/clinical-dashboard/answer-provider-notice.tsx b/src/components/clinical-dashboard/answer-provider-notice.tsx
index 91cc7783d9..b320c297b2 100644
--- a/src/components/clinical-dashboard/answer-provider-notice.tsx
+++ b/src/components/clinical-dashboard/answer-provider-notice.tsx
@@ -52,6 +52,10 @@ export function AnswerProviderNotice({
export function answerSummaryUnavailableReason(answer: AnswerStateInput): string | null {
const code = answer.fallbackReasonCode;
if (!answerUsesDegradedMode(answer) || !code || !isProviderGenerationFallbackCode(code)) return null;
+ // A retrieval deadline also reports provider_timeout, but no evidence was confirmed. The client
+ // payload drops routingReason, so the empty source list is the guard that holds in the browser:
+ // with nothing confirmed, the warning stays above the answer.
+ if (answer.routingReason === "retrieval_deadline_exceeded" || !answer.sources?.length) return null;
return publicFallbackReason(code);
}
diff --git a/tests/answer-clipboard-product-path.dom.test.tsx b/tests/answer-clipboard-product-path.dom.test.tsx
index b31b98a928..a1f645d943 100644
--- a/tests/answer-clipboard-product-path.dom.test.tsx
+++ b/tests/answer-clipboard-product-path.dom.test.tsx
@@ -7,6 +7,7 @@ import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answe
import { buildAnswerRenderModel } from "@/lib/answer-render-policy";
import { toClientAnswerPayload } from "@/lib/answer-client-payload";
import { PriorAnswerTurnSurface, type AnswerTurn } from "@/components/clinical-dashboard/answer-thread-turn";
+import { retrievalDeadlineExceededAnswer } from "@/lib/rag/rag-route-budget";
import type { RagAnswer, SearchResult } from "@/lib/types";
vi.mock("next/navigation", () => ({
@@ -271,6 +272,42 @@ it("R3 moves a provider failure to the amber line under the overseas notice, ful
expect(notes[0]).toHaveTextContent(phrase);
});
+it("R3 keeps a retrieval deadline warning above the answer, with no amber summary line", () => {
+ // The deadline answer reports provider_timeout but confirmed no evidence, so it must not
+ // claim a verified source-backed portion below the answer.
+ const answer = toClientAnswerPayload(
+ retrievalDeadlineExceededAnswer({ searchStartedAt: Date.now(), startedAt: Date.now(), budgetMs: 1000 }),
+ );
+ render(
+ <>
+ {}}
+ answerGrounded={false}
+ sources={answer.sources}
+ demoMode={false}
+ safeAnswerSections={[]}
+ safetyFindings={[]}
+ copiedAnswer={false}
+ pendingFeedback={null}
+ onCopyAnswer={() => {}}
+ onSubmitFeedback={() => {}}
+ />
+
+ >,
+ );
+ expect(screen.queryByText("AI summary unavailable")).not.toBeInTheDocument();
+ const notes = screen.getAllByTestId("current-answer-degradation");
+ expect(notes).toHaveLength(1);
+ expect(notes[0]).not.toHaveTextContent("AI summary unavailable");
+});
+
it.each([
// An evidence reason keeps its full sentence above the answer, even on a source-only answer.
["retrieval_degraded", "source_only", "Search used a reduced retrieval path; review the cited sources carefully."],
From 2bcef8acda3fa8ff016887e891c13849e4af1797 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Oct 2026 09:02:32 +0000
Subject: [PATCH 09/11] Capture answer build history before the final payload
lands
onAnswerProgress now updates the progress ref synchronously instead of
inside a lazy state updater. The stream sends the 'complete' event just
before returning the final payload, so the updater had not run when the
accepted answer copied the ref. The build disclosure then never appeared,
which failed the answer-progress browser test. Also clears the ref on
non-answer searches.
Updates the review-due ui-smoke check: the limitation count is now in
the chip's accessible label, not its visible text.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01N75FN1tsEXMbK7qAxe5L9p
---
src/components/ClinicalDashboard.tsx | 35 ++++++++++++++--------------
tests/ui-smoke.spec.ts | 9 +++----
2 files changed, 23 insertions(+), 21 deletions(-)
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index c4f8f3409e..c29411b677 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -1360,23 +1360,23 @@ function ClinicalDashboardContent({
const onAnswerProgress = (progress: AnswerProgressUpdate) => {
if (!requestIsCurrent()) return;
setAnswerProgress(progress.message);
- setAnswerProgressEvents((current) => {
- const latest = current.at(-1);
- if (
- latest?.stage === progress.stage &&
- latest.message === progress.message &&
- latest.resultCount === progress.resultCount &&
- latest.selectedContextCount === progress.selectedContextCount &&
- latest.australianSourceCount === progress.australianSourceCount &&
- latest.waSourceCount === progress.waSourceCount
- ) {
- answerProgressEventsRef.current = current;
- return current;
- }
- const next = [...current, { ...progress, receivedAt: Date.now() }].slice(-16);
- answerProgressEventsRef.current = next;
- return next;
- });
+ // Work from the ref, not a lazy updater: the stream sends "complete" just before it returns
+ // the final payload, and the accepted answer copies this ref before React renders again.
+ const current = answerProgressEventsRef.current;
+ const latest = current.at(-1);
+ if (
+ latest?.stage === progress.stage &&
+ latest.message === progress.message &&
+ latest.resultCount === progress.resultCount &&
+ latest.selectedContextCount === progress.selectedContextCount &&
+ latest.australianSourceCount === progress.australianSourceCount &&
+ latest.waSourceCount === progress.waSourceCount
+ ) {
+ return;
+ }
+ const next = [...current, { ...progress, receivedAt: Date.now() }].slice(-16);
+ answerProgressEventsRef.current = next;
+ setAnswerProgressEvents(next);
};
const onAnswerEvidencePreview = (preview: VerifiedEvidencePreviewUnit | null) => {
if (!requestIsCurrent()) return;
@@ -1417,6 +1417,7 @@ function ClinicalDashboardContent({
setAnswerProgress("Preparing the clinical search scope.");
} else {
setAnswerProgressStartedAt(null);
+ answerProgressEventsRef.current = [];
setAnswerProgressEvents([]);
onProgress(modeSearch.progressLabel);
}
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index 36547e8c9d..d02d02dff1 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -3954,13 +3954,14 @@ test.describe("PsychSift UI smoke coverage", () => {
const gapsChip = page.getByTestId("answer-limitations-trigger");
await expect(gapsChip).toBeVisible({ timeout: uiAssertionTimeoutMs });
- // All three parts, not one. This chip is the only thing on the default view
+ // Both prefixes stay visible. This chip is the only thing on the default view
// that says no model wrote the answer AND that a cited source is overdue —
- // `VerificationNotice` is print-only here — so a limitation count must never
- // displace either prefix.
+ // `VerificationNotice` is print-only here. The limitation count left the visible
+ // text (owner decision, 17 Oct 2026) and is still read out from the label.
await expect(gapsChip).toContainText("Source-only");
await expect(gapsChip).toContainText("Review due");
- await expect(gapsChip).toContainText(/\d+ limitations?/);
+ await expect(gapsChip).not.toContainText(/\d+ limitations?/);
+ await expect(gapsChip).toHaveAttribute("aria-label", /Source-only · Review due · \d+ limitations?/);
await expect(gapsChip).toHaveAttribute("aria-haspopup", "dialog");
const gapsDetail = page.locator("#answer-limitations-detail");
await expect(gapsDetail).toHaveCount(0);
From c1f00bbe289ec1762f3a7709050f24310b68853d Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Oct 2026 09:05:06 +0000
Subject: [PATCH 10/11] Don't promise verified passages when no source came
back
A provider fallback code's public wording says a verified source-backed
portion is shown. With no sources (a retrieval deadline, for one) that is
false, so the note above the answer now says source evidence could not be
confirmed. The regression test asserts the new wording.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01N75FN1tsEXMbK7qAxe5L9p
---
.../clinical-dashboard/answer-provider-notice.tsx | 13 +++++++++++++
.../clinical-dashboard/answer-result-surface.tsx | 8 +++++---
tests/answer-clipboard-product-path.dom.test.tsx | 2 ++
3 files changed, 20 insertions(+), 3 deletions(-)
diff --git a/src/components/clinical-dashboard/answer-provider-notice.tsx b/src/components/clinical-dashboard/answer-provider-notice.tsx
index b320c297b2..cda8a8c5ed 100644
--- a/src/components/clinical-dashboard/answer-provider-notice.tsx
+++ b/src/components/clinical-dashboard/answer-provider-notice.tsx
@@ -59,6 +59,19 @@ export function answerSummaryUnavailableReason(answer: AnswerStateInput): string
return publicFallbackReason(code);
}
+export const ANSWER_NO_CONFIRMED_SOURCES_TEXT =
+ "Source evidence could not be confirmed, so no verified passages are shown. Please try again.";
+
+/**
+ * The sentence above a degraded answer. A provider code's own wording promises a verified
+ * source-backed portion, which is false when no source came back (a retrieval deadline, for one).
+ */
+export function answerDegradationNoteText(answer: AnswerStateInput): string {
+ const code = answer.fallbackReasonCode ?? "unknown";
+ if (isProviderGenerationFallbackCode(code) && !answer.sources?.length) return ANSWER_NO_CONFIRMED_SOURCES_TEXT;
+ return publicFallbackReason(code);
+}
+
/**
* Amber line directly under the overseas notice when the model could not write the answer
* (owner decision, 17 Oct 2026). It replaced the grey sentence above the question, which pushed the
diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx
index c00921483f..78f189b877 100644
--- a/src/components/clinical-dashboard/answer-result-surface.tsx
+++ b/src/components/clinical-dashboard/answer-result-surface.tsx
@@ -43,8 +43,10 @@ import { answerSurface, cn } from "@/components/ui-primitives";
import { isCurrencyReviewWarning, type AnswerRenderModel } from "@/lib/answer-render-policy";
import { demoAnswerDisclosure } from "@/lib/answer-client-payload";
import { formatRestoredAnswerAge } from "@/lib/answer-thread-storage";
-import { publicFallbackReason } from "@/lib/rag/rag-fallback-reason";
-import { answerSummaryUnavailableReason } from "@/components/clinical-dashboard/answer-provider-notice";
+import {
+ answerDegradationNoteText,
+ answerSummaryUnavailableReason,
+} from "@/components/clinical-dashboard/answer-provider-notice";
import { AnswerBuildDetails } from "@/components/clinical-dashboard/answer-build-details";
import type { TimedAnswerProgressUpdate } from "@/components/clinical-dashboard/answer-progress";
import type {
@@ -648,7 +650,7 @@ function StagedAnswerResultSurfaceImpl({
data-testid="current-answer-degradation"
className="text-sm text-[color:var(--text-muted)]"
>
- {publicFallbackReason(answer.fallbackReasonCode ?? "unknown")}
+ {answerDegradationNoteText(answer)}
) : null}
{typeof restoredSavedAt === "number" && Number.isFinite(restoredSavedAt) ? (
diff --git a/tests/answer-clipboard-product-path.dom.test.tsx b/tests/answer-clipboard-product-path.dom.test.tsx
index a1f645d943..6c10ab1c30 100644
--- a/tests/answer-clipboard-product-path.dom.test.tsx
+++ b/tests/answer-clipboard-product-path.dom.test.tsx
@@ -306,6 +306,8 @@ it("R3 keeps a retrieval deadline warning above the answer, with no amber summar
const notes = screen.getAllByTestId("current-answer-degradation");
expect(notes).toHaveLength(1);
expect(notes[0]).not.toHaveTextContent("AI summary unavailable");
+ expect(notes[0]).not.toHaveTextContent("source-backed portion");
+ expect(notes[0]).toHaveTextContent("Source evidence could not be confirmed");
});
it.each([
From 5b256cfee9e80637f128cbbab5fa64bc060bc8e2 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Oct 2026 09:17:07 +0000
Subject: [PATCH 11/11] Keep the home overseas notice in the future tense
On the Answer home nothing has been sent yet, so 'Sent to OpenAI overseas'
was untrue there. The home placement now reads 'Questions go to OpenAI
overseas. Don't include patient details.' The line under an answer keeps
the owner-approved wording. Decision record updated.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01N75FN1tsEXMbK7qAxe5L9p
---
docs/decisions/2026-10-17-answer-page-tidy-up.md | 2 +-
src/components/ClinicalDashboard.tsx | 7 ++++++-
.../clinical-dashboard/answer-provider-notice.tsx | 7 ++++++-
tests/answer-provider-notice.dom.test.tsx | 9 +++++++--
4 files changed, 20 insertions(+), 5 deletions(-)
diff --git a/docs/decisions/2026-10-17-answer-page-tidy-up.md b/docs/decisions/2026-10-17-answer-page-tidy-up.md
index f7a4b1ea4e..a75e2ac1e3 100644
--- a/docs/decisions/2026-10-17-answer-page-tidy-up.md
+++ b/docs/decisions/2026-10-17-answer-page-tidy-up.md
@@ -3,6 +3,6 @@
- **Status:** decided 2026-10-17
- **Source:** `src/components/clinical-dashboard/answer-provider-notice.tsx`, find "AnswerSummaryUnavailableNotice"
-The owner approved a mockup of the Answer page and asked for it to be built. The overseas notice under an answer now reads "Sent to OpenAI overseas. Don't include patient details." and is centred. When the model could not write the answer, an amber "AI summary unavailable" line sits directly under that notice instead of a sentence above the question. Other degraded reasons, which say something about the evidence, keep their sentence above the answer. The limitations chip drops its count when it already says "Source-only" or "Review due", and the support and limitations chips share one square shape and size. "How this answer was built" moved under the answer's actions, and "Also in your library" became a plain row with its count shown at every width.
+The owner approved a mockup of the Answer page and asked for it to be built. The overseas notice under an answer now reads "Sent to OpenAI overseas. Don't include patient details." and is centred. On the Answer home, before anything is sent, the same line reads "Questions go to OpenAI overseas. Don't include patient details." so it never claims a question was sent. When the model could not write the answer, an amber "AI summary unavailable" line sits directly under that notice instead of a sentence above the question. Other degraded reasons, which say something about the evidence, keep their sentence above the answer. The limitations chip drops its count when it already says "Source-only" or "Review due", and the support and limitations chips share one square shape and size. "How this answer was built" moved under the answer's actions, and "Also in your library" became a plain row with its count shown at every width.
If this summary and the source ever differ, the source wins.
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index c29411b677..8ddfa309fe 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -72,6 +72,7 @@ import { resolveModeHomeCanvasClass } from "@/components/clinical-dashboard/mode
import { sanitizeAnswerDisplayText } from "@/components/clinical-dashboard/display-text";
import { AnswerCancelledNotice } from "@/components/clinical-dashboard/answer-cancelled-notice";
import {
+ ANSWER_PROVIDER_NOTICE_HOME_TEXT,
AnswerProviderNotice,
AnswerSummaryUnavailableNotice,
} from "@/components/clinical-dashboard/answer-provider-notice";
@@ -2841,7 +2842,11 @@ function ClinicalDashboardContent({
{/* Before the first question is sent (owner decision, 13 Oct 2026): where an
Answer question goes. Below the home content, so the composer never moves. */}
{searchMode === "answer" && !clientDemoMode ? (
-
+
) : null}
>
) : activeModeResultKind === "differentials" ? (
diff --git a/src/components/clinical-dashboard/answer-provider-notice.tsx b/src/components/clinical-dashboard/answer-provider-notice.tsx
index cda8a8c5ed..aa44791570 100644
--- a/src/components/clinical-dashboard/answer-provider-notice.tsx
+++ b/src/components/clinical-dashboard/answer-provider-notice.tsx
@@ -11,6 +11,9 @@ import { isProviderGenerationFallbackCode, publicFallbackReason } from "@/lib/ra
*/
export const ANSWER_PROVIDER_NOTICE_TEXT = "Sent to OpenAI overseas. Don't include patient details.";
+/** The same line on the Answer home, where nothing has been sent yet, so it must not say "Sent". */
+export const ANSWER_PROVIDER_NOTICE_HOME_TEXT = "Questions go to OpenAI overseas. Don't include patient details.";
+
/** The visible words when the model could not write the answer and the source text is shown instead. */
export const ANSWER_SUMMARY_UNAVAILABLE_TEXT = "AI summary unavailable";
@@ -29,9 +32,11 @@ export const ANSWER_SUMMARY_UNAVAILABLE_TEXT = "AI summary unavailable";
export function AnswerProviderNotice({
className,
testId = "answer-provider-notice",
+ text = ANSWER_PROVIDER_NOTICE_TEXT,
}: {
className?: string;
testId?: string;
+ text?: string;
}) {
return (
- {ANSWER_PROVIDER_NOTICE_TEXT}
+ {text}
);
}
diff --git a/tests/answer-provider-notice.dom.test.tsx b/tests/answer-provider-notice.dom.test.tsx
index bc9e8b926d..862aeb6fa1 100644
--- a/tests/answer-provider-notice.dom.test.tsx
+++ b/tests/answer-provider-notice.dom.test.tsx
@@ -5,6 +5,7 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
+ ANSWER_PROVIDER_NOTICE_HOME_TEXT,
ANSWER_PROVIDER_NOTICE_TEXT,
AnswerProviderNotice,
} from "@/components/clinical-dashboard/answer-provider-notice";
@@ -20,8 +21,12 @@ describe("AnswerProviderNotice", () => {
});
it("takes its own test id for the pre-submit copy on the home", () => {
- render();
- expect(screen.getByRole("note")).toHaveAttribute("data-testid", "answer-provider-notice-home");
+ render();
+ const note = screen.getByRole("note");
+ expect(note).toHaveAttribute("data-testid", "answer-provider-notice-home");
+ // Nothing has been sent on the home yet, so the line must not say it was.
+ expect(note).toHaveTextContent("Questions go to OpenAI overseas. Don't include patient details.");
+ expect(note).not.toHaveTextContent(/^Sent/);
});
it("is mounted under the latest answer and on the Answer home, never in demo mode", () => {