Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions data/repo-awareness-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions docs/decisions/2026-10-17-answer-page-tidy-up.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
60 changes: 41 additions & 19 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -427,6 +430,8 @@ function ClinicalDashboardContent({
const [loading, setLoading] = useState(false);
const [answerProgress, setAnswerProgress] = useState<string | null>(null);
const [answerProgressEvents, setAnswerProgressEvents] = useState<TimedAnswerProgressUpdate[]>([]);
const [answerBuildEvents, setAnswerBuildEvents] = useState<TimedAnswerProgressUpdate[]>([]);
const answerProgressEventsRef = useRef<TimedAnswerProgressUpdate[]>([]);
const [answerProgressStartedAt, setAnswerProgressStartedAt] = useState<number | null>(null);
const [answerEvidencePreview, setAnswerEvidencePreview] = useState<VerifiedEvidencePreviewUnit | null>(null);
const [answerLifecycle, dispatchAnswerLifecycle] = useReducer(answerLifecycleReducer, initialAnswerLifecycle);
Expand All @@ -442,6 +447,7 @@ function ClinicalDashboardContent({
setSourceGovernanceWarnings([]);
setError(null);
setAnswerProgress(null);
setAnswerBuildEvents([]);
setAnswerEvidencePreview(null);
setDifferentialEvidenceQuery(null);
}, [
Expand Down Expand Up @@ -1177,6 +1183,7 @@ function ClinicalDashboardContent({
setLoading(false);
setAnswerProgress(null);
setAnswerProgressEvents([]);
answerProgressEventsRef.current = [];
setAnswerProgressStartedAt(null);
setAnswerEvidencePreview(null);
dispatchAnswerLifecycle({ type: "cancel" });
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Resolved
applySearchResult(successfulPayload, trimmedQuery, !replaceExistingAnswer);
if (isDifferentialsMode) setDifferentialEvidenceQuery(trimmedQuery);
if (successfulPayload.kind === "answer") {
Expand Down Expand Up @@ -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 : (
<AnswerProviderNotice className="mt-1" />
<AnswerProviderNotice className="mt-1 justify-center text-center" />
)}
{/* Directly under it: the model could not write this answer (owner decision, 17 Oct 2026). */}
<AnswerSummaryUnavailableNotice answer={answer} />
</>
) : null
) : null}
Expand Down
46 changes: 46 additions & 0 deletions src/components/clinical-dashboard/answer-build-details.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<details data-testid="answer-build-details" className="group min-w-0 border-t border-[color:var(--border)]">
<summary className="flex min-h-12 cursor-pointer list-none items-center gap-2.5 rounded-md text-sm font-medium text-[color:var(--text-heading)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] [&::-webkit-details-marker]:hidden">
<span
aria-hidden="true"
className="grid h-7 w-7 shrink-0 place-items-center rounded-md border border-[color:var(--border)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)] forced-colors:border"
>
<ListTree className="size-icon-md" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">How this answer was built</span>
<ChevronDown
aria-hidden="true"
className="size-icon-md shrink-0 text-[color:var(--text-muted)] transition-transform group-open:rotate-180 motion-reduce:transition-none"
/>
</summary>
<ol className="mb-3 ml-3.5 space-y-1 border-l border-[color:var(--border)] pl-3 text-2xs text-[color:var(--text-muted)]">
{details.map((event, index) => (
<li key={`${event.receivedAt}-${event.stage}-${index}`} className="leading-relaxed">
{event.displayMessage}
</li>
))}
</ol>
</details>
);
}
79 changes: 75 additions & 4 deletions src/components/clinical-dashboard/answer-provider-notice.tsx
Original file line number Diff line number Diff line change
@@ -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.";
Comment thread
BigSimmo marked this conversation as resolved.
Resolved

/** 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
Expand All @@ -30,3 +38,66 @@ export function AnswerProviderNotice({ className }: { className?: string }) {
</p>
);
}

/**
* 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);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
}

/**
* 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 (
<p
role="note"
data-testid="current-answer-degradation"
className={cn(
"flex min-w-0 items-start justify-center gap-1.5 text-center text-2xs leading-4 font-medium text-[color:var(--warning)]",
className,
)}
>
<TriangleAlert aria-hidden="true" className="mt-0.5 size-icon-xs shrink-0" />
<span>
{ANSWER_SUMMARY_UNAVAILABLE_TEXT}
<span className="sr-only">. {reason}</span>
</span>
</p>
);
}
Loading
Loading