From db67ecdf8fcf0c1bf8974e8527c25f064fa42a38 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 12 Sep 2026 01:14:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(domain,application,backend,app-tauri,front?= =?UTF-8?q?end):=20lot=20utilisateur=20#328=20#329=20#330=20#331=20?= =?UTF-8?q?=E2=80=94=20section=20plugins=20projet,=20erreurs=20menus=20plu?= =?UTF-8?q?gin=20visibles,=20refresh=20auto=20des=20pages,=20droplist=20ag?= =?UTF-8?q?ents=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 { - 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, - }} - > - - {agents.map((a) => { - const elsewhere = visibleElsewhere(a.id); - const background = backgroundLive(a.id); - return ( - - ); - })} - + 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}