diff --git a/apps/gittensory-ui/src/components/site/state-views.test.tsx b/apps/gittensory-ui/src/components/site/state-views.test.tsx
new file mode 100644
index 0000000000..5a64223d78
--- /dev/null
+++ b/apps/gittensory-ui/src/components/site/state-views.test.tsx
@@ -0,0 +1,140 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+const { notifyApiFailure } = vi.hoisted(() => ({ notifyApiFailure: vi.fn() }));
+vi.mock("@/lib/api/request", () => ({
+ notifyApiFailure: (...args: unknown[]) => notifyApiFailure(...args),
+}));
+vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }) }));
+
+import { ErrorState, StateBoundary } from "@/components/site/state-views";
+
+describe("ErrorState network-vs-API distinction (#793)", () => {
+ it("uses the generic 'couldn't load' copy when no errorKind is given (unchanged default)", () => {
+ render( );
+ expect(screen.getByText("Couldn't load this")).toBeTruthy();
+ });
+
+ it("uses connectivity-specific copy for a network errorKind", () => {
+ render( );
+ expect(screen.getByText("Can't reach the server")).toBeTruthy();
+ });
+
+ it("uses connectivity-specific copy for a timeout errorKind too", () => {
+ render( );
+ expect(screen.getByText("Can't reach the server")).toBeTruthy();
+ });
+
+ it("falls back to the generic copy for an http errorKind", () => {
+ render( );
+ expect(screen.getByText("Couldn't load this")).toBeTruthy();
+ });
+
+ it("lets an explicit title/description override the errorKind-derived copy", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Custom title")).toBeTruthy();
+ expect(screen.getByText("Custom description")).toBeTruthy();
+ expect(screen.queryByText("Can't reach the server")).toBeNull();
+ });
+});
+
+describe("StateBoundary loadingSkeleton (#793)", () => {
+ it("renders the default spinner LoadingState when no skeleton is given", () => {
+ render(
+
+ content
+ ,
+ );
+ expect(screen.getByRole("status")).toBeTruthy();
+ expect(screen.queryByText("content")).toBeNull();
+ });
+
+ it("renders the provided skeleton instead of the spinner when loading", () => {
+ render(
+ placeholder}>
+ content
+ ,
+ );
+ expect(screen.getByTestId("skeleton")).toBeTruthy();
+ expect(screen.queryByRole("status")).toBeNull();
+ expect(screen.queryByText("content")).toBeNull();
+ });
+});
+
+describe("StateBoundary errorKind passthrough (#793)", () => {
+ it("keeps the pre-#793 default error copy when no errorKind is given", () => {
+ render(
+
+ content
+ ,
+ );
+ expect(screen.getByText("Couldn't load data")).toBeTruthy();
+ });
+
+ it("falls through to ErrorState's network-aware copy when errorKind is network and no override is given", () => {
+ render(
+
+ content
+ ,
+ );
+ expect(screen.getByText("Can't reach the server")).toBeTruthy();
+ expect(screen.queryByText("Couldn't load data")).toBeNull();
+ });
+
+ it("lets an explicit errorTitle/errorDescription win even with a network errorKind", () => {
+ render(
+
+ content
+ ,
+ );
+ expect(screen.getByText("Custom title")).toBeTruthy();
+ expect(screen.getByText("Custom description")).toBeTruthy();
+ });
+
+ it("passes the real errorKind (not a hardcoded 'network') to the error-failure notifier", () => {
+ render(
+
+ content
+ ,
+ );
+ expect(notifyApiFailure).toHaveBeenCalledWith(expect.objectContaining({ kind: "http" }));
+ });
+
+ it("defaults the notifier kind to 'network' when no errorKind is given, matching pre-#793 behavior", () => {
+ render(
+
+ content
+ ,
+ );
+ expect(notifyApiFailure).toHaveBeenCalledWith(expect.objectContaining({ kind: "network" }));
+ });
+});
+
+describe("StateBoundary retry/refresh actions (#793 regression guard)", () => {
+ it("still invokes onRetry from the error state's retry button", () => {
+ const onRetry = vi.fn();
+ render(
+
+ content
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: /try again/i }));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders children unchanged when neither loading, error, nor empty", () => {
+ render(
+
+ content
+ ,
+ );
+ expect(screen.getByText("content")).toBeTruthy();
+ });
+});
diff --git a/apps/gittensory-ui/src/components/site/state-views.tsx b/apps/gittensory-ui/src/components/site/state-views.tsx
index f9eafcf88e..c860be86ff 100644
--- a/apps/gittensory-ui/src/components/site/state-views.tsx
+++ b/apps/gittensory-ui/src/components/site/state-views.tsx
@@ -1,9 +1,13 @@
-import { Loader2, Inbox, AlertTriangle, RefreshCw } from "lucide-react";
+import { Loader2, Inbox, AlertTriangle, RefreshCw, WifiOff } from "lucide-react";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
-import { notifyApiFailure } from "@/lib/api/request";
+import { notifyApiFailure, type ApiFailureKind } from "@/lib/api/request";
+
+/** `errorKind`s that mean "we never reached the server" as opposed to "the server answered with an
+ * error" — StateBoundary/ErrorState use this to show connectivity-specific copy and iconography (#793). */
+const NETWORK_ERROR_KINDS: ReadonlySet = new Set(["network", "timeout"]);
/**
* Shared state primitives so every panel/route renders a consistent
@@ -140,8 +144,9 @@ export function StateActionButton({
}
export function ErrorState({
- title = "Couldn't load this",
- description = "Something went wrong fetching this data. You can retry, or come back in a moment. If this keeps happening, check status or try again shortly.",
+ title,
+ description,
+ errorKind,
onRetry,
retryLabel = "Try again",
secondaryAction,
@@ -150,18 +155,34 @@ export function ErrorState({
}: {
title?: string;
description?: ReactNode;
+ /** Distinguishes "couldn't reach the server at all" from "the server answered with an error" (#793).
+ * Only changes the default `title`/`description` — an explicit `title`/`description` always wins. */
+ errorKind?: ApiFailureKind;
onRetry?: () => void;
retryLabel?: string;
secondaryAction?: ReactNode;
toastOnRetry?: boolean;
className?: string;
}) {
+ const isNetworkIssue = errorKind !== undefined && NETWORK_ERROR_KINDS.has(errorKind);
+ const resolvedTitle = title ?? (isNetworkIssue ? "Can't reach the server" : "Couldn't load this");
+ const resolvedDescription =
+ description ??
+ (isNetworkIssue
+ ? "We couldn't reach the API — check your connection and retry. This is a connectivity issue, not a problem with the data itself."
+ : "Something went wrong fetching this data. You can retry, or come back in a moment. If this keeps happening, check status or try again shortly.");
return (
}
- title={title}
- description={description}
+ icon={
+ isNetworkIssue ? (
+
+ ) : (
+
+ )
+ }
+ title={resolvedTitle}
+ description={resolvedDescription}
action={
onRetry && (
void;
onRefresh?: () => void;
/** When provided, surfaces a global API-failure toast with a Retry action. */
errorLabel?: string;
children: ReactNode;
}) {
+ const isNetworkIssue = errorKind !== undefined && NETWORK_ERROR_KINDS.has(errorKind);
+ // Preserve the pre-#793 defaults exactly when no errorKind is given; when one is given and the caller
+ // didn't override the copy, fall through to ErrorState's own network-aware defaults instead.
+ const resolvedErrorTitle = errorTitle ?? (isNetworkIssue ? undefined : "Couldn't load data");
+ const resolvedErrorDescription =
+ errorDescription ??
+ (isNetworkIssue
+ ? undefined
+ : "The data source did not respond. Retry the request, or check back once the service has recovered.");
+
// When this boundary flips into the error state, surface a toast with Retry.
useEffect(() => {
if (isError && errorLabel) {
notifyApiFailure({
label: errorLabel,
- kind: "network",
+ kind: errorKind ?? "network",
message:
- typeof errorDescription === "string" ? errorDescription : "Data source did not respond.",
+ typeof resolvedErrorDescription === "string"
+ ? resolvedErrorDescription
+ : "Data source did not respond.",
retry: onRetry,
});
}
- }, [isError, errorLabel, errorDescription, onRetry]);
+ }, [isError, errorLabel, errorKind, resolvedErrorDescription, onRetry]);
if (isLoading) {
- return ;
+ return (
+ loadingSkeleton ??
+ );
}
if (isError) {
return (
{
expect(result.current.loadedAt).toBeNull();
});
});
+
+describe("useApiResource errorKind/errorStatus (#793)", () => {
+ it("carries the apiFetch failure kind and status through to the error state", async () => {
+ apiFetch.mockResolvedValue({
+ ok: false,
+ kind: "http",
+ message: "500 Internal Server Error",
+ status: 500,
+ durationMs: 5,
+ });
+ const { result } = renderHook(() => useApiResource("/v1/thing", "Thing"));
+ await waitFor(() => expect(result.current.status).toBe("error"));
+ expect(result.current).toMatchObject({ errorKind: "http", errorStatus: 500 });
+ });
+
+ it("carries a network failure kind with no status", async () => {
+ apiFetch.mockResolvedValue({
+ ok: false,
+ kind: "network",
+ message: "fetch failed",
+ durationMs: 5,
+ });
+ const { result } = renderHook(() => useApiResource("/v1/thing", "Thing"));
+ await waitFor(() => expect(result.current.status).toBe("error"));
+ expect(result.current).toMatchObject({ errorKind: "network", errorStatus: undefined });
+ });
+
+ it("carries a timeout failure kind", async () => {
+ apiFetch.mockResolvedValue({
+ ok: false,
+ kind: "timeout",
+ message: "Request timed out",
+ durationMs: 5,
+ });
+ const { result } = renderHook(() => useApiResource("/v1/thing", "Thing"));
+ await waitFor(() => expect(result.current.status).toBe("error"));
+ expect(result.current).toMatchObject({ errorKind: "timeout" });
+ });
+
+ it("leaves errorKind undefined for the synthetic disabled sentinel", async () => {
+ const { result } = renderHook(() =>
+ useApiResource("/v1/thing", "Thing", undefined, { enabled: false }),
+ );
+ await waitFor(() => expect(result.current.status).toBe("error"));
+ const state = result.current;
+ if (state.status !== "error") throw new Error("expected error status");
+ expect(state.error).toBe("disabled");
+ expect(state.errorKind).toBeUndefined();
+ });
+});
diff --git a/apps/gittensory-ui/src/lib/api/use-api-resource.ts b/apps/gittensory-ui/src/lib/api/use-api-resource.ts
index fc59b3cdf7..14bf95cfb5 100644
--- a/apps/gittensory-ui/src/lib/api/use-api-resource.ts
+++ b/apps/gittensory-ui/src/lib/api/use-api-resource.ts
@@ -1,12 +1,20 @@
import { useCallback, useEffect, useState } from "react";
import { getApiOrigin } from "./origin";
-import { apiFetch } from "./request";
+import { apiFetch, type ApiFailureKind } from "./request";
type ResourceState =
| { status: "loading"; data: null; error: null; loadedAt: null }
| { status: "ready"; data: T; error: null; loadedAt: number }
- | { status: "error"; data: null; error: string; loadedAt: null };
+ | {
+ status: "error";
+ data: null;
+ error: string;
+ /** Absent for the synthetic "disabled" sentinel below — only real `apiFetch` failures carry one (#793). */
+ errorKind?: ApiFailureKind;
+ errorStatus?: number;
+ loadedAt: null;
+ };
type UseApiResourceOptions = {
enabled?: boolean;
@@ -42,7 +50,14 @@ export function useApiResource(
if (result.ok) {
setState({ status: "ready", data: result.data, error: null, loadedAt: Date.now() });
} else {
- setState({ status: "error", data: null, error: result.message, loadedAt: null });
+ setState({
+ status: "error",
+ data: null,
+ error: result.message,
+ errorKind: result.kind,
+ errorStatus: result.status,
+ loadedAt: null,
+ });
}
}, [enabled, label, path, token]);
diff --git a/apps/gittensory-ui/src/routes/app.runs.tsx b/apps/gittensory-ui/src/routes/app.runs.tsx
index 9c1e524d52..154e054b3f 100644
--- a/apps/gittensory-ui/src/routes/app.runs.tsx
+++ b/apps/gittensory-ui/src/routes/app.runs.tsx
@@ -24,7 +24,9 @@ import {
} from "@/components/site/control-primitives";
import { useApiResource } from "@/lib/api/use-api-resource";
import { useSession } from "@/lib/api/session";
-import { EmptyState } from "@/components/site/state-views";
+import { EmptyState, StateBoundary } from "@/components/site/state-views";
+import { RefreshMeta } from "@/components/site/refresh-meta";
+import { Skeleton } from "@/components/ui/skeleton";
import { useLocalStorage } from "@/lib/use-local-storage";
import { cn } from "@/lib/utils";
import { SnapshotReplayCard } from "@/components/site/snapshot-replay";
@@ -232,139 +234,149 @@ function AgentRuns() {
and a public/private boundary.
- {sourceLabel}
-
-
- {canUseLiveRuns && liveRuns.status === "error" && liveRuns.error !== "disabled" && (
-
- Live runs are unavailable right now ({liveRuns.error}).
+
+ {sourceLabel}
+
- )}
+
-
-
-
-
- Status
-
-
- {STATUS_FILTERS.map((s) => (
- setStatus(s)}>
- {s}
-
- ))}
-
-
-
- Kind
-
-
- {KIND_FILTERS.map((k) => (
- setKind(k)}>
- {k}
-
- ))}
-
-
-
-
setQ(e.target.value)}
- placeholder="Search runs…"
- className="w-40 border-0 bg-transparent py-1 text-token-sm outline-none placeholder:text-muted-foreground"
- />
+
}
+ >
+
+
+
+
+
+ Status
+
+
+ {STATUS_FILTERS.map((s) => (
+ setStatus(s)}>
+ {s}
+
+ ))}
+
+
+
+ Kind
+
+
+ {KIND_FILTERS.map((k) => (
+ setKind(k)}>
+ {k}
+
+ ))}
+
+
+
+ setQ(e.target.value)}
+ placeholder="Search runs…"
+ className="w-40 border-0 bg-transparent py-1 text-token-sm outline-none placeholder:text-muted-foreground"
+ />
+
+
-
-
-
- navigate({
- search: () => ({
- status: v.status === "all" ? undefined : v.status,
- kind: v.kind === "all" ? undefined : v.kind,
- q: v.q ? v.q : undefined,
- }),
- replace: true,
- })
- }
- />
+
+ navigate({
+ search: () => ({
+ status: v.status === "all" ? undefined : v.status,
+ kind: v.kind === "all" ? undefined : v.kind,
+ q: v.q ? v.q : undefined,
+ }),
+ replace: true,
+ })
+ }
+ />
-
- Showing {filtered.length} of {runs.length}
-
+
+ Showing {filtered.length} of {runs.length}
+
- {filtered.length === 0 ? (
-
-
- {
- setStatus("all");
- setKind("all");
- setQ("");
- toast("Filters cleared", {
- description: "Showing all available agent runs again.",
- });
- }}
- className="inline-flex min-w-0 items-center justify-center rounded-token border border-border bg-transparent px-3 py-1.5 text-center text-token-xs font-medium text-foreground transition-all duration-150 hover:bg-accent focus-ring motion-reduce:transition-none motion-reduce:active:scale-100 active:scale-[0.98]"
- >
- Clear filters
-
- }
- />
-
-
- ) : (
-
- {grouped.map((bucket) => (
-
-
- {bucket.label} · {bucket.runs.length}
-
-
- {bucket.runs.map((r) => (
-
+ {filtered.length === 0 ? (
+
+
+ setSelected(r.id)}
- aria-current={selectedId === r.id ? "true" : undefined}
- className={cn(
- "grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-token border bg-transparent p-3 text-left transition-all duration-150 focus-ring motion-reduce:transition-none motion-reduce:active:scale-100 active:scale-[0.99]",
- selectedId === r.id
- ? "border-mint/40 bg-mint/[0.04]"
- : "border-border hover:border-foreground/30",
- )}
+ onClick={() => {
+ setStatus("all");
+ setKind("all");
+ setQ("");
+ toast("Filters cleared", {
+ description: "Showing all available agent runs again.",
+ });
+ }}
+ className="inline-flex min-w-0 items-center justify-center rounded-token border border-border bg-transparent px-3 py-1.5 text-center text-token-xs font-medium text-foreground transition-all duration-150 hover:bg-accent focus-ring motion-reduce:transition-none motion-reduce:active:scale-100 active:scale-[0.98]"
>
-
- {r.signal_fidelity}
-
-
-
{r.kind}
-
- {r.id}
- ·
- {r.source}
- ·
- {r.repo}
-
-
-
- {new Date(r.created_at).toUTCString().slice(5, 22)}
-
+ Clear filters
-
- ))}
-
-
- ))}
+ }
+ />
+
+
+ ) : (
+
+ {grouped.map((bucket) => (
+
+
+ {bucket.label} · {bucket.runs.length}
+
+
+ {bucket.runs.map((r) => (
+
+ setSelected(r.id)}
+ aria-current={selectedId === r.id ? "true" : undefined}
+ className={cn(
+ "grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-token border bg-transparent p-3 text-left transition-all duration-150 focus-ring motion-reduce:transition-none motion-reduce:active:scale-100 active:scale-[0.99]",
+ selectedId === r.id
+ ? "border-mint/40 bg-mint/[0.04]"
+ : "border-border hover:border-foreground/30",
+ )}
+ >
+
+ {r.signal_fidelity}
+
+
+
{r.kind}
+
+ {r.id}
+ ·
+ {r.source}
+ ·
+ {r.repo}
+
+
+
+ {new Date(r.created_at).toUTCString().slice(5, 22)}
+
+
+
+ ))}
+
+
+ ))}
+
+ )}
- )}
+
+
+
+ {Array.from({ length: 5 }, (_, index) => (
+
+
+
+ ))}
+
+
+ );
+}
+
function Chip({
active,
onClick,