diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json
index c822dffea6..4d2e392bc6 100644
--- a/data/repo-awareness-snapshot.json
+++ b/data/repo-awareness-snapshot.json
@@ -3619,6 +3619,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",
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..d51e816dfc
--- /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 "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. The line no longer shows on the Answer home at all ([the home-page decision](2026-10-17-no-overseas-line-on-home-pages.md)), so it only appears once a question has been 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/docs/decisions/2026-10-17-no-overseas-line-on-home-pages.md b/docs/decisions/2026-10-17-no-overseas-line-on-home-pages.md
index e278c408d6..629ec74d3a 100644
--- a/docs/decisions/2026-10-17-no-overseas-line-on-home-pages.md
+++ b/docs/decisions/2026-10-17-no-overseas-line-on-home-pages.md
@@ -5,7 +5,8 @@
The owner asked for the line "Questions are sent to an overseas AI service (OpenAI) to search and write
answers. Don't include patient details." to be removed from the home pages. The Answer home was the only
-one showing it. It still shows under each answer, the composer still warns against patient-identifiable
+one showing it. It still shows under each answer, in the shorter wording of
+[the Answer page tidy-up](2026-10-17-answer-page-tidy-up.md), the composer still warns against patient-identifiable
information, and the privacy page still says questions may go to the OpenAI API outside Australia. This
replaces the 13 Oct 2026 wish to show the line on the Answer home before the first question.
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index d850e24520..64eca09ca0 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -71,7 +71,10 @@ 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 {
AnswerProgress,
@@ -427,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);
@@ -442,6 +447,7 @@ function ClinicalDashboardContent({
setSourceGovernanceWarnings([]);
setError(null);
setAnswerProgress(null);
+ setAnswerBuildEvents([]);
setAnswerEvidencePreview(null);
setDifferentialEvidenceQuery(null);
}, [
@@ -1177,6 +1183,7 @@ function ClinicalDashboardContent({
setLoading(false);
setAnswerProgress(null);
setAnswerProgressEvents([]);
+ answerProgressEventsRef.current = [];
setAnswerProgressStartedAt(null);
setAnswerEvidencePreview(null);
dispatchAnswerLifecycle({ type: "cancel" });
@@ -1353,20 +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
- ) {
- return current;
- }
- return [...current, { ...progress, receivedAt: Date.now() }].slice(-16);
- });
+ // 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;
@@ -1395,16 +1405,19 @@ 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);
+ answerProgressEventsRef.current = [];
setAnswerProgressEvents([]);
onProgress(modeSearch.progressLabel);
}
@@ -1529,6 +1542,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") {
@@ -2982,13 +3001,16 @@ function ClinicalDashboardContent({
onScopeDocument={handleScopeDocument}
restoredSavedAt={restoredAnswerSavedAt}
onRecheckAnswer={handleRecheckRestoredAnswer}
+ buildEvents={answerBuildEvents}
/>
{/* 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-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-provider-notice.tsx b/src/components/clinical-dashboard/answer-provider-notice.tsx
index 9e2de16cc1..85131c846b 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. It was also on the Answer home before the first question
@@ -30,3 +38,66 @@ export function AnswerProviderNotice({ className }: { className?: string }) {
);
}
+
+/**
+ * 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;
+ // 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);
+}
+
+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
+ * 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..78f189b877 100644
--- a/src/components/clinical-dashboard/answer-result-surface.tsx
+++ b/src/components/clinical-dashboard/answer-result-surface.tsx
@@ -43,7 +43,12 @@ 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 {
+ 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 {
ClientBestSourceRecommendation,
ClientQuoteCard,
@@ -78,7 +83,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
@@ -127,6 +132,7 @@ function StagedAnswerResultSurfaceImpl({
restoredSavedAt = null,
restoredNow,
onRecheckAnswer,
+ buildEvents,
}: {
answer: ClientRagAnswerPayload & { interactionId?: string };
query: string;
@@ -160,6 +166,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;
}) {
@@ -390,7 +398,7 @@ function StagedAnswerResultSurfaceImpl({
>
0 || answerReviewDue || sourceOnlyAnswer ? (
<>
@@ -480,7 +494,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,13 +641,16 @@ 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) ? (
) : null}
{typeof restoredSavedAt === "number" && Number.isFinite(restoredSavedAt) ? (
@@ -734,6 +751,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 206621361f..fd1c0f8322 100644
--- a/src/components/clinical-dashboard/answer-status.tsx
+++ b/src/components/clinical-dashboard/answer-status.tsx
@@ -7,7 +7,6 @@ import {
answerProgressDisplayMessage,
answerProgressPreviewMessage,
answerProgressStepIndex,
- answerProgressTookUnusualRoute,
type TimedAnswerProgressUpdate,
} from "@/components/clinical-dashboard/answer-progress";
import {
@@ -451,7 +450,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 +462,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 answer-build-details.tsx. 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} ({
@@ -232,8 +234,85 @@ 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("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");
+ expect(notes[0]).not.toHaveTextContent("source-backed portion");
+ expect(notes[0]).toHaveTextContent("Source evidence could not be confirmed");
+});
+
it.each([
- ["provider_timeout", "source_only", "Answer generation timed out; the verified source-backed portion is shown."],
+ // 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({
diff --git a/tests/answer-progress-ui-smoke.spec.ts b/tests/answer-progress-ui-smoke.spec.ts
index f513ef3dbd..52a3a9f834 100644
--- a/tests/answer-progress-ui-smoke.spec.ts
+++ b/tests/answer-progress-ui-smoke.spec.ts
@@ -542,10 +542,12 @@ test("answer progress remains user-safe through fallback and discloses the unusu
// This run went through `fallback`, so the build disclosure is offered. On an
// ordinary run it is not — pinned in the follow-up test below.
- const disclosure = progress.getByText("How this answer was built", { exact: true });
+ // It sits under the answer now, not inside the progress section (owner decision, 17 Oct 2026).
+ const buildDetails = page.getByTestId("answer-build-details");
+ const disclosure = buildDetails.getByText("How this answer was built", { exact: true });
await expect(disclosure).toBeVisible();
await disclosure.click();
- await expect(progress).toContainText("Assembling the answer from the sources directly");
+ await expect(buildDetails).toContainText("Assembling the answer from the sources directly");
await expect(page.getByText(/In the synthetic lithium document/i)).toBeVisible({ timeout: 8_000 });
await expect(page.locator("body")).not.toContainText(
@@ -569,7 +571,7 @@ test("follow-up answer generation stays one line above the previous answer", asy
const progress = page.getByTestId("answer-progress");
// The first answer took the ordinary route, so nothing is disclosed about it.
- await expect(progress.getByText("How this answer was built", { exact: true })).toHaveCount(0);
+ await expect(page.getByTestId("answer-build-details")).toHaveCount(0);
const followUpSubmit = await fillHydratedAnswerQuestion(page, "What monitoring is needed?");
await followUpSubmit.click();
diff --git a/tests/answer-provider-notice.dom.test.tsx b/tests/answer-provider-notice.dom.test.tsx
index e3bd576ef8..8494a1393f 100644
--- a/tests/answer-provider-notice.dom.test.tsx
+++ b/tests/answer-provider-notice.dom.test.tsx
@@ -14,9 +14,7 @@ describe("AnswerProviderNotice", () => {
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..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);
@@ -4000,7 +4001,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");