mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
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:
parent
b448a4e246
commit
54867d29ed
10 changed files with 310 additions and 186 deletions
102
apps/fabro-web/app/components/state.tsx
Normal file
102
apps/fabro-web/app/components/state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
2
lib/crates/fabro-spa/assets/assets/app.css
generated
2
lib/crates/fabro-spa/assets/assets/app.css
generated
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
lib/crates/fabro-spa/assets/index.html
generated
2
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue