From 69046af637d292bf400ec5c37925072f62e99be2 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:00:49 +0800 Subject: [PATCH 1/2] feat(answers): compact source and safety support UI --- .design-sync/config.json | 2 +- docs/design-system/COMPONENTS.md | 2 +- docs/design-system/adoption-manifest.json | 10 +- .../clinical-dashboard/answer-content.tsx | 143 ++++--- .../answer-result-surface.tsx | 64 +-- .../clinical-dashboard/cross-mode-links.tsx | 6 +- .../clinical-dashboard/evidence-panels.tsx | 381 +++++++----------- src/components/ui/answer-card.tsx | 11 +- src/components/ui/retrieval-state-banner.tsx | 4 +- tests/answer-source-marks.dom.test.tsx | 77 +++- tests/answer-source-rail.dom.test.tsx | 9 +- tests/answer-support-priority.dom.test.tsx | 19 +- tests/ui-smoke.spec.ts | 96 +++-- tests/ui-v2-answer-safety.dom.test.tsx | 28 ++ 14 files changed, 483 insertions(+), 369 deletions(-) diff --git a/.design-sync/config.json b/.design-sync/config.json index 790764d543..bbd29d78b9 100644 --- a/.design-sync/config.json +++ b/.design-sync/config.json @@ -20,7 +20,7 @@ ], "dtsPropsFor": { "AccessibleTable": "actionsHeader?: string; caption: string; clinicalOnly?: boolean; columnAlign?: AccessibleTableColumnAlign[]; columns?: string[] | null; compact?: boolean; densePreview?: boolean; dialogTitle?: string | null; expandOnMobile?: boolean; hidePreviewCaption?: boolean; hidePreviewRowCount?: boolean; lowConfidenceFallback?: ReactNode; markdown?: string | null; normalizedTable?: NormalizedAccessibleTable | null; numericColumns?: number[]; previewRows?: number; rowActions?: ReactNode[]; rows?: string[][] | null;", - "AnswerCard": "actions?: AnswerCardAction[]; children: ReactNode; className?: string; frame?: \"raised\" | \"bare\"; onOpenSource?: ((sourceId: string, locator?: string) => void) | ((sourceId: string, locator?: string) => void); provenance?: AnswerFooterProps; query?: string; state: { kind: \"ready\"; sourceCount: number; } | DegradedAnswerState; support: AnswerSupportStrength; verification: VerificationNoticeProps;", + "AnswerCard": "actions?: AnswerCardAction[]; children: ReactNode; className?: string; frame?: \"raised\" | \"bare\"; onOpenSource?: ((sourceId: string, locator?: string) => void) | ((sourceId: string, locator?: string) => void); provenance?: AnswerFooterProps; query?: string; retrievalStatePlacement?: \"header\" | \"content\"; state: { kind: \"ready\"; sourceCount: number; } | DegradedAnswerState; support: AnswerSupportStrength; verification: VerificationNoticeProps;", "AnswerFooter": "className?: string; generatedAt?: string | null; metadata?: SourceMetadataInput; provenance?: string | SourceMetadataInput; publisher?: string | null; reviewDate?: string | null; version?: string | null;", "AsyncButton": "about?: string; accessKey?: string; \"aria-activedescendant\"?: string; \"aria-atomic\"?: (boolean | \"true\" | \"false\"); \"aria-autocomplete\"?: \"none\" | \"list\" | \"inline\" | \"both\"; \"aria-braillelabel\"?: string; \"aria-brailleroledescription\"?: string; \"aria-busy\"?: (boolean | \"true\" | \"false\"); \"aria-checked\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-colcount\"?: number; \"aria-colindex\"?: number; \"aria-colindextext\"?: string; \"aria-colspan\"?: number; \"aria-controls\"?: string; \"aria-current\"?: boolean | \"true\" | \"false\" | \"page\" | \"step\" | \"location\" | \"date\" | \"time\"; \"aria-describedby\"?: string; \"aria-description\"?: string; \"aria-details\"?: string; \"aria-disabled\"?: (boolean | \"true\" | \"false\"); \"aria-dropeffect\"?: \"none\" | \"link\" | \"copy\" | \"execute\" | \"move\" | \"popup\"; \"aria-errormessage\"?: string; \"aria-expanded\"?: (boolean | \"true\" | \"false\"); \"aria-flowto\"?: string; \"aria-grabbed\"?: (boolean | \"true\" | \"false\"); \"aria-haspopup\"?: boolean | \"true\" | \"false\" | \"dialog\" | \"grid\" | \"listbox\" | \"menu\" | \"tree\"; \"aria-hidden\"?: (boolean | \"true\" | \"false\"); \"aria-invalid\"?: boolean | \"true\" | \"false\" | \"grammar\" | \"spelling\"; \"aria-keyshortcuts\"?: string; \"aria-label\"?: string; \"aria-labelledby\"?: string; \"aria-level\"?: number; \"aria-live\"?: \"off\" | \"assertive\" | \"polite\"; \"aria-modal\"?: (boolean | \"true\" | \"false\"); \"aria-multiline\"?: (boolean | \"true\" | \"false\"); \"aria-multiselectable\"?: (boolean | \"true\" | \"false\"); \"aria-orientation\"?: \"horizontal\" | \"vertical\"; \"aria-owns\"?: string; \"aria-placeholder\"?: string; \"aria-posinset\"?: number; \"aria-pressed\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-readonly\"?: (boolean | \"true\" | \"false\"); \"aria-relevant\"?: \"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"; \"aria-required\"?: (boolean | \"true\" | \"false\"); \"aria-roledescription\"?: string; \"aria-rowcount\"?: number; \"aria-rowindex\"?: number; \"aria-rowindextext\"?: string; \"aria-rowspan\"?: number; \"aria-selected\"?: (boolean | \"true\" | \"false\"); \"aria-setsize\"?: number; \"aria-sort\"?: \"none\" | \"ascending\" | \"descending\" | \"other\"; \"aria-valuemax\"?: number; \"aria-valuemin\"?: number; \"aria-valuenow\"?: number; \"aria-valuetext\"?: string; autoCapitalize?: \"off\" | \"none\" | \"on\" | \"sentences\" | \"words\" | \"characters\" | (string & {}); autoCorrect?: string; autoFocus?: boolean; autoSave?: string; busy: boolean; busyLabel: string; children: ReactNode; className?: string; color?: string; content?: string; contentEditable?: (boolean | \"true\" | \"false\") | \"inherit\" | \"plaintext-only\"; contextMenu?: string; dangerouslySetInnerHTML?: { __html: string | TrustedHTML; }; datatype?: string; defaultChecked?: boolean; defaultValue?: string | number | readonly string[]; dir?: string; disabled?: boolean; draggable?: (boolean | \"true\" | \"false\"); enterKeyHint?: \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\"; exportparts?: string; form?: string; formAction?: string | ((formData: FormData) => void | Promise); formEncType?: string; formMethod?: string; formNoValidate?: boolean; formTarget?: string; hidden?: boolean; id?: string; idleIcon?: ReactNode; inert?: boolean; inlist?: any; inputMode?: \"none\" | \"search\" | \"text\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"; is?: string; itemID?: string; itemProp?: string; itemRef?: string; itemScope?: boolean; itemType?: string; lang?: string; name?: string; nonce?: string; onAbort?: import(\"react\").ReactEventHandler; onAbortCapture?: import(\"react\").ReactEventHandler; onAnimationEnd?: import(\"react\").AnimationEventHandler; onAnimationEndCapture?: import(\"react\").AnimationEventHandler; onAnimationIteration?: import(\"react\").AnimationEventHandler; onAnimationIterationCapture?: import(\"react\").AnimationEventHandler; onAnimationStart?: import(\"react\").AnimationEventHandler; onAnimationStartCapture?: import(\"react\").AnimationEventHandler; onAuxClick?: import(\"react\").MouseEventHandler; onAuxClickCapture?: import(\"react\").MouseEventHandler; onBeforeInput?: import(\"react\").InputEventHandler; onBeforeInputCapture?: import(\"react\").InputEventHandler; onBeforeToggle?: import(\"react\").ToggleEventHandler; onBlur?: import(\"react\").FocusEventHandler; onBlurCapture?: import(\"react\").FocusEventHandler; onCanPlay?: import(\"react\").ReactEventHandler; onCanPlayCapture?: import(\"react\").ReactEventHandler; onCanPlayThrough?: import(\"react\").ReactEventHandler; onCanPlayThroughCapture?: import(\"react\").ReactEventHandler; onChange?: import(\"react\").ChangeEventHandler; onChangeCapture?: import(\"react\").ChangeEventHandler; onClick?: import(\"react\").MouseEventHandler; onClickCapture?: import(\"react\").MouseEventHandler; onCompositionEnd?: import(\"react\").CompositionEventHandler; onCompositionEndCapture?: import(\"react\").CompositionEventHandler; onCompositionStart?: import(\"react\").CompositionEventHandler; onCompositionStartCapture?: import(\"react\").CompositionEventHandler; onCompositionUpdate?: import(\"react\").CompositionEventHandler; onCompositionUpdateCapture?: import(\"react\").CompositionEventHandler; onContextMenu?: import(\"react\").MouseEventHandler; onContextMenuCapture?: import(\"react\").MouseEventHandler; onCopy?: import(\"react\").ClipboardEventHandler; onCopyCapture?: import(\"react\").ClipboardEventHandler; onCut?: import(\"react\").ClipboardEventHandler; onCutCapture?: import(\"react\").ClipboardEventHandler; onDoubleClick?: import(\"react\").MouseEventHandler; onDoubleClickCapture?: import(\"react\").MouseEventHandler; onDrag?: import(\"react\").DragEventHandler; onDragCapture?: import(\"react\").DragEventHandler; onDragEnd?: import(\"react\").DragEventHandler; onDragEndCapture?: import(\"react\").DragEventHandler; onDragEnter?: import(\"react\").DragEventHandler; onDragEnterCapture?: import(\"react\").DragEventHandler; onDragExit?: import(\"react\").DragEventHandler; onDragExitCapture?: import(\"react\").DragEventHandler; onDragLeave?: import(\"react\").DragEventHandler; onDragLeaveCapture?: import(\"react\").DragEventHandler; onDragOver?: import(\"react\").DragEventHandler; onDragOverCapture?: import(\"react\").DragEventHandler; onDragStart?: import(\"react\").DragEventHandler; onDragStartCapture?: import(\"react\").DragEventHandler; onDrop?: import(\"react\").DragEventHandler; onDropCapture?: import(\"react\").DragEventHandler; onDurationChange?: import(\"react\").ReactEventHandler; onDurationChangeCapture?: import(\"react\").ReactEventHandler; onEmptied?: import(\"react\").ReactEventHandler; onEmptiedCapture?: import(\"react\").ReactEventHandler; onEncrypted?: import(\"react\").ReactEventHandler; onEncryptedCapture?: import(\"react\").ReactEventHandler; onEnded?: import(\"react\").ReactEventHandler; onEndedCapture?: import(\"react\").ReactEventHandler; onError?: import(\"react\").ReactEventHandler; onErrorCapture?: import(\"react\").ReactEventHandler; onFocus?: import(\"react\").FocusEventHandler; onFocusCapture?: import(\"react\").FocusEventHandler; onGotPointerCapture?: import(\"react\").PointerEventHandler; onGotPointerCaptureCapture?: import(\"react\").PointerEventHandler; onInput?: import(\"react\").InputEventHandler; onInputCapture?: import(\"react\").InputEventHandler; onInvalid?: import(\"react\").ReactEventHandler; onInvalidCapture?: import(\"react\").ReactEventHandler; onKeyDown?: import(\"react\").KeyboardEventHandler; onKeyDownCapture?: import(\"react\").KeyboardEventHandler; onKeyPress?: import(\"react\").KeyboardEventHandler; onKeyPressCapture?: import(\"react\").KeyboardEventHandler; onKeyUp?: import(\"react\").KeyboardEventHandler; onKeyUpCapture?: import(\"react\").KeyboardEventHandler; onLoad?: import(\"react\").ReactEventHandler; onLoadCapture?: import(\"react\").ReactEventHandler; onLoadedData?: import(\"react\").ReactEventHandler; onLoadedDataCapture?: import(\"react\").ReactEventHandler; onLoadedMetadata?: import(\"react\").ReactEventHandler; onLoadedMetadataCapture?: import(\"react\").ReactEventHandler; onLoadStart?: import(\"react\").ReactEventHandler; onLoadStartCapture?: import(\"react\").ReactEventHandler; onLostPointerCapture?: import(\"react\").PointerEventHandler; onLostPointerCaptureCapture?: import(\"react\").PointerEventHandler; onMouseDown?: import(\"react\").MouseEventHandler; onMouseDownCapture?: import(\"react\").MouseEventHandler; onMouseEnter?: import(\"react\").MouseEventHandler; onMouseLeave?: import(\"react\").MouseEventHandler; onMouseMove?: import(\"react\").MouseEventHandler; onMouseMoveCapture?: import(\"react\").MouseEventHandler; onMouseOut?: import(\"react\").MouseEventHandler; onMouseOutCapture?: import(\"react\").MouseEventHandler; onMouseOver?: import(\"react\").MouseEventHandler; onMouseOverCapture?: import(\"react\").MouseEventHandler; onMouseUp?: import(\"react\").MouseEventHandler; onMouseUpCapture?: import(\"react\").MouseEventHandler; onPaste?: import(\"react\").ClipboardEventHandler; onPasteCapture?: import(\"react\").ClipboardEventHandler; onPause?: import(\"react\").ReactEventHandler; onPauseCapture?: import(\"react\").ReactEventHandler; onPlay?: import(\"react\").ReactEventHandler; onPlayCapture?: import(\"react\").ReactEventHandler; onPlaying?: import(\"react\").ReactEventHandler; onPlayingCapture?: import(\"react\").ReactEventHandler; onPointerCancel?: import(\"react\").PointerEventHandler; onPointerCancelCapture?: import(\"react\").PointerEventHandler; onPointerDown?: import(\"react\").PointerEventHandler; onPointerDownCapture?: import(\"react\").PointerEventHandler; onPointerEnter?: import(\"react\").PointerEventHandler; onPointerLeave?: import(\"react\").PointerEventHandler; onPointerMove?: import(\"react\").PointerEventHandler; onPointerMoveCapture?: import(\"react\").PointerEventHandler; onPointerOut?: import(\"react\").PointerEventHandler; onPointerOutCapture?: import(\"react\").PointerEventHandler; onPointerOver?: import(\"react\").PointerEventHandler; onPointerOverCapture?: import(\"react\").PointerEventHandler; onPointerUp?: import(\"react\").PointerEventHandler; onPointerUpCapture?: import(\"react\").PointerEventHandler; onProgress?: import(\"react\").ReactEventHandler; onProgressCapture?: import(\"react\").ReactEventHandler; onRateChange?: import(\"react\").ReactEventHandler; onRateChangeCapture?: import(\"react\").ReactEventHandler; onReset?: import(\"react\").ReactEventHandler; onResetCapture?: import(\"react\").ReactEventHandler; onScroll?: import(\"react\").UIEventHandler; onScrollCapture?: import(\"react\").UIEventHandler; onScrollEnd?: import(\"react\").UIEventHandler; onScrollEndCapture?: import(\"react\").UIEventHandler; onSeeked?: import(\"react\").ReactEventHandler; onSeekedCapture?: import(\"react\").ReactEventHandler; onSeeking?: import(\"react\").ReactEventHandler; onSeekingCapture?: import(\"react\").ReactEventHandler; onSelect?: import(\"react\").ReactEventHandler; onSelectCapture?: import(\"react\").ReactEventHandler; onStalled?: import(\"react\").ReactEventHandler; onStalledCapture?: import(\"react\").ReactEventHandler; onSubmit?: import(\"react\").SubmitEventHandler; onSubmitCapture?: import(\"react\").SubmitEventHandler; onSuspend?: import(\"react\").ReactEventHandler; onSuspendCapture?: import(\"react\").ReactEventHandler; onTimeUpdate?: import(\"react\").ReactEventHandler; onTimeUpdateCapture?: import(\"react\").ReactEventHandler; onToggle?: import(\"react\").ToggleEventHandler; onTouchCancel?: import(\"react\").TouchEventHandler; onTouchCancelCapture?: import(\"react\").TouchEventHandler; onTouchEnd?: import(\"react\").TouchEventHandler; onTouchEndCapture?: import(\"react\").TouchEventHandler; onTouchMove?: import(\"react\").TouchEventHandler; onTouchMoveCapture?: import(\"react\").TouchEventHandler; onTouchStart?: import(\"react\").TouchEventHandler; onTouchStartCapture?: import(\"react\").TouchEventHandler; onTransitionCancel?: import(\"react\").TransitionEventHandler; onTransitionCancelCapture?: import(\"react\").TransitionEventHandler; onTransitionEnd?: import(\"react\").TransitionEventHandler; onTransitionEndCapture?: import(\"react\").TransitionEventHandler; onTransitionRun?: import(\"react\").TransitionEventHandler; onTransitionRunCapture?: import(\"react\").TransitionEventHandler; onTransitionStart?: import(\"react\").TransitionEventHandler; onTransitionStartCapture?: import(\"react\").TransitionEventHandler; onVolumeChange?: import(\"react\").ReactEventHandler; onVolumeChangeCapture?: import(\"react\").ReactEventHandler; onWaiting?: import(\"react\").ReactEventHandler; onWaitingCapture?: import(\"react\").ReactEventHandler; onWheel?: import(\"react\").WheelEventHandler; onWheelCapture?: import(\"react\").WheelEventHandler; part?: string; popover?: \"\" | \"auto\" | \"manual\" | \"hint\"; popoverTarget?: string; popoverTargetAction?: \"toggle\" | \"show\" | \"hide\"; prefix?: string; property?: string; radioGroup?: string; rel?: string; resource?: string; results?: number; rev?: string; role?: import(\"react\").AriaRole; security?: string; slot?: string; spellCheck?: (boolean | \"true\" | \"false\"); style?: import(\"react\").CSSProperties; suppressContentEditableWarning?: boolean; suppressHydrationWarning?: boolean; tabIndex?: number; title?: string; translate?: \"yes\" | \"no\"; type?: \"submit\" | \"reset\" | \"button\"; typeof?: string; unselectable?: \"off\" | \"on\"; value?: string | number | readonly string[]; vocab?: string;", "Breadcrumb": "className?: string; items: Crumb[];", diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index cf48d70887..06090104d6 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -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 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 8ec62286e8..cdb361a700 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -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, diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 871c86ce67..89fe729f38 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -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, @@ -384,6 +386,8 @@ export function NaturalLanguageAnswer({ preformatted = false, sourceOnly, sourceOnlyVerificationState = "source_only", + answerState, + onOpenStateSource, bestSource, sources, sourceLinks, @@ -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 can render the high-yield emphasis. @@ -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[]; @@ -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; @@ -499,55 +511,66 @@ 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. */} -
- {sourceOnly ? ( -
- - {sourceOnlyNoticeOpen ? ( -
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 + // `
` 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} > -

{compactVerificationWordingFor(sourceOnlyVerificationState, "extractive")}

-
- ) : null} -
- ) : null} -
+ + Source-only + + · verify passages + + + + {sourceOnlyNoticeOpen ? ( +
+

{compactVerificationWordingFor(sourceOnlyVerificationState, "extractive")}

+
+ ) : null} + + ) : null} + {answerState?.kind === "stale_evidence" && onOpenStateSource ? ( + + ) : null} + + ) : null} -
- -
+ {showCopyAction ? ( +
+ +
+ ) : null} ); diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index c350df4297..44eae503bf 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -15,6 +15,7 @@ import { import { answerStateForAnswer } from "@/components/clinical-dashboard/answer-copy-payload"; import { AnswerSupportSummaryCard, + AnswerUtilityActions, answerSupportPriority, primaryVisualTable, SafetyFindingsListContent, @@ -199,6 +200,11 @@ 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 = ( ); /** * 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 (
@@ -270,7 +278,13 @@ function StagedAnswerResultSurfaceImpl({ reader nothing. */} {answerState.kind === "ready" ? ( - + {answerProse} ) : ( @@ -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 , p. N". - onOpenSource={(sourceId, locator) => { - const href = citedDocumentHref(sourceId, locator, [...sources, ...(answer.sources ?? [])]); - if (href) router.push(href); - }} + onOpenSource={openAnswerStateSource} > {answerProse} )} + + {showInlineSupportCard ? ( 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 ? ( + + ) : null} + {followUpSuggestions?.length && onPickFollowUpSuggestion ? ( ) : null} - - {crossModeQueries?.length && onCrossModeSearch ? ( - - ) : null}
diff --git a/src/components/clinical-dashboard/cross-mode-links.tsx b/src/components/clinical-dashboard/cross-mode-links.tsx index de00efc64c..d125156a5c 100644 --- a/src/components/clinical-dashboard/cross-mode-links.tsx +++ b/src/components/clinical-dashboard/cross-mode-links.tsx @@ -190,9 +190,9 @@ export function CrossModeLinksSection({ * Under an answer this block used to be a permanently expanded rail sitting * directly above a second, near-identical panel of mode matches. Two panels * asking the same question ("where else does this appear") read as one panel - * repeated, and neither is the clinician's next step — the follow-up questions - * above them are. So this collapses to a single row carrying a preview of what - * is inside, and opens to exactly the rail it always was. + * repeated. This collapses to a single row carrying a preview of what is inside, + * closes the answer's evidence/safety stack, and opens to exactly the rail it + * always was before the follow-up conversation begins. * * The preview names come from the resolved links, so the line can never * advertise a match the expanded rail does not list. diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index 67bee4f1c5..98e64b63b0 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -7,6 +7,7 @@ import { CircleAlert, CircleCheck, ChevronDown, + ChevronRight, ClipboardCheck, Copy, ExternalLink, @@ -42,6 +43,8 @@ import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text" import { SourceActionRow, logCitationOpen } from "@/components/clinical-dashboard/source-actions"; import { clinicalDivider, + chatActionRow, + chatMicroAction, cn, codeText, EmptyState, @@ -170,263 +173,175 @@ export function answerSupportPriority( } /** - * The answer-level strip under the prose. + * Quiet answer-level utilities under the source rail. * - * Since the source rail and drawer took over every per-source surface, this card - * carries only what belongs to the answer rather than to any one document: the - * safety/priority row, the evidence gaps, and the feedback control. - * - * The safety row is not optional chrome. `answerSupportPriority` returns a - * safety-findings priority ahead of everything else, and the trigger below is - * the only route to the safety-critical findings sheet — so this card renders - * whenever `priority` is set, and removing it would remove that route. + * Evidence gaps and feedback belong to the answer rather than one document, but + * they are utilities rather than safety findings. Keeping them beside Copy with + * sources stops the safety panel's warning chrome from colouring neutral actions. */ -export function AnswerSupportSummaryCard({ - priority, +export function AnswerUtilityActions({ + copied, + onCopy, warnings = [], - safetyTriggerRef, - safetyFindingsCount = 0, - onOpenSafetyFindings, pendingFeedback = null, onSubmitFeedback, - density = "comfortable", }: { - priority: AnswerSupportPriority | null; + copied: boolean; + onCopy: () => void; /** Answer-level evidence gaps (`renderModel.warnings`); they belong to no single source. */ warnings?: string[]; - safetyTriggerRef?: RefObject; - safetyFindingsCount?: number; - onOpenSafetyFindings?: () => void; pendingFeedback?: AnswerFeedbackType | null; onSubmitFeedback?: (feedbackType: AnswerFeedbackType) => void; - /** - * `"compact"` is the chat-framed answer's density. Safety keeps its full row — - * it is the one thing here that must not be tucked into a chip — while the - * evidence-gaps and report rows collapse from two 56px two-line rows into one - * line of small buttons. Same controls, same ids, same routes; roughly 90px of - * phone scroll returned under a four-line answer. - */ - density?: "comfortable" | "compact"; }) { const [gapsOpen, setGapsOpen] = useState(false); const [feedbackOpen, setFeedbackOpen] = useState(false); - const compact = density === "compact"; - const supportRowCount = Number(warnings.length > 0) + Number(Boolean(onSubmitFeedback)); - const supportButtonClass = - "grid min-h-[56px] grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2.5 px-3 py-2 text-left transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]"; - // min-h-12 (48px), not min-h-11: 44px reintroduced a known sub-pixel rounding - // flake in `ui-smoke`. See AGENTS.md "External skill precedence". - const compactChipClass = - "inline-flex min-h-12 items-center gap-1.5 rounded-md px-2 text-xs font-semibold text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text-heading)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]"; - const safetyInteractive = Boolean(onOpenSafetyFindings && safetyFindingsCount > 0); - const gapsDetail = - warnings.length > 0 && gapsOpen ? ( -
- {warnings.map((warning, index) => ( -

- {warning} -

- ))} -
- ) : null; - const feedbackDetail = - onSubmitFeedback && feedbackOpen ? ( -
- -
- ) : null; - return ( -
- {priority ? ( - safetyInteractive ? ( +
+
+ + {warnings.length > 0 ? ( - ) : ( -
-
+ {warnings.length > 0 && gapsOpen ? ( +
+ {warnings.map((warning, index) => ( +

- {priority.tone === "caution" ? ( -

-

{priority.title}

-

{priority.detail}

-
- {priority.sourceLabel ? ( - - {priority.sourceLabel} - - ) : null} -
- ) + {warning} +

+ ))} +
) : null} + {onSubmitFeedback && feedbackOpen ? ( +
+ +
+ ) : null} +
+ ); +} - {supportRowCount > 0 ? ( - compact ? ( - // One line of small buttons instead of two stacked 56px rows. The - // detail each one opens is unchanged and still carries the same id, so - // `aria-controls`, the feedback route and the gap wording all survive - // the density change. -
-
- {warnings.length > 0 ? ( - - ) : null} - {onSubmitFeedback ? ( - +export function AnswerSupportSummaryCard({ + priority, + safetyTriggerRef, + safetyFindingsCount = 0, + onOpenSafetyFindings, +}: { + priority: AnswerSupportPriority | null; + safetyTriggerRef?: RefObject; + safetyFindingsCount?: number; + onOpenSafetyFindings?: () => void; +}) { + // The safety row is not optional chrome. `answerSupportPriority` returns a + // safety finding ahead of everything else, and this trigger is the only route + // to the safety-critical findings sheet. + if (!priority) return null; + const safetyInteractive = Boolean(onOpenSafetyFindings && safetyFindingsCount > 0); + const rowClass = "grid min-h-12 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 px-2.5 py-1.5 text-left"; + + return ( +
+ {safetyInteractive ? ( +
- {gapsDetail} - {feedbackDetail} -
- ) : ( -
- {warnings.length > 0 ? ( -
- - {gapsDetail} -
- ) : null} - {onSubmitFeedback ? ( -
- - {feedbackDetail} -
- ) : null} + {priority.detail} + + + + {safetyFindingsCount} + + + ) : ( +
+ +
+

{priority.title}

+

{priority.detail}

- ) - ) : null} + {priority.sourceLabel ? ( + + {priority.sourceLabel} + + ) : null} +
+ )}
); } diff --git a/src/components/ui/answer-card.tsx b/src/components/ui/answer-card.tsx index f5a8e5af2d..af0ff782a8 100644 --- a/src/components/ui/answer-card.tsx +++ b/src/components/ui/answer-card.tsx @@ -93,6 +93,13 @@ type AnswerCardBase = { * around them goes. Adopted for the answer surface 2026-08-25. */ frame?: "raised" | "bare"; + /** + * Keeps the shared card safe by default while allowing the live answer + * surface to place source-currency controls beside its source-only disclosure. + * The content owner must render the same state and source route when it opts + * into `"content"`. + */ + retrievalStatePlacement?: "header" | "content"; className?: string; }; @@ -117,6 +124,7 @@ export function AnswerCard({ actions, onOpenSource, frame = "raised", + retrievalStatePlacement = "header", className, }: AnswerCardProps) { const bare = frame === "bare"; @@ -194,7 +202,8 @@ export function AnswerCard({ * `onOpenSource` stays required for every degraded state (DECISIONS §Q1): a * degraded answer must remain re-verifiable whether or not a banner renders. */} - {state.kind === "stale_evidence" || state.kind === "partial_retrieval" ? ( + {retrievalStatePlacement === "header" && + (state.kind === "stale_evidence" || state.kind === "partial_retrieval") ? (
setOpen((current) => !current)} aria-expanded={open} aria-controls={detailId} - className="inline-flex min-h-tap max-w-full items-center gap-1.5 rounded-full px-2.5 py-1 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)]" + className="inline-flex min-h-compact-meta max-w-full items-center gap-1 rounded-full 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)]" > Review due diff --git a/tests/answer-source-marks.dom.test.tsx b/tests/answer-source-marks.dom.test.tsx index 6d0755dcc1..0e5e45c7db 100644 --- a/tests/answer-source-marks.dom.test.tsx +++ b/tests/answer-source-marks.dom.test.tsx @@ -367,11 +367,86 @@ describe("source-only disclosure", () => { expect(disclosure).toHaveTextContent("Source-only"); expect(disclosure).not.toHaveTextContent("Copied from cited sources without model synthesis"); expect(disclosure.className).toContain("text-2xs"); - expect(disclosure.parentElement?.className).toContain("py-1"); + expect(disclosure.parentElement?.className).not.toContain("py-1"); await user.click(within(disclosure).getByRole("button", { name: /Source-only/ })); expect(disclosure).toHaveTextContent( "Copied from cited sources without model synthesis. Sources could not be shown to support every claim. Check each dose, number, timing and threshold before acting.", ); }); + + it("places review-due status beside Source-only and keeps the cited-page route", async () => { + const user = userEvent.setup(); + const onOpenStateSource = vi.fn(); + render( + , + ); + + const row = screen.getByTestId("answer-source-status-row"); + expect(within(row).getByTestId("source-only-disclosure")).toBeInTheDocument(); + const reviewDue = within(row).getByTestId("retrieval-state-stale-toggle"); + expect(reviewDue).toHaveTextContent(/Review due\s*· 1 source/); + await user.click(reviewDue); + await user.click(screen.getByRole("button", { name: "Open Clozapine monitoring protocol, p. 8" })); + expect(onOpenStateSource).toHaveBeenCalledWith("doc-chunk-a", "p. 8"); + }); + + it("keeps review-due status in the source row when the answer is synthesized", () => { + render( + , + ); + + const row = screen.getByTestId("answer-source-status-row"); + expect(within(row).queryByTestId("source-only-disclosure")).not.toBeInTheDocument(); + expect(within(row).getByTestId("retrieval-state-stale-toggle")).toHaveTextContent(/Review due\s*· 1 source/); + }); }); diff --git a/tests/answer-source-rail.dom.test.tsx b/tests/answer-source-rail.dom.test.tsx index d8c019065a..4d32b87581 100644 --- a/tests/answer-source-rail.dom.test.tsx +++ b/tests/answer-source-rail.dom.test.tsx @@ -48,7 +48,7 @@ vi.mock("@/components/clinical-dashboard/signed-image", () => ({ ), })); -import { AnswerSupportSummaryCard } from "@/components/clinical-dashboard/evidence-panels"; +import { AnswerUtilityActions } from "@/components/clinical-dashboard/evidence-panels"; import { AnswerSourceDrawer } from "@/components/clinical-dashboard/answer-source-drawer"; import { AnswerSourceRail } from "@/components/clinical-dashboard/answer-source-rail"; import { @@ -394,11 +394,12 @@ describe("answer source drawer", () => { }); describe("evidence gaps stay answer-level", () => { - it("lists the answer's warnings on the card rather than against any one source", async () => { + it("lists the answer's warnings with the quiet answer utilities rather than against any one source", async () => { const user = userEvent.setup(); render( - , diff --git a/tests/answer-support-priority.dom.test.tsx b/tests/answer-support-priority.dom.test.tsx index 8c2b1c8595..e608d85f63 100644 --- a/tests/answer-support-priority.dom.test.tsx +++ b/tests/answer-support-priority.dom.test.tsx @@ -4,7 +4,7 @@ import { resolve } from "node:path"; import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { AnswerSupportSummaryCard, answerSupportPriority } from "@/components/clinical-dashboard/evidence-panels"; +import { AnswerUtilityActions, answerSupportPriority } from "@/components/clinical-dashboard/evidence-panels"; import type { AnswerState } from "@/components/ui/answer-state"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import type { RagAnswer } from "@/lib/types"; @@ -134,26 +134,27 @@ describe("answerSupportPriority · Review source match", () => { }); }); -describe("AnswerSupportSummaryCard · feedback on a clean answer", () => { - it("still hosts Report a problem when priority and warnings are both empty", () => { +describe("AnswerUtilityActions · feedback on a clean answer", () => { + it("keeps Report a problem beside Copy with sources when priority and warnings are both empty", () => { render( - undefined} warnings={[]} pendingFeedback={null} onSubmitFeedback={() => undefined} />, ); + expect(screen.getByRole("button", { name: "Copy answer with source status" })).toBeInTheDocument(); expect(screen.getByTestId("answer-feedback-trigger")).toBeInTheDocument(); }); - it("the answer surface mounts that card for feedback even without priority or warnings", () => { + it("the answer surface mounts utilities independently and reserves the support card for a real priority", () => { const surface = readFileSync( resolve(process.cwd(), "src/components/clinical-dashboard/answer-result-surface.tsx"), "utf8", ); - expect(surface).toMatch( - /showInlineSupportCard = Boolean\(priority \|\| renderModel\.warnings\.length > 0 \|\| onSubmitFeedback\)/, - ); + expect(surface).toContain(" { await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: new URL(page.url()).origin, }); - const copyWithSources = plainAnswer.getByRole("button", { name: "Copy answer with source status" }); + const copyWithSources = page.getByRole("button", { name: "Copy answer with source status" }); await expect(copyWithSources).toBeVisible(); await expectMinTouchTarget(copyWithSources); await copyWithSources.click(); @@ -2027,13 +2027,13 @@ test.describe("PsychSift UI smoke coverage", () => { } await expect(plainAnswer.getByRole("button", { name: "More answer actions" })).toHaveCount(0); - // The support card is the answer-level strip now: priority/safety, evidence - // gaps, and feedback. Everything per-source moved to the rail and drawer, so - // the clinical-notes and evidence rows are gone rather than relocated. + // The support card is one focused priority/safety row. Answer-level utilities + // sit with Copy with sources instead of visually inheriting warning chrome. const supportCard = page.getByTestId("answer-support-card"); await expect(supportCard).toBeVisible(); await expect(supportCard).toContainText(/Safety findings|Priority|FBC\/ANC|Myocarditis|Metabolic/i); - await expect(supportCard).toContainText("Report a problem"); + await expect(supportCard).not.toContainText("Report a problem"); + await expect(supportCard).not.toContainText("Evidence gaps"); await expect(supportCard.getByTestId("answer-clinical-notes-trigger")).toHaveCount(0); await expect(supportCard.getByTestId("answer-evidence-trigger")).toHaveCount(0); await expect(page.getByTestId("safety-findings-panel")).toHaveCount(0); @@ -2118,32 +2118,36 @@ test.describe("PsychSift UI smoke coverage", () => { await expect(page.getByTestId("evidence-support-panel")).toHaveCount(0); // Document order on the answer surface: question, prose, the rail that cites - // it, then the answer-level support strip. + // it, the quiet utilities, then the focused support strip. const hierarchy = await page.evaluate(() => { const question = document.querySelector('[data-testid="user-question-bubble"]'); const plainAnswer = document.querySelector('[data-testid="plain-answer-response"]'); const rail = document.querySelector('[data-testid="answer-source-rail"]'); + const utilities = document.querySelector('section[aria-label="Answer utilities"]'); const support = document.querySelector('[data-testid="answer-support-card"]'); return { questionTop: question?.getBoundingClientRect().top ?? 9999, plainAnswerTop: plainAnswer?.getBoundingClientRect().top ?? 9999, railTop: rail?.getBoundingClientRect().top ?? 9999, + utilitiesTop: utilities?.getBoundingClientRect().top ?? 9999, supportTop: support?.getBoundingClientRect().top ?? 9999, }; }); expect(hierarchy.questionTop).toBeLessThan(hierarchy.plainAnswerTop); expect(hierarchy.plainAnswerTop).toBeLessThan(hierarchy.railTop); - expect(hierarchy.railTop).toBeLessThan(hierarchy.supportTop); + expect(hierarchy.railTop).toBeLessThan(hierarchy.utilitiesTop); + expect(hierarchy.utilitiesTop).toBeLessThan(hierarchy.supportTop); - // Evidence gaps and feedback are answer-level, so they stayed on the card - // rather than following the per-source content into the drawer. - const feedbackTrigger = supportCard.getByTestId("answer-feedback-trigger"); + // Evidence gaps and feedback remain answer-level, but use the same quiet + // utility treatment as Copy with sources rather than the safety panel. + const utilities = page.getByLabel("Answer utilities"); + const feedbackTrigger = utilities.getByTestId("answer-feedback-trigger"); await expect(feedbackTrigger).toBeVisible(); await expectMinTouchTarget(feedbackTrigger); await feedbackTrigger.click(); - await expect(supportCard.getByTestId("answer-review-panel")).toBeVisible(); + await expect(utilities.getByTestId("answer-review-panel")).toBeVisible(); await feedbackTrigger.click(); - await expect(supportCard.getByTestId("answer-review-panel")).toHaveCount(0); + await expect(utilities.getByTestId("answer-review-panel")).toHaveCount(0); await expect(page.getByTestId("answer-section-heading")).toHaveText("Answer"); await expect(page.getByTestId("answer-header-actions")).toHaveCount(0); @@ -2208,7 +2212,7 @@ test.describe("PsychSift UI smoke coverage", () => { await visibleAnswerSubmitButton(page).click(); const answerSurface = page.getByTestId("plain-answer-response"); await expect(answerSurface).toBeVisible({ timeout: uiAssertionTimeoutMs }); - await answerSurface.getByRole("button", { name: "Copy answer with source status" }).click(); + await page.getByRole("button", { name: "Copy answer with source status" }).click(); const copiedText = await page.evaluate(() => navigator.clipboard.readText()); expect(copiedText).toContain("toxicity safety-net review"); @@ -2300,8 +2304,7 @@ test.describe("PsychSift UI smoke coverage", () => { expect(seenTables.sort()).toEqual(["ANC actions", "Metabolic monitoring"]); expect(sawCaveat).toBe(true); - const answerSurface = page.getByTestId("plain-answer-response"); - await answerSurface.getByRole("button", { name: "Copy answer with source status" }).click(); + await page.getByRole("button", { name: "Copy answer with source status" }).click(); const copiedText = await page.evaluate(() => navigator.clipboard.readText()); expect(copiedText).toContain("ANC range | [header missing] | Action"); expect(copiedText).toContain("1.0–1.5 × 10⁹/L | [blank] | Increase monitoring"); @@ -2683,9 +2686,8 @@ test.describe("PsychSift UI smoke coverage", () => { await visibleAnswerSubmitButton(page).click(); await expect(page.getByTestId("plain-answer-response")).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId("answer-streaming")).toHaveCount(0); - // The library matches are one collapsed line under the answer now (owner - // decision, 2026-08-26, "direction B"): the follow-up questions lead, and - // this opens on demand. Still asserted end to end rather than dropped — + // The library matches are one collapsed line in the answer's evidence stack + // and open on demand. Still asserted end to end rather than dropped — // open it and the same two links are there, at full tap size. const relatedRegion = page.getByRole("region", { name: "Related pages in other modes" }); const relatedTrigger = relatedRegion.getByTestId("cross-mode-links-line-trigger"); @@ -3012,9 +3014,9 @@ test.describe("PsychSift UI smoke coverage", () => { const followUpBox = await followUps.boundingBox(); expect(stripBox).toBeTruthy(); expect(followUpBox).toBeTruthy(); - // Questions above matches, not below. Asking the next question is the - // clinical step; browsing the library is not. - expect(followUpBox!.y).toBeLessThan(stripBox!.y); + // Library context now closes the evidence/safety stack before the next + // conversational turn begins. + expect(stripBox!.y).toBeLessThan(followUpBox!.y); } const medicationLink = strip.getByRole("link", { name: "Clozapine", exact: true }); @@ -3199,7 +3201,9 @@ test.describe("PsychSift UI smoke coverage", () => { expect(disclosureButtonBox!.height).toBeGreaterThanOrEqual(40); expect(disclosureBox!.height).toBeLessThanOrEqual(42); expect(disclosureBox!.y - (proseBox!.y + proseBox!.height)).toBeGreaterThanOrEqual(7); - expect(railBox!.y - (disclosureBox!.y + disclosureBox!.height)).toBeGreaterThanOrEqual(7); + const disclosureToRailGap = railBox!.y - (disclosureBox!.y + disclosureBox!.height); + expect(disclosureToRailGap).toBeGreaterThanOrEqual(3); + expect(disclosureToRailGap).toBeLessThanOrEqual(6); await page.emulateMedia({ reducedMotion: "reduce" }); await sourceOnlyButton.focus(); @@ -3208,7 +3212,9 @@ test.describe("PsychSift UI smoke coverage", () => { await expect(sourceOnlyDisclosure).toContainText( "Copied from cited sources without model synthesis. Sources could not be shown to support every claim. Check each dose, number, timing and threshold before acting.", ); - await expect(page.locator("#source-only-disclosure-detail")).toHaveCSS("animation-name", "none"); + const sourceOnlyDetailId = await sourceOnlyButton.getAttribute("aria-controls"); + expect(sourceOnlyDetailId).toBeTruthy(); + await expect(page.locator(`[id="${sourceOnlyDetailId}"]`)).toHaveCSS("animation-name", "none"); await page.emulateMedia({ forcedColors: "active", reducedMotion: "reduce" }); await expect(sourceOnlyDisclosure).toBeVisible(); @@ -3248,13 +3254,14 @@ test.describe("PsychSift UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); - test("review-due sources collapse into a compact expandable tab", async ({ page }, testInfo) => { + test("review-due source-only answers share one compact expandable status row", async ({ page }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); await mockDemoApi(page, { answerOverride: (query, documentId, documentIds) => { const base = demoAnswer(query, documentId, documentIds); return { ...base, + answerQualityTier: "source_only" as const, sources: base.sources.map((source, index) => index === 0 ? { @@ -3276,21 +3283,58 @@ test.describe("PsychSift UI smoke coverage", () => { await fillVisibleQuestionInput(page, "What lithium toxicity symptoms need review?"); await visibleAnswerSubmitButton(page).click(); + const statusRow = page.getByTestId("answer-source-status-row"); + const sourceOnlyDisclosure = statusRow.getByTestId("source-only-disclosure"); const reviewDueTab = page.getByTestId("retrieval-state-stale-toggle"); + await expect(statusRow).toBeVisible({ timeout: uiAssertionTimeoutMs }); + await expect(sourceOnlyDisclosure).toBeVisible(); await expect(reviewDueTab).toBeVisible({ timeout: uiAssertionTimeoutMs }); await expect(reviewDueTab).toContainText("Review due"); await expect(reviewDueTab).toHaveAttribute("aria-expanded", "false"); const reviewDuePanel = page.locator(`#${await reviewDueTab.getAttribute("aria-controls")}`); await expect(reviewDuePanel).toBeHidden(); await expect(page.getByTestId("retrieval-state-overdue-row")).toBeHidden(); - await expectNoPageHorizontalOverflow(page); + for (const viewport of [ + { width: 320, height: 844 }, + { width: 390, height: 844 }, + { width: 639, height: 900 }, + { width: 768, height: 1024 }, + { width: 1440, height: 900 }, + { width: 1920, height: 1080 }, + ]) { + await page.setViewportSize(viewport); + const statusBox = await statusRow.boundingBox(); + const sourceOnlyBox = await sourceOnlyDisclosure.boundingBox(); + const reviewDueBox = await reviewDueTab.boundingBox(); + expect(statusBox).toBeTruthy(); + expect(sourceOnlyBox).toBeTruthy(); + expect(reviewDueBox).toBeTruthy(); + expect(Math.abs(sourceOnlyBox!.y - reviewDueBox!.y)).toBeLessThanOrEqual(1); + expect(statusBox!.height).toBeLessThanOrEqual(42); + expect(sourceOnlyBox!.height).toBeLessThanOrEqual(42); + expect(reviewDueBox!.height).toBeLessThanOrEqual(42); + await expectNoPageHorizontalOverflow(page); + } + + await page.setViewportSize({ width: 390, height: 844 }); await testInfo.attach("review-due-tab-phone", { body: await page.screenshot({ fullPage: true }), contentType: "image/png", }); - await reviewDueTab.click(); + await page.emulateMedia({ forcedColors: "active", reducedMotion: "reduce" }); + const sourceOnlyButton = sourceOnlyDisclosure.getByRole("button", { name: /Source-only/ }); + await sourceOnlyButton.focus(); + await expect(sourceOnlyButton).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(sourceOnlyButton).toHaveAttribute("aria-expanded", "true"); + await page.keyboard.press("Enter"); + await expect(sourceOnlyButton).toHaveAttribute("aria-expanded", "false"); + + await reviewDueTab.focus(); + await expect(reviewDueTab).toBeFocused(); + await page.keyboard.press("Enter"); await expect(reviewDueTab).toHaveAttribute("aria-expanded", "true"); await expect(reviewDuePanel).toBeVisible(); await expect(page.getByTestId("retrieval-state-overdue-row")).toHaveCount(1); diff --git a/tests/ui-v2-answer-safety.dom.test.tsx b/tests/ui-v2-answer-safety.dom.test.tsx index 927fe1e51a..79ce2bb5a1 100644 --- a/tests/ui-v2-answer-safety.dom.test.tsx +++ b/tests/ui-v2-answer-safety.dom.test.tsx @@ -682,6 +682,34 @@ describe("AnswerCard", () => { expect(onOpenSource).toHaveBeenCalledWith("doc-1", "p. 12"); }); + it("allows the live content owner to relocate stale status without duplicating it", () => { + render( + +

Titrate slowly.

+
, + ); + + expect(screen.queryByTestId("retrieval-state-banner")).not.toBeInTheDocument(); + expect(screen.getByTestId("verification-notice")).toHaveAttribute("data-state", "stale_evidence"); + }); + it("wires every declared action", async () => { const onActivate = vi.fn(); render( From 888649d7d45576d632e743e764fb429868652b94 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:33:44 +0800 Subject: [PATCH 2/2] docs(review): record answer surface readiness --- ...d4e8be016b6d181c434e996a4a21e5d788b2862b12296656a21.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/46928e14c627bd4e8be016b6d181c434e996a4a21e5d788b2862b12296656a21.record.md diff --git a/docs/branch-review-records/46928e14c627bd4e8be016b6d181c434e996a4a21e5d788b2862b12296656a21.record.md b/docs/branch-review-records/46928e14c627bd4e8be016b6d181c434e996a4a21e5d788b2862b12296656a21.record.md new file mode 100644 index 0000000000..27a7bb7ab7 --- /dev/null +++ b/docs/branch-review-records/46928e14c627bd4e8be016b6d181c434e996a4a21e5d788b2862b12296656a21.record.md @@ -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 |