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:
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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()],
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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?;
|
||||||
|
if let Some(reference) = input.reference.as_deref() {
|
||||||
self.resolver
|
self.resolver
|
||||||
.read_assigned_by_name(&snapshot, &input.project.root, &input.name)
|
.read_assigned_by_ref(&snapshot, &input.project.root, reference)
|
||||||
.await
|
.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(),
|
||||||
|
]
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
.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(),
|
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())?,
|
||||||
|
|||||||
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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={}",
|
||||||
|
|||||||
@ -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"));
|
||||||
|
|||||||
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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", {
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
? 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);
|
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",
|
||||||
|
|||||||
Reference in New Issue
Block a user