feat(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)

Lot en un seul commit: les quatre tickets partagent les mêmes fichiers
(événements domaine/DTO, ProjectPluginsPanel, useProjectPlugins) — une
séparation par hunks aurait risqué des états intermédiaires non
compilables (convention lot des batches #310-#313, #315 et #325-#326).

#328 — section dédiée plugins dans Paramètres du projet:
- frontend: ProjectPluginsPanel intégré à ProjectSettingsPanel
  (Panneaux > Paramètres du projet) — activation/désactivation par
  plugin avec état actif/inactif explicite par projet, distinct de
  l'assignation agents/skills/tools
- domaine: événement ProjectPluginSettingsChanged publié par
  SetProjectPluginEnabled, MutateAgentPluginSkill et
  MutateAgentPluginTool après persistance de .ideai/project-plugins.json
  (application + backend + DTO camelCase, test dto.rs)

#329 — les menus plugin qui échouent ne sont plus muets:
- usePluginMenus: dispatcher strict (runCommandStrict) pour les
  commandes réellement enregistrées au lieu du dispatcher laxiste qui
  journalisait et avalait les rejets — rendait les échecs d'ouverture
  de fenêtres natifs invisibles pour l'hôte de menus
- le no-op SDK historique (commande déclarée sans handler) est préservé
- échec remonté à l'hôte via commandError/clearCommandError (journal
  console + état exposé); test window_usecases adapté à un plugin
  installé
- le correctif côté SDK/GitPlugin vit dans le projet GitPlugin (son
  commit/merge local y est fait, hors périmètre de ce dépôt)

#330 — refresh automatique des pages:
- frontend/domain: typage des événements pluginEnabled,
  pluginDisabled (restartRequired), pluginUninstalled
  (restartRequired), pluginLoadFailed et projectPluginSettingsChanged
- useProjectPlugins et PluginRuntimeProvider: souscription
  onDomainEvent — refresh sur changement catalogue global ou sur
  projectPluginSettingsChanged du projet courant; gardes
  révision/projet contre les réponses obsolètes
- useProjectAgents: refresh auto quand un agent est ajouté au projet;
  ProjectsView et AgentsPanel suivent (erreur de rafraîchissement
  affichée avec bouton Réessayer)

#331 — droplist agents des cellules:
- SmallDropdown: liste bg-overlay + bordure border-strong (plus de
  fond blanc hors thème), hover/focus bg-raised, sélection
  bg-primary/15, disabled text-faint opacity-60, role=combobox
- LayoutGrid: sélecteur d'agent des cellules migré du <select> natif
  vers SmallDropdown (mêmes libellés Terminal/visible
  ailleurs/arrière-plan)

QA verte réelle sur les quatre lots (tests ciblés + exécution).
sdk/IdeaSDK: contenu modifié non committable ici (pointeur inchangé).
Aucun push.
This commit is contained in:
2026-09-12 01:14:45 +02:00
parent 4f0457c08a
commit db67ecdf8f
24 changed files with 706 additions and 146 deletions

View File

@ -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());

View File

@ -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,
}]
);
}
}

View File

@ -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(

View File

@ -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(),

View File

@ -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) ---

View File

@ -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.

View File

@ -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 }
| {

View File

@ -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 ── */}

View File

@ -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",

View File

@ -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

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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 () => {

View File

@ -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);
}
})

View File

@ -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", {

View File

@ -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">

View File

@ -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();
});
});

View File

@ -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 };
}

View File

@ -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,

View File

@ -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");

View File

@ -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"

View File

@ -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

View File

@ -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])),

View File

@ -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>