merge(domain,application,backend,app-tauri,frontend): lot utilisateur #328 #329 #330 #331 — section plugins projet, erreurs menus plugin visibles, refresh auto des pages, droplist agents (QA verte)
This commit is contained in:
@ -206,6 +206,21 @@ fn domain_event_dto_is_tagged_and_camel_case() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_plugin_settings_changed_event_is_tagged_and_camel_case() {
|
||||||
|
let pid = ProjectId::from_uuid(Uuid::from_u128(328));
|
||||||
|
let dto = DomainEventDto::from(&DomainEvent::ProjectPluginSettingsChanged { project_id: pid });
|
||||||
|
let value = serde_json::to_value(dto).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
value,
|
||||||
|
json!({
|
||||||
|
"type": "projectPluginSettingsChanged",
|
||||||
|
"projectId": pid.to_string(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn domain_event_dto_maps_agent_launched() {
|
fn domain_event_dto_maps_agent_launched() {
|
||||||
let aid = AgentId::from_uuid(Uuid::nil());
|
let aid = AgentId::from_uuid(Uuid::nil());
|
||||||
|
|||||||
@ -4,10 +4,11 @@ use std::collections::HashSet;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentContextStore, AgentId, AgentPluginAssignments, MarkdownDoc, PluginId, PluginManifest,
|
AgentContextStore, AgentId, AgentPluginAssignments, DomainEvent, EventBus, MarkdownDoc,
|
||||||
PluginManifestValidator, PluginMcpServerId, PluginMcpServerSpec, PluginPackageStore,
|
PluginId, PluginManifest, PluginManifestValidator, PluginMcpServerId, PluginMcpServerSpec,
|
||||||
PluginRegistryStore, PluginSkillId, PluginSkillRef, PluginStoreError, PluginToolRef, Project,
|
PluginPackageStore, PluginRegistryStore, PluginSkillId, PluginSkillRef, PluginStoreError,
|
||||||
ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore,
|
PluginToolRef, Project, ProjectPluginActivation, ProjectPluginSettings,
|
||||||
|
ProjectPluginSettingsStore,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@ -292,6 +293,7 @@ impl ListProjectPluginSettings {
|
|||||||
pub struct SetProjectPluginEnabled {
|
pub struct SetProjectPluginEnabled {
|
||||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||||
packages: Arc<dyn PluginPackageStore>,
|
packages: Arc<dyn PluginPackageStore>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SetProjectPluginEnabled {
|
impl SetProjectPluginEnabled {
|
||||||
@ -300,8 +302,13 @@ impl SetProjectPluginEnabled {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||||
packages: Arc<dyn PluginPackageStore>,
|
packages: Arc<dyn PluginPackageStore>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self { store, packages }
|
Self {
|
||||||
|
store,
|
||||||
|
packages,
|
||||||
|
events,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists activation. Enabling requires a currently installed plugin;
|
/// Persists activation. Enabling requires a currently installed plugin;
|
||||||
@ -336,6 +343,10 @@ impl SetProjectPluginEnabled {
|
|||||||
.push(ProjectPluginActivation { plugin_id, enabled }),
|
.push(ProjectPluginActivation { plugin_id, enabled }),
|
||||||
}
|
}
|
||||||
self.store.save(&project.root, &settings).await?;
|
self.store.save(&project.root, &settings).await?;
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::ProjectPluginSettingsChanged {
|
||||||
|
project_id: project.id,
|
||||||
|
});
|
||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -354,6 +365,7 @@ pub struct MutateAgentPluginSkill {
|
|||||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||||
contexts: Arc<dyn AgentContextStore>,
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
resolver: Arc<ProjectPluginResolver>,
|
resolver: Arc<ProjectPluginResolver>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MutateAgentPluginSkill {
|
impl MutateAgentPluginSkill {
|
||||||
@ -363,11 +375,13 @@ impl MutateAgentPluginSkill {
|
|||||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||||
contexts: Arc<dyn AgentContextStore>,
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
resolver: Arc<ProjectPluginResolver>,
|
resolver: Arc<ProjectPluginResolver>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
store,
|
store,
|
||||||
contexts,
|
contexts,
|
||||||
resolver,
|
resolver,
|
||||||
|
events,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -412,6 +426,10 @@ impl MutateAgentPluginSkill {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
self.store.save(&project.root, &settings).await?;
|
self.store.save(&project.root, &settings).await?;
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::ProjectPluginSettingsChanged {
|
||||||
|
project_id: project.id,
|
||||||
|
});
|
||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -421,6 +439,7 @@ pub struct MutateAgentPluginTool {
|
|||||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||||
contexts: Arc<dyn AgentContextStore>,
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
resolver: Arc<ProjectPluginResolver>,
|
resolver: Arc<ProjectPluginResolver>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MutateAgentPluginTool {
|
impl MutateAgentPluginTool {
|
||||||
@ -430,11 +449,13 @@ impl MutateAgentPluginTool {
|
|||||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||||
contexts: Arc<dyn AgentContextStore>,
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
resolver: Arc<ProjectPluginResolver>,
|
resolver: Arc<ProjectPluginResolver>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
store,
|
store,
|
||||||
contexts,
|
contexts,
|
||||||
resolver,
|
resolver,
|
||||||
|
events,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -483,6 +504,10 @@ impl MutateAgentPluginTool {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
self.store.save(&project.root, &settings).await?;
|
self.store.save(&project.root, &settings).await?;
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::ProjectPluginSettingsChanged {
|
||||||
|
project_id: project.id,
|
||||||
|
});
|
||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -550,16 +575,82 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
ContentHash, LocalPath, PluginActivationScope, PluginCapability, PluginContributionSet,
|
Agent, AgentManifest, AgentOrigin, ContentHash, LocalPath, PluginActivationScope,
|
||||||
PluginInstallSource, PluginLifecycleState, PluginManifestBytes, PluginManifestError,
|
PluginCapability, PluginContributionSet, PluginInstallSource, PluginLifecycleState,
|
||||||
PluginMcpServerContribution, PluginPackageRef, PluginRegistry, PluginRegistryEntry,
|
PluginManifestBytes, PluginManifestError, PluginMcpServerContribution, PluginPackageRef,
|
||||||
PluginTrustLevel, PluginVersion, ProjectPath, RelativePath, RemovalOutcome,
|
PluginRegistry, PluginRegistryEntry, PluginTrustLevel, PluginVersion, ProfileId,
|
||||||
StagedPluginPackage,
|
ProjectPath, RelativePath, RemoteRef, RemovalOutcome, StagedPluginPackage,
|
||||||
};
|
};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
struct MemorySettings {
|
struct MemorySettings {
|
||||||
settings: Mutex<ProjectPluginSettings>,
|
settings: Mutex<ProjectPluginSettings>,
|
||||||
|
fail_save: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct SpyEvents(Arc<Mutex<Vec<DomainEvent>>>);
|
||||||
|
|
||||||
|
impl SpyEvents {
|
||||||
|
fn events(&self) -> Vec<DomainEvent> {
|
||||||
|
self.0.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventBus for SpyEvents {
|
||||||
|
fn publish(&self, event: DomainEvent) {
|
||||||
|
self.0.lock().unwrap().push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe(&self) -> domain::EventStream {
|
||||||
|
Box::new(std::iter::empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeContexts;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentContextStore for FakeContexts {
|
||||||
|
async fn read_context(
|
||||||
|
&self,
|
||||||
|
_project: &Project,
|
||||||
|
_agent: &AgentId,
|
||||||
|
) -> Result<MarkdownDoc, domain::StoreError> {
|
||||||
|
Ok(MarkdownDoc::new("# Agent"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_context(
|
||||||
|
&self,
|
||||||
|
_project: &Project,
|
||||||
|
_agent: &AgentId,
|
||||||
|
_md: &MarkdownDoc,
|
||||||
|
) -> Result<(), domain::StoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_manifest(
|
||||||
|
&self,
|
||||||
|
_project: &Project,
|
||||||
|
) -> Result<AgentManifest, domain::StoreError> {
|
||||||
|
let agent = Agent::new(
|
||||||
|
agent_id(),
|
||||||
|
"Dev",
|
||||||
|
"agents/dev.md",
|
||||||
|
ProfileId::from_uuid(uuid::Uuid::from_u128(2)),
|
||||||
|
AgentOrigin::Scratch,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Ok(AgentManifest::new(1, vec![domain::ManifestEntry::from_agent(&agent)]).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_manifest(
|
||||||
|
&self,
|
||||||
|
_project: &Project,
|
||||||
|
_manifest: &AgentManifest,
|
||||||
|
) -> Result<(), domain::StoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@ -576,6 +667,9 @@ mod tests {
|
|||||||
_project_root: &ProjectPath,
|
_project_root: &ProjectPath,
|
||||||
settings: &ProjectPluginSettings,
|
settings: &ProjectPluginSettings,
|
||||||
) -> Result<(), domain::StoreError> {
|
) -> Result<(), domain::StoreError> {
|
||||||
|
if self.fail_save {
|
||||||
|
return Err(domain::StoreError::Io("save failed".to_owned()));
|
||||||
|
}
|
||||||
*self.settings.lock().unwrap() = settings.clone();
|
*self.settings.lock().unwrap() = settings.clone();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -684,6 +778,17 @@ mod tests {
|
|||||||
ProjectPath::new("/project").unwrap()
|
ProjectPath::new("/project").unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn project() -> Project {
|
||||||
|
Project::new(
|
||||||
|
domain::ProjectId::from_uuid(uuid::Uuid::from_u128(328)),
|
||||||
|
"Plugin project",
|
||||||
|
project_root(),
|
||||||
|
RemoteRef::Local,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn manifest(auto_start: bool) -> PluginManifest {
|
fn manifest(auto_start: bool) -> PluginManifest {
|
||||||
PluginManifest {
|
PluginManifest {
|
||||||
idea_plugin_manifest_version: 1,
|
idea_plugin_manifest_version: 1,
|
||||||
@ -769,6 +874,7 @@ mod tests {
|
|||||||
ProjectPluginResolver::new(
|
ProjectPluginResolver::new(
|
||||||
Arc::new(MemorySettings {
|
Arc::new(MemorySettings {
|
||||||
settings: Mutex::new(settings),
|
settings: Mutex::new(settings),
|
||||||
|
fail_save: false,
|
||||||
}),
|
}),
|
||||||
Arc::new(MemoryPackages {
|
Arc::new(MemoryPackages {
|
||||||
packages: vec![PluginPackageRef {
|
packages: vec![PluginPackageRef {
|
||||||
@ -869,4 +975,111 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.is_empty());
|
.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn project_plugin_activation_publishes_change_after_successful_save() {
|
||||||
|
let store = Arc::new(MemorySettings {
|
||||||
|
settings: Mutex::new(ProjectPluginSettings::default()),
|
||||||
|
fail_save: false,
|
||||||
|
});
|
||||||
|
let packages = Arc::new(MemoryPackages {
|
||||||
|
packages: vec![PluginPackageRef {
|
||||||
|
plugin_id: Some(plugin_id()),
|
||||||
|
root: "/plugins/unity".to_owned(),
|
||||||
|
}],
|
||||||
|
app_data_dir: None,
|
||||||
|
});
|
||||||
|
let events = SpyEvents::default();
|
||||||
|
let use_case = SetProjectPluginEnabled::new(store, packages, Arc::new(events.clone()));
|
||||||
|
let project = project();
|
||||||
|
|
||||||
|
use_case
|
||||||
|
.execute(project.clone(), plugin_id(), true)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
events.events(),
|
||||||
|
vec![DomainEvent::ProjectPluginSettingsChanged {
|
||||||
|
project_id: project.id,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn project_plugin_activation_does_not_publish_when_save_fails() {
|
||||||
|
let store = Arc::new(MemorySettings {
|
||||||
|
settings: Mutex::new(ProjectPluginSettings::default()),
|
||||||
|
fail_save: true,
|
||||||
|
});
|
||||||
|
let packages = Arc::new(MemoryPackages {
|
||||||
|
packages: vec![PluginPackageRef {
|
||||||
|
plugin_id: Some(plugin_id()),
|
||||||
|
root: "/plugins/unity".to_owned(),
|
||||||
|
}],
|
||||||
|
app_data_dir: None,
|
||||||
|
});
|
||||||
|
let events = SpyEvents::default();
|
||||||
|
let use_case = SetProjectPluginEnabled::new(store, packages, Arc::new(events.clone()));
|
||||||
|
|
||||||
|
let error = use_case
|
||||||
|
.execute(project(), plugin_id(), true)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error.code(), "STORE");
|
||||||
|
assert!(events.events().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn project_plugin_assignment_publishes_change_after_successful_save() {
|
||||||
|
let store = Arc::new(MemorySettings {
|
||||||
|
settings: Mutex::new(settings(true, Vec::new())),
|
||||||
|
fail_save: false,
|
||||||
|
});
|
||||||
|
let packages = Arc::new(MemoryPackages {
|
||||||
|
packages: vec![PluginPackageRef {
|
||||||
|
plugin_id: Some(plugin_id()),
|
||||||
|
root: "/plugins/unity".to_owned(),
|
||||||
|
}],
|
||||||
|
app_data_dir: None,
|
||||||
|
});
|
||||||
|
let resolver = Arc::new(ProjectPluginResolver::new(
|
||||||
|
store.clone(),
|
||||||
|
packages,
|
||||||
|
Arc::new(MemoryRegistry {
|
||||||
|
registry: registry(PluginLifecycleState::Enabled),
|
||||||
|
}),
|
||||||
|
Arc::new(FixedManifest(manifest(true))),
|
||||||
|
));
|
||||||
|
let events = SpyEvents::default();
|
||||||
|
let use_case = MutateAgentPluginTool::new(
|
||||||
|
store,
|
||||||
|
Arc::new(FakeContexts),
|
||||||
|
resolver,
|
||||||
|
Arc::new(events.clone()),
|
||||||
|
);
|
||||||
|
let project = project();
|
||||||
|
|
||||||
|
use_case
|
||||||
|
.execute(
|
||||||
|
project.clone(),
|
||||||
|
agent_id(),
|
||||||
|
PluginToolRef {
|
||||||
|
plugin_id: plugin_id(),
|
||||||
|
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
|
||||||
|
tool_name: "unity_detect_project".to_owned(),
|
||||||
|
},
|
||||||
|
AssignmentMutation::Assign,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
events.events(),
|
||||||
|
vec![DomainEvent::ProjectPluginSettingsChanged {
|
||||||
|
project_id: project.id,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,7 +108,10 @@ impl FakePluginPackages {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl PluginPackageStore for FakePluginPackages {
|
impl PluginPackageStore for FakePluginPackages {
|
||||||
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
|
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
|
||||||
Ok(Vec::new())
|
Ok(vec![PluginPackageRef {
|
||||||
|
plugin_id: Some(PluginId::new("dev.idea.android-plugin").unwrap()),
|
||||||
|
root: "/plugin".to_owned(),
|
||||||
|
}])
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_manifest(
|
async fn read_manifest(
|
||||||
|
|||||||
@ -148,6 +148,12 @@ pub enum DomainEventDto {
|
|||||||
/// Project id (UUID string).
|
/// Project id (UUID string).
|
||||||
project_id: String,
|
project_id: String,
|
||||||
},
|
},
|
||||||
|
/// Project-scoped plugin activation or assignments changed.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
ProjectPluginSettingsChanged {
|
||||||
|
/// Owning project id.
|
||||||
|
project_id: String,
|
||||||
|
},
|
||||||
/// A plugin was installed.
|
/// A plugin was installed.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
PluginInstalled {
|
PluginInstalled {
|
||||||
@ -857,6 +863,11 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
|
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
|
||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
},
|
},
|
||||||
|
DomainEvent::ProjectPluginSettingsChanged { project_id } => {
|
||||||
|
Self::ProjectPluginSettingsChanged {
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
DomainEvent::PluginInstalled { plugin_id, version } => Self::PluginInstalled {
|
DomainEvent::PluginInstalled { plugin_id, version } => Self::PluginInstalled {
|
||||||
plugin_id: plugin_id.to_string(),
|
plugin_id: plugin_id.to_string(),
|
||||||
version: version.as_str().to_owned(),
|
version: version.as_str().to_owned(),
|
||||||
|
|||||||
@ -3636,16 +3636,19 @@ impl BackendCore {
|
|||||||
let set_project_plugin_enabled = Arc::new(SetProjectPluginEnabled::new(
|
let set_project_plugin_enabled = Arc::new(SetProjectPluginEnabled::new(
|
||||||
Arc::clone(&project_plugin_settings_store),
|
Arc::clone(&project_plugin_settings_store),
|
||||||
Arc::clone(&plugin_package_store),
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&events_port),
|
||||||
));
|
));
|
||||||
let mutate_agent_plugin_skill = Arc::new(MutateAgentPluginSkill::new(
|
let mutate_agent_plugin_skill = Arc::new(MutateAgentPluginSkill::new(
|
||||||
Arc::clone(&project_plugin_settings_store),
|
Arc::clone(&project_plugin_settings_store),
|
||||||
Arc::clone(&contexts_port),
|
Arc::clone(&contexts_port),
|
||||||
Arc::clone(&project_plugin_resolver),
|
Arc::clone(&project_plugin_resolver),
|
||||||
|
Arc::clone(&events_port),
|
||||||
));
|
));
|
||||||
let mutate_agent_plugin_tool = Arc::new(MutateAgentPluginTool::new(
|
let mutate_agent_plugin_tool = Arc::new(MutateAgentPluginTool::new(
|
||||||
Arc::clone(&project_plugin_settings_store),
|
Arc::clone(&project_plugin_settings_store),
|
||||||
Arc::clone(&contexts_port),
|
Arc::clone(&contexts_port),
|
||||||
Arc::clone(&project_plugin_resolver),
|
Arc::clone(&project_plugin_resolver),
|
||||||
|
Arc::clone(&events_port),
|
||||||
));
|
));
|
||||||
|
|
||||||
// --- Memory use cases (LOT A — §14.5.1) ---
|
// --- Memory use cases (LOT A — §14.5.1) ---
|
||||||
|
|||||||
@ -92,6 +92,11 @@ pub enum DomainEvent {
|
|||||||
/// The new project.
|
/// The new project.
|
||||||
project_id: ProjectId,
|
project_id: ProjectId,
|
||||||
},
|
},
|
||||||
|
/// Project-scoped plugin activation or assignments changed.
|
||||||
|
ProjectPluginSettingsChanged {
|
||||||
|
/// Project whose `.ideai/project-plugins.json` was persisted.
|
||||||
|
project_id: ProjectId,
|
||||||
|
},
|
||||||
/// A plugin was installed.
|
/// A plugin was installed.
|
||||||
PluginInstalled {
|
PluginInstalled {
|
||||||
/// Plugin id.
|
/// Plugin id.
|
||||||
|
|||||||
@ -294,6 +294,7 @@ export type AgentRestartFailureCode =
|
|||||||
|
|
||||||
export type DomainEvent =
|
export type DomainEvent =
|
||||||
| { type: "projectCreated"; projectId: string }
|
| { type: "projectCreated"; projectId: string }
|
||||||
|
| { type: "projectPluginSettingsChanged"; projectId: string }
|
||||||
| { type: "pluginInstalled"; pluginId: string; version: string }
|
| { type: "pluginInstalled"; pluginId: string; version: string }
|
||||||
| {
|
| {
|
||||||
type: "pluginReloaded";
|
type: "pluginReloaded";
|
||||||
@ -302,6 +303,10 @@ export type DomainEvent =
|
|||||||
newContentHash: string;
|
newContentHash: string;
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
}
|
}
|
||||||
|
| { type: "pluginEnabled"; pluginId: string }
|
||||||
|
| { type: "pluginDisabled"; pluginId: string; restartRequired: boolean }
|
||||||
|
| { type: "pluginUninstalled"; pluginId: string; restartRequired: boolean }
|
||||||
|
| { type: "pluginLoadFailed"; pluginId: string; reason: string }
|
||||||
| { type: "agentLaunched"; agentId: string; sessionId: string }
|
| { type: "agentLaunched"; agentId: string; sessionId: string }
|
||||||
| { type: "agentExited"; agentId: string; code: number }
|
| { type: "agentExited"; agentId: string; code: number }
|
||||||
| {
|
| {
|
||||||
|
|||||||
@ -264,12 +264,16 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
return (
|
return (
|
||||||
<Panel title={tr("Agents", "Agents")} className="flex flex-col gap-0">
|
<Panel title={tr("Agents", "Agents")} className="flex flex-col gap-0">
|
||||||
{vm.error && (
|
{vm.error && (
|
||||||
<p
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
className="mx-4 mt-3 flex flex-wrap items-center gap-2 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||||
>
|
>
|
||||||
{vm.error}
|
<span>{tr("Mise à jour interrompue.", "Update interrupted.")}</span>
|
||||||
</p>
|
<span>{vm.error}</span>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => void vm.refresh()}>
|
||||||
|
{tr("Réessayer", "Retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Create form ── */}
|
{/* ── Create form ── */}
|
||||||
|
|||||||
@ -160,10 +160,11 @@ function controlsFor(cellId: string) {
|
|||||||
|
|
||||||
type CellAction = "selector" | "split" | "close";
|
type CellAction = "selector" | "split" | "close";
|
||||||
|
|
||||||
function performAction(action: CellAction, cellId: string) {
|
async function performAction(action: CellAction, cellId: string) {
|
||||||
const { selector, splitColumns, close } = controlsFor(cellId);
|
const { selector, splitColumns, close } = controlsFor(cellId);
|
||||||
if (action === "selector") {
|
if (action === "selector") {
|
||||||
fireEvent.change(selector, { target: { value: "" } });
|
fireEvent.click(selector);
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "Terminal" }));
|
||||||
} else if (action === "split") {
|
} else if (action === "split") {
|
||||||
fireEvent.click(splitColumns);
|
fireEvent.click(splitColumns);
|
||||||
} else {
|
} else {
|
||||||
@ -443,7 +444,8 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selector = screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`);
|
const selector = screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`);
|
||||||
fireEvent.change(selector, { target: { value: setup.otherAgentId } });
|
fireEvent.click(selector);
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "Reviewer" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(mutate).toHaveBeenCalledWith(
|
expect(mutate).toHaveBeenCalledWith(
|
||||||
"p1",
|
"p1",
|
||||||
@ -509,7 +511,7 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
|
|||||||
expect(controls.style.zIndex).toBe("5");
|
expect(controls.style.zIndex).toBe("5");
|
||||||
expect(error.style.zIndex).toBe("3");
|
expect(error.style.zIndex).toBe("3");
|
||||||
|
|
||||||
performAction(action, setup.bId);
|
await performAction(action, setup.bId);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(mutate).toHaveBeenCalledWith(
|
expect(mutate).toHaveBeenCalledWith(
|
||||||
"p1",
|
"p1",
|
||||||
@ -538,7 +540,7 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
|
|||||||
expect(overlay.className).toContain("z-[4]");
|
expect(overlay.className).toContain("z-[4]");
|
||||||
expect(overlay.className).toContain("pointer-events-none");
|
expect(overlay.className).toContain("pointer-events-none");
|
||||||
|
|
||||||
performAction(action, setup.bId);
|
await performAction(action, setup.bId);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(mutate).toHaveBeenCalledWith(
|
expect(mutate).toHaveBeenCalledWith(
|
||||||
"p1",
|
"p1",
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import type {
|
|||||||
import { TerminalView, useWritePortal } from "@/features/terminals";
|
import { TerminalView, useWritePortal } from "@/features/terminals";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
|
import { SmallDropdown } from "@/shared";
|
||||||
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||||||
import {
|
import {
|
||||||
AgentRestartOverlay,
|
AgentRestartOverlay,
|
||||||
@ -1359,12 +1360,11 @@ function LeafView({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Agent selector */}
|
{/* Agent selector */}
|
||||||
<select
|
<SmallDropdown
|
||||||
aria-label={`${tr("Sélecteur d’agent", "Agent selector")} ${id}`}
|
aria-label={`${tr("Sélecteur d’agent", "Agent selector")} ${id}`}
|
||||||
value={agentId ?? ""}
|
value={agentId ?? ""}
|
||||||
disabled={cellRestartBusy}
|
disabled={cellRestartBusy}
|
||||||
onChange={(e) => {
|
onChange={(val) => {
|
||||||
const val = e.target.value;
|
|
||||||
if (val === "") {
|
if (val === "") {
|
||||||
setBusyNotice(null);
|
setBusyNotice(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@ -1415,29 +1415,23 @@ function LeafView({
|
|||||||
setBusyNotice(await noticeFromError(err, val)),
|
setBusyNotice(await noticeFromError(err, val)),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
style={{
|
className="h-6 w-24 min-w-14 rounded px-1.5 py-0 text-[11px]"
|
||||||
fontSize: 11,
|
options={[
|
||||||
background: "var(--color-surface, #1e1e1e)",
|
{ value: "", label: tr("Terminal", "Plain") },
|
||||||
color: "var(--color-content, #e0e0e0)",
|
...agents.map((a) => {
|
||||||
border: "1px solid var(--color-border, #3a3a3a)",
|
|
||||||
borderRadius: 3,
|
|
||||||
padding: "1px 2px",
|
|
||||||
width: 96,
|
|
||||||
minWidth: 56,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">{tr("Terminal", "Plain")}</option>
|
|
||||||
{agents.map((a) => {
|
|
||||||
const elsewhere = visibleElsewhere(a.id);
|
const elsewhere = visibleElsewhere(a.id);
|
||||||
const background = backgroundLive(a.id);
|
const background = backgroundLive(a.id);
|
||||||
return (
|
return {
|
||||||
<option key={a.id} value={a.id} disabled={Boolean(elsewhere)}>
|
value: a.id,
|
||||||
{a.name}
|
label: `${a.name}${elsewhere ? tr(" (visible ailleurs)", " (visible elsewhere)") : background ? tr(" (arrière-plan)", " (background)") : ""}`,
|
||||||
{elsewhere ? tr(" (visible ailleurs)", " (visible elsewhere)") : background ? tr(" (arrière-plan)", " (background)") : ""}
|
disabled: Boolean(elsewhere),
|
||||||
</option>
|
};
|
||||||
);
|
}),
|
||||||
})}
|
...(agents.length === 0
|
||||||
</select>
|
? [{ value: "__no-agents__", label: tr("Aucun agent disponible", "No agent available"), disabled: true }]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{(effectiveCustomCliAvailable || (agentId && cellMode === "custom")) && (
|
{(effectiveCustomCliAvailable || (agentId && cellMode === "custom")) && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -34,7 +34,13 @@ function renderGrid(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function waitForAgentOption(name = "MyAgent") {
|
async function waitForAgentOption(name = "MyAgent") {
|
||||||
await screen.findByRole("option", { name });
|
const selector = await screen.findByRole("combobox");
|
||||||
|
if (selector.getAttribute("aria-expanded") !== "true") fireEvent.click(selector);
|
||||||
|
return screen.findByRole("option", { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseAgent(name = "MyAgent") {
|
||||||
|
fireEvent.click(await waitForAgentOption(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("setCellAgent (agent dropdown per cell)", () => {
|
describe("setCellAgent (agent dropdown per cell)", () => {
|
||||||
@ -63,10 +69,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||||
});
|
});
|
||||||
await waitForAgentOption();
|
await chooseAgent();
|
||||||
|
|
||||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
|
||||||
fireEvent.change(select, { target: { value: a.id } });
|
|
||||||
|
|
||||||
// The layout should persist the agent on the leaf.
|
// The layout should persist the agent on the leaf.
|
||||||
const initial = await layout.loadLayout("p1");
|
const initial = await layout.loadLayout("p1");
|
||||||
@ -129,9 +132,9 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
|||||||
expect(sel).toBeTruthy();
|
expect(sel).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
|
||||||
// Switch back to Plain.
|
// Switch back to Plain.
|
||||||
fireEvent.change(select, { target: { value: "" } });
|
fireEvent.click(screen.getByRole("combobox"));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "Terminal" }));
|
||||||
|
|
||||||
// After clearing, the layout should have no agent on the leaf.
|
// After clearing, the layout should have no agent on the leaf.
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
@ -157,9 +160,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
|||||||
|
|
||||||
renderGrid(layout, agent);
|
renderGrid(layout, agent);
|
||||||
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
||||||
await waitForAgentOption();
|
await chooseAgent();
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const fresh = await layout.loadLayout("p1");
|
const fresh = await layout.loadLayout("p1");
|
||||||
@ -175,7 +176,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
|||||||
const agent = new MockAgentGateway();
|
const agent = new MockAgentGateway();
|
||||||
const terminal = new MockTerminalGateway();
|
const terminal = new MockTerminalGateway();
|
||||||
const closeSpy = vi.spyOn(terminal, "closeTerminal");
|
const closeSpy = vi.spyOn(terminal, "closeTerminal");
|
||||||
const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
|
await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
|
||||||
|
|
||||||
const tree = await layout.loadLayout("p1");
|
const tree = await layout.loadLayout("p1");
|
||||||
const leafId = leaves(tree)[0].id;
|
const leafId = leaves(tree)[0].id;
|
||||||
@ -187,9 +188,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
|||||||
|
|
||||||
renderGrid(layout, agent, "p1", terminal);
|
renderGrid(layout, agent, "p1", terminal);
|
||||||
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
||||||
await waitForAgentOption();
|
await chooseAgent();
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(closeSpy).toHaveBeenCalledWith("old-session");
|
expect(closeSpy).toHaveBeenCalledWith("old-session");
|
||||||
@ -205,9 +204,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
|||||||
|
|
||||||
renderGrid(layout, agent, "p1", terminal);
|
renderGrid(layout, agent, "p1", terminal);
|
||||||
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
||||||
await waitForAgentOption();
|
await chooseAgent();
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const fresh = await layout.loadLayout("p1");
|
const fresh = await layout.loadLayout("p1");
|
||||||
@ -262,9 +259,17 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
|||||||
|
|
||||||
renderGrid(layout, agent);
|
renderGrid(layout, agent);
|
||||||
|
|
||||||
await waitFor(() => {
|
fireEvent.click(await screen.findByRole("combobox"));
|
||||||
expect(screen.getByText("Agent Alpha")).toBeTruthy();
|
expect(await screen.findByRole("option", { name: "Agent Alpha" })).toBeTruthy();
|
||||||
expect(screen.getByText("Agent Beta")).toBeTruthy();
|
expect(screen.getByRole("option", { name: "Agent Beta" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows a themed empty state when the project has no agents", async () => {
|
||||||
|
renderGrid(new MockLayoutGateway(), new MockAgentGateway());
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("combobox"));
|
||||||
|
const list = await screen.findByRole("listbox");
|
||||||
|
expect(list.className).toContain("bg-overlay");
|
||||||
|
expect((screen.getByRole("option", { name: "Aucun agent disponible" }) as HTMLButtonElement).disabled).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -112,6 +112,11 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openAgentOption(name: RegExp | string) {
|
||||||
|
fireEvent.click(await screen.findByRole("combobox"));
|
||||||
|
return screen.findByRole("option", { name });
|
||||||
|
}
|
||||||
|
|
||||||
it("opens an agent already running elsewhere by rebinding its live session", async () => {
|
it("opens an agent already running elsewhere by rebinding its live session", async () => {
|
||||||
const layout = new MockLayoutGateway();
|
const layout = new MockLayoutGateway();
|
||||||
const agent = new MockAgentGateway();
|
const agent = new MockAgentGateway();
|
||||||
@ -129,13 +134,9 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
|||||||
renderGrid(layout, agent);
|
renderGrid(layout, agent);
|
||||||
|
|
||||||
// The option is selectable: choosing it rebinds the running session here.
|
// The option is selectable: choosing it rebinds the running session here.
|
||||||
const option = await screen.findByRole("option", {
|
const option = await openAgentOption(/Busy/);
|
||||||
name: /Busy/,
|
expect((option as HTMLButtonElement).disabled).toBe(false);
|
||||||
});
|
fireEvent.click(option);
|
||||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
|
||||||
|
|
||||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
|
||||||
fireEvent.change(select, { target: { value: a.id } });
|
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const updated = await layout.loadLayout("p1");
|
const updated = await layout.loadLayout("p1");
|
||||||
@ -165,13 +166,9 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
|||||||
|
|
||||||
renderGrid(layout, agent);
|
renderGrid(layout, agent);
|
||||||
|
|
||||||
const option = await screen.findByRole("option", {
|
const option = await openAgentOption(/Busy/);
|
||||||
name: /Busy/,
|
expect((option as HTMLButtonElement).disabled).toBe(false);
|
||||||
});
|
fireEvent.click(option);
|
||||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
|
||||||
|
|
||||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
|
||||||
fireEvent.change(select, { target: { value: a.id } });
|
|
||||||
|
|
||||||
expect((await screen.findByRole("status")).textContent).toMatch(
|
expect((await screen.findByRole("status")).textContent).toMatch(
|
||||||
/Session active introuvable/,
|
/Session active introuvable/,
|
||||||
@ -198,11 +195,9 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
|||||||
|
|
||||||
renderGrid(layout, agent);
|
renderGrid(layout, agent);
|
||||||
|
|
||||||
const option = await screen.findByRole("option", { name: /Headless/ });
|
const option = await openAgentOption(/Headless/);
|
||||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
expect((option as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
fireEvent.click(option);
|
||||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
|
||||||
fireEvent.change(select, { target: { value: a.id } });
|
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const updated = await layout.loadLayout("p1");
|
const updated = await layout.loadLayout("p1");
|
||||||
@ -234,9 +229,9 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
|||||||
|
|
||||||
renderGrid(layout, agent);
|
renderGrid(layout, agent);
|
||||||
|
|
||||||
const option = await screen.findByRole("option", { name: "Mine" });
|
const option = await openAgentOption("Mine");
|
||||||
// Live on its own cell ⇒ NOT disabled (it can keep running here).
|
// Live on its own cell ⇒ NOT disabled (it can keep running here).
|
||||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
expect((option as HTMLButtonElement).disabled).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -291,23 +286,13 @@ describe("R0d — dropdown disables agents live elsewhere + go-to-cell", () => {
|
|||||||
renderGrid(layout, agent);
|
renderGrid(layout, agent);
|
||||||
|
|
||||||
// In cell A's dropdown the option is DISABLED (cannot run in two cells).
|
// In cell A's dropdown the option is DISABLED (cannot run in two cells).
|
||||||
const selectA = (await screen.findByLabelText(
|
const selectA = await screen.findByLabelText(
|
||||||
`Sélecteur d’agent ${a}`,
|
`Sélecteur d’agent ${a}`,
|
||||||
)) as HTMLSelectElement;
|
|
||||||
await waitFor(() => {
|
|
||||||
const opt = Array.from(selectA.options).find((o) => o.value === ag.id)!;
|
|
||||||
expect(opt.disabled).toBe(true);
|
|
||||||
expect(opt.textContent).toMatch(/visible ailleurs/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Selecting it surfaces a clear notice + an "aller à la cellule" action.
|
|
||||||
fireEvent.change(selectA, { target: { value: ag.id } });
|
|
||||||
expect((await screen.findByRole("status")).textContent).toMatch(
|
|
||||||
/déjà actif dans une autre cellule/,
|
|
||||||
);
|
);
|
||||||
expect(
|
fireEvent.click(selectA);
|
||||||
screen.getByRole("button", { name: "aller à la cellule" }),
|
const opt = await screen.findByRole("option", { name: /Busy.*visible ailleurs/ });
|
||||||
).toBeTruthy();
|
expect((opt as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
expect(opt.textContent).toMatch(/visible ailleurs/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an agent NOT live anywhere stays selectable (no regression)", async () => {
|
it("an agent NOT live anywhere stays selectable (no regression)", async () => {
|
||||||
@ -320,13 +305,13 @@ describe("R0d — dropdown disables agents live elsewhere + go-to-cell", () => {
|
|||||||
|
|
||||||
renderGrid(layout, agent);
|
renderGrid(layout, agent);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("combobox"));
|
||||||
const option = await screen.findByRole("option", { name: "Free" });
|
const option = await screen.findByRole("option", { name: "Free" });
|
||||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
expect((option as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
|
||||||
// And selecting it pins the agent on the cell (no notice, no block).
|
// And selecting it pins the agent on the cell (no notice, no block).
|
||||||
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
|
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
|
||||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
fireEvent.click(option);
|
||||||
fireEvent.change(select, { target: { value: ag.id } });
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const leaf = leaves(await layout.loadLayout("p1")).find(
|
const leaf = leaves(await layout.loadLayout("p1")).find(
|
||||||
(l) => l.id === leafId,
|
(l) => l.id === leafId,
|
||||||
@ -396,18 +381,16 @@ describe("ticket #56 — 'visible ailleurs' derives from the layout, not stale l
|
|||||||
// In cell B's dropdown, X is ENABLED and flagged as a background session —
|
// In cell B's dropdown, X is ENABLED and flagged as a background session —
|
||||||
// NOT "visible ailleurs" (regression: it used to be disabled via the stale
|
// NOT "visible ailleurs" (regression: it used to be disabled via the stale
|
||||||
// live nodeId still pointing at the now-visible cell A).
|
// live nodeId still pointing at the now-visible cell A).
|
||||||
const selectB = (await screen.findByLabelText(
|
const selectB = await screen.findByLabelText(
|
||||||
`Sélecteur d’agent ${b}`,
|
`Sélecteur d’agent ${b}`,
|
||||||
)) as HTMLSelectElement;
|
);
|
||||||
await waitFor(() => {
|
fireEvent.click(selectB);
|
||||||
const opt = Array.from(selectB.options).find((o) => o.value === x.id)!;
|
const opt = await screen.findByRole("option", { name: /Xavier.*arrière-plan/ });
|
||||||
expect(opt.disabled).toBe(false);
|
expect((opt as HTMLButtonElement).disabled).toBe(false);
|
||||||
expect(opt.textContent).toMatch(/arrière-plan/);
|
|
||||||
expect(opt.textContent).not.toMatch(/visible ailleurs/);
|
expect(opt.textContent).not.toMatch(/visible ailleurs/);
|
||||||
});
|
|
||||||
|
|
||||||
// Selecting X in B re-attaches its live session (no re-spawn) and pins it here.
|
// Selecting X in B re-attaches its live session (no re-spawn) and pins it here.
|
||||||
fireEvent.change(selectB, { target: { value: x.id } });
|
fireEvent.click(opt);
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const leaf = leaves(await layout.loadLayout("p1")).find((l) => l.id === b)!;
|
const leaf = leaves(await layout.loadLayout("p1")).find((l) => l.id === b)!;
|
||||||
expect(leaf.agent).toBe(x.id);
|
expect(leaf.agent).toBe(x.id);
|
||||||
@ -438,13 +421,11 @@ describe("ticket #56 — 'visible ailleurs' derives from the layout, not stale l
|
|||||||
|
|
||||||
renderGrid(layout, agent);
|
renderGrid(layout, agent);
|
||||||
|
|
||||||
const selectB = (await screen.findByLabelText(
|
const selectB = await screen.findByLabelText(
|
||||||
`Sélecteur d’agent ${b}`,
|
`Sélecteur d’agent ${b}`,
|
||||||
)) as HTMLSelectElement;
|
);
|
||||||
await waitFor(() => {
|
fireEvent.click(selectB);
|
||||||
const opt = Array.from(selectB.options).find((o) => o.value === x.id)!;
|
const opt = await screen.findByRole("option", { name: /Xavier.*visible ailleurs/ });
|
||||||
expect(opt.disabled).toBe(true);
|
expect((opt as HTMLButtonElement).disabled).toBe(true);
|
||||||
expect(opt.textContent).toMatch(/visible ailleurs/);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -132,6 +132,17 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
|
|||||||
expect(mounts).toBe(1);
|
expect(mounts).toBe(1);
|
||||||
expect(plugin.calls).toBe(3);
|
expect(plugin.calls).toBe(3);
|
||||||
expect(plugin.projectIds).toEqual(["project-1", "project-1", "project-1"]);
|
expect(plugin.projectIds).toEqual(["project-1", "project-1", "project-1"]);
|
||||||
|
|
||||||
|
plugin.catalog = { plugins: [] };
|
||||||
|
system.emit({ type: "projectPluginSettingsChanged", projectId: "another-project" });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(plugin.calls).toBe(3);
|
||||||
|
|
||||||
|
system.emit({ type: "projectPluginSettingsChanged", projectId: "project-1" });
|
||||||
|
expect(await screen.findByText("none")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("workspace-state").textContent).toBe("preserved");
|
||||||
|
expect(mounts).toBe(1);
|
||||||
|
expect(plugin.calls).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores a custom plugin layout ref when its contribution disappears", async () => {
|
it("ignores a custom plugin layout ref when its contribution disappears", async () => {
|
||||||
|
|||||||
@ -179,7 +179,16 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
|||||||
|
|
||||||
void gateways.system
|
void gateways.system
|
||||||
.onDomainEvent((event) => {
|
.onDomainEvent((event) => {
|
||||||
if (event.type === "pluginInstalled" || event.type === "pluginReloaded") {
|
const globalCatalogChanged =
|
||||||
|
event.type === "pluginInstalled" ||
|
||||||
|
event.type === "pluginReloaded" ||
|
||||||
|
event.type === "pluginEnabled" ||
|
||||||
|
event.type === "pluginDisabled" ||
|
||||||
|
event.type === "pluginUninstalled";
|
||||||
|
const currentProjectChanged =
|
||||||
|
event.type === "projectPluginSettingsChanged" &&
|
||||||
|
event.projectId === currentProjectId;
|
||||||
|
if (globalCatalogChanged || currentProjectChanged) {
|
||||||
void refreshRuntime(false);
|
void refreshRuntime(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { DIProvider } from "@/app/di";
|
|||||||
import {
|
import {
|
||||||
createMockGateways,
|
createMockGateways,
|
||||||
MockPluginGateway,
|
MockPluginGateway,
|
||||||
|
MockSystemGateway,
|
||||||
} from "@/adapters/mock";
|
} from "@/adapters/mock";
|
||||||
import type { ProjectPluginManifest } from "@/domain";
|
import type { ProjectPluginManifest } from "@/domain";
|
||||||
import { SkillsPanel } from "@/features/skills";
|
import { SkillsPanel } from "@/features/skills";
|
||||||
@ -91,6 +92,53 @@ describe("project plugin settings #282", () => {
|
|||||||
expect(onAssignAgents).toHaveBeenCalledOnce();
|
expect(onAssignAgents).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refreshes project settings on the matching domain event without remounting", async () => {
|
||||||
|
const gateways = gatewaysWithPlugin();
|
||||||
|
const system = new MockSystemGateway();
|
||||||
|
const scoped = { ...gateways, system };
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={scoped}>
|
||||||
|
<ProjectPluginsPanel projectId="project-1" />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = await screen.findByRole("checkbox", { name: "Activé pour ce projet" });
|
||||||
|
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||||
|
gateways.plugin._seedProjectPluginSettings("project-1", {
|
||||||
|
version: 1,
|
||||||
|
plugins: [{ pluginId: "dev.acme.unity", enabled: true }],
|
||||||
|
agents: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
system.emit({ type: "projectPluginSettingsChanged", projectId: "other-project" });
|
||||||
|
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||||
|
system.emit({ type: "projectPluginSettingsChanged", projectId: "project-1" });
|
||||||
|
await waitFor(() => expect((toggle as HTMLInputElement).checked).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the previous activation after failure and retries the requested state", async () => {
|
||||||
|
const gateways = gatewaysWithPlugin();
|
||||||
|
const original = gateways.plugin.setProjectEnabled.bind(gateways.plugin);
|
||||||
|
const update = vi
|
||||||
|
.spyOn(gateways.plugin, "setProjectEnabled")
|
||||||
|
.mockRejectedValueOnce(new Error("settings unavailable"))
|
||||||
|
.mockImplementation(original);
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<ProjectPluginsPanel projectId="project-1" />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = await screen.findByRole("checkbox", { name: "Activé pour ce projet" });
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
expect(await screen.findByText("Erreur — état précédent conservé")).toBeTruthy();
|
||||||
|
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Réessayer" }));
|
||||||
|
await waitFor(() => expect((toggle as HTMLInputElement).checked).toBe(true));
|
||||||
|
expect(update).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("assigns plugin skills from the unified Skills panel and keeps tombstones removable", async () => {
|
it("assigns plugin skills from the unified Skills panel and keeps tombstones removable", async () => {
|
||||||
const gateways = gatewaysWithPlugin();
|
const gateways = gatewaysWithPlugin();
|
||||||
const agent = await gateways.agent.createAgent("project-1", {
|
const agent = await gateways.agent.createAgent("project-1", {
|
||||||
|
|||||||
@ -48,7 +48,21 @@ export function ProjectPluginsPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{vm.error && <p role="alert" className="text-sm text-danger">{vm.error}</p>}
|
{vm.error && (
|
||||||
|
<div role="alert" className="flex flex-wrap items-center gap-2 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||||
|
<span>{tr("Mise à jour interrompue.", "Update interrupted.")}</span>
|
||||||
|
<span>{vm.error}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void (vm.failedActivation
|
||||||
|
? vm.setEnabled(vm.failedActivation.pluginId, vm.failedActivation.enabled)
|
||||||
|
: vm.refresh())}
|
||||||
|
>
|
||||||
|
{tr("Réessayer", "Retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{vm.installed.length === 0 && missing.length === 0 ? (
|
{vm.installed.length === 0 && missing.length === 0 ? (
|
||||||
<Panel><p className="text-sm text-muted">{tr("Aucun plugin installé globalement.", "No globally installed plugins.")}</p></Panel>
|
<Panel><p className="text-sm text-muted">{tr("Aucun plugin installé globalement.", "No globally installed plugins.")}</p></Panel>
|
||||||
@ -60,6 +74,10 @@ export function ProjectPluginsPanel({
|
|||||||
) ?? false;
|
) ?? false;
|
||||||
const skills = plugin.contributes.skills ?? [];
|
const skills = plugin.contributes.skills ?? [];
|
||||||
const servers = plugin.contributes.mcpServers ?? [];
|
const servers = plugin.contributes.mcpServers ?? [];
|
||||||
|
const pending = vm.pendingActivation?.pluginId === plugin.id
|
||||||
|
? vm.pendingActivation
|
||||||
|
: null;
|
||||||
|
const failed = vm.failedActivation?.pluginId === plugin.id;
|
||||||
return (
|
return (
|
||||||
<li key={plugin.id}>
|
<li key={plugin.id}>
|
||||||
<Panel className="flex flex-col gap-3">
|
<Panel className="flex flex-col gap-3">
|
||||||
@ -86,6 +104,15 @@ export function ProjectPluginsPanel({
|
|||||||
{tr("Activé pour ce projet", "Enabled for this project")}
|
{tr("Activé pour ce projet", "Enabled for this project")}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{(pending || failed) && (
|
||||||
|
<p role={failed ? "alert" : "status"} className={`text-xs ${failed ? "text-danger" : "text-muted"}`}>
|
||||||
|
{failed
|
||||||
|
? tr("Erreur — état précédent conservé", "Error — previous state preserved")
|
||||||
|
: pending?.enabled
|
||||||
|
? tr("Activation…", "Enabling…")
|
||||||
|
: tr("Désactivation…", "Disabling…")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary className="cursor-pointer text-xs font-medium text-primary">
|
<summary className="cursor-pointer text-xs font-medium text-primary">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { MenuTargetId } from "@/domain";
|
import type { MenuTargetId } from "@/domain";
|
||||||
@ -16,6 +16,8 @@ const NO_CONTEXT: WhenContext = {
|
|||||||
function throwingRegistry(overrides: Record<string, unknown>): PluginRuntimeRegistry {
|
function throwingRegistry(overrides: Record<string, unknown>): PluginRuntimeRegistry {
|
||||||
return {
|
return {
|
||||||
runCommand: vi.fn(async () => {}),
|
runCommand: vi.fn(async () => {}),
|
||||||
|
runCommandStrict: vi.fn(async () => {}),
|
||||||
|
get: vi.fn(() => ({ commands: { has: () => true } })),
|
||||||
topLevelMenus: vi.fn(() => []),
|
topLevelMenus: vi.fn(() => []),
|
||||||
menuItems: vi.fn(() => []),
|
menuItems: vi.fn(() => []),
|
||||||
...overrides,
|
...overrides,
|
||||||
@ -72,4 +74,50 @@ describe("usePluginMenus", () => {
|
|||||||
consoleWarn.mockRestore();
|
consoleWarn.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("logs rejected commands and exposes retryable feedback to the host", async () => {
|
||||||
|
const failure = new Error("native window refused");
|
||||||
|
const runCommandStrict = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce("opened");
|
||||||
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
const registry = throwingRegistry({ runCommandStrict });
|
||||||
|
const { result } = renderHook(() => usePluginMenus(registry, NO_CONTEXT));
|
||||||
|
|
||||||
|
await act(() => result.current.runCommand("dev.acme.git", "git.configure"));
|
||||||
|
|
||||||
|
expect(consoleError).toHaveBeenCalledWith(
|
||||||
|
"[plugins] menu command rejected",
|
||||||
|
{
|
||||||
|
pluginId: "dev.acme.git",
|
||||||
|
commandId: "git.configure",
|
||||||
|
message: "native window refused",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result.current.commandError).toEqual({
|
||||||
|
pluginId: "dev.acme.git",
|
||||||
|
commandId: "git.configure",
|
||||||
|
message: "native window refused",
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(() => result.current.runCommand("dev.acme.git", "git.configure"));
|
||||||
|
expect(result.current.commandError).toBeNull();
|
||||||
|
expect(runCommandStrict).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
consoleError.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a missing command handler as a silent no-op", async () => {
|
||||||
|
const runCommandStrict = vi.fn();
|
||||||
|
const registry = throwingRegistry({
|
||||||
|
runCommandStrict,
|
||||||
|
get: vi.fn(() => ({ commands: { has: () => false } })),
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => usePluginMenus(registry, NO_CONTEXT));
|
||||||
|
|
||||||
|
await act(() => result.current.runCommand("dev.acme.git", "git.missing"));
|
||||||
|
|
||||||
|
expect(runCommandStrict).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.commandError).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
* future lot threads real focus state up to this level).
|
* future lot threads real focus state up to this level).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { MenuTargetId } from "@/domain";
|
import type { MenuTargetId } from "@/domain";
|
||||||
import type { MenuBarItem, MenuBarMenu } from "@/shared";
|
import type { MenuBarItem, MenuBarMenu } from "@/shared";
|
||||||
@ -24,6 +24,14 @@ export interface UsePluginMenusResult {
|
|||||||
/** Resolved+ordered `MenuBarItem`s to append to a native menu's items. */
|
/** Resolved+ordered `MenuBarItem`s to append to a native menu's items. */
|
||||||
itemsFor: (targetMenuId: MenuTargetId) => MenuBarItem[];
|
itemsFor: (targetMenuId: MenuTargetId) => MenuBarItem[];
|
||||||
runCommand: (pluginId: string, commandId: string) => Promise<unknown>;
|
runCommand: (pluginId: string, commandId: string) => Promise<unknown>;
|
||||||
|
commandError: PluginMenuCommandError | null;
|
||||||
|
clearCommandError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginMenuCommandError {
|
||||||
|
pluginId: string;
|
||||||
|
commandId: string;
|
||||||
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeError(e: unknown): string {
|
function describeError(e: unknown): string {
|
||||||
@ -49,8 +57,25 @@ export function usePluginMenus(
|
|||||||
registry: PluginRuntimeRegistry,
|
registry: PluginRuntimeRegistry,
|
||||||
whenCtx: WhenContext,
|
whenCtx: WhenContext,
|
||||||
): UsePluginMenusResult {
|
): UsePluginMenusResult {
|
||||||
const runCommand = useMemo(
|
const [commandError, setCommandError] = useState<PluginMenuCommandError | null>(null);
|
||||||
() => (pluginId: string, commandId: string) => registry.runCommand(pluginId, commandId),
|
const clearCommandError = useCallback(() => setCommandError(null), []);
|
||||||
|
const runCommand = useCallback(
|
||||||
|
async (pluginId: string, commandId: string): Promise<unknown> => {
|
||||||
|
setCommandError(null);
|
||||||
|
try {
|
||||||
|
// Preserve the SDK's historical no-op for a declared menu command whose
|
||||||
|
// handler was not registered, but do not use the lenient dispatcher for
|
||||||
|
// real handlers: it logs and swallows rejected promises, which made
|
||||||
|
// native-window failures invisible to the menu host (#329).
|
||||||
|
if (!registry.get(pluginId)?.commands.has(commandId)) return undefined;
|
||||||
|
return await registry.runCommandStrict(pluginId, commandId);
|
||||||
|
} catch (error) {
|
||||||
|
const failure = { pluginId, commandId, message: describeError(error) };
|
||||||
|
console.error("[plugins] menu command rejected", failure);
|
||||||
|
setCommandError(failure);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
[registry],
|
[registry],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -126,5 +151,5 @@ export function usePluginMenus(
|
|||||||
});
|
});
|
||||||
}, [registry, itemsFor]);
|
}, [registry, itemsFor]);
|
||||||
|
|
||||||
return { topLevelMenus, itemsFor, runCommand };
|
return { topLevelMenus, itemsFor, runCommand, commandError, clearCommandError };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import type {
|
import type {
|
||||||
@ -21,6 +21,8 @@ export interface ProjectPluginsViewModel {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
pendingActivation: { pluginId: string; enabled: boolean } | null;
|
||||||
|
failedActivation: { pluginId: string; enabled: boolean } | null;
|
||||||
refresh(): Promise<void>;
|
refresh(): Promise<void>;
|
||||||
setEnabled(pluginId: string, enabled: boolean): Promise<void>;
|
setEnabled(pluginId: string, enabled: boolean): Promise<void>;
|
||||||
setSkillAssigned(agentId: string, pluginId: string, skillId: string, assigned: boolean): Promise<void>;
|
setSkillAssigned(agentId: string, pluginId: string, skillId: string, assigned: boolean): Promise<void>;
|
||||||
@ -28,31 +30,91 @@ export interface ProjectPluginsViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useProjectPlugins(projectId: string): ProjectPluginsViewModel {
|
export function useProjectPlugins(projectId: string): ProjectPluginsViewModel {
|
||||||
const { plugin } = useGateways();
|
const { plugin, system } = useGateways();
|
||||||
const [installed, setInstalled] = useState<ProjectPluginManifest[]>([]);
|
const [installed, setInstalled] = useState<ProjectPluginManifest[]>([]);
|
||||||
const [settings, setSettings] = useState<ProjectPluginSettings | null>(null);
|
const [settings, setSettings] = useState<ProjectPluginSettings | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pendingActivation, setPendingActivation] = useState<{
|
||||||
|
pluginId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [failedActivation, setFailedActivation] = useState<{
|
||||||
|
pluginId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const refreshRevision = useRef(0);
|
||||||
|
const currentProjectId = useRef(projectId);
|
||||||
|
currentProjectId.current = projectId;
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
|
const revision = ++refreshRevision.current;
|
||||||
|
const refreshedProjectId = projectId;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const view = await plugin.listProjectSettings(projectId);
|
const view = await plugin.listProjectSettings(refreshedProjectId);
|
||||||
|
if (
|
||||||
|
revision !== refreshRevision.current ||
|
||||||
|
currentProjectId.current !== refreshedProjectId
|
||||||
|
) return;
|
||||||
setInstalled(view.installed);
|
setInstalled(view.installed);
|
||||||
setSettings(view.settings);
|
setSettings(view.settings);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
if (
|
||||||
|
revision !== refreshRevision.current ||
|
||||||
|
currentProjectId.current !== refreshedProjectId
|
||||||
|
) return;
|
||||||
setError(describe(cause));
|
setError(describe(cause));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (
|
||||||
|
revision === refreshRevision.current &&
|
||||||
|
currentProjectId.current === refreshedProjectId
|
||||||
|
) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [plugin, projectId]);
|
}, [plugin, projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setInstalled([]);
|
||||||
|
setSettings(null);
|
||||||
|
setBusy(false);
|
||||||
|
setError(null);
|
||||||
|
setPendingActivation(null);
|
||||||
|
setFailedActivation(null);
|
||||||
void refresh();
|
void refresh();
|
||||||
|
return () => {
|
||||||
|
refreshRevision.current += 1;
|
||||||
|
};
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!system?.onDomainEvent) return;
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
void system
|
||||||
|
.onDomainEvent((event) => {
|
||||||
|
const globalCatalogChanged =
|
||||||
|
event.type === "pluginInstalled" ||
|
||||||
|
event.type === "pluginReloaded" ||
|
||||||
|
event.type === "pluginEnabled" ||
|
||||||
|
event.type === "pluginDisabled" ||
|
||||||
|
event.type === "pluginUninstalled";
|
||||||
|
const currentProjectChanged =
|
||||||
|
event.type === "projectPluginSettingsChanged" &&
|
||||||
|
event.projectId === currentProjectId.current;
|
||||||
|
if (globalCatalogChanged || currentProjectChanged) void refresh();
|
||||||
|
})
|
||||||
|
.then((next) => {
|
||||||
|
if (cancelled) next();
|
||||||
|
else unsubscribe = next;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
}, [refresh, system]);
|
||||||
|
|
||||||
const mutate = useCallback(async (operation: () => Promise<ProjectPluginSettings>) => {
|
const mutate = useCallback(async (operation: () => Promise<ProjectPluginSettings>) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -65,11 +127,30 @@ export function useProjectPlugins(projectId: string): ProjectPluginsViewModel {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setEnabled = useCallback(
|
const setEnabled = useCallback(async (pluginId: string, enabled: boolean) => {
|
||||||
(pluginId: string, enabled: boolean) =>
|
const mutatedProjectId = projectId;
|
||||||
mutate(() => plugin.setProjectEnabled({ projectId, pluginId, enabled })),
|
setBusy(true);
|
||||||
[mutate, plugin, projectId],
|
setError(null);
|
||||||
);
|
setFailedActivation(null);
|
||||||
|
setPendingActivation({ pluginId, enabled });
|
||||||
|
try {
|
||||||
|
const next = await plugin.setProjectEnabled({
|
||||||
|
projectId: mutatedProjectId,
|
||||||
|
pluginId,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
if (currentProjectId.current === mutatedProjectId) setSettings(next);
|
||||||
|
} catch (cause) {
|
||||||
|
if (currentProjectId.current !== mutatedProjectId) return;
|
||||||
|
setError(describe(cause));
|
||||||
|
setFailedActivation({ pluginId, enabled });
|
||||||
|
} finally {
|
||||||
|
if (currentProjectId.current === mutatedProjectId) {
|
||||||
|
setPendingActivation(null);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [plugin, projectId]);
|
||||||
|
|
||||||
const setSkillAssigned = useCallback(
|
const setSkillAssigned = useCallback(
|
||||||
(agentId: string, pluginId: string, skillId: string, assigned: boolean) => {
|
(agentId: string, pluginId: string, skillId: string, assigned: boolean) => {
|
||||||
@ -97,6 +178,8 @@ export function useProjectPlugins(projectId: string): ProjectPluginsViewModel {
|
|||||||
loading,
|
loading,
|
||||||
busy,
|
busy,
|
||||||
error,
|
error,
|
||||||
|
pendingActivation,
|
||||||
|
failedActivation,
|
||||||
refresh,
|
refresh,
|
||||||
setEnabled,
|
setEnabled,
|
||||||
setSkillAssigned,
|
setSkillAssigned,
|
||||||
|
|||||||
@ -19,6 +19,23 @@ function deferred<T>() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ProjectSettingsPanel", () => {
|
describe("ProjectSettingsPanel", () => {
|
||||||
|
it("composes project plugin activation between appearance and context", async () => {
|
||||||
|
const gateways = createMockGateways();
|
||||||
|
const project = await gateways.project.createProject("Alpha", "/alpha");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<ProjectSettingsPanel projectId={project.id} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const appearance = await screen.findByText("Apparence");
|
||||||
|
const plugins = screen.getByRole("region", { name: "Plugins du projet" });
|
||||||
|
const context = screen.getByRole("heading", { name: "Contexte du projet", level: 2 });
|
||||||
|
expect(appearance.compareDocumentPosition(plugins) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(plugins.compareDocumentPosition(context) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("persists a project colour, emits the scoped refresh, and resets to fallback", async () => {
|
it("persists a project colour, emits the scoped refresh, and resets to fallback", async () => {
|
||||||
const gateways = createMockGateways();
|
const gateways = createMockGateways();
|
||||||
const project = await gateways.project.createProject("Alpha", "/alpha");
|
const project = await gateways.project.createProject("Alpha", "/alpha");
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { Button, Panel, Spinner } from "@/shared";
|
|||||||
import { ProjectContextPanel } from "./ProjectContextPanel";
|
import { ProjectContextPanel } from "./ProjectContextPanel";
|
||||||
import { ProjectIconPicker } from "./ProjectIconPicker";
|
import { ProjectIconPicker } from "./ProjectIconPicker";
|
||||||
import { ProjectIcon, publishProjectIconChanged } from "./projectIcons";
|
import { ProjectIcon, publishProjectIconChanged } from "./projectIcons";
|
||||||
|
import { ProjectPluginsPanel } from "@/features/plugins";
|
||||||
import {
|
import {
|
||||||
DEFAULT_PROJECT_COLOR,
|
DEFAULT_PROJECT_COLOR,
|
||||||
onProjectThemeChanged,
|
onProjectThemeChanged,
|
||||||
@ -216,6 +217,8 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
|||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<ProjectPluginsPanel projectId={projectId} />
|
||||||
|
|
||||||
<section aria-labelledby="project-context-settings-title">
|
<section aria-labelledby="project-context-settings-title">
|
||||||
<h2
|
<h2
|
||||||
id="project-context-settings-title"
|
id="project-context-settings-title"
|
||||||
|
|||||||
@ -1033,6 +1033,36 @@ export function ProjectsView() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{pluginMenus.commandError && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mx-4 mt-2 flex shrink-0 items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{tr("L’action du plugin a échoué.", "The plugin action failed.")} {pluginMenus.commandError.message}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void pluginMenus.runCommand(
|
||||||
|
pluginMenus.commandError!.pluginId,
|
||||||
|
pluginMenus.commandError!.commandId,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tr("Réessayer", "Retry")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={tr("Fermer l’erreur du plugin", "Dismiss plugin error")}
|
||||||
|
onClick={pluginMenus.clearCommandError}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Project tab bar (#26): tabs = open projects, "+" opens Projects ── */}
|
{/* ── Project tab bar (#26): tabs = open projects, "+" opens Projects ── */}
|
||||||
<ProjectTabs
|
<ProjectTabs
|
||||||
|
|||||||
@ -23,15 +23,14 @@ export interface ProjectAgents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useProjectAgents(projectId: string): ProjectAgents {
|
export function useProjectAgents(projectId: string): ProjectAgents {
|
||||||
const { agent } = useGateways();
|
const { agent, system } = useGateways();
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoaded(false);
|
setLoaded(false);
|
||||||
void agent
|
const refresh = () => agent.listAgents(projectId)
|
||||||
.listAgents(projectId)
|
|
||||||
.then((list) => {
|
.then((list) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setAgents(list);
|
setAgents(list);
|
||||||
@ -40,14 +39,31 @@ export function useProjectAgents(projectId: string): ProjectAgents {
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setAgents([]);
|
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
void refresh();
|
||||||
|
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
if (system?.onDomainEvent) {
|
||||||
|
void system
|
||||||
|
.onDomainEvent((event) => {
|
||||||
|
if (
|
||||||
|
event.type === "agentLaunched" ||
|
||||||
|
event.type === "agentExited" ||
|
||||||
|
event.type === "agentProfileChanged"
|
||||||
|
) void refresh();
|
||||||
|
})
|
||||||
|
.then((next) => {
|
||||||
|
if (cancelled) next();
|
||||||
|
else unsubscribe = next;
|
||||||
|
});
|
||||||
|
}
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
unsubscribe?.();
|
||||||
};
|
};
|
||||||
}, [projectId, agent]);
|
}, [projectId, agent, system]);
|
||||||
|
|
||||||
const byId = useMemo(
|
const byId = useMemo(
|
||||||
() => new Map(agents.map((a) => [a.id, a.name])),
|
() => new Map(agents.map((a) => [a.id, a.name])),
|
||||||
|
|||||||
@ -213,6 +213,7 @@ export function SmallDropdown({
|
|||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
id={id}
|
id={id}
|
||||||
type="button"
|
type="button"
|
||||||
|
role="combobox"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
@ -256,7 +257,7 @@ export function SmallDropdown({
|
|||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
data-placement={position.placement}
|
data-placement={position.placement}
|
||||||
className="fixed overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-xl"
|
className="fixed overflow-y-auto rounded-md border border-border-strong bg-overlay py-1 shadow-xl"
|
||||||
style={{
|
style={{
|
||||||
top: position.top,
|
top: position.top,
|
||||||
left: position.left,
|
left: position.left,
|
||||||
@ -281,8 +282,9 @@ export function SmallDropdown({
|
|||||||
"flex min-h-8 w-full items-center px-2 text-left text-content",
|
"flex min-h-8 w-full items-center px-2 text-left text-content",
|
||||||
"hover:bg-raised focus:bg-raised focus:outline-none",
|
"hover:bg-raised focus:bg-raised focus:outline-none",
|
||||||
size === "md" ? "text-sm" : "text-xs",
|
size === "md" ? "text-sm" : "text-xs",
|
||||||
(option.value === value || index === activeIndex) && "bg-raised",
|
option.value === value && "bg-primary/15 text-content",
|
||||||
option.disabled && "cursor-not-allowed opacity-50",
|
index === activeIndex && option.value !== value && "bg-raised",
|
||||||
|
option.disabled && "cursor-not-allowed text-faint opacity-60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate">{option.label}</span>
|
<span className="min-w-0 truncate">{option.label}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user