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
36 changes: 36 additions & 0 deletions src/app/(search-app)/differentials/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client";

import Link from "next/link";
import { FileQuestion } from "lucide-react";
import { appModeHomeHref } from "@/lib/app-modes";

export default function NotFound() {
Comment thread
cursor[bot] marked this conversation as resolved.
return (
<div className="flex h-[400px] items-center justify-center px-4">
<div
role="status"
className="w-full max-w-md 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 className="flex items-start gap-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-[color:var(--surface)] text-[color:var(--text-muted)]">
<FileQuestion className="size-icon-md sm:size-icon-lg" aria-hidden="true" />
</span>
<div className="min-w-0">
<h1 className="text-base font-semibold text-[color:var(--text)]">Differential Not Found</h1>
<p className="mt-1 leading-6 text-[color:var(--text-muted)]">
The requested differential diagnosis could not be found or has been deleted.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Link
href={appModeHomeHref("differentials")}
className="text-sm font-medium text-blue-600 hover:underline"
>
Return to differentials
</Link>
</div>
</div>
</div>
</div>
</div>
);
}
33 changes: 33 additions & 0 deletions src/app/(search-app)/documents/[id]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import Link from "next/link";
import { FileQuestion } from "lucide-react";
import { appModeHomeHref } from "@/lib/app-modes";

export default function NotFound() {
Comment thread
cursor[bot] marked this conversation as resolved.
return (
<div className="flex h-[400px] items-center justify-center px-4">
<div
role="status"
className="w-full max-w-md 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 className="flex items-start gap-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-[color:var(--surface)] text-[color:var(--text-muted)]">
<FileQuestion className="size-icon-md sm:size-icon-lg" aria-hidden="true" />
</span>
<div className="min-w-0">
<h1 className="text-base font-semibold text-[color:var(--text)]">Document Not Found</h1>
<p className="mt-1 leading-6 text-[color:var(--text-muted)]">
The requested document could not be found or has been deleted.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Link href={appModeHomeHref("documents")} className="text-sm font-medium text-blue-600 hover:underline">
Return to document library
</Link>
</div>
</div>
</div>
</div>
</div>
);
}
5 changes: 5 additions & 0 deletions src/app/(search-app)/documents/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import { DocumentViewerLazy as DocumentViewer } from "@/components/document-viewer-lazy";
import {
documentDetailQuerySchema,
loadAuthorizedDocumentDetail,
sanitizeDocumentDetailError,
} from "@/lib/document-detail";
import type { DocumentDetailPayload } from "@/lib/document-detail-contract";
import { PublicApiError } from "@/lib/http";

export default async function DocumentPage({
params,
Expand All @@ -32,6 +34,9 @@ export default async function DocumentPage({
});
initialDetail = await loadAuthorizedDocumentDetail({ request, rawId: id, query: detailQuery });
} catch (error) {
if (error instanceof PublicApiError && error.status === 404) {
notFound();
}
initialError = sanitizeDocumentDetailError(error);
}

Expand Down
7 changes: 7 additions & 0 deletions src/app/(search-app)/dsm/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import { RouteErrorBoundary } from "@/components/route-error-boundary";

export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <RouteErrorBoundary error={error} reset={reset} />;
}
7 changes: 7 additions & 0 deletions src/app/(search-app)/formulation/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import { RouteErrorBoundary } from "@/components/route-error-boundary";

export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <RouteErrorBoundary error={error} reset={reset} />;
}
7 changes: 7 additions & 0 deletions src/app/(search-app)/medications/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import { RouteErrorBoundary } from "@/components/route-error-boundary";

export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <RouteErrorBoundary error={error} reset={reset} />;
}
7 changes: 7 additions & 0 deletions src/app/(search-app)/therapy-compass/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import { RouteErrorBoundary } from "@/components/route-error-boundary";

export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <RouteErrorBoundary error={error} reset={reset} />;
}
55 changes: 54 additions & 1 deletion src/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { copyTextToClipboard } from "@/lib/copy-to-clipboard";

/**
* Last-resort boundary for the App Router. Unlike `app/error.tsx`, this replaces
Expand All @@ -12,11 +13,43 @@ import { useEffect, useRef } from "react";
*/
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
const headingRef = useRef<HTMLHeadingElement>(null);
const copiedResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);

useEffect(() => {
console.error("Fatal error captured by global-error boundary:", error);
headingRef.current?.focus({ preventScroll: true });
}, [error]);

useEffect(() => {
return () => {
if (copiedResetTimerRef.current) clearTimeout(copiedResetTimerRef.current);
};
}, []);

const handleCopyDiagnostics = () => {
const diagnosticPayload = {
name: error.name,
message: error.message,
digest: error.digest,
url: typeof window !== "undefined" ? window.location.href : "unknown",
userAgent: typeof window !== "undefined" ? window.navigator.userAgent : "unknown",
timestamp: new Date().toISOString(),
};
void copyTextToClipboard(JSON.stringify(diagnosticPayload, null, 2))
.then(() => {
setCopyFailed(false);
setCopied(true);
if (copiedResetTimerRef.current) clearTimeout(copiedResetTimerRef.current);
copiedResetTimerRef.current = setTimeout(() => setCopied(false), 2000);
})
.catch(() => {
setCopied(false);
setCopyFailed(true);
});
};

return (
<html lang="en">
<body
Expand Down Expand Up @@ -116,6 +149,26 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
>
Reload page
</button>
<button
type="button"
onClick={handleCopyDiagnostics}
style={{
cursor: "pointer",
borderRadius: "0.5rem",
border: "1px solid ButtonText",
backgroundColor: "ButtonFace",
color: "ButtonText",
padding: "0.625rem 1rem",
fontSize: "0.875rem",
fontWeight: 600,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
}}
>
{copied ? "Copied Diagnostics" : copyFailed ? "Copy failed — try again" : "Copy Diagnostics"}
</button>
</div>
</div>
</body>
Expand Down
3 changes: 2 additions & 1 deletion src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ import {
answerPayloadIsUsable,
classifyAnswerError,
createAnswerRequestWatchdog,
generateQuerySuggestions,
isRetryableError,
keywordQueryFromNaturalLanguage,
makeSearchError,
Expand Down Expand Up @@ -3560,7 +3561,7 @@ export function ClinicalDashboard({
<EmptyState
icon={Search}
title={answerRecovery.noResults.heading}
body={answerRecovery.noResults.body}
body={`${answerRecovery.noResults.body} Suggestions: ${generateQuerySuggestions((lastFailedQuery ?? query).trim()).join("; ")}.`}
live="polite"
tone="info"
testId="answer-no-results"
Expand Down
2 changes: 1 addition & 1 deletion src/components/clinical-dashboard/answer-status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export function AnswerProgressStepper({
type="button"
onClick={onStop}
data-testid="stop-answer"
className="inline-flex min-h-8 shrink-0 items-center gap-1.5 rounded-full border border-[color:var(--border-strong)] bg-[color:var(--surface-raised)] px-3 text-xs font-semibold text-[color:var(--text-heading)] shadow-[var(--shadow-inset)] 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)]"
className="inline-flex min-h-8 shrink-0 items-center gap-1.5 rounded-full border border-[color:var(--border-strong)] bg-[color:var(--surface-raised)] px-3 text-xs font-semibold text-[color:var(--text-heading)] shadow-[var(--shadow-inset)] transition motion-reduce:transition-none hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"
>
<Square className="h-3 w-3 shrink-0 fill-current" aria-hidden />
Stop
Expand Down
17 changes: 13 additions & 4 deletions src/components/clinical-dashboard/image-lightbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function ImageLightbox({
returnFocusRef?: RefObject<HTMLElement | null>;
}) {
const { url, failed, retry, markFailed } = useSignedImageUrl(endpoint, open);
const [retryDisabled, setRetryDisabled] = useState(false);
const [scale, setScale] = useState(1);
const [rotation, setRotation] = useState(0);
const [translate, setTranslate] = useState({ x: 0, y: 0 });
Expand All @@ -47,6 +48,13 @@ export function ImageLightbox({
scaleRef.current = scale;
}, [scale]);

const handleRetry = useCallback(() => {
if (retryDisabled) return;
setRetryDisabled(true);
retry();
setTimeout(() => setRetryDisabled(false), 2000);
}, [retryDisabled, retry]);

// Reset the view on close so the next open never inherits a prior image's zoom.
const handleClose = useCallback(() => {
setScale(1);
Expand Down Expand Up @@ -125,11 +133,12 @@ export function ImageLightbox({
Image could not load.
<button
type="button"
onClick={retry}
className="inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--warning)]/30 bg-[color:var(--surface)] px-3 text-[color:var(--warning)]"
onClick={handleRetry}
disabled={retryDisabled}
className="inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--warning)]/30 bg-[color:var(--surface)] px-3 text-[color:var(--warning)] disabled:opacity-50 disabled:cursor-not-allowed transition"
>
<RefreshCw aria-hidden="true" className="h-4 w-4" />
Retry
<RefreshCw aria-hidden="true" className={cn("h-4 w-4", retryDisabled && "animate-spin")} />
{retryDisabled ? "Retrying..." : "Retry"}
</button>
</div>
) : (
Expand Down
32 changes: 32 additions & 0 deletions src/components/clinical-dashboard/search-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,35 @@ export function classifyAnswerError(error: unknown): AnswerErrorKind {
}
return "failure";
}

/**
* Generate intelligent query rephrasing suggestions for zero-result states.
*/
export function generateQuerySuggestions(query: string): string[] {
Comment thread
cursor[bot] marked this conversation as resolved.
if (!query || query.trim() === "") {
return ["Check for spelling errors", "Try broader search terms", "Remove strict filters"];
}

const suggestions: string[] = [];
const trimmed = query.trim();

if (trimmed.split(/\s+/).length > 3) {
suggestions.push("Use fewer words");
}

const hasQuotedPhrase = /"[^"]+"|(?:^|\s)'[^']+'(?=\s|$)/.test(trimmed);
if (hasQuotedPhrase) {
suggestions.push("Remove quotes for a broader search");
}

const booleanQuery = trimmed.replace(/"[^"]*"|(?:^|\s)'[^']+'(?=\s|$)/g, " ");
if (/(^|[\s(])(AND|OR|NOT)(?=$|[\s),.;:!?])/i.test(booleanQuery)) {
suggestions.push("Check boolean operators (AND, OR, NOT)");
} else {
suggestions.push("Try more general medical terms");
}

suggestions.push("Check for alternate spellings");

return suggestions.slice(0, 3);
}
9 changes: 7 additions & 2 deletions src/components/clinical-dashboard/signed-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export const SignedImage = memo(function SignedImage({
const [lightboxOpen, setLightboxOpen] = useState(false);
const frameRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const [retryDisabled, setRetryDisabled] = useState(false);
const { url, failed, retry, markFailed } = useSignedImageUrl(endpoint, shouldLoad);

// Defer the request until the frame is near the viewport. A cached URL seeds
Expand Down Expand Up @@ -84,9 +85,12 @@ export const SignedImage = memo(function SignedImage({
}, [rootMargin, shouldLoad]);

function retryImage() {
if (retryDisabled) return;
setRetryDisabled(true);
setLoaded(false);
setShouldLoad(true);
retry();
setTimeout(() => setRetryDisabled(false), 2000);
}

function handleImageError() {
Expand All @@ -111,9 +115,10 @@ export const SignedImage = memo(function SignedImage({
<button
type="button"
onClick={retryImage}
className="mt-3 inline-flex min-h-tap items-center rounded-lg border border-[color:var(--warning)]/30 bg-[color:var(--surface)] px-3 text-[color:var(--warning)]"
disabled={retryDisabled}
className="mt-3 inline-flex min-h-tap items-center rounded-lg border border-[color:var(--warning)]/30 bg-[color:var(--surface)] px-3 text-[color:var(--warning)] disabled:opacity-50 disabled:cursor-not-allowed transition"
>
{retryLabel}
{retryDisabled ? "Retrying..." : retryLabel}
</button>
</div>
</div>
Expand Down
8 changes: 6 additions & 2 deletions src/components/document-viewer/document-rail-panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ import {
clinicalDivider,
cn,
codeText,
EmptyState,
eyebrowText,
InlineNotice,
LoadingPanel,
panel,
proseMeasure,
sourceCard,
textMuted,
} from "@/components/ui-primitives";
import type { ClinicalDocument, DocumentLabel } from "@/lib/types";
import type { FormattedDocumentSummary } from "@/lib/document-summary-formatting";
Expand Down Expand Up @@ -240,7 +240,11 @@ export function DocumentViewerRail({
{effectiveLoadingDocument ? (
<LoadingPanel label="Loading extracted tables" />
) : clinicalImages.length === 0 ? (
<p className={cn("text-base-minus", textMuted)}>No indexed clinically useful tables or diagrams.</p>
<EmptyState
title="No clinically useful tables or diagrams"
body="No indexed clinically useful tables or diagrams."
tone="neutral"
/>
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
clinicalImages.map((image) => <DocumentImage key={image.id} image={image} />)
)}
Expand Down
6 changes: 3 additions & 3 deletions src/components/mode-home-page-skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Skeleton } from "@/components/ui-primitives";
export function ModeHomePageSkeleton() {
return (
<div
className="mx-auto grid w-full max-w-[60rem] justify-items-center gap-3.5 px-4 py-8 sm:gap-6"
className="mx-auto grid w-full max-w-[60rem] justify-items-center gap-3.5 px-4 py-8 sm:gap-6 animate-fade-in motion-reduce:animate-none"
role="status"
aria-label="Loading"
>
Expand Down Expand Up @@ -36,7 +36,7 @@ export function ModeHomeRouteLoading() {
export function DocumentSearchPageSkeleton() {
return (
<div
className="mx-auto w-full max-w-[104rem] space-y-4 px-3 py-4 sm:px-5"
className="mx-auto w-full max-w-[104rem] space-y-4 px-3 py-4 sm:px-5 animate-fade-in motion-reduce:animate-none"
role="status"
aria-label="Loading documents"
>
Expand All @@ -55,7 +55,7 @@ export function DocumentSearchPageSkeleton() {
export function DocumentViewerPageSkeleton() {
return (
<div
className="flex h-[calc(100dvh-var(--shell-header-h))] flex-col gap-4 px-4 py-4"
className="flex h-[calc(100dvh-var(--shell-header-h))] flex-col gap-4 px-4 py-4 animate-fade-in motion-reduce:animate-none"
role="status"
aria-label="Loading document"
>
Expand Down
Loading
Loading