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 docs/design-system/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1057,7 +1057,7 @@ This generated snapshot is a local source-derived inventory. It does not assert
| `SearchField` | controls | yes | yes | no | yes | no | 0 |
| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 9 |
| `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 |
| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 32 |
| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 33 |
| `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 |
| `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 4 |
| `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 |
Expand Down
3 changes: 3 additions & 0 deletions docs/design-system/adoption-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1717,6 +1717,7 @@
"src/components/clinical-dashboard/answer-source-drawer.tsx",
"src/components/clinical-dashboard/clinical-ask-workspace.tsx",
"src/components/clinical-dashboard/dashboard-shell.tsx",
"src/components/clinical-dashboard/evidence-panels.tsx",
"src/components/clinical-dashboard/guide-dialog.tsx",
"src/components/clinical-dashboard/image-lightbox.tsx",
"src/components/clinical-dashboard/master-search-header.tsx",
Expand Down Expand Up @@ -1752,6 +1753,7 @@
"src/components/clinical-dashboard/answer-source-drawer.tsx",
"src/components/clinical-dashboard/clinical-ask-workspace.tsx",
"src/components/clinical-dashboard/dashboard-shell.tsx",
"src/components/clinical-dashboard/evidence-panels.tsx",
"src/components/clinical-dashboard/guide-dialog.tsx",
"src/components/clinical-dashboard/image-lightbox.tsx",
"src/components/clinical-dashboard/master-search-header.tsx",
Expand Down Expand Up @@ -1783,6 +1785,7 @@
"testFiles": [
"tests/accessible-table.dom.test.tsx",
"tests/answer-source-rail.dom.test.tsx",
"tests/answer-support-priority.dom.test.tsx",
"tests/caring-contacts-overlay-host.dom.test.tsx",
"tests/caring-contacts-overlay-trigger.dom.test.tsx",
"tests/caring-contacts-plan-wizard.dom.test.tsx",
Expand Down
134 changes: 106 additions & 28 deletions src/components/clinical-dashboard/evidence-panels.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import Link from "next/link";
import { type KeyboardEvent as ReactKeyboardEvent, type RefObject, useId, useRef, useState } from "react";
import { type KeyboardEvent as ReactKeyboardEvent, type RefObject, useCallback, useId, useRef, useState } from "react";
import {
Activity,
CircleAlert,
Expand All @@ -25,6 +25,7 @@ import {
ThumbsUp,
} from "lucide-react";

import { Sheet } from "@/components/ui/sheet";
import { type AnswerFeedbackType } from "@/lib/answer-feedback";
import { ClinicalOutputPanel } from "@/components/clinical-dashboard/output-panel";
import {
Expand Down Expand Up @@ -192,6 +193,30 @@ export function AnswerUtilityActions({
onSubmitFeedback?: (feedbackType: AnswerFeedbackType) => void;
}) {
const [feedbackOpen, setFeedbackOpen] = useState(false);
const feedbackTriggerRef = useRef<HTMLButtonElement>(null);
const closeFeedback = useCallback(() => setFeedbackOpen(false), []);
/**
* Submitting closes the sheet, because the sheet is where the outcome is NOT.
*
* `ClinicalDashboard.submitAnswerFeedback` reports every outcome — success,
* network failure, an expired feedback token, and synthetic demo answers —
* through the page-level `actionNotice` alone, which renders outside this
* portaled modal. While the sheet is open that notice is behind the backdrop
* and the page under it is inert, so the reader sees a tap that did nothing.
* The demo and expired-token paths are the worst of it: both return before
* `pendingFeedback` is ever set, so there is not even a spinner to explain the
* silence.
*
* This did not arise until the list became a modal (2026-09-02). As an in-flow
* disclosure the notice was simply visible above it, so nothing had to close.
*/
const submitFeedbackAndClose = useCallback(
(feedbackType: AnswerFeedbackType) => {
onSubmitFeedback?.(feedbackType);
setFeedbackOpen(false);
},
[onSubmitFeedback],
);
return (
<section className="max-w-[68ch]" aria-label="Answer utilities">
{/* Copy sits left; the two verdict controls sit right, as the approved
Expand Down Expand Up @@ -231,11 +256,13 @@ export function AnswerUtilityActions({
negative, because an unlabelled negative tells a reviewer
nothing about which claim failed. */}
<button
ref={feedbackTriggerRef}
id="answer-feedback-trigger"
data-testid="answer-feedback-trigger"
type="button"
onClick={() => setFeedbackOpen((current) => !current)}
className={cn(chatMicroAction, "min-w-12 justify-center px-2")}
aria-haspopup="dialog"
aria-expanded={feedbackOpen}
aria-controls={feedbackOpen ? "answer-feedback-detail" : undefined}
aria-label="Report a problem with this answer"
Expand All @@ -245,21 +272,50 @@ export function AnswerUtilityActions({
</span>
) : null}
</div>
{/* This panel deliberately does NOT scroll itself into view when it
opens, though it can open partly behind the fixed phone composer.
Every scripted scroll that would clear it is a downward scroll, and a
downward scroll is what hides the phone chrome; closing the panel then
shrinks the page back to the top without generating the upward travel
that reveals the chrome again, so the composer stays gone at the top
of the page. `ui-smoke`'s critical answer journey caught exactly that.
The options below the fold are reachable by scrolling — measured at
390x844, the last one clears the composer by 180px at full scroll —
so the remaining problem is that the list LOOKS complete, which wants
a sheet rather than a page scroll. Tracked, not bodged. */}
{onSubmitFeedback && feedbackOpen ? (
<div id="answer-feedback-detail" className="px-2 pb-2">
<AnswerFeedbackPanel pending={pendingFeedback} onSubmit={onSubmitFeedback} tone="problems" />
</div>
{/* A Sheet, not an in-flow disclosure. As a disclosure this opened partly
behind the fixed phone composer, and it could not scroll itself clear:
every scripted scroll that would do it is a DOWNWARD scroll, downward
scroll is what hides the phone chrome, and closing the panel then
shrank the page back to the top without the upward travel that reveals
the chrome again — so the composer stayed gone. `ui-smoke`'s critical
answer journey caught exactly that, twice. The options were reachable
by scrolling (measured 390x844: the last one cleared the composer by
180px at full scroll), so the defect was never reachability — it was
that the list LOOKED complete when it was not.

A sheet answers both at once: it owns its own scrollport above the
composer, so nothing is clipped and no page scroll is needed, and it is
the same overlay the safety-findings control beside it already opens.
`mobilePlacement` defaults to "bottom", so this rises from the bottom
on a phone and is a centred dialog from `sm:` up. */}
{onSubmitFeedback ? (
<Sheet
id="answer-feedback-detail"
open={feedbackOpen}
onClose={closeFeedback}
returnFocusRef={feedbackTriggerRef}
title={answerFeedbackQuestion.problems.title}
description={answerFeedbackQuestion.problems.description}
closeLabel="Close report a problem"
testId="answer-feedback-sheet"
headerLeading={
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-wash)] text-[color:var(--text-muted)]">
<ThumbsDown aria-hidden="true" className="h-3.5 w-3.5" />
</span>
}
headerClassName="gap-2 p-2.5 sm:p-3"
titleClassName="text-base-minus leading-5"
contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-lg"
>
{/* The sheet header already asks the question, so the panel does not
ask it again. */}
<AnswerFeedbackPanel
pending={pendingFeedback}
onSubmit={submitFeedbackAndClose}
tone="problems"
chrome="bare"
/>
</Sheet>
) : null}
</section>
);
Expand Down Expand Up @@ -1193,10 +1249,25 @@ function feedbackToneClass(tone: "success" | "warning" | "danger" | "neutral") {
return toneNeutral;
}

/** The question the panel asks, so a Sheet header can ask it instead. */
export const answerFeedbackQuestion = {
problems: {
title: "What is wrong with this answer?",
description:
"Name the fault so a reviewer can find it. This sends feedback for review; it does not change the answer.",
},
full: {
title: "Is the answer supported?",
description:
"Record whether the linked evidence supports the answer. This sends feedback for review; it does not change the answer.",
},
} as const;

export function AnswerFeedbackPanel({
pending,
onSubmit,
tone = "full",
chrome = "card",
}: {
pending: AnswerFeedbackType | null;
onSubmit: (feedbackType: AnswerFeedbackType) => void;
Expand All @@ -1207,37 +1278,44 @@ export function AnswerFeedbackPanel({
* mis-click waiting to record the opposite of what they meant.
*/
tone?: "full" | "problems";
/**
* `"bare"` drops the card's own border and its heading pair for a host that
* already carries them — the Sheet the answer surface opens, whose title and
* description ARE `answerFeedbackQuestion`. Asking the same question twice,
* once in the sheet header and again three lines below it, is the duplication
* this exists to avoid.
*/
chrome?: "card" | "bare";
}) {
const problemsOnly = tone === "problems";
const bare = chrome === "bare";
const options = problemsOnly
? answerFeedbackOptions.filter((item) => item.tone !== "success")
: answerFeedbackOptions;
const question = answerFeedbackQuestion[problemsOnly ? "problems" : "full"];
return (
<section
data-testid="answer-review-panel"
data-tone={tone}
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-subtle)] p-3"
data-chrome={chrome}
className={cn(!bare && "rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-subtle)] p-3")}
aria-label={problemsOnly ? "Report a problem" : "Answer review"}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-[color:var(--text)]">
{problemsOnly ? "What is wrong with this answer?" : "Is the answer supported?"}
</p>
<p className={cn("mt-1 text-xs leading-5", textMuted)}>
{problemsOnly
? "Name the fault so a reviewer can find it. This sends feedback for review; it does not change the answer."
: "Record whether the linked evidence supports the answer. This sends feedback for review; it does not change the answer."}
</p>
</div>
{bare ? null : (
<div>
<p className="text-sm font-semibold text-[color:var(--text)]">{question.title}</p>
<p className={cn("mt-1 text-xs leading-5", textMuted)}>{question.description}</p>
</div>
)}
{pending ? (
<span className={metadataPillDensity.dense}>
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
Saving
</span>
) : null}
</div>
<div className="mt-3 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<div className={cn("grid grid-cols-2 gap-2 sm:flex sm:flex-wrap", bare ? "mt-0" : "mt-3")}>
{options.map((item) => {
const Icon = item.icon;
return (
Expand Down
46 changes: 43 additions & 3 deletions tests/answer-support-priority.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";

import { render, screen, within } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";

import { AnswerUtilityActions, answerSupportPriority } from "@/components/clinical-dashboard/evidence-panels";
import type { AnswerState } from "@/components/ui/answer-state";
Expand Down Expand Up @@ -150,19 +150,59 @@ describe("AnswerUtilityActions · feedback on a clean answer", () => {

const report = screen.getByTestId("answer-feedback-trigger");
expect(report).toHaveAccessibleName("Report a problem with this answer");
// A dialog opener, not an in-flow disclosure: as a disclosure this list
// opened partly behind the fixed phone composer and could not scroll itself
// clear without hiding the phone chrome (see the comment on the Sheet).
expect(report).toHaveAttribute("aria-haspopup", "dialog");
expect(report).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("answer-review-panel")).not.toBeInTheDocument();

await user.click(report);
const panel = screen.getByTestId("answer-review-panel");
expect(report).toHaveAttribute("aria-expanded", "true");
const sheet = await screen.findByTestId("answer-feedback-sheet");
// The sheet asks the question in its own header, so the panel inside it
// does not ask it a second time.
expect(within(sheet).getByText("What is wrong with this answer?")).toBeInTheDocument();
const panel = within(sheet).getByTestId("answer-review-panel");
expect(panel).toHaveAttribute("data-tone", "problems");
expect(panel).toHaveAttribute("data-chrome", "bare");
expect(within(panel).queryByText("What is wrong with this answer?")).not.toBeInTheDocument();
// The affirmative option is the thumb up, not an entry in a list opened to
// report a fault — offering it here is a mis-click that records the
// opposite of what the reader meant.
expect(within(panel).queryByRole("button", { name: /Verified/ })).not.toBeInTheDocument();
expect(within(panel).getByRole("button", { name: /Wrong source/ })).toBeInTheDocument();
});

it("closes the sheet on submission so the outcome notice is reachable", async () => {
// The dashboard reports every outcome — success, network failure, an expired
// feedback token, and synthetic demo answers — through the page-level notice
// alone, which renders outside this portaled modal. Leaving the sheet open
// puts that notice behind the backdrop with the page inert, so the tap reads
// as having done nothing. Codex P2 on PR #2541; it did not exist while the
// list was an in-flow disclosure.
const user = userEvent.setup();
const onSubmitFeedback = vi.fn();
render(
<AnswerUtilityActions
copied={false}
onCopy={() => undefined}
pendingFeedback={null}
onSubmitFeedback={onSubmitFeedback}
/>,
);

const report = screen.getByTestId("answer-feedback-trigger");
await user.click(report);
const sheet = await screen.findByTestId("answer-feedback-sheet");
await user.click(within(sheet).getByRole("button", { name: /Wrong source/ }));

expect(onSubmitFeedback).toHaveBeenCalledTimes(1);
expect(onSubmitFeedback).toHaveBeenCalledWith("wrong_source");
await waitFor(() => expect(screen.queryByTestId("answer-feedback-sheet")).not.toBeInTheDocument());
expect(report).toHaveAttribute("aria-expanded", "false");
});

it("routes the safety sheet from the header chip now that the support card is gone", () => {
const surface = readFileSync(
resolve(process.cwd(), "src/components/clinical-dashboard/answer-result-surface.tsx"),
Expand Down
Loading
Loading