Skip to content
Closed
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
146 changes: 68 additions & 78 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ import {
appBackdrop,
answerSurface,
cn,
EmptyState,
floatingControl,
InlineNotice,
primaryControl,
textMuted,
toneInfo,
} from "@/components/ui-primitives";
import { useAuthSession } from "@/lib/supabase/client";
import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog";
Expand Down Expand Up @@ -3598,100 +3598,90 @@ export function ClinicalDashboard({
{activeModeSearch.resultHeading}
</h2>
{answerLifecycle.status === "cancelled" && activeModeResultKind === "answer" ? (
<div
role="status"
aria-live="polite"
data-testid="answer-cancelled"
className={cn(
"flex flex-wrap items-center justify-between gap-3 rounded-lg border p-4 text-sm",
toneInfo,
)}
>
<div>
<p className="font-semibold text-[color:var(--text-heading)]">Generation stopped</p>
<p className={textMuted}>No partial clinical answer was kept.</p>
</div>
<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>
</div>
) : error && errorKind === "no-results" && activeModeResultKind === "answer" ? (
<div
role="status"
data-testid="answer-no-results"
className={cn("rounded-lg border p-4 text-sm", toneInfo)}
>
<div className="flex items-start gap-2">
<Search aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 space-y-1">
<p className="font-semibold text-[color:var(--text-heading)]">
{answerRecovery.noResults.heading}
</p>
<p className={textMuted}>{answerRecovery.noResults.body}</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<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"
data-testid="answer-no-results-rephrase"
onClick={() => focusComposerInput()}
className={cn(primaryControl, "text-xs")}
onClick={() => void ask(answerLifecycle.query ?? query)}
>
{answerRecovery.rephrase}
<RefreshCw className="h-4 w-4" aria-hidden="true" />
Run again
</button>
<button
type="button"
data-testid="answer-no-results-search-documents"
onClick={() => crossModeSearch("documents", (lastFailedQuery ?? query).trim())}
className={cn(floatingControl, "text-xs")}
>
<FileText aria-hidden="true" className="h-4 w-4" />
{answerRecovery.searchDocuments}
</button>
</div>
</div>
) : error ? (
<div
role="alert"
data-testid="answer-error"
className="rounded-lg border border-[color:var(--danger)]/30 bg-[color:var(--danger-soft)] p-3 text-sm font-medium text-[color:var(--danger)]"
>
<div className="flex items-start gap-2">
<CircleAlert aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0" />
<span className="min-w-0">{error}</span>
</div>
{activeModeResultKind === "answer" && lastFailedQuery && (
<div className="mt-3 flex flex-wrap gap-2">
}
/>
) : error && errorKind === "no-results" && activeModeResultKind === "answer" ? (
<EmptyState
icon={Search}
title={answerRecovery.noResults.heading}
body={answerRecovery.noResults.body}
live="polite"
tone="info"
testId="answer-no-results"
actions={
<>
<button
type="button"
data-testid="answer-error-retry"
onClick={() => {
const retryQuery = lastFailedQuery ?? query;
setError(null);
void ask(retryQuery);
}}
className={cn(floatingControl, "text-xs")}
data-testid="answer-no-results-rephrase"
onClick={() => focusComposerInput()}
className={cn(primaryControl, "text-xs")}
>
<RefreshCw aria-hidden="true" className="h-4 w-4" />
{answerRecovery.retry}
{answerRecovery.rephrase}
</button>
<button
type="button"
data-testid="answer-error-search-documents"
data-testid="answer-no-results-search-documents"
onClick={() => crossModeSearch("documents", (lastFailedQuery ?? query).trim())}
className={cn(floatingControl, "text-xs")}
>
<FileText aria-hidden="true" className="h-4 w-4" />
{answerRecovery.searchDocuments}
</button>
</div>
)}
</div>
</>
}
/>
) : error ? (
<EmptyState
icon={CircleAlert}
title="Answer unavailable"
body={error}
live="assertive"
tone="danger"
testId="answer-error"
actions={
activeModeResultKind === "answer" && lastFailedQuery ? (
<>
<button
type="button"
data-testid="answer-error-retry"
onClick={() => {
const retryQuery = lastFailedQuery ?? query;
setError(null);
void ask(retryQuery);
}}
className={cn(floatingControl, "text-xs")}
>
<RefreshCw aria-hidden="true" className="h-4 w-4" />
{answerRecovery.retry}
</button>
<button
type="button"
data-testid="answer-error-search-documents"
onClick={() => crossModeSearch("documents", (lastFailedQuery ?? query).trim())}
className={cn(floatingControl, "text-xs")}
>
<FileText aria-hidden="true" className="h-4 w-4" />
{answerRecovery.searchDocuments}
</button>
</>
) : undefined
}
/>
) : null}

{searchMode !== "prescribing" &&
Expand Down
39 changes: 36 additions & 3 deletions src/components/ui-primitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -426,18 +426,51 @@ export function LoadingPanel({
);
}

export function EmptyState({ icon: Icon, title, body }: { icon?: IconComponent; title: string; body: string }) {
export function EmptyState({
icon: Icon,
title,
body,
actions,
live,
tone = "neutral",
testId,
}: {
icon?: IconComponent;
title: string;
body: string;
/** Optional controls stay within the shared state surface rather than becoming a second panel. */
actions?: ReactNode;
/** Announce a state transition only when the state is introduced dynamically. */
live?: "polite" | "assertive";
tone?: "neutral" | "info" | "danger";
testId?: string;
}) {
return (
<div className="rounded-lg border border-dashed border-[color:var(--border-strong)] bg-[color:var(--surface-inset)] p-4 text-sm shadow-[var(--shadow-inset)] sm:p-5">
<div
data-testid={testId}
role={live === "assertive" ? "alert" : live === "polite" ? "status" : undefined}
className={cn(
"rounded-lg border border-dashed border-[color:var(--border-strong)] bg-[color:var(--surface-inset)] p-4 text-sm shadow-[var(--shadow-inset)] sm:p-5",
tone === "info" && "border-[color:var(--info-border)] bg-[color:var(--info-soft)]",
tone === "danger" && "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)]",
)}
>
<div className="flex items-start gap-3">
{Icon && (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-[color:var(--surface)] text-[color:var(--text-muted)]">
<span
className={cn(
"grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-[color:var(--surface)] text-[color:var(--text-muted)]",
tone === "info" && "bg-[color:var(--info-soft)] text-[color:var(--info)]",
tone === "danger" && "bg-[color:var(--danger-soft)] text-[color:var(--danger)]",
)}
>
<Icon className="size-icon-md sm:size-icon-lg" />
</span>
)}
<div className="min-w-0">
<p className="font-semibold text-[color:var(--text)]">{title}</p>
<p className={cn("mt-1 leading-6", textMuted)}>{body}</p>
{actions ? <div className="mt-3 flex flex-wrap gap-2">{actions}</div> : null}
</div>
</div>
</div>
Expand Down
30 changes: 30 additions & 0 deletions tests/ui-primitives.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { render, screen } from "@testing-library/react";
import { Search } from "lucide-react";
import { describe, expect, it } from "vitest";

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

describe("EmptyState", () => {
it("keeps recovery actions inside an announced state surface", () => {
render(
<EmptyState
icon={Search}
title="No matching sources"
body="Try a more specific question."
live="polite"
testId="recovery-state"
actions={<button type="button">Rephrase question</button>}
/>,
);

const state = screen.getByTestId("recovery-state");
expect(state).toHaveAttribute("role", "status");
expect(screen.getByRole("button", { name: "Rephrase question" })).toBeVisible();
});

it("uses an assertive announcement for a dynamic failure", () => {
render(<EmptyState title="Answer unavailable" body="Please try again." live="assertive" />);

expect(screen.getByRole("alert")).toHaveTextContent("Answer unavailable");
});
});
Loading