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
44 changes: 29 additions & 15 deletions src/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect } from "react";
import { useEffect, useRef } from "react";

/**
* Last-resort boundary for the App Router. Unlike `app/error.tsx`, this replaces
Expand All @@ -11,8 +11,10 @@ import { useEffect } from "react";
* still renders correctly when the styling/theming system is exactly what failed.
*/
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
console.error("Fatal error captured by global-error boundary:", error);
headingRef.current?.focus();
}, [error]);

return (
Expand All @@ -25,8 +27,8 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
alignItems: "center",
justifyContent: "center",
padding: "1rem",
backgroundColor: "#f4f5f7",
color: "#1a1c1e",
backgroundColor: "Canvas",
color: "CanvasText",
fontFamily:
'system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"',
}}
Expand All @@ -37,27 +39,39 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
maxWidth: "28rem",
boxSizing: "border-box",
borderRadius: "1rem",
border: "1px solid #e2e4e8",
backgroundColor: "#ffffff",
border: "1px solid CanvasText",
backgroundColor: "Canvas",
padding: "1.5rem",
textAlign: "center",
boxShadow: "0 10px 30px rgba(15, 23, 42, 0.12)",
boxShadow: "0 10px 30px color-mix(in srgb, CanvasText 12%, transparent)",
}}
>
<h1 style={{ margin: "0 0 0.5rem", fontSize: "1.125rem", fontWeight: 600 }}>Something went wrong</h1>
<p style={{ margin: "0 0 1.25rem", fontSize: "0.875rem", lineHeight: 1.6, color: "#5b6069" }}>
<h1
ref={headingRef}
tabIndex={-1}
style={{
margin: "0 0 0.5rem",
fontSize: "1.125rem",
fontWeight: 600,
outline: "2px solid Highlight",
outlineOffset: "2px",
}}
>
Something went wrong
</h1>
<p style={{ margin: "0 0 1.25rem", fontSize: "0.875rem", lineHeight: 1.6 }}>
The application failed to load. Please try again, or reload the page if the problem persists.
</p>
{error.digest && (
<div
style={{
margin: "0 0 1.25rem",
borderRadius: "0.5rem",
backgroundColor: "#f4f5f7",
backgroundColor: "Canvas",
padding: "0.5rem",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
fontSize: "0.75rem",
color: "#5b6069",
color: "CanvasText",
wordBreak: "break-all",
}}
>
Expand All @@ -72,8 +86,8 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
cursor: "pointer",
borderRadius: "0.5rem",
border: "none",
backgroundColor: "#1a1c1e",
color: "#ffffff",
backgroundColor: "ButtonFace",
color: "ButtonText",
padding: "0.625rem 1rem",
fontSize: "0.875rem",
fontWeight: 600,
Expand All @@ -87,9 +101,9 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
style={{
cursor: "pointer",
borderRadius: "0.5rem",
border: "1px solid #e2e4e8",
backgroundColor: "#ffffff",
color: "#1a1c1e",
border: "1px solid ButtonText",
backgroundColor: "ButtonFace",
color: "ButtonText",
padding: "0.625rem 1rem",
fontSize: "0.875rem",
fontWeight: 600,
Expand Down
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
30 changes: 29 additions & 1 deletion src/components/DocumentViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import Link from "next/link";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import {
CircleAlert,
Expand Down Expand Up @@ -64,7 +65,6 @@ import {
} from "@/components/ui-primitives";
import { BadgeCluster } from "@/components/clinical-dashboard/clinical-badge";
import { SignedImage } from "@/components/clinical-dashboard/signed-image";
import { NativePdfEmbed, PdfCanvasViewer } from "@/components/document-viewer/pdf-canvas-viewer";
import { NonPdfSourcePreview } from "@/components/document-viewer/non-pdf-source-preview";
import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/lib/signed-url-cache";
import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity";
Expand Down Expand Up @@ -105,6 +105,34 @@ import { buildDocumentSummaryBadges } from "@/lib/document-summary-badges";
import { documentSummaryQuestion } from "@/lib/answer-contract";
import type { DocumentDetailPayload } from "@/lib/document-detail-contract";

// pdf-canvas-viewer is only needed after a source document has loaded and the
// user is viewing a PDF. Keeping it out of the document route's initial client
// chunk avoids parsing its reader controls for image, text, and download-only
// documents. pdf.js itself remains loaded on demand by that component.
const PdfCanvasViewer = dynamic(
() => import("@/components/document-viewer/pdf-canvas-viewer").then((module) => module.PdfCanvasViewer),
{
ssr: false,
loading: () => <PdfPreviewLoading />,
},
);
const NativePdfEmbed = dynamic(
() => import("@/components/document-viewer/pdf-canvas-viewer").then((module) => module.NativePdfEmbed),
{ ssr: false, loading: () => <PdfPreviewLoading /> },
);

function PdfPreviewLoading() {
return (
<div
aria-busy="true"
aria-live="polite"
className="grid min-h-64 place-items-center bg-[color:var(--surface-inset)] p-5 text-center text-sm text-[color:var(--text-muted)] sm:min-h-72"
>
Loading PDF reader…
</div>
);
}

type PageRow = {
id: string;
page_number: number;
Expand Down
Loading