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:
95
.ideai/briefs/d7-menu-restreint.md
Normal file
95
.ideai/briefs/d7-menu-restreint.md
Normal file
@ -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**.
|
||||
@ -569,8 +569,13 @@ pub async fn first_run_state(state: State<'_, AppState>) -> Result<FirstRunState
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `reference_profiles` — the pre-filled, editable reference catalogue
|
||||
/// (Claude/Codex/Gemini/Aider).
|
||||
/// `reference_profiles` — the pre-filled, editable reference catalogue offered to
|
||||
/// agent creation/selection.
|
||||
///
|
||||
/// Restricted to the **selectable** profiles (§17.3, lot D7): only profiles
|
||||
/// drivable in structured mode (today Claude + Codex) are returned. Gemini/Aider
|
||||
/// stay in the catalogue data but are not proposed, and there is no custom-profile
|
||||
/// entry. Persistence/editing of pre-existing (legacy) profiles is unaffected.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (never in practice; the catalogue is in-memory).
|
||||
@ -651,8 +656,15 @@ pub async fn delete_profile(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `configure_profiles` — persist the batch of chosen/edited/custom profiles,
|
||||
/// closing the first run.
|
||||
/// `configure_profiles` — persist the batch of chosen/edited profiles, closing
|
||||
/// the first run.
|
||||
///
|
||||
/// The selection surface offered upstream is already restricted to selectable
|
||||
/// profiles (§17.3, D7), so the wizard sends only structured-drivable profiles
|
||||
/// and no arbitrary custom command. This command itself stays permissive on
|
||||
/// purpose: it must keep persisting any profile shape so a project with a
|
||||
/// pre-existing Gemini/Aider/custom **legacy** profile remains editable and
|
||||
/// runnable (we restrict creation, not the existing).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure).
|
||||
|
||||
@ -97,3 +97,25 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
.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<AgentProfile> {
|
||||
reference_profiles()
|
||||
.into_iter()
|
||||
.filter(AgentProfile::is_selectable)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<AgentProfile>,
|
||||
}
|
||||
|
||||
/// 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<ReferenceProfilesOutput, AppError> {
|
||||
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<AgentProfile>,
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<String> = 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]
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,7 +50,10 @@ fn seed_conversation_id(session: &SessionPlan) -> Option<String> {
|
||||
#[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(
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -1160,7 +1160,13 @@ class MockRemoteGateway implements RemoteGateway {
|
||||
async connect(): Promise<void> {}
|
||||
}
|
||||
|
||||
/** 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}",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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