diff --git a/packages/cli/package.json b/packages/cli/package.json index 9d4c98e55..b0609d3c7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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": { diff --git a/packages/web/package.json b/packages/web/package.json index 59e0a6306..962df764c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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", diff --git a/packages/web/src/app/error.test.tsx b/packages/web/src/app/error.test.tsx new file mode 100644 index 000000000..5e120196f --- /dev/null +++ b/packages/web/src/app/error.test.tsx @@ -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(); + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + + expect(reset).toHaveBeenCalledTimes(1); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("renders a dashboard escape hatch", () => { + render(); + + expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute("href", "/"); + }); +}); diff --git a/packages/web/src/app/error.tsx b/packages/web/src/app/error.tsx new file mode 100644 index 000000000..ba96be99a --- /dev/null +++ b/packages/web/src/app/error.tsx @@ -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 ( + { + reset(); + router.refresh(); + }, + }} + secondaryAction={{ label: "Back to dashboard", href: "/" }} + error={error} + compact + chrome="card" + /> + ); +} diff --git a/packages/web/src/app/global-error.test.tsx b/packages/web/src/app/global-error.test.tsx new file mode 100644 index 000000000..461d1c744 --- /dev/null +++ b/packages/web/src/app/global-error.test.tsx @@ -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 ( +
+
{props.title}
+
{props.message}
+ {props.primaryAction ? ( + + ) : null} + {props.secondaryAction ? ( + + ) : null} +
+ ); + }, +})); + +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(); + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(reset).toHaveBeenCalledTimes(1); + }); + + it("reloads the page on demand", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Reload page" })); + expect(window.location.reload).toHaveBeenCalledTimes(1); + }); + + it("passes the expected shell copy to ErrorDisplay", () => { + render(); + + 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.", + }), + ); + }); +}); diff --git a/packages/web/src/app/global-error.tsx b/packages/web/src/app/global-error.tsx new file mode 100644 index 000000000..fa1784e5f --- /dev/null +++ b/packages/web/src/app/global-error.tsx @@ -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 ( + + + window.location.reload() }} + error={error} + /> + + + ); +} diff --git a/packages/web/src/app/not-found.test.tsx b/packages/web/src/app/not-found.test.tsx index c370cae0a..c2678acbc 100644 --- a/packages/web/src/app/not-found.test.tsx +++ b/packages/web/src/app/not-found.test.tsx @@ -18,9 +18,18 @@ describe("NotFound (global)", () => { expect(screen.getByText("Page not found")).toBeInTheDocument(); }); + it("renders descriptive copy", () => { + render(); + 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(); - const link = screen.getByText("← Back to dashboard"); + const link = screen.getByText("Back to dashboard"); expect(link).toBeInTheDocument(); expect(link.closest("a")).toHaveAttribute("href", "/"); }); diff --git a/packages/web/src/app/not-found.tsx b/packages/web/src/app/not-found.tsx index 868beb5f0..8b817324d 100644 --- a/packages/web/src/app/not-found.tsx +++ b/packages/web/src/app/not-found.tsx @@ -1,27 +1,12 @@ -import Link from "next/link"; +import { ErrorDisplay } from "@/components/ErrorDisplay"; export default function NotFound() { return ( -
- - - - -

- Page not found -

- - ← Back to dashboard - -
+ ); } diff --git a/packages/web/src/app/sessions/[id]/error.test.tsx b/packages/web/src/app/sessions/[id]/error.test.tsx new file mode 100644 index 000000000..adcfb9d68 --- /dev/null +++ b/packages/web/src/app/sessions/[id]/error.test.tsx @@ -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(); + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + + expect(reset).toHaveBeenCalledTimes(1); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("shows a session-specific message", () => { + render(); + + expect( + screen.getByText( + "The session request failed before the dashboard got a response. Check the server connection and try again.", + ), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/app/sessions/[id]/error.tsx b/packages/web/src/app/sessions/[id]/error.tsx new file mode 100644 index 000000000..f9a0011c1 --- /dev/null +++ b/packages/web/src/app/sessions/[id]/error.tsx @@ -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 ( + { + reset(); + router.refresh(); + }, + }} + secondaryAction={{ label: "Back to dashboard", href: "/" }} + error={error} + compact + chrome="card" + /> + ); +} diff --git a/packages/web/src/app/sessions/[id]/not-found.test.tsx b/packages/web/src/app/sessions/[id]/not-found.test.tsx index 7edf1f315..614ec96de 100644 --- a/packages/web/src/app/sessions/[id]/not-found.test.tsx +++ b/packages/web/src/app/sessions/[id]/not-found.test.tsx @@ -22,14 +22,14 @@ describe("SessionNotFound", () => { render(); expect( screen.getByText( - "The session you\u2019re looking for doesn\u2019t exist or has been deleted.", + "The session you’re looking for does not exist anymore, or the link is stale.", ), ).toBeInTheDocument(); }); it("renders a link back to the dashboard", () => { render(); - const link = screen.getByText("← Back to dashboard"); + const link = screen.getByText("Back to dashboard"); expect(link).toBeInTheDocument(); expect(link.closest("a")).toHaveAttribute("href", "/"); }); diff --git a/packages/web/src/app/sessions/[id]/not-found.tsx b/packages/web/src/app/sessions/[id]/not-found.tsx index 7fd7481fb..4fb654b60 100644 --- a/packages/web/src/app/sessions/[id]/not-found.tsx +++ b/packages/web/src/app/sessions/[id]/not-found.tsx @@ -1,31 +1,14 @@ -import Link from "next/link"; +import { ErrorDisplay } from "@/components/ErrorDisplay"; export default function SessionNotFound() { return ( -
- - - - -

- Session not found -

-

- The session you’re looking for doesn’t exist or has been - deleted. -

- - ← Back to dashboard - -
+ ); } diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx index 45eb15790..3b2243e5a 100644 --- a/packages/web/src/app/sessions/[id]/page.test.tsx +++ b/packages/web/src/app/sessions/[id]/page.test.tsx @@ -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 { }); } +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
{this.state.error.message}
; + } + 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(); + 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( + + + , + ); + + await flushAsyncWork(); + + expect(screen.getByTestId("route-error")).toHaveTextContent("HTTP 500"); + }); }); diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index cf0bf2fe3..f90bf9a81 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -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(null); const [projectOrchestratorId, setProjectOrchestratorId] = useState(undefined); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [routeError, setRouteError] = useState(null); + const [sessionMissing, setSessionMissing] = useState(false); const [prefixByProject, setPrefixByProject] = useState>(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 ( -
-
- {error ?? "Session not found"} -
- - ← Back to dashboard - -
- ); + if (sessionMissing) { + notFound(); + return null; + } + + if (routeError) { + throw routeError; + } + + if (!session) { + throw new Error("Session data was unavailable after loading completed"); } return ( diff --git a/packages/web/src/components/ErrorDisplay.tsx b/packages/web/src/components/ErrorDisplay.tsx new file mode 100644 index 000000000..e520ab979 --- /dev/null +++ b/packages/web/src/components/ErrorDisplay.tsx @@ -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 = { + 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 ( +
+ + + + +
+ ); +} + +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 ( + + {action.label} + + ); + } + + return ( + + ); +} + +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 ( +
+
+
+
+ +
+
+ {meta.label} +
+

+ {title} +

+

+ {message} +

+
+
+ + {(primaryAction || secondaryAction) && ( +
+ {primaryAction ? : null} + {secondaryAction ? : null} +
+ )} + + {children} + + {hasDetails ? ( +
+ + {detailsTitle} + +
+ {error?.digest ? ( +

+ digest: {error.digest} +

+ ) : null} + {error?.message ? ( +

+ {error.message} +

+ ) : null} + {error?.stack ? ( +
+                    {error.stack}
+                  
+ ) : null} +
+
+ ) : null} +
+
+
+ ); +} diff --git a/packages/web/src/components/__tests__/ErrorDisplay.test.tsx b/packages/web/src/components/__tests__/ErrorDisplay.test.tsx new file mode 100644 index 000000000..1c86bff0e --- /dev/null +++ b/packages/web/src/components/__tests__/ErrorDisplay.test.tsx @@ -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>) => ( + {children} + ), +})); + +import { ErrorDisplay } from "../ErrorDisplay"; + +describe("ErrorDisplay", () => { + it("renders title, message, and actions", () => { + const onRetry = vi.fn(); + + render( + , + ); + + 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( + , + ); + + expect(screen.getByText("Technical details")).toBeInTheDocument(); + expect(screen.getByText(/digest: abc123/)).toBeInTheDocument(); + expect(screen.getByText("HTTP 500")).toBeInTheDocument(); + }); +});