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 numberDiff line numberDiff line change
Expand Up@@ -988,7 +988,7 @@ This generated snapshot is a local source-derived inventory. It does not assert
| `DisclosureGroup` | layout | yes | yes | inherited-global-root | yes | no | 1 |
| `DoseLine` | answer | yes | yes | no | yes | no | 0 |
| `DownloadLink` | controls | yes | yes | no | yes | no | 0 |
| `EmptyState` | feedback | yes | yes | inherited-global-root | yes | no | 13 |
| `EmptyState` | feedback | yes | yes | inherited-global-root | yes | no | 14 |
| `ErrorState` | feedback | yes | yes | no | yes | no | 0 |
| `ErrorSummary` | feedback | yes | yes | no | yes | no | 0 |
| `ExternalTextLink` | controls | yes | yes | no | yes | no | 0 |
Expand Down
2 changes: 2 additions & 0 deletions docs/design-system/adoption-manifest.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -748,6 +748,7 @@
"src/components/ClinicalDashboard.tsx",
"src/components/applications-launcher-page.tsx",
"src/components/clinical-dashboard/DocumentManagerPanel.tsx",
"src/components/clinical-dashboard/answer-cancelled-notice.tsx",
"src/components/clinical-dashboard/clinical-output-helpers.tsx",
"src/components/clinical-dashboard/document-admin.tsx",
"src/components/clinical-dashboard/evidence-panels.tsx",
Expand All@@ -763,6 +764,7 @@
"src/components/ClinicalDashboard.tsx",
"src/components/applications-launcher-page.tsx",
"src/components/clinical-dashboard/DocumentManagerPanel.tsx",
"src/components/clinical-dashboard/answer-cancelled-notice.tsx",
"src/components/clinical-dashboard/clinical-output-helpers.tsx",
"src/components/clinical-dashboard/document-admin.tsx",
"src/components/clinical-dashboard/evidence-panels.tsx",
Expand Down
53 changes: 30 additions & 23 deletions src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ import {
RefreshCw,
Search,
ShieldAlert,
Square,
Activity,
Wrench,
} from "lucide-react";
Expand DownExpand Up@@ -82,6 +81,7 @@ import { LazyGuideDialog, loadGuideDialog } from "@/components/clinical-dashboar
import { SystemNotice, DegradedNoticeFrame } from "@/components/clinical-dashboard/dashboard-notices";
import { resolveModeHomeCanvasClass } from "@/components/clinical-dashboard/mode-home-canvas";
import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/clinical-dashboard/display-text";
import { AnswerCancelledNotice } from "@/components/clinical-dashboard/answer-cancelled-notice";
import { isPreformattedGroundedAnswer } from "@/components/clinical-dashboard/answer-content";
import {
AnswerProgressStepper,
Expand DownExpand Up@@ -3007,8 +3007,19 @@ function ClinicalDashboardContent({
useEffect(() => {
if (showSharedHome) document.title = sharedHomeDocumentTitle(searchMode);
}, [searchMode, showSharedHome]);
// A stopped generation reports on the last action rather than describing the
// page, so the notice renders at the top of the content column while this same
// condition still short-circuits the mode-home empty-state chain below.
const showAnswerCancelledNotice = answerLifecycle.status === "cancelled" && activeModeResultKind === "answer";
// `submittedAnswerSearchActive` stays true after the reader presses Stop, and a
// cancel is not an `error`, so without the cancelled guard the pending branch
// held its skeleton on screen indefinitely — a shimmering placeholder promising
// an answer that was already abandoned, directly beneath the notice saying so.
const showAnswerPending =
activeModeResultKind === "answer" && !answer && (loading || (submittedAnswerSearchActive && !error));
activeModeResultKind === "answer" &&
!answer &&
!showAnswerCancelledNotice &&
(loading || (submittedAnswerSearchActive && !error));
const answerProgressCompleted = answerProgressEvents.at(-1)?.stage === "complete";
const showAnswerProgress =
activeModeResultKind === "answer" &&
Expand DownExpand Up@@ -3511,9 +3522,13 @@ function ClinicalDashboardContent({
: // The <main> reserve already clears the fixed composer dock on
// phones, so the old large mobile bottom padding only floated a
// long answer's last line high above the dock (and padded a short
// answer's empty space further). Keep it small here; sm+/desktop
// answer's empty space further). This stays far below that, but
// `pb-4` was the smallest tail in the app and left the last card
// sitting almost on the bottom edge once the dock scroll-hides
// and its reserve releases to zero. `pb-10` matches the
// `sm:pb-10` every other mode wrapper already uses. sm+/desktop
// keep the original generous padding.
"pb-4 sm:pb-36 lg:pb-40"
"pb-10 sm:pb-36 lg:pb-40"
: hasMobileBottomSearch
? compactMobileModeHome
? "sm:pb-10 lg:pb-12"
Expand All@@ -3522,6 +3537,9 @@ function ClinicalDashboardContent({
)}
>
<DashboardDesktopResultComposerSlot slotId={desktopResultComposerSlotId} />
{showAnswerCancelledNotice ? (
<AnswerCancelledNotice onRunAgain={() => void ask(answerLifecycle.query ?? query)} />
) : null}
{actionNotice && (
<InlineNotice tone={actionNotice.tone} onDismiss={() => setActionNotice(null)} animated>
{actionNotice.message}
Expand All@@ -3547,25 +3565,14 @@ function ClinicalDashboardContent({
<h2 data-testid="answer-section-heading" className="sr-only">
{activeModeSearch.resultHeading}
</h2>
{answerLifecycle.status === "cancelled" && activeModeResultKind === "answer" ? (
<EmptyState
icon={Square}
title="Generation stopped"
body="No partial clinical answer was kept. You can safely run the same question again."
live="polite"
testId="answer-cancelled"
actions={
<button
type="button"
className={cn(primaryControl, "text-xs")}
onClick={() => void ask(answerLifecycle.query ?? query)}
>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
Run again
</button>
}
/>
) : error && errorKind === "no-results" && activeModeResultKind === "answer" ? (
{/* Rendered above, at the top of the content column — see
`showAnswerCancelledNotice`. The condition stays here so the
chain below still short-circuits exactly as it did: a stopped
generation must not fall through into the no-results or error
empty states. */}
{showAnswerCancelledNotice ? null : error &&
errorKind === "no-results" &&
activeModeResultKind === "answer" ? (
<EmptyState
icon={Search}
title={answerRecovery.noResults.heading}
Expand Down
10 changes: 7 additions & 3 deletions src/components/clinical-dashboard/ClinicalSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,9 +69,13 @@ function accountProfileLabel(identity: SidebarIdentity) {

const sidebarToolItems = [
{ id: "answer", label: "Answer", icon: Sparkles, href: "/?mode=answer" },
// Documents owns a real home: the shell mounts ClinicalDashboard for /documents,
// so it paints browse and recent documents rather than the shared hero.
{ id: "documents", label: "Documents", icon: FileText, href: "/documents" },
// Owner decision 2026-08-27: the sidebar opens the shared "Clinical Documents"
// home, not the `/documents` workspace. `/documents` paints a second, older
// landing page — same subtitle, different title, plus three rows that only open
// drawers — and arriving there from the sidebar read as landing on the wrong
// screen. `/documents` keeps its route and its inbound link from the Tools
// directory (`tools-catalog.ts`); only this entry moves.
{ id: "documents", label: "Documents", icon: FileText, href: "/?mode=documents" },
// Every consolidated mode links to the one shared home; their bare paths are now
// redirects onto it, so pointing a pinned entry at `/services` or `/factsheets`
// would spend a round trip arriving at the same place.
Expand Down
35 changes: 35 additions & 0 deletions src/components/clinical-dashboard/answer-cancelled-notice.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
"use client";

import { RefreshCw, Square } from "lucide-react";

import { cn, EmptyState, primaryControl } from "@/components/ui-primitives";

/**
* The notice shown after the reader stops answer generation.
*
* It reports on the last action rather than describing the page, so the
* dashboard renders it with the other top-of-content notices instead of inside
* the mode-home canvas. In the canvas it was centred as one group with the
* `SharedHomeEmptyState` hero, which on a phone left it floating in the middle
* of the screen under a tall empty gap (device report, 2026-08-27).
*
* Extracted from ClinicalDashboard so the notice's markup lives with its
* rationale rather than adding lines to a file under a no-growth budget.
*/
export function AnswerCancelledNotice({ onRunAgain }: { onRunAgain: () => void }) {
return (
<EmptyState
icon={Square}
title="Generation stopped"
body="No partial clinical answer was kept. You can safely run the same question again."
live="polite"
testId="answer-cancelled"
actions={
<button type="button" className={cn(primaryControl, "text-xs")} onClick={onRunAgain}>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
Run again
</button>
}
/>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -379,7 +379,14 @@ function StagedAnswerResultSurfaceImpl({
titleClassName="text-base-minus leading-5"
closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"
contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-lg"
bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3"
// No `flex flex-col` here. The Sheet body is the scrollport, and as a flex
// column its single child (the findings card) became a shrinkable flex
// item: it was compressed from its natural height to whatever was left,
// and because that card is `overflow-hidden` the findings below the fold
// were clipped rather than scrolled. The body then had nothing to scroll,
// so the gesture went to the page behind the sheet. A plain block
// scrollport keeps the list at its natural height and scrolls it.
bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3"
returnFocusRef={safetyTriggerRef}
>
<SafetyFindingsListContent findings={safetyFindings} />
Expand Down
5 changes: 4 additions & 1 deletion src/components/clinical-dashboard/evidence-panels.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1075,7 +1075,10 @@ export function SafetyFindingsListContent({ findings, query }: { findings: Safet
return (
<div
data-testid="safety-findings-panel"
className="overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)]"
// shrink-0: this card clips its own overflow, so if a flex parent ever
// compresses it the findings past the fold vanish with no way to reach
// them. Inert outside a flex container.
className="shrink-0 overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)]"
>
{sortedFindings.map((finding, index) => (
<article
Expand Down
14 changes: 14 additions & 0 deletions src/components/ui/sheet-focus.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,17 @@ const openSheets: SheetEntry[] = [];
const inertedElements = new Set<HTMLElement>();
const stackListeners = new Set<() => void>();
let bodyScrollLockPreviousOverflow = "";
// The root element is locked as well as <body>, and it is the half that
// actually does the work on phones. `globals.css` sets `html { overflow-x:
// clip }`, and a root whose overflow is not `visible` stops <body>'s overflow
// from propagating to the viewport — so `body { overflow: hidden }` alone left
// the document freely scrollable behind an open sheet. Measured on a 390x844
// phone with the safety-findings sheet open: the page behind still had 272px of
// range and one wheel/touch gesture over the sheet ran it to the bottom instead
// of scrolling the sheet. Locking `overflow-y` on the root keeps the scroll
// offset (an element made `overflow: hidden` retains its scrollTop), so nothing
// jumps when the sheet closes.
let rootScrollLockPreviousOverflowY = "";

export function isTopmostSheet(id: string) {
return openSheets[openSheets.length - 1]?.id === id;
Expand DownExpand Up@@ -110,6 +121,8 @@ export function pushSheet(id: string, root: HTMLElement | null = null) {
if (openSheets.length === 0) {
bodyScrollLockPreviousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
rootScrollLockPreviousOverflowY = document.documentElement.style.overflowY;
document.documentElement.style.overflowY = "hidden";
}
openSheets.push({ id, root });
syncBackgroundInert();
Expand DownExpand Up@@ -140,6 +153,7 @@ export function popSheet(id: string) {
}
if (openSheets.length === 0) {
document.body.style.overflow = bodyScrollLockPreviousOverflow;
document.documentElement.style.overflowY = rootScrollLockPreviousOverflowY;
}
syncBackgroundInert();
notifyStackListeners();
Expand Down
8 changes: 7 additions & 1 deletion src/components/ui/sheet.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -554,7 +554,13 @@ export function Sheet({
ref={bodyRef}
onScroll={onBodyScroll}
tabIndex={bodyTabIndex}
className={cn("min-h-0 min-w-0 flex-1 overflow-y-auto p-4 polished-scroll sm:p-5", bodyClassName)}
// overscroll-contain: without it a gesture that reaches the end of this
// body continues into the page behind the sheet. `.polished-scroll` is
// scrollbar styling only and never carried this.
className={cn(
"min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain p-4 polished-scroll sm:p-5",
bodyClassName,
)}
>
{children}
</div>
Expand Down
2 changes: 1 addition & 1 deletion tests/favourites-auth-gate.dom.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,7 @@ describe("favourites auth gate DOM", () => {
navigation.getAllByRole("link").map((link) => ({ name: link.textContent, href: link.getAttribute("href") })),
).toEqual([
{ name: "Answer", href: "/?mode=answer" },
{ name: "Documents", href: "/documents" },
{ name: "Documents", href: "/?mode=documents" },
{ name: "Services", href: "/?mode=services" },
{ name: "Medication", href: "/medications" },
{ name: "Factsheets", href: "/?mode=factsheets" },
Expand Down
17 changes: 9 additions & 8 deletions tests/ui-smoke.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -811,13 +811,14 @@ async function openMobileClinicalGuideMenu(page: Page) {
.evaluateAll((links) => links.map((link) => ({ name: link.textContent, href: link.getAttribute("href") }))),
).toEqual([
{ name: "Answer", href: "/?mode=answer" },
// Documents owns a real home: the shell mounts ClinicalDashboard for
// /documents, so it paints browse and recent documents rather than the
// shared hero. Every other consolidated mode links at the shared home
// directly — pointing a pinned entry at its old bare path would spend a
// 307 arriving in the same place. Medication is not consolidated:
// /medications is the prescribing workspace, not a 307 onto /?mode=prescribing.
{ name: "Documents", href: "/documents" },
// Owner decision 2026-08-27: Documents joins the other consolidated modes and
// links at the shared home. `/documents` still exists and still paints its
// browse/recent workspace, but it is a second landing page — same subtitle,
// different title — and reaching it from the sidebar read as the wrong screen.
// It keeps its route and its inbound link from the Tools directory.
// Medication is not consolidated: /medications is the prescribing workspace,
// not a 307 onto /?mode=prescribing.
{ name: "Documents", href: "/?mode=documents" },
{ name: "Services", href: "/?mode=services" },
{ name: "Medication", href: "/medications" },
{ name: "Factsheets", href: "/?mode=factsheets" },
Expand DownExpand Up@@ -1373,7 +1374,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
),
).toEqual([
{ name: "Answer", href: "/?mode=answer" },
{ name: "Documents", href: "/documents" },
{ name: "Documents", href: "/?mode=documents" },
{ name: "Services", href: "/?mode=services" },
{ name: "Medication", href: "/medications" },
{ name: "Factsheets", href: "/?mode=factsheets" },
Expand Down
Loading