merge feature/ticket120-plugin-menu-crash-isolation dans develop (#120: isolation menus plugin validee QA + requalification deploiement)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 16:54:36 +02:00
15 changed files with 637 additions and 81 deletions

View File

@ -1,6 +1,190 @@
---
issueRef: "#120"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1785534496259
version: 8
updatedBy: {"kind":"agent","agent_id":"dce19c75-9669-4e45-b8de-9950025157da"}
updatedAt: 1785596006134
---
---
issueRef: "#120"
version: 7
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1785595113109
---
# Carnet de suivi — hello-plugin
## Etat courant
- Ticket canonique: `#120`
- Ticket parent historique: `#43`
- Date de rechute confirmee: 2026-08-01
- Statut: investigation relancee sur rechute reelle post-rebuild SDK
- Severite: critique (l'UI d'IdeA tombe sur une erreur d'affichage lors de l'installation/chargement de `hello-plugin`)
## Constat central au 2026-08-01
Le probleme persiste meme apres reconstruction de `hello-plugin` comme plugin d'exemple 100% SDK.
Implication forte:
- la cause racine est probablement dans le systeme plugin / runtime frontend / chargement UI / integration layout-menu, et non dans l'ancien exemple `hello-plugin` uniquement.
## Trace utilisateur fournie le 2026-08-01
Message visible:
- `IdeA a rencontre une erreur d'affichage.`
- `L'application reste ouverte. Rechargez la fenetre apres avoir copie le diagnostic si le probleme doit etre investigue.`
Stack affichee (trace la plus recente):
```text
ST@tauri://localhost/assets/index-DxqF_K_z.js:88:33125
wT@tauri://localhost/assets/index-DxqF_K_z.js:88:32065
om@tauri://localhost/assets/index-DxqF_K_z.js:38:17019
oh@tauri://localhost/assets/index-DxqF_K_z.js:40:3141
Fb@tauri://localhost/assets/index-DxqF_K_z.js:40:39779
SC@tauri://localhost/assets/index-DxqF_K_z.js:40:39707
ic@tauri://localhost/assets/index-DxqF_K_z.js:40:39559
yh@tauri://localhost/assets/index-DxqF_K_z.js:40:35923
Bb@tauri://localhost/assets/index-DxqF_K_z.js:40:34872
E@tauri://localhost/assets/index-DxqF_K_z.js:25:1541
L@tauri://localhost/assets/index-DxqF_K_z.js:25:1903
wT@tauri://localhost/assets/index-DxqF_K_z.js:88:28284
div
div
div
MD@tauri://localhost/assets/index-DxqF_K_z.js:94:63541
main
div
div
G4@tauri://localhost/assets/index-DxqF_K_z.js:129:18726
div
div
yT@tauri://localhost/assets/index-DxqF_K_z.js:88:24863
BP@tauri://localhost/assets/index-DxqF_K_z.js:88:1862
ez@tauri://localhost/assets/index-DxqF_K_z.js:129:31967
mP@tauri://localhost/assets/index-DxqF_K_z.js:82:27658
Iz@tauri://localhost/assets/index-DxqF_K_z.js:129:83720
```
## Faits etablis avant cette rechute
- Un rebuild SDK de `hello-plugin` a ete realise pour produire un plugin d'exemple minimal mais fonctionnel.
- Des verifications de build, packaging et tests locaux ont ete annoncees vertes par DevFrontend.
- QA avait pu valider partiellement:
- l'artefact SDK se reconstruit
- le plugin s'installe cote runtime/backend
- IdeA charge reellement le bundle plugin via `idea-plugin://.../dist/index.js`
- QA n'avait pas pu valider de bout en bout la surface UI visible (menu `Hello Plugin`, entree `hello-plugin`, rendu `hello-world`) faute d'une session UI observable sans ambiguite.
## Reinterpretation apres rechute utilisateur
- L'absence de validation UI de bout en bout n'etait pas un detail: la rechute utilisateur montre que le crash survient bien dans le flux reel d'affichage, malgre un plugin reconstruit proprement.
- Le signal pointe desormais plus fortement vers un probleme dans la consommation frontend des contributions plugin que vers le contenu fonctionnel du plugin lui-meme.
## Cadrage UX du 2026-08-01
- Une contribution plugin invalide ou qui plante ne doit jamais faire tomber l'app entiere.
- Le fallback attendu est local a la surface plugin en faute:
- menu plugin en erreur -> menus natifs seuls
- layout plugin en erreur -> fallback local de layout indisponible
- `INTERFACE INTERROMPUE` doit rester reserve aux crashs shell irrecoverables.
- A terme, l'etat d'erreur plugin devrait rester visible dans la gestion des plugins plutot qu'etre seulement silencieux.
## Requalification Architect du 2026-08-01
- Fait cle: le meme hash de crash apparait avec deux plugins differents:
- ancien hello-plugin minimal (menu + item, sans layout utile)
- nouveau hello-plugin reconstruit via SDK (menu + item + layout)
- Denominateur commun probable: la contribution de menu, pas le layout.
- Zone la plus suspecte identifiee: `ProjectsView.tsx` sur l'injection de `pluginMenus` dans `<MenuBar>`.
- Point precis: le rendu des menus plugin est consomme dans l'app-shell sans isolation locale equivalente a celle deja ajoutee pour `PluginLayoutCellView`.
- Hypothese prioritaire: une entree de menu plugin ou son rendu dans `MenuBar` leve une erreur qui remonte jusqu'a `RootErrorBoundary`, produisant `INTERFACE INTERROMPUE`.
- Strate touchee: frontend prioritaire, pas de nouveau chantier backend requis pour cette cause racine.
## Verification DevFrontend du 2026-08-01
- Branche verifiee: `feature/ticket120-plugin-menu-crash-isolation`
- HEAD verifie: `5a30ec8`
- Verdict DevFrontend: le correctif existant couvre deja la rechute prioritaire sur le chemin `ProjectsView -> usePluginMenus -> MenuBar`.
- Aucun complement de code ajoute a ce stade.
- Verifications executees:
- `cd frontend && npx vitest run src/features/plugins/usePluginMenus.test.tsx src/plugins/runtime/loader.test.ts src/features/plugins/menus.test.ts` : OK (22 tests)
- `cd frontend && npx vitest run` : OK (114 fichiers, 1046 tests)
## Correctif systeme plugin/UI deja porte par la branche
### Cause probable retenue
Deux plugins differents declenchent le meme crash minifie apres installation. Le denominateur commun le plus probable est le chemin `ProjectsView -> usePluginMenus -> MenuBar`.
### Correctif applique
- `usePluginMenus` isole defensivement la resolution/conversion des contributions plugin.
- Si la resolution de menus ou d'items plugin throw, le hook renvoie `[]` pour la surface plugin concernee au lieu de propager l'erreur.
- `ProjectsView` separe les menus natifs des menus enrichis par plugin.
- `ProjectsView` rend `MenuBar` derriere une error boundary locale: si le rendu enrichi plante, fallback immediat sur les menus natifs seuls.
- Logs ajoutes:
- `[plugins] menu contribution rejected` avec le contexte utile quand disponible
- `[plugins] menu render failed; using native menus only`
### Fichiers touches
- `frontend/src/features/plugins/usePluginMenus.ts`
- `frontend/src/features/plugins/usePluginMenus.test.tsx`
- `frontend/src/features/projects/ProjectsView.tsx`
## Validation QA du 2026-08-01
- Branche validee: `feature/ticket120-plugin-menu-crash-isolation`
- HEAD valide: `5a30ec8b9c4758602cc96e72ffd69e415422cd85`
- Verdict QA courant: bug `INTERFACE INTERROMPUE` non reproduit par les validations reelles executees ici.
### Commandes executees
- `git -C /home/anthony/Documents/Projects/IdeA rev-parse HEAD`
- `npm --prefix /home/anthony/Documents/Projects/IdeA/sdk/IdeaSDK run package:hello-plugin`
- `cd /home/anthony/Documents/Projects/IdeA/frontend && npx vitest run src/features/plugins/menus.test.ts src/features/plugins/usePluginMenus.test.tsx src/features/plugins/plugins.test.tsx src/plugins/runtime/loader.test.ts`
- `cargo test -p infrastructure --test plugin_install_load -- --nocapture`
- `cargo test -p infrastructure extracts_archive_without_path_escape -- --nocapture`
- `cd /home/anthony/Documents/Projects/IdeA/frontend && npm run test:bundle-transport`
- `cd /home/anthony/Documents/Projects/IdeA/crates/app-tauri && NO_STRIP=true ../../frontend/node_modules/.bin/tauri build --bundles appimage`
### Resultats utiles
- archive SDK regeneree: `sdk/IdeaSDK/examples/hello-plugin/build/hello-plugin-0.1.0.zip`
- tests frontend cibles: OK (4 fichiers, 29 tests)
- tests backend d'installation/chargement plugin: OK (3 tests)
- AppImage rebuild: `target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`
### Limite de preuve restante
- Pas de harness e2e UI automatise ici pour cliquer le parcours Tauri/AppImage "Installer depuis une archive..." de bout en bout.
- La validation est donc reelle sur archive SDK, backend d'installation, non-regression frontend, et artefact packagé reconstruit, mais pas sur un clic UI automatise observable.
## Requalification Architect du 2026-08-01 (rechute post-rebuild)
### Fait determinant trouve par inspection directe des artefacts
Deux binaires AppImage distincts coexistent sur la machine, avec un ecart temporel et de contenu net:
| Fichier | mtime | sha256 (8 premiers car.) |
|---|---|---|
| `/home/anthony/Documents/IdeA_0.3.0_amd64.AppImage` (celui qu'IdeA fait tourner — cf. memoire `mcp-bridge-and-delegation-runtime-notes`) | 2026-07-24 15:09 | `61d499f2` |
| `target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage` (rebuild QA du jour) | 2026-08-01 16:37 | `9ad50c21` |
Le commit du correctif (`5a30ec8`, isolation `usePluginMenus`/`ProjectsView`) date du **2026-08-01 16:23:56**, soit **apres** le mtime du binaire `~/Documents`. Le binaire que l'utilisateur execute au quotidien (`~/Documents/IdeA_0.3.0_amd64.AppImage`) est donc anterieur de plusieurs jours au correctif et ne peut structurellement pas le contenir.
### Requalification de cause racine
- La rechute rapportee le 2026-08-01 par l'utilisateur est tres vraisemblablement un **artefact de deploiement**, pas une regression de code: le rebuild QA a produit un binaire correct dans `target/release/bundle/appimage/`, mais ce binaire n'a jamais remplace celui reellement lance par l'utilisateur (`~/Documents/IdeA_0.3.0_amd64.AppImage`).
- C'est exactement le piege deja documente en memoire projet (`mcp-bridge-and-delegation-runtime-notes`, section « Le binaire qui tourne = AppImage installee, pas les sources ») applique cette fois au correctif plugin plutot qu'au pont MCP.
- Le carnet QA ci-dessus ne mentionne a aucun moment le remplacement du binaire `~/Documents` ni le redemarrage d'IdeA sur ce binaire remplace — seule la production de l'artefact dans `target/release/bundle/` est tracee.
### Borne de correction attendue
- **Aucun nouveau code frontend ou backend n'est requis a ce stade.** Le correctif `5a30ec8` (isolation menus plugin) est deja en place et deja valide par tests reels (114 fichiers / 1046 tests vitest, tests backend d'installation/chargement).
- Action requise: deploiement, pas developpement — remplacer `~/Documents/IdeA_0.3.0_amd64.AppImage` par `target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage` (backup de l'ancien conseille, cf. convention memoire `*.old-<raison>fix`), relancer IdeA depuis ce binaire, puis reproduire exactement le parcours Parametres > Plugins > Installer depuis une archive > hello-plugin.
- Si la rechute persiste APRES ce remplacement effectif et un redemarrage complet d'IdeA, alors l'hypothese frontend doit etre rouverte avec un perimetre elargi au-dela de `ProjectsView -> usePluginMenus -> MenuBar`, en verifiant en priorite (deja inspectes le 2026-08-01, RAS a la lecture statique mais non exerces en e2e reel):
- `frontend/src/features/plugins/PluginsPanel.tsx` (flux `startInstallFromArchive` / `reviewArchive` / dialog de confirmation) — surface active au moment precis du clic « Installer »
- `frontend/src/features/plugins/PluginConfirmDialog.tsx`
- `frontend/src/features/plugins/PluginLayoutCellView.tsx` (isolation deja posee en amont de ce ticket, a re-verifier qu'elle couvre bien le layout du hello-plugin reconstruit)
- la place de `RootErrorBoundary` par rapport a ces surfaces, pour confirmer qu'aucun chemin ne la contourne
### Prochaine etape (remplace la precedente)
- Ne pas rouvrir de chantier de code avant d'avoir confirme que l'utilisateur reproduit sur le binaire effectivement a jour.
- Remplacement de binaire + retest = action Git/deploiement, a executer avant toute nouvelle investigation frontend.

View File

@ -2,17 +2,17 @@
id: "f4218553-680a-4fbb-9514-a96eab79b8d8"
number: 120
title: "Réinvestiguer linstallation de hello-plugin: écran noir / perte daffichage IdeA"
status: "open"
status: "inProgress"
priority: "critical"
sprint: null
links: [{"target":"#116","kind":"relatesTo"}]
links: [{"target":"#116","kind":"relatesTo"},{"target":"#43","kind":"relatesTo"}]
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"dce19c75-9669-4e45-b8de-9950025157da"}
createdAt: 1785534496259
updatedAt: 1785534496259
version: 1
updatedAt: 1785596006134
version: 8
---
## Constat utilisateur

View File

@ -0,0 +1,6 @@
---
issueRef: "#122"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1785592528432
---

View File

@ -0,0 +1,17 @@
---
id: "fa083793-48ae-417a-ab16-3813e22df2e3"
number: 122
title: "[Bug] Override des permissions defaut qui ne marche pas"
status: "open"
priority: "medium"
sprint: null
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
attachments: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1785592422173
updatedAt: 1785592528432
version: 3
---
J'ai l'impression que l'override des permissions systeme ne fonctionne pas. J'avais les permissions par defaut qui ne donnait pas les droits bash, et meme après avoir override ce parametre sur un de mes agents, il n'avait aps acces aux tools bash. Il y a eu acces une fois qu'avais mis le droit dans la config defaut

View File

@ -1,3 +1,3 @@
{
"nextNumber": 122
"nextNumber": 123
}

View File

@ -1556,7 +1556,7 @@
"issueRef": "#120",
"path": "120",
"title": "Réinvestiguer linstallation de hello-plugin: écran noir / perte daffichage IdeA",
"status": "open",
"status": "inProgress",
"priority": "critical",
"sprint": null,
"assignedAgentIds": [],
@ -1564,7 +1564,7 @@
"kind": "agent",
"agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641"
},
"updatedAt": 1785534496259
"updatedAt": 1785596006134
},
{
"issueRef": "#121",
@ -1579,6 +1579,21 @@
"agent_id": "dce19c75-9669-4e45-b8de-9950025157da"
},
"updatedAt": 1785534534027
},
{
"issueRef": "#122",
"path": "122",
"title": "[Bug] Override des permissions defaut qui ne marche pas",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"createdBy": {
"kind": "user"
},
"updatedAt": 1785592528432
}
]
}

View File

@ -123,6 +123,7 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() {
assert_eq!(result.plugin.display_name, "Hello Plugin");
assert_eq!(result.review.contribution_summary.top_level_menus, 1);
assert_eq!(result.review.contribution_summary.menu_items, 1);
assert_eq!(result.review.contribution_summary.layouts, 1);
assert!(app_data
.join("plugins/installed/com.example.hello-plugin/dist/index.js")
.is_file());
@ -139,10 +140,16 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() {
.starts_with("idea-plugin://com.example.hello-plugin/0.1.0/"));
assert!(plugin.bundle_url.ends_with("/dist/index.js"));
assert_eq!(plugin.contributes.menus[0].id, "hello-plugin.menu");
assert_eq!(plugin.contributes.menus[0].label.as_str(), "Hello Plugin");
assert_eq!(
plugin.contributes.menu_items[0].command.as_str(),
"hello-plugin.sayHello"
"hello-plugin"
);
assert_eq!(
plugin.contributes.layouts[0].layout_type.as_str(),
"hello-plugin.hello-world"
);
assert_eq!(plugin.contributes.layouts[0].label.as_str(), "hello-world");
let _ = fs::remove_dir_all(app_data);
}

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

View File

@ -1,6 +1,13 @@
# Hello Plugin
Minimal IdeA plugin example using the public SDK types.
Installable IdeA plugin example rebuilt from the public SDK types.
It exercises the current plugin primitives end to end:
- top-level menu: `Hello Plugin`;
- menu entry: `hello-plugin`;
- command: `hello-plugin`, returning `hello-world`;
- layout contribution: `hello-plugin.hello-world`, rendered as `hello-world`.
```sh
npm run typecheck:examples
@ -10,3 +17,15 @@ npm run package:hello-plugin
The installable archive is emitted at `examples/hello-plugin/build/hello-plugin-0.1.0.zip`.
It contains `idea-plugin.json` at the ZIP root and the compiled ESM entrypoint at
`dist/index.js`, matching the manifest `main` field.
## Diagnostics
During activation the plugin logs:
- whether the command and layout runtime registries are available;
- successful registration of the `hello-plugin` command;
- successful registration of the `hello-plugin.hello-world` layout;
- the first layout render, including project/node identifiers.
These messages are intentionally small and stable so installation, bundle import, activation and
layout rendering failures can be separated quickly in IdeA logs/devtools.

View File

@ -4,7 +4,7 @@
"displayName": "Hello Plugin",
"publisher": "IdeA Examples",
"version": "0.1.0",
"description": "Minimal IdeA plugin example.",
"description": "SDK example plugin for validating command, menu and layout loading.",
"main": "dist/index.js",
"engines": {
"idea": ">=0.1.0"
@ -17,16 +17,26 @@
"menus": [
{
"id": "hello-plugin.menu",
"label": "Hello",
"topLevel": true
"label": "Hello Plugin",
"topLevel": true,
"order": 100
}
],
"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",
"order": 10
}
],
"layouts": [
{
"type": "hello-plugin.hello-world",
"label": "hello-world",
"component": "hello-world",
"order": 10
}
]
}

View File

@ -1,15 +1,75 @@
import type { ActivateContext, IdeAPluginModule } from "@idea/plugin-sdk";
import type { ActivateContext, CommandDisposable, IdeAPluginModule } from "@idea/plugin-sdk";
const COMMAND_ID = "hello-plugin";
const LAYOUT_TYPE = "hello-plugin.hello-world";
type HelloPluginLayoutProps = {
projectId?: string;
nodeId?: string;
layoutType?: string;
state?: unknown;
};
type LayoutRegistry = {
register(definition: {
type: string;
component: (props: HelloPluginLayoutProps) => string;
}): CommandDisposable;
};
type HelloPluginContext = ActivateContext & {
layouts?: LayoutRegistry;
};
let hasLoggedFirstLayoutRender = false;
function HelloWorldLayout(props: HelloPluginLayoutProps): string {
if (!hasLoggedFirstLayoutRender) {
hasLoggedFirstLayoutRender = true;
console.info("[hello-plugin] layout first render", {
projectId: props.projectId,
nodeId: props.nodeId,
layoutType: props.layoutType,
hasState: props.state !== undefined
});
}
return "hello-world";
}
export function activate(ctx: ActivateContext): void {
ctx.logger.info("Hello from the IdeA hello plugin.");
const disposable = ctx.commands?.registerCommand("hello-plugin.sayHello", () => {
ctx.logger.info("Hello command executed.");
return "Hello from IdeA";
const pluginContext = ctx as HelloPluginContext;
ctx.logger.info("activating hello-plugin", {
pluginId: ctx.pluginId,
hasCommands: Boolean(pluginContext.commands),
hasLayouts: Boolean(pluginContext.layouts)
});
if (disposable) {
ctx.subscriptions.push(disposable);
const commandDisposable = pluginContext.commands?.registerCommand(COMMAND_ID, () => {
ctx.logger.info("command executed", { commandId: COMMAND_ID });
return "hello-world";
});
if (commandDisposable) {
ctx.subscriptions.push(commandDisposable);
ctx.logger.info("command registered", { commandId: COMMAND_ID });
} else {
ctx.logger.warn("command registry unavailable", { commandId: COMMAND_ID });
}
const layoutDisposable = pluginContext.layouts?.register({
type: LAYOUT_TYPE,
component: HelloWorldLayout
});
if (layoutDisposable) {
ctx.subscriptions.push(layoutDisposable);
ctx.logger.info("layout registered", {
layoutType: LAYOUT_TYPE,
component: "hello-world"
});
} else {
ctx.logger.warn("layout registry unavailable", { layoutType: LAYOUT_TYPE });
}
}
@ -18,4 +78,3 @@ const plugin: IdeAPluginModule = {
};
export default plugin;