Let the agent copy nodes, also into another graph (#434)
The agent built every part again that already stood in a graph, node by node, while the editor has Ctrl+C and Ctrl+V for it. The new command `copy_nodes` in the area graphs copies nodes into the same graph or another one, in one undo step with the rest of the call. - The links between the copied nodes come along, and so do groups and comments that are only about copied nodes. A node belongs to a group by its middle, as when it is dragged. `boxesAbout` in annotationFollow.ts decides this. - `made` names each copy by the id of its original, so later steps link to it with `$copy.<id>`. `$copy` alone is the first one. - Without x and y the copies are arranged at the end of the call like new nodes. With them they keep their layout, top left corner there. - Nodes the target graph does not take are refused by name, not left out as Ctrl+V does. The rule is the graph tab's: `fitsGraph` and `fittingPart` moved from clipboard.ts to paletteEntries.ts. - A copied Variables node takes the variables of its behavior along into the target behavior (`CarryVariablesCommand`), with default, value, Keep and Private. A variable of that name of another kind there is refused. - The relay's instructions name `copy_nodes`. Found on the way: - Groups made by the agent can take in a node of another group once the nodes are arranged. lint_graph does not count that. - Undo after a paste leaves an empty `groups: []`, as AddAnnotationCommand does. It is the same as no list. Tests: - agent-copy (new, 3 tests) with the agent, relay, clipboard and palette tests: 118/118. - Counter-probes: 6 bendings, all compile, all red. - e2e 71 (atx): ALL PASSED, 26 checks. - Gate (atx): CHECK EXIT 0, 327 files, 5024 tests. - Live in the MCP Test Project: 5 nodes of Arrow keys walk into a new behavior, 6 links, group, comment and speed (300) along, lint_graph 0 everywhere, taken back with undo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e4b0419a53
commit
fb981b5b0f
7 files changed
+386
-45
No files matched your search
@@ -19,6 +19,7 @@ import {
|
||||
ResizeNodeCommand,
|
||||
SetNodeParamCommand,
|
||||
} from '../../../core/commands/graphCommands';
|
||||
import { PasteGraphCommand } from '../../../core/commands/pasteCommands';
|
||||
import {
|
||||
AddPortCommand,
|
||||
MovePortCommand,
|
||||
@@ -34,25 +35,33 @@ import {
|
||||
SetStructTypeCommand,
|
||||
SplitOutputCommand,
|
||||
} from '../../../core/commands/structTypeCommands';
|
||||
import { CarryVariablesCommand } from '../../../core/commands/variableCommands';
|
||||
import {
|
||||
type AnnotationKind,
|
||||
type DeclaredPort,
|
||||
declaresVariablePorts,
|
||||
findAnnotation,
|
||||
findNode,
|
||||
type Graph,
|
||||
type GraphNode,
|
||||
nodeDef,
|
||||
portKindOf,
|
||||
variablesSourceOf,
|
||||
} from '../../../core/graph';
|
||||
import { copyGraph } from '../../../core/model/clipboard';
|
||||
import type { PropertyBag, PropertyValue } from '../../../core/model/Properties';
|
||||
import { findGraphAsset, type Project } from '../../../core/model/Project';
|
||||
import { findGraphAsset, type GraphHolder, type Project } from '../../../core/model/Project';
|
||||
import { DECLARED_PORT_KINDS, type DeclaredPortKind } from '../../../core/properties/kinds';
|
||||
import { coerceValue } from '../../../core/properties/PropertySchema';
|
||||
import { freeNoteBox, groupBoxAround } from '../../graph/annotationDrag';
|
||||
import { boxesAbout } from '../../graph/annotationFollow';
|
||||
import { arrangeCommand, layoutOf } from '../../graph/graphLayout';
|
||||
import { type PaletteEntry, paletteEntries } from '../../graph/paletteEntries';
|
||||
import { fittingPart, type PaletteEntry, paletteEntries } from '../../graph/paletteEntries';
|
||||
import { pickedPortChoices } from '../../graph/portChoices';
|
||||
import {
|
||||
type Args,
|
||||
type BuildContext,
|
||||
type Built,
|
||||
type CatalogCommand,
|
||||
flag,
|
||||
GRAPH,
|
||||
@@ -122,6 +131,32 @@ export const GRAPH_COMMANDS: readonly CatalogCommand[] = [
|
||||
},
|
||||
shows: (args, made) => ({ kind: 'nodes', asset: text(args, 'asset'), ids: Object.values(made) }),
|
||||
},
|
||||
{
|
||||
name: 'copy_nodes',
|
||||
description:
|
||||
'Copies nodes into the same graph or another one, as Ctrl+C and Ctrl+V do: with the links between them ' +
|
||||
'and the groups and comments about them alone. made names each copy by the id of its original ' +
|
||||
'("$copy.<id>"; "$copy" is the copy of the first node given), so later steps can link it. Leave x and y ' +
|
||||
'out: the copies are then arranged like new nodes when the call has run. With x and y the copy keeps ' +
|
||||
'its layout, its top left there. Nodes the other graph cannot hold are refused by name; the variables ' +
|
||||
'a copied Variables node reads from its behavior come along into the other behavior.',
|
||||
args: object(
|
||||
{
|
||||
from: id('Id of the asset holding the nodes.'),
|
||||
nodes: ids('The nodes to copy.'),
|
||||
to: id('Id of the asset to copy them into; leave out for the same one.'),
|
||||
x: number('Only to place the copy by hand: its left.'),
|
||||
y: number('Only to place the copy by hand: its top.'),
|
||||
},
|
||||
['from', 'nodes'],
|
||||
),
|
||||
build: copyNodes,
|
||||
shows: (args, made) => ({
|
||||
kind: 'nodes',
|
||||
asset: (args.to as string | undefined) ?? text(args, 'from'),
|
||||
ids: Object.values(made),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'arrange_graph',
|
||||
description:
|
||||
@@ -588,6 +623,68 @@ function coercedParams(entry: PaletteEntry, params: PropertyBag): PropertyBag {
|
||||
return coerced;
|
||||
}
|
||||
|
||||
/** `copy_nodes` (#434): the copies, where they go, and the variables that go along with them. */
|
||||
function copyNodes(args: Args, context: BuildContext): Built {
|
||||
const { project } = context;
|
||||
const fromId = text(args, 'from');
|
||||
const toId = (args.to as string | undefined) ?? fromId;
|
||||
const nodeIds = listOf(args, 'nodes');
|
||||
requireNodes(project, fromId, nodeIds);
|
||||
const source = findGraphAsset(project, fromId)!;
|
||||
const target = findGraphAsset(project, toId);
|
||||
if (!target) throw new CommandError(`There is no graph asset ${toId}`);
|
||||
const layout = layoutOf(project, fromId, context.measure(fromId))!;
|
||||
const clip = copyGraph(source.graph, nodeIds, boxesAbout(layout, new Set(nodeIds)))!;
|
||||
const { skipped } = fittingPart(project, target, clip);
|
||||
if (skipped.length > 0) {
|
||||
const named = skipped.map((node) => `${nodeDef(node.type)?.label ?? node.type} (${node.id})`);
|
||||
throw new CommandError(`${target.name} cannot hold ${named.join(', ')} – leave them out`);
|
||||
}
|
||||
const carried = carriedVariables(source, target, clip.nodes);
|
||||
const placed = typeof args.x === 'number' && typeof args.y === 'number';
|
||||
const at = placed ? { x: args.x as number, y: args.y as number } : context.spot(toId);
|
||||
const corners = [...clip.nodes, ...clip.annotations.map((a) => a.annotation)];
|
||||
const shift = {
|
||||
x: at.x - Math.min(...corners.map((c) => c.x)),
|
||||
y: at.y - Math.min(...corners.map((c) => c.y)),
|
||||
};
|
||||
const paste = new PasteGraphCommand(toId, clip, shift);
|
||||
const command =
|
||||
carried.length > 0
|
||||
? new CompoundCommand(paste.label, [new CarryVariablesCommand(toId, fromId, carried), paste])
|
||||
: paste;
|
||||
const copies = paste.nodeIds;
|
||||
const made: Record<string, string> = { id: copies[clip.nodes.findIndex((n) => n.id === nodeIds[0])]! };
|
||||
clip.nodes.forEach((node, i) => (made[node.id] = copies[i]!));
|
||||
clip.annotations.forEach(({ annotation }, i) => (made[annotation.id] = paste.annotationIds[i]!));
|
||||
return placed ? { command, made } : { command, made, unplaced: { asset: toId, nodes: copies } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The variables of `source` that copied Variables nodes read from it, for a copy into another
|
||||
* behavior that lacks them; one declared there in another kind is refused.
|
||||
*/
|
||||
function carriedVariables(source: GraphHolder, target: GraphHolder, nodes: readonly GraphNode[]): string[] {
|
||||
if (source.type !== 'behavior' || target.type !== 'behavior' || source.id === target.id) return [];
|
||||
const named = nodes.flatMap((node) =>
|
||||
declaresVariablePorts(node.type) && !variablesSourceOf(node.params)
|
||||
? (node.ports ?? []).map((port) => port.key)
|
||||
: [],
|
||||
);
|
||||
const carried: string[] = [];
|
||||
for (const key of new Set(named)) {
|
||||
const def = source.variables.find((v) => v.key === key);
|
||||
const there = target.variables.find((v) => v.key === key);
|
||||
if (!def) continue;
|
||||
if (!there) carried.push(key);
|
||||
else if (there.kind !== def.kind)
|
||||
throw new CommandError(
|
||||
`"${key}" is a ${def.kind} in ${source.name} but a ${there.kind} in ${target.name} – rename one first`,
|
||||
);
|
||||
}
|
||||
return carried;
|
||||
}
|
||||
|
||||
function requireGraphOf(project: Project, assetId: string): Graph {
|
||||
const holder = findGraphAsset(project, assetId);
|
||||
if (!holder) throw new CommandError(`There is no graph asset ${assetId}`);
|
||||
|
||||
Reference in new issue
Block a user