/** * `useFirstRun` — view-model for the first-run wizard and profile management * (L5). Owns the wizard state (selectable reference candidates + selection + * availability) and the actions. Consumes the {@link ProfileGateway} * exclusively — never `invoke()` — so the feature is testable with a mock. */ import { useCallback, useEffect, useState } from "react"; import type { AgentProfile, FirstRunState, GatewayError, ProfileAvailability, } from "@/domain"; import { useGateways } from "@/app/di"; /** One row in the wizard: a candidate profile, selected state, availability. */ export interface WizardEntry { profile: AgentProfile; selected: boolean; /** `null` until detection ran; then `true`/`false`. */ available: boolean | null; } /** What the first-run wizard UI needs from this hook. */ export interface FirstRunViewModel { /** Whether the wizard should be shown (null while loading). */ isFirstRun: boolean | null; /** The candidate rows (selectable reference profiles). */ entries: WizardEntry[]; /** Last error message, or null. */ error: string | null; /** Whether a request is in flight. */ busy: boolean; /** Toggles selection of a candidate by id. */ toggle: (id: string) => void; /** Replaces a candidate's profile (edited command/args/injection). */ updateProfile: (id: string, profile: AgentProfile) => void; /** Removes a candidate by id. */ remove: (id: string) => void; /** Runs detection over all candidates, filling availability. */ detect: () => Promise; /** Persists the selected profiles and closes the wizard. */ finish: () => Promise; /** Re-checks first-run state (e.g. reopening profile management). */ reload: () => Promise; } function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } export function useFirstRun(): FirstRunViewModel { const { profile } = useGateways(); const [isFirstRun, setIsFirstRun] = useState(null); const [entries, setEntries] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const reload = useCallback(async () => { setBusy(true); setError(null); try { const state: FirstRunState = await profile.firstRunState(); setIsFirstRun(state.isFirstRun); const refs = state.referenceProfiles; // Show the rows immediately (nothing pre-checked yet). setEntries( refs.map((p) => ({ profile: p, selected: false, available: null })), ); // Auto-detect once on open: only the CLIs actually installed end up // pre-checked. The manual "Detect installed CLIs" button stays available to // re-probe. If detection is unavailable, rows stay unchecked (the user can // still tick them by hand and re-run detection). try { const results = await profile.detectProfiles(refs); const byId = new Map(results.map((r) => [r.profile.id, r.available])); setEntries( refs.map((p) => { const available = byId.get(p.id) ?? null; return { profile: p, selected: available === true, available }; }), ); } catch { /* detection unavailable: leave rows unchecked */ } } catch (e) { setError(describe(e)); setIsFirstRun(false); } finally { setBusy(false); } }, [profile]); useEffect(() => { void reload(); }, [reload]); const toggle = useCallback((id: string) => { setEntries((prev) => prev.map((e) => e.profile.id === id ? { ...e, selected: !e.selected } : e, ), ); }, []); const updateProfile = useCallback((id: string, next: AgentProfile) => { setEntries((prev) => prev.map((e) => (e.profile.id === id ? { ...e, profile: next } : e)), ); }, []); const remove = useCallback((id: string) => { setEntries((prev) => prev.filter((e) => e.profile.id !== id)); }, []); const detect = useCallback(async () => { setBusy(true); setError(null); try { const candidates = entries.map((e) => e.profile); const results: ProfileAvailability[] = await profile.detectProfiles(candidates); const byId = new Map(results.map((r) => [r.profile.id, r.available])); setEntries((prev) => prev.map((e) => ({ ...e, available: byId.get(e.profile.id) ?? e.available, })), ); } catch (e) { setError(describe(e)); } finally { setBusy(false); } }, [profile, entries]); const finish = useCallback(async () => { setBusy(true); setError(null); try { const chosen = entries.filter((e) => e.selected).map((e) => e.profile); await profile.configureProfiles(chosen); setIsFirstRun(false); } catch (e) { setError(describe(e)); } finally { setBusy(false); } }, [profile, entries]); return { isFirstRun, entries, error, busy, toggle, updateProfile, remove, detect, finish, reload, }; }