mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +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)
201 lines
5.2 KiB
TypeScript
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;
|
|
}
|