fabro/apps/fabro-web/app/lib/chats-runtime.ts
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

130 lines
3.6 KiB
TypeScript

import type {
ChatModelAdapter,
ChatModelRunResult,
ThreadAssistantMessagePart,
ThreadMessageLike,
} from "@assistant-ui/react";
import type { Chat, ChatContentPart, ChatMessage } from "./chats-types";
import { pickReply } from "./chats-script";
const STREAM_CHUNK_CHARS = 28;
const STREAM_CHUNK_INTERVAL_MS = 55;
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const handle = setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
clearTimeout(handle);
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
});
}
function toAssistantParts(
content: readonly ChatContentPart[],
): ThreadAssistantMessagePart[] {
const out: ThreadAssistantMessagePart[] = [];
for (const part of content) {
if (part.kind === "text") {
out.push({ type: "text", text: part.data.text });
} else if (part.kind === "tool_call") {
out.push({
type: "tool-call",
toolCallId: part.data.tool_call_id,
toolName: part.data.name,
args: part.data.arguments,
argsText: JSON.stringify(part.data.arguments),
});
} else if (part.kind === "tool_result") {
for (let i = out.length - 1; i >= 0; i--) {
const candidate = out[i];
if (
candidate?.type === "tool-call" &&
candidate.toolCallId === part.data.tool_call_id
) {
out[i] = { ...candidate, result: part.data.content };
break;
}
}
}
}
return out;
}
export function createScriptedAdapter(args: {
getChat: () => Chat | undefined;
onReplyComplete: (reply: ChatMessage) => void;
}): ChatModelAdapter {
return {
async *run({ abortSignal }) {
const chat = args.getChat();
const reply = pickReply(chat?.scriptIndex ?? 0);
const accumulated: ChatContentPart[] = [];
for (const part of reply.content) {
if (part.kind === "text") {
const text = part.data.text;
let cursor = 0;
accumulated.push({ kind: "text", data: { text: "" } });
const accIndex = accumulated.length - 1;
while (cursor < text.length) {
cursor = Math.min(cursor + STREAM_CHUNK_CHARS, text.length);
accumulated[accIndex] = {
kind: "text",
data: { text: text.slice(0, cursor) },
};
yield buildUpdate(accumulated);
if (cursor < text.length) {
await sleep(STREAM_CHUNK_INTERVAL_MS, abortSignal);
}
}
} else {
accumulated.push(part);
yield buildUpdate(accumulated);
await sleep(STREAM_CHUNK_INTERVAL_MS * 3, abortSignal);
}
}
args.onReplyComplete(reply);
},
};
}
function buildUpdate(parts: ChatContentPart[]): ChatModelRunResult {
return { content: toAssistantParts(parts) };
}
export function toThreadMessages(
messages: readonly ChatMessage[],
): ThreadMessageLike[] {
return messages.map((msg) => {
if (msg.role === "user") {
const content = [];
for (const part of msg.content) {
if (part.kind === "text") {
content.push({ type: "text", text: part.data.text } as const);
}
}
return {
role: "user",
content,
};
}
if (msg.role === "assistant") {
return {
role: "assistant",
content: toAssistantParts(msg.content),
};
}
return { role: "system", content: [] };
});
}