feat(web): unify empty/error/loading states in one primitive

Adds apps/fabro-web/app/components/state.tsx exposing EmptyState,
ErrorState, and LoadingState on a shared StatePanel chrome so every
"the content isn't ready" surface looks like the same app.

Swaps in place of bare <p> tags and ad-hoc bordered divs:
- run-detail: "Run not found" is now an ErrorState
- run-stages: "No stages yet" is an EmptyState
- run-overview: empty-graph panel is an EmptyState
- run-billing: empty-billing panel is an EmptyState
- runs: filtered-empty ("no matching runs") now renders an EmptyState
  (the branded landing empty is preserved as RunsLandingEmpty)
- install-app: session-loading StatusPanel replaced by LoadingState

No change to the root ErrorBoundary — full-page crashes stay there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-19 17:22:57 -04:00
parent b448a4e246
commit 54867d29ed
No known key found for this signature in database
10 changed files with 310 additions and 186 deletions

View file

@ -0,0 +1,102 @@
import type { ComponentType, ReactNode, SVGProps } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { SECONDARY_BUTTON_CLASS } from "./ui";
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
// Shared chrome for non-critical "the content isn't there" states. Full-page
// crashes are handled by the root ErrorBoundary; these render inside the
// current content column.
function StatePanel({ children }: { children: ReactNode }) {
return (
<div className="mx-auto flex max-w-md flex-col items-center rounded-md border border-line bg-panel p-8 text-center">
{children}
</div>
);
}
export function EmptyState({
icon: Icon,
title,
description,
action,
}: {
icon?: IconComponent;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<StatePanel>
{Icon ? (
<Icon className="mb-3 size-6 text-fg-muted" aria-hidden="true" />
) : null}
<p className="text-sm font-medium text-fg">{title}</p>
{description ? (
<p className="mt-1 max-w-[48ch] text-sm/6 text-fg-3">{description}</p>
) : null}
{action ? <div className="mt-4">{action}</div> : null}
</StatePanel>
);
}
export function ErrorState({
title = "Something went wrong",
description,
onRetry,
}: {
title?: string;
description?: string;
onRetry?: () => void;
}) {
return (
<StatePanel>
<ExclamationTriangleIcon
className="mb-3 size-6 text-coral"
aria-hidden="true"
/>
<p className="text-sm font-medium text-fg">{title}</p>
{description ? (
<p className="mt-1 max-w-[48ch] text-sm/6 text-fg-3">{description}</p>
) : null}
{onRetry ? (
<button
type="button"
onClick={onRetry}
className={`${SECONDARY_BUTTON_CLASS} mt-4`}
>
Try again
</button>
) : null}
</StatePanel>
);
}
export function LoadingState({ label }: { label?: string }) {
return (
<StatePanel>
<Spinner className="mb-3 size-5 text-teal-500" />
<p className="text-sm text-fg-3">{label ?? "Loading…"}</p>
</StatePanel>
);
}
function Spinner({ className = "" }: { className?: string }) {
return (
<svg
className={`shrink-0 animate-spin ${className}`}
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
<path
d="M14 8a6 6 0 0 0-6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}

View file

@ -43,6 +43,7 @@ import {
PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS,
} from "./components/ui";
import { LoadingState } from "./components/state";
const INSTALL_STEPS = [
{ id: "welcome", label: "Welcome", href: "/install/welcome" },
@ -260,9 +261,7 @@ export default function InstallApp() {
if (sessionState.status === "loading") {
return (
<InstallLayout currentStep={currentStep} completedSteps={completedSteps}>
<StatusPanel title="Connecting to install session">
Reading the current install state from the server.
</StatusPanel>
<LoadingState label="Connecting to install session…" />
</InstallLayout>
);
}
@ -1195,24 +1194,6 @@ function GithubAppDoneScreen({
);
}
function StatusPanel({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<div className="flex items-start gap-3 rounded-lg bg-overlay px-4 py-3 outline-1 -outline-offset-1 outline-white/10">
<Spinner className="mt-0.5 text-teal-300" />
<div>
<p className="text-sm font-medium text-fg">{title}</p>
<p className="mt-1 text-sm/6 text-fg-3">{children}</p>
</div>
</div>
);
}
function SummaryRow({
label,
value,

View file

@ -1,4 +1,5 @@
import { apiJson } from "../api";
import { EmptyState } from "../components/state";
import { formatDurationSecs } from "../lib/format";
import type { RunBilling } from "@qltysh/fabro-api-client";
@ -42,8 +43,11 @@ export default function RunBilling({ loaderData }: any) {
if (!stages.length) {
return (
<div className="rounded-md border border-line bg-panel-alt p-10 text-center">
<p className="text-sm/6 text-fg-3">No billing recorded for this run yet.</p>
<div className="py-12">
<EmptyState
title="No billing yet"
description="Token usage and cost will appear here once stages complete."
/>
</div>
);
}

View file

@ -4,6 +4,7 @@ import { Link, Outlet, useFetcher, useLocation } from "react-router";
import { mapRunSummaryToRunItem, runStatusDisplay, isRunStatus } from "../data/runs";
import type { RunSummaryResponse } from "../data/runs";
import { apiJson } from "../api";
import { ErrorState } from "../components/state";
import { useDemoMode } from "../lib/demo-mode";
import type { PreviewUrlResponse } from "@qltysh/fabro-api-client";
@ -72,7 +73,14 @@ export default function RunDetail({ loaderData, params }: any) {
}, [previewFetcher.data]);
if (!run) {
return <p className="py-8 text-center text-sm text-fg-muted">Run not found.</p>;
return (
<div className="py-12">
<ErrorState
title="Run not found"
description="The run you're looking for doesn't exist or was deleted."
/>
</div>
);
}
return (

View file

@ -12,6 +12,7 @@ import {
GRAPH_ZOOM_STEPS,
GraphToolbar,
} from "../components/graph-toolbar";
import { EmptyState } from "../components/state";
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
export const handle = { wide: true };
@ -230,9 +231,10 @@ export default function RunOverview({ loaderData }: any) {
</div>
</div>
) : (
<div className="rounded-md border border-line bg-panel-alt p-10 text-center">
<p className="text-sm/6 text-fg-3">No workflow graph available.</p>
</div>
<EmptyState
title="No workflow graph"
description="This run doesn't have a renderable graph yet."
/>
)}
</div>
</div>

View file

@ -6,6 +6,7 @@ import { ToolBlock } from "../components/tool-use";
import type { ToolUse } from "../components/tool-use";
import { StageSidebar, statusConfig } from "../components/stage-sidebar";
import type { Stage } from "../components/stage-sidebar";
import { EmptyState } from "../components/state";
import { apiJson, apiJsonOrNull } from "../api";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "../lib/format";
@ -313,7 +314,14 @@ export default function RunStages({ loaderData }: any) {
}, [isRunning]);
if (!stages.length) {
return <p className="py-8 text-center text-sm text-fg-muted">No stages available for this run.</p>;
return (
<div className="py-12">
<EmptyState
title="No stages yet"
description="Stages will appear here once the run begins executing."
/>
</div>
);
}
const selectedConfig = statusConfig[selectedStage.status];

View file

@ -21,6 +21,7 @@ import { CSS } from "@dnd-kit/utilities";
import { ciConfig, columnStatusDisplay, deriveCiStatus, mapRunListItem } from "../data/runs";
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs";
import { apiPaginatedJson } from "../api";
import { EmptyState } from "../components/state";
import type { PaginatedBoardRunList } from "@qltysh/fabro-api-client";
export function meta({}: any) {
@ -607,7 +608,7 @@ function CopyButton({ text }: { text: string }) {
);
}
function EmptyState() {
function RunsLandingEmpty() {
return (
<div className="mt-4 flex flex-col items-center">
<div className="w-full max-w-lg space-y-5">
@ -741,6 +742,10 @@ export default function Runs({ loaderData }: any) {
(item.number != null && `#${item.number}`.includes(lowerQuery))),
),
}));
const filteredRuns = filteredColumns.reduce(
(sum, col) => sum + col.items.length,
0,
);
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
@ -808,9 +813,16 @@ export default function Runs({ loaderData }: any) {
</div>
))}
</div>
{totalRuns === 0 && (
<EmptyState />
)}
{totalRuns === 0 ? (
<RunsLandingEmpty />
) : filteredRuns === 0 ? (
<div className="py-8">
<EmptyState
title="No matching runs"
description="Try clearing the search or repo filter."
/>
</div>
) : null}
</>
) : (
<>
@ -853,9 +865,16 @@ export default function Runs({ loaderData }: any) {
);
})}
</div>
{totalRuns === 0 && (
<EmptyState />
)}
{totalRuns === 0 ? (
<RunsLandingEmpty />
) : filteredRuns === 0 ? (
<div className="py-8">
<EmptyState
title="No matching runs"
description="Try clearing the search or repo filter."
/>
</div>
) : null}
</>
)}
</div>

File diff suppressed because one or more lines are too long

View file

@ -61,7 +61,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-h24h4gjh.js"></script>
<script type="module" src="/assets/entry-gv0e8m65.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>