feat(layout): supporte la création de layouts plugins (customPluginLayout)

Étend le flux de création de layout backend (DTO, usecases, store) et le
frontend (sélecteur, adaptateurs, LayoutTabs/LayoutGrid) pour permettre
d'ouvrir un layout déclaré par un plugin installé (ex. Android Health),
sans passer par le message bloquant "extension backend pas encore livrée".

Ticket #141 — QA vert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 18:34:21 +02:00
parent 168f93df78
commit 45617992ef
29 changed files with 907 additions and 99 deletions

View File

@ -26,6 +26,7 @@ import type {
LayoutList,
LayoutOperation,
LayoutTree,
PluginLayoutOrigin,
LocalModelServerConfig,
Memory,
MemoryIndexEntry,
@ -116,8 +117,15 @@ export class HttpLayoutGateway implements LayoutGateway {
listLayouts(projectId: string): Promise<LayoutList> {
return this.http.invoke<LayoutList>("list_layouts", { projectId });
}
createLayout(projectId: string, name: string, kind?: LayoutKind): Promise<{ layoutId: string }> {
return this.http.invoke<{ layoutId: string }>("create_layout", { request: { projectId, name, kind } });
createLayout(
projectId: string,
name: string,
kind?: LayoutKind,
pluginOrigin?: PluginLayoutOrigin,
): Promise<{ layoutId: string }> {
return this.http.invoke<{ layoutId: string }>("create_layout", {
request: { projectId, name, kind, pluginOrigin },
});
}
renameLayout(projectId: string, layoutId: string, name: string): Promise<void> {
return this.http.invoke<void>("rename_layout", { request: { projectId, layoutId, name } });

View File

@ -10,7 +10,13 @@
import { invoke } from "@tauri-apps/api/core";
import type { LayoutKind, LayoutList, LayoutOperation, LayoutTree } from "@/domain";
import type {
LayoutKind,
LayoutList,
LayoutOperation,
LayoutTree,
PluginLayoutOrigin,
} from "@/domain";
import type { LayoutGateway } from "@/ports";
export class TauriLayoutGateway implements LayoutGateway {
@ -30,9 +36,14 @@ export class TauriLayoutGateway implements LayoutGateway {
return invoke<LayoutList>("list_layouts", { projectId });
}
createLayout(projectId: string, name: string, kind?: LayoutKind): Promise<{ layoutId: string }> {
createLayout(
projectId: string,
name: string,
kind?: LayoutKind,
pluginOrigin?: PluginLayoutOrigin,
): Promise<{ layoutId: string }> {
return invoke<{ layoutId: string }>("create_layout", {
request: { projectId, name, kind },
request: { projectId, name, kind, pluginOrigin },
});
}

View File

@ -27,6 +27,7 @@ import type {
LayoutList,
LayoutOperation,
LayoutTree,
PluginLayoutOrigin,
LocalModelServerConfig,
ModelServerCommandPreview,
Memory,
@ -958,6 +959,7 @@ interface MockLayoutEntry {
id: string;
name: string;
kind: LayoutKind;
pluginOrigin?: PluginLayoutOrigin | null;
tree: LayoutTree;
}
@ -1028,14 +1030,38 @@ export class MockLayoutGateway implements LayoutGateway {
async listLayouts(projectId: string): Promise<LayoutList> {
const ps = this.getProjectLayouts(projectId);
const layouts: LayoutInfo[] = ps.layouts.map((l) => ({ id: l.id, name: l.name, kind: l.kind }));
const layouts: LayoutInfo[] = ps.layouts.map((l) => ({
id: l.id,
name: l.name,
kind: l.kind,
pluginOrigin: l.pluginOrigin,
}));
return { layouts, activeId: ps.activeId };
}
async createLayout(projectId: string, name: string, kind: LayoutKind = "terminal"): Promise<{ layoutId: string }> {
async createLayout(
projectId: string,
name: string,
kind: LayoutKind = "terminal",
pluginOrigin?: PluginLayoutOrigin,
): Promise<{ layoutId: string }> {
const ps = this.getProjectLayouts(projectId);
const layoutId = `layout-${Math.random().toString(36).slice(2, 10)}`;
ps.layouts.push({ id: layoutId, name, kind, tree: singleLeafTree() });
const tree =
kind === "plugin" && pluginOrigin
? {
root: {
type: "customPluginLayout" as const,
node: {
id: `plugin-cell-${Math.random().toString(36).slice(2, 10)}`,
pluginId: pluginOrigin.pluginId,
layoutType: pluginOrigin.layoutType,
state: null,
},
},
}
: singleLeafTree();
ps.layouts.push({ id: layoutId, name, kind, pluginOrigin: pluginOrigin ?? null, tree });
return { layoutId };
}

View File

@ -29,6 +29,7 @@ describe("createMockGateways", () => {
"plugin",
"pluginConfig",
"pluginEvents",
"pluginStorage",
"pluginTask",
"pluginToolchain",
"pluginWorkspace",

View File

@ -952,16 +952,24 @@ export type LayoutOperation =
| { type: "setSession"; target: string; session?: string | null }
| { type: "setCellAgent"; target: string; agent: string | null }
| { type: "setCellConversation"; target: string; conversationId: string | null }
| { type: "setAgentRunning"; target: string; running: boolean };
| { type: "setAgentRunning"; target: string; running: boolean }
| { type: "setPluginLayoutState"; target: string; state: unknown };
/** The kind of a named layout. */
export type LayoutKind = "terminal" | "gitGraph";
export type LayoutKind = "terminal" | "gitGraph" | "plugin";
/** Origin metadata required to create and render a plugin-provided layout. */
export interface PluginLayoutOrigin {
pluginId: string;
layoutType: string;
}
/** Named layout entry returned by `listLayouts`. */
export interface LayoutInfo {
id: string;
name: string;
kind: LayoutKind;
pluginOrigin?: PluginLayoutOrigin | null;
}
/**

View File

@ -179,7 +179,7 @@ function NodeView({
<PluginLayoutCellView
projectId={projectId}
cell={node.node}
onStateChange={(nextState) => vm.setPluginLayoutState(node.node.id, nextState)}
onStateChange={(nextState) => void vm.setPluginLayoutState(node.node.id, nextState)}
onOpenPlugins={() => onOpenPluginsSettings?.()}
onChooseAnotherLayout={() => vm.replacePluginLayoutWithTerminal(node.node.id)}
/>

View File

@ -45,9 +45,6 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
// 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.
@ -88,6 +85,18 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
}
}
async function handleCreatePluginLayout(
name: string,
pluginId: string,
layoutType: string,
) {
setShowCreateMenu(false);
const newId = await vm.create(name, "plugin", { pluginId, layoutType });
if (newId) {
await vm.setActive(newId);
}
}
if (vm.layouts.length === 0) return null;
return (
@ -203,20 +212,21 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
</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.`,
);
}}
onSelect={(choice) =>
void handleCreatePluginLayout(
choice.layout.label,
choice.pluginId,
choice.layout.type,
)
}
/>
</div>
)}
</div>
{(vm.error || pluginLayoutNotice) && (
{vm.error && (
<span className="ml-2 text-xs text-danger" role="alert">
{vm.error ?? pluginLayoutNotice}
{vm.error}
</span>
)}
</div>

View File

@ -499,6 +499,20 @@ describe("customPluginLayout — parsing a backend-shaped JSON tree", () => {
expect(updated).toEqual(tree);
});
it("applyOperation persists plugin layout state via setPluginLayoutState", () => {
const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
const updated = applyOperation(tree, {
type: "setPluginLayoutState",
target: "018f0c5a-2b4b-70d4-a7c2-300000000001",
state: { branchFilter: "release/1" },
});
expect(updated.root.type).toBe("customPluginLayout");
if (updated.root.type !== "customPluginLayout") throw new Error("unreachable");
expect(updated.root.node.state).toEqual({ branchFilter: "release/1" });
expect(updated.root.node.pluginId).toBe("dev.acme.gitgraph");
expect(updated.root.node.layoutType).toBe("dev.acme.gitgraph.layout");
});
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(

View File

@ -282,6 +282,18 @@ export function applyOperation(
if (!found) throw notFound(op.target);
return { root };
}
case "setPluginLayoutState": {
let found = false;
const root = mapNode(tree.root, (n) => {
if (n.type === "customPluginLayout" && n.node.id === op.target) {
found = true;
return { type: "customPluginLayout", node: { ...n.node, state: op.state } };
}
return n;
});
if (!found) throw notFound(op.target);
return { root };
}
}
}
@ -324,15 +336,10 @@ export function droppedSessions(
}
/**
* 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.
* Pure helper for patching a `customPluginLayout` node's opaque `state`.
* The runtime path persists the same change through `mutate_layout` with
* `setPluginLayoutState`; this helper remains useful for local tree transforms
* and focused parsing tests.
*/
export function setCustomPluginLayoutState(
tree: LayoutTree,

View File

@ -15,6 +15,14 @@ import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { Gateways } from "@/ports";
import { MockLayoutGateway, MockAgentGateway, MockTerminalGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { PluginRuntimeProvider } from "@/features/plugins";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
PluginMenuRegistry,
PluginRuntimeRegistry,
type LoadedPlugin,
} from "@/plugins/runtime";
import { LayoutTabs } from "./LayoutTabs";
// ---------------------------------------------------------------------------
@ -25,17 +33,45 @@ function renderTabs(
layout: MockLayoutGateway,
projectId = "p1",
onActiveLayoutChange = vi.fn(),
pluginRegistry?: PluginRuntimeRegistry,
) {
const gateways = {
layout,
terminal: new MockTerminalGateway(),
agent: new MockAgentGateway(),
} as unknown as Gateways;
return render(
const ui = (
<DIProvider gateways={gateways}>
<LayoutTabs projectId={projectId} onActiveLayoutChange={onActiveLayoutChange} />
</DIProvider>,
{pluginRegistry ? (
<PluginRuntimeProvider
value={{ registry: pluginRegistry, failures: [], pending: [], loading: false }}
>
<LayoutTabs projectId={projectId} onActiveLayoutChange={onActiveLayoutChange} />
</PluginRuntimeProvider>
) : (
<LayoutTabs projectId={projectId} onActiveLayoutChange={onActiveLayoutChange} />
)}
</DIProvider>
);
return render(ui);
}
function stubPlugin(pluginId: string, displayName: string, layoutType: string): LoadedPlugin {
const contributes = {
menus: [],
menuItems: [],
layouts: [{ type: layoutType, label: "Android Health", component: "AndroidHealth" }],
mcpServers: [],
};
return {
pluginId,
displayName,
contributes,
commands: new PluginCommandRegistry(pluginId, new Set()),
layouts: new PluginLayoutRegistry(pluginId, new Set([layoutType])),
menu: new PluginMenuRegistry(pluginId),
dispose: async () => {},
};
}
// ---------------------------------------------------------------------------
@ -68,6 +104,58 @@ describe("LayoutTabs — git graph kind", () => {
expect(created!.kind).toBe("gitGraph");
});
it("MockLayoutGateway.createLayout stores plugin origin and creates a plugin layout tree", async () => {
const layout = new MockLayoutGateway();
const pluginOrigin = {
pluginId: "dev.idea.android-plugin",
layoutType: "idea-android.health",
};
const { layoutId } = await layout.createLayout("p1", "Android Health", "plugin", pluginOrigin);
const { layouts } = await layout.listLayouts("p1");
const created = layouts.find((l) => l.id === layoutId);
expect(created).toMatchObject({ kind: "plugin", pluginOrigin });
const tree = await layout.loadLayout("p1", layoutId);
expect(tree.root).toMatchObject({
type: "customPluginLayout",
node: {
pluginId: "dev.idea.android-plugin",
layoutType: "idea-android.health",
state: null,
},
});
});
it("choosing a plugin layout creates and activates a plugin layout without the old blocking notice", async () => {
const layout = new MockLayoutGateway();
const registry = new PluginRuntimeRegistry();
registry.add(stubPlugin("dev.idea.android-plugin", "Android", "idea-android.health"));
const onActiveLayoutChange = vi.fn();
renderTabs(layout, "p1", onActiveLayoutChange, registry);
await waitFor(() => {
expect(screen.getByLabelText("create layout")).toBeTruthy();
});
fireEvent.click(screen.getByLabelText("create layout"));
fireEvent.click(await screen.findByRole("menuitem", { name: /Android Health/ }));
await waitFor(() => {
expect(screen.getByRole("tab", { name: "Android Health" })).toBeTruthy();
expect(onActiveLayoutChange).toHaveBeenCalledWith(
expect.objectContaining({
name: "Android Health",
kind: "plugin",
pluginOrigin: {
pluginId: "dev.idea.android-plugin",
layoutType: "idea-android.health",
},
}),
);
});
expect(screen.queryByText(/extension backend pas encore livrée/i)).toBeNull();
});
it("MockLayoutGateway.createLayout defaults to kind=terminal", async () => {
const layout = new MockLayoutGateway();
const { layoutId } = await layout.createLayout("p1", "My Terminal");

View File

@ -20,7 +20,6 @@ import { useGateways } from "@/app/di";
import {
leaves,
replaceCustomPluginLayoutWithTerminal,
setCustomPluginLayoutState,
splitOp,
} from "./layout";
@ -65,12 +64,9 @@ export interface LayoutViewModel {
*/
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.
* Persists a `customPluginLayout` node's opaque state through `mutate_layout`.
*/
setPluginLayoutState: (nodeId: string, state: unknown) => void;
setPluginLayoutState: (nodeId: string, state: unknown) => Promise<void>;
/**
* "Choisir un autre layout" fallback action (#43, F4): locally swaps a
* `customPluginLayout` node for a blank terminal leaf of the same id.
@ -265,11 +261,10 @@ export function useLayout(
);
const setPluginLayoutState = useCallback(
(nodeId: string, state: unknown) => {
setLayout((prev) => (prev ? setCustomPluginLayoutState(prev, nodeId, state) : prev));
setLayoutVersion((v) => v + 1);
async (nodeId: string, state: unknown) => {
await mutate({ type: "setPluginLayoutState", target: nodeId, state });
},
[],
[mutate],
);
const replacePluginLayoutWithTerminal = useCallback((nodeId: string) => {

View File

@ -7,7 +7,7 @@
import { useCallback, useEffect, useState } from "react";
import type { GatewayError, LayoutInfo, LayoutKind } from "@/domain";
import type { GatewayError, LayoutInfo, LayoutKind, PluginLayoutOrigin } from "@/domain";
import { useGateways } from "@/app/di";
export interface LayoutsViewModel {
@ -22,7 +22,11 @@ export interface LayoutsViewModel {
/** Switches the active layout (does NOT force a re-fetch here; the caller uses the returned activeId). */
setActive: (layoutId: string) => Promise<void>;
/** Creates a new layout with the given name and kind; resolves with the new layoutId. */
create: (name: string, kind?: LayoutKind) => Promise<string | null>;
create: (
name: string,
kind?: LayoutKind,
pluginOrigin?: PluginLayoutOrigin,
) => Promise<string | null>;
/** Renames a layout. */
rename: (layoutId: string, name: string) => Promise<void>;
/** Deletes a layout; refuses if it is the last one. */
@ -144,12 +148,16 @@ export function useLayouts(projectId: string | null): LayoutsViewModel {
);
const create = useCallback(
async (name: string, kind?: LayoutKind): Promise<string | null> => {
async (
name: string,
kind?: LayoutKind,
pluginOrigin?: PluginLayoutOrigin,
): Promise<string | null> => {
if (!projectId || !gateway) return null;
setBusy(true);
setError(null);
try {
const { layoutId } = await gateway.createLayout(projectId, name, kind);
const { layoutId } = await gateway.createLayout(projectId, name, kind, pluginOrigin);
const updated = await gateway.listLayouts(projectId);
setLayouts(updated.layouts);
return layoutId;

View File

@ -6,15 +6,8 @@
* plugins never appear — carnet §1.3: only `enabled` plugins are loaded, so
* there is nothing to filter here beyond what the registry already omits).
*
* Presentational only; not yet mounted in a layout-creation flow. The
* existing "layout" concept in this codebase (`LayoutTabs`, `LayoutKind`) is
* a whole-tab kind (`"terminal" | "gitGraph"`) picked via a fixed two-item
* dropdown, backed by a `create(name, kind)` Tauri command that only knows
* those two kinds. Wiring an actual "create a plugin layout" action needs a
* backend `LayoutKind`/`create_layout` extension (carnet §10 flags F4 as
* "DevFrontend + DevBackend si ajustement DTO layout") — this component is
* the frontend half, ready to drop into that flow once the DTO lands; see the
* F4 delivery report's open point.
* Presentational only: the caller owns whether selecting a contribution creates
* a named plugin layout, replaces an unavailable cell, or just previews it.
*/
import type { PluginLayoutContribution } from "@/domain";

View File

@ -30,6 +30,7 @@ import type {
LayoutList,
LayoutOperation,
LayoutTree,
PluginLayoutOrigin,
LocalModelServerConfig,
ModelServerCommandPreview,
Memory,
@ -469,7 +470,12 @@ export interface LayoutGateway {
/** Lists all named layouts for a project, with the current active id. */
listLayouts(projectId: string): Promise<LayoutList>;
/** Creates a new named layout for a project; returns the new layout id. */
createLayout(projectId: string, name: string, kind?: LayoutKind): Promise<{ layoutId: string }>;
createLayout(
projectId: string,
name: string,
kind?: LayoutKind,
pluginOrigin?: PluginLayoutOrigin,
): Promise<{ layoutId: string }>;
/** Renames a layout. */
renameLayout(projectId: string, layoutId: string, name: string): Promise<void>;
/** Deletes a layout; returns the new active layout id. */