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"
Comment on lines +3648 to +3655

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not label every dashboard error as an answer failure.

This branch also handles document-search and clipboard errors, so those modes now display “Answer unavailable.” Select the title/icon based on activeModeResultKind, or restrict this EmptyState to answer errors.

Proposed fix
-                    title="Answer unavailable"
+                    title={activeModeResultKind === "answer" ? "Answer unavailable" : "Search unavailable"}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
) : error ? (
<EmptyState
icon={CircleAlert}
title="Answer unavailable"
body={error}
live="assertive"
tone="danger"
testId="answer-error"
) : error ? (
<EmptyState
icon={CircleAlert}
title={activeModeResultKind === "answer" ? "Answer unavailable" : "Search unavailable"}
body={error}
live="assertive"
tone="danger"
testId="answer-error"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ClinicalDashboard.tsx` around lines 3648 - 3655, Update the
error EmptyState branch in ClinicalDashboard to distinguish errors by
activeModeResultKind instead of always presenting “Answer unavailable.” Use the
appropriate title and icon for document-search and clipboard errors, while
preserving the answer-specific presentation for answer errors.

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");
});
});