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:
@ -1,13 +1,13 @@
|
||||
/**
|
||||
* L5 — the first-run wizard wired to the stateful {@link MockProfileGateway} via
|
||||
* the real {@link DIProvider}. Covers: pre-filled editable rows, detection ✓/✗,
|
||||
* adding a custom profile, and finishing (configure ⇒ first run closed ⇒ onDone).
|
||||
* the real {@link DIProvider}. Covers: pre-filled editable rows (only the
|
||||
* selectable Claude/Codex profiles, §17.3/D7), detection ✓/✗, the **absence** of
|
||||
* any custom-profile block, and finishing (configure ⇒ first run closed ⇒ onDone).
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from "@testing-library/react";
|
||||
@ -38,13 +38,19 @@ async function waitForLoaded() {
|
||||
}
|
||||
|
||||
describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
it("renders the four pre-filled, editable reference profiles", async () => {
|
||||
it("renders only the selectable (Claude/Codex) pre-filled, editable profiles", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
for (const name of ["Claude Code", "OpenAI Codex CLI", "Gemini CLI", "Aider"]) {
|
||||
// Only structured-drivable profiles are offered (§17.3/D7).
|
||||
for (const name of ["Claude Code", "OpenAI Codex CLI"]) {
|
||||
expect(screen.getByText(name)).toBeTruthy();
|
||||
}
|
||||
// Gemini/Aider are no longer proposed for selection.
|
||||
expect(screen.queryByText("Gemini CLI")).toBeNull();
|
||||
expect(screen.queryByText("Aider")).toBeNull();
|
||||
expect(screen.queryByLabelText("Aider command")).toBeNull();
|
||||
|
||||
// Commands are editable inputs, pre-filled.
|
||||
const claudeCmd = screen.getByLabelText(
|
||||
"Claude Code command",
|
||||
@ -55,9 +61,11 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
it("editing a command updates the input value", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
const cmd = screen.getByLabelText("Aider command") as HTMLInputElement;
|
||||
fireEvent.change(cmd, { target: { value: "aider-2" } });
|
||||
expect(cmd.value).toBe("aider-2");
|
||||
const cmd = screen.getByLabelText(
|
||||
"OpenAI Codex CLI command",
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(cmd, { target: { value: "codex-2" } });
|
||||
expect(cmd.value).toBe("codex-2");
|
||||
});
|
||||
|
||||
it("detection shows ✓ for claude and ✗ for the rest", async () => {
|
||||
@ -71,49 +79,25 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
screen.getByLabelText("Claude Code availability").textContent,
|
||||
).toMatch(/installed/);
|
||||
});
|
||||
expect(screen.getByLabelText("Aider availability").textContent).toMatch(
|
||||
/not found/,
|
||||
);
|
||||
expect(
|
||||
screen.getByLabelText("OpenAI Codex CLI availability").textContent,
|
||||
).toMatch(/not found/);
|
||||
});
|
||||
|
||||
it("adds a valid custom profile as a new row", async () => {
|
||||
// §17.3/D7 — anti-regression guard. The custom-profile block was removed
|
||||
// because we cannot drive an arbitrary command in structured mode. This test
|
||||
// asserts the *absence* of that block: it must fail the instant any
|
||||
// `AddCustomProfile` UI (its label/role/inputs) reappears.
|
||||
it("offers no custom-profile block (removed in D7)", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
const form = screen.getByLabelText("add custom profile");
|
||||
fireEvent.change(within(form).getByLabelText("custom name"), {
|
||||
target: { value: "My AI" },
|
||||
});
|
||||
fireEvent.change(within(form).getByLabelText("custom command"), {
|
||||
target: { value: "my-ai" },
|
||||
});
|
||||
fireEvent.click(
|
||||
within(form).getByRole("button", { name: "Add custom profile" }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText("My AI")).toBeTruthy();
|
||||
// The custom command input now exists as a row.
|
||||
expect((screen.getByLabelText("My AI command") as HTMLInputElement).value).toBe(
|
||||
"my-ai",
|
||||
);
|
||||
});
|
||||
|
||||
it("add button is disabled until the custom draft is valid", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
const form = screen.getByLabelText("add custom profile");
|
||||
const addBtn = within(form).getByRole("button", {
|
||||
name: "Add custom profile",
|
||||
}) as HTMLButtonElement;
|
||||
expect(addBtn.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(within(form).getByLabelText("custom name"), {
|
||||
target: { value: "X" },
|
||||
});
|
||||
fireEvent.change(within(form).getByLabelText("custom command"), {
|
||||
target: { value: "x" },
|
||||
});
|
||||
expect(addBtn.disabled).toBe(false);
|
||||
expect(screen.queryByLabelText("add custom profile")).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Add custom profile" }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByLabelText("custom name")).toBeNull();
|
||||
expect(screen.queryByLabelText("custom command")).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-detects on open and pre-checks only installed CLIs", async () => {
|
||||
@ -129,9 +113,6 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
expect(
|
||||
(screen.getByLabelText("use OpenAI Codex CLI") as HTMLInputElement).checked,
|
||||
).toBe(false);
|
||||
expect(
|
||||
(screen.getByLabelText("use Aider") as HTMLInputElement).checked,
|
||||
).toBe(false);
|
||||
// Availability was filled automatically, without clicking the button.
|
||||
expect(
|
||||
screen.getByLabelText("Claude Code availability").textContent,
|
||||
@ -169,13 +150,13 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
).toBe(true),
|
||||
);
|
||||
|
||||
// Keep Aider too (not installed, unchecked by default).
|
||||
fireEvent.click(screen.getByLabelText("use Aider"));
|
||||
// Keep Codex too (not installed, unchecked by default).
|
||||
fireEvent.click(screen.getByLabelText("use OpenAI Codex CLI"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
expect(saved.map((p) => p.command)).toEqual(["claude", "aider"]);
|
||||
expect(saved.map((p) => p.command)).toEqual(["claude", "codex"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* `useFirstRun` — view-model for the first-run wizard and profile management
|
||||
* (L5). Owns the wizard state (reference candidates + selection + availability +
|
||||
* custom profiles) and the actions. Consumes the {@link ProfileGateway}
|
||||
* (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.
|
||||
*/
|
||||
|
||||
@ -27,7 +27,7 @@ export interface WizardEntry {
|
||||
export interface FirstRunViewModel {
|
||||
/** Whether the wizard should be shown (null while loading). */
|
||||
isFirstRun: boolean | null;
|
||||
/** The candidate rows (reference + added custom profiles). */
|
||||
/** The candidate rows (selectable reference profiles). */
|
||||
entries: WizardEntry[];
|
||||
/** Last error message, or null. */
|
||||
error: string | null;
|
||||
@ -37,9 +37,7 @@ export interface FirstRunViewModel {
|
||||
toggle: (id: string) => void;
|
||||
/** Replaces a candidate's profile (edited command/args/injection). */
|
||||
updateProfile: (id: string, profile: AgentProfile) => void;
|
||||
/** Adds a custom profile (selected by default). */
|
||||
addCustom: (profile: AgentProfile) => void;
|
||||
/** Removes a candidate by id (e.g. a custom one). */
|
||||
/** Removes a candidate by id. */
|
||||
remove: (id: string) => void;
|
||||
/** Runs detection over all candidates, filling availability. */
|
||||
detect: () => Promise<void>;
|
||||
@ -116,13 +114,6 @@ export function useFirstRun(): FirstRunViewModel {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const addCustom = useCallback((p: AgentProfile) => {
|
||||
setEntries((prev) => [
|
||||
...prev,
|
||||
{ profile: p, selected: true, available: null },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setEntries((prev) => prev.filter((e) => e.profile.id !== id));
|
||||
}, []);
|
||||
@ -169,7 +160,6 @@ export function useFirstRun(): FirstRunViewModel {
|
||||
busy,
|
||||
toggle,
|
||||
updateProfile,
|
||||
addCustom,
|
||||
remove,
|
||||
detect,
|
||||
finish,
|
||||
|
||||
Reference in New Issue
Block a user