fabro/apps/fabro-web/app/components/event-debug-helpers.tsx
Bryan Helmkamp 62f0b3e7d1
refactor(web): improve React Doctor score (#405)
## 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`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 (context not reported, default reasoning) via
[Codex](https://openai.com/codex)
2026-05-25 22:41:37 -04:00

120 lines
3.3 KiB
TypeScript

import type { ReactNode } from "react";
export type DebugCategory =
| "agent"
| "command"
| "lifecycle"
| "human"
| "system";
export const DEBUG_CATEGORIES: readonly DebugCategory[] = [
"agent",
"command",
"lifecycle",
"human",
"system",
] as const;
const PREFIX_TO_CATEGORY: Record<string, DebugCategory> = {
agent: "agent",
command: "command",
run: "lifecycle",
stage: "lifecycle",
parallel: "lifecycle",
subgraph: "lifecycle",
edge: "lifecycle",
loop: "lifecycle",
prompt: "lifecycle",
interview: "human",
};
const CATEGORY_LABEL: Record<DebugCategory, string> = {
agent: "Agent",
command: "Command",
lifecycle: "Lifecycle",
human: "Human",
system: "System",
};
const CATEGORY_TONE: Record<DebugCategory, string> = {
agent: "bg-teal-500/15 text-teal-500",
command: "bg-mint/15 text-mint",
lifecycle: "bg-amber/15 text-amber",
human: "bg-coral/15 text-coral",
system: "bg-overlay-strong text-fg-3",
};
const CATEGORY_COLOR: Record<DebugCategory, string> = {
agent: "var(--color-teal-500)",
command: "var(--color-mint)",
lifecycle: "var(--color-amber)",
human: "var(--color-coral)",
system: "var(--color-ice-300)",
};
export function debugCategory(eventName: string | null | undefined): DebugCategory {
if (!eventName) return "system";
const dot = eventName.indexOf(".");
const prefix = dot < 0 ? eventName : eventName.slice(0, dot);
return PREFIX_TO_CATEGORY[prefix] ?? "system";
}
export function debugCategoryLabel(category: DebugCategory): string {
return CATEGORY_LABEL[category];
}
export function debugCategoryTone(category: DebugCategory): string {
return CATEGORY_TONE[category];
}
export function debugCategoryColor(category: DebugCategory): string {
return CATEGORY_COLOR[category];
}
export function formatElapsed(eventTs: string, runStart: string | undefined): string {
if (!runStart) return "";
const startMs = Date.parse(runStart);
const eventMs = Date.parse(eventTs);
if (Number.isNaN(startMs) || Number.isNaN(eventMs)) return "";
const delta = Math.max(0, Math.floor((eventMs - startMs) / 1000));
const hours = Math.floor(delta / 3600);
const minutes = Math.floor((delta % 3600) / 60);
const seconds = delta % 60;
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
const JSON_TOKEN_RE =
/"(?:\\.|[^"\\])*"|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?/g;
export function highlightJson(text: string): ReactNode[] {
const parts: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let key = 0;
JSON_TOKEN_RE.lastIndex = 0;
while ((match = JSON_TOKEN_RE.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const token = match[0];
let cls: string;
if (token.startsWith('"')) {
const after = text.slice(JSON_TOKEN_RE.lastIndex);
cls = /^\s*:/.test(after) ? "text-teal-300" : "text-mint";
} else if (token === "true" || token === "false") {
cls = "text-coral";
} else if (token === "null") {
cls = "text-fg-muted";
} else {
cls = "text-amber";
}
parts.push(
<span key={key++} className={cls}>
{token}
</span>,
);
lastIndex = JSON_TOKEN_RE.lastIndex;
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
return parts;
}