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:
@ -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 } });
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ describe("createMockGateways", () => {
|
||||
"plugin",
|
||||
"pluginConfig",
|
||||
"pluginEvents",
|
||||
"pluginStorage",
|
||||
"pluginTask",
|
||||
"pluginToolchain",
|
||||
"pluginWorkspace",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user