feat(domain,application,infrastructure,backend,app-tauri,frontend): lot utilisateur #350 — animation d'écartement des onglets au drag/reorder, icônes projet N&B colorisées derrière les messages, tools/skills plugin utilisables par les agents autorisés en headless, refs stables des skills assignés (QA verte)

- Tabs (shared/ui): cible de drop matérialisée par écartement before/after pendant le drag/reorder, reset via rAF sans reliquat, clavier et annonces préservés.
- projectIcons: icônes projet en noir et blanc par défaut, colorisées avec la couleur du projet, occupant la cellule et visibles derrière les messages rendus légèrement transparents.
- Permissions plugin headless: PluginMcpServerSpec transporte l'allowlist exacte authorized_tools regroupée par (pluginId, serverId) depuis les PluginToolRef; projection Codex enabled_tools + default_tools_approval_mode="approve" — une permission déjà accordée au niveau projet/template ne déclenche pas de seconde approbation, un tool non assigné reste hors surface appelable; réserves orchestrator/mcp server+tools alignées, dto backend + dto_agents app-tauri.
- Skills: ref stable plugin:<pluginId>:<skillId> exposée dans le contexte agent, idea_skill_read(ref=...) canonique via read_assigned_by_ref, erreur structurée ambiguousSkillName avec candidates; SkillsPanel dédoublonné (android-build-install); agents-dev/L12-skills.md à jour.
- Docs: ARCHITECTURE.md section 22.3 (projection des autorisations agent vers les runtimes plugin).
- QA verte #350 reconfirmée avant commit.
This commit is contained in:
2026-09-13 14:11:31 +02:00
parent 1e15bf9f96
commit 92d0dce2b7
23 changed files with 660 additions and 95 deletions

View File

@ -1928,7 +1928,7 @@ Nouvelles commandes (jumelles des commandes PTY existantes ; réutilisent `resol
- **Vivant de bout en bout** : `apply_mcp_config` matérialise `.mcp.json` dans le **run dir isolé** de l'agent **AVANT** le split structuré/PTY (`crates/application/src/agent/lifecycle.rs` ~1094) ⇒ Claude/Codex le lisent **nativement**. La déclaration porte l'**exe réel** injecté par `McpRuntime` (`$APPIMAGE` sinon `current_exe`, `crates/app-tauri/src/mcp_endpoint.rs:139` `idea_exe_path`), l'**endpoint loopback** du projet, le `--project` et le `--requester` (agent réel, fin du `"mcp"` figé). - **Vivant de bout en bout** : `apply_mcp_config` matérialise `.mcp.json` dans le **run dir isolé** de l'agent **AVANT** le split structuré/PTY (`crates/application/src/agent/lifecycle.rs` ~1094) ⇒ Claude/Codex le lisent **nativement**. La déclaration porte l'**exe réel** injecté par `McpRuntime` (`$APPIMAGE` sinon `current_exe`, `crates/app-tauri/src/mcp_endpoint.rs:139` `idea_exe_path`), l'**endpoint loopback** du projet, le `--project` et le `--requester` (agent réel, fin du `"mcp"` figé).
- **Endpoint loopback** = **UDS** (Linux/macOS) / **named pipe** (Windows), **zéro port réseau** ; source de vérité unique `mcp_endpoint(project_id)` (`mcp_endpoint.rs`), bindé à l'open / fermé au close (`crates/app-tauri/src/state.rs` `ensure_mcp_server`/`bind_endpoint`). **Fix D1** : cadavre `.sock` (run SIGKILL) **unlinké avant bind** (`state.rs` ~1019-1033) — sinon `EADDRINUSE`. - **Endpoint loopback** = **UDS** (Linux/macOS) / **named pipe** (Windows), **zéro port réseau** ; source de vérité unique `mcp_endpoint(project_id)` (`mcp_endpoint.rs`), bindé à l'open / fermé au close (`crates/app-tauri/src/state.rs` `ensure_mcp_server`/`bind_endpoint`). **Fix D1** : cadavre `.sock` (run SIGKILL) **unlinké avant bind** (`state.rs` ~1019-1033) — sinon `EADDRINUSE`.
- **Serveur** `McpServer` (`crates/infrastructure/src/orchestrator/mcp/server.rs`) = **jumeau du `FsOrchestratorWatcher`** : autre porte sur le **même** `OrchestratorService::dispatch`, `serve(conn)` **par pair**. Transport `StdioTransport` (JSON Lines stdin/stdout du pont) + `MemoryTransport` (tests, sans socket ni process). Pont = sous-commande `mcp-server` du binaire app-tauri (`mcp_bridge.rs`). - **Serveur** `McpServer` (`crates/infrastructure/src/orchestrator/mcp/server.rs`) = **jumeau du `FsOrchestratorWatcher`** : autre porte sur le **même** `OrchestratorService::dispatch`, `serve(conn)` **par pair**. Transport `StdioTransport` (JSON Lines stdin/stdout du pont) + `MemoryTransport` (tests, sans socket ni process). Pont = sous-commande `mcp-server` du binaire app-tauri (`mcp_bridge.rs`).
- **Outils** (`mcp/tools.rs`) — **catalogue de 14**, tous mappés 1:1 vers `OrchestratorCommand` et servis par le **même** `dispatch` (les trois portes : fichier, MCP, UI) : `idea_list_agents`, `idea_ask_agent`, `idea_reply`, `idea_launch_agent`, `idea_stop_agent`, `idea_update_context`, `idea_create_skill` (7 base) · `idea_context_read`, `idea_context_propose`, `idea_memory_read`, `idea_memory_write` (4 FileGuard C7) · `idea_skill_read` (skill-awareness) · `idea_workstate_read`, `idea_workstate_set` (LS4 ; `set` n'écrit que la ligne de l'agent courant, via l'identité handshake). - **Outils** (`mcp/tools.rs`) — **catalogue de 14**, tous mappés 1:1 vers `OrchestratorCommand` et servis par le **même** `dispatch` (les trois portes : fichier, MCP, UI) : `idea_list_agents`, `idea_ask_agent`, `idea_reply`, `idea_launch_agent`, `idea_stop_agent`, `idea_update_context`, `idea_create_skill` (7 base) · `idea_context_read`, `idea_context_propose`, `idea_memory_read`, `idea_memory_write` (4 FileGuard C7) · `idea_skill_read` (skill-awareness ; lecture canonique par ref stable, lookup par nom compatible mais erreur typée `ambiguousSkillName` avec candidates en cas de doublon) · `idea_workstate_read`, `idea_workstate_set` (LS4 ; `set` n'écrit que la ligne de l'agent courant, via l'identité handshake).
### 18.6 Invariant « 1 agent = 1 session vivante » (livré) ### 18.6 Invariant « 1 agent = 1 session vivante » (livré)
- Registres `TerminalSessions` + `StructuredSessions` (`crates/application/src/terminal/registry.rs`) agrégés en `LiveSessions` (PTY+structuré). `session_for_agent` (singulier, **non ambigu**) **+** `sessions_for_agent` (pluriel). Garde reattach `Rebind`/`Refuse`/`Idempotent` dans `LaunchAgent` (`lifecycle.rs`). Ancienne ambiguïté `session-registry-agent-ambiguity` = **fermée par construction**. - Registres `TerminalSessions` + `StructuredSessions` (`crates/application/src/terminal/registry.rs`) agrégés en `LiveSessions` (PTY+structuré). `session_for_agent` (singulier, **non ambigu**) **+** `sessions_for_agent` (pluriel). Garde reattach `Rebind`/`Refuse`/`Idempotent` dans `LaunchAgent` (`lifecycle.rs`). Ancienne ambiguïté `session-registry-agent-ambiguity` = **fermée par construction**.
@ -2418,3 +2418,9 @@ Ok(declared_main || declared_icon || rel.as_str().starts_with("assets/"))
4. Preuve requise : test de purge (installer, écrire via `ctx.storage`, désinstaller, vérifier `plugins/data/<id>/` disparu) et absence de tout chemin `.ideai/...` dans les exemples SDK par défaut. 4. Preuve requise : test de purge (installer, écrire via `ctx.storage`, désinstaller, vérifier `plugins/data/<id>/` disparu) et absence de tout chemin `.ideai/...` dans les exemples SDK par défaut.
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.* *Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*
### 22.3 #350 — Projection des autorisations agent vers les runtimes plugin
`PluginMcpServerSpec` transporte l'allowlist exacte `authorizedTools`. Le resolver projet regroupe les `PluginToolRef` par `(pluginId, serverId)` sans perdre leur `toolName` ni leur ordre d'assignation. Pour Codex, chaque table plugin générée écrit `enabled_tools` avec cette seule allowlist et `default_tools_approval_mode = "approve"` : une permission déjà accordée par le projet ne déclenche pas de seconde approbation interactive, tandis qu'un tool non assigné n'entre pas dans la surface appelable.
Les skills assignés exposent parallèlement une ref stable dans le contexte agent. Une contribution plugin utilise `plugin:<pluginId>:<skillId>`. `idea_skill_read(ref=...)` est canonique ; `name` reste compatible, mais plusieurs correspondances produisent l'erreur structurée `ambiguousSkillName` et la liste `candidates` des refs utilisables.

View File

@ -44,7 +44,7 @@ Modéliser les **Skills** : workflows réutilisables (équivalent universel des
### ✅ Injection dans le convention file (vert, fil L6) ### ✅ Injection dans le convention file (vert, fil L6)
- `LaunchAgent` reçoit le port `SkillStore` ; `resolve_skills` lit les `.md` des skills assignés (ordre manifest, déterministe ; skill supprimé = `SkillRef` pendant → ignoré sans bloquer le lancement). - `LaunchAgent` reçoit le port `SkillStore` ; `resolve_skills` lit les `.md` des skills assignés (ordre manifest, déterministe ; skill supprimé = `SkillRef` pendant → ignoré sans bloquer le lancement).
- `compose_convention_file` étendu : section `# Skills` (sous-titres `## <name>`) après le persona ; omise si aucun skill. **3 tests** unitaires + e2e (`agent_lifecycle.rs` : injection ordonnée, ref pendant tolérée). - `compose_convention_file` étendu : section `# Skills` avant le persona ; omise si aucun skill. Chaque entrée expose une ref stable (`skill:<scope>:<uuid>` ou `plugin:<pluginId>:<skillId>`). `idea_skill_read(ref=…)` est la voie canonique ; le lookup historique par `name` reste accepté seulement s'il est non ambigu et renvoie `ambiguousSkillName` avec les refs candidates sinon. **3 tests** unitaires + e2e (`agent_lifecycle.rs` : injection ordonnée, ref pendant tolérée).
- **Composition root** (`app-tauri/state.rs`) : `FsSkillStore` construit (app-data global), injecté dans `LaunchAgent`. - **Composition root** (`app-tauri/state.rs`) : `FsSkillStore` construit (app-data global), injecté dans `LaunchAgent`.
### ✅ IPC `app-tauri` (vert) ### ✅ IPC `app-tauri` (vert)

View File

@ -79,6 +79,7 @@ fn agent_list_dto_is_transparent_array() {
capabilities: vec![ListedAgentCapabilities { capabilities: vec![ListedAgentCapabilities {
agent_id: first.id, agent_id: first.id,
capabilities: vec![AgentCapability { capabilities: vec![AgentCapability {
reference: "skill:project:00000000-0000-0000-0000-000000000001".to_owned(),
name: "review".to_owned(), name: "review".to_owned(),
description: "Reviews changes".to_owned(), description: "Reviews changes".to_owned(),
kind: SkillKind::Reference, kind: SkillKind::Reference,
@ -91,6 +92,10 @@ fn agent_list_dto_is_transparent_array() {
let arr = v.as_array().expect("transparent array"); let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 2); assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["name"], "My Agent"); assert_eq!(arr[0]["name"], "My Agent");
assert_eq!(
arr[0]["capabilities"][0]["reference"],
"skill:project:00000000-0000-0000-0000-000000000001"
);
assert_eq!(arr[0]["capabilities"][0]["name"], "review"); assert_eq!(arr[0]["capabilities"][0]["name"], "review");
assert_eq!(arr[0]["capabilities"][0]["description"], "Reviews changes"); assert_eq!(arr[0]["capabilities"][0]["description"], "Reviews changes");
assert_eq!(arr[0]["capabilities"][0]["kind"], "reference"); assert_eq!(arr[0]["capabilities"][0]["kind"], "reference");

View File

@ -3801,7 +3801,7 @@ fn codex_plugin_mcp_declaration(plugin_servers: &[PluginMcpServerSpec]) -> Strin
for server in plugin_servers { for server in plugin_servers {
rendered.push('\n'); rendered.push('\n');
rendered.push_str(&format!( rendered.push_str(&format!(
"[mcp_servers.{}]\ncommand = {}\nargs = [{}]\nenv = {}\ncwd = {}\n", "[mcp_servers.{}]\ncommand = {}\nargs = [{}]\nenv = {}\ncwd = {}\nenabled_tools = [{}]\ndefault_tools_approval_mode = \"approve\"\n",
toml_string(&server.client_name()), toml_string(&server.client_name()),
toml_string(&server.command), toml_string(&server.command),
server server
@ -3812,6 +3812,12 @@ fn codex_plugin_mcp_declaration(plugin_servers: &[PluginMcpServerSpec]) -> Strin
.join(", "), .join(", "),
toml_inline_string_map(&server.env), toml_inline_string_map(&server.env),
toml_string(&server.cwd), toml_string(&server.cwd),
server
.authorized_tools
.iter()
.map(|tool| toml_string(tool))
.collect::<Vec<_>>()
.join(", "),
)); ));
} }
rendered rendered
@ -4468,7 +4474,7 @@ pub(crate) fn compose_convention_file(
out.push_str( out.push_str(
"**Skills** : workflows réutilisables. Ceux qui te sont assignés te sont listés \ "**Skills** : workflows réutilisables. Ceux qui te sont assignés te sont listés \
plus haut dans ce fichier ; charge le détail d'un skill avec \ plus haut dans ce fichier ; charge le détail d'un skill avec \
`idea_skill_read(name=…)` ; crée-en un avec `idea_create_skill`. Ne réinvente \ `idea_skill_read(ref=…)` ; crée-en un avec `idea_create_skill`. Ne réinvente \
pas un workflow déjà couvert par un skill.\n\n", pas un workflow déjà couvert par un skill.\n\n",
); );
} else { } else {
@ -4517,7 +4523,7 @@ pub(crate) fn compose_convention_file(
if mcp_enabled { if mcp_enabled {
out.push_str( out.push_str(
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \ "Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \ détail, appelle l'outil `idea_skill_read(ref=…)` avec la ref affichée — n'improvise pas un \
workflow déjà couvert par un skill, charge-le.\n\n", workflow déjà couvert par un skill, charge-le.\n\n",
); );
} else { } else {
@ -4531,7 +4537,9 @@ pub(crate) fn compose_convention_file(
for skill in &effective.agent_capabilities { for skill in &effective.agent_capabilities {
out.push_str("**"); out.push_str("**");
out.push_str(&skill.name); out.push_str(&skill.name);
out.push_str("** — "); out.push_str("** (`");
out.push_str(&skill.reference);
out.push_str("`) — ");
out.push_str(&skill.description); out.push_str(&skill.description);
out.push_str(" ("); out.push_str(" (");
out.push_str(match skill.kind { out.push_str(match skill.kind {
@ -4943,8 +4951,10 @@ mod tests {
); );
// Both skill affordances are present, but bodies are not dumped. // Both skill affordances are present, but bodies are not dumped.
assert!(doc.contains("**refactor** — REFAC_BODY (workflow)")); assert!(doc.contains("**refactor** (`skill:global:"));
assert!(doc.contains("**review** — REVIEW_BODY (workflow)")); assert!(doc.contains("`) — REFAC_BODY (workflow)"));
assert!(doc.contains("**review** (`skill:global:"));
assert!(doc.contains("`) — REVIEW_BODY (workflow)"));
assert!(!doc.contains("\n\nREFAC_BODY")); assert!(!doc.contains("\n\nREFAC_BODY"));
assert!(!doc.contains("\n\nREVIEW_BODY")); assert!(!doc.contains("\n\nREVIEW_BODY"));
let awareness_at = doc.find("**Skills IdeA**").unwrap(); let awareness_at = doc.find("**Skills IdeA**").unwrap();
@ -5118,8 +5128,10 @@ mod tests {
"points to the file-protocol read path" "points to the file-protocol read path"
); );
} }
assert!(doc.contains("**refactor** — Refactors code (workflow)")); assert!(doc.contains("**refactor** (`skill:global:"));
assert!(doc.contains("**review** — Review skill (workflow)")); assert!(doc.contains("`) — Refactors code (workflow)"));
assert!(doc.contains("**review** (`skill:global:"));
assert!(doc.contains("`) — Review skill (workflow)"));
assert!(!doc.contains("REFAC_BODY"), "no full body is injected"); assert!(!doc.contains("REFAC_BODY"), "no full body is injected");
assert!(!doc.contains("REVIEW_BODY"), "no full body is injected"); assert!(!doc.contains("REVIEW_BODY"), "no full body is injected");
assert!(!doc.contains("## refactor")); assert!(!doc.contains("## refactor"));
@ -5541,6 +5553,7 @@ mod tests {
env: vec![("UNITY_SOCKET".to_owned(), "/tmp/unity.sock".to_owned())], env: vec![("UNITY_SOCKET".to_owned(), "/tmp/unity.sock".to_owned())],
cwd: "/plugins/unity".to_owned(), cwd: "/plugins/unity".to_owned(),
transport: "stdio".to_owned(), transport: "stdio".to_owned(),
authorized_tools: vec!["unity_detect_project".to_owned(), "unity_build".to_owned()],
} }
} }
@ -5565,6 +5578,9 @@ mod tests {
); );
assert!(codex.contains(&format!("[mcp_servers.{}]", toml_string(&name)))); assert!(codex.contains(&format!("[mcp_servers.{}]", toml_string(&name))));
assert!(codex.contains("UNITY_SOCKET")); assert!(codex.contains("UNITY_SOCKET"));
assert!(codex.contains("enabled_tools = [\"unity_detect_project\", \"unity_build\"]"));
assert!(!codex.contains("unity_delete_project"));
assert!(codex.contains("default_tools_approval_mode = \"approve\""));
let opencode = opencode_mcp_servers( let opencode = opencode_mcp_servers(
vec!["idea".into(), "mcp-server".into()], vec!["idea".into(), "mcp-server".into()],

View File

@ -27,6 +27,15 @@ pub enum AppError {
#[error("invalid input: {0}")] #[error("invalid input: {0}")]
Invalid(String), Invalid(String),
/// A legacy skill-name lookup matched more than one assigned capability.
#[error("skill name `{name}` is ambiguous; use one of these refs: {candidates:?}")]
AmbiguousSkillName {
/// Name supplied by the caller.
name: String,
/// Stable refs of every matching assigned skill.
candidates: Vec<String>,
},
/// An optimistic-concurrency `if_match` did not match the resource's current /// An optimistic-concurrency `if_match` did not match the resource's current
/// version. Carries the current version so the caller can retry. /// version. Carries the current version so the caller can retry.
#[error("concurrency conflict: {0}")] #[error("concurrency conflict: {0}")]
@ -150,6 +159,7 @@ impl AppError {
match self { match self {
Self::NotFound(_) => "NOT_FOUND", Self::NotFound(_) => "NOT_FOUND",
Self::Invalid(_) => "INVALID", Self::Invalid(_) => "INVALID",
Self::AmbiguousSkillName { .. } => "ambiguousSkillName",
Self::Conflict(_) => "CONFLICT", Self::Conflict(_) => "CONFLICT",
Self::FileSystem(_) => "FILESYSTEM", Self::FileSystem(_) => "FILESYSTEM",
Self::Store(_) => "STORE", Self::Store(_) => "STORE",

View File

@ -1186,8 +1186,8 @@ pub struct OrchestratorService {
/// [`Self::with_memory_harvest`] ; `None` ⇒ aucun harvest (zéro régression). Un /// [`Self::with_memory_harvest`] ; `None` ⇒ aucun harvest (zéro régression). Un
/// échec de harvest ne transforme **jamais** un succès de délégation en erreur. /// échec de harvest ne transforme **jamais** un succès de délégation en erreur.
memory_harvest: Option<Arc<HarvestMemoryFromTurn>>, memory_harvest: Option<Arc<HarvestMemoryFromTurn>>,
/// Use case de lecture d'un skill **par nom** (`idea_skill_read`, feature /// Use case de lecture d'un skill par ref stable ou nom compatible
/// « skills à la MCP »). Compose le port `SkillStore` existant (aucun nouveau /// (`idea_skill_read`, feature « skills à la MCP »). Compose le port `SkillStore` existant (aucun nouveau
/// port). Injecté via [`Self::with_read_skill`] ; `None` ⇒ la commande /// port). Injecté via [`Self::with_read_skill`] ; `None` ⇒ la commande
/// `skill.read` renvoie une erreur typée (call sites/tests legacy restent verts). /// `skill.read` renvoie une erreur typée (call sites/tests legacy restent verts).
read_skill: Option<Arc<ReadSkill>>, read_skill: Option<Arc<ReadSkill>>,
@ -2396,9 +2396,11 @@ impl OrchestratorService {
OrchestratorCommand::ReadMemory { slug, requester } => { OrchestratorCommand::ReadMemory { slug, requester } => {
self.read_memory(project, slug, requester).await self.read_memory(project, slug, requester).await
} }
OrchestratorCommand::ReadSkill { name, requester } => { OrchestratorCommand::ReadSkill {
self.read_skill(project, name, requester).await reference,
} name,
requester,
} => self.read_skill(project, reference, name, requester).await,
OrchestratorCommand::WriteMemory { OrchestratorCommand::WriteMemory {
slug, slug,
content, content,
@ -2997,14 +2999,15 @@ impl OrchestratorService {
}) })
} }
/// `skill.read` → resolves a skill by name (project scope first, then global) /// `skill.read` → resolves an assigned skill by stable ref or legacy name
/// and returns its Markdown body inline in the outcome's `reply`. Read-only: /// and returns its Markdown body inline in the outcome's `reply`. Read-only:
/// `requester` is accepted for symmetry with the other read tools but skill /// `requester` is accepted for symmetry with the other read tools but skill
/// reads take no [`domain::fileguard::FileGuard`] lease. /// reads take no [`domain::fileguard::FileGuard`] lease.
async fn read_skill( async fn read_skill(
&self, &self,
project: &Project, project: &Project,
name: String, reference: Option<String>,
name: Option<String>,
requester: ConversationParty, requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> { ) -> Result<OrchestratorOutcome, AppError> {
let read_skill = self.read_skill.as_deref().ok_or_else(|| { let read_skill = self.read_skill.as_deref().ok_or_else(|| {
@ -3012,13 +3015,17 @@ impl OrchestratorService {
})?; })?;
let md = read_skill let md = read_skill
.execute(ReadSkillInput { .execute(ReadSkillInput {
reference: reference.clone(),
name: name.clone(), name: name.clone(),
project: project.clone(), project: project.clone(),
requester, requester,
}) })
.await?; .await?;
Ok(OrchestratorOutcome { Ok(OrchestratorOutcome {
detail: format!("read skill {name}"), detail: format!(
"read skill {}",
reference.as_deref().or(name.as_deref()).unwrap_or_default()
),
reply: Some(md.into_string()), reply: Some(md.into_string()),
}) })
} }

View File

@ -3649,6 +3649,7 @@ async fn active_mcp_specs(
} else { } else {
format!("{}/{}", plugin_root.trim_end_matches(['/', '\\']), command) format!("{}/{}", plugin_root.trim_end_matches(['/', '\\']), command)
}; };
let authorized_tools = server.tools.iter().map(|tool| tool.name.clone()).collect();
specs.push(PluginMcpServerSpec { specs.push(PluginMcpServerSpec {
identity: format!( identity: format!(
"plugin:{}:{}", "plugin:{}:{}",
@ -3680,6 +3681,7 @@ async fn active_mcp_specs(
app_data_dir.as_deref(), app_data_dir.as_deref(),
), ),
transport: server.transport, transport: server.transport,
authorized_tools,
}); });
} }
} }

View File

@ -1,6 +1,5 @@
//! Project-scoped plugin activation and per-agent capability assignments. //! Project-scoped plugin activation and per-agent capability assignments.
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use domain::{ use domain::{
@ -144,12 +143,16 @@ impl ProjectPluginResolver {
.await .await
.map_err(package_error)?; .map_err(package_error)?;
let app_data_dir = self.packages.app_data_dir_label(); let app_data_dir = self.packages.app_data_dir_label();
let mut seen = HashSet::new(); let mut spec_indexes = std::collections::HashMap::new();
let mut specs = Vec::new(); let mut specs = Vec::new();
for reference in &assignments.tools { for reference in &assignments.tools {
let key = (reference.plugin_id.clone(), reference.server_id.clone()); let key = (reference.plugin_id.clone(), reference.server_id.clone());
if !seen.insert(key) { if let Some(index) = spec_indexes.get(&key).copied() {
let spec: &mut PluginMcpServerSpec = &mut specs[index];
if !spec.authorized_tools.contains(&reference.tool_name) {
spec.authorized_tools.push(reference.tool_name.clone());
}
continue; continue;
} }
let Some(manifest) = self let Some(manifest) = self
@ -180,6 +183,7 @@ impl ProjectPluginResolver {
} else { } else {
format!("{}/{}", package_root.trim_end_matches(['/', '\\']), command) format!("{}/{}", package_root.trim_end_matches(['/', '\\']), command)
}; };
let index = specs.len();
specs.push(PluginMcpServerSpec { specs.push(PluginMcpServerSpec {
identity: format!( identity: format!(
"plugin:{}:{}", "plugin:{}:{}",
@ -211,7 +215,9 @@ impl ProjectPluginResolver {
app_data_dir.as_deref(), app_data_dir.as_deref(),
), ),
transport: server.transport, transport: server.transport,
authorized_tools: vec![reference.tool_name.clone()],
}); });
spec_indexes.insert(key, index);
} }
Ok(specs) Ok(specs)
} }
@ -896,8 +902,13 @@ mod tests {
server_id: PluginMcpServerId::new("unity-editor").unwrap(), server_id: PluginMcpServerId::new("unity-editor").unwrap(),
tool_name: "unity_detect_project".to_owned(), tool_name: "unity_detect_project".to_owned(),
}; };
let second_tool = PluginToolRef {
plugin_id: plugin_id(),
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
tool_name: "unity_build".to_owned(),
};
let resolver = resolver( let resolver = resolver(
settings(true, vec![tool]), settings(true, vec![tool.clone(), second_tool, tool]),
registry(PluginLifecycleState::Enabled), registry(PluginLifecycleState::Enabled),
manifest(true), manifest(true),
); );
@ -923,6 +934,10 @@ mod tests {
vec![("PLUGIN_ROOT".to_owned(), "/plugins/unity".to_owned())] vec![("PLUGIN_ROOT".to_owned(), "/plugins/unity".to_owned())]
); );
assert_eq!(server.cwd, "/plugins/unity/work"); assert_eq!(server.cwd, "/plugins/unity/work");
assert_eq!(
server.authorized_tools,
vec!["unity_detect_project".to_owned(), "unity_build".to_owned()]
);
} }
#[tokio::test] #[tokio::test]

View File

@ -223,6 +223,9 @@ impl DeleteSkill {
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AgentCapability { pub struct AgentCapability {
/// Stable reference accepted by `idea_skill_read(ref=...)`.
#[serde(default)]
pub reference: String,
/// Capability display name. /// Capability display name.
pub name: String, pub name: String,
/// One-line affordance description. /// One-line affordance description.
@ -234,6 +237,7 @@ pub struct AgentCapability {
impl From<&AssignedSkillSnapshot> for AgentCapability { impl From<&AssignedSkillSnapshot> for AgentCapability {
fn from(skill: &AssignedSkillSnapshot) -> Self { fn from(skill: &AssignedSkillSnapshot) -> Self {
Self { Self {
reference: skill.stable_ref(),
name: skill.name.clone(), name: skill.name.clone(),
description: skill.description.clone(), description: skill.description.clone(),
kind: skill.kind, kind: skill.kind,
@ -415,6 +419,22 @@ impl AssignedSkillResolver {
Ok(assigned) Ok(assigned)
} }
/// Reads the Markdown body for one skill by stable ref, but only inside the supplied
/// assigned-skill snapshot.
pub async fn read_assigned_by_ref(
&self,
snapshot: &OrchestrationCapabilitySnapshot,
root: &ProjectPath,
reference: &str,
) -> Result<MarkdownDoc, AppError> {
let selected = snapshot
.assigned_skills
.iter()
.find(|skill| skill.stable_ref() == reference)
.ok_or_else(|| AppError::NotFound(format!("assigned skill ref `{reference}`")))?;
self.read_assigned_snapshot(selected, root).await
}
/// Reads the Markdown body for one skill by name, but only inside the supplied /// Reads the Markdown body for one skill by name, but only inside the supplied
/// assigned-skill snapshot. /// assigned-skill snapshot.
/// ///
@ -435,27 +455,41 @@ impl AssignedSkillResolver {
let selected = match (matches.next(), matches.next()) { let selected = match (matches.next(), matches.next()) {
(None, _) => return Err(AppError::NotFound(format!("assigned skill `{name}`"))), (None, _) => return Err(AppError::NotFound(format!("assigned skill `{name}`"))),
(Some(skill), None) => skill, (Some(skill), None) => skill,
(Some(_), Some(_)) => { (Some(first), Some(second)) => {
return Err(AppError::Invalid(format!( let mut candidates = vec![first.stable_ref(), second.stable_ref()];
"assigned skill name `{name}` is ambiguous for this agent" candidates.extend(matches.map(AssignedSkillSnapshot::stable_ref));
))) return Err(AppError::AmbiguousSkillName {
name: name.to_owned(),
candidates,
});
} }
}; };
self.read_assigned_snapshot(selected, root).await
}
async fn read_assigned_snapshot(
&self,
selected: &AssignedSkillSnapshot,
root: &ProjectPath,
) -> Result<MarkdownDoc, AppError> {
if let Some(plugin) = &selected.plugin { if let Some(plugin) = &selected.plugin {
let resolver = self let resolver = self.plugin_skills.as_ref().ok_or_else(|| {
.plugin_skills AppError::NotFound(format!("assigned plugin skill `{}`", selected.stable_ref()))
.as_ref() })?;
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
let manifest = resolver let manifest = resolver
.enabled_manifest(root, &plugin.plugin_id) .enabled_manifest(root, &plugin.plugin_id)
.await? .await?
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?; .ok_or_else(|| {
AppError::NotFound(format!("assigned plugin skill `{}`", selected.stable_ref()))
})?;
let contribution = manifest let contribution = manifest
.contributes .contributes
.skills .skills
.iter() .iter()
.find(|s| s.id == plugin.skill_id) .find(|s| s.id == plugin.skill_id)
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?; .ok_or_else(|| {
AppError::NotFound(format!("assigned plugin skill `{}`", selected.stable_ref()))
})?;
return resolver return resolver
.read_skill_body(&plugin.plugin_id, &contribution.path) .read_skill_body(&plugin.plugin_id, &contribution.path)
.await; .await;
@ -471,8 +505,10 @@ impl AssignedSkillResolver {
/// Input for [`ReadSkill::execute`]. /// Input for [`ReadSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadSkillInput { pub struct ReadSkillInput {
/// Skill display name to resolve (case-insensitive). /// Stable assigned-skill reference. Takes precedence over `name`.
pub name: String, pub reference: Option<String>,
/// Legacy skill display name to resolve (case-insensitive).
pub name: Option<String>,
/// Active project. /// Active project.
pub project: Project, pub project: Project,
/// Current requester identity from the orchestration handshake. /// Current requester identity from the orchestration handshake.
@ -532,9 +568,19 @@ impl ReadSkill {
.resolver .resolver
.resolve_for_agent(&agent, &input.project.root) .resolve_for_agent(&agent, &input.project.root)
.await?; .await?;
self.resolver if let Some(reference) = input.reference.as_deref() {
.read_assigned_by_name(&snapshot, &input.project.root, &input.name) self.resolver
.await .read_assigned_by_ref(&snapshot, &input.project.root, reference)
.await
} else if let Some(name) = input.name.as_deref() {
self.resolver
.read_assigned_by_name(&snapshot, &input.project.root, name)
.await
} else {
Err(AppError::Invalid(
"idea_skill_read requires `ref` or `name`".to_owned(),
))
}
} }
} }
@ -929,7 +975,35 @@ mod tests {
let body = uc let body = uc
.execute(ReadSkillInput { .execute(ReadSkillInput {
name: "deploy".to_owned(), reference: None,
name: Some("deploy".to_owned()),
project,
requester: ConversationParty::agent(requester),
})
.await
.unwrap();
assert_eq!(body.as_str(), "PROJECT_BODY");
}
#[tokio::test]
async fn read_skill_returns_assigned_skill_by_stable_ref() {
let store = Arc::new(FakeSkillStore::default());
store.push(skill(1, "deploy", "PROJECT_BODY", SkillScope::Project));
let requester = agent_id(10);
let manifest = manifest_for(
requester,
vec![SkillRef {
skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(1)),
scope: SkillScope::Project,
}],
);
let (uc, project, requester) = read_skill_uc(store, manifest);
let body = uc
.execute(ReadSkillInput {
reference: Some("skill:project:00000000-0000-0000-0000-000000000001".to_owned()),
name: None,
project, project,
requester: ConversationParty::agent(requester), requester: ConversationParty::agent(requester),
}) })
@ -955,7 +1029,8 @@ mod tests {
let body = uc let body = uc
.execute(ReadSkillInput { .execute(ReadSkillInput {
name: "deploy".to_owned(), reference: None,
name: Some("deploy".to_owned()),
project, project,
requester: ConversationParty::agent(requester), requester: ConversationParty::agent(requester),
}) })
@ -975,7 +1050,8 @@ mod tests {
let err = uc let err = uc
.execute(ReadSkillInput { .execute(ReadSkillInput {
name: "deploy".to_owned(), reference: None,
name: Some("deploy".to_owned()),
project, project,
requester: ConversationParty::agent(requester), requester: ConversationParty::agent(requester),
}) })
@ -1001,7 +1077,8 @@ mod tests {
let body = uc let body = uc
.execute(ReadSkillInput { .execute(ReadSkillInput {
name: "deploy".to_owned(), reference: None,
name: Some("deploy".to_owned()),
project, project,
requester: ConversationParty::agent(requester), requester: ConversationParty::agent(requester),
}) })
@ -1019,7 +1096,8 @@ mod tests {
let err = uc let err = uc
.execute(ReadSkillInput { .execute(ReadSkillInput {
name: "ghost".to_owned(), reference: None,
name: Some("ghost".to_owned()),
project, project,
requester: ConversationParty::User, requester: ConversationParty::User,
}) })
@ -1052,12 +1130,69 @@ mod tests {
let err = uc let err = uc
.execute(ReadSkillInput { .execute(ReadSkillInput {
name: "deploy".to_owned(), reference: None,
name: Some("deploy".to_owned()),
project, project,
requester: ConversationParty::agent(requester), requester: ConversationParty::agent(requester),
}) })
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, AppError::Invalid(_)), "got {err:?}"); assert!(
matches!(
err,
AppError::AmbiguousSkillName { ref name, ref candidates }
if name == "deploy"
&& *candidates == vec![
"skill:project:00000000-0000-0000-0000-000000000001".to_owned(),
"skill:global:00000000-0000-0000-0000-000000000002".to_owned(),
]
),
"got {err:?}"
);
}
#[tokio::test]
async fn duplicate_plugin_skill_name_reports_stable_plugin_refs() {
let resolver = AssignedSkillResolver::new(Arc::new(FakeSkillStore::default()));
let snapshot = OrchestrationCapabilitySnapshot::new(vec![
AssignedSkillSnapshot {
skill_id: SkillId::from_uuid(uuid::Uuid::nil()),
scope: SkillScope::Project,
plugin: Some(domain::orchestrator::AssignedPluginSkillSource {
plugin_id: domain::PluginId::new("dev.idea.android").unwrap(),
skill_id: domain::PluginSkillId::new("build-install").unwrap(),
}),
name: "android-build-install".to_owned(),
description: "Android".to_owned(),
kind: SkillKind::Workflow,
assignment_index: 0,
},
AssignedSkillSnapshot {
skill_id: SkillId::from_uuid(uuid::Uuid::nil()),
scope: SkillScope::Project,
plugin: Some(domain::orchestrator::AssignedPluginSkillSource {
plugin_id: domain::PluginId::new("dev.idea.unity").unwrap(),
skill_id: domain::PluginSkillId::new("android-build-install").unwrap(),
}),
name: "android-build-install".to_owned(),
description: "Unity Android".to_owned(),
kind: SkillKind::Workflow,
assignment_index: 1,
},
]);
let error = resolver
.read_assigned_by_name(&snapshot, &project().root, "android-build-install")
.await
.unwrap_err();
assert_eq!(error.code(), "ambiguousSkillName");
assert!(matches!(
error,
AppError::AmbiguousSkillName { ref candidates, .. }
if *candidates == vec![
"plugin:dev.idea.android:build-install".to_owned(),
"plugin:dev.idea.unity:android-build-install".to_owned(),
]
));
} }
} }

View File

@ -2979,6 +2979,8 @@ use domain::{
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AgentCapabilityDto { pub struct AgentCapabilityDto {
/// Stable reference accepted by `idea_skill_read(ref=...)`.
pub reference: String,
/// Capability display name. /// Capability display name.
pub name: String, pub name: String,
/// One-line affordance description. /// One-line affordance description.
@ -2990,6 +2992,7 @@ pub struct AgentCapabilityDto {
impl From<AgentCapability> for AgentCapabilityDto { impl From<AgentCapability> for AgentCapabilityDto {
fn from(value: AgentCapability) -> Self { fn from(value: AgentCapability) -> Self {
Self { Self {
reference: value.reference,
name: value.name, name: value.name,
description: value.description, description: value.description,
kind: value.kind, kind: value.kind,

View File

@ -92,6 +92,25 @@ impl OrchestrationCapabilitySnapshot {
} }
} }
impl AssignedSkillSnapshot {
/// Stable reference accepted by `idea_skill_read(ref=...)`.
#[must_use]
pub fn stable_ref(&self) -> String {
if let Some(plugin) = &self.plugin {
return format!(
"plugin:{}:{}",
plugin.plugin_id.as_str(),
plugin.skill_id.as_str()
);
}
let scope = match self.scope {
SkillScope::Global => "global",
SkillScope::Project => "project",
};
format!("skill:{scope}:{}", self.skill_id)
}
}
/// Errors raised while validating a raw [`OrchestratorRequest`]. /// Errors raised while validating a raw [`OrchestratorRequest`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum OrchestratorError { pub enum OrchestratorError {
@ -164,6 +183,10 @@ pub struct OrchestratorRequest {
/// Target agent display name (required by every v1 action). /// Target agent display name (required by every v1 action).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>, pub name: Option<String>,
/// Stable assigned-skill reference for `skill.read`. Preferred over `name`;
/// kept separate so legacy name-based callers remain compatible.
#[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
pub skill_ref: Option<String>,
/// V2 target agent display name (`agent.run`/`agent.stop`). /// V2 target agent display name (`agent.run`/`agent.stop`).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub target_agent: Option<String>, pub target_agent: Option<String>,
@ -411,13 +434,15 @@ pub enum OrchestratorCommand {
/// The reading party (handshake identity). /// The reading party (handshake identity).
requester: ConversationParty, requester: ConversationParty,
}, },
/// Read a reusable skill's Markdown body **by name** (`idea_skill_read`). /// Read a reusable skill's Markdown body by stable ref, or by legacy name.
/// The application layer authorizes the read against the requester's assigned /// The application layer authorizes the read against the requester's assigned
/// runtime skill snapshot; unassigned skills are not readable. Read-only — no /// runtime skill snapshot; unassigned skills are not readable. Read-only — no
/// [`crate::fileguard::FileGuard`] lease (skills are not mutated through this path). /// [`crate::fileguard::FileGuard`] lease (skills are not mutated through this path).
ReadSkill { ReadSkill {
/// Skill display name to resolve (case-insensitive). /// Stable reference (`plugin:<pluginId>:<skillId>` for plugin skills).
name: String, reference: Option<String>,
/// Legacy display-name lookup (case-insensitive).
name: Option<String>,
/// The party that issued the read (handshake identity). Must be an agent /// The party that issued the read (handshake identity). Must be an agent
/// requester for authorization against assigned skills. /// requester for authorization against assigned skills.
requester: ConversationParty, requester: ConversationParty,
@ -651,10 +676,29 @@ impl OrchestratorRequest {
slug: self.optional_slug(), slug: self.optional_slug(),
requester: self.requester_party(), requester: self.requester_party(),
}), }),
"skill.read" => Ok(OrchestratorCommand::ReadSkill { "skill.read" => {
name: self.require_name(action)?, let reference = self
requester: self.requester_party(), .skill_ref
}), .as_deref()
.map(str::trim)
.filter(|v| !v.is_empty());
let name = self
.name
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty());
if reference.is_none() && name.is_none() {
return Err(OrchestratorError::MissingField {
action: action.to_owned(),
field: "ref or name".to_owned(),
});
}
Ok(OrchestratorCommand::ReadSkill {
reference: reference.map(str::to_owned),
name: name.map(str::to_owned),
requester: self.requester_party(),
})
}
"memory.write" => Ok(OrchestratorCommand::WriteMemory { "memory.write" => Ok(OrchestratorCommand::WriteMemory {
slug: self.require("slug", action, self.slug.as_deref())?, slug: self.require("slug", action, self.slug.as_deref())?,
content: self.require("content", action, self.content.as_deref())?, content: self.require("content", action, self.content.as_deref())?,

View File

@ -924,6 +924,12 @@ pub struct PluginMcpServerSpec {
pub cwd: String, pub cwd: String,
/// Transport. /// Transport.
pub transport: String, pub transport: String,
/// Exact MCP tool names authorized for the target agent.
///
/// This allowlist is carried with the launch spec so provider adapters never
/// have to infer authorization from the server's advertised catalogue.
#[serde(default)]
pub authorized_tools: Vec<String>,
} }
impl PluginMcpServerSpec { impl PluginMcpServerSpec {
@ -1044,6 +1050,7 @@ mod tests {
env: Vec::new(), env: Vec::new(),
cwd: String::new(), cwd: String::new(),
transport: "stdio".to_owned(), transport: "stdio".to_owned(),
authorized_tools: Vec::new(),
} }
} }

View File

@ -759,7 +759,17 @@ impl McpServer {
// (`isError: true`) rather than a protocol error: the agent can read // (`isError: true`) rather than a protocol error: the agent can read
// it and decide, and the connection stays healthy. // it and decide, and the connection stays healthy.
Err(e) => { Err(e) => {
let detail = e.to_string(); let detail = match &e {
application::AppError::AmbiguousSkillName { candidates, .. } => {
serde_json::json!({
"code": e.code(),
"message": e.to_string(),
"candidates": candidates,
})
.to_string()
}
_ => e.to_string(),
};
application::diag!( application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} \ "[mcp] tools_call end tool={name} requester={requester_label} \
target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}", target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}",

View File

@ -521,16 +521,21 @@ pub fn catalogue() -> Vec<ToolDef> {
}, },
ToolDef { ToolDef {
name: "idea_skill_read", name: "idea_skill_read",
description: "Read the full body of an IdeA skill assigned to you, by name — call this \ description: "Read the full body of an IdeA skill assigned to you, preferably by its stable ref. \
Name lookup remains available for compatibility and fails when ambiguous — call this \
to actually execute/follow a skill listed in your « Skills disponibles » \ to actually execute/follow a skill listed in your « Skills disponibles » \
section. Resolves project scope first, then global. Returns the skill's \ section. Returns the skill's \
Markdown inline.", Markdown inline.",
input_schema: json!({ input_schema: json!({
"type": "object", "type": "object",
"properties": { "properties": {
"name": { "type": "string", "description": "Skill name (as listed in your context)." } "ref": { "type": "string", "description": "Stable skill ref (for example plugin:<pluginId>:<skillId>)." },
"name": { "type": "string", "description": "Legacy skill name lookup (must be unambiguous)." }
}, },
"required": ["name"], "anyOf": [
{ "required": ["ref"] },
{ "required": ["name"] }
],
"additionalProperties": false "additionalProperties": false
}), }),
}, },
@ -767,6 +772,7 @@ pub fn map_tool_call(
// The requester (handshake identity) is carried for symmetry with the // The requester (handshake identity) is carried for symmetry with the
// other read tools; `validate` derives the requester party from it. // other read tools; `validate` derives the requester party from it.
requested_by: Some(requester.to_owned()), requested_by: Some(requester.to_owned()),
skill_ref: s("ref"),
name: s("name"), name: s("name"),
..base() ..base()
}, },
@ -809,6 +815,7 @@ fn base() -> OrchestratorRequest {
request_type: None, request_type: None,
requested_by: None, requested_by: None,
name: None, name: None,
skill_ref: None,
target_agent: None, target_agent: None,
profile: None, profile: None,
context: None, context: None,
@ -1450,6 +1457,48 @@ mod tests {
assert!(matches!(err, Err(ToolMapError::Invalid(_)))); assert!(matches!(err, Err(ToolMapError::Invalid(_))));
} }
#[test]
fn skill_read_prefers_stable_ref_and_keeps_name_compatibility() {
let requester = domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap(),
));
assert_eq!(
map_tool_call(
"idea_skill_read",
&json!({ "ref": "plugin:dev.idea.android:build-install" }),
REQ,
)
.unwrap(),
OrchestratorCommand::ReadSkill {
reference: Some("plugin:dev.idea.android:build-install".to_owned()),
name: None,
requester: requester.clone(),
}
);
assert_eq!(
map_tool_call("idea_skill_read", &json!({ "name": "build" }), REQ).unwrap(),
OrchestratorCommand::ReadSkill {
reference: None,
name: Some("build".to_owned()),
requester,
}
);
assert!(matches!(
map_tool_call("idea_skill_read", &json!({}), REQ),
Err(ToolMapError::Invalid(_))
));
let tool = catalogue()
.into_iter()
.find(|tool| tool.name == "idea_skill_read")
.unwrap();
assert!(tool.input_schema["properties"].get("ref").is_some());
assert_eq!(
tool.input_schema["anyOf"],
json!([{ "required": ["ref"] }, { "required": ["name"] }])
);
}
#[test] #[test]
fn ask_agent_is_an_inline_reply_tool_but_reply_is_not_exposed() { fn ask_agent_is_an_inline_reply_tool_but_reply_is_not_exposed() {
assert!(tool_returns_reply("idea_ask_agent")); assert!(tool_returns_reply("idea_ask_agent"));

View File

@ -890,6 +890,7 @@ mod tests {
env: vec![("PLUGIN_ROOT".to_owned(), "/plugin".to_owned())], env: vec![("PLUGIN_ROOT".to_owned(), "/plugin".to_owned())],
cwd: "/plugin".to_owned(), cwd: "/plugin".to_owned(),
transport: "stdio".to_owned(), transport: "stdio".to_owned(),
authorized_tools: Vec::new(),
} }
} }

View File

@ -12,6 +12,7 @@ import {
hasProjectCliInteraction, hasProjectCliInteraction,
} from "@/features/projects/projectCliInteractionActivity"; } from "@/features/projects/projectCliInteractionActivity";
import { publishProjectIconChanged } from "@/features/projects/projectIcons"; import { publishProjectIconChanged } from "@/features/projects/projectIcons";
import { publishProjectThemeChanged } from "@/features/projects/projectTheme";
import { import {
PluginCommandRegistry, PluginCommandRegistry,
PluginLayoutRegistry, PluginLayoutRegistry,
@ -213,9 +214,17 @@ describe("CustomAgentChatView", () => {
expect(watermark.getAttribute("aria-hidden")).toBe("true"); expect(watermark.getAttribute("aria-hidden")).toBe("true");
expect((watermark.firstElementChild as HTMLElement | null)?.style.width).toBe("92%"); expect((watermark.firstElementChild as HTMLElement | null)?.style.width).toBe("92%");
expect((watermark.firstElementChild as HTMLElement | null)?.style.height).toBe("92%"); expect((watermark.firstElementChild as HTMLElement | null)?.style.height).toBe("92%");
expect(watermark.textContent).toBe("🐍"); expect(watermark.textContent).toBe("🐍\uFE0E");
expect(watermark.dataset.colorMode).toBe("monochrome");
publishProjectIconChanged({ projectId: "project-1", icon: { set: "emoji", name: "star" } }); publishProjectIconChanged({ projectId: "project-1", icon: { set: "emoji", name: "star" } });
await waitFor(() => expect(watermark.textContent).toBe("⭐")); await waitFor(() => expect(watermark.textContent).toBe("⭐\uFE0E"));
publishProjectThemeChanged({ projectId: "project-1", themeColor: "#7c3aed" });
await waitFor(() => {
expect(watermark.dataset.colorMode).toBe("project");
expect(watermark.style.color).toBe("rgb(124, 58, 237)");
});
expect(screen.getByTestId("custom-agent-chat-scroll").className).toContain("z-10");
expect(screen.getByTestId("custom-agent-chat-composer").className).toContain("bg-raised/40");
}); });
it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => { it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => {

View File

@ -45,6 +45,7 @@ import {
} from "@/features/projects/projectCliInteractionActivity"; } from "@/features/projects/projectCliInteractionActivity";
import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared"; import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared";
import { onProjectIconChanged, ProjectIcon } from "@/features/projects/projectIcons"; import { onProjectIconChanged, ProjectIcon } from "@/features/projects/projectIcons";
import { onProjectThemeChanged, validThemeColor } from "@/features/projects/projectTheme";
import type { import type {
ChatAttachmentInput, ChatAttachmentInput,
ChatDraftScope, ChatDraftScope,
@ -1012,6 +1013,7 @@ export function CustomAgentChatView({
const [stopTerminalOverride, setStopTerminalOverride] = useState(false); const [stopTerminalOverride, setStopTerminalOverride] = useState(false);
const [error, setError] = useState<ChatErrorState | null>(null); const [error, setError] = useState<ChatErrorState | null>(null);
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null); const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
const [projectThemeColor, setProjectThemeColor] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null); const composerRef = useRef<HTMLTextAreaElement | null>(null);
const chatRootRef = useRef<HTMLDivElement | null>(null); const chatRootRef = useRef<HTMLDivElement | null>(null);
@ -1062,12 +1064,24 @@ export function CustomAgentChatView({
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void project?.readProjectSettings(projectId).then((settings) => { void project?.readProjectSettings(projectId).then((settings) => {
if (!cancelled) setProjectIcon(settings.icon ?? null); if (!cancelled) {
setProjectIcon(settings.icon ?? null);
setProjectThemeColor(validThemeColor(settings.themeColor));
}
}).catch(() => undefined); }).catch(() => undefined);
const unsubscribe = onProjectIconChanged((change) => { const unsubscribeIcon = onProjectIconChanged((change) => {
if (change.projectId === projectId) setProjectIcon(change.icon); if (change.projectId === projectId) setProjectIcon(change.icon);
}); });
return () => { cancelled = true; unsubscribe(); }; const unsubscribeTheme = onProjectThemeChanged((change) => {
if (change.projectId === projectId) {
setProjectThemeColor(validThemeColor(change.themeColor));
}
});
return () => {
cancelled = true;
unsubscribeIcon();
unsubscribeTheme();
};
}, [project, projectId]); }, [project, projectId]);
const clampComposerHeight = useCallback( const clampComposerHeight = useCallback(
@ -2494,9 +2508,14 @@ export function CustomAgentChatView({
> >
<div <div
data-testid="project-icon-watermark" data-testid="project-icon-watermark"
data-color-mode={projectThemeColor ? "project" : "monochrome"}
aria-hidden="true" aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center overflow-hidden text-content opacity-[0.035]" className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center overflow-hidden text-content opacity-[0.055]"
style={{ pointerEvents: "none", containerType: "size" }} style={{
pointerEvents: "none",
containerType: "size",
color: projectThemeColor ?? undefined,
}}
> >
<ProjectIcon <ProjectIcon
icon={projectIcon} icon={projectIcon}
@ -2521,14 +2540,14 @@ export function CustomAgentChatView({
</div> </div>
{!supported && ( {!supported && (
<p role="alert" className="m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning"> <p role="alert" className="relative z-10 m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
{t("custom.unsupported")} {t("custom.unsupported")}
</p> </p>
)} )}
{error && ( {error && (
<div <div
role="alert" role="alert"
className="m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger" className="relative z-10 m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger"
> >
{error.openFailure ? ( {error.openFailure ? (
<div className="flex min-w-0 flex-col gap-2"> <div className="flex min-w-0 flex-col gap-2">
@ -2569,7 +2588,7 @@ export function CustomAgentChatView({
aria-modal="true" aria-modal="true"
aria-labelledby={`profile-command-title-${domId}`} aria-labelledby={`profile-command-title-${domId}`}
className={cn( className={cn(
"border border-border bg-surface p-3 shadow-lg", "relative z-30 border border-border bg-surface p-3 shadow-lg",
mobile mobile
? "fixed inset-x-0 bottom-0 z-40 m-0 rounded-t-md pb-[max(0.75rem,env(safe-area-inset-bottom))]" ? "fixed inset-x-0 bottom-0 z-40 m-0 rounded-t-md pb-[max(0.75rem,env(safe-area-inset-bottom))]"
: "m-3 rounded-md", : "m-3 rounded-md",
@ -2667,7 +2686,7 @@ export function CustomAgentChatView({
<div <div
ref={scrollRef} ref={scrollRef}
data-testid="custom-agent-chat-scroll" data-testid="custom-agent-chat-scroll"
className="flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3" className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
> >
<div className="shrink-0"> <div className="shrink-0">
<AnnouncementsPreview <AnnouncementsPreview
@ -2738,7 +2757,7 @@ export function CustomAgentChatView({
<div <div
data-testid="custom-agent-chat-composer" data-testid="custom-agent-chat-composer"
className={cn( className={cn(
"flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2", "relative z-10 flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2",
mobile && "pb-[max(0.5rem,env(safe-area-inset-bottom))]", mobile && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
)} )}
> >
@ -2947,7 +2966,7 @@ function ChatBubble({
if (turn.role === "tool") { if (turn.role === "tool") {
return ( return (
<div className="flex min-w-0 justify-center"> <div className="flex min-w-0 justify-center">
<span className="flex max-w-[80%] items-baseline gap-2 truncate rounded-md border border-border bg-raised px-2 py-1 text-xs text-muted"> <span className="flex max-w-[80%] items-baseline gap-2 truncate rounded-md border border-border bg-raised/80 px-2 py-1 text-xs text-muted">
<span className="truncate">{turn.label === "Activité" ? tr("Activité", "Activity") : turn.label}</span> <span className="truncate">{turn.label === "Activité" ? tr("Activité", "Activity") : turn.label}</span>
<EntryTimestamp atMs={turn.receivedAtMs} /> <EntryTimestamp atMs={turn.receivedAtMs} />
</span> </span>
@ -3007,7 +3026,7 @@ function ChatBubble({
<div <div
className={cn( className={cn(
"min-w-0 max-w-[82%] overflow-hidden rounded-lg border px-3 py-2 text-sm text-content", "min-w-0 max-w-[82%] overflow-hidden rounded-lg border px-3 py-2 text-sm text-content",
user ? "border-border bg-raised" : "border-primary/25 bg-primary/10", user ? "border-border bg-raised/85" : "border-primary/25 bg-primary/10",
)} )}
> >
<ChatMessageMeta <ChatMessageMeta

View File

@ -171,6 +171,51 @@ describe("project plugin settings #282", () => {
expect(assigned?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" }); expect(assigned?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" });
}); });
it("shows stable refs without blocking assignment when skill names are ambiguous", async () => {
const gateways = gatewaysWithPlugin();
const duplicatedContribution = manifest();
duplicatedContribution.contributes.skills = [
duplicatedContribution.contributes.skills![0],
duplicatedContribution.contributes.skills![0],
];
gateways.plugin._seedProjectPluginManifest(duplicatedContribution);
const secondPlugin = manifest();
secondPlugin.id = "dev.acme.android";
secondPlugin.displayName = "Android Plugin";
secondPlugin.contributes.skills = [{
...secondPlugin.contributes.skills![0],
id: "android.debug",
}];
gateways.plugin._seedProjectPluginManifest(secondPlugin);
await gateways.agent.createAgent("project-1", {
name: "Dev",
profileId: "profile-1",
});
gateways.plugin._seedProjectPluginSettings("project-1", {
version: 1,
plugins: [
{ pluginId: "dev.acme.unity", enabled: true },
{ pluginId: "dev.acme.android", enabled: true },
],
agents: [],
});
render(
<DIProvider gateways={gateways}>
<SkillsPanel projectId="project-1" />
</DIProvider>,
);
expect((await screen.findByRole("status")).textContent).toContain(
"Plusieurs skills portent le même nom",
);
expect(screen.getByText("plugin:dev.acme.unity:unity.debug")).toBeTruthy();
expect(screen.getByText("plugin:dev.acme.android:android.debug")).toBeTruthy();
// A malformed manifest repeating the exact same stable ref never creates a
// second assignment control; only genuinely distinct refs stay visible.
expect(screen.getAllByRole("checkbox", { name: /unity-debug/ })).toHaveLength(2);
});
it("lists catalogued plugin tools in MCP and removes free-form assignment", async () => { it("lists catalogued plugin tools in MCP and removes free-form assignment", async () => {
const gateways = gatewaysWithPlugin(); const gateways = gatewaysWithPlugin();
const agent = await gateways.agent.createAgent("project-1", { const agent = await gateways.agent.createAgent("project-1", {

View File

@ -7,7 +7,7 @@
* read as faint text rather than a control. * read as faint text rather than a control.
*/ */
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react"; import { createEvent, render, screen, fireEvent, within } from "@testing-library/react";
import type { TabItem } from "@/shared"; import type { TabItem } from "@/shared";
import { ProjectTabs } from "./ProjectTabs"; import { ProjectTabs } from "./ProjectTabs";
@ -123,6 +123,60 @@ describe("ProjectTabs reordering (#347)", () => {
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
}); });
it("opens a stable animated gap on the side selected by the pointer midpoint", () => {
renderTabs({ onReorder: vi.fn() });
const alphaContainer = screen.getByRole("tab", { name: /alpha/ }).parentElement!;
const betaContainer = screen.getByRole("tab", { name: /beta/ }).parentElement!;
const rectSpy = vi.spyOn(betaContainer, "getBoundingClientRect").mockReturnValue({
left: 100,
width: 120,
right: 220,
top: 0,
bottom: 32,
height: 32,
x: 100,
y: 0,
toJSON: () => ({}),
});
const dataTransfer = {
effectAllowed: "none",
dropEffect: "none",
setData: vi.fn(),
getData: vi.fn(() => "a"),
};
fireEvent.dragStart(alphaContainer, { dataTransfer });
expect(alphaContainer.dataset.dragged).toBe("true");
expect(alphaContainer.className).toContain("opacity-90");
const beforeEvent = createEvent.dragOver(betaContainer, { dataTransfer });
Object.defineProperty(beforeEvent, "clientX", { value: 130 });
fireEvent(betaContainer, beforeEvent);
expect(betaContainer.dataset.dropPosition).toBe("before");
expect(betaContainer.style.marginInlineStart).toBe("0.75rem");
expect(betaContainer.className).toContain("duration-150");
expect(betaContainer.className).toContain("ease-out");
expect(betaContainer.className).toContain("motion-reduce:transition-none");
rectSpy.mockReturnValue({
left: -200,
width: 120,
right: -80,
top: 0,
bottom: 32,
height: 32,
x: -200,
y: 0,
toJSON: () => ({}),
});
const afterEvent = createEvent.dragOver(betaContainer, { dataTransfer });
Object.defineProperty(afterEvent, "clientX", { value: 190 });
fireEvent(betaContainer, afterEvent);
expect(betaContainer.dataset.dropPosition).toBe("after");
expect(betaContainer.style.marginInlineStart).toBe("");
expect(betaContainer.style.marginInlineEnd).toBe("0.75rem");
});
it("moves the focused tab with Alt+Arrow and announces the change", () => { it("moves the focused tab with Alt+Arrow and announces the change", () => {
const onReorder = vi.fn(); const onReorder = vi.fn();
renderTabs({ onReorder }); renderTabs({ onReorder });

View File

@ -42,6 +42,24 @@ describe("project emoji catalogue", () => {
expect(getByRole("img", { name: "A" }).textContent).toBe("A"); expect(getByRole("img", { name: "A" }).textContent).toBe("A");
expect(getByRole("img").dataset.projectIcon).toBe("none"); expect(getByRole("img").dataset.projectIcon).toBe("none");
}); });
it("requests a monochrome text presentation for watermark emoji only", () => {
const { getByTestId } = render(
<>
<ProjectIcon icon={{ set: "emoji", name: "star" }} data-testid="product-icon" />
<ProjectIcon
icon={{ set: "emoji", name: "star" }}
surface="watermark"
decorative
data-testid="watermark-icon"
/>
</>,
);
expect(getByTestId("product-icon").textContent).toBe("⭐");
expect(getByTestId("watermark-icon").textContent).toBe("⭐\uFE0E");
expect((getByTestId("watermark-icon") as HTMLElement).style.fontFamily).toContain("system-ui");
});
}); });
describe("ProjectIconPicker", () => { describe("ProjectIconPicker", () => {

View File

@ -1,4 +1,4 @@
import type { CSSProperties } from "react"; import type { CSSProperties, HTMLAttributes } from "react";
import type { ProjectIconRef } from "@/ports"; import type { ProjectIconRef } from "@/ports";
export type ProjectIconGroup = export type ProjectIconGroup =
@ -197,7 +197,7 @@ function fallbackInitial(ref: ProjectIconRef | null, fallbackLabel?: string): st
return first.toUpperCase(); return first.toUpperCase();
} }
export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, style, decorative = false, surface = "product-ui" }: { export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, style, decorative = false, surface = "product-ui", ...htmlProps }: {
icon: ProjectIconRef | null; icon: ProjectIconRef | null;
fallbackLabel?: string; fallbackLabel?: string;
size?: number | string; size?: number | string;
@ -205,12 +205,17 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
style?: CSSProperties; style?: CSSProperties;
decorative?: boolean; decorative?: boolean;
surface?: ProjectIconSurface; surface?: ProjectIconSurface;
}) { } & Omit<HTMLAttributes<HTMLSpanElement>, "children" | "style" | "className">) {
const entry = resolveProjectIcon(ref, surface); const entry = resolveProjectIcon(ref, surface);
const label = entry?.label ?? fallbackInitial(ref, fallbackLabel); const label = entry?.label ?? fallbackInitial(ref, fallbackLabel);
const numericSize = typeof size === "number" ? size : null; const numericSize = typeof size === "number" ? size : null;
const glyph = entry?.glyph ?? fallbackInitial(ref, fallbackLabel);
const renderedGlyph = surface === "watermark" && entry
? `${glyph.replace(/\uFE0F/g, "\uFE0E")}${glyph.includes("\uFE0F") ? "" : "\uFE0E"}`
: glyph;
return ( return (
<span <span
{...htmlProps}
className={className} className={className}
role={decorative ? undefined : "img"} role={decorative ? undefined : "img"}
aria-hidden={decorative || undefined} aria-hidden={decorative || undefined}
@ -221,11 +226,13 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
style={{ style={{
display: "inline-flex", alignItems: "center", justifyContent: "center", display: "inline-flex", alignItems: "center", justifyContent: "center",
width: size, height: size, fontSize: numericSize ? Math.max(16, numericSize * 0.72) : "62%", width: size, height: size, fontSize: numericSize ? Math.max(16, numericSize * 0.72) : "62%",
fontFamily: '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif', fontFamily: surface === "watermark"
? "ui-sans-serif, system-ui, sans-serif"
: '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif',
fontWeight: entry ? 400 : 700, lineHeight: 1, flexShrink: 0, ...style, fontWeight: entry ? 400 : 700, lineHeight: 1, flexShrink: 0, ...style,
}} }}
> >
{entry?.glyph ?? fallbackInitial(ref, fallbackLabel)} {renderedGlyph}
</span> </span>
); );
} }

View File

@ -33,6 +33,9 @@ type EditorState = { mode: "create" } | { mode: "edit"; skill: Skill };
const nativeSkillKey = (ref: SkillRef) => `${ref.scope}:${ref.skillId}`; const nativeSkillKey = (ref: SkillRef) => `${ref.scope}:${ref.skillId}`;
const pluginSkillKey = (ref: PluginSkillRef) => `${ref.pluginId}:${ref.skillId}`; const pluginSkillKey = (ref: PluginSkillRef) => `${ref.pluginId}:${ref.skillId}`;
const nativeSkillStableRef = (skill: Skill) => `skill:${skill.scope}:${skill.id}`;
const pluginSkillStableRef = (pluginId: string, skillId: string) =>
`plugin:${pluginId}:${skillId}`;
export function SkillsPanel({ projectId }: SkillsPanelProps) { export function SkillsPanel({ projectId }: SkillsPanelProps) {
const { tr } = useI18n(); const { tr } = useI18n();
@ -71,6 +74,32 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
() => [...vm.projectSkills, ...vm.globalSkills], () => [...vm.projectSkills, ...vm.globalSkills],
[vm.globalSkills, vm.projectSkills], [vm.globalSkills, vm.projectSkills],
); );
const pluginSkillRows = useMemo(() => {
const seen = new Set<string>();
return plugins.installed.flatMap((plugin) =>
(plugin.contributes.skills ?? []).flatMap((skill) => {
const reference = pluginSkillStableRef(plugin.id, skill.id);
if (seen.has(reference)) return [];
seen.add(reference);
return [{ plugin, skill, reference }];
}),
);
}, [plugins.installed]);
const ambiguousSkillNames = useMemo(() => {
const counts = new Map<string, number>();
for (const name of [
...nativeSkills.map((skill) => skill.name),
...pluginSkillRows.map(({ skill }) => skill.name),
]) {
const normalized = name.trim().toLocaleLowerCase();
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
}
return new Set(
[...counts.entries()].filter(([, count]) => count > 1).map(([name]) => name),
);
}, [nativeSkills, pluginSkillRows]);
const isAmbiguousName = (name: string) =>
ambiguousSkillNames.has(name.trim().toLocaleLowerCase());
const normalizedQuery = query.trim().toLocaleLowerCase(); const normalizedQuery = query.trim().toLocaleLowerCase();
const matches = (value: string) => const matches = (value: string) =>
!normalizedQuery || value.toLocaleLowerCase().includes(normalizedQuery); !normalizedQuery || value.toLocaleLowerCase().includes(normalizedQuery);
@ -84,9 +113,7 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
)?.skills ?? []; )?.skills ?? [];
const assignedPluginKeys = new Set(pluginAssignments.map(pluginSkillKey)); const assignedPluginKeys = new Set(pluginAssignments.map(pluginSkillKey));
const knownPluginKeys = new Set( const knownPluginKeys = new Set(
plugins.installed.flatMap((plugin) => pluginSkillRows.map(({ plugin, skill }) => `${plugin.id}:${skill.id}`),
(plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`),
),
); );
const stalePluginSkills = pluginAssignments.filter( const stalePluginSkills = pluginAssignments.filter(
(ref) => !knownPluginKeys.has(pluginSkillKey(ref)), (ref) => !knownPluginKeys.has(pluginSkillKey(ref)),
@ -249,13 +276,31 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
/> />
{selectedAgent && ( {selectedAgent && (
<div className="mt-3 flex flex-col gap-2"> <div className="mt-3 flex flex-col gap-2">
{ambiguousSkillNames.size > 0 && (
<p
role="status"
className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"
>
{tr(
"Plusieurs skills portent le même nom. Leur référence stable permet de les distinguer.",
"Several skills share the same name. Their stable reference distinguishes them.",
)}
</p>
)}
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">IdeA</h5> <h5 className="text-xs font-semibold uppercase tracking-wide text-faint">IdeA</h5>
{nativeSkills.filter((skill) => matches(`${skill.name} ${skill.description ?? ""}`)).map((skill) => { {nativeSkills.filter((skill) => matches(`${skill.name} ${skill.description ?? ""}`)).map((skill) => {
const assigned = assignedNativeKeys.has(`${skill.scope}:${skill.id}`); const assigned = assignedNativeKeys.has(`${skill.scope}:${skill.id}`);
return ( return (
<label key={`${skill.scope}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content"> <label key={`${skill.scope}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={assigned} disabled={assignmentBusy || vm.busy} onChange={(event) => void setNativeSkill(skill, event.target.checked)} /> <input type="checkbox" checked={assigned} disabled={assignmentBusy || vm.busy} onChange={(event) => void setNativeSkill(skill, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span> <span className="min-w-0 flex-1">
<span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}
{isAmbiguousName(skill.name) && (
<span className="mt-0.5 block break-all font-mono text-[10px] text-faint">
{nativeSkillStableRef(skill)}
</span>
)}
</span>
<span className="text-faint">{skill.scope === "project" ? tr("Projet", "Project") : tr("Global", "Global")}</span> <span className="text-faint">{skill.scope === "project" ? tr("Projet", "Project") : tr("Global", "Global")}</span>
</label> </label>
); );
@ -270,28 +315,33 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
<h5 className="mt-2 text-xs font-semibold uppercase tracking-wide text-faint"> <h5 className="mt-2 text-xs font-semibold uppercase tracking-wide text-faint">
{tr("Plugins", "Plugins")} {tr("Plugins", "Plugins")}
</h5> </h5>
{plugins.installed.flatMap((plugin) => {pluginSkillRows
(plugin.contributes.skills ?? []) .filter(({ plugin, skill, reference }) => matches(`${skill.name} ${skill.description} ${plugin.displayName} ${reference}`))
.filter((skill) => matches(`${skill.name} ${skill.description} ${plugin.displayName}`)) .map(({ plugin, skill, reference }) => {
.map((skill) => {
const assigned = assignedPluginKeys.has(`${plugin.id}:${skill.id}`); const assigned = assignedPluginKeys.has(`${plugin.id}:${skill.id}`);
const enabled = enabledPluginIds.has(plugin.id); const enabled = enabledPluginIds.has(plugin.id);
return ( return (
<label key={`${plugin.id}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content"> <label key={`${plugin.id}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={assigned} disabled={plugins.busy || (!enabled && !assigned)} onChange={(event) => void plugins.setSkillAssigned(selectedAgent.id, plugin.id, skill.id, event.target.checked)} /> <input type="checkbox" checked={assigned} disabled={plugins.busy || (!enabled && !assigned)} onChange={(event) => void plugins.setSkillAssigned(selectedAgent.id, plugin.id, skill.id, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}{!enabled && <span className="text-warning"> — {tr("Plugin désactivé", "Plugin disabled")}</span>}</span> <span className="min-w-0 flex-1">
<span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}{!enabled && <span className="text-warning"> — {tr("Plugin désactivé", "Plugin disabled")}</span>}
{isAmbiguousName(skill.name) && (
<span className="mt-0.5 block break-all font-mono text-[10px] text-faint">
{reference}
</span>
)}
</span>
<span className="text-faint">{plugin.displayName}</span> <span className="text-faint">{plugin.displayName}</span>
</label> </label>
); );
}), })}
)}
{stalePluginSkills.filter((ref) => matches(`${ref.pluginId} ${ref.skillId}`)).map((ref) => ( {stalePluginSkills.filter((ref) => matches(`${ref.pluginId} ${ref.skillId}`)).map((ref) => (
<div key={`missing-plugin:${pluginSkillKey(ref)}`} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs"> <div key={`missing-plugin:${pluginSkillKey(ref)}`} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
<span><span className="font-medium">{ref.skillId}</span> <span className="text-warning">— {tr("Indisponible", "Unavailable")}</span> <span className="text-faint">{tr("Plugin", "Plugin")}: {ref.pluginId}</span></span> <span><span className="font-medium">{ref.skillId}</span> <span className="text-warning">— {tr("Indisponible", "Unavailable")}</span> <span className="text-faint">{tr("Plugin", "Plugin")}: {ref.pluginId}</span></span>
<Button size="sm" variant="ghost" disabled={plugins.busy} onClick={() => void plugins.setSkillAssigned(selectedAgent.id, ref.pluginId, ref.skillId, false)}>{tr("Retirer", "Remove")}</Button> <Button size="sm" variant="ghost" disabled={plugins.busy} onClick={() => void plugins.setSkillAssigned(selectedAgent.id, ref.pluginId, ref.skillId, false)}>{tr("Retirer", "Remove")}</Button>
</div> </div>
))} ))}
{nativeSkills.length === 0 && plugins.installed.every((plugin) => (plugin.contributes.skills ?? []).length === 0) && staleNativeSkills.length === 0 && stalePluginSkills.length === 0 && ( {nativeSkills.length === 0 && pluginSkillRows.length === 0 && staleNativeSkills.length === 0 && stalePluginSkills.length === 0 && (
<p className="text-sm text-muted">{tr("Aucun skill disponible.", "No skills available.")}</p> <p className="text-sm text-muted">{tr("Aucun skill disponible.", "No skills available.")}</p>
)} )}
</div> </div>

View File

@ -7,7 +7,7 @@
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
import { IconButton } from "./IconButton"; import { IconButton } from "./IconButton";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { CSSProperties, DragEvent, KeyboardEvent, ReactNode } from "react"; import type { CSSProperties, DragEvent, KeyboardEvent, ReactNode } from "react";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
@ -47,7 +47,19 @@ export interface TabsProps {
export function Tabs({ items, value, onSelect, onClose, onReorder, className }: TabsProps) { export function Tabs({ items, value, onSelect, onClose, onReorder, className }: TabsProps) {
const { tr } = useI18n(); const { tr } = useI18n();
const [draggedId, setDraggedId] = useState<string | null>(null); const [draggedId, setDraggedId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<{
id: string;
position: "before" | "after";
} | null>(null);
const [announcement, setAnnouncement] = useState(""); const [announcement, setAnnouncement] = useState("");
const dropCommittedRef = useRef(false);
const dropResetFrameRef = useRef<number | null>(null);
useEffect(() => () => {
if (dropResetFrameRef.current !== null) {
cancelAnimationFrame(dropResetFrameRef.current);
}
}, []);
const reorder = (id: string, targetId: string, after: boolean) => { const reorder = (id: string, targetId: string, after: boolean) => {
if (!onReorder || id === targetId) return; if (!onReorder || id === targetId) return;
@ -65,8 +77,20 @@ export function Tabs({ items, value, onSelect, onClose, onReorder, className }:
event.preventDefault(); event.preventDefault();
const id = draggedId ?? event.dataTransfer.getData("text/plain"); const id = draggedId ?? event.dataTransfer.getData("text/plain");
const rect = event.currentTarget.getBoundingClientRect(); const rect = event.currentTarget.getBoundingClientRect();
reorder(id, targetId, rect.width > 0 && event.clientX >= rect.left + rect.width / 2); const after = dropTarget?.id === targetId
setDraggedId(null); ? dropTarget.position === "after"
: rect.width > 0 && event.clientX >= rect.left + rect.width / 2;
dropCommittedRef.current = true;
reorder(id, targetId, after);
// Keep the visual state until the controlled parent has rendered the new
// order. Clearing it synchronously briefly paints the old order at rest.
dropResetFrameRef.current = requestAnimationFrame(() => {
dropCommittedRef.current = false;
dropResetFrameRef.current = null;
setDraggedId(null);
setDropTarget(null);
});
}; };
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, id: string) => { const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {
@ -96,13 +120,23 @@ export function Tabs({ items, value, onSelect, onClose, onReorder, className }:
...(active ? { borderColor: tab.themeAccent ?? tab.themeColor } : {}), ...(active ? { borderColor: tab.themeAccent ?? tab.themeColor } : {}),
} }
: undefined; : undefined;
const targetPosition = dropTarget?.id === tab.id ? dropTarget.position : null;
const tabStyle: CSSProperties | undefined = themedStyle || targetPosition
? {
...themedStyle,
...(targetPosition === "before" ? { marginInlineStart: "0.75rem" } : {}),
...(targetPosition === "after" ? { marginInlineEnd: "0.75rem" } : {}),
}
: undefined;
return ( return (
<div <div
key={tab.id} key={tab.id}
draggable={Boolean(onReorder)} draggable={Boolean(onReorder)}
onDragStart={(event) => { onDragStart={(event) => {
if (!onReorder) return; if (!onReorder) return;
dropCommittedRef.current = false;
setDraggedId(tab.id); setDraggedId(tab.id);
setDropTarget(null);
event.dataTransfer.effectAllowed = "move"; event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", tab.id); event.dataTransfer.setData("text/plain", tab.id);
}} }}
@ -110,14 +144,33 @@ export function Tabs({ items, value, onSelect, onClose, onReorder, className }:
if (!onReorder) return; if (!onReorder) return;
event.preventDefault(); event.preventDefault();
event.dataTransfer.dropEffect = "move"; event.dataTransfer.dropEffect = "move";
if (!draggedId || draggedId === tab.id) {
setDropTarget(null);
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const position = rect.width > 0 && event.clientX >= rect.left + rect.width / 2
? "after"
: "before";
setDropTarget((current) =>
current?.id === tab.id && current.position === position
? current
: { id: tab.id, position },
);
}} }}
onDrop={(event) => handleDrop(event, tab.id)} onDrop={(event) => handleDrop(event, tab.id)}
onDragEnd={() => setDraggedId(null)} onDragEnd={() => {
style={themedStyle} if (dropCommittedRef.current) return;
setDraggedId(null);
setDropTarget(null);
}}
data-dragged={draggedId === tab.id || undefined}
data-drop-position={targetPosition ?? undefined}
style={tabStyle}
className={cn( className={cn(
"group flex items-center gap-1 rounded-md border px-1 transition-colors", "group flex items-center gap-1 rounded-md border px-1 transition-[margin,transform,opacity,box-shadow,background-color,border-color] duration-150 ease-out motion-reduce:transition-none",
onReorder && "cursor-grab active:cursor-grabbing", onReorder && "cursor-grab active:cursor-grabbing",
draggedId === tab.id && "opacity-60", draggedId === tab.id && "-translate-y-0.5 opacity-90 shadow-md",
active active
? cn( ? cn(
"border-border-strong", "border-border-strong",