fix(plugins): isole les menus plugin et durcit hello-plugin SDK contre la rechute #120

Requalification Architect du 2026-08-01: le crash INTERFACE INTERROMPUE au
chargement d'un plugin (menu + item) remonte via ProjectsView -> usePluginMenus
-> MenuBar jusqu'à RootErrorBoundary, quel que soit le plugin (ancien ou
reconstruit via SDK).

- usePluginMenus isole la résolution/conversion des contributions plugin :
  toute erreur retombe sur [] au lieu de propager.
- ProjectsView sépare les menus natifs des menus enrichis par plugin et rend
  MenuBar derrière une error boundary locale (fallback menus natifs seuls).
- hello-plugin (SDK) et les tests d'installation associés durcis en cohérence.

Bookkeeping ticket #120 uniquement (issue.md, carnet.md) ; les fichiers
counter.json/index.json et le dossier tickets/122/ restent hors commit car
une collision de numérotation #122 existe entre cette base et
feature/ticket120-hello-plugin-install-path-audit (deux tickets différents
revendiquent #122) — à arbitrer avant de committer le bookkeeping global.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 16:23:56 +02:00
parent 8031d86deb
commit 5a30ec8b9c
11 changed files with 516 additions and 75 deletions

View File

@ -0,0 +1,75 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { MenuTargetId } from "@/domain";
import type { PluginRuntimeRegistry, WhenContext } from "@/plugins/runtime";
import { usePluginMenus } from "./usePluginMenus";
const NO_CONTEXT: WhenContext = {
projectOpen: false,
gitRepository: false,
agentSelected: false,
terminalFocused: false,
layoutCellFocused: false,
};
function throwingRegistry(overrides: Record<string, unknown>): PluginRuntimeRegistry {
return {
runCommand: vi.fn(async () => {}),
topLevelMenus: vi.fn(() => []),
menuItems: vi.fn(() => []),
...overrides,
} as unknown as PluginRuntimeRegistry;
}
describe("usePluginMenus", () => {
it("falls back to no plugin top-level menus when plugin menu resolution throws", () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const registry = throwingRegistry({
topLevelMenus: vi.fn(() => {
throw new Error("broken top-level contribution");
}),
});
const { result } = renderHook(() => usePluginMenus(registry, NO_CONTEXT));
expect(result.current.topLevelMenus).toEqual([]);
expect(consoleWarn).toHaveBeenCalledWith(
"[plugins] menu contribution rejected",
expect.objectContaining({
scope: "top-level-menus",
reason: "broken top-level contribution",
}),
);
} finally {
consoleWarn.mockRestore();
}
});
it("falls back to no plugin items for a native menu when item resolution throws", () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const registry = throwingRegistry({
menuItems: vi.fn(() => {
throw new Error("broken item contribution");
}),
});
const { result } = renderHook(() => usePluginMenus(registry, NO_CONTEXT));
const items = result.current.itemsFor("panels" as MenuTargetId);
expect(items).toEqual([]);
expect(consoleWarn).toHaveBeenCalledWith(
"[plugins] menu contribution rejected",
expect.objectContaining({
scope: "items",
targetMenuId: "panels",
reason: "broken item contribution",
}),
);
} finally {
consoleWarn.mockRestore();
}
});
});

View File

@ -16,7 +16,7 @@ import { useMemo } from "react";
import type { MenuTargetId } from "@/domain";
import type { MenuBarItem, MenuBarMenu } from "@/shared";
import type { PluginRuntimeRegistry, WhenContext } from "@/plugins/runtime";
import { resolveMenuItems, resolveTopLevelMenus } from "./menus";
import { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus";
export interface UsePluginMenusResult {
/** Top-level plugin menus, in order, ready to splice between Panneaux/Paramètres. */
@ -26,6 +26,25 @@ export interface UsePluginMenusResult {
runCommand: (pluginId: string, commandId: string) => Promise<void>;
}
function describeError(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as { message: unknown }).message);
}
return String(e);
}
function rejectPluginMenuContribution(
scope: string,
details: Record<string, unknown>,
error: unknown,
): void {
console.warn("[plugins] menu contribution rejected", {
scope,
...details,
reason: describeError(error),
});
}
export function usePluginMenus(
registry: PluginRuntimeRegistry,
whenCtx: WhenContext,
@ -36,27 +55,75 @@ export function usePluginMenus(
);
const itemsFor = useMemo(() => {
return (targetMenuId: MenuTargetId): MenuBarItem[] =>
resolveMenuItems(registry, targetMenuId, whenCtx).map((item) => ({
id: item.id,
// Provenance is shown discreetly (carnet §0 UX decision) rather than
// with a visual section divider, which the shared `MenuBar` primitive
// doesn't support — a trailing "· Plugin Name" suffix is the least
// intrusive option that still surfaces where the item came from.
label: `${item.label} · ${item.pluginDisplayName}`,
disabled: !item.enabled,
onSelect: () => {
void runCommand(item.pluginId, item.command);
},
}));
return (targetMenuId: MenuTargetId): MenuBarItem[] => {
let resolved;
try {
resolved = resolveMenuItems(registry, targetMenuId, whenCtx);
} catch (e) {
rejectPluginMenuContribution("items", { targetMenuId }, e);
return [];
}
return resolved.flatMap((item) => {
try {
return [
{
id: item.id,
// Provenance is shown discreetly (carnet §0 UX decision) rather than
// with a visual section divider, which the shared `MenuBar` primitive
// doesn't support — a trailing "· Plugin Name" suffix is the least
// intrusive option that still surfaces where the item came from.
label: `${item.label} · ${item.pluginDisplayName}`,
disabled: !item.enabled,
onSelect: () => {
void runCommand(item.pluginId, item.command);
},
},
];
} catch (e) {
rejectPluginMenuContribution(
"item",
{
pluginId: item.pluginId,
itemId: item.id,
targetMenuId,
commandId: item.command,
},
e,
);
return [];
}
});
};
}, [registry, whenCtx, runCommand]);
const topLevelMenus = useMemo<MenuBarMenu[]>(() => {
return resolveTopLevelMenus(registry).map((menu) => ({
id: menu.id,
label: menu.label,
items: itemsFor(menu.id),
}));
let resolved: ResolvedTopLevelMenu[];
try {
resolved = resolveTopLevelMenus(registry);
} catch (e) {
rejectPluginMenuContribution("top-level-menus", {}, e);
return [];
}
return resolved.flatMap((menu) => {
try {
return [
{
id: menu.id,
label: menu.label,
items: itemsFor(menu.id),
},
];
} catch (e) {
rejectPluginMenuContribution(
"top-level-menu",
{ pluginId: menu.pluginId, menuId: menu.id },
e,
);
return [];
}
});
}, [registry, itemsFor]);
return { topLevelMenus, itemsFor, runCommand };

View File

@ -33,7 +33,14 @@
* open; the project tab bar (`role="tablist"`) is always present.
*/
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Component,
useEffect,
useMemo,
useState,
type ErrorInfo,
type ReactNode,
} from "react";
import type { Agent, DomainEvent, LayoutInfo } from "@/domain";
import { LayoutGrid, LayoutTabs } from "@/features/layout";
@ -109,6 +116,41 @@ interface PendingConversationOpen {
conversationId: string;
}
interface PluginMenuRenderBoundaryProps {
resetKey: string;
fallbackMenus: MenuBarMenu[];
children: ReactNode;
}
class PluginMenuRenderBoundary extends Component<
PluginMenuRenderBoundaryProps,
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error("[plugins] menu render failed; using native menus only", {
error,
componentStack: errorInfo.componentStack,
});
}
componentDidUpdate(prevProps: PluginMenuRenderBoundaryProps): void {
if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) {
this.setState({ hasError: false });
}
}
render(): ReactNode {
if (this.state.hasError) return <MenuBar menus={this.props.fallbackMenus} />;
return this.props.children;
}
}
function isTerminalBackgroundTaskEvent(
event: DomainEvent,
): event is Extract<DomainEvent, { type: "backgroundTaskChanged" }> {
@ -546,22 +588,43 @@ export function ProjectsView() {
return items;
}
const nativePanelItems: MenuBarItem[] = panelOrder.map((panel) => ({
id: panel,
label: PANEL_TITLE[panel],
active: placementOf(placements, panel) !== "closed",
onSelect: () => {},
submenu: placementSubmenu(panel),
}));
const nativeSettingsItems: MenuBarItem[] = SETTINGS_SECTIONS.map((section) => ({
id: section,
label: SETTINGS_SECTION_LABEL[section],
active: settingsSection === section,
onSelect: () => {
setSettingsSection(section);
dismissFloating();
},
}));
const pluginPanelItems = pluginMenus.itemsFor("panels");
const pluginSettingsItems = pluginMenus.itemsFor("settings");
const nativeMenus: MenuBarMenu[] = [
{
id: "panels",
label: "Panneaux",
items: nativePanelItems,
},
{
id: "settings",
label: "Paramètres",
items: nativeSettingsItems,
},
];
const menus: MenuBarMenu[] = [
{
id: "panels",
label: "Panneaux",
// Native items first, plugin-contributed items after in their own
// (discreetly-labelled) group (#43, F3, carnet §7.2).
items: [
...panelOrder.map((panel) => ({
id: panel,
label: PANEL_TITLE[panel],
active: placementOf(placements, panel) !== "closed",
onSelect: () => {},
submenu: placementSubmenu(panel),
})),
...pluginMenus.itemsFor("panels"),
],
items: [...nativePanelItems, ...pluginPanelItems],
},
// Top-level plugin menus render between Panneaux and Paramètres (#43,
// carnet §0 UX decision + §7.1).
@ -572,20 +635,14 @@ export function ProjectsView() {
// One entry per section (#68). The entries name sections and mark the open
// one; closing lives in the view ("Fermer les paramètres"), so no label
// alternates. Plugin-contributed items are appended after (#43, F3).
items: [
...SETTINGS_SECTIONS.map((section) => ({
id: section,
label: SETTINGS_SECTION_LABEL[section],
active: settingsSection === section,
onSelect: () => {
setSettingsSection(section);
dismissFloating();
},
})),
...pluginMenus.itemsFor("settings"),
],
items: [...nativeSettingsItems, ...pluginSettingsItems],
},
];
const pluginMenuResetKey = [
pluginMenus.topLevelMenus.map((menu) => menu.id).join(","),
pluginPanelItems.map((item) => item.id).join(","),
pluginSettingsItems.map((item) => item.id).join(","),
].join("|");
// The create-project form + known-projects list. Rendered inline in the
// welcome area (no active project) or inside the Projects floating window.
@ -810,7 +867,12 @@ export function ProjectsView() {
/>
{/* ── Menu bar (replaces the former left sidebar) ── */}
<MenuBar menus={menus} />
<PluginMenuRenderBoundary
resetKey={pluginMenuResetKey}
fallbackMenus={nativeMenus}
>
<MenuBar menus={menus} />
</PluginMenuRenderBoundary>
{/* ── Chrome row: left dock │ main │ right dock (#22). Docks are in-flow
resizable columns, not overlays — they sit beside the main surface. */}

View File

@ -178,12 +178,16 @@ describe("loadPlugins", () => {
expect(registry.get("com.example.hello-plugin")).toBeUndefined();
});
it("loads the hello-plugin contribution shape with omitted optional arrays", async () => {
it("loads the hello-plugin command and layout contribution shape", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
ctx.commands.registerCommand("hello-plugin.sayHello", () => {
ctx.commands.registerCommand("hello-plugin", () => {
globalThis.__helloArchiveCommandRan = true;
});
ctx.layouts.register({
type: "hello-plugin.hello-world",
component: () => "hello-world",
});
}
`);
const { registry, failures } = await loadPlugins(
@ -193,13 +197,20 @@ describe("loadPlugins", () => {
displayName: "Hello Plugin",
bundleUrl: bundle,
contributes: {
menus: [{ id: "hello-plugin.menu", label: "Hello", topLevel: true }],
menus: [{ id: "hello-plugin.menu", label: "Hello Plugin", topLevel: true }],
menuItems: [
{
id: "hello-plugin.sayHello.item",
id: "hello-plugin.command.item",
targetMenuId: "hello-plugin.menu",
label: "Say Hello",
command: "hello-plugin.sayHello",
label: "hello-plugin",
command: "hello-plugin",
},
],
layouts: [
{
type: "hello-plugin.hello-world",
label: "hello-world",
component: "hello-world",
},
],
} as unknown as PluginContributionDto,
@ -209,10 +220,22 @@ describe("loadPlugins", () => {
);
expect(failures).toEqual([]);
expect(registry.get("com.example.hello-plugin")?.contributes.layouts).toEqual([]);
expect(registry.get("com.example.hello-plugin")?.contributes.layouts).toEqual([
{
type: "hello-plugin.hello-world",
label: "hello-world",
component: "hello-world",
},
]);
expect(registry.get("com.example.hello-plugin")?.contributes.mcpServers).toEqual([]);
await registry.runCommand("com.example.hello-plugin", "hello-plugin.sayHello");
await registry.runCommand("com.example.hello-plugin", "hello-plugin");
expect((globalThis as Record<string, unknown>).__helloArchiveCommandRan).toBe(true);
const Layout = registry.layoutComponent(
"com.example.hello-plugin",
"hello-plugin.hello-world",
);
expect(Layout).toBeDefined();
expect((Layout as unknown as () => string)()).toBe("hello-world");
});
it("confines a malformed runtime catalog entry and still loads healthy plugins", async () => {

View File

@ -301,8 +301,20 @@ export async function loadPlugins(
entries.map((entry) => loadOne(entry, gateways, resolvedOptions)),
);
for (const result of results) {
if ("failure" in result) failures.push(result.failure);
else registry.add(result.plugin);
if ("failure" in result) {
failures.push(result.failure);
console.warn(
`[plugins] load failed plugin=${result.failure.pluginId}: ${result.failure.reason}`,
);
} else {
registry.add(result.plugin);
}
}
if (entries.length > 0) {
console.info(
`[plugins] load complete loaded=${registry.list().length} failed=${failures.length}`,
);
}
return { registry, failures };