mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-04 02:33:56 +00:00
## Summary Improves the web UI's React Doctor audit score by separating reusable helpers from React component modules, tightening effect/state ownership, and extracting real component boundaries in the install wizard, stage activity view, run-files diff browser, RunDetail route, and Runs workspace. The branch removes the previously deferred RunDetail and Runs giant-component diagnostics without changing RunDetail UX, route contracts, action ordering, or Runs workspace behavior. | Metric | Main baseline | Initial PR | Current PR | |--------|---------------|------------|------------| | React Doctor score | 63 | 71 | 99 | | React Doctor errors | 123 | 0 | 0 | | React Doctor warnings | 241 | 163 | 3 | | React Doctor diagnostics | 364 | 163 | 3 | ## Changes - Moves exported helper logic out of component files so Fast Refresh/component-export rules no longer dominate the audit. - Adds a targeted React Doctor config exception for React Router route modules, where non-component exports like route metadata are intentional. - Refactors low-risk state/effect patterns: keyed interview question state, reducer-backed editable run title state, event-owned preview opening, route-keyed insights editor initialization, refresh timer ownership, and selection/derived list cleanup. - Reworks `InstallApp` around an install reducer, a controller hook for install lifecycle state, and focused wizard step components for LLM, server, object-store, sandbox, and GitHub setup. - Moves `RunStages` selected-stage activity into a keyed boundary for panel/debug detail state while preserving stage activity filters across navigation. - Extracts the `RunFiles` loaded diff-browser view from route/query coordination so the route owns data/URL state and the loaded view owns rendering. - Splits `RunDetail` into route-local header, actions, tab shell, docked controls, model, and lifecycle-toast modules; the actions menu now uses grouped descriptors instead of a large boolean/callback prop matrix. - Extracts Runs workspace preference ownership into `useRunsWorkspacePreferences` and moves toolbar rendering into `RunsToolbar`, leaving the route focused on data, DnD state, filtering, and view selection. - Guards `InsightsEditor` query execution with a latest-run id and timeout cleanup so stale or unmounted mock query runs cannot overwrite newer results. - Adds regression coverage for archived-run deletion from RunDetail and stale-result handling in InsightsEditor. - Improves semantic/accessibility coverage with labeled controls, native meter/section semantics, decorative status dots, and clearer unavailable copy. - Removes dead UI code and applies local suppressions only where the rule is a documented false positive or an intentional imperative integration boundary. ## Remaining React Doctor warnings Current score is 99 with 0 errors and 3 warnings. The remaining warnings are intentionally left for separate judgment rather than mechanical churn: - `prefer-useReducer` (3): `AutomationsNew`, `InsightsEditor`, and `CreateSecretForm` need reducers only if they encode real coupled transitions, not simple field setters. ## Verification - `cd apps/fabro-web && bun test app/routes/run-detail.test.ts` -> `22 pass`, `0 fail` - `cd apps/fabro-web && bun test app/routes/insights-editor.test.tsx app/routes/runs.preferences.test.tsx` -> `7 pass`, `0 fail` - `cd apps/fabro-web && bun run typecheck` - `cd apps/fabro-web && bun test --isolate` -> `490 pass`, `0 fail` - `cd apps/fabro-web && bunx react-doctor@latest --full --json > /tmp/fabro-react-doctor-runs-insights.json` -> score `99`, `0` errors, `3` warnings - Earlier branch verification also included `cd apps/fabro-web && bun run build` - `git diff --check` --- [](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 (context not reported, default reasoning) via [Codex](https://openai.com/codex)
263 lines
6.6 KiB
TypeScript
263 lines
6.6 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import type { ObjectStoreSettings } from "@qltysh/fabro-api-client";
|
|
|
|
export type SettingsView = "settings" | "json";
|
|
|
|
export function Panel({ title, children }: { title: string; children: ReactNode }) {
|
|
return (
|
|
<section className="overflow-hidden rounded-md border border-line bg-panel/40">
|
|
<header className="border-b border-line bg-overlay px-4 py-2.5">
|
|
<h2 className="text-xs font-medium uppercase tracking-wider text-fg-muted">
|
|
{title}
|
|
</h2>
|
|
</header>
|
|
<div className="divide-y divide-line">{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function PanelSkeleton() {
|
|
return (
|
|
<div className="overflow-hidden rounded-md border border-line bg-panel/40">
|
|
<div className="h-10 border-b border-line bg-overlay" />
|
|
<div className="space-y-4 px-4 py-6">
|
|
<div className="h-3 w-40 rounded bg-overlay-strong" />
|
|
<div className="h-3 w-64 rounded bg-overlay" />
|
|
<div className="h-3 w-52 rounded bg-overlay" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Row({
|
|
title,
|
|
help,
|
|
children,
|
|
}: {
|
|
title: ReactNode;
|
|
help?: ReactNode;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="grid grid-cols-[minmax(0,5fr)_minmax(0,7fr)] items-start gap-x-6 gap-y-1 px-4 py-3.5">
|
|
<div className="min-w-0">
|
|
<div className="text-sm text-fg-2">{title}</div>
|
|
{help ? (
|
|
<div className="mt-0.5 text-xs/5 text-fg-3 text-pretty">{help}</div>
|
|
) : null}
|
|
</div>
|
|
<div className="min-w-0 self-center text-sm text-fg">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SettingsPageIntro({
|
|
description,
|
|
action,
|
|
view,
|
|
setView,
|
|
}: {
|
|
description: ReactNode;
|
|
action?: ReactNode;
|
|
view?: SettingsView;
|
|
setView?: (v: SettingsView) => void;
|
|
}) {
|
|
const trailing =
|
|
action ??
|
|
(view !== undefined && setView ? (
|
|
<ViewToggle view={view} setView={setView} />
|
|
) : null);
|
|
|
|
return (
|
|
<div className="flex items-start justify-between gap-6">
|
|
<p className="max-w-[64ch] text-sm/6 text-fg-3 text-pretty">{description}</p>
|
|
{trailing ? <div className="shrink-0">{trailing}</div> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ViewToggle({
|
|
view,
|
|
setView,
|
|
}: {
|
|
view: SettingsView;
|
|
setView: (v: SettingsView) => void;
|
|
}) {
|
|
const btn = "rounded px-3 py-1.5 text-xs font-medium transition-colors";
|
|
return (
|
|
<div className="inline-flex shrink-0 rounded-md border border-line bg-panel/80 p-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setView("settings")}
|
|
aria-pressed={view === "settings"}
|
|
className={`${btn} ${view === "settings" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
|
>
|
|
Settings
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setView("json")}
|
|
aria-pressed={view === "json"}
|
|
className={`${btn} ${view === "json" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
|
>
|
|
JSON
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Mono({
|
|
children,
|
|
title,
|
|
}: {
|
|
children: ReactNode;
|
|
title?: string;
|
|
}) {
|
|
return (
|
|
<div
|
|
className="truncate font-mono text-xs text-fg-2"
|
|
title={title}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Muted({ children }: { children: ReactNode }) {
|
|
return <span className="text-fg-muted">{children}</span>;
|
|
}
|
|
|
|
export function Badge({ children }: { children: ReactNode }) {
|
|
return (
|
|
<span className="inline-flex items-center rounded-sm bg-overlay-strong px-1.5 py-0.5 font-mono text-[11px] text-fg-2">
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function NumberValue({ value }: { value: number }) {
|
|
return <span className="font-mono tabular-nums text-fg">{value}</span>;
|
|
}
|
|
|
|
export function Dot({ on }: { on: boolean }) {
|
|
return (
|
|
<span
|
|
className={`size-1.5 rounded-full ${on ? "bg-emerald-400" : "bg-fg-muted"}`}
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function Toggle({ on }: { on: boolean }) {
|
|
return (
|
|
<span className="inline-flex items-center gap-2">
|
|
<Dot on={on} />
|
|
<span className={on ? "text-fg" : "text-fg-muted"}>
|
|
{on ? "Enabled" : "Disabled"}
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function UrlValue({ url }: { url: string }) {
|
|
return (
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="truncate font-mono text-xs text-fg-2 hover:text-fg hover:underline"
|
|
title={url}
|
|
>
|
|
{url}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
export function ObjectStoreRows({
|
|
store,
|
|
prefix,
|
|
}: {
|
|
store: ObjectStoreSettings;
|
|
prefix: string;
|
|
}) {
|
|
const prefixRow = (
|
|
<Row title="Prefix" help="Key prefix appended to every object path.">
|
|
{prefix ? <Mono>{prefix}</Mono> : <Muted>None</Muted>}
|
|
</Row>
|
|
);
|
|
|
|
if (store.type === "s3") {
|
|
return (
|
|
<>
|
|
<Row title="Type" help="Backend driver for this store.">
|
|
<Badge>s3</Badge>
|
|
</Row>
|
|
<Row title="Bucket" help="S3 bucket holding the objects.">
|
|
<Mono>{store.bucket}</Mono>
|
|
</Row>
|
|
<Row title="Region" help="AWS region the bucket lives in.">
|
|
<Mono>{store.region}</Mono>
|
|
</Row>
|
|
{store.endpoint ? (
|
|
<Row title="Endpoint" help="Custom S3-compatible endpoint URL.">
|
|
<Mono>{store.endpoint}</Mono>
|
|
</Row>
|
|
) : null}
|
|
{store.path_style ? (
|
|
<Row title="Path style" help="Use path-style addressing instead of virtual-hosted.">
|
|
<Toggle on={true} />
|
|
</Row>
|
|
) : null}
|
|
{prefixRow}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Row title="Type" help="Backend driver for this store.">
|
|
<Badge>local</Badge>
|
|
</Row>
|
|
<Row title="Root" help="Filesystem directory holding the objects.">
|
|
<Mono>{store.root}</Mono>
|
|
</Row>
|
|
{prefixRow}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function UsernameList({ names }: { names: string[] }) {
|
|
const visible = names.slice(0, 3);
|
|
const remaining = names.length - visible.length;
|
|
return (
|
|
<span className="inline-flex flex-wrap items-center gap-1.5">
|
|
{visible.map((n) => (
|
|
<Badge key={n}>{n}</Badge>
|
|
))}
|
|
{remaining > 0 ? (
|
|
<span className="text-xs text-fg-muted">+{remaining} more</span>
|
|
) : null}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function Count({
|
|
n,
|
|
singular,
|
|
plural: pluralLabel,
|
|
suffix,
|
|
}: {
|
|
n: number;
|
|
singular: string;
|
|
plural: string;
|
|
suffix?: string;
|
|
}) {
|
|
if (n === 0) return <Muted>None</Muted>;
|
|
return (
|
|
<span className="text-fg-2">
|
|
<span className="font-mono tabular-nums text-fg">{n}</span>{" "}
|
|
{n === 1 ? singular : pluralLabel}
|
|
{suffix ? <span className="ml-1 text-fg-muted">{suffix}</span> : null}
|
|
</span>
|
|
);
|
|
}
|