MCP v2 PR-2: close_pane, swap_panes, promote_pane, apply_preset

Four more tree-shape tools routed through the existing dispatcher
+ confirm modal + audit log. All take leaf_id args (single or pair)
that must be MCP-allowed via the per-pane chip; apply_preset takes
a typed PresetName enum (single, two_columns, three_columns,
two_rows, two_by_two) plus an allow_drops boolean.

apply_preset's data-loss case is handled non-interactively: if the
preset has fewer slots than the current pane count and allow_drops
is not set, the frontend handler throws with a descriptive message
listing the leaf labels that would be killed, so Claude can decide
whether to retry with allow_drops=true rather than the user being
ambushed by a destructive confirm modal.

promote_pane errors with "no perpendicular split above it" when the
parent shares orientation with the grandparent (same condition the
Ctrl+Shift+P shortcut uses to toast a no-op).

Extracted a require_visible_leaf helper on TileService since 4+ of
the v2 tools now do the same mirror-presence + cloned-metadata
check. Same args_repr convention as set_label so policy rules like
"close_pane" (bare tool name) work uniformly.
This commit is contained in:
megaproxy 2026-05-26 12:44:11 +01:00
parent 09019a0ad7
commit e0ce223985
2 changed files with 281 additions and 8 deletions

View file

@ -829,11 +829,109 @@ export default function App() {
summary: `Rename pane "${before}" → "${after}"`,
};
}
case "close_pane": {
const a = args as { leafId?: string };
if (typeof a.leafId !== "string") throw new Error("missing leafId");
const leaf = findLeaf(treeRef.current, a.leafId);
if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`);
const labelStr = leaf.label ?? a.leafId.slice(0, 8);
close(a.leafId);
return {
payload: { leafId: a.leafId },
summary: `Close pane "${labelStr}"`,
};
}
case "swap_panes": {
const a = args as { leafA?: string; leafB?: string };
if (typeof a.leafA !== "string") throw new Error("missing leafA");
if (typeof a.leafB !== "string") throw new Error("missing leafB");
if (a.leafA === a.leafB) throw new Error("leafA and leafB are the same");
const lA = findLeaf(treeRef.current, a.leafA);
const lB = findLeaf(treeRef.current, a.leafB);
if (!lA || lA.kind !== "leaf") throw new Error(`leaf not found: ${a.leafA}`);
if (!lB || lB.kind !== "leaf") throw new Error(`leaf not found: ${a.leafB}`);
const labelA = lA.label ?? a.leafA.slice(0, 8);
const labelB = lB.label ?? a.leafB.slice(0, 8);
setTree((t) => swapLeaves(t, a.leafA!, a.leafB!));
return {
payload: { leafA: a.leafA, leafB: a.leafB },
summary: `Swap panes "${labelA}" ↔ "${labelB}"`,
};
}
case "promote_pane": {
const a = args as { leafId?: string };
if (typeof a.leafId !== "string") throw new Error("missing leafId");
const leaf = findLeaf(treeRef.current, a.leafId);
if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`);
const next = promoteLeaf(treeRef.current, a.leafId);
if (next === null) {
throw new Error(
"pane can't be promoted (no perpendicular split above it)",
);
}
setTree(next);
return {
payload: { leafId: a.leafId },
summary: `Promote pane "${leaf.label ?? a.leafId.slice(0, 8)}" up one level`,
};
}
case "apply_preset": {
const a = args as { name?: string; allowDrops?: boolean };
const presetMap: Record<string, (d: Partial<LeafNode>) => TreeNode> = {
single: presetSingle,
two_columns: presetTwoColumns,
three_columns: presetThreeColumns,
two_rows: presetTwoRows,
two_by_two: presetTwoByTwo,
};
const make = a.name ? presetMap[a.name] : undefined;
if (!make) {
throw new Error(
`unknown preset: ${a.name} (valid: single, two_columns, three_columns, two_rows, two_by_two)`,
);
}
const { tree: nextTree, dropped } = reshapeToPreset(
treeRef.current,
make,
defaultShellAsLeafProps(defaultShell),
);
if (dropped.length > 0 && !a.allowDrops) {
const labels = dropped
.map((id) => {
const l = findLeaf(treeRef.current, id);
return l && l.kind === "leaf" ? (l.label ?? id.slice(0, 8)) : id.slice(0, 8);
})
.join(", ");
throw new Error(
`would drop ${dropped.length} pane(s) (${labels}); pass allow_drops=true to confirm`,
);
}
for (const id of dropped) {
const paneId = paneIdByLeafRef.current.get(id);
if (paneId != null) {
void killPane(paneId).catch((e) =>
console.warn("killPane failed:", e),
);
paneIdByLeafRef.current.delete(id);
}
}
if (activeLeafId && dropped.includes(activeLeafId)) {
setActiveLeafId(null);
}
setTree(nextTree);
return {
payload: { name: a.name, dropped: dropped.length, droppedLeafIds: dropped },
summary:
dropped.length > 0
? `Reshape to ${a.name} (closes ${dropped.length} pane${dropped.length === 1 ? "" : "s"})`
: `Reshape to ${a.name}`,
};
}
default:
throw new Error(`unsupported MCP tool: ${tool}`);
}
},
[setLabel],
[setLabel, close, defaultShell, activeLeafId],
);
// The summary string for the confirm modal needs access to the leaf
@ -841,14 +939,36 @@ export default function App() {
// logic (without mutating). For now we just rebuild it inline per tool;
// when more tools land this should split out.
const buildConfirmSummary = useCallback((tool: string, args: unknown): string => {
if (tool === "set_label") {
const a = args as { leafId?: string; label?: string };
const leaf = a.leafId ? findLeaf(treeRef.current, a.leafId) : null;
const before = leaf && leaf.kind === "leaf" ? (leaf.label ?? "(unlabelled)") : "(unknown)";
const after = a.label || "(cleared)";
return `Rename pane "${before}" → "${after}"`;
function leafLabel(id: string | undefined): string {
if (!id) return "(unknown)";
const l = findLeaf(treeRef.current, id);
return l && l.kind === "leaf" ? (l.label ?? id.slice(0, 8)) : id.slice(0, 8);
}
switch (tool) {
case "set_label": {
const a = args as { leafId?: string; label?: string };
return `Rename pane "${leafLabel(a.leafId)}" → "${a.label || "(cleared)"}"`;
}
case "close_pane": {
const a = args as { leafId?: string };
return `Close pane "${leafLabel(a.leafId)}"`;
}
case "swap_panes": {
const a = args as { leafA?: string; leafB?: string };
return `Swap panes "${leafLabel(a.leafA)}" ↔ "${leafLabel(a.leafB)}"`;
}
case "promote_pane": {
const a = args as { leafId?: string };
return `Promote pane "${leafLabel(a.leafId)}" up one level`;
}
case "apply_preset": {
const a = args as { name?: string; allowDrops?: boolean };
const suffix = a.allowDrops ? " (drops allowed)" : "";
return `Reshape workspace to ${a.name}${suffix}`;
}
default:
return `Run ${tool}`;
}
return `Run ${tool}`;
}, []);
useEffect(() => {