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

201 lines
5.2 KiB
TypeScript

import {
createContext,
use,
useCallback,
useMemo,
useReducer,
type ReactNode,
} from "react";
import type { Chat, ChatMessage } from "./chats-types";
import { pickReply } from "./chats-script";
type State = {
chats: Record<string, Chat>;
order: string[]; // newest first
};
type Action =
| {
type: "create";
id: string;
title: string;
createdAt: number;
userMessage: ChatMessage;
}
| { type: "consume_pending"; chatId: string }
| { type: "advance_script"; chatId: string };
function deriveTitle(text: string): string {
const trimmed = text.trim().replace(/\s+/g, " ");
if (trimmed.length <= 40) return trimmed || "New chat";
const cut = trimmed.slice(0, 40);
const lastSpace = cut.lastIndexOf(" ");
const base = lastSpace > 20 ? cut.slice(0, lastSpace) : cut;
return `${base}…`;
}
function userMessage(text: string): ChatMessage {
return {
role: "user",
content: [{ kind: "text", data: { text } }],
};
}
function seedChat(args: {
id: string;
title: string;
ageDays: number;
scriptIndex: number;
userText: string;
}): Chat {
return {
id: args.id,
title: args.title,
createdAt: Date.now() - args.ageDays * 86_400_000,
scriptIndex: args.scriptIndex + 1, // seeded reply already "consumed"
pendingResponse: false,
seedMessages: [userMessage(args.userText), pickReply(args.scriptIndex)],
};
}
const initialState: State = (() => {
const seeds: Chat[] = [
seedChat({
id: "seed_email",
title: "Draft a launch email",
ageDays: 0.5,
scriptIndex: 0,
userText:
"Help me draft a launch announcement email for our new analytics dashboard.",
}),
seedChat({
id: "seed_hook",
title: "Refactor a React hook",
ageDays: 2,
scriptIndex: 3,
userText:
"My useChat hook has grown to 200 lines and I keep tangling concerns. How should I think about refactoring it?",
}),
seedChat({
id: "seed_db",
title: "Compare Postgres vs SQLite",
ageDays: 6,
scriptIndex: 5,
userText:
"For a side project with ~50 daily users, should I reach for Postgres or stick with SQLite?",
}),
];
const chats: Record<string, Chat> = {};
for (const s of seeds) chats[s.id] = s;
return { chats, order: seeds.map((s) => s.id) };
})();
function reducer(state: State, action: Action): State {
switch (action.type) {
case "create": {
const chat: Chat = {
id: action.id,
title: action.title,
createdAt: action.createdAt,
scriptIndex: 0,
pendingResponse: true,
seedMessages: [action.userMessage],
};
return {
chats: { ...state.chats, [action.id]: chat },
order: [action.id, ...state.order],
};
}
case "consume_pending": {
const existing = state.chats[action.chatId];
if (!existing || !existing.pendingResponse) return state;
return {
...state,
chats: {
...state.chats,
[action.chatId]: { ...existing, pendingResponse: false },
},
};
}
case "advance_script": {
const existing = state.chats[action.chatId];
if (!existing) return state;
return {
...state,
chats: {
...state.chats,
[action.chatId]: {
...existing,
scriptIndex: existing.scriptIndex + 1,
},
},
};
}
}
}
export type ChatsActions = {
createChatWithFirstMessage: (text: string) => string;
consumePendingResponse: (chatId: string) => void;
advanceScriptIndex: (chatId: string) => void;
};
const ChatsStateContext = createContext<State | null>(null);
const ChatsActionsContext = createContext<ChatsActions | null>(null);
function shortId(): string {
return `c_${Math.random().toString(36).slice(2, 8)}`;
}
export function ChatsProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
const actions = useMemo<ChatsActions>(
() => ({
createChatWithFirstMessage(text: string) {
const id = shortId();
dispatch({
type: "create",
id,
title: deriveTitle(text),
createdAt: Date.now(),
userMessage: userMessage(text),
});
return id;
},
consumePendingResponse(chatId: string) {
dispatch({ type: "consume_pending", chatId });
},
advanceScriptIndex(chatId: string) {
dispatch({ type: "advance_script", chatId });
},
}),
[],
);
return (
<ChatsStateContext.Provider value={state}>
<ChatsActionsContext.Provider value={actions}>
{children}
</ChatsActionsContext.Provider>
</ChatsStateContext.Provider>
);
}
export function useChatsState(): State {
const value = use(ChatsStateContext);
if (!value) throw new Error("useChatsState must be used inside <ChatsProvider>");
return value;
}
export function useChatsActions(): ChatsActions {
const value = use(ChatsActionsContext);
if (!value) throw new Error("useChatsActions must be used inside <ChatsProvider>");
return value;
}
export function useChat(chatId: string | undefined): Chat | undefined {
const state = useChatsState();
return chatId ? state.chats[chatId] : undefined;
}