diff --git a/crates/app-tauri/tests/dto.rs b/crates/app-tauri/tests/dto.rs index 8b74c1f0..990ba8ee 100644 --- a/crates/app-tauri/tests/dto.rs +++ b/crates/app-tauri/tests/dto.rs @@ -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()); diff --git a/crates/application/src/project_plugin.rs b/crates/application/src/project_plugin.rs index d28c1c84..538334cb 100644 --- a/crates/application/src/project_plugin.rs +++ b/crates/application/src/project_plugin.rs @@ -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, packages: Arc, + events: Arc, } impl SetProjectPluginEnabled { @@ -300,8 +302,13 @@ impl SetProjectPluginEnabled { pub fn new( store: Arc, packages: Arc, + events: Arc, ) -> 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, contexts: Arc, resolver: Arc, + events: Arc, } impl MutateAgentPluginSkill { @@ -363,11 +375,13 @@ impl MutateAgentPluginSkill { store: Arc, contexts: Arc, resolver: Arc, + events: Arc, ) -> 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, contexts: Arc, resolver: Arc, + events: Arc, } impl MutateAgentPluginTool { @@ -430,11 +449,13 @@ impl MutateAgentPluginTool { store: Arc, contexts: Arc, resolver: Arc, + events: Arc, ) -> 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, + fail_save: bool, + } + + #[derive(Clone, Default)] + struct SpyEvents(Arc>>); + + impl SpyEvents { + fn events(&self) -> Vec { + 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 { + 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 { + 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, + }] + ); + } } diff --git a/crates/application/tests/window_usecases.rs b/crates/application/tests/window_usecases.rs index ab17aa8d..4defe0e1 100644 --- a/crates/application/tests/window_usecases.rs +++ b/crates/application/tests/window_usecases.rs @@ -108,7 +108,10 @@ impl FakePluginPackages { #[async_trait] impl PluginPackageStore for FakePluginPackages { async fn list_installed(&self) -> Result, 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( diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs index bca64a90..e05ea797 100644 --- a/crates/backend/src/events.rs +++ b/crates/backend/src/events.rs @@ -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(), diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index ac34dcde..0e842f2d 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -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) --- diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index abaa5378..d3bc04f6 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -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. diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index c56c1238..e5520794 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -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 } | { diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 7cd3ecc2..69eef2aa 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -264,12 +264,16 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { return ( {vm.error && ( -

- {vm.error} -

+ {tr("Mise à jour interrompue.", "Update interrupted.")} + {vm.error} + + )} {/* ── Create form ── */} diff --git a/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx b/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx index 25ecb943..ef4e3ff5 100644 --- a/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx @@ -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", diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 36233039..78d4b7a0 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -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 */} - + 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")) && (
{ @@ -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); }); }); diff --git a/frontend/src/features/layout/singletonAgent.test.tsx b/frontend/src/features/layout/singletonAgent.test.tsx index 60cb70d0..f26ad27b 100644 --- a/frontend/src/features/layout/singletonAgent.test.tsx +++ b/frontend/src/features/layout/singletonAgent.test.tsx @@ -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); }); }); diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx index 82b75777..f16908d9 100644 --- a/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx +++ b/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx @@ -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 () => { diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.tsx index 883c6c02..7475c507 100644 --- a/frontend/src/features/plugins/PluginRuntimeProvider.tsx +++ b/frontend/src/features/plugins/PluginRuntimeProvider.tsx @@ -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); } }) diff --git a/frontend/src/features/plugins/ProjectPluginSettings.test.tsx b/frontend/src/features/plugins/ProjectPluginSettings.test.tsx index 8e7021b6..4c845507 100644 --- a/frontend/src/features/plugins/ProjectPluginSettings.test.tsx +++ b/frontend/src/features/plugins/ProjectPluginSettings.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + 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", { diff --git a/frontend/src/features/plugins/ProjectPluginsPanel.tsx b/frontend/src/features/plugins/ProjectPluginsPanel.tsx index 190588e8..ea868bf3 100644 --- a/frontend/src/features/plugins/ProjectPluginsPanel.tsx +++ b/frontend/src/features/plugins/ProjectPluginsPanel.tsx @@ -48,7 +48,21 @@ export function ProjectPluginsPanel({ )}
- {vm.error &&

{vm.error}

} + {vm.error && ( +
+ {tr("Mise à jour interrompue.", "Update interrupted.")} + {vm.error} + +
+ )} {vm.installed.length === 0 && missing.length === 0 ? (

{tr("Aucun plugin installé globalement.", "No globally installed plugins.")}

@@ -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 (
  • @@ -86,6 +104,15 @@ export function ProjectPluginsPanel({ {tr("Activé pour ce projet", "Enabled for this project")} + {(pending || failed) && ( +

    + {failed + ? tr("Erreur — état précédent conservé", "Error — previous state preserved") + : pending?.enabled + ? tr("Activation…", "Enabling…") + : tr("Désactivation…", "Disabling…")} +

    + )}
    diff --git a/frontend/src/features/plugins/usePluginMenus.test.tsx b/frontend/src/features/plugins/usePluginMenus.test.tsx index d2016aee..4561acf3 100644 --- a/frontend/src/features/plugins/usePluginMenus.test.tsx +++ b/frontend/src/features/plugins/usePluginMenus.test.tsx @@ -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): 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(); + }); }); diff --git a/frontend/src/features/plugins/usePluginMenus.ts b/frontend/src/features/plugins/usePluginMenus.ts index e667d372..4ca579f4 100644 --- a/frontend/src/features/plugins/usePluginMenus.ts +++ b/frontend/src/features/plugins/usePluginMenus.ts @@ -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; + 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(null); + const clearCommandError = useCallback(() => setCommandError(null), []); + const runCommand = useCallback( + async (pluginId: string, commandId: string): Promise => { + 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 }; } diff --git a/frontend/src/features/plugins/useProjectPlugins.ts b/frontend/src/features/plugins/useProjectPlugins.ts index a3040a30..0e83182c 100644 --- a/frontend/src/features/plugins/useProjectPlugins.ts +++ b/frontend/src/features/plugins/useProjectPlugins.ts @@ -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; setEnabled(pluginId: string, enabled: boolean): Promise; setSkillAssigned(agentId: string, pluginId: string, skillId: string, assigned: boolean): Promise; @@ -28,31 +30,91 @@ export interface ProjectPluginsViewModel { } export function useProjectPlugins(projectId: string): ProjectPluginsViewModel { - const { plugin } = useGateways(); + const { plugin, system } = useGateways(); const [installed, setInstalled] = useState([]); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(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) => { 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, diff --git a/frontend/src/features/projects/ProjectSettingsPanel.test.tsx b/frontend/src/features/projects/ProjectSettingsPanel.test.tsx index 6d1ad57e..fd940b0c 100644 --- a/frontend/src/features/projects/ProjectSettingsPanel.test.tsx +++ b/frontend/src/features/projects/ProjectSettingsPanel.test.tsx @@ -19,6 +19,23 @@ function deferred() { } describe("ProjectSettingsPanel", () => { + it("composes project plugin activation between appearance and context", async () => { + const gateways = createMockGateways(); + const project = await gateways.project.createProject("Alpha", "/alpha"); + + render( + + + , + ); + + 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"); diff --git a/frontend/src/features/projects/ProjectSettingsPanel.tsx b/frontend/src/features/projects/ProjectSettingsPanel.tsx index 7c57cd49..9edce4e0 100644 --- a/frontend/src/features/projects/ProjectSettingsPanel.tsx +++ b/frontend/src/features/projects/ProjectSettingsPanel.tsx @@ -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 }) { )} + +

    )} + {pluginMenus.commandError && ( +
    + + {tr("L’action du plugin a échoué.", "The plugin action failed.")} {pluginMenus.commandError.message} + +
    + + +
    +
    + )} {/* ── Project tab bar (#26): tabs = open projects, "+" opens Projects ── */} ([]); 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])), diff --git a/frontend/src/shared/ui/SmallDropdown.tsx b/frontend/src/shared/ui/SmallDropdown.tsx index 25a2943e..e90546fc 100644 --- a/frontend/src/shared/ui/SmallDropdown.tsx +++ b/frontend/src/shared/ui/SmallDropdown.tsx @@ -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", )} > {option.label}