Files
webstudio/apps/builder/app/shared/copy-paste/plugin-instance.ts
Oleg Isonen dde30ffb54 feat: Improve MCP authoring, compact output, and agent onboarding (#5883)
## Summary

This improves how agents discover, author, inspect, and verify Webstudio
projects while reducing unnecessary MCP context usage.

## What changed

- Adds an **Agents** section to Project Settings with ready-to-run setup
commands and client configuration for Claude Code, Codex, Cursor, and VS
Code.
- Makes large list and schema responses compact and paginated by
default, with **verbose** available when complete records or diagnostics
are needed.
- Applies consistent compact output to assets, instances, styles, design
tokens, CLI schemas, and project-session metadata.
- Returns actionable expression warnings for props, resources, and
Collection bindings, including unknown variables and risky render-time
mutation resources.
- Improves Collection expression guidance so agents can bind array items
with the correct scoped variables.
- Reports rendered audit outcomes explicitly as complete, partial,
confirmation-required, or failed.
- Expands runtime output schemas so MCP clients can validate responses
reliably.
- Strengthens the packaged CLI smoke workflow with a realistic
seven-page stdio MCP scenario.

## Architecture

- Consolidates `project-build` from 65 file-oriented entrypoints to 11
architecture-owned surfaces.
- Separates the core model, contracts, runtime authoring, transfer,
state, project sessions, MCP, visual tooling, persistence codecs, and
authenticated server services.
- Isolates Node-only JSX evaluation from browser-safe transfer utilities
and authenticated server modules.
- Keeps persistence codecs independent from authenticated database
services, preventing unrelated module initialization.
- Defines shared instance-path types once and reuses them across runtime
modules.
- Preserves Immer initialization through explicit package side-effect
metadata.
- Adds package tests that prevent a return to source-file entrypoints or
generic exposure of Node-only surfaces.

## Validation

- Full repository checks, tests, typechecks, generated-file checks, and
fixtures
- All 163 Builder test files and 2,141 tests
- All 71 project-build test files and 1,794 tests
- Lint and package-boundary validation
- Full packed CLI release smoke test, including install, preview, agent
setup, seven-page stdio MCP workflow, rendered audit, and structured
commands
2026-07-15 18:11:28 +02:00

407 lines
12 KiB
TypeScript

import {
detectFragmentTokenConflicts,
extractWebstudioFragment,
findSafeFragmentPasteTarget,
getCommonAncestorSelector,
getPasteRootInstanceIds,
mergeWebstudioFragments,
} from "@webstudio-is/project-build/runtime";
import {
instanceTransferDataVersion,
instancesTransferDataVersion,
parseInstanceTransferData,
type InstanceTransferData,
type InstancesTransferData,
} from "@webstudio-is/project-build/transfer";
import { findClosestInsertable } from "../instance-utils/insert";
import {
executeRuntimeMutationAsync,
getWebstudioData,
} from "../instance-utils/data";
import { type Insertable } from "../instance-utils/insert";
import { shallowEqual } from "shallow-equal";
import { toast } from "@webstudio-is/design-system";
import {
type Instance,
type WebstudioFragment,
isComponentDetachable,
} from "@webstudio-is/sdk";
import { $instances } from "~/shared/sync/data-stores";
import {
type InstanceSelector,
sortInstancePathsForChildMutation,
} from "@webstudio-is/project-build/runtime";
import { findChildReferenceIndex } from "@webstudio-is/project-build/runtime";
import { deleteInstanceBySelector } from "../instance-utils/mutation";
import {
$allSelectedInstanceSelectors,
clearInstanceSelection,
$selectedInstancePath,
$selectedInstanceSelector,
selectInstances,
} from "~/shared/nano-states";
import { getInstancePath } from "@webstudio-is/project-build/runtime";
import { builderApi } from "../builder-api";
import { pasteHandled, pasteIgnored, type Plugin } from "./copy-paste";
import { breakpointPasteLimitWarning } from "@webstudio-is/project-build/runtime";
const invalidPasteDataMessage =
"Could not paste Webstudio instance data. The clipboard data appears to be incomplete or invalid.";
const getTreeData = (
instanceSelector: InstanceSelector,
{ showToast = true }: { showToast?: boolean } = {}
) => {
const instances = $instances.get();
const [targetInstanceId] = instanceSelector;
const instance = instances.get(targetInstanceId);
if (instance && !isComponentDetachable(instance.component)) {
if (showToast) {
toast.error(
"This instance can not be moved outside of its parent component."
);
}
return;
}
// @todo tell user they can't copy or cut root
if (instanceSelector.length === 1) {
return;
}
return {
instanceSelector,
...extractWebstudioFragment(getWebstudioData(), targetInstanceId),
};
};
const stringify = (data: InstanceTransferData) => {
return JSON.stringify({ [instanceTransferDataVersion]: data });
};
const stringifyMultiRoot = (data: InstancesTransferData) => {
return JSON.stringify({ [instancesTransferDataVersion]: data });
};
const stringifyMultiRootSelection = (selectedData: InstanceTransferData[]) => {
const rootInstanceIds = selectedData.map((data) => data.instanceSelector[0]);
return stringifyMultiRoot({
rootInstanceIds,
fragment: mergeWebstudioFragments(rootInstanceIds, selectedData),
});
};
const reportSkippedSelectedInstances = (operation: "copied" | "cut") => {
builderApi.toast.info(`Some selected instances could not be ${operation}.`);
};
const findMultiSelectionInsertable = (
fragment: WebstudioFragment
): undefined | Insertable => {
const selectedInstanceSelectors = $allSelectedInstanceSelectors.get();
if (selectedInstanceSelectors.length < 2) {
return;
}
const instances = $instances.get();
const selectedPaths = selectedInstanceSelectors
.map((instanceSelector) => getInstancePath(instanceSelector, instances))
.filter((path): path is NonNullable<typeof path> => path !== undefined);
if (selectedPaths.length < 2) {
return;
}
const directParentSelector = selectedPaths[0][1]?.instanceSelector;
const hasSameDirectParent =
directParentSelector !== undefined &&
selectedPaths.every((path) =>
shallowEqual(path[1]?.instanceSelector, directParentSelector)
);
if (hasSameDirectParent) {
const parentInstance = instances.get(directParentSelector[0]);
if (parentInstance === undefined) {
return;
}
const selectedSiblingIndexes = selectedPaths.map((path) =>
findChildReferenceIndex(parentInstance.children, path[0].instance.id)
);
if (selectedSiblingIndexes.includes(-1)) {
return;
}
const lastSelectedSiblingIndex = Math.max(...selectedSiblingIndexes);
return findPasteTargetForFragment(fragment, {
parentSelector: directParentSelector,
position: lastSelectedSiblingIndex + 1,
});
}
const commonAncestorSelector = getCommonAncestorSelector(
selectedPaths.map((path) => path[0].instanceSelector)
);
if (commonAncestorSelector === undefined) {
return;
}
return findPasteTargetForFragment(fragment, {
parentSelector: commonAncestorSelector,
position: "end",
});
};
const findSelectionPasteTarget = (fragment: WebstudioFragment) =>
findMultiSelectionInsertable(fragment) ??
findPasteTargetForFragment(fragment);
const findPasteTargetForFragment = (
fragment: WebstudioFragment,
insertable?: Insertable
): undefined | Insertable => {
const instances = $instances.get();
insertable = findClosestInsertable(fragment, insertable);
if (insertable === undefined) {
return;
}
return findSafeFragmentPasteTarget({
fragment,
instances,
insertTarget: insertable,
});
};
const findPasteTarget = (
data: InstanceTransferData
): undefined | Insertable => {
const instances = $instances.get();
const instanceSelector = $selectedInstanceSelector.get();
// paste after selected instance
if (
instanceSelector &&
shallowEqual(instanceSelector, data.instanceSelector)
) {
// body is not allowed to copy
// so clipboard always have at least two level instance selector
const [currentInstanceId, parentInstanceId] = instanceSelector;
const parentInstance = instances.get(parentInstanceId);
if (parentInstance === undefined) {
return;
}
const indexWithinChildren = parentInstance.children.findIndex(
(child) => child.type === "id" && child.value === currentInstanceId
);
return {
parentSelector: instanceSelector.slice(1),
position: indexWithinChildren + 1,
};
}
return findSelectionPasteTarget(data);
};
const resolveFragmentTokenConflicts = async (fragment: WebstudioFragment) => {
const conflicts = detectFragmentTokenConflicts({
fragment,
targetData: getWebstudioData(),
});
return conflicts.length > 0
? await builderApi.showTokenConflictDialog(conflicts)
: "theirs";
};
const insertPastedFragment = async ({
fragment,
pasteTarget,
selectRootInstances,
}: {
fragment: WebstudioFragment;
pasteTarget: Insertable;
selectRootInstances: (rootInstanceIds: Instance["id"][]) => void;
}) => {
try {
const conflictResolution = await resolveFragmentTokenConflicts(fragment);
const result = await executeRuntimeMutationAsync({
id: "instances.insertFragment",
input: {
parentInstanceId: pasteTarget.parentSelector[0],
fragment,
conflictResolution,
insertIndex:
typeof pasteTarget.position === "number"
? pasteTarget.position
: undefined,
},
});
const rootInstanceIds = result?.result.rootInstanceIds;
if (rootInstanceIds === undefined || rootInstanceIds.length === 0) {
return false;
}
if (result?.result.didMergeBreakpointsDueToLimit === true) {
toast.warn(breakpointPasteLimitWarning);
}
selectRootInstances(rootInstanceIds);
} catch (error) {
// User cancelled
return false;
}
return true;
};
const handlePasteInstance = async (clipboardData: string) => {
const transferData = parseInstanceTransferData(clipboardData);
if (transferData.owned === false) {
return pasteIgnored;
}
if (transferData.valid === false) {
return { success: false, error: invalidPasteDataMessage } as const;
}
if (transferData.type === "multi-root") {
const pasteRootInstanceIds = getPasteRootInstanceIds(transferData.data);
if (pasteRootInstanceIds.length === 0) {
return pasteHandled;
}
const fragment: WebstudioFragment = {
...transferData.data.fragment,
children: pasteRootInstanceIds.map((instanceId) => ({
type: "id",
value: instanceId,
})),
};
const pasteTarget = findSelectionPasteTarget(fragment);
if (pasteTarget === undefined) {
return pasteHandled;
}
await insertPastedFragment({
fragment,
pasteTarget,
selectRootInstances: (rootInstanceIds) => {
selectInstances(
rootInstanceIds.map((newRootInstanceId) => [
newRootInstanceId,
...pasteTarget.parentSelector,
])
);
},
});
return pasteHandled;
}
const fragment = transferData.data;
const pasteTarget = findPasteTarget(fragment);
if (pasteTarget === undefined) {
return pasteHandled;
}
await insertPastedFragment({
fragment,
pasteTarget,
selectRootInstances: (rootInstanceIds) => {
const newRootInstanceId = rootInstanceIds[0];
if (newRootInstanceId === undefined) {
return;
}
selectInstances([[newRootInstanceId, ...pasteTarget.parentSelector]]);
},
});
return pasteHandled;
};
const handleCopyInstance = () => {
const selectedInstanceSelectors = $allSelectedInstanceSelectors.get();
if (selectedInstanceSelectors.length === 0) {
return;
}
if (selectedInstanceSelectors.length === 1) {
const data = getTreeData(selectedInstanceSelectors[0]);
if (data === undefined) {
return;
}
return stringify(data);
}
const selectedData = selectedInstanceSelectors
.map((instanceSelector) =>
getTreeData(instanceSelector, { showToast: false })
)
.filter((data): data is InstanceTransferData => data !== undefined);
if (selectedData.length === 0) {
return;
}
if (selectedData.length < selectedInstanceSelectors.length) {
reportSkippedSelectedInstances("copied");
}
return stringifyMultiRootSelection(selectedData);
};
const handleCutInstance = () => {
const selectedInstanceSelectors = $allSelectedInstanceSelectors.get();
if (selectedInstanceSelectors.length > 1) {
const instances = $instances.get();
const selectedPaths = selectedInstanceSelectors
.map((instanceSelector) => {
const data = getTreeData(instanceSelector, { showToast: false });
const instancePath =
data === undefined
? undefined
: getInstancePath(data.instanceSelector, instances);
if (data === undefined || instancePath === undefined) {
return;
}
return { data, instancePath };
})
.filter(
(
item
): item is {
data: InstanceTransferData;
instancePath: NonNullable<ReturnType<typeof getInstancePath>>;
} => item !== undefined
);
if (selectedPaths.length === 0) {
return;
}
const selectedPathData = selectedPaths.map(({ data }) => data);
if (selectedPathData.length < selectedInstanceSelectors.length) {
reportSkippedSelectedInstances("cut");
}
const clipboardData = stringifyMultiRootSelection(selectedPathData);
for (const { instancePath } of sortInstancePathsForChildMutation(
selectedPaths
)) {
deleteInstanceBySelector(instancePath[0].instanceSelector);
}
clearInstanceSelection();
return clipboardData;
}
const instancePath = $selectedInstancePath.get();
if (instancePath === undefined) {
return;
}
// @todo tell user they can't delete root
if (instancePath.length === 1) {
return;
}
const data = getTreeData(instancePath[0].instanceSelector);
if (data === undefined) {
return;
}
deleteInstanceBySelector(instancePath[0].instanceSelector);
if (data === undefined) {
return;
}
return stringify(data);
};
export const instanceText = {
name: "instance-text",
mimeType: "text/plain",
onCopy: handleCopyInstance,
onCut: handleCutInstance,
onPaste: handlePasteInstance,
} satisfies Plugin;
export const instanceJson = {
name: "instance-json",
mimeType: "application/json",
onPaste: handlePasteInstance,
} satisfies Plugin;