diff --git a/.ideai/briefs/d7-menu-restreint.md b/.ideai/briefs/d7-menu-restreint.md new file mode 100644 index 0000000..e4e57b2 --- /dev/null +++ b/.ideai/briefs/d7-menu-restreint.md @@ -0,0 +1,95 @@ +# Brief Dev — Lot D7 : menu de profils restreint + retrait custom (§17.9) + +> Demandé par **Main** à **DevBackend** + **DevFrontend** + **QA**. Cycle §3. +> Dernier lot de §17. **Back + front.** DevBackend livre le contrat, DevFrontend consomme. + +## 0. Objectif (§17.3 / §17.6) + +Tant que seuls Claude et Codex ont un adapter structuré, le **menu de sélection de profil IA** +ne doit proposer **que** des profils pilotables en mode structuré. Conséquences : +- **Gemini / Aider** (présents dans le catalogue de référence mais **sans** `structured_adapter`) + ne sont **plus proposés** à la sélection. +- Le **profil custom** est **retiré** (l'utilisateur ne peut plus saisir une commande arbitraire, + car on ne saurait pas la piloter en structuré). + +Principe : `is_selectable(profile) == structured_adapter.is_some()` (équivaut à +`AgentSessionFactory::supports(profile)`). C'est ce prédicat qui **filtre la liste exposée** +(wizard first-run **et** création/édition d'agent). + +> Note : on **ne casse pas** le modèle `AgentProfile` (un profil sans adapter reste un profil +> PTY/legacy valide, §17.3). On restreint seulement ce qui est **proposé à la sélection**. + +## 1. Côté DevBackend (`crates/`) + +1. **Prédicat de sélectionnabilité** centralisé : `is_selectable(&AgentProfile) -> bool` + (= `structured_adapter.is_some()`). Place-le là où c'est cohérent (catalogue/usecases agent). + Évite de dupliquer la logique ; si `AgentSessionFactory::supports` existe déjà (livré D2), + garde la **même sémantique** (les deux doivent rester d'accord). +2. **Exposer uniquement les profils sélectionnables** au chemin de sélection : le use case qui + alimente le wizard/la création (autour de `ReferenceProfiles` / `reference_profiles()` dans + `crates/application/src/agent/{catalogue,usecases}.rs`) doit **filtrer** sur `is_selectable`. + Gemini/Aider restent dans le catalogue **data** (ne les supprime pas du modèle) mais + **n'apparaissent pas** dans la liste proposée. Décide proprement : soit un nouveau champ + `selectable: bool` sur le DTO exposé, soit une liste déjà filtrée — choisis l'option la moins + ambiguë pour le front et documente-la. +3. **Retrait custom (back)** : si une commande/usecase accepte un profil custom arbitraire pour + la sélection/création depuis le wizard, neutralise ce chemin (ou documente qu'il n'est plus + appelé). Ne casse pas la persistance de profils existants. +4. Vérifie `cargo build -p domain -p application -p app-tauri`. + +**Contrat à livrer à DevFrontend** (à mettre dans ton rapport) : la forme exacte de ce que le +front reçoit (liste filtrée ? champ `selectable`/`structuredAdapter` sur `ProfileDto` ?) pour +qu'il sache quoi afficher et quoi masquer. Rappel : `ProfileDto(pub AgentProfile)` sérialise déjà +`structuredAdapter` (camelCase) — tu peux t'appuyer dessus plutôt que d'ajouter un champ. + +## 2. Côté DevFrontend (`frontend/src/`) + +> **Ne démarre qu'après le contrat de DevBackend** (Main te relaiera la forme exacte). + +1. **Wizard first-run** (`features/first-run/FirstRunWizard.tsx`, `ProfilesSettings.tsx`) : + - n'affiche que les profils **sélectionnables** (Claude/Codex) ; + - **retire le bloc `AddCustomProfile`** (`onAdd`/`vm.addCustom`, `emptyCustomProfile`, + `aria-label="add custom profile"`) — le bouton/forme custom **disparaît**. +2. **Sélecteur d'agent** (création/édition dans `features/agents/`) : même filtre — seuls + Claude/Codex proposés ; pas d'option custom. +3. Nettoie le code mort résultant (helpers `emptyCustomProfile`, validation custom) **uniquement** + s'il n'est plus référencé ailleurs — sinon laisse-le et signale-le. +4. Vérifie `cd frontend && npm run build`. + +## 3. Invariants + +- **Zéro régression** : la persistance/édition des profils déjà configurés n'est pas cassée ; + un projet existant avec un agent Gemini/Aider/custom **legacy** continue de fonctionner (on + restreint la **création**, pas l'exécution de l'existant). +- Le prédicat `is_selectable` est la **source unique** ; back et front doivent rester cohérents. +- Frontières : le front filtre/affiche selon le contrat du port, le back décide la sélectionnabilité. + +## 4. Tests attendus (QA) + +**Rust** (`-p application`/`app-tauri`) : +- `is_selectable` vrai pour Claude/Codex, faux pour Gemini/Aider. +- la liste exposée à la sélection ne contient **que** Claude/Codex (custom absent). +- non-régression : `reference_profiles()` (catalogue brut) contient toujours les 4 (data intacte). + +**Vitest** (`frontend`) : +- le wizard first-run n'affiche que Claude/Codex ; **le bloc custom est absent** + (`aria-label="add custom profile"` introuvable). +- le sélecteur de création d'agent ne propose que Claude/Codex, pas de custom. +- garde anti-always-green : un test qui vérifie l'**absence** du custom doit échouer si le bloc + réapparaît (assertion sur non-présence d'un testid/label précis). + +## 5. Méthode + +DevBackend → contrat + build vert → Main relaie à DevFrontend → build vert → QA écrit+exécute +(`cargo test --workspace` ET `npx vitest run`) → vert. Rapport d'erreurs clair si rouge. +**Ne pas committer, ne pas push.** + +## 6. Références +- Catalogue : `crates/application/src/agent/catalogue.rs` (`reference_profiles()` : claude+codex + `with_structured_adapter`, gemini+aider sans) ; use cases : `…/agent/usecases.rs` + (`ReferenceProfiles`). +- Factory : `AgentSessionFactory::supports` (livré D2, `crates/infrastructure/src/session/factory.rs`). +- DTO : `crates/app-tauri/src/dto.rs` (`ProfileDto`/`ProfileListDto`). +- Front : `frontend/src/features/first-run/{FirstRunWizard,ProfilesSettings}.tsx`, + `frontend/src/features/agents/`, `frontend/src/domain/index.ts` (`emptyCustomProfile`). +- Spec : `ARCHITECTURE.md` §17.3, §17.6 et tableau §17.9 ligne **D7**. diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 21e96c1..32fcfe8 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -569,8 +569,13 @@ pub async fn first_run_state(state: State<'_, AppState>) -> Result Vec { .expect("aider reference profile is valid"), ] } + +/// Returns the **selectable** subset of [`reference_profiles`] — the profiles the +/// first-run wizard and the agent-creation menu are allowed to offer (§17.3, +/// lot D7). +/// +/// A profile is selectable iff it can be driven in **structured** mode +/// ([`AgentProfile::is_selectable`] = it carries a `structured_adapter`). Today +/// that is Claude + Codex; Gemini/Aider stay in [`reference_profiles`] (the data +/// catalogue is untouched) but are **not** proposed for selection. There is no +/// custom-profile entry here either: the selection path offers only profiles we +/// know how to pilot. +/// +/// This filter is the single selection gate; `is_selectable` is the same +/// predicate the `AgentSessionFactory` uses to decide it `supports` a profile, so +/// the menu and the runtime can never disagree. +#[must_use] +pub fn selectable_reference_profiles() -> Vec { + reference_profiles() + .into_iter() + .filter(AgentProfile::is_selectable) + .collect() +} diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 988272f..96f94b4 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -17,7 +17,7 @@ pub(crate) use lifecycle::unique_md_path; pub use structured::send_blocking; -pub use catalogue::{reference_profile_id, reference_profiles}; +pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles}; pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput}; pub use resume::{ ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent, diff --git a/crates/application/src/agent/usecases.rs b/crates/application/src/agent/usecases.rs index def35e2..c73f7b7 100644 --- a/crates/application/src/agent/usecases.rs +++ b/crates/application/src/agent/usecases.rs @@ -18,7 +18,7 @@ use domain::profile::AgentProfile; use crate::error::AppError; -use super::catalogue::reference_profiles; +use super::catalogue::selectable_reference_profiles; // --------------------------------------------------------------------------- // DetectProfiles @@ -256,11 +256,15 @@ impl ConfigureProfiles { /// Output of [`ReferenceProfiles::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReferenceProfilesOutput { - /// The pre-filled, editable reference catalogue. + /// The pre-filled, editable reference catalogue, **restricted to the + /// selectable profiles** (§17.3, D7): only profiles drivable in structured + /// mode are offered to selection/creation. Today: Claude + Codex. pub profiles: Vec, } -/// Exposes the pre-filled reference catalogue (Claude/Codex/Gemini/Aider). +/// Exposes the **selectable** reference catalogue for the agent-creation menu +/// (§17.3, D7): the structured-drivable profiles only (Claude/Codex). Gemini and +/// Aider remain in the raw catalogue data but are not proposed here. #[derive(Default)] pub struct ReferenceProfiles; @@ -278,7 +282,7 @@ impl ReferenceProfiles { #[allow(clippy::unused_async)] pub async fn execute(&self) -> Result { Ok(ReferenceProfilesOutput { - profiles: reference_profiles(), + profiles: selectable_reference_profiles(), }) } } @@ -292,7 +296,9 @@ impl ReferenceProfiles { pub struct FirstRunStateOutput { /// `true` when no `profiles.json` exists yet ⇒ show the first-run wizard. pub is_first_run: bool, - /// The pre-filled reference catalogue to seed the wizard. + /// The pre-filled reference catalogue to seed the wizard, **restricted to the + /// selectable profiles** (§17.3, D7): only structured-drivable profiles + /// (Claude/Codex) are offered. No custom-profile entry. pub reference_profiles: Vec, } @@ -317,7 +323,7 @@ impl FirstRunState { let configured = self.store.is_configured().await?; Ok(FirstRunStateOutput { is_first_run: !configured, - reference_profiles: reference_profiles(), + reference_profiles: selectable_reference_profiles(), }) } } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 34f19a1..38525d8 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -27,8 +27,8 @@ pub mod terminal; pub mod window; pub use agent::{ - reference_profile_id, reference_profiles, ChangeAgentProfile, ChangeAgentProfileInput, - ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput, + reference_profile_id, reference_profiles, selectable_reference_profiles, ChangeAgentProfile, + ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 711cffb..2b65776 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -216,7 +216,20 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() { let out = uc.execute().await.unwrap(); assert!(out.is_first_run); - assert_eq!(out.reference_profiles.len(), 4, "catalogue seeded"); + // §17.3/D7: the wizard is seeded only with the *selectable* (structured- + // drivable) profiles — Claude + Codex — not the full 4-profile catalogue. + assert_eq!(out.reference_profiles.len(), 2, "selectable catalogue seeded"); + let commands: Vec<&str> = out + .reference_profiles + .iter() + .map(|p| p.command.as_str()) + .collect(); + assert_eq!(commands, vec!["claude", "codex"], "only Claude/Codex offered"); + // Every seeded profile is selectable (the gate the menu relies on). + assert!( + out.reference_profiles.iter().all(AgentProfile::is_selectable), + "seeded profiles must all be selectable" + ); } #[tokio::test] @@ -274,9 +287,53 @@ async fn delete_unknown_is_not_found_error() { // --------------------------------------------------------------------------- #[tokio::test] -async fn reference_profiles_use_case_returns_four() { +async fn reference_profiles_use_case_returns_only_selectable() { + // §17.3/D7: the selection use case exposes only structured-drivable profiles + // (Claude + Codex). Gemini/Aider stay in the raw catalogue (see + // `catalogue_*` tests below) but are not offered to selection/creation. let out = ReferenceProfiles::new().execute().await.unwrap(); - assert_eq!(out.profiles.len(), 4); + assert_eq!(out.profiles.len(), 2); + let commands: Vec<&str> = out.profiles.iter().map(|p| p.command.as_str()).collect(); + assert_eq!(commands, vec!["claude", "codex"]); + assert!(out.profiles.iter().all(AgentProfile::is_selectable)); +} + +/// §17.3/D7 — non-regression: the *raw* catalogue still carries the four profiles +/// (data intact). Only the selection-facing use case is filtered. +#[test] +fn raw_catalogue_still_has_all_four_profiles() { + let commands: Vec = reference_profiles() + .iter() + .map(|p| p.command.clone()) + .collect(); + assert_eq!(commands, vec!["claude", "codex", "gemini", "aider"]); +} + +/// §17.3/D7 — `is_selectable` is the single selection predicate: true for the two +/// structured-drivable profiles, false for the two PTY-only ones. This assertion +/// would flip (and fail) the moment Gemini/Aider gained an adapter or Claude/Codex +/// lost theirs — i.e. it actually constrains behaviour. +#[test] +fn is_selectable_is_true_only_for_claude_and_codex() { + let profiles = reference_profiles(); + let by_command: HashMap<&str, &AgentProfile> = + profiles.iter().map(|p| (p.command.as_str(), p)).collect(); + assert!( + by_command["claude"].is_selectable(), + "Claude carries a structured adapter ⇒ selectable" + ); + assert!( + by_command["codex"].is_selectable(), + "Codex carries a structured adapter ⇒ selectable" + ); + assert!( + !by_command["gemini"].is_selectable(), + "Gemini has no adapter ⇒ not selectable" + ); + assert!( + !by_command["aider"].is_selectable(), + "Aider has no adapter ⇒ not selectable" + ); } #[test] diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index 64e751f..d93b6ef 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -318,4 +318,19 @@ impl AgentProfile { self.structured_adapter = Some(adapter); self } + + /// Indique si ce profil peut être **proposé à la sélection/création** d'un + /// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est + /// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il + /// est pilotable en mode structuré. C'est exactement le prédicat que + /// l'`AgentSessionFactory` utilise pour décider qu'il sait piloter le profil + /// (`supports`) : les deux doivent rester d'accord. + /// + /// Ceci ne restreint **que** ce qui est offert à la création : un profil + /// sans adapter reste un profil PTY/legacy valide, persistable et exécutable + /// (zéro régression sur l'existant). + #[must_use] + pub fn is_selectable(&self) -> bool { + self.structured_adapter.is_some() + } } diff --git a/crates/infrastructure/src/session/factory.rs b/crates/infrastructure/src/session/factory.rs index 105aa38..93b7211 100644 --- a/crates/infrastructure/src/session/factory.rs +++ b/crates/infrastructure/src/session/factory.rs @@ -50,7 +50,10 @@ fn seed_conversation_id(session: &SessionPlan) -> Option { #[async_trait] impl AgentSessionFactory for StructuredSessionFactory { fn supports(&self, profile: &AgentProfile) -> bool { - profile.structured_adapter.is_some() + // Source unique de vérité (§17.3, D7) : sélectionnabilité = pilotable en + // structuré. `is_selectable` et `supports` partagent ainsi le **même** + // prédicat de domaine, ils ne peuvent pas diverger. + profile.is_selectable() } async fn start( diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index 70edf42..b978c04 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -352,6 +352,43 @@ mod tests { assert!(!factory.supports(&tui)); } + /// §17.3/D7 — **strict coherence**: `AgentProfile::is_selectable` (the menu's + /// selection gate) and `StructuredSessionFactory::supports` (the runtime's + /// routing gate) must agree on **every** reference profile. If they ever + /// diverged, the wizard could offer a profile the runtime cannot drive (or + /// hide one it can). Asserting profile-by-profile over the real catalogue — + /// including the expected `claude=codex=true`, `gemini=aider=false` truth + /// table — means this fails the moment either gate changes without the other. + #[tokio::test] + async fn supports_and_is_selectable_agree_on_every_reference_profile() { + use std::collections::HashMap; + let factory = StructuredSessionFactory::new(); + let profiles = application::reference_profiles(); + + let mut expected: HashMap<&str, bool> = HashMap::new(); + expected.insert("claude", true); + expected.insert("codex", true); + expected.insert("gemini", false); + expected.insert("aider", false); + + assert_eq!(profiles.len(), 4, "catalogue has the four reference profiles"); + for profile in &profiles { + let selectable = profile.is_selectable(); + assert_eq!( + selectable, + factory.supports(profile), + "is_selectable and supports must agree for `{}`", + profile.command + ); + assert_eq!( + Some(&selectable), + expected.get(profile.command.as_str()), + "unexpected selectability for `{}`", + profile.command + ); + } + } + #[tokio::test] async fn factory_routes_claude_and_codex() { let factory = StructuredSessionFactory::new(); diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index e3cc136..0879d52 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1160,7 +1160,13 @@ class MockRemoteGateway implements RemoteGateway { async connect(): Promise {} } -/** The pre-filled reference catalogue the mock serves (mirror of the backend). */ +/** + * The pre-filled reference catalogue the mock serves — mirror of the backend's + * **selectable** catalogue (§17.3/D7): only profiles drivable in structured mode + * (Claude + Codex) are offered to selection/creation. Gemini/Aider stay in the + * backend's raw catalogue data but are filtered out of the selection path, so the + * mock must not serve them either. + */ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [ { id: "mock-claude", @@ -1180,24 +1186,6 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [ detect: "codex --version", cwdTemplate: "{projectRoot}", }, - { - id: "mock-gemini", - name: "Gemini CLI", - command: "gemini", - args: [], - contextInjection: { strategy: "conventionFile", target: "GEMINI.md" }, - detect: "gemini --version", - cwdTemplate: "{projectRoot}", - }, - { - id: "mock-aider", - name: "Aider", - command: "aider", - args: [], - contextInjection: { strategy: "flag", flag: "--message-file {path}" }, - detect: "aider --version", - cwdTemplate: "{projectRoot}", - }, ]; /** diff --git a/frontend/src/adapters/mock/profile.test.ts b/frontend/src/adapters/mock/profile.test.ts index 9e22925..ce15288 100644 --- a/frontend/src/adapters/mock/profile.test.ts +++ b/frontend/src/adapters/mock/profile.test.ts @@ -21,15 +21,15 @@ function customProfile(id: string, command: string): AgentProfile { } describe("MockProfileGateway", () => { - it("firstRunState is first-run with the four reference profiles", async () => { + it("firstRunState is first-run with only the selectable reference profiles", async () => { + // §17.3/D7: only structured-drivable profiles (Claude/Codex) are offered; + // Gemini/Aider are filtered out of the selection path server-side. const gw = new MockProfileGateway(); const state = await gw.firstRunState(); expect(state.isFirstRun).toBe(true); expect(state.referenceProfiles.map((p) => p.command)).toEqual([ "claude", "codex", - "gemini", - "aider", ]); }); @@ -51,8 +51,6 @@ describe("MockProfileGateway", () => { expect(byCommand).toEqual({ claude: true, codex: false, - gemini: false, - aider: false, }); }); diff --git a/frontend/src/features/first-run/FirstRunWizard.test.tsx b/frontend/src/features/first-run/FirstRunWizard.test.tsx index 7fb0801..56a6998 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -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"]); }); }); diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index 0a4bd7f..3c5e274 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -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 ` setDraft({ ...draft, name: e.target.value })} - /> - setDraft({ ...draft, command: e.target.value })} - /> - setDraft({ ...draft, args: parseArgs(e.target.value) })} - /> - - - - {ci.strategy === "conventionFile" && ( - - setDraft({ - ...draft, - contextInjection: { strategy: "conventionFile", target: e.target.value }, - }) - } - /> - )} - {ci.strategy === "flag" && ( - - setDraft({ - ...draft, - contextInjection: { strategy: "flag", flag: e.target.value }, - }) - } - /> - )} - {ci.strategy === "env" && ( - - setDraft({ - ...draft, - contextInjection: { strategy: "env", var: e.target.value }, - }) - } - /> - )} - - {Object.values(errors).map((msg) => ( - - {msg} - - ))} - - - - ); -} diff --git a/frontend/src/features/first-run/useFirstRun.ts b/frontend/src/features/first-run/useFirstRun.ts index fc2d87a..38497cc 100644 --- a/frontend/src/features/first-run/useFirstRun.ts +++ b/frontend/src/features/first-run/useFirstRun.ts @@ -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; @@ -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,