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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
| 2026-09-03 | claude/answer-loading-sources-animation-4obfpq | da1cc81620afc9db0dc6c9ff71061b3a471a193e | answer-preview source governance, evidence rail | P1 governance display-cap defect fixed with a mutation-proven test; P2 invariant/doc drift recorded as a dated design-doc amendment | clinical-governance-reviewer; verify:cheap; verify:pr-local; build; eval:rag:offline; eval:rag:adversarial:offline; focused chromium (answer-progress-ui-smoke 8, ui-phone-motion+ui-universal-search 25, ui-smoke 106) |
47 changes: 42 additions & 5 deletions docs/verified-answer-incremental-delivery-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,32 @@ boundary. If it refuses the authoritative response, the stream emits zero verifi
through the existing refusal/fallback contract; a preview must never disclose source content that the
final governed response withholds.

**Amendment, 2026-09-03 (owner decision), for the evidence preview only.** As first shipped, the
preview ran the danger-level check over the whole retrieval set (`answerInputResults`) and emitted
nothing on any hit. Because that is also the set the final response governs
(`answer.sources = answerInputResults` in `rag.ts`), it held the invariant above exactly — and it made
the feature unusable: one outdated or badly-extracted chunk among twelve to twenty-four retrieved
passages blanked the whole rail, which on a real corpus is most questions, and the sources it hid were
the clean ones.

The decision is now **per document**. A danger-level document is excluded from the preview; the
remaining sources are shown. Per card this is stricter than before — such a document can no longer
appear in the preview at all, where the wide check merely delayed its appearance until the answer's
own rail. The answer-level verdict (`WEAK_EVIDENCE`, from `relevance.verdict === "none"`) still
suppresses the entire preview, because it is not a property of any one document.

The residual, stated plainly rather than left to be discovered: when the final response refuses on
source governance it blanks `sources: []`, so a preview that showed clean cards has disclosed
document identities the final response then withholds. Those cards are documents that passed
governance individually and are reachable by ordinary search; the refusal blocks the synthesised
answer, not access to the documents. They are removed the moment the answer lands, because the rail
renders only while the request is in flight. This is a deliberate, owner-approved narrowing of the
invariant above, not an oversight, and it applies to the evidence preview only — Phase 2 answer
sections carry generated clinical prose and keep the unamended rule.

The exclusion set must never be derived from `sourceGovernanceWarnings`' returned list, which is
capped at eight for display. Evaluate one source at a time; see `src/lib/answer-preview.ts`.

Do not implement a second, weaker “stream-safe” verifier. If the current gates cannot operate on an
independent section, that section stays buffered until the final answer. Cross-section comparisons,
conflicts, and conclusions that depend on later sections are not independently emit-able in v1.
Expand All @@ -107,8 +133,11 @@ conflicts, and conclusions that depend on later sections are not independently e
of `token` / `revising`.
- Add reconciliation tests proving every preview is an exact subset of `final` and is discarded on
error, cancellation, retry, unknown schema version, or mismatch.
- Add a source-governance refusal fixture proving an outdated or poorly extracted danger-level source
emits zero evidence previews and zero answer-section units.
- Add a source-governance fixture proving an outdated or poorly extracted danger-level source is
excluded from every evidence preview and emits zero answer-section units, that an answer-level
`WEAK_EVIDENCE` verdict emits zero evidence previews, and that exclusion holds past the
eight-warning display cap. (Amended 2026-09-03 with the per-document decision above; before that
amendment any such source emitted zero evidence previews.)
- Add owner-boundary fixtures proving private source fields and cross-owner identifiers cannot cross
the route boundary.

Expand All @@ -117,16 +146,24 @@ This phase is provider-free and must land before either visible phase.
### Phase 1 — retrieval-complete evidence preview

- After answer evidence is ranked and the final context pack is selected, run the same danger-level
source-governance refusal used by the authoritative final response. Only when it permits disclosure,
build a preview through the existing client-source trimming policy and emit it as
`progress.verifiedUnit`.
source-governance decision used by the authoritative final response, per document (see the
2026-09-03 amendment above). Emit the sources it permits, through the existing client-source
trimming policy, as `progress.verifiedUnit`; emit nothing when the answer-level verdict is
danger-level or when no source survives.
- Render it where the answer's own source rail will land, so arrival swaps content in place rather
than moving it. As shipped this is `AnswerEvidencePreview`, a rail of unnumbered cards under the
status line and prose placeholder — not the labelled “Selected evidence” panel this design first
described, which stacked a second loud block in the answer's position. Do not render it as answer
prose, do not mark the answer complete, and do not number the cards: the preview is retrieval
order while the final list is rebuilt from what the answer cites, so an early number can end up
pointing at a different document.
- The cards are revealed one at a time, one per `--duration-moderate`, with the first standing the
instant the unit arrives. Retrieval is a single call and the contract carries exactly one preview
per answer, so this is presentation, not live discovery: the pacing exists because a rail that
mounts whole is over before a reader watching an otherwise still screen registers it. The status
line's count is read from the same reveal, so the number always equals the cards beneath it.
Suppressing motion returns the full count with no timer in the path — a JS reveal could withhold
content in a way a CSS delay never could.
- Preserve the current final source list, source governance warnings, feedback token, telemetry, and
persistence behaviour.

Expand Down
33 changes: 15 additions & 18 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -4407,24 +4407,21 @@ html[data-motion="reduced"] .answer-progress-dot {
animation-delay: calc(var(--stagger-index, 0) * var(--stagger-cascade));
}

/* The answer wait's source rail arrives one card at a time rather than as a single movement.
The default `--stagger-cascade` rung is right for a results grid the reader is about to
scan, but six cards in 175ms reads as one block appearing. On `--stagger-cascade-wide` the
last card lands around 450ms, so each is separately noticeable, and the whole rail is still
standing long before generation ends.

Scoped to this rail deliberately — the shared class also drives the prose skeleton bars
immediately above it and the search result grids, which keep the 35ms rung. Declared here,
unlayered and adjacent to the rule it overrides, because `.answer-sources-arriving` itself
lives in @layer components and a layered rule would lose to this unlayered one whatever its
specificity.

It cannot reintroduce motion when motion is suppressed: both reduced-motion paths below set
`animation: none !important` on `.stagger-item`, and that shorthand resets animation-delay
to 0s with it, so the cards stay immediately and fully visible. */
.answer-sources-arriving .stagger-item {
animation-delay: calc(var(--stagger-index, 0) * var(--stagger-cascade-wide));
}
/* The answer wait's source rail arrives one card at a time, and the pacing is NOT here.

This used to be a `--stagger-cascade-wide` delay override, because every card mounted at
once and only an animation delay could separate them. Six cards then landed inside 450ms —
correct to the millisecond, and over before a reader looking at an otherwise still screen
registered that anything had happened. The rail is now revealed a card at a time by
`useProgressiveReveal` in `answer-status.tsx`, one per `--duration-moderate`, so each card
animates on mount with the shared `.stagger-item` rule and no delay of its own. The cards
deliberately set no `--stagger-index`: mount timing and an animation delay would compound,
and the last card would arrive at twice the intended distance.

The pacing owner moved, not the reduced-motion contract. The hook returns the full count
immediately when motion is suppressed, and both reduced-motion paths below independently set
`animation: none !important` on `.stagger-item`. Suppressing motion must never withhold
content on this surface — that is the rule this rail was built under. */

@media (prefers-reduced-motion: reduce) {
html:not([data-motion="full"]) .animate-skeleton-shimmer::after,
Expand Down
30 changes: 26 additions & 4 deletions src/components/clinical-dashboard/answer-evidence-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,28 @@ export const visiblePreviewSourceLimit = 6;
*
* Each card is a real link to the real page, so a reader who recognises a
* document can open it without waiting for the answer at all.
*
* **The cards arrive one at a time, and that pacing is presentation.** Retrieval is a
* single call, and the stream contract carries exactly one evidence preview per answer
* (`sequence: 0`), so every source in this rail was found in the same instant. What the
* pacing buys is a wait that accrues instead of a screen that sits still for several
* seconds and then blinks. Nothing here claims live discovery: the status line reads
* "N sources found", and N is always the number of cards standing beside it, which is the
* one rule the wait's copy is held to.
*/
export function AnswerEvidencePreview({ preview }: { preview: VerifiedEvidencePreviewUnit }) {
const visibleSources = preview.sources.slice(0, visiblePreviewSourceLimit);
export function AnswerEvidencePreview({
preview,
revealedCount,
}: {
preview: VerifiedEvidencePreviewUnit;
/** How many of the capped sources are on screen right now. The rail does not own this
* number: `AnswerProgress` paces it so the status line and the cards can never disagree
* about how many sources have been found. Omitted, every capped source shows at once,
* which is what a caller with no pacing state (and every reduced-motion reader) gets. */
revealedCount?: number;
}) {
const cappedSources = preview.sources.slice(0, visiblePreviewSourceLimit);
const visibleSources = cappedSources.slice(0, revealedCount ?? cappedSources.length);
if (visibleSources.length === 0) return null;

return (
Expand All @@ -53,7 +72,7 @@ export function AnswerEvidencePreview({ preview }: { preview: VerifiedEvidencePr
aria-label={`Sources found so far, ${visibleSources.length}. Not yet numbered — the answer decides the final list.`}
className="answer-sources-arriving flex gap-1.5 overflow-x-auto pb-1"
>
{visibleSources.map((source, index) => {
{visibleSources.map((source) => {
const title = cleanDisplayTitle(source.title);
// Freshness, not the section heading. A section heading is orientation a
// reader gets anyway once the card is opened; whether the document is
Expand All @@ -68,7 +87,10 @@ export function AnswerEvidencePreview({ preview }: { preview: VerifiedEvidencePr
href={sourceResultHref(source)}
data-testid="answer-evidence-preview-source"
aria-label={`Open source found so far: ${title}, page ${source.page_number ?? "unknown"}, ${status}`}
style={{ "--stagger-index": index } as React.CSSProperties}
// No `--stagger-index`: the cascade is carried by mount timing now, not by an
// animation delay. Each card animates the moment it appears, so keeping an index
// here would delay it a second time and the last card would arrive twice as late
// as the pacing intends. The default `var(--stagger-index, 0)` is what we want.
className={cn(
"stagger-item inline-flex min-h-12 shrink-0 items-center gap-2 rounded-xl border border-[color:var(--border)]",
"bg-[color:var(--surface-raised)] px-2.5 text-left transition",
Expand Down
86 changes: 83 additions & 3 deletions src/components/clinical-dashboard/answer-status.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState, type CSSProperties } from "react";
import { useEffect, useState, useSyncExternalStore, type CSSProperties } from "react";
import { History, Square } from "lucide-react";

import {
Expand All @@ -22,6 +22,7 @@ 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";
import { motionIsSuppressed } from "@/lib/scroll-behavior";
import {
answerLoading,
sharedHomeEmptyState,
Expand Down Expand Up @@ -231,6 +232,79 @@ function useSlowNotice(active: boolean, startedAt: number | null) {
return active && startedAt !== null && slowRun === startedAt;
}

/** One card per rung. `--duration-moderate` is an existing duration token (200ms) rather than
* a new one — `globals.css` says in as many words not to invent a rung — and six cards
* therefore land over about 1.2s. That is long enough that each is separately noticeable and
* short enough to be standing well before generation ends, which is the window the rail has
* to be readable in. */
const evidenceRevealIntervalMs = 200;

/** The motion preference as a subscription rather than a snapshot, so the in-app Reduce motion
* toggle takes effect on the wait already on screen. `use-app-preferences.ts` mirrors that
* toggle onto `<html data-motion>`, and the OS request arrives through the media query;
* `motionIsSuppressed()` reads both, this only watches them for changes. */
function subscribeToMotionPreference(onChange: () => void) {
const media = window.matchMedia?.("(prefers-reduced-motion: reduce)");
media?.addEventListener("change", onChange);
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-motion"] });
return () => {
media?.removeEventListener("change", onChange);
observer.disconnect();
};
}

/**
* How many source cards are on screen right now.
*
* The sources all arrive in one stream event, so this is a reveal, not live discovery — see
* the note on `AnswerEvidencePreview`. It lives here rather than in the rail because the
* status line prints this same number, and the wait's one copy rule is that no number appears
* that the reader cannot reconcile with something on screen. One owner, one count.
*
* Keyed by preview identity, in the same shape `useSlowNotice` uses for the run: a retry or a
* new question hands over a different unit and the count starts again. The reset is a
* render-phase adjustment, not a write inside an effect — nothing here calls setState
* synchronously during an effect, where it would cascade renders.
*
* **The count for one unit never goes backwards.** Motion is a live subscription, so a reader
* can change the preference mid-generation; if suppressing motion filled the rail and then
* re-enabling it restarted the count, six cards a reader was already reading would vanish and
* re-accrue. The count only ever rises, so a preference change can complete the rail early but
* can never take back a card.
*
* Motion suppressed reveals everything immediately, with no timer in the path at all. That is
* the hard-won rule on this surface: Reduce Motion once left a dead panel on a physical iPhone
* mid-generation, and a JS reveal could withhold content in a way a CSS delay never could.
*/
function useProgressiveReveal(total: number, preview: VerifiedEvidencePreviewUnit | null) {
// `motionIsSuppressed`, not `prefersReducedMotion`: this gates a JS animation, and the CSS
// animations around it honour an explicit in-app "Full" over an OS reduce request. Reading
// the weaker form here would freeze this rail alone while the rest of the interface animates.
const suppressed = useSyncExternalStore(subscribeToMotionPreference, motionIsSuppressed, () => false);
const [revealed, setRevealed] = useState<{ unit: VerifiedEvidencePreviewUnit; count: number } | null>(null);

const cap = preview && total > 0 ? total : 0;
const stored = revealed?.unit === preview ? revealed.count : 0;
// The first card is on screen the instant the unit arrives. This preview exists to shorten
// time to first useful content, and holding the whole rail back for a rung to make the
// animation tidier would spend exactly what it was built to buy.
const floor = cap === 0 ? 0 : suppressed ? cap : Math.min(1, cap);
const count = Math.max(stored, floor);
if (preview && count !== stored) setRevealed({ unit: preview, count });

useEffect(() => {
if (suppressed || !preview || count >= cap) return undefined;
// One card per rung, as a chain of timeouts keyed on the current count rather than a
// self-clearing interval: it terminates on its own when the rail is full, and the state
// write is the only thing the callback does.
const timer = window.setTimeout(() => setRevealed({ unit: preview, count: count + 1 }), evidenceRevealIntervalMs);
return () => window.clearTimeout(timer);
}, [preview, cap, count, suppressed]);

return count;
}

/**
* Single-line progress for the non-answer (library/document) search modes, the
* flat sibling of AnswerProgress.
Expand Down Expand Up @@ -318,7 +392,11 @@ export function AnswerProgress({
// sources while the rail draws six, and a line reading "8 sources found" above six cards
// is a number the reader cannot reconcile with anything on screen.
const previewSourceCount = Math.min(evidencePreview?.sources.length ?? 0, visiblePreviewSourceLimit);
const previewMessage = latest ? answerProgressPreviewMessage(previewSourceCount, latest.stage) : null;
// The cards are revealed one at a time, so the line counts what is currently standing
// beneath it rather than what the unit carries. Before the first card lands there is no
// count to print and the line falls back to the stage clause.
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) }))
Expand Down Expand Up @@ -354,7 +432,9 @@ export function AnswerProgress({

<AnswerProseSkeleton />

{evidencePreview ? <AnswerEvidencePreview preview={evidencePreview} /> : null}
{evidencePreview ? (
<AnswerEvidencePreview preview={evidencePreview} revealedCount={revealedSourceCount} />
) : null}
</>
)}

Expand Down
Loading
Loading