diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx
index f16908d9..ff54dc7c 100644
--- a/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx
+++ b/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx
@@ -11,12 +11,16 @@ import {
import { DIProvider } from "@/app/di";
import type {
CustomPluginLayoutCell,
+ PluginContributionDto,
PluginRuntimeContributionCatalog,
PluginRuntimePlugin,
} from "@/domain";
+import type { WhenContext } from "@/plugins/runtime";
+import { MenuBar } from "@/shared";
import { usePluginRuntime } from "./PluginRuntimeProvider";
import { PluginLayoutCellView } from "./PluginLayoutCellView";
import { PluginRuntimeProvider } from "./PluginRuntimeProvider";
+import { usePluginMenus } from "./usePluginMenus";
function dataUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
@@ -78,6 +82,28 @@ function RuntimeProbe({ onMount }: { onMount: () => void }) {
);
}
+const OPEN_PROJECT_CONTEXT: WhenContext = {
+ projectOpen: true,
+ gitRepository: true,
+ agentSelected: false,
+ terminalFocused: false,
+ layoutCellFocused: false,
+};
+
+function MenuRuntimeProbe() {
+ const { registry, loading } = usePluginRuntime();
+ const menus = usePluginMenus(registry, OPEN_PROJECT_CONTEXT);
+ return (
+ <>
+
+ {loading ? "loading" : (registry.list()[0]?.displayName ?? "none")}
+
+
+ {menus.commandError &&
{menus.commandError.message}
}
+ >
+ );
+}
+
describe("PluginRuntimeProvider soft refresh (#279)", () => {
it("reloads contributions after install and reload events without remounting its workspace", async () => {
const plugin = new MutableRuntimeCatalogGateway();
@@ -145,6 +171,86 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
expect(plugin.calls).toBe(4);
});
+ it("dispatches the V2 handler after plugin reload when menu and command ids stay unchanged (#336)", async () => {
+ const calls: string[] = [];
+ (globalThis as typeof globalThis & { __IDEA_MENU_RELOAD_CALLS__?: string[] })
+ .__IDEA_MENU_RELOAD_CALLS__ = calls;
+ const plugin = new MutableRuntimeCatalogGateway();
+ const system = new MockSystemGateway();
+ const focusedProject = new MockFocusedProjectGateway();
+ await focusedProject.setFocusedProject({ id: "project-1", name: "Alpha", root: "/alpha" });
+ const contributions: PluginContributionDto = {
+ menus: [{ id: "git-menu", label: "Git", topLevel: true, order: 10 }],
+ menuItems: [{
+ id: "open.item",
+ targetMenuId: "plugin:git-menu",
+ label: "Open repositories",
+ command: "git.open",
+ order: 10,
+ when: "projectOpen",
+ }],
+ layouts: [],
+ mcpServers: [],
+ };
+ const bundle = (version: string) => dataUrl(`
+ export function activate(ctx) {
+ ctx.subscriptions.push(ctx.commands.registerCommand("git.open", () => {
+ globalThis.__IDEA_MENU_RELOAD_CALLS__.push("${version}");
+ }));
+ }
+ `);
+ plugin.catalog = {
+ plugins: [runtimePlugin({
+ displayName: "Git runtime V1",
+ contentHash: "menu-v1",
+ bundleUrl: bundle("v1"),
+ contributes: contributions,
+ })],
+ };
+ const gateways = { ...createMockGateways(), plugin, system, focusedProject };
+
+ try {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(await screen.findByText("Git runtime V1")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Git" }));
+ fireEvent.click(screen.getByRole("button", { name: "Open repositories · Git runtime V1" }));
+ await waitFor(() => expect(calls).toEqual(["v1"]));
+
+ plugin.catalog = {
+ plugins: [runtimePlugin({
+ displayName: "Git runtime V2",
+ version: "2.0.0",
+ contentHash: "menu-v2",
+ bundleUrl: bundle("v2"),
+ contributes: contributions,
+ })],
+ };
+ system.emit({
+ type: "pluginReloaded",
+ pluginId: "dev.acme.refreshable",
+ oldContentHash: "menu-v1",
+ newContentHash: "menu-v2",
+ changed: true,
+ });
+
+ expect(await screen.findByText("Git runtime V2")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Git" }));
+ fireEvent.click(screen.getByRole("button", { name: "Open repositories · Git runtime V2" }));
+ await waitFor(() => expect(calls).toEqual(["v1", "v2"]));
+ expect(screen.queryByRole("alert")).toBeNull();
+ } finally {
+ delete (globalThis as typeof globalThis & { __IDEA_MENU_RELOAD_CALLS__?: string[] })
+ .__IDEA_MENU_RELOAD_CALLS__;
+ }
+ });
+
it("ignores a custom plugin layout ref when its contribution disappears", async () => {
const plugin = new MutableRuntimeCatalogGateway();
const system = new MockSystemGateway();
diff --git a/frontend/src/features/plugins/usePluginMenus.test.tsx b/frontend/src/features/plugins/usePluginMenus.test.tsx
index 4561acf3..22d2c1ce 100644
--- a/frontend/src/features/plugins/usePluginMenus.test.tsx
+++ b/frontend/src/features/plugins/usePluginMenus.test.tsx
@@ -107,8 +107,9 @@ describe("usePluginMenus", () => {
}
});
- it("keeps a missing command handler as a silent no-op", async () => {
+ it("surfaces a missing command handler after plugin reload", async () => {
const runCommandStrict = vi.fn();
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const registry = throwingRegistry({
runCommandStrict,
get: vi.fn(() => ({ commands: { has: () => false } })),
@@ -118,6 +119,43 @@ describe("usePluginMenus", () => {
await act(() => result.current.runCommand("dev.acme.git", "git.missing"));
expect(runCommandStrict).not.toHaveBeenCalled();
- expect(result.current.commandError).toBeNull();
+ expect(result.current.commandError).toEqual({
+ pluginId: "dev.acme.git",
+ commandId: "git.missing",
+ message: "Commande indisponible après le rechargement du plugin. Réessayez après avoir rechargé le plugin.",
+ });
+ expect(consoleError).toHaveBeenCalledWith(
+ "[plugins] menu command handler unavailable",
+ expect.objectContaining({ pluginId: "dev.acme.git", commandId: "git.missing" }),
+ );
+ consoleError.mockRestore();
+ });
+
+ it.each([
+ [
+ { status: "skipped", reason: "services-unavailable", message: "raw services payload" },
+ "Le service Git n’est pas disponible pour le moment. Réessayez dans quelques instants.",
+ ],
+ [
+ { status: "skipped", reason: "no-focused-project", message: "raw project payload" },
+ "Sélectionnez un projet pour utiliser cette commande.",
+ ],
+ [
+ { status: "failed", message: "raw internal failure payload" },
+ "La commande Git n’a pas pu être exécutée. Réessayez.",
+ ],
+ ])("maps structured command feedback to a minimal visible message", async (feedback, message) => {
+ const runCommandStrict = vi.fn(async () => feedback);
+ const registry = throwingRegistry({ runCommandStrict });
+ const { result } = renderHook(() => usePluginMenus(registry, NO_CONTEXT));
+
+ await act(() => result.current.runCommand("dev.idea.git-plugin", "git-plugin.open-dashboard"));
+
+ expect(result.current.commandError).toEqual({
+ pluginId: "dev.idea.git-plugin",
+ commandId: "git-plugin.open-dashboard",
+ message,
+ });
+ expect(result.current.commandError?.message).not.toContain("raw");
});
});
diff --git a/frontend/src/features/plugins/usePluginMenus.ts b/frontend/src/features/plugins/usePluginMenus.ts
index 4ca579f4..feadc722 100644
--- a/frontend/src/features/plugins/usePluginMenus.ts
+++ b/frontend/src/features/plugins/usePluginMenus.ts
@@ -14,6 +14,7 @@
import { useCallback, useMemo, useState } from "react";
import type { MenuTargetId } from "@/domain";
+import { useI18n } from "@/i18n";
import type { MenuBarItem, MenuBarMenu } from "@/shared";
import type { PluginRuntimeRegistry, WhenContext } from "@/plugins/runtime";
import { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus";
@@ -41,6 +42,43 @@ function describeError(e: unknown): string {
return String(e);
}
+function objectOrNull(value: unknown): Record | null {
+ return value !== null && typeof value === "object"
+ ? value as Record
+ : null;
+}
+
+function structuredCommandFeedbackMessage(
+ value: unknown,
+ tr: (french: string, english: string) => string,
+): string | null {
+ const feedback = objectOrNull(value);
+ if (!feedback) return null;
+ if (feedback.status === "failed") {
+ return tr(
+ "La commande Git n’a pas pu être exécutée. Réessayez.",
+ "The Git command could not be run. Try again.",
+ );
+ }
+ if (feedback.status !== "skipped") return null;
+ if (feedback.reason === "services-unavailable") {
+ return tr(
+ "Le service Git n’est pas disponible pour le moment. Réessayez dans quelques instants.",
+ "The Git service is currently unavailable. Try again in a few moments.",
+ );
+ }
+ if (feedback.reason === "no-focused-project") {
+ return tr(
+ "Sélectionnez un projet pour utiliser cette commande.",
+ "Select a project to use this command.",
+ );
+ }
+ return tr(
+ "La commande du plugin n’est pas disponible pour le moment. Réessayez.",
+ "The plugin command is currently unavailable. Try again.",
+ );
+}
+
function rejectPluginMenuContribution(
scope: string,
details: Record,
@@ -57,18 +95,32 @@ export function usePluginMenus(
registry: PluginRuntimeRegistry,
whenCtx: WhenContext,
): UsePluginMenusResult {
+ const { tr } = useI18n();
const [commandError, setCommandError] = useState(null);
const clearCommandError = useCallback(() => setCommandError(null), []);
const runCommand = useCallback(
async (pluginId: string, commandId: string): Promise => {
setCommandError(null);
+ if (!registry.get(pluginId)?.commands.has(commandId)) {
+ const failure = {
+ pluginId,
+ commandId,
+ message: tr(
+ "Commande indisponible après le rechargement du plugin. Réessayez après avoir rechargé le plugin.",
+ "Command unavailable after the plugin reload. Try again after reloading the plugin.",
+ ),
+ };
+ console.error("[plugins] menu command handler unavailable", failure);
+ setCommandError(failure);
+ return undefined;
+ }
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);
+ const result = await registry.runCommandStrict(pluginId, commandId);
+ const feedbackMessage = structuredCommandFeedbackMessage(result, tr);
+ if (feedbackMessage) {
+ setCommandError({ pluginId, commandId, message: feedbackMessage });
+ }
+ return result;
} catch (error) {
const failure = { pluginId, commandId, message: describeError(error) };
console.error("[plugins] menu command rejected", failure);
@@ -76,7 +128,7 @@ export function usePluginMenus(
return undefined;
}
},
- [registry],
+ [registry, tr],
);
const itemsFor = useMemo(() => {