fabro/apps/fabro-web/app/lib/session-stream.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

142 lines
3.8 KiB
TypeScript

import {
SessionsApiAxiosParamCreator,
type EventEnvelope,
type SubmitTurnRequest,
} from "@qltysh/fabro-api-client";
import {
apiErrorFromFetchResponse,
generatedApiConfiguration,
} from "./api-client";
export type SessionStreamEvent = EventEnvelope;
type FetchLike = (
input: string,
init?: RequestInit,
) => Promise<Response>;
interface SessionStreamOptions {
sessionId: string;
signal?: AbortSignal;
fetchImpl?: FetchLike;
onEvent: (event: SessionStreamEvent) => void;
}
export interface StreamSessionTurnOptions extends SessionStreamOptions {
input: string;
turnId?: string;
}
export interface StreamSessionTurnResult {
turnId: string | null;
}
export interface AttachSessionEventsOptions extends SessionStreamOptions {
sinceSeq?: number;
}
export async function streamSessionTurn({
sessionId,
input,
turnId,
signal,
fetchImpl = fetch,
onEvent,
}: StreamSessionTurnOptions): Promise<StreamSessionTurnResult> {
const body: SubmitTurnRequest = { input };
if (turnId) body.turn_id = turnId;
const request = await SessionsApiAxiosParamCreator(
generatedApiConfiguration,
).submitSessionTurn(sessionId, body, { signal });
const response = await fetchImpl(request.url, fetchInitFromAxiosRequest(request.options));
await throwIfApiError(response);
await readEventStream(response, onEvent);
return { turnId: response.headers.get("x-fabro-turn-id") };
}
export async function attachSessionEvents({
sessionId,
sinceSeq,
signal,
fetchImpl = fetch,
onEvent,
}: AttachSessionEventsOptions): Promise<void> {
const request = await SessionsApiAxiosParamCreator(
generatedApiConfiguration,
).attachSessionEvents(sessionId, sinceSeq, { signal });
const response = await fetchImpl(request.url, fetchInitFromAxiosRequest(request.options));
await throwIfApiError(response);
await readEventStream(response, onEvent);
}
async function throwIfApiError(response: Response): Promise<void> {
const error = await apiErrorFromFetchResponse(response);
if (error) throw error;
}
function fetchInitFromAxiosRequest(options: {
method?: string;
headers?: unknown;
data?: unknown;
signal?: unknown;
}): RequestInit {
const init: RequestInit = {
method: options.method,
credentials: "same-origin",
headers: options.headers as HeadersInit,
signal: options.signal as AbortSignal | undefined,
};
if (options.data !== undefined) {
init.body = typeof options.data === "string"
? options.data
: JSON.stringify(options.data);
}
return init;
}
async function readEventStream(
response: Response,
onEvent: (event: SessionStreamEvent) => void,
): Promise<void> {
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
// react-doctor-disable-next-line react-doctor/async-await-in-loop -- Streaming readers must consume chunks sequentially to preserve SSE order and decoder state.
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
buffer = drainSseBuffer(buffer, onEvent);
}
buffer += decoder.decode();
drainSseBuffer(`${buffer}\n\n`, onEvent);
}
function drainSseBuffer(
buffer: string,
onEvent: (event: SessionStreamEvent) => void,
): string {
let cursor = 0;
while (true) {
const match = /\r?\n\r?\n/g.exec(buffer.slice(cursor));
if (!match) return buffer.slice(cursor);
const next = cursor + match.index;
const frame = buffer.slice(cursor, next);
cursor = next + match[0].length;
const data = frame
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart())
.join("\n");
if (!data) continue;
onEvent(JSON.parse(data) as SessionStreamEvent);
}
}