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>
This commit is contained in:
2026-06-09 23:57:51 +02:00
parent dd1194abe8
commit 6de4e5a6e0
15 changed files with 324 additions and 245 deletions

View File

@ -1,31 +1,24 @@
/**
* First-run wizard (L5). Shown on the very first IDE launch: it offers the
* pre-filled reference profiles (Claude/Codex/Gemini/Aider) with **editable**
* commands, lets the user detect which CLIs are installed (✓/✗), add a **custom**
* profile, then saves the chosen profiles and closes the first run.
* pre-filled, selectable reference profiles (Claude/Codex — only AIs drivable in
* structured mode, §17.6/D7) with **editable** commands, lets the user detect
* which CLIs are installed (✓/✗), then saves the chosen profiles and closes the
* first run.
*
* The candidate list is already filtered server-side (`reference_profiles` only
* exposes selectable profiles), so the wizard renders whatever it receives.
* Adding an arbitrary custom profile is no longer offered, since we cannot drive
* it in structured mode.
*
* Pure presentation: all behaviour comes from {@link useFirstRun} (the
* {@link ProfileGateway} port). Custom-profile validation is the pure logic in
* {@link ProfileGateway} port). Profile validation is the pure logic in
* `./profile`.
*/
import { useState } from "react";
import type { AgentProfile, InjectionStrategy } from "@/domain";
import type { AgentProfile } from "@/domain";
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
import { useFirstRun, type WizardEntry } from "./useFirstRun";
import {
defaultInjection,
emptyCustomProfile,
isProfileValid,
parseArgs,
validateProfile,
} from "./profile";
/** Shared classes for the native `<select>` so it matches the Input control. */
const SELECT_CLASS =
"h-9 w-full rounded-md border border-border bg-raised px-3 text-sm text-content " +
"outline-none focus:border-primary";
import { parseArgs, validateProfile } from "./profile";
/** A small caption above a control. */
function Caption({ children }: { children: React.ReactNode }) {
@ -67,7 +60,7 @@ export function FirstRunWizard({
<h2 className="text-base font-semibold text-content">Welcome to IdeA</h2>
<p className="text-sm text-muted">
Choose which AI CLIs to configure. Commands are pre-filled and
editable; you can also add your own.
editable.
</p>
</div>
}
@ -101,8 +94,6 @@ export function FirstRunWizard({
))}
</ul>
<AddCustomProfile onAdd={(p) => vm.addCustom(p)} />
<footer className="flex gap-2">
<Button variant="primary" onClick={() => void finish()} disabled={vm.busy}>
Save and continue
@ -186,119 +177,3 @@ function ProfileRow({
</li>
);
}
/** Inline form to add a custom profile, validated before it is accepted. */
function AddCustomProfile({ onAdd }: { onAdd: (p: AgentProfile) => void }) {
const [draft, setDraft] = useState<AgentProfile>(emptyCustomProfile());
const errors = validateProfile(draft);
const valid = isProfileValid(draft);
const ci = draft.contextInjection;
function submit(e: React.FormEvent) {
e.preventDefault();
if (!valid) return;
onAdd(draft);
setDraft(emptyCustomProfile());
}
return (
<form
onSubmit={submit}
aria-label="add custom profile"
className="flex flex-col gap-2 rounded-md border border-dashed border-border-strong p-3"
>
<strong className="text-sm text-content">Add a custom profile</strong>
<Input
aria-label="custom name"
placeholder="Name"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
<Input
aria-label="custom command"
placeholder="Command (e.g. my-ai)"
value={draft.command}
onChange={(e) => setDraft({ ...draft, command: e.target.value })}
/>
<Input
aria-label="custom args"
placeholder="Arguments (space-separated)"
value={draft.args.join(" ")}
onChange={(e) => setDraft({ ...draft, args: parseArgs(e.target.value) })}
/>
<label className="flex flex-col gap-1">
<Caption>Context injection</Caption>
<select
aria-label="injection strategy"
className={SELECT_CLASS}
value={ci.strategy}
onChange={(e) =>
setDraft({
...draft,
contextInjection: defaultInjection(
e.target.value as InjectionStrategy,
),
})
}
>
<option value="conventionFile">Convention file</option>
<option value="flag">Flag</option>
<option value="stdin">Stdin</option>
<option value="env">Environment variable</option>
</select>
</label>
{ci.strategy === "conventionFile" && (
<Input
aria-label="injection target"
placeholder="Target file (e.g. CONTEXT.md)"
value={ci.target}
onChange={(e) =>
setDraft({
...draft,
contextInjection: { strategy: "conventionFile", target: e.target.value },
})
}
/>
)}
{ci.strategy === "flag" && (
<Input
aria-label="injection flag"
placeholder="Flag (e.g. --context-file {path})"
value={ci.flag}
onChange={(e) =>
setDraft({
...draft,
contextInjection: { strategy: "flag", flag: e.target.value },
})
}
/>
)}
{ci.strategy === "env" && (
<Input
aria-label="injection var"
placeholder="Env var (e.g. AGENT_CONTEXT_FILE)"
value={ci.var}
onChange={(e) =>
setDraft({
...draft,
contextInjection: { strategy: "env", var: e.target.value },
})
}
/>
)}
{Object.values(errors).map((msg) => (
<small key={msg} className="text-xs text-danger">
{msg}
</small>
))}
<Button type="submit" variant="primary" disabled={!valid}>
Add custom profile
</Button>
</form>
);
}