feat(web): add dashboard error boundaries

This commit is contained in:
Ashish Huddar 2026-04-06 00:32:53 +05:30
parent 129149e166
commit 52bcce8f2b
16 changed files with 664 additions and 70 deletions

View File

@ -29,7 +29,7 @@
"dev": "tsx src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "pnpm --filter @composio/ao-cli^... build && tsc --noEmit",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {

View File

@ -19,7 +19,7 @@
"build": "next build && tsc -p tsconfig.server.json",
"start": "next start",
"start:all": "node dist-server/start-all.js",
"typecheck": "pnpm --filter @composio/ao-web^... build && tsc --noEmit",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf .next dist-server",

View File

@ -0,0 +1,33 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const refresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh }),
}));
import ErrorPage from "./error";
describe("Route error boundary", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("retries with reset and router refresh", () => {
const reset = vi.fn();
render(<ErrorPage error={new Error("boom")} reset={reset} />);
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(reset).toHaveBeenCalledTimes(1);
expect(refresh).toHaveBeenCalledTimes(1);
});
it("renders a dashboard escape hatch", () => {
render(<ErrorPage error={new Error("boom")} reset={vi.fn()} />);
expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute("href", "/");
});
});

View File

@ -0,0 +1,38 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { ErrorDisplay } from "@/components/ErrorDisplay";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const router = useRouter();
useEffect(() => {
console.error(error);
}, [error]);
return (
<ErrorDisplay
title="Something went wrong"
message="The dashboard hit an unexpected error. Try reloading the route data or head back to the main dashboard."
tone="warning"
primaryAction={{
label: "Try again",
onClick: () => {
reset();
router.refresh();
},
}}
secondaryAction={{ label: "Back to dashboard", href: "/" }}
error={error}
compact
chrome="card"
/>
);
}

View File

@ -0,0 +1,72 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const errorDisplaySpy = vi.fn();
vi.mock("@/components/ErrorDisplay", () => ({
ErrorDisplay: (props: {
title: string;
message: string;
primaryAction?: { label: string; onClick?: () => void };
secondaryAction?: { label: string; onClick?: () => void };
}) => {
errorDisplaySpy(props);
return (
<div>
<div>{props.title}</div>
<div>{props.message}</div>
{props.primaryAction ? (
<button type="button" onClick={props.primaryAction.onClick}>
{props.primaryAction.label}
</button>
) : null}
{props.secondaryAction ? (
<button type="button" onClick={props.secondaryAction.onClick}>
{props.secondaryAction.label}
</button>
) : null}
</div>
);
},
}));
import GlobalError from "./global-error";
describe("Global error boundary", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
errorDisplaySpy.mockClear();
Object.defineProperty(window, "location", {
configurable: true,
value: { reload: vi.fn() },
});
});
it("calls reset when retrying", () => {
const reset = vi.fn();
render(<GlobalError error={new Error("layout failed")} reset={reset} />);
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(reset).toHaveBeenCalledTimes(1);
});
it("reloads the page on demand", () => {
render(<GlobalError error={new Error("layout failed")} reset={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Reload page" }));
expect(window.location.reload).toHaveBeenCalledTimes(1);
});
it("passes the expected shell copy to ErrorDisplay", () => {
render(<GlobalError error={new Error("layout failed")} reset={vi.fn()} />);
expect(errorDisplaySpy).toHaveBeenCalledWith(
expect.objectContaining({
title: "Something broke at the app shell",
message:
"The dashboard could not recover from this error at the layout level. Try again first, then reload the page if it still fails.",
}),
);
});
});

View File

@ -0,0 +1,31 @@
"use client";
import { useEffect } from "react";
import { ErrorDisplay } from "@/components/ErrorDisplay";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<html lang="en" className="dark">
<body className="bg-[var(--color-bg-base)] text-[var(--color-text-primary)] antialiased">
<ErrorDisplay
title="Something broke at the app shell"
message="The dashboard could not recover from this error at the layout level. Try again first, then reload the page if it still fails."
tone="error"
primaryAction={{ label: "Try again", onClick: reset }}
secondaryAction={{ label: "Reload page", onClick: () => window.location.reload() }}
error={error}
/>
</body>
</html>
);
}

View File

@ -18,9 +18,18 @@ describe("NotFound (global)", () => {
expect(screen.getByText("Page not found")).toBeInTheDocument();
});
it("renders descriptive copy", () => {
render(<NotFound />);
expect(
screen.getByText(
"This route does not exist in the dashboard. Return to the main view to pick an active project or session.",
),
).toBeInTheDocument();
});
it("renders a link back to the dashboard", () => {
render(<NotFound />);
const link = screen.getByText("← Back to dashboard");
const link = screen.getByText("Back to dashboard");
expect(link).toBeInTheDocument();
expect(link.closest("a")).toHaveAttribute("href", "/");
});

View File

@ -1,27 +1,12 @@
import Link from "next/link";
import { ErrorDisplay } from "@/components/ErrorDisplay";
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--color-bg-base)]">
<svg
className="h-8 w-8 text-[var(--color-border-strong)]"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
viewBox="0 0 24 24"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 9l4 3-4 3M13 15h5" />
</svg>
<p className="text-[13px] text-[var(--color-text-muted)]">
Page not found
</p>
<Link
href="/"
className="text-[12px] text-[var(--color-accent)] hover:underline"
>
Back to dashboard
</Link>
</div>
<ErrorDisplay
title="Page not found"
message="This route does not exist in the dashboard. Return to the main view to pick an active project or session."
tone="not-found"
primaryAction={{ label: "Back to dashboard", href: "/" }}
/>
);
}

View File

@ -0,0 +1,37 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const refresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh }),
}));
import SessionError from "./error";
describe("Session error boundary", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("retries with reset and router refresh", () => {
const reset = vi.fn();
render(<SessionError error={new Error("HTTP 500")} reset={reset} />);
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(reset).toHaveBeenCalledTimes(1);
expect(refresh).toHaveBeenCalledTimes(1);
});
it("shows a session-specific message", () => {
render(<SessionError error={new Error("Network request failed")} reset={vi.fn()} />);
expect(
screen.getByText(
"The session request failed before the dashboard got a response. Check the server connection and try again.",
),
).toBeInTheDocument();
});
});

View File

@ -0,0 +1,55 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { ErrorDisplay } from "@/components/ErrorDisplay";
function getSessionErrorMessage(error: Error): string {
const normalized = error.message.toLowerCase();
if (normalized.includes("network")) {
return "The session request failed before the dashboard got a response. Check the server connection and try again.";
}
if (normalized.includes("403")) {
return "The dashboard could not access this session. Permissions or auth may have changed.";
}
if (normalized.includes("404")) {
return "This session is no longer available. It may have been removed while the page was open.";
}
if (normalized.includes("500")) {
return "The server returned an internal error while loading this session. Try re-fetching the session data.";
}
return "The dashboard could not load this session cleanly. Try again to re-fetch the latest state.";
}
export default function SessionError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const router = useRouter();
useEffect(() => {
console.error(error);
}, [error]);
return (
<ErrorDisplay
title="Failed to load session"
message={getSessionErrorMessage(error)}
tone="error"
primaryAction={{
label: "Try again",
onClick: () => {
reset();
router.refresh();
},
}}
secondaryAction={{ label: "Back to dashboard", href: "/" }}
error={error}
compact
chrome="card"
/>
);
}

View File

@ -22,14 +22,14 @@ describe("SessionNotFound", () => {
render(<SessionNotFound />);
expect(
screen.getByText(
"The session you\u2019re looking for doesn\u2019t exist or has been deleted.",
"The session youre looking for does not exist anymore, or the link is stale.",
),
).toBeInTheDocument();
});
it("renders a link back to the dashboard", () => {
render(<SessionNotFound />);
const link = screen.getByText("Back to dashboard");
const link = screen.getByText("Back to dashboard");
expect(link).toBeInTheDocument();
expect(link.closest("a")).toHaveAttribute("href", "/");
});

View File

@ -1,31 +1,14 @@
import Link from "next/link";
import { ErrorDisplay } from "@/components/ErrorDisplay";
export default function SessionNotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--color-bg-base)]">
<svg
className="h-8 w-8 text-[var(--color-border-strong)]"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
viewBox="0 0 24 24"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 9l4 3-4 3M13 15h5" />
</svg>
<p className="text-[13px] text-[var(--color-text-muted)]">
Session not found
</p>
<p className="text-[12px] text-[var(--color-text-tertiary)]">
The session you&rsquo;re looking for doesn&rsquo;t exist or has been
deleted.
</p>
<Link
href="/"
className="text-[12px] text-[var(--color-accent)] hover:underline"
>
Back to dashboard
</Link>
</div>
<ErrorDisplay
title="Session not found"
message="The session youre looking for does not exist anymore, or the link is stale."
tone="not-found"
primaryAction={{ label: "Back to dashboard", href: "/" }}
compact
chrome="card"
/>
);
}

View File

@ -1,11 +1,17 @@
import { act, render } from "@testing-library/react";
import React, { type ReactNode } from "react";
import { act, render, screen } from "@testing-library/react";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import type { DashboardSession } from "@/lib/types";
const sessionDetailSpy = vi.fn();
const notFoundError = new Error("NEXT_NOT_FOUND");
const notFoundSpy = vi.fn(() => {
throw notFoundError;
});
vi.mock("next/navigation", () => ({
useParams: () => ({ id: "worker-1" }),
notFound: notFoundSpy,
}));
vi.mock("@/components/SessionDetail", () => ({
@ -40,10 +46,32 @@ async function flushAsyncWork(): Promise<void> {
});
}
class TestErrorBoundary extends React.Component<
{ children: ReactNode },
{ error: Error | null }
> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
if (this.state.error) {
return <div data-testid="route-error">{this.state.error.message}</div>;
}
return this.props.children;
}
}
describe("SessionPage project polling", () => {
beforeEach(() => {
vi.useFakeTimers();
sessionDetailSpy.mockClear();
vi.spyOn(console, "error").mockImplementation(() => {});
const eventSourceMock = {
addEventListener: vi.fn(),
@ -59,6 +87,7 @@ describe("SessionPage project polling", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
notFoundSpy.mockReset();
});
it("resolves orchestrator nav once for non-orchestrator pages and skips repeated project polling", async () => {
@ -125,4 +154,70 @@ describe("SessionPage project polling", () => {
),
).toHaveLength(1);
});
it("routes 404 responses through notFound()", async () => {
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/projects") {
return {
ok: true,
status: 200,
json: async () => ({ projects: [] }),
} as Response;
}
if (url === "/api/sessions/worker-1") {
return {
ok: false,
status: 404,
json: async () => ({}),
} as Response;
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
const { default: SessionPage } = await import("./page");
render(<SessionPage />);
await flushAsyncWork();
expect(notFoundSpy).toHaveBeenCalled();
expect(screen.queryByTestId("session-detail")).not.toBeInTheDocument();
});
it("throws non-404 session fetch failures to the route error boundary", async () => {
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/projects") {
return {
ok: true,
status: 200,
json: async () => ({ projects: [] }),
} as Response;
}
if (url === "/api/sessions/worker-1") {
return {
ok: false,
status: 500,
json: async () => ({}),
} as Response;
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
const { default: SessionPage } = await import("./page");
render(
<TestErrorBoundary>
<SessionPage />
</TestErrorBoundary>,
);
await flushAsyncWork();
expect(screen.getByTestId("route-error")).toHaveTextContent("HTTP 500");
});
});

View File

@ -1,7 +1,7 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { useParams } from "next/navigation";
import { notFound, useParams } from "next/navigation";
import { isOrchestratorSession } from "@composio/ao-core/types";
import { SessionDetail } from "@/components/SessionDetail";
import { type DashboardSession, type ActivityState, getAttentionLevel, type AttentionLevel } from "@/lib/types";
@ -63,7 +63,8 @@ export default function SessionPage() {
const [zoneCounts, setZoneCounts] = useState<ZoneCounts | null>(null);
const [projectOrchestratorId, setProjectOrchestratorId] = useState<string | null | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [routeError, setRouteError] = useState<Error | null>(null);
const [sessionMissing, setSessionMissing] = useState(false);
const [prefixByProject, setPrefixByProject] = useState<Map<string, string>>(new Map());
const sessionProjectId = session?.projectId ?? null;
const allPrefixes = [...prefixByProject.values()];
@ -119,17 +120,18 @@ export default function SessionPage() {
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`);
if (res.status === 404) {
setError("Session not found");
setSessionMissing(true);
setLoading(false);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as DashboardSession;
setSession(data);
setError(null);
setRouteError(null);
setSessionMissing(false);
} catch (err) {
console.error("Failed to fetch session:", err);
setError("Failed to load session");
setRouteError(err instanceof Error ? err : new Error("Failed to load session"));
} finally {
setLoading(false);
}
@ -211,17 +213,17 @@ export default function SessionPage() {
);
}
if (error || !session) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--color-bg-base)]">
<div className="text-[13px] text-[var(--color-status-error)]">
{error ?? "Session not found"}
</div>
<a href="/" className="text-[12px] text-[var(--color-accent)] hover:underline">
Back to dashboard
</a>
</div>
);
if (sessionMissing) {
notFound();
return null;
}
if (routeError) {
throw routeError;
}
if (!session) {
throw new Error("Session data was unavailable after loading completed");
}
return (

View File

@ -0,0 +1,204 @@
"use client";
import Link from "next/link";
import { type ReactNode } from "react";
type ErrorTone = "error" | "warning" | "not-found";
interface ErrorAction {
label: string;
onClick?: () => void;
href?: string;
}
interface ErrorDisplayProps {
title: string;
message: string;
tone?: ErrorTone;
detailsTitle?: string;
error?: Error & { digest?: string };
primaryAction?: ErrorAction;
secondaryAction?: ErrorAction;
compact?: boolean;
chrome?: "page" | "card";
children?: ReactNode;
}
const toneMeta: Record<ErrorTone, { accent: string; bg: string; border: string; label: string }> = {
error: {
accent: "var(--color-status-error)",
bg: "var(--color-tint-red)",
border: "color-mix(in srgb, var(--color-status-error) 24%, transparent)",
label: "error",
},
warning: {
accent: "var(--color-status-attention)",
bg: "var(--color-tint-yellow)",
border: "color-mix(in srgb, var(--color-status-attention) 24%, transparent)",
label: "warning",
},
"not-found": {
accent: "var(--color-accent)",
bg: "var(--color-tint-blue)",
border: "color-mix(in srgb, var(--color-accent) 24%, transparent)",
label: "missing",
},
};
function TerminalIcon({ accent }: { accent: string }) {
return (
<div
className="flex h-14 w-14 items-center justify-center rounded-2xl border"
style={{
background: `linear-gradient(180deg, color-mix(in srgb, ${accent} 16%, var(--color-bg-elevated)) 0%, var(--color-bg-surface) 100%)`,
borderColor: `color-mix(in srgb, ${accent} 18%, var(--color-border-default))`,
boxShadow: "var(--detail-card-shadow)",
}}
>
<svg
className="h-7 w-7"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
style={{ color: accent }}
viewBox="0 0 24 24"
>
<rect x="2.5" y="4.5" width="19" height="15" rx="3" />
<path d="M6.5 9.5l3.25 2.5-3.25 2.5M12.75 15h4.75" />
</svg>
</div>
);
}
function ActionButton({ action, primary = false }: { action: ErrorAction; primary?: boolean }) {
const className = primary
? "inline-flex items-center justify-center rounded-full border px-4 py-2 text-[12px] font-semibold transition-colors hover:no-underline"
: "inline-flex items-center justify-center rounded-full border px-4 py-2 text-[12px] font-medium transition-colors hover:no-underline";
const style = primary
? {
background: "var(--color-accent)",
color: "var(--color-text-inverse)",
borderColor: "color-mix(in srgb, var(--color-accent) 72%, transparent)",
}
: {
background: "var(--color-bg-surface)",
color: "var(--color-text-secondary)",
borderColor: "var(--color-border-default)",
};
if (action.href) {
return (
<Link href={action.href} className={className} style={style}>
{action.label}
</Link>
);
}
return (
<button type="button" onClick={action.onClick} className={className} style={style}>
{action.label}
</button>
);
}
export function ErrorDisplay({
title,
message,
tone = "error",
detailsTitle = "Technical details",
error,
primaryAction,
secondaryAction,
compact = false,
chrome = "page",
children,
}: ErrorDisplayProps) {
const meta = toneMeta[tone];
const hasDetails = Boolean(error?.digest || error?.message || error?.stack);
return (
<div
className={`flex w-full items-center justify-center px-6 py-10 ${compact ? "min-h-[calc(100vh-4rem)]" : "min-h-screen"}`}
style={{
background:
chrome === "page"
? "radial-gradient(circle at top, var(--color-body-gradient-blue), transparent 35%), var(--color-bg-base)"
: "transparent",
}}
>
<div
className="w-full max-w-[36rem] rounded-[28px] border p-6 sm:p-8"
style={{
background:
"linear-gradient(180deg, color-mix(in srgb, var(--color-bg-elevated) 88%, transparent) 0%, var(--color-bg-surface) 100%)",
borderColor: "var(--color-border-default)",
boxShadow: "var(--detail-card-shadow)",
}}
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<TerminalIcon accent={meta.accent} />
<div className="min-w-0 flex-1">
<div
className="mb-3 inline-flex items-center rounded-full border px-2.5 py-1 font-[var(--font-mono)] text-[10px] font-medium uppercase tracking-[0.22em]"
style={{
color: meta.accent,
background: meta.bg,
borderColor: meta.border,
}}
>
{meta.label}
</div>
<h1 className="text-[24px] font-semibold tracking-[-0.04em] text-[var(--color-text-primary)]">
{title}
</h1>
<p className="mt-2 max-w-[34rem] text-[14px] leading-6 text-[var(--color-text-secondary)]">
{message}
</p>
</div>
</div>
{(primaryAction || secondaryAction) && (
<div className="flex flex-wrap gap-3">
{primaryAction ? <ActionButton action={primaryAction} primary /> : null}
{secondaryAction ? <ActionButton action={secondaryAction} /> : null}
</div>
)}
{children}
{hasDetails ? (
<details
className="rounded-2xl border"
style={{
background: "color-mix(in srgb, var(--color-bg-elevated) 84%, transparent)",
borderColor: "var(--color-border-subtle)",
}}
>
<summary className="cursor-pointer list-none px-4 py-3 text-[12px] font-medium text-[var(--color-text-secondary)]">
{detailsTitle}
</summary>
<div className="border-t px-4 py-4" style={{ borderColor: "var(--color-border-subtle)" }}>
{error?.digest ? (
<p className="mb-3 font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
digest: {error.digest}
</p>
) : null}
{error?.message ? (
<p className="mb-3 text-[12px] leading-5 text-[var(--color-text-secondary)]">
{error.message}
</p>
) : null}
{error?.stack ? (
<pre className="overflow-x-auto whitespace-pre-wrap font-[var(--font-mono)] text-[11px] leading-5 text-[var(--color-text-tertiary)]">
{error.stack}
</pre>
) : null}
</div>
</details>
) : null}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,50 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("next/link", () => ({
default: ({
children,
...props
}: React.PropsWithChildren<React.AnchorHTMLAttributes<HTMLAnchorElement>>) => (
<a {...props}>{children}</a>
),
}));
import { ErrorDisplay } from "../ErrorDisplay";
describe("ErrorDisplay", () => {
it("renders title, message, and actions", () => {
const onRetry = vi.fn();
render(
<ErrorDisplay
title="Failed to load session"
message="Try again."
primaryAction={{ label: "Try again", onClick: onRetry }}
secondaryAction={{ label: "Back to dashboard", href: "/" }}
/>,
);
expect(screen.getByText("Failed to load session")).toBeInTheDocument();
expect(screen.getByText("Try again.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(onRetry).toHaveBeenCalledTimes(1);
expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute("href", "/");
});
it("renders technical details when provided", () => {
render(
<ErrorDisplay
title="Something went wrong"
message="Details are available."
error={Object.assign(new Error("HTTP 500"), { digest: "abc123" })}
/>,
);
expect(screen.getByText("Technical details")).toBeInTheDocument();
expect(screen.getByText(/digest: abc123/)).toBeInTheDocument();
expect(screen.getByText("HTTP 500")).toBeInTheDocument();
});
});