diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index 61f627c620..579a46350b 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v1", "captured_revision": { - "sha": "2717fc58379236dbc41f6755cf77d55c9f6d4b12", - "committed_at": "2026-08-27T22:51:37+08:00" + "sha": "15dcd00c16cfa8328a0a578f392e221459034aed", + "committed_at": "2026-08-27T16:04:44+00:00" }, "routes": { "modes": [ @@ -313,6 +313,11 @@ "file": "src/app/mockups/answer-home-proposal/page.tsx", "area": "mockup" }, + { + "path": "/mockups/answer-loading-redesign", + "file": "src/app/mockups/answer-loading-redesign/page.tsx", + "area": "mockup" + }, { "path": "/mockups/calculators-bedside-sheet", "file": "src/app/mockups/calculators-bedside-sheet/page.tsx", @@ -1367,9 +1372,9 @@ ], "counts": { "modes": 15, - "pages": 189, + "pages": 190, "product_pages": 55, - "mockup_pages": 134, + "mockup_pages": 135, "redirects": 17, "api": 58 } diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 6f1fbc40e0..2d8b6f8852 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -480,9 +480,14 @@ into the previous sentence; `RouteAnnouncer` skips the first render (arrival is navigation), moves focus to the new `

` unless focus sits inside a dialog or a `data-preserve-focus` workflow, and announces the page title once. Retiring the visible `aria-live` nodes that remain in production — `document-search-results.tsx`, `StageList`, -`AnswerProgressStepper`, `EmptyState`'s default — is adoption work in PR 13, because each +`AnswerProgress`, `EmptyState`'s default — is adoption work in PR 13, because each one needs its own surface diff. Until then two announcement mechanisms coexist. +`AnswerProgress` is the successor to `AnswerProgressStepper`, which was retired when the +answer wait was redrawn as a single quiet status line plus the arriving source rail. Its +live region moved with it and is now the status line itself (`answer-progress-line`) rather +than a wrapper, because the line is the element that persists while its text is replaced. + --- ## 6 · `DocumentFrame` diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 057795e747..faf2207a29 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -831,13 +831,15 @@ The application supports explicit user motion preference overrides in addition t ### Physical iPhone acceptance rubric -To prevent regressions of the phone/PWA answer-progress animation defect (where OS Reduce Motion froze animations and rendered the ECG trace invisible at `opacity: 0`), verify the following rubric on a physical iPhone in both Mobile Safari and the installed standalone PWA: +To prevent regressions of the phone/PWA answer-progress animation defect (where OS Reduce Motion froze animations and rendered the then-current ECG trace invisible at `opacity: 0`), verify the following rubric on a physical iPhone in both Mobile Safari and the installed standalone PWA. + +The indicator under test changed when the answer wait was redrawn as a single quiet status line: the scrolling ECG strip and the five-circle stepper are gone, and what remains is one breathing dot (`.answer-progress-dot`, `data-slot="answer-progress-dot"`) at the head of the line. The rubric is otherwise unchanged, and the dot was chosen partly because it makes step 2 trivial to satisfy — its resting frame is a complete, correct bullet, where a stopped spinner is a fragment of a circle. 1. **Motion=Full (`data-motion="full"`):** - - In physical Safari and installed standalone PWA, when the in-app Motion setting is set to **Full**, the ECG strip (`.answer-activity-trace__sweep`) visibly travels continuously and the current-step spinner rotates, even if iOS system **Reduce Motion** is enabled in Accessibility settings. + - In physical Safari and installed standalone PWA, when the in-app Motion setting is set to **Full**, the dot visibly breathes (a continuous opacity cycle, 2.4s) even if iOS system **Reduce Motion** is enabled in Accessibility settings. 2. **Motion=System / Motion=Reduced:** - - When iOS system **Reduce Motion** is enabled (or in-app Motion is set to **Reduced**), the ECG trace remains static and clearly visible at `opacity: 0.55` (`translateX(0)` aligned), rather than disappearing or rendering a blank box (`opacity: 0`). - - The current step spinner stops rotating and displays as a static marker without layout jumps. + - When iOS system **Reduce Motion** is enabled (or in-app Motion is set to **Reduced**), the dot stops breathing and remains clearly visible at full opacity, rather than disappearing or rendering a blank box (`opacity: 0`). + - The status line beside it still reads out what is happening, and the arriving source rail (when `NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER` is enabled) appears without layout jumps. The motion preference contract in `src/components/clinical-dashboard/answer-status.tsx` and the corresponding stylesheet rules in `src/app/globals.css` must remain strictly intact across all breakpoints. diff --git a/docs/site-map.md b/docs/site-map.md index 4c76e8273a..4b7604a4bc 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1139,6 +1139,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/answer-chat-redesign` - Route discovered from app directory Source: `src/app/mockups/answer-chat-redesign/page.tsx`. - `/mockups/answer-evidence-popups` - Route discovered from app directory Source: `src/app/mockups/answer-evidence-popups/page.tsx`. - `/mockups/answer-home-proposal` - Route discovered from app directory Source: `src/app/mockups/answer-home-proposal/page.tsx`. +- `/mockups/answer-loading-redesign` - Route discovered from app directory Source: `src/app/mockups/answer-loading-redesign/page.tsx`. - `/mockups/calculators-bedside-sheet` - Route discovered from app directory Source: `src/app/mockups/calculators-bedside-sheet/page.tsx`. - `/mockups/calculators-clinical-console` - Route discovered from app directory Source: `src/app/mockups/calculators-clinical-console/page.tsx`. - `/mockups/calculators-directory-grid` - Route discovered from app directory Source: `src/app/mockups/calculators-directory-grid/page.tsx`. diff --git a/src/app/globals.css b/src/app/globals.css index 57af6f986c..4053e8dd30 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -308,8 +308,7 @@ --animate-dialog-rise: dialog-rise 220ms cubic-bezier(0.22, 1, 0.36, 1) both; --animate-action-tray-in: action-tray-in var(--swipe-duration, 160ms) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-shimmer: shimmer 1.4s linear infinite; - --animate-answer-ecg: answer-ecg-scroll 3.2s linear infinite; - --animate-answer-ecg-compact: answer-ecg-scroll 2.6s linear infinite; + --animate-answer-progress-breath: answer-progress-breath 2.4s ease-in-out infinite; } /* Theme tokens */ @@ -3934,24 +3933,26 @@ td, } } -/* Scroll the bright ECG strip like a cardiac monitor. +/* The answer/search progress indicator, breathing. * - * The strip is two identical copies of the trace side by side inside a 200%-wide - * HTML span, so translating it by exactly -50% lands copy 2 where copy 1 started - * and the loop has no seam (the path starts at `M0 24` and ends at the same y). + * Opacity only, on a 5px dot. The ECG strip this replaces animated `transform` + * because mobile WebKit can report SVG path animations as running without + * repainting them; a plain opacity fade on a plain HTML span has never had that + * problem, and there is no strip to keep seamless. * - * `transform` is the only animated property here on purpose. Mobile WebKit can - * report animations on SVG path properties as running without repainting them, - * especially in an installed PWA, and an earlier whole-line opacity pulse was - * technically running but too subtle to read as motion on a phone hairline. - * Translating a plain HTML layer is the same compositor-thread recipe that - * `.animate-skeleton-shimmer::after` already uses reliably in this app. */ -@keyframes answer-ecg-scroll { - from { - transform: translate3d(0, 0, 0); + * It never reaches 0. The indicator must remain visible at every frame, + * including the resting frame when motion is suppressed — that is the contract + * `ui-phone-motion.spec.ts` exists to hold, after Reduce Motion set the old + * trace to `opacity: 0` and left a dead panel on a physical iPhone while an + * answer was generating. */ +@keyframes answer-progress-breath { + 0%, + 100% { + opacity: 1; } - to { - transform: translate3d(-50%, 0, 0); + + 50% { + opacity: 0.35; } } @@ -3968,23 +3969,22 @@ td, * those unlayered classes, check the class body first; the class wins. */ @layer components { - /* `isolation` + `translateZ(0)` mirror .animate-skeleton-shimmer: they give the - scrolling strip its own compositor layer so WebKit repaints it. The mask is - static (never animated) and only softens the two clipped edges. */ - .answer-activity-trace { - isolation: isolate; - transform: translateZ(0); - -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 10%, #000 90%, transparent 100%); - mask-image: linear-gradient(90deg, transparent 0%, #000 10%, #000 90%, transparent 100%); + .answer-progress-dot { + animation: var(--animate-answer-progress-breath); + will-change: opacity; } - .answer-activity-trace__sweep { - animation: var(--animate-answer-ecg); - will-change: transform; + /* The arriving source rail fades at its right edge instead of clipping a card + mid-word, the same mechanism .answer-suggestion-chips-scroll already ships. + The mask is static and never animated. */ + .answer-sources-arriving { + scrollbar-width: none; + -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 1.75rem), transparent); + mask-image: linear-gradient(90deg, #000 calc(100% - 1.75rem), transparent); } - .answer-activity-trace[data-density="compact"] .answer-activity-trace__sweep { - animation: var(--animate-answer-ecg-compact); + .answer-sources-arriving::-webkit-scrollbar { + display: none; } .app-edge-backdrop { @@ -4318,20 +4318,22 @@ td, } /* Suppressing motion must not delete the status indicator. `opacity: 0` here used - to hide the bright trace outright, so anyone with Reduce Motion on saw a dead - panel while an answer was generating. At translateX(0) the first copy of the - strip fills the box exactly, so simply stopping the animation leaves a correct, - clearly visible static ECG. */ + to hide the old ECG trace outright, so anyone with Reduce Motion on saw a dead + panel while an answer was generating. Stopping the dot's breath leaves it at + full opacity — a correct, clearly visible bullet — which is the whole reason + the indicator is a dot rather than a spinner. + `html:not([data-motion="full"])` is what lets the in-app Motion preference opt + back IN over the OS request; a Tailwind `motion-safe:` variant could not. */ @media (prefers-reduced-motion: reduce) { - html:not([data-motion="full"]) .answer-activity-trace__sweep { + html:not([data-motion="full"]) .answer-progress-dot { animation: none; - opacity: 0.55; + opacity: 1; } } -html[data-motion="reduced"] .answer-activity-trace__sweep { +html[data-motion="reduced"] .answer-progress-dot { animation: none; - opacity: 0.55; + opacity: 1; } /* IMP-04: Compositor-thread sweep used by .animate-skeleton-shimmer::after. */ diff --git a/src/app/mockups/answer-loading-redesign/page.tsx b/src/app/mockups/answer-loading-redesign/page.tsx new file mode 100644 index 0000000000..918b1c9297 --- /dev/null +++ b/src/app/mockups/answer-loading-redesign/page.tsx @@ -0,0 +1,5 @@ +import { AnswerLoadingRedesignMockupsPage } from "@/components/answer-loading-redesign-mockups"; + +export default function AnswerLoadingRedesignMockupRoute() { + return ; +} diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 5d31598284..9d6f5b10bd 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -66,6 +66,11 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { const isAnswerChatRedesignMockup = pathname === "/mockups/answer-chat-redesign"; const isAnswerChatPerfectedMockup = pathname === "/mockups/answer-chat-perfected" || pathname === "/mockups/answer-chat-perfected-v2"; + // The loading study draws its own top bar, transcript and composer inside every + // phone and desktop frame, and its whole subject is what occupies the answer + // column before the answer. Shared chrome above those frames would read as a + // second real header and a second real search bar over the study. + const isAnswerLoadingRedesignMockup = pathname === "/mockups/answer-loading-redesign"; // Draws its own sticky chrome + device frames for /privacy; shared shell would // read as a second real header over the study. const isPrivacyPageDirectionsMockup = pathname === "/mockups/privacy-page-directions"; @@ -184,6 +189,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isAnswerHomeProposalMockup && !isAnswerChatRedesignMockup && !isAnswerChatPerfectedMockup && + !isAnswerLoadingRedesignMockup && !isPrivacyPageDirectionsMockup && !isPrivacyLiveSignalPerfectedMockup && !isSearchLensMenuMockup && @@ -216,6 +222,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isAnswerHomeProposalMockup && !isAnswerChatRedesignMockup && !isAnswerChatPerfectedMockup && + !isAnswerLoadingRedesignMockup && !isPrivacyPageDirectionsMockup && !isPrivacyLiveSignalPerfectedMockup && !isSearchLensMenuMockup && diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index ad144da0d9..61c6a20d2e 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -84,7 +84,7 @@ import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/cli import { AnswerCancelledNotice } from "@/components/clinical-dashboard/answer-cancelled-notice"; import { isPreformattedGroundedAnswer } from "@/components/clinical-dashboard/answer-content"; import { - AnswerProgressStepper, + AnswerProgress, AnswerSkeleton, SearchProgressBanner, SharedHomeEmptyState, @@ -93,7 +93,6 @@ import { type AnswerProgressUpdate, type TimedAnswerProgressUpdate, } from "@/components/clinical-dashboard/answer-progress"; -import { AnswerEvidencePreview } from "@/components/clinical-dashboard/answer-evidence-preview"; import { requestAnswerStream } from "@/components/clinical-dashboard/answer-request"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { PhoneFooterLayerFrame } from "@/components/clinical-dashboard/phone-footer-layer-portal"; @@ -3650,22 +3649,23 @@ function ClinicalDashboardContent({ {searchMode !== "prescribing" && (activeModeResultKind === "answer" ? ( showAnswerProgress ? ( - ) : null ) : loading && answerProgress ? ( ) : null)} - {activeModeResultKind === "answer" && loading && answerEvidencePreview ? ( - - ) : null} - {showUniversalAlsoMatches && (activeModeResultKind === "tools" || activeModeResultKind === "documents" || @@ -3784,7 +3784,13 @@ function ClinicalDashboardContent({ ) ) : showAnswerPending ? ( - + // Only until the first progress event. From there AnswerProgress owns + // the whole wait — line, prose placeholder, sources, in the order the + // arrived answer uses — and rendering the skeleton here as well would + // put a second prose placeholder below its sources. + showAnswerProgress ? null : ( + + ) ) : answer && answerRenderModel ? ( stagedDashboardExtraction.answerSurface ? ( <> diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 9e4a7edf9f..3b56537d63 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -21,7 +21,7 @@ import { PhoneFooterLayerPortal } from "@/components/clinical-dashboard/phone-fo import { useActiveScrollOwner } from "@/components/clinical-dashboard/use-active-scroll-owner"; import { PhoneHeaderCollapsePortal } from "@/components/clinical-dashboard/phone-header-collapse-portal"; import { useDocumentViewerChromeScroll } from "@/components/clinical-dashboard/use-document-viewer-chrome-scroll"; -import { AnswerProgressStepper } from "@/components/clinical-dashboard/answer-status"; +import { AnswerProgress } from "@/components/clinical-dashboard/answer-status"; import { appBackdrop, cn, @@ -1416,7 +1416,7 @@ export function DocumentViewer({ {(loadingSummary || summary || summaryError) && (
{summaryProgressStartedAt && summaryProgressEvents.length > 0 ? ( - = [ + { + id: "a1", + text: "Full blood count and absolute neutrophil count at baseline, weekly for the first 18 weeks, fortnightly to week 52, then monthly while treatment continues.", + refs: [1], + }, + { + id: "a2", + text: "Troponin and CRP at baseline and weekly for the first four weeks, with urgent cardiology review where troponin exceeds twice the upper limit of normal.", + refs: [2], + }, + { + id: "a3", + text: "Weight, waist circumference, lipids and HbA1c at baseline, at three months, then annually.", + refs: [3, 5], + }, +]; + +/* ══════════════════════ the replay clock ══════════════════════ */ + +/** + * A loading state cannot be judged from a still. Every direction below is + * driven from one shared tick so the three phones move together and can be + * compared at the same instant, and so a reviewer can stop the clock on the + * frame they want to argue about. + * + * Autoplay is off when the reviewer's OS asks for reduced motion — this page + * is about restraint, and a page about restraint that ignores the setting + * would be arguing against itself. + */ +const BEAT_COUNT = 11; + +type Phase = "asked" | "searching" | "writing" | "answered"; + +type Beat = { + tick: number; + phase: Phase; + found: number; + scanned: number; + seconds: number; +}; + +const BEAT_SECONDS = [0, 0.6, 1.1, 1.5, 1.9, 2.3, 2.7, 3.4, 4.6, 6.1, 7.2]; + +function beatFor(tick: number): Beat { + const seconds = BEAT_SECONDS[tick] ?? 0; + if (tick === 0) return { tick, phase: "asked", found: 0, scanned: 0, seconds }; + if (tick <= 6) { + return { tick, phase: "searching", found: tick, scanned: Math.round((2_140 * tick) / 6), seconds }; + } + if (tick <= 9) return { tick, phase: "writing", found: 6, scanned: 2_140, seconds }; + return { tick, phase: "answered", found: 6, scanned: 2_140, seconds }; +} + +const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; + +/** Subscribed rather than sampled into state, so the preference is read during + * render and the server snapshot is a stable `false`. Sampling it in an effect + * would mean a first paint that autoplays and then stops. */ +function usePrefersReducedMotion() { + return useSyncExternalStore( + (onChange) => { + const query = window.matchMedia(REDUCED_MOTION_QUERY); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); + }, + () => window.matchMedia(REDUCED_MOTION_QUERY).matches, + () => false, + ); +} + +function useReplayClock(intervalMs = 640) { + const [tick, setTick] = useState(0); + // `null` means "follow the OS". Pressing Play or Pause is a deliberate + // override and is honoured either way — a reviewer who asked for reduced + // motion may still want to watch this page move once. + const [override, setOverride] = useState(null); + const reducedMotion = usePrefersReducedMotion(); + const playing = override ?? !reducedMotion; + + useEffect(() => { + if (!playing) return undefined; + const id = window.setInterval(() => setTick((current) => (current + 1) % BEAT_COUNT), intervalMs); + return () => window.clearInterval(id); + }, [playing, intervalMs]); + + return { + beat: beatFor(tick), + playing, + toggle: () => setOverride(!playing), + scrub: (next: number) => { + setOverride(false); + setTick(next); + }, + reset: () => setTick(0), + }; +} + +function ReplayControls({ + playing, + tick, + onToggle, + onScrub, + onReset, +}: { + playing: boolean; + tick: number; + onToggle: () => void; + onScrub: (next: number) => void; + onReset: () => void; +}) { + return ( +
+ + +
+ {Array.from({ length: BEAT_COUNT }, (_, index) => { + const label = `${BEAT_SECONDS[index]?.toFixed(1)} seconds in`; + return ( + + ); + })} +
+

t + {BEAT_SECONDS[tick]?.toFixed(1)}s

+
+ ); +} + +/* ══════════════════════ shared parts of the redesign ══════════════════════ */ + +/** + * The whole animation, in one element. + * + * A 5px dot at the start of the status line, breathing on a 2.4s cycle. It is + * the only moving thing in directions A and B, it costs one composited + * property, and at rest — reduced motion, forced colors, a screenshot — it is + * still a correct, legible bullet rather than a blank space where a spinner + * used to be. That last property is what disqualifies a spinner here: a + * stopped `Loader2` is a fragment of a circle. + */ +function BreathDot({ tone = "accent" }: { tone?: "accent" | "muted" | "success" }) { + return ( + + + + {title} + + + p.{source.page_number ?? "n/a"} + {` · ${status}`} + + + + ); + })} +
); } diff --git a/src/components/clinical-dashboard/answer-progress.ts b/src/components/clinical-dashboard/answer-progress.ts index 2bfd194dbd..d6019ef497 100644 --- a/src/components/clinical-dashboard/answer-progress.ts +++ b/src/components/clinical-dashboard/answer-progress.ts @@ -4,14 +4,6 @@ import { isDeliverableVerifiedUnit } from "@/lib/answer-stream-contract"; export type AnswerProgressUpdate = PublicAnswerProgressEvent; export type TimedAnswerProgressUpdate = AnswerProgressUpdate & { receivedAt: number }; -export const answerProgressSteps = [ - { label: "Prepare scope", description: "Interpreting your question", stage: "scoping" }, - { label: "Search sources", description: "Scanning indexed clinical documents", stage: "retrieving" }, - { label: "Select evidence", description: "Prioritising relevant passages", stage: "ranking" }, - { label: "Draft answer", description: "Synthesising the response and citations", stage: "generating" }, - { label: "Check answer", description: "Checking citations and clinical details", stage: "verifying" }, -] as const; - const answerProgressStages = new Set([ "scoping", "retrieving", @@ -83,30 +75,78 @@ export function answerProgressStepIndex(stage: PublicAnswerProgressStage) { return 4; } -/** UI copy is derived from the public stage/counts and never from an incoming message. */ +/** UI copy is derived from the public stage and never from an incoming message. + * + * One rule holds this whole set together, taken from the pending screens in + * `/mockups/answer-chat-perfected-v2`: + * + * **The wait shows no number the reader cannot reconcile with something on + * screen.** + * + * That rules out every raw count the stream offers. `resultCount` is candidate + * chunks — commonly 24 where the answer will cite three — and a reader who takes + * "24" away from this screen has been told the wrong thing about how much + * evidence is behind their answer, whether or not the word beside it was + * "passages". `australianSourceCount` fails the same test: it is real and it is + * useful, but 4 of 6 is a ratio nothing on screen can confirm, so the fact + * survives here as a fact ("Prioritising Australian sources") and the per-source + * origin stays where it can be checked — on the sources themselves. + * + * The one count that IS shown lives in `answerProgressPreviewMessage` below, + * because it counts exactly the cards visible beneath the line. + * + * Accrual does not depend on numbers. A healthy wait moves through four + * distinct clauses in roughly seven seconds, which is what tells a reader the + * search is working rather than stuck. + */ export function answerProgressDisplayMessage(progress: AnswerProgressUpdate) { - if (progress.stage === "scoping") return "Preparing the clinical search scope."; - if (progress.stage === "retrieved" && progress.resultCount !== undefined) { - return `Found ${progress.resultCount} candidate source passage${progress.resultCount === 1 ? "" : "s"}.`; - } - if (progress.stage === "retrieving" || progress.stage === "retrieved") { - return "Searching indexed clinical documents."; - } + if (progress.stage === "scoping") return "Reading your question…"; + if (progress.stage === "retrieving" || progress.stage === "retrieved") return "Searching your documents…"; if (progress.stage === "ranking") { - if (progress.australianSourceCount) { - const waDetail = progress.waSourceCount ? `, including ${progress.waSourceCount} WA` : ""; - return `Prioritising ${progress.australianSourceCount} Australian source passage${progress.australianSourceCount === 1 ? "" : "s"}${waDetail}.`; - } - return "Selecting the most relevant source passages."; - } - if (progress.stage === "retrying") { - return "The draft needs another pass; revising it against the evidence."; - } - if (progress.stage === "fallback") { - return "Building a source-backed answer from the selected passages."; + // The fact, not the ratio. A Perth reader cares that local guidance is being + // favoured; "4 of 6" is the part nothing on screen can confirm. + return progress.australianSourceCount + ? "Prioritising Australian sources…" + : "Selecting the most relevant passages…"; } - if (progress.stage === "generating") return "Drafting a cited answer from the selected passages."; - if (progress.stage === "verifying") return "Checking citations, clinical numbers, and source metadata."; - if (progress.stage === "cached") return "Loading a recent cited answer."; + if (progress.stage === "retrying") return "Revising the draft against the evidence…"; + if (progress.stage === "fallback") return "Assembling the answer from the sources directly…"; + if (progress.stage === "generating") return "Writing the answer…"; + if (progress.stage === "verifying") return "Checking citations and clinical numbers…"; + if (progress.stage === "cached") return "Loading a recent cited answer…"; return "Answer ready."; } + +/** + * The line once the evidence preview is on screen. + * + * This is the only place the wait prints a number, and it prints the number of + * cards the reader can count directly beneath it. The mockup's wording + * ("3 sources found · writing the answer…") is kept because it names both halves + * of what is true at that moment: retrieval finished, generation has not. + * + * Returns null before generation starts, so the caller falls back to the stage + * clause rather than claiming the answer is being written while ranking is still + * running. + */ +export function answerProgressPreviewMessage(sourceCount: number, stage: PublicAnswerProgressStage) { + if (sourceCount <= 0) return null; + const sources = `${sourceCount} source${sourceCount === 1 ? "" : "s"} found`; + if (stage === "generating" || stage === "retrying") return `${sources} · writing the answer…`; + if (stage === "fallback") return `${sources} · assembling the answer from them…`; + if (stage === "verifying") return `${sources} · checking the citations…`; + return sources; +} + +/** The stages worth disclosing after the fact. + * + * A routine answer has nothing to explain — scope, search, select, write, check, + * in that order, every time — which is why the old Processing details disclosure + * held the same five lines for every question and nobody opened it. These three + * stages mean the answer did NOT take the ordinary route, and that is worth a + * reader being able to read back. */ +const disclosableStages = new Set(["retrying", "fallback", "cached"]); + +export function answerProgressTookUnusualRoute(events: readonly AnswerProgressUpdate[]) { + return events.some((event) => disclosableStages.has(event.stage)); +} diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx index 29647a8e86..d7bdfe77c4 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -1,20 +1,21 @@ "use client"; -import type { CSSProperties } from "react"; -import { Activity, Check, History, Loader2, Square } from "lucide-react"; +import { useEffect, useState, type CSSProperties } from "react"; +import { History, Square } from "lucide-react"; import { answerProgressDisplayMessage, - answerProgressStepIndex, - answerProgressSteps, + answerProgressPreviewMessage, + answerProgressTookUnusualRoute, type TimedAnswerProgressUpdate, } from "@/components/clinical-dashboard/answer-progress"; -import { useClientTime } from "@/lib/use-client-time"; +import { AnswerEvidencePreview } from "@/components/clinical-dashboard/answer-evidence-preview"; +import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract"; import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences"; import { ModeHomeTemplate } from "@/components/mode-home-template"; import { ShowAllChip } from "@/components/show-all-chip"; -import { cn, sourceCard } from "@/components/ui-primitives"; +import { cn } from "@/components/ui-primitives"; import { appModeIcons } from "@/lib/app-mode-icons"; import type { AppModeId } from "@/lib/app-modes"; import { consolidatedModeSearchPath } from "@/lib/consolidated-mode-home-redirect"; @@ -98,172 +99,220 @@ function skeletonBar(className: string, staggerIndex: number) { ); } +/** + * Three prose bars, and deliberately nothing else. + * + * The retired skeleton drew a bordered card, a source card with a tap-sized + * block, two pill placeholders and a two-column grid — a wireframe of an answer + * that has not been retrieved yet, promising a shape the payload may not + * produce. Twenty of thirty answers in the 2026-08-18 blinded read carried no + * sections at all. Three bars promise only "text is coming", which is the one + * thing actually known at this point. + */ +function AnswerProseSkeleton() { + return ( + + ); +} + +/** + * The window before the first progress event, and the lazy-load fallback for the + * dashboard chunk. + * + * It carries no status text of its own. Once progress events start arriving, + * `AnswerProgress` owns the whole wait — line, prose placeholder and sources, in + * that order — and this component is not rendered beside it. Two indicators + * disagreeing on one screen ("Writing the answer…" above "Reading your + * question…") is worse than one, and that is exactly what shipped before this + * was split. + * + * role=status so the window is still announced; without it a screen reader stays + * silent until AnswerProgress mounts with its own live region. + */ export function AnswerSkeleton() { - // role=status (matching LoadingPanel) so the initial answer-pending window — - // after submit but before the first progress event — is announced. Without it - // the aria-label sits on a plain div and screen readers stay silent until the - // progress stepper (its own role=status) mounts. return ( -
-
- {skeletonBar("h-4 w-10/12", 0)} - {skeletonBar("h-4 w-full", 1)} - {skeletonBar("h-4 w-8/12", 2)} -
-
- {skeletonBar("h-3 w-24", 3)} - {skeletonBar("h-4 w-48 max-w-full", 4)} -
- {skeletonBar("h-tap w-20 rounded-lg", 5)} -
-
-
- {skeletonBar("h-tap w-48 rounded-lg", 6)} - {skeletonBar("h-tap w-40 rounded-lg", 7)} -
-
- {skeletonBar("h-28 rounded-lg", 8)} - {skeletonBar("hidden h-28 rounded-lg sm:block", 9)} -
+
+ {answerLoading.ariaLabel}
); } -function elapsedLabel(elapsedMs: number) { - const seconds = Math.max(0, Math.floor(elapsedMs / 1_000)); - return seconds < 1 ? "<1s" : `${seconds}s`; +/** + * The whole animation, in one element. + * + * A 5px dot at the head of the status line, breathing on a 2.4s cycle. It + * replaces a `Loader2` spinner in the search banner and a scrolling ECG trace in + * the answer progress panel, and it is the only moving thing either surface now + * has. + * + * The reason it is a dot and not a spinner is the state it has to survive. The + * indicator must stay correct and clearly visible when motion is suppressed — + * that is a contract this repo learned the hard way, after Reduce Motion set the + * ECG trace to `opacity: 0` and left a dead panel on a physical iPhone while an + * answer was generating. A stopped dot is a bullet. A stopped spinner is a + * fragment of a circle. + * + * The animation itself lives in globals.css as `.answer-progress-dot`, not as a + * `motion-safe:` utility, because the in-app Motion preference has to be able to + * opt back IN over the OS request and a Tailwind media variant cannot be + * overridden by `html[data-motion="full"]`. + */ +function ProgressDot() { + // One colour, running or complete. A green dot on completion was a status hue + // carrying meaning that nothing else on the element repeated — and it was + // redundant besides, because the line beside it already changes to "Answer + // ready in 3s". Dropping it removes a colour-only signal and one more thing to + // look at. + // + // The 20px box is the line-height of the text it marks, so the dot sits on the + // optical centre of the first line without a nudge margin, and stays on the + // first line when the text wraps. + return ( +