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]
|
||||
fn domain_event_dto_maps_agent_launched() {
|
||||
let aid = AgentId::from_uuid(Uuid::nil());
|
||||
|
||||
@ -4,10 +4,11 @@ use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
AgentContextStore, AgentId, AgentPluginAssignments, MarkdownDoc, PluginId, PluginManifest,
|
||||
PluginManifestValidator, PluginMcpServerId, PluginMcpServerSpec, PluginPackageStore,
|
||||
PluginRegistryStore, PluginSkillId, PluginSkillRef, PluginStoreError, PluginToolRef, Project,
|
||||
ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore,
|
||||
AgentContextStore, AgentId, AgentPluginAssignments, DomainEvent, EventBus, MarkdownDoc,
|
||||
PluginId, PluginManifest, PluginManifestValidator, PluginMcpServerId, PluginMcpServerSpec,
|
||||
PluginPackageStore, PluginRegistryStore, PluginSkillId, PluginSkillRef, PluginStoreError,
|
||||
PluginToolRef, Project, ProjectPluginActivation, ProjectPluginSettings,
|
||||
ProjectPluginSettingsStore,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@ -292,6 +293,7 @@ impl ListProjectPluginSettings {
|
||||
pub struct SetProjectPluginEnabled {
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl SetProjectPluginEnabled {
|
||||
@ -300,8 +302,13 @@ impl SetProjectPluginEnabled {
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self { store, packages }
|
||||
Self {
|
||||
store,
|
||||
packages,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists activation. Enabling requires a currently installed plugin;
|
||||
@ -336,6 +343,10 @@ impl SetProjectPluginEnabled {
|
||||
.push(ProjectPluginActivation { plugin_id, enabled }),
|
||||
}
|
||||
self.store.save(&project.root, &settings).await?;
|
||||
self.events
|
||||
.publish(DomainEvent::ProjectPluginSettingsChanged {
|
||||
project_id: project.id,
|
||||
});
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
@ -354,6 +365,7 @@ pub struct MutateAgentPluginSkill {
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl MutateAgentPluginSkill {
|
||||
@ -363,11 +375,13 @@ impl MutateAgentPluginSkill {
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
contexts,
|
||||
resolver,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
@ -412,6 +426,10 @@ impl MutateAgentPluginSkill {
|
||||
_ => {}
|
||||
}
|
||||
self.store.save(&project.root, &settings).await?;
|
||||
self.events
|
||||
.publish(DomainEvent::ProjectPluginSettingsChanged {
|
||||
project_id: project.id,
|
||||
});
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
@ -421,6 +439,7 @@ pub struct MutateAgentPluginTool {
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl MutateAgentPluginTool {
|
||||
@ -430,11 +449,13 @@ impl MutateAgentPluginTool {
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
contexts,
|
||||
resolver,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
@ -483,6 +504,10 @@ impl MutateAgentPluginTool {
|
||||
_ => {}
|
||||
}
|
||||
self.store.save(&project.root, &settings).await?;
|
||||
self.events
|
||||
.publish(DomainEvent::ProjectPluginSettingsChanged {
|
||||
project_id: project.id,
|
||||
});
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
@ -550,16 +575,82 @@ mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
ContentHash, LocalPath, PluginActivationScope, PluginCapability, PluginContributionSet,
|
||||
PluginInstallSource, PluginLifecycleState, PluginManifestBytes, PluginManifestError,
|
||||
PluginMcpServerContribution, PluginPackageRef, PluginRegistry, PluginRegistryEntry,
|
||||
PluginTrustLevel, PluginVersion, ProjectPath, RelativePath, RemovalOutcome,
|
||||
StagedPluginPackage,
|
||||
Agent, AgentManifest, AgentOrigin, ContentHash, LocalPath, PluginActivationScope,
|
||||
PluginCapability, PluginContributionSet, PluginInstallSource, PluginLifecycleState,
|
||||
PluginManifestBytes, PluginManifestError, PluginMcpServerContribution, PluginPackageRef,
|
||||
PluginRegistry, PluginRegistryEntry, PluginTrustLevel, PluginVersion, ProfileId,
|
||||
ProjectPath, RelativePath, RemoteRef, RemovalOutcome, StagedPluginPackage,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MemorySettings {
|
||||
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]
|
||||
@ -576,6 +667,9 @@ mod tests {
|
||||
_project_root: &ProjectPath,
|
||||
settings: &ProjectPluginSettings,
|
||||
) -> Result<(), domain::StoreError> {
|
||||
if self.fail_save {
|
||||
return Err(domain::StoreError::Io("save failed".to_owned()));
|
||||
}
|
||||
*self.settings.lock().unwrap() = settings.clone();
|
||||
Ok(())
|
||||
}
|
||||
@ -684,6 +778,17 @@ mod tests {
|
||||
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 {
|
||||
PluginManifest {
|
||||
idea_plugin_manifest_version: 1,
|
||||
@ -769,6 +874,7 @@ mod tests {
|
||||
ProjectPluginResolver::new(
|
||||
Arc::new(MemorySettings {
|
||||
settings: Mutex::new(settings),
|
||||
fail_save: false,
|
||||
}),
|
||||
Arc::new(MemoryPackages {
|
||||
packages: vec![PluginPackageRef {
|
||||
@ -869,4 +975,111 @@ mod tests {
|
||||
.unwrap()
|
||||
.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]
|
||||
impl PluginPackageStore for FakePluginPackages {
|
||||
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(
|
||||
|
||||
@ -148,6 +148,12 @@ pub enum DomainEventDto {
|
||||
/// Project id (UUID 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.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PluginInstalled {
|
||||
@ -857,6 +863,11 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
|
||||
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 {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
version: version.as_str().to_owned(),
|
||||
|
||||
@ -3636,16 +3636,19 @@ impl BackendCore {
|
||||
let set_project_plugin_enabled = Arc::new(SetProjectPluginEnabled::new(
|
||||
Arc::clone(&project_plugin_settings_store),
|
||||
Arc::clone(&plugin_package_store),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let mutate_agent_plugin_skill = Arc::new(MutateAgentPluginSkill::new(
|
||||
Arc::clone(&project_plugin_settings_store),
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&project_plugin_resolver),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let mutate_agent_plugin_tool = Arc::new(MutateAgentPluginTool::new(
|
||||
Arc::clone(&project_plugin_settings_store),
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&project_plugin_resolver),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
|
||||
// --- Memory use cases (LOT A — §14.5.1) ---
|
||||
|
||||
@ -92,6 +92,11 @@ pub enum DomainEvent {
|
||||
/// The new project.
|
||||
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.
|
||||
PluginInstalled {
|
||||
/// Plugin id.
|
||||
|
||||
@ -294,6 +294,7 @@ export type AgentRestartFailureCode =
|
||||
|
||||
export type DomainEvent =
|
||||
| { type: "projectCreated"; projectId: string }
|
||||
| { type: "projectPluginSettingsChanged"; projectId: string }
|
||||
| { type: "pluginInstalled"; pluginId: string; version: string }
|
||||
| {
|
||||
type: "pluginReloaded";
|
||||
@ -302,6 +303,10 @@ export type DomainEvent =
|
||||
newContentHash: string;
|
||||
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: "agentExited"; agentId: string; code: number }
|
||||
| {
|
||||
|
||||
@ -264,12 +264,16 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
return (
|
||||
<Panel title={tr("Agents", "Agents")} className="flex flex-col gap-0">
|
||||
{vm.error && (
|
||||
<p
|
||||
<div
|
||||
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}
|
||||
</p>
|
||||
<span>{tr("Mise à jour interrompue.", "Update interrupted.")}</span>
|
||||
<span>{vm.error}</span>
|
||||
<Button size="sm" variant="secondary" onClick={() => void vm.refresh()}>
|
||||
{tr("Réessayer", "Retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Create form ── */}
|
||||
|
||||
@ -160,10 +160,11 @@ function controlsFor(cellId: string) {
|
||||
|
||||
type CellAction = "selector" | "split" | "close";
|
||||
|
||||
function performAction(action: CellAction, cellId: string) {
|
||||
async function performAction(action: CellAction, cellId: string) {
|
||||
const { selector, splitColumns, close } = controlsFor(cellId);
|
||||
if (action === "selector") {
|
||||
fireEvent.change(selector, { target: { value: "" } });
|
||||
fireEvent.click(selector);
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Terminal" }));
|
||||
} else if (action === "split") {
|
||||
fireEvent.click(splitColumns);
|
||||
} else {
|
||||
@ -443,7 +444,8 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
|
||||
);
|
||||
|
||||
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(() =>
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
@ -509,7 +511,7 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
|
||||
expect(controls.style.zIndex).toBe("5");
|
||||
expect(error.style.zIndex).toBe("3");
|
||||
|
||||
performAction(action, setup.bId);
|
||||
await performAction(action, setup.bId);
|
||||
await waitFor(() =>
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
@ -538,7 +540,7 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
|
||||
expect(overlay.className).toContain("z-[4]");
|
||||
expect(overlay.className).toContain("pointer-events-none");
|
||||
|
||||
performAction(action, setup.bId);
|
||||
await performAction(action, setup.bId);
|
||||
await waitFor(() =>
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
|
||||
@ -29,6 +29,7 @@ import type {
|
||||
import { TerminalView, useWritePortal } from "@/features/terminals";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { SmallDropdown } from "@/shared";
|
||||
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||||
import {
|
||||
AgentRestartOverlay,
|
||||
@ -1359,12 +1360,11 @@ function LeafView({
|
||||
}}
|
||||
>
|
||||
{/* Agent selector */}
|
||||
<select
|
||||
<SmallDropdown
|
||||
aria-label={`${tr("Sélecteur d’agent", "Agent selector")} ${id}`}
|
||||
value={agentId ?? ""}
|
||||
disabled={cellRestartBusy}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
onChange={(val) => {
|
||||
if (val === "") {
|
||||
setBusyNotice(null);
|
||||
void (async () => {
|
||||
@ -1415,29 +1415,23 @@ function LeafView({
|
||||
setBusyNotice(await noticeFromError(err, val)),
|
||||
);
|
||||
}}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
background: "var(--color-surface, #1e1e1e)",
|
||||
color: "var(--color-content, #e0e0e0)",
|
||||
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 background = backgroundLive(a.id);
|
||||
return (
|
||||
<option key={a.id} value={a.id} disabled={Boolean(elsewhere)}>
|
||||
{a.name}
|
||||
{elsewhere ? tr(" (visible ailleurs)", " (visible elsewhere)") : background ? tr(" (arrière-plan)", " (background)") : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
className="h-6 w-24 min-w-14 rounded px-1.5 py-0 text-[11px]"
|
||||
options={[
|
||||
{ value: "", label: tr("Terminal", "Plain") },
|
||||
...agents.map((a) => {
|
||||
const elsewhere = visibleElsewhere(a.id);
|
||||
const background = backgroundLive(a.id);
|
||||
return {
|
||||
value: a.id,
|
||||
label: `${a.name}${elsewhere ? tr(" (visible ailleurs)", " (visible elsewhere)") : background ? tr(" (arrière-plan)", " (background)") : ""}`,
|
||||
disabled: Boolean(elsewhere),
|
||||
};
|
||||
}),
|
||||
...(agents.length === 0
|
||||
? [{ value: "__no-agents__", label: tr("Aucun agent disponible", "No agent available"), disabled: true }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
||||
{(effectiveCustomCliAvailable || (agentId && cellMode === "custom")) && (
|
||||
<div
|
||||
|
||||
@ -34,7 +34,13 @@ function renderGrid(
|
||||
}
|
||||
|
||||
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)", () => {
|
||||
@ -63,10 +69,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
});
|
||||
await waitForAgentOption();
|
||||
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: a.id } });
|
||||
await chooseAgent();
|
||||
|
||||
// The layout should persist the agent on the leaf.
|
||||
const initial = await layout.loadLayout("p1");
|
||||
@ -129,9 +132,9 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
expect(sel).toBeTruthy();
|
||||
});
|
||||
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
// 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.
|
||||
await waitFor(async () => {
|
||||
@ -157,9 +160,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
|
||||
renderGrid(layout, agent);
|
||||
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
||||
await waitForAgentOption();
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
||||
await chooseAgent();
|
||||
|
||||
await waitFor(async () => {
|
||||
const fresh = await layout.loadLayout("p1");
|
||||
@ -175,7 +176,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const terminal = new MockTerminalGateway();
|
||||
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 leafId = leaves(tree)[0].id;
|
||||
@ -187,9 +188,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
|
||||
renderGrid(layout, agent, "p1", terminal);
|
||||
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
||||
await waitForAgentOption();
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
||||
await chooseAgent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(closeSpy).toHaveBeenCalledWith("old-session");
|
||||
@ -205,9 +204,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
|
||||
renderGrid(layout, agent, "p1", terminal);
|
||||
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
||||
await waitForAgentOption();
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
||||
await chooseAgent();
|
||||
|
||||
await waitFor(async () => {
|
||||
const fresh = await layout.loadLayout("p1");
|
||||
@ -262,9 +259,17 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Agent Alpha")).toBeTruthy();
|
||||
expect(screen.getByText("Agent Beta")).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("combobox"));
|
||||
expect(await screen.findByRole("option", { name: "Agent Alpha" })).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 () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
@ -129,13 +134,9 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
||||
renderGrid(layout, agent);
|
||||
|
||||
// The option is selectable: choosing it rebinds the running session here.
|
||||
const option = await screen.findByRole("option", {
|
||||
name: /Busy/,
|
||||
});
|
||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
||||
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: a.id } });
|
||||
const option = await openAgentOption(/Busy/);
|
||||
expect((option as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(option);
|
||||
|
||||
await waitFor(async () => {
|
||||
const updated = await layout.loadLayout("p1");
|
||||
@ -165,13 +166,9 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
const option = await screen.findByRole("option", {
|
||||
name: /Busy/,
|
||||
});
|
||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
||||
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: a.id } });
|
||||
const option = await openAgentOption(/Busy/);
|
||||
expect((option as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(option);
|
||||
|
||||
expect((await screen.findByRole("status")).textContent).toMatch(
|
||||
/Session active introuvable/,
|
||||
@ -198,11 +195,9 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
const option = await screen.findByRole("option", { name: /Headless/ });
|
||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
||||
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: a.id } });
|
||||
const option = await openAgentOption(/Headless/);
|
||||
expect((option as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(option);
|
||||
|
||||
await waitFor(async () => {
|
||||
const updated = await layout.loadLayout("p1");
|
||||
@ -234,9 +229,9 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
||||
|
||||
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).
|
||||
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);
|
||||
|
||||
// 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}`,
|
||||
)) 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(
|
||||
screen.getByRole("button", { name: "aller à la cellule" }),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(selectA);
|
||||
const opt = await screen.findByRole("option", { name: /Busy.*visible ailleurs/ });
|
||||
expect((opt as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(opt.textContent).toMatch(/visible ailleurs/);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
fireEvent.click(await screen.findByRole("combobox"));
|
||||
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).
|
||||
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: ag.id } });
|
||||
fireEvent.click(option);
|
||||
await waitFor(async () => {
|
||||
const leaf = leaves(await layout.loadLayout("p1")).find(
|
||||
(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 —
|
||||
// NOT "visible ailleurs" (regression: it used to be disabled via the stale
|
||||
// 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}`,
|
||||
)) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
const opt = Array.from(selectB.options).find((o) => o.value === x.id)!;
|
||||
expect(opt.disabled).toBe(false);
|
||||
expect(opt.textContent).toMatch(/arrière-plan/);
|
||||
expect(opt.textContent).not.toMatch(/visible ailleurs/);
|
||||
});
|
||||
);
|
||||
fireEvent.click(selectB);
|
||||
const opt = await screen.findByRole("option", { name: /Xavier.*arrière-plan/ });
|
||||
expect((opt as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(opt.textContent).not.toMatch(/visible ailleurs/);
|
||||
|
||||
// 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 () => {
|
||||
const leaf = leaves(await layout.loadLayout("p1")).find((l) => l.id === b)!;
|
||||
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);
|
||||
|
||||
const selectB = (await screen.findByLabelText(
|
||||
const selectB = await screen.findByLabelText(
|
||||
`Sélecteur d’agent ${b}`,
|
||||
)) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
const opt = Array.from(selectB.options).find((o) => o.value === x.id)!;
|
||||
expect(opt.disabled).toBe(true);
|
||||
expect(opt.textContent).toMatch(/visible ailleurs/);
|
||||
});
|
||||
);
|
||||
fireEvent.click(selectB);
|
||||
const opt = await screen.findByRole("option", { name: /Xavier.*visible ailleurs/ });
|
||||
expect((opt as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -132,6 +132,17 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
|
||||
expect(mounts).toBe(1);
|
||||
expect(plugin.calls).toBe(3);
|
||||
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 () => {
|
||||
|
||||
@ -179,7 +179,16 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
|
||||
void gateways.system
|
||||
.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);
|
||||
}
|
||||
})
|
||||
|
||||
@ -5,6 +5,7 @@ import { DIProvider } from "@/app/di";
|
||||
import {
|
||||
createMockGateways,
|
||||
MockPluginGateway,
|
||||
MockSystemGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { ProjectPluginManifest } from "@/domain";
|
||||
import { SkillsPanel } from "@/features/skills";
|
||||
@ -91,6 +92,53 @@ describe("project plugin settings #282", () => {
|
||||
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 () => {
|
||||
const gateways = gatewaysWithPlugin();
|
||||
const agent = await gateways.agent.createAgent("project-1", {
|
||||
|
||||
@ -48,7 +48,21 @@ export function ProjectPluginsPanel({
|
||||
)}
|
||||
</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 ? (
|
||||
<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;
|
||||
const skills = plugin.contributes.skills ?? [];
|
||||
const servers = plugin.contributes.mcpServers ?? [];
|
||||
const pending = vm.pendingActivation?.pluginId === plugin.id
|
||||
? vm.pendingActivation
|
||||
: null;
|
||||
const failed = vm.failedActivation?.pluginId === plugin.id;
|
||||
return (
|
||||
<li key={plugin.id}>
|
||||
<Panel className="flex flex-col gap-3">
|
||||
@ -86,6 +104,15 @@ export function ProjectPluginsPanel({
|
||||
{tr("Activé pour ce projet", "Enabled for this project")}
|
||||
</label>
|
||||
</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>
|
||||
<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 type { MenuTargetId } from "@/domain";
|
||||
@ -16,6 +16,8 @@ const NO_CONTEXT: WhenContext = {
|
||||
function throwingRegistry(overrides: Record<string, unknown>): PluginRuntimeRegistry {
|
||||
return {
|
||||
runCommand: vi.fn(async () => {}),
|
||||
runCommandStrict: vi.fn(async () => {}),
|
||||
get: vi.fn(() => ({ commands: { has: () => true } })),
|
||||
topLevelMenus: vi.fn(() => []),
|
||||
menuItems: vi.fn(() => []),
|
||||
...overrides,
|
||||
@ -72,4 +74,50 @@ describe("usePluginMenus", () => {
|
||||
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).
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import type { MenuTargetId } from "@/domain";
|
||||
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. */
|
||||
itemsFor: (targetMenuId: MenuTargetId) => MenuBarItem[];
|
||||
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 {
|
||||
@ -49,8 +57,25 @@ export function usePluginMenus(
|
||||
registry: PluginRuntimeRegistry,
|
||||
whenCtx: WhenContext,
|
||||
): UsePluginMenusResult {
|
||||
const runCommand = useMemo(
|
||||
() => (pluginId: string, commandId: string) => registry.runCommand(pluginId, commandId),
|
||||
const [commandError, setCommandError] = useState<PluginMenuCommandError | null>(null);
|
||||
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],
|
||||
);
|
||||
|
||||
@ -126,5 +151,5 @@ export function usePluginMenus(
|
||||
});
|
||||
}, [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 type {
|
||||
@ -21,6 +21,8 @@ export interface ProjectPluginsViewModel {
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
pendingActivation: { pluginId: string; enabled: boolean } | null;
|
||||
failedActivation: { pluginId: string; enabled: boolean } | null;
|
||||
refresh(): Promise<void>;
|
||||
setEnabled(pluginId: string, enabled: 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 {
|
||||
const { plugin } = useGateways();
|
||||
const { plugin, system } = useGateways();
|
||||
const [installed, setInstalled] = useState<ProjectPluginManifest[]>([]);
|
||||
const [settings, setSettings] = useState<ProjectPluginSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
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 revision = ++refreshRevision.current;
|
||||
const refreshedProjectId = projectId;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const view = await plugin.listProjectSettings(projectId);
|
||||
const view = await plugin.listProjectSettings(refreshedProjectId);
|
||||
if (
|
||||
revision !== refreshRevision.current ||
|
||||
currentProjectId.current !== refreshedProjectId
|
||||
) return;
|
||||
setInstalled(view.installed);
|
||||
setSettings(view.settings);
|
||||
} catch (cause) {
|
||||
if (
|
||||
revision !== refreshRevision.current ||
|
||||
currentProjectId.current !== refreshedProjectId
|
||||
) return;
|
||||
setError(describe(cause));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (
|
||||
revision === refreshRevision.current &&
|
||||
currentProjectId.current === refreshedProjectId
|
||||
) setLoading(false);
|
||||
}
|
||||
}, [plugin, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
setInstalled([]);
|
||||
setSettings(null);
|
||||
setBusy(false);
|
||||
setError(null);
|
||||
setPendingActivation(null);
|
||||
setFailedActivation(null);
|
||||
void refresh();
|
||||
return () => {
|
||||
refreshRevision.current += 1;
|
||||
};
|
||||
}, [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>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@ -65,11 +127,30 @@ export function useProjectPlugins(projectId: string): ProjectPluginsViewModel {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setEnabled = useCallback(
|
||||
(pluginId: string, enabled: boolean) =>
|
||||
mutate(() => plugin.setProjectEnabled({ projectId, pluginId, enabled })),
|
||||
[mutate, plugin, projectId],
|
||||
);
|
||||
const setEnabled = useCallback(async (pluginId: string, enabled: boolean) => {
|
||||
const mutatedProjectId = projectId;
|
||||
setBusy(true);
|
||||
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(
|
||||
(agentId: string, pluginId: string, skillId: string, assigned: boolean) => {
|
||||
@ -97,6 +178,8 @@ export function useProjectPlugins(projectId: string): ProjectPluginsViewModel {
|
||||
loading,
|
||||
busy,
|
||||
error,
|
||||
pendingActivation,
|
||||
failedActivation,
|
||||
refresh,
|
||||
setEnabled,
|
||||
setSkillAssigned,
|
||||
|
||||
@ -19,6 +19,23 @@ function deferred<T>() {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const gateways = createMockGateways();
|
||||
const project = await gateways.project.createProject("Alpha", "/alpha");
|
||||
|
||||
@ -7,6 +7,7 @@ import { Button, Panel, Spinner } from "@/shared";
|
||||
import { ProjectContextPanel } from "./ProjectContextPanel";
|
||||
import { ProjectIconPicker } from "./ProjectIconPicker";
|
||||
import { ProjectIcon, publishProjectIconChanged } from "./projectIcons";
|
||||
import { ProjectPluginsPanel } from "@/features/plugins";
|
||||
import {
|
||||
DEFAULT_PROJECT_COLOR,
|
||||
onProjectThemeChanged,
|
||||
@ -216,6 +217,8 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<ProjectPluginsPanel projectId={projectId} />
|
||||
|
||||
<section aria-labelledby="project-context-settings-title">
|
||||
<h2
|
||||
id="project-context-settings-title"
|
||||
|
||||
@ -1033,6 +1033,36 @@ export function ProjectsView() {
|
||||
</Button>
|
||||
</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 ── */}
|
||||
<ProjectTabs
|
||||
|
||||
@ -23,15 +23,14 @@ export interface ProjectAgents {
|
||||
}
|
||||
|
||||
export function useProjectAgents(projectId: string): ProjectAgents {
|
||||
const { agent } = useGateways();
|
||||
const { agent, system } = useGateways();
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoaded(false);
|
||||
void agent
|
||||
.listAgents(projectId)
|
||||
const refresh = () => agent.listAgents(projectId)
|
||||
.then((list) => {
|
||||
if (!cancelled) {
|
||||
setAgents(list);
|
||||
@ -40,14 +39,31 @@ export function useProjectAgents(projectId: string): ProjectAgents {
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAgents([]);
|
||||
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 () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [projectId, agent]);
|
||||
}, [projectId, agent, system]);
|
||||
|
||||
const byId = useMemo(
|
||||
() => new Map(agents.map((a) => [a.id, a.name])),
|
||||
|
||||
@ -213,6 +213,7 @@ export function SmallDropdown({
|
||||
ref={triggerRef}
|
||||
id={id}
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
@ -256,7 +257,7 @@ export function SmallDropdown({
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
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={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
@ -281,8 +282,9 @@ export function SmallDropdown({
|
||||
"flex min-h-8 w-full items-center px-2 text-left text-content",
|
||||
"hover:bg-raised focus:bg-raised focus:outline-none",
|
||||
size === "md" ? "text-sm" : "text-xs",
|
||||
(option.value === value || index === activeIndex) && "bg-raised",
|
||||
option.disabled && "cursor-not-allowed opacity-50",
|
||||
option.value === value && "bg-primary/15 text-content",
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user