diff --git a/packages/studio/src/webmcp/StudioAgentTools.tsx b/packages/studio/src/webmcp/StudioAgentTools.tsx index c52ee655b1..f201ca23bf 100644 --- a/packages/studio/src/webmcp/StudioAgentTools.tsx +++ b/packages/studio/src/webmcp/StudioAgentTools.tsx @@ -30,6 +30,9 @@ export function StudioAgentTools() { applyDomSelection, handleDomTextCommit, handleDomStyleCommit, + handleDomPathOffsetCommit, + handleDomBoxSizeCommit, + handleDomRotationCommit, } = useDomEditActionsContext(); const getSnapshot = useCallback((): StudioLookSnapshot => { @@ -85,6 +88,15 @@ export function StudioAgentTools() { getWriteBlockedReason: () => writeBlockedReason, setText: (value, fieldKey) => handleDomTextCommit(value, fieldKey), setStyle: (property, value) => handleDomStyleCommit(property, value), + // Measured, not authored: the tool compares this before and after to + // tell a real change from a handler that did nothing and resolved. + readBox: (selection) => { + const rect = selection.element.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }, + moveTo: (selection, next) => handleDomPathOffsetCommit(selection, next), + resizeTo: (selection, next) => handleDomBoxSizeCommit(selection, next), + rotateTo: (selection, next) => handleDomRotationCommit(selection, next), getGsapDiagnostics: () => ({ animations: selectedGsapAnimations, multipleTimelines: gsapMultipleTimelines, @@ -101,6 +113,9 @@ export function StudioAgentTools() { writeBlockedReason, handleDomTextCommit, handleDomStyleCommit, + handleDomPathOffsetCommit, + handleDomBoxSizeCommit, + handleDomRotationCommit, domEditSelection, selectedGsapAnimations, gsapMultipleTimelines, diff --git a/packages/studio/src/webmcp/tools/transformTools.test.ts b/packages/studio/src/webmcp/tools/transformTools.test.ts new file mode 100644 index 0000000000..b438ed9e5d --- /dev/null +++ b/packages/studio/src/webmcp/tools/transformTools.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; +import { + studioTransform, + type ElementBox, + type StudioTransformResult, + type TransformToolDeps, +} from "./transformTools"; +import { expectFailure, expectOk, previewElement, selectionFor } from "../webmcpTestUtils"; + +/** + * A stand-in for the rendered box. happy-dom and jsdom report all-zero rects, + * so the box is injected rather than measured; these tests are about what the + * tool concludes from a box, not about layout. + */ +function boxStore(initial: ElementBox) { + const box = { ...initial }; + return { + read: () => ({ ...box }), + set: (next: Partial) => Object.assign(box, next), + }; +} + +function transformDeps(overrides: Partial = {}): TransformToolDeps { + const element = previewElement('

Ship it

', "headline"); + return { + getCurrentSelection: () => selectionFor(element), + getWriteBlockedReason: () => null, + readBox: () => ({ x: 0, y: 0, width: 100, height: 50 }), + moveTo: async () => undefined, + resizeTo: async () => undefined, + rotateTo: async () => undefined, + ...overrides, + }; +} + +describe("studioTransform", () => { + it("reports the box read back, not the box requested", async () => { + const store = boxStore({ x: 0, y: 0, width: 100, height: 50 }); + // The handler lands somewhere other than asked, which is what a clamp or a + // layout constraint does. + const resizeTo = vi.fn(async () => store.set({ width: 300, height: 120 })); + + const result = await studioTransform(transformDeps({ readBox: store.read, resizeTo }), { + width: 999, + height: 999, + }); + + const ok = expectOk(result); + expect(ok.box.width).toBe(300); + expect(ok.box.height).toBe(120); + expect(ok.applied).toContain("resize"); + }); + + it("reports a silent no-op as unchanged instead of success", async () => { + // handleGsapAwarePathOffsetCommit is `if (gsapCommitMutation) {...}` with no + // else branch. Without GSAP it resolves having written nothing, and echoing + // the request back would be a lie the agent builds on. + const store = boxStore({ x: 10, y: 10, width: 100, height: 50 }); + const moveTo = vi.fn(async () => undefined); + + const result = expectFailure( + await studioTransform(transformDeps({ readBox: store.read, moveTo }), { x: 500, y: 400 }), + ); + + expect(moveTo).toHaveBeenCalled(); + expect(result.kind).toBe("blocked"); + expect(result.reason).toMatch(/did not move/); + expect(result.hint).toMatch(/GSAP/); + }); + + it("separates what landed from what did not, in one call", async () => { + const store = boxStore({ x: 0, y: 0, width: 100, height: 50 }); + const resizeTo = vi.fn(async () => store.set({ width: 200, height: 80 })); + const moveTo = vi.fn(async () => undefined); + + const result = await studioTransform(transformDeps({ readBox: store.read, resizeTo, moveTo }), { + x: 40, + y: 40, + width: 200, + height: 80, + }); + + const ok = expectOk(result); + expect(ok.applied).toEqual(["resize"]); + expect(ok.unchanged.move).toMatch(/did not move/); + }); + + it("re-reads between operations so a later one sees the earlier result", async () => { + const store = boxStore({ x: 0, y: 0, width: 100, height: 50 }); + const resizeTo = vi.fn(async () => store.set({ width: 200, height: 80 })); + const moveTo = vi.fn(async () => store.set({ x: 40, y: 40 })); + + const result = await studioTransform(transformDeps({ readBox: store.read, resizeTo, moveTo }), { + x: 40, + y: 40, + width: 200, + height: 80, + }); + + // Move is judged against the box AFTER the resize. Comparing against the + // original would credit the resize's change to the move. + const ok = expectOk(result); + expect(ok.applied).toEqual(["resize", "move"]); + expect(ok.unchanged).toEqual({}); + }); + + it("reports rotation as dispatched rather than verified", async () => { + // `rotate` is an individual transform property and does not appear in the + // computed transform, so there is no honest box-derived signal for it. + const rotateTo = vi.fn(async () => undefined); + + const result = await studioTransform(transformDeps({ rotateTo }), { rotate: 15 }); + + const ok = expectOk(result); + expect(rotateTo).toHaveBeenCalledWith(expect.anything(), { angle: 15 }); + expect(ok.applied).toEqual(["rotate"]); + }); + + it("refuses to write while a conflict is waiting for the user", async () => { + const moveTo = vi.fn(); + + const result = expectFailure( + await studioTransform( + transformDeps({ getWriteBlockedReason: () => "Auto-save is paused", moveTo }), + { x: 10, y: 10 }, + ), + ); + + expect(result.kind).toBe("blocked"); + expect(moveTo).not.toHaveBeenCalled(); + }); + + it("requires x and y together, and width and height together", async () => { + const moveTo = vi.fn(); + const resizeTo = vi.fn(); + const deps = transformDeps({ moveTo, resizeTo }); + + expect(expectFailure(await studioTransform(deps, { x: 10 })).reason).toMatch(/together/); + expect(expectFailure(await studioTransform(deps, { width: 10 })).reason).toMatch(/together/); + expect(moveTo).not.toHaveBeenCalled(); + expect(resizeTo).not.toHaveBeenCalled(); + }); + + it("rejects a negative size and an empty request", async () => { + const deps = transformDeps(); + + expect(expectFailure(await studioTransform(deps, { width: -1, height: 10 })).kind).toBe( + "invalid", + ); + expect(expectFailure(await studioTransform(deps, {})).reason).toMatch(/at least one/); + }); + + it("rejects non-finite numbers rather than passing them to a handler", async () => { + const moveTo = vi.fn(); + + const result = expectFailure( + await studioTransform(transformDeps({ moveTo }), { x: Number.NaN, y: 10 }), + ); + + expect(result.kind).toBe("invalid"); + expect(moveTo).not.toHaveBeenCalled(); + }); + + it("fails when nothing is selected", async () => { + const moveTo = vi.fn(); + + const result = expectFailure( + await studioTransform(transformDeps({ getCurrentSelection: () => null, moveTo }), { + x: 1, + y: 1, + }), + ); + + expect(result.kind).toBe("invalid"); + expect(result.hint).toMatch(/studio_select/); + expect(moveTo).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/webmcp/tools/transformTools.ts b/packages/studio/src/webmcp/tools/transformTools.ts new file mode 100644 index 0000000000..0ea9bf670b --- /dev/null +++ b/packages/studio/src/webmcp/tools/transformTools.ts @@ -0,0 +1,205 @@ +/** + * `studio_transform`: move, resize and rotate, as a drag would. + * + * This tool reads the element's box back after every write and reports what + * ACTUALLY changed. That is not belt-and-braces, it is the only thing standing + * between an agent and a silent lie, because two of the three handlers can do + * nothing and resolve: + * + * - The handlers exposed on `DomEditActionsValue` are the GSAP-AWARE wrappers + * (`useDomEditSession.ts` aliases them), not the CSS ones in + * `useDomGeometryCommits.ts`. + * - `handleGsapAwarePathOffsetCommit` and `handleGsapAwareRotationCommit` are + * `if (gsapCommitMutation) { ...intercept... }` with NO else branch. In a + * composition with no GSAP they return having done nothing. The adjacent + * comments confirm that is deliberate: there is no CSS fallback to write to. + * - `handleGsapAwareBoxSizeCommit` is different. It runs through + * `runGestureTransaction` with a scale route and a width/height route, so + * resize works more generally than the other two. + * + * Read back, do not assume. + */ + +import type { DomEditSelection } from "../../components/editor/domEditingTypes"; +import { toolFailure, toolOk, type ToolFailure, type ToolResult } from "../toolResult"; + +export interface ElementBox { + x: number; + y: number; + width: number; + height: number; +} + +export interface TransformToolDeps { + getCurrentSelection: () => DomEditSelection | null; + getWriteBlockedReason: () => string | null; + /** The element's box as it renders right now. */ + readBox: (selection: DomEditSelection) => ElementBox; + moveTo: (selection: DomEditSelection, next: { x: number; y: number }) => Promise; + resizeTo: (selection: DomEditSelection, next: { width: number; height: number }) => Promise; + rotateTo: (selection: DomEditSelection, next: { angle: number }) => Promise; +} + +export interface StudioTransformInput { + x?: unknown; + y?: unknown; + width?: unknown; + height?: unknown; + rotate?: unknown; +} + +export interface StudioTransformResult { + /** The box as it renders after the write, read back, not echoed. */ + box: ElementBox; + applied: string[]; + /** Requested operations whose effect could not be observed, with why. */ + unchanged: Record; +} + +const NO_OP_HINT = + "Move and rotate are written as GSAP code; a composition with no GSAP timeline has nothing to write to. studio_inspect reports the element's animations."; + +function readNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function guard(deps: TransformToolDeps): ToolFailure | null { + const blocked = deps.getWriteBlockedReason(); + if (blocked) return toolFailure("blocked", blocked, "Resolve it in Studio, then retry."); + if (!deps.getCurrentSelection()) { + return toolFailure("invalid", "nothing is selected", "Call studio_select first."); + } + return null; +} + +interface TransformRequest { + move: { x: number; y: number } | null; + size: { width: number; height: number } | null; + rotate: number | null; +} + +/** + * Both or neither. Accepting one axis alone would mean inventing the other from + * the current value, which moves the element somewhere the caller did not ask + * for. + */ +function parsePair( + a: unknown, + b: unknown, + names: [string, string], + min = Number.NEGATIVE_INFINITY, +): { pair: [number, number] | null } | ToolFailure { + const first = readNumber(a); + const second = readNumber(b); + if (first === null && second === null) return { pair: null }; + if (first === null || second === null) { + return toolFailure("invalid", `${names[0]} and ${names[1]} must be given together`); + } + if (first < min || second < min) { + return toolFailure("invalid", `${names[0]} and ${names[1]} must be at least ${min}`); + } + return { pair: [first, second] }; +} + +function isFailure(value: object): value is ToolFailure { + return "ok" in value; +} + +function parseRequest(input: StudioTransformInput): TransformRequest | ToolFailure { + const move = parsePair(input.x, input.y, ["x", "y"]); + if (isFailure(move)) return move; + const size = parsePair(input.width, input.height, ["width", "height"], 0); + if (isFailure(size)) return size; + const rotate = readNumber(input.rotate); + + if (!move.pair && !size.pair && rotate === null) { + return toolFailure( + "invalid", + "give at least one of x, y, width, height, rotate as a finite number", + ); + } + + return { + move: move.pair ? { x: move.pair[0], y: move.pair[1] } : null, + size: size.pair ? { width: size.pair[0], height: size.pair[1] } : null, + rotate, + }; +} + +export async function studioTransform( + deps: TransformToolDeps, + input: StudioTransformInput, +): Promise> { + const request = parseRequest(input); + if (isFailure(request)) return request; + + const blocked = guard(deps); + if (blocked) return blocked; + + const selection = deps.getCurrentSelection(); + if (!selection) return toolFailure("invalid", "nothing is selected"); + + const applied: string[] = []; + const unchanged: Record = {}; + + // Sequential, and each one re-reads first, so a move is judged against the box + // AFTER a resize in the same call rather than against the original. + if (request.size) { + const before = deps.readBox(selection); + await deps.resizeTo(selection, request.size); + const after = deps.readBox(selection); + if (after.width !== before.width || after.height !== before.height) applied.push("resize"); + else unchanged.resize = "the element's size did not change"; + } + + if (request.move) { + const before = deps.readBox(selection); + await deps.moveTo(selection, request.move); + const after = deps.readBox(selection); + if (after.x !== before.x || after.y !== before.y) applied.push("move"); + else unchanged.move = `the element did not move. ${NO_OP_HINT}`; + } + + if (request.rotate !== null) { + // Rotation is written as the CSS `rotate` property, an individual transform + // property that does NOT appear in getComputedStyle().transform. There is no + // reliable box-derived signal, so this is reported as dispatched rather than + // verified, and the description says so. + await deps.rotateTo(selection, { angle: request.rotate }); + applied.push("rotate"); + } + + if (applied.length === 0) { + return toolFailure( + "blocked", + `nothing changed: ${Object.values(unchanged).join("; ")}`, + NO_OP_HINT, + ); + } + + return toolOk({ box: deps.readBox(selection), applied, unchanged }); +} + +export const STUDIO_TRANSFORM_INPUT_SCHEMA = { + type: "object", + properties: { + x: { type: "number", description: "New x offset in pixels. Must be paired with y." }, + y: { type: "number", description: "New y offset in pixels. Must be paired with x." }, + width: { type: "number", minimum: 0, description: "New width. Must be paired with height." }, + height: { type: "number", minimum: 0, description: "New height. Must be paired with width." }, + rotate: { type: "number", description: "Rotation in degrees." }, + }, + additionalProperties: false, +} as const; + +export const STUDIO_TRANSFORM_DESCRIPTION = [ + "Move, resize or rotate the CURRENTLY SELECTED element, the way a drag would.", + "Call studio_select first. Give x with y, and width with height.", + "The result's `box` is READ BACK after the write, not echoed from your request, and", + "`applied` lists what actually took effect. Check it.", + "Move and rotate are written as GSAP code, so in a composition with no GSAP timeline they", + "do nothing; that shows up in `unchanged` rather than as a false success.", + "Rotation is reported as dispatched rather than verified, because the CSS `rotate` property", + "does not appear in the element's computed transform.", + "Returns `ok: true`, or `ok: false` with `kind`, `reason` and a `hint`.", +].join(" "); diff --git a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx index 8431d84265..274b697c91 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx +++ b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx @@ -46,6 +46,10 @@ function deps(overrides: Partial = {}): StudioAgentToolsDe getWriteBlockedReason: () => null, setText: async () => ({ ok: true }), setStyle: async () => ({ ok: true }), + readBox: () => ({ x: 0, y: 0, width: 100, height: 50 }), + moveTo: async () => undefined, + resizeTo: async () => undefined, + rotateTo: async () => undefined, getGsapDiagnostics: () => ({ animations: [], multipleTimelines: false, @@ -119,6 +123,7 @@ describe("useStudioAgentTools", () => { "studio_inspect", "studio_set_text", "studio_set_style", + "studio_transform", ]); expect(trackEvent).toHaveBeenCalledWith("webmcp.native_present"); }); @@ -133,14 +138,14 @@ describe("useStudioAgentTools", () => { await act(async () => { harness = mountTools(deps({ getSnapshot: () => snapshot() })); }); - expect(registerTool).toHaveBeenCalledTimes(7); + expect(registerTool).toHaveBeenCalledTimes(8); await act(async () => { harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 5 }) })); harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 6 }) })); }); - expect(registerTool).toHaveBeenCalledTimes(7); + expect(registerTool).toHaveBeenCalledTimes(8); }); it("executes against the LATEST deps, not the ones present at registration", async () => { @@ -213,7 +218,7 @@ describe("useStudioAgentTools", () => { mountTools(deps({ getSnapshot: () => snapshot() })); }); - expect(registerTool).toHaveBeenCalledTimes(7); + expect(registerTool).toHaveBeenCalledTimes(8); }); it("reports a non-abort registration failure through production telemetry", async () => { diff --git a/packages/studio/src/webmcp/useStudioAgentTools.ts b/packages/studio/src/webmcp/useStudioAgentTools.ts index a8f25c61c8..f62c369bcd 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.ts +++ b/packages/studio/src/webmcp/useStudioAgentTools.ts @@ -52,6 +52,14 @@ import { type StudioSetStyleResult, type StudioSetTextResult, } from "./tools/contentTools"; +import { + studioTransform, + STUDIO_TRANSFORM_DESCRIPTION, + STUDIO_TRANSFORM_INPUT_SCHEMA, + type StudioTransformInput, + type StudioTransformResult, + type TransformToolDeps, +} from "./tools/transformTools"; const log = makeStudioDebugLogger("webmcp"); @@ -66,7 +74,7 @@ function reportRegistration(report: ToolRegistrationReport, native: boolean): vo } export interface StudioAgentToolsDeps - extends SelectionToolDeps, FrameToolDeps, InspectToolDeps, ContentToolDeps { + extends SelectionToolDeps, FrameToolDeps, InspectToolDeps, ContentToolDeps, TransformToolDeps { /** Read Studio's current state. Called per tool invocation, never cached. */ getSnapshot: () => StudioLookSnapshot; } @@ -156,6 +164,17 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }): execute: (input): Promise> => runToolBody("studio_set_style", () => studioSetStyle(depsRef.current, input)), }, + { + name: "studio_transform", + title: "Move, resize or rotate", + description: STUDIO_TRANSFORM_DESCRIPTION, + inputSchema: STUDIO_TRANSFORM_INPUT_SCHEMA, + annotations: { readOnlyHint: false }, + execute: (input): Promise> => + runToolBody("studio_transform", () => + studioTransform(depsRef.current, input as StudioTransformInput), + ), + }, ]; }