feat(frontend): système de plugins — runtime, menus, layouts custom (#43)

Lots F1-F4 : runtime de chargement/registre plugin, extension des menus
existants, panneau de gestion des plugins, types de layout custom
(sélecteur, fallback, cellule dédiée) branchés sur le port plugin.
Suite npm typecheck/test verte (947/947).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 07:37:11 +02:00
parent bb35641715
commit ac726d075e
41 changed files with 3245 additions and 24 deletions

View File

@ -38,6 +38,7 @@ import {
TargetAnnouncementsOverlay,
useTargetAnnouncements,
} from "@/features/announcements";
import { PluginLayoutCellView } from "@/features/plugins";
import {
modelServerOverlayText,
describeModelServerDownload,
@ -79,9 +80,21 @@ interface LayoutGridProps {
layoutId?: string;
/** Opens the read-only canonical transcript for a conversation. */
onOpenConversation?: (conversationId: string) => void;
/**
* Navigates to `Paramètres > Plugins` (#43, F4) — the "Ouvrir Plugins" action
* of a plugin layout's unavailable fallback. Threaded down to
* `PluginLayoutCellView` the same way `onOpenConversation` is.
*/
onOpenPluginsSettings?: () => void;
}
export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation }: LayoutGridProps) {
export function LayoutGrid({
projectId,
cwd,
layoutId,
onOpenConversation,
onOpenPluginsSettings,
}: LayoutGridProps) {
const vm = useLayout(projectId, layoutId);
const work = useProjectWorkState(projectId);
@ -117,6 +130,7 @@ export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation }: Lay
workState={work.state}
refreshWorkState={work.refresh}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
</div>
);
@ -132,6 +146,7 @@ interface NodeViewProps {
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void;
}
function NodeView({
@ -143,8 +158,24 @@ function NodeView({
workState,
refreshWorkState,
onOpenConversation,
onOpenPluginsSettings,
}: NodeViewProps) {
switch (node.type) {
case "customPluginLayout":
// A true top-level `LayoutNode` variant (#43, F4, carnet v2 §3) — a
// plugin layout occupies a slot in the tree at the same level as a
// terminal leaf, split or grid. Rendered separately from `LeafView`
// (which owns a lot of terminal-only concerns — write-portal,
// model-server overlay, agent dropdown — none of which apply here).
return (
<PluginLayoutCellView
projectId={projectId}
cell={node.node}
onStateChange={(nextState) => vm.setPluginLayoutState(node.node.id, nextState)}
onOpenPlugins={() => onOpenPluginsSettings?.()}
onChooseAnotherLayout={() => vm.replacePluginLayoutWithTerminal(node.node.id)}
/>
);
case "leaf":
return (
<LeafView
@ -172,6 +203,7 @@ function NodeView({
workState={workState}
refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
);
case "grid":
@ -184,6 +216,7 @@ function NodeView({
workState={workState}
refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
);
}
@ -1153,6 +1186,7 @@ interface SplitViewProps {
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void;
}
function SplitView({
@ -1163,6 +1197,7 @@ function SplitView({
workState,
refreshWorkState,
onOpenConversation,
onOpenPluginsSettings,
}: SplitViewProps) {
const isRow = split.direction === "row";
const baseWeights = split.children.map((c) => c.weight);
@ -1207,6 +1242,7 @@ function SplitView({
workState={workState}
refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
parentSplit={{
container: split.id,
index: i,
@ -1301,6 +1337,7 @@ interface GridViewProps {
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void;
}
function GridView({
@ -1311,6 +1348,7 @@ function GridView({
workState,
refreshWorkState,
onOpenConversation,
onOpenPluginsSettings,
}: GridViewProps) {
const cols = normalizeWeights(grid.colWeights)
.map((p) => `${p}fr`)
@ -1351,6 +1389,7 @@ function GridView({
workState={workState}
refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
</div>
))}

View File

@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react";
import type { LayoutInfo } from "@/domain";
import { cn } from "@/shared";
import { PluginLayoutSelectorSection, usePluginRuntime } from "@/features/plugins";
import { useLayouts } from "./useLayouts";
interface LayoutTabsProps {
@ -43,6 +44,10 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
const renameInputRef = useRef<HTMLInputElement | null>(null);
// Show/hide the create-kind dropdown.
const [showCreateMenu, setShowCreateMenu] = useState(false);
const { registry: pluginRegistry } = usePluginRuntime();
// Plugin layouts need a `create_layout` backend extension that doesn't exist
// yet (#43, F4 open point) — surfaced rather than silently swallowed.
const [pluginLayoutNotice, setPluginLayoutNotice] = useState<string | null>(null);
async function handleSelect(id: string) {
// The effect above propagates the new active layout (id + kind) to the parent.
@ -196,13 +201,22 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
>
Git graph
</button>
<PluginLayoutSelectorSection
registry={pluginRegistry}
onSelect={(choice) => {
setShowCreateMenu(false);
setPluginLayoutNotice(
`« ${choice.layout.label} » (${choice.pluginDisplayName}) : la création de layouts plugins nécessite une extension backend pas encore livrée.`,
);
}}
/>
</div>
)}
</div>
{vm.error && (
{(vm.error || pluginLayoutNotice) && (
<span className="ml-2 text-xs text-danger" role="alert">
{vm.error}
{vm.error ?? pluginLayoutNotice}
</span>
)}
</div>

View File

@ -13,7 +13,9 @@ import {
droppedSessions,
leaves,
normalizeWeights,
replaceCustomPluginLayoutWithTerminal,
resizeAdjacent,
setCustomPluginLayoutState,
singleLeafTree,
splitOp,
} from "./layout";
@ -403,3 +405,109 @@ describe("splitOp", () => {
}
});
});
// #43, F4 — the `customPluginLayout` top-level `LayoutNode` variant, cadré
// (carnet v2 §3.2) as the exact backend serde shape
// (`#[serde(tag = "type", content = "node")]`, camelCase). These JSON literals
// are the carnet's own worked examples, parsed with `JSON.parse` + a type
// assertion (never hand-built as TS object literals) so this test actually
// exercises "a layout JSON produced by Rust", not just the TS type shape.
describe("customPluginLayout — parsing a backend-shaped JSON tree", () => {
const ROOT_PLUGIN_LAYOUT_JSON = `{
"root": {
"type": "customPluginLayout",
"node": {
"id": "018f0c5a-2b4b-70d4-a7c2-300000000001",
"pluginId": "dev.acme.gitgraph",
"layoutType": "dev.acme.gitgraph.layout",
"state": { "branchFilter": "main" }
}
}
}`;
const SPLIT_WITH_PLUGIN_LAYOUT_JSON = `{
"root": {
"type": "split",
"node": {
"id": "split-1",
"direction": "row",
"children": [
{
"weight": 1,
"node": { "type": "leaf", "node": { "id": "terminal-1" } }
},
{
"weight": 1,
"node": {
"type": "customPluginLayout",
"node": {
"id": "plugin-cell-1",
"pluginId": "dev.acme.gitgraph",
"layoutType": "dev.acme.gitgraph.layout",
"state": {}
}
}
}
]
}
}
}`;
it("parses a root-level customPluginLayout node with its opaque state intact", () => {
const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
expect(tree.root.type).toBe("customPluginLayout");
if (tree.root.type !== "customPluginLayout") throw new Error("unreachable");
expect(tree.root.node).toEqual({
id: "018f0c5a-2b4b-70d4-a7c2-300000000001",
pluginId: "dev.acme.gitgraph",
layoutType: "dev.acme.gitgraph.layout",
state: { branchFilter: "main" },
});
});
it("parses a customPluginLayout node nested as a split child alongside a terminal leaf", () => {
const tree = JSON.parse(SPLIT_WITH_PLUGIN_LAYOUT_JSON) as LayoutTree;
expect(tree.root.type).toBe("split");
if (tree.root.type !== "split") throw new Error("unreachable");
const [terminalChild, pluginChild] = tree.root.node.children;
expect(terminalChild.node).toEqual({ type: "leaf", node: { id: "terminal-1" } });
expect(pluginChild.node.type).toBe("customPluginLayout");
if (pluginChild.node.type !== "customPluginLayout") throw new Error("unreachable");
expect(pluginChild.node.node.pluginId).toBe("dev.acme.gitgraph");
});
it("leaves() does not pick up a customPluginLayout node as a terminal leaf", () => {
const tree = JSON.parse(SPLIT_WITH_PLUGIN_LAYOUT_JSON) as LayoutTree;
expect(leaves(tree).map((l) => l.id)).toEqual(["terminal-1"]);
});
it("setCustomPluginLayoutState patches only the matching node's state, leaving the rest of the tree untouched", () => {
const tree = JSON.parse(SPLIT_WITH_PLUGIN_LAYOUT_JSON) as LayoutTree;
const updated = setCustomPluginLayoutState(tree, "plugin-cell-1", { branchFilter: "feature/x" });
if (updated.root.type !== "split") throw new Error("unreachable");
const [terminalChild, pluginChild] = updated.root.node.children;
expect(terminalChild.node).toEqual({ type: "leaf", node: { id: "terminal-1" } });
if (pluginChild.node.type !== "customPluginLayout") throw new Error("unreachable");
expect(pluginChild.node.node.state).toEqual({ branchFilter: "feature/x" });
expect(pluginChild.node.node.id).toBe("plugin-cell-1");
expect(pluginChild.node.node.pluginId).toBe("dev.acme.gitgraph");
});
it("setCustomPluginLayoutState is a no-op when the node id isn't found", () => {
const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
const updated = setCustomPluginLayoutState(tree, "does-not-exist", { x: 1 });
expect(updated).toEqual(tree);
});
it("replaceCustomPluginLayoutWithTerminal swaps the node for a blank terminal leaf of the same id", () => {
const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
const updated = replaceCustomPluginLayoutWithTerminal(
tree,
"018f0c5a-2b4b-70d4-a7c2-300000000001",
);
expect(updated.root).toEqual({
type: "leaf",
node: { id: "018f0c5a-2b4b-70d4-a7c2-300000000001" },
});
});
});

View File

@ -91,6 +91,10 @@ function mapNode(node: LayoutNode, f: (n: LayoutNode) => LayoutNode): LayoutNode
case "leaf":
rebuilt = node;
break;
case "customPluginLayout":
// No children to recurse into — same leaf-like treatment as "leaf".
rebuilt = node;
break;
case "split":
rebuilt = {
type: "split",
@ -319,6 +323,52 @@ export function droppedSessions(
return out;
}
/**
* Locally patches a `customPluginLayout` node's opaque `state` (#43, F4).
*
* Client-side only: the backend has no `LayoutOperation` variant to persist
* plugin layout state yet (carnet v2 §3.5 — no backend refonte expected for
* F4), so this does NOT call `LayoutGateway.mutateLayout`. It's the same
* "real, in-session, not yet cross-restart-persisted" contract every plugin
* component's `setState` gets: the tree re-renders with the new state
* immediately, but a reload re-fetches the last **persisted** value from the
* backend. Returns `tree` unchanged if no such node is found.
*/
export function setCustomPluginLayoutState(
tree: LayoutTree,
nodeId: string,
state: unknown,
): LayoutTree {
return {
root: mapNode(tree.root, (n) => {
if (n.type === "customPluginLayout" && n.node.id === nodeId) {
return { type: "customPluginLayout", node: { ...n.node, state } };
}
return n;
}),
};
}
/**
* Locally replaces a `customPluginLayout` node with a blank terminal leaf of
* the same id (#43, F4 "Choisir un autre layout" fallback action). Same
* client-side-only contract as {@link setCustomPluginLayoutState} — no
* backend operation exists to persist the node-kind change yet.
*/
export function replaceCustomPluginLayoutWithTerminal(
tree: LayoutTree,
nodeId: string,
): LayoutTree {
return {
root: mapNode(tree.root, (n) => {
if (n.type === "customPluginLayout" && n.node.id === nodeId) {
return { type: "leaf", node: { id: nodeId } };
}
return n;
}),
};
}
/** Convenience: builds a `split` operation splitting `target` in `direction`. */
export function splitOp(target: string, direction: Direction): LayoutOperation {
return {

View File

@ -17,7 +17,12 @@ import type {
LayoutTree,
} from "@/domain";
import { useGateways } from "@/app/di";
import { leaves, splitOp } from "./layout";
import {
leaves,
replaceCustomPluginLayoutWithTerminal,
setCustomPluginLayoutState,
splitOp,
} from "./layout";
/** What the layout grid UI needs from this hook. */
export interface LayoutViewModel {
@ -59,6 +64,19 @@ export interface LayoutViewModel {
* to persist the id assigned at first launch so the next open resumes it.
*/
setCellConversation: (target: string, conversationId: string | null) => Promise<void>;
/**
* Patches a `customPluginLayout` node's opaque state locally (#43, F4) —
* in-session only, no backend `LayoutOperation` for this exists yet (carnet
* v2 §3.5). The tree re-renders immediately; a reload re-fetches the last
* value actually persisted by the backend.
*/
setPluginLayoutState: (nodeId: string, state: unknown) => void;
/**
* "Choisir un autre layout" fallback action (#43, F4): locally swaps a
* `customPluginLayout` node for a blank terminal leaf of the same id.
* Same client-side-only contract as {@link setPluginLayoutState}.
*/
replacePluginLayoutWithTerminal: (nodeId: string) => void;
}
function describe(e: unknown): string {
@ -246,6 +264,19 @@ export function useLayout(
[mutate],
);
const setPluginLayoutState = useCallback(
(nodeId: string, state: unknown) => {
setLayout((prev) => (prev ? setCustomPluginLayoutState(prev, nodeId, state) : prev));
setLayoutVersion((v) => v + 1);
},
[],
);
const replacePluginLayoutWithTerminal = useCallback((nodeId: string) => {
setLayout((prev) => (prev ? replaceCustomPluginLayoutWithTerminal(prev, nodeId) : prev));
setLayoutVersion((v) => v + 1);
}, []);
return {
layout,
layoutVersion,
@ -259,5 +290,7 @@ export function useLayout(
setCellAgent,
attachLiveAgentToCell,
setCellConversation,
setPluginLayoutState,
replacePluginLayoutWithTerminal,
};
}