Files
IdeA/frontend/src/features/first-run/useFirstRun.ts
Blomios 6de4e5a6e0 feat(agent): menu de profils restreint Claude/Codex + retrait custom (D7) — §17
Dernier lot de §17 : seuls les profils pilotables en mode structuré sont
proposés à la sélection/création.

- domain : AgentProfile::is_selectable() = structured_adapter.is_some(),
  source unique de vérité du prédicat.
- infrastructure : AgentSessionFactory::supports délègue à is_selectable
  (supports et is_selectable ne peuvent plus diverger).
- application : selectable_reference_profiles() = reference_profiles()
  filtré ; ReferenceProfiles et FirstRunState exposent la liste filtrée
  (Claude/Codex). reference_profiles() brut reste à 4 (data intacte) ⇒
  un agent Gemini/Aider/custom legacy déjà configuré continue de tourner.
- frontend : bloc AddCustomProfile retiré du wizard first-run, action
  addCustom retirée du viewmodel ; wizard n'affiche que la liste filtrée.

Tests (QA) : is_selectable (table de vérité), cohérence stricte
is_selectable<->supports, liste exposée=2 / data brute=4, garde
anti-régression Vitest sur l'absence du bloc custom — validées par
mutation. cargo test --workspace : 820 passed. npx vitest run : 344 passed.

§17 COMPLET (D0->D7). Suivi restant : D6b (surfacer reply dans le writer
wire .response.json pour la délégation par protocole fichier).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:57:51 +02:00

169 lines
5.1 KiB
TypeScript

/**
* `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<void>;
/** Persists the selected profiles and closes the wizard. */
finish: () => Promise<void>;
/** Re-checks first-run state (e.g. reopening profile management). */
reload: () => Promise<void>;
}
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<boolean | null>(null);
const [entries, setEntries] = useState<WizardEntry[]>([]);
const [error, setError] = useState<string | null>(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,
};
}