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
2 changes: 1 addition & 1 deletion .design-sync/config.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
| 2026-08-31 | codex/answer-surface-compact-20260830 | 705561dd1f9b1ac8f72c7a4858e3819b9ee5a40e | compact answer source safety and library UI | No P0-P2 findings; compact source status, answer utilities, safety row, and library placement ready for PR | 13 focused DOM tests passed; targeted Chromium 1/1 passed; lint and typecheck passed; build passed 1998 routes; design contracts passed; production-readiness CI READY; offline RAG 628/628 and adversarial 25/25 passed; full unit 11656 passed with 6 unrelated Windows Claude Cloud harness exit-127 failures; no provider-backed checks run |
2 changes: 1 addition & 1 deletion docs/design-system/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1052,7 +1052,7 @@ This generated snapshot is a local source-derived inventory. It does not assert
| `Progress` | feedback | yes | yes | no | yes | no | 0 |
| `Quantity` | answer | yes | yes | inherited-global-root | yes | no | 1 |
| `RadioGroup` | controls | yes | yes | no | yes | no | 0 |
| `RetrievalStateBanner` | answer | yes | yes | inherited-global-root | yes | no | 1 |
| `RetrievalStateBanner` | answer | yes | yes | inherited-global-root | yes | no | 2 |
| `SafeBoldText` | layout | yes | yes | inherited-global-root | yes | no | 8 |
| `SearchField` | controls | yes | yes | no | yes | no | 0 |
| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 9 |
Expand Down
10 changes: 8 additions & 2 deletions docs/design-system/adoption-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1490,8 +1490,14 @@
"source": "src/components/ui/retrieval-state-banner.tsx",
"sourceExported": true,
"entryExported": true,
"directImportFiles": ["src/components/ui/answer-card.tsx"],
"productImportFiles": ["src/components/ui/answer-card.tsx"],
"directImportFiles": [
"src/components/clinical-dashboard/answer-content.tsx",
"src/components/ui/answer-card.tsx"
],
"productImportFiles": [
"src/components/clinical-dashboard/answer-content.tsx",
"src/components/ui/answer-card.tsx"
],
"designSync": {
"listedInSourceMap": true,
"listedInDtsProps": true,
Expand Down
143 changes: 84 additions & 59 deletions src/components/clinical-dashboard/answer-content.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"use client";

import { Fragment, memo, useState } from "react";
import { Fragment, memo, useId, useState } from "react";
import { CircleAlert, ChevronDown, Copy } from "lucide-react";

import { SafeBoldText } from "@/components/SafeBoldText";
import { chatActionRow, chatAnswerText, chatMicroAction, cn, textMuted } from "@/components/ui-primitives";
import { compactVerificationWordingFor, type VerificationState } from "@/components/ui/verification-notice";
import type { AnswerState } from "@/components/ui/answer-state";
import { RetrievalStateBanner } from "@/components/ui/retrieval-state-banner";
import {
cleanDisplayTitle,
comparableAnswerText,
Expand Down Expand Up @@ -384,6 +386,8 @@ export function NaturalLanguageAnswer({
preformatted = false,
sourceOnly,
sourceOnlyVerificationState = "source_only",
answerState,
onOpenStateSource,
bestSource,
sources,
sourceLinks,
Expand All @@ -394,6 +398,7 @@ export function NaturalLanguageAnswer({
onOpenSource,
onOpenRailSource,
openSourceIndex = null,
showCopyAction = true,
}: {
// Raw answer text (server bold intact); this component owns display
// sanitization so <SafeBoldText> can render the high-yield emphasis.
Expand All @@ -402,6 +407,10 @@ export function NaturalLanguageAnswer({
preformatted?: boolean;
sourceOnly: boolean;
sourceOnlyVerificationState?: VerificationState;
/** The answer-level state shown beside Source-only when source currency is degraded. */
answerState?: AnswerState;
/** Direct route used by expanded source-currency detail. */
onOpenStateSource?: (sourceId: string, locator?: string) => void;
bestSource: BestSourceRecommendation | null;
sources: SearchResult[];
sourceLinks: SourceLink[];
Expand Down Expand Up @@ -435,8 +444,11 @@ export function NaturalLanguageAnswer({
onOpenRailSource?: (index: number) => void;
/** Which rail row the drawer is showing, so the mark and its sentence can light up. */
openSourceIndex?: number | null;
/** Historical turns keep their local copy action; the live turn renders the combined utility row outside. */
showCopyAction?: boolean;
}) {
const [sourceOnlyNoticeOpen, setSourceOnlyNoticeOpen] = useState(false);
const sourceOnlyDetailId = useId();
const { preferences } = useAppPreferences();
const fragments = primaryAnswerDisplayFragments(text, { preformatted, preserveBold: true });
if (!fragments.length) return null;
Expand Down Expand Up @@ -499,73 +511,86 @@ export function NaturalLanguageAnswer({
{/* No negative bottom margin. It pulled the rail up by 8px, and the rail
heading used to carry a top border — the two collided and drew a rule
straight through the Source-only pill. */}
<div className={cn("space-y-1", sourceOnly && "py-1")}>
{sourceOnly ? (
<section
data-testid="source-only-disclosure"
role="note"
className={cn(
"w-fit max-w-full overflow-hidden border border-[color:var(--warning)]/30 bg-[color:var(--warning-soft)]/40 text-2xs transition-[border-radius] duration-[var(--duration-quick)] print:hidden",
sourceOnlyNoticeOpen ? "rounded-lg" : "rounded-full",
textMuted,
)}
>
<button
type="button"
onClick={() => setSourceOnlyNoticeOpen((current) => !current)}
// Compact-meta disclosure (not a primary CTA), TOKENS.md §2 "disclosure"
// row: 40px `--spacing-compact-meta`, the floor the service owner ruled
// acceptable for named compact roles on 2026-08-29. It was `min-h-7`
// (28px), 12px under even that floor. The `::before` hit-expansion its
// DocumentTagCloud siblings use is unavailable here: the wrapping
// `<section>` is `overflow-hidden` (it clips the detail block to the
// pill radius), and overflow clipping removes the expanded region from
// hit testing as well as from paint — the classes would have read as
// compliant while expanding nothing.
className="inline-flex min-h-compact-meta w-full max-w-[68ch] items-center gap-1 px-2 py-0.5 text-left transition hover:bg-[color:var(--warning-soft)]/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]"
aria-expanded={sourceOnlyNoticeOpen}
aria-controls="source-only-disclosure-detail"
{sourceOnly || (answerState?.kind === "stale_evidence" && onOpenStateSource) ? (
<div data-testid="answer-source-status-row" className="flex min-w-0 flex-wrap items-start gap-1 print:hidden">
{sourceOnly ? (
<section
data-testid="source-only-disclosure"
role="note"
className={cn(
"w-fit max-w-full overflow-hidden border border-[color:var(--warning)]/30 bg-[color:var(--warning-soft)]/40 text-2xs transition-[border-radius] duration-[var(--duration-quick)]",
sourceOnlyNoticeOpen ? "rounded-lg" : "rounded-full",
textMuted,
)}
>
<CircleAlert className="h-3 w-3 shrink-0 text-[color:var(--warning)]" aria-hidden />
<span className="min-w-0 truncate font-semibold text-[color:var(--text-heading)]">Source-only</span>
<span className="shrink-0 text-[color:var(--text-muted)]">· verify passages</span>
<ChevronDown
className={cn(
"ml-auto h-3 w-3 shrink-0 text-[color:var(--text-muted)] transition-transform",
sourceOnlyNoticeOpen && "rotate-180",
)}
aria-hidden
/>
</button>
{sourceOnlyNoticeOpen ? (
<div
id="source-only-disclosure-detail"
className="border-t border-[color:var(--warning)]/15 px-2.5 py-1.5 leading-4 text-[color:var(--text-muted)] motion-safe:animate-fade-up"
<button
type="button"
onClick={() => setSourceOnlyNoticeOpen((current) => !current)}
// Compact-meta disclosure (not a primary CTA), TOKENS.md §2 "disclosure"
// row: 40px `--spacing-compact-meta`, the floor the service owner ruled
// acceptable for named compact roles on 2026-08-29. It was `min-h-7`
// (28px), 12px under even that floor. The `::before` hit-expansion its
// DocumentTagCloud siblings use is unavailable here: the wrapping
// `<section>` is `overflow-hidden` (it clips the detail block to the
// pill radius), and overflow clipping removes the expanded region from
// hit testing as well as from paint — the classes would have read as
// compliant while expanding nothing.
className="inline-flex min-h-compact-meta w-full max-w-[68ch] items-center gap-1 px-2 py-0.5 text-left transition hover:bg-[color:var(--warning-soft)]/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]"
aria-expanded={sourceOnlyNoticeOpen}
aria-controls={sourceOnlyDetailId}
>
<p>{compactVerificationWordingFor(sourceOnlyVerificationState, "extractive")}</p>
</div>
) : null}
</section>
) : null}
</div>
<CircleAlert className="h-3 w-3 shrink-0 text-[color:var(--warning)]" aria-hidden />
<span className="min-w-0 truncate font-semibold text-[color:var(--text-heading)]">Source-only</span>
<span className="hidden shrink-0 text-[color:var(--text-muted)] min-[360px]:inline">
· verify passages
</span>
<ChevronDown
className={cn(
"ml-auto h-3 w-3 shrink-0 text-[color:var(--text-muted)] transition-transform",
sourceOnlyNoticeOpen && "rotate-180",
)}
aria-hidden
/>
</button>
{sourceOnlyNoticeOpen ? (
<div
id={sourceOnlyDetailId}
className="border-t border-[color:var(--warning)]/15 px-2.5 py-1.5 leading-4 text-[color:var(--text-muted)] motion-safe:animate-fade-up"
>
<p>{compactVerificationWordingFor(sourceOnlyVerificationState, "extractive")}</p>
</div>
) : null}
</section>
) : null}
{answerState?.kind === "stale_evidence" && onOpenStateSource ? (
<RetrievalStateBanner
state={answerState}
onOpenSource={onOpenStateSource}
className="w-fit min-w-0 max-w-full flex-none"
/>
) : null}
</div>
) : null}
<AnswerSourceRail
sources={railSources}
query={query}
onOpenSource={onOpenRailSource ?? onOpenSource}
activeIndex={openSourceIndex}
compact={preferences.compactCitations}
/>
<div className={cn(chatActionRow, "mt-0.5")} aria-label="Answer actions">
<button
type="button"
onClick={onCopy}
className={chatMicroAction}
aria-label="Copy answer with source status"
>
<Copy aria-hidden="true" className="h-3.5 w-3.5" />
{copied ? "Copied with sources" : "Copy with sources"}
</button>
</div>
{showCopyAction ? (
<div className={cn(chatActionRow, "mt-0.5")} aria-label="Answer actions">
<button
type="button"
onClick={onCopy}
className={chatMicroAction}
aria-label="Copy answer with source status"
>
<Copy aria-hidden="true" className="h-3.5 w-3.5" />
{copied ? "Copied with sources" : "Copy with sources"}
</button>
</div>
) : null}
</div>
</section>
);
Expand Down
64 changes: 37 additions & 27 deletions src/components/clinical-dashboard/answer-result-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { answerStateForAnswer } from "@/components/clinical-dashboard/answer-copy-payload";
import {
AnswerSupportSummaryCard,
AnswerUtilityActions,
answerSupportPriority,
primaryVisualTable,
SafetyFindingsListContent,
Expand Down Expand Up @@ -199,13 +200,20 @@ function StagedAnswerResultSurfaceImpl({
// collapsed interactive disclosure is not part of the printed record.
className: sourceOnly ? "hidden print:flex" : undefined,
};
function openAnswerStateSource(sourceId: string, locator?: string) {
const href = citedDocumentHref(sourceId, locator, [...sources, ...(answer.sources ?? [])]);
if (href) router.push(href);
}

const answerProse = (
<NaturalLanguageAnswer
text={answer.answer}
query={query}
preformatted={isPreformattedGroundedAnswer(answer)}
sourceOnly={sourceOnly}
sourceOnlyVerificationState={answerState.kind}
answerState={answerState}
onOpenStateSource={openAnswerStateSource}
bestSource={bestSource}
sources={sources}
sourceLinks={renderModel.primarySources}
Expand All @@ -219,17 +227,17 @@ function StagedAnswerResultSurfaceImpl({
onOpenSource={openSourceFromClaim}
onOpenRailSource={openSourceFromRail}
openSourceIndex={openSourceIndex}
showCopyAction={false}
/>
);
/**
* The support card is now the answer-level strip and nothing else: the safety
* priority row (its trigger is the only route to the safety sheet), the
* evidence gaps that belong to the answer rather than to any one document, and
* the feedback control. Everything per-source moved to the rail and drawer.
* Mount when any of those rows would render — a clean high-trust answer still
* needs the "Report a problem" path.
* Everything per-source moved to the rail and drawer. Evidence gaps and
* feedback now sit beside Copy with sources as answer utilities, so this
* surface mounts only when there is an actual safety/priority row.
*/
const showInlineSupportCard = Boolean(priority || renderModel.warnings.length > 0 || onSubmitFeedback);
const showInlineSupportCard = Boolean(priority);

return (
<div className="min-w-0 space-y-4 motion-safe:animate-fade-up sm:space-y-5" data-dashboard-stage="answer-surface">
Expand Down Expand Up @@ -270,7 +278,13 @@ function StagedAnswerResultSurfaceImpl({
reader nothing. */}
<UserQuestionBubble query={query} />
{answerState.kind === "ready" ? (
<AnswerCard state={answerState} verification={answerVerification} support={answerSupport} frame="bare">
<AnswerCard
state={answerState}
verification={answerVerification}
support={answerSupport}
frame="bare"
retrievalStatePlacement="content"
>
{answerProse}
</AnswerCard>
) : (
Expand All @@ -279,41 +293,41 @@ function StagedAnswerResultSurfaceImpl({
verification={answerVerification}
support={answerSupport}
frame="bare"
retrievalStatePlacement={answerState.kind === "stale_evidence" ? "content" : "header"}
// Navigate to the cited page — do not reuse onScopeDocument. That
// handler only replaces selectedDocumentIds and leaves the clinician
// on the answer screen with a silent filter change while the button
// is labelled "Open <source>, p. N".
onOpenSource={(sourceId, locator) => {
const href = citedDocumentHref(sourceId, locator, [...sources, ...(answer.sources ?? [])]);
if (href) router.push(href);
}}
onOpenSource={openAnswerStateSource}
>
{answerProse}
</AnswerCard>
)}

<AnswerUtilityActions
copied={copiedAnswer}
onCopy={onCopyAnswer}
warnings={renderModel.warnings}
pendingFeedback={pendingFeedback}
onSubmitFeedback={onSubmitFeedback}
/>

{showInlineSupportCard ? (
<AnswerSupportSummaryCard
priority={priority}
warnings={renderModel.warnings}
safetyTriggerRef={safetyTriggerRef}
safetyFindingsCount={safetyFindings.length}
onOpenSafetyFindings={safetyFindings.length > 0 ? openSafetyFindings : undefined}
pendingFeedback={pendingFeedback}
onSubmitFeedback={onSubmitFeedback}
// Chat framing: safety keeps its row, the other two collapse to
// one line of buttons rather than two 56px stacked rows.
density="compact"
/>
) : null}

{/* Follow-ups first, and on every width. They were wrapped in
`hidden sm:block`, so the most likely next tap on this surface
was desktop-only — the phone got the two "where else does this
appear" panels and none of the questions. Asking the next
question is the clinical step; browsing the library is not, so
the questions lead and the matches collapse to one line beneath
them (owner decision, 2026-08-26, "direction B"). */}
{/* Related library context belongs with the answer's evidence and
safety stack. Keep it collapsed and place it before the next-turn
prompts so it is not stranded beneath the composer. */}
{crossModeQueries?.length && onCrossModeSearch ? (
<CrossModeLinksSection queries={crossModeQueries} onModeSearch={onCrossModeSearch} variant="line" />
) : null}

{followUpSuggestions?.length && onPickFollowUpSuggestion ? (
<AnswerFollowUpSuggestions
suggestions={followUpSuggestions}
Expand All @@ -322,10 +336,6 @@ function StagedAnswerResultSurfaceImpl({
layout="rows"
/>
) : null}

{crossModeQueries?.length && onCrossModeSearch ? (
<CrossModeLinksSection queries={crossModeQueries} onModeSearch={onCrossModeSearch} variant="line" />
) : null}
</div>
</div>

Expand Down
Loading
Loading