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>
169 lines
5.1 KiB
TypeScript
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,
|
|
};
|
|
}
|