Skip to content

Commit f4e938f

Browse files
committed
feat(studio): let an agent drive Studio's selection and playhead
Adds `studio_select` and `studio_seek`, so an agent and the human are looking at the same element and the same instant. Selecting reveals the inspector, exactly as a click does, which is what makes the agent's move visible. Selection is shared state, not a per-call argument, and that is forced rather than chosen. Most of Studio's edit handlers read the ambient React selection, and `applyDomSelection` only schedules a state update, so selecting and committing inside ONE call would write to whatever was selected before. Two tool calls are separated by a render, so the contract is select first, then act. That is also how a human works: click, then type. `studio_seek` uses `requestSeek`, not `setCurrentTime`. The latter only moves the timeline's displayed number and leaves the composition where it was. Two things the tools refuse to fake: Seek does not clamp. `seek()` already clamps against the adapter's duration, which can differ from the store's, and clamping again would give that invariant two owners that can disagree. The tool reports where the playhead actually landed instead, read back afterwards. `requestSeek` is fire-and-forget, so it cannot report that no adapter was mounted to receive it. The tool compares the playhead before and after and fails rather than claiming a seek that never happened. Select separates three failures that a single message would have merged: the preview is not mounted yet (wait), no element matches the handle (re-read), and the element cannot be selected (try a neighbour). The agent's next move differs for each, so collapsing them would cost it a round trip or a retry loop.
1 parent 097d901 commit f4e938f

6 files changed

Lines changed: 487 additions & 32 deletions

File tree

packages/studio/src/webmcp/StudioAgentTools.tsx

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { useCallback } from "react";
2-
import { useDomEditSelectionContext } from "../contexts/DomEditContext";
1+
import { useCallback, useMemo } from "react";
2+
import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
33
import { useStudioShellContext } from "../contexts/StudioContext";
44
import { usePlayerStore } from "../player";
5-
import { useStudioAgentTools } from "./useStudioAgentTools";
5+
import { useStudioAgentTools, type StudioAgentToolsDeps } from "./useStudioAgentTools";
66
import type { StudioLookSnapshot } from "./tools/lookTools";
77

88
/**
@@ -12,14 +12,15 @@ import type { StudioLookSnapshot } from "./tools/lookTools";
1212
* contexts are only readable below `DomEditProvider`, which `App` renders, and
1313
* `App.tsx` sits three lines under the 600-line cap.
1414
*
15-
* The player store is read IMPERATIVELY through `getState()` inside the
16-
* snapshot callback rather than subscribed to. Subscribing to `currentTime`
17-
* would re-render this component on every animation frame during playback for
18-
* a value nothing here displays.
15+
* The player store is read IMPERATIVELY through `getState()` rather than
16+
* subscribed to. Subscribing to `currentTime` would re-render this component on
17+
* every animation frame during playback for a value nothing here displays.
1918
*/
2019
export function StudioAgentTools() {
2120
const { projectId, activeCompPath, editHistory } = useStudioShellContext();
2221
const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext();
22+
const { previewIframeRef, buildDomSelectionFromTarget, applyDomSelection } =
23+
useDomEditActionsContext();
2324

2425
const getSnapshot = useCallback((): StudioLookSnapshot => {
2526
const player = usePlayerStore.getState();
@@ -41,6 +42,25 @@ export function StudioAgentTools() {
4142
};
4243
}, [projectId, activeCompPath, domEditSelection, selectedGsapAnimations, editHistory]);
4344

44-
useStudioAgentTools({ getSnapshot });
45+
const deps = useMemo<StudioAgentToolsDeps>(
46+
() => ({
47+
getSnapshot,
48+
getPreviewDocument: () => previewIframeRef.current?.contentDocument ?? null,
49+
buildSelection: (element) => buildDomSelectionFromTarget(element),
50+
applySelection: (selection) => applyDomSelection(selection, { revealPanel: true }),
51+
requestSeek: (time) => usePlayerStore.getState().requestSeek(time),
52+
readPlayhead: () => {
53+
const player = usePlayerStore.getState();
54+
return {
55+
currentTime: player.currentTime,
56+
duration: player.duration,
57+
isPlaying: player.isPlaying,
58+
};
59+
},
60+
}),
61+
[getSnapshot, previewIframeRef, buildDomSelectionFromTarget, applyDomSelection],
62+
);
63+
64+
useStudioAgentTools(deps);
4565
return null;
4666
}

packages/studio/src/webmcp/toolResult.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function toolOk<T extends object>(value: T): { ok: true } & T {
3636
return { ok: true, ...value };
3737
}
3838

39-
function toolFailure(kind: ToolFailureKind, reason: string, hint?: string): ToolFailure {
39+
export function toolFailure(kind: ToolFailureKind, reason: string, hint?: string): ToolFailure {
4040
return hint ? { ok: false, kind, reason, hint } : { ok: false, kind, reason };
4141
}
4242

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// @vitest-environment jsdom
2+
import { describe, expect, it, vi } from "vitest";
3+
import type { DomEditSelection } from "../../components/editor/domEditingTypes";
4+
import {
5+
studioSeek,
6+
studioSelect,
7+
type SelectionToolDeps,
8+
type StudioSeekResult,
9+
type StudioSelectResult,
10+
} from "./selectionTools";
11+
import type { ToolFailure, ToolResult } from "../toolResult";
12+
13+
function previewDoc(html: string): Document {
14+
const iframe = document.createElement("iframe");
15+
document.body.append(iframe);
16+
const doc = iframe.contentDocument;
17+
if (!doc) throw new Error("expected iframe document");
18+
doc.body.innerHTML = html;
19+
return doc;
20+
}
21+
22+
function selectionFor(element: HTMLElement): DomEditSelection {
23+
return {
24+
id: element.id || undefined,
25+
hfId: element.getAttribute("data-hf-id") ?? undefined,
26+
element,
27+
label: "Headline",
28+
tagName: element.tagName.toLowerCase(),
29+
sourceFile: "index.html",
30+
compositionPath: "index.html",
31+
isCompositionHost: false,
32+
isInsideLockedComposition: false,
33+
boundingBox: { x: 40, y: 12, width: 880, height: 96 },
34+
textContent: element.textContent,
35+
dataAttributes: {},
36+
inlineStyles: {},
37+
computedStyles: {},
38+
textFields: [],
39+
capabilities: {
40+
canSelect: true,
41+
canEditStyles: true,
42+
canCrop: true,
43+
canMove: true,
44+
canResize: true,
45+
canApplyManualOffset: true,
46+
canApplyManualSize: true,
47+
canApplyManualRotation: true,
48+
},
49+
};
50+
}
51+
52+
function selectionDeps(overrides: Partial<SelectionToolDeps> = {}): SelectionToolDeps {
53+
return {
54+
getPreviewDocument: () => null,
55+
buildSelection: async (element) => selectionFor(element),
56+
applySelection: () => undefined,
57+
requestSeek: () => undefined,
58+
readPlayhead: () => ({ currentTime: 0, duration: 10, isPlaying: false }),
59+
...overrides,
60+
};
61+
}
62+
63+
function expectFailure(result: ToolResult<unknown>): ToolFailure {
64+
if (result.ok) throw new Error(`expected failure, got ${JSON.stringify(result)}`);
65+
return result;
66+
}
67+
68+
function expectOk<T>(result: ToolResult<T>): { ok: true } & T {
69+
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
70+
return result;
71+
}
72+
73+
describe("studioSelect", () => {
74+
it("applies the selection a click would produce and reports it back", async () => {
75+
const doc = previewDoc('<h1 id="headline" data-hf-id="abc">Ship it</h1>');
76+
const applySelection = vi.fn();
77+
78+
const result = await studioSelect(
79+
selectionDeps({ getPreviewDocument: () => doc, applySelection }),
80+
"hf:abc",
81+
);
82+
83+
const ok = expectOk<StudioSelectResult>(result);
84+
expect(ok.handle).toBe("hf:abc");
85+
expect(ok.label).toBe("Headline");
86+
expect(ok.box.width).toBe(880);
87+
// Reveals the inspector, which is what makes the human see what the agent did.
88+
expect(applySelection).toHaveBeenCalledTimes(1);
89+
});
90+
91+
it("distinguishes a preview that is not mounted from a handle that does not match", async () => {
92+
const notMounted = expectFailure(await studioSelect(selectionDeps(), "dom:headline"));
93+
expect(notMounted.kind).toBe("blocked");
94+
expect(notMounted.reason).toMatch(/not mounted/);
95+
96+
const doc = previewDoc('<h1 id="headline">Ship it</h1>');
97+
const noMatch = expectFailure(
98+
await studioSelect(selectionDeps({ getPreviewDocument: () => doc }), "dom:missing"),
99+
);
100+
expect(noMatch.kind).toBe("invalid");
101+
expect(noMatch.reason).toMatch(/no element matches/);
102+
// The two must not be the same message: waiting and re-reading are different fixes.
103+
expect(noMatch.reason).not.toBe(notMounted.reason);
104+
});
105+
106+
it("reports an element Studio cannot build a selection for, as a third case", async () => {
107+
const doc = previewDoc('<h1 id="headline">Ship it</h1>');
108+
109+
const result = expectFailure(
110+
await studioSelect(
111+
selectionDeps({ getPreviewDocument: () => doc, buildSelection: async () => null }),
112+
"dom:headline",
113+
),
114+
);
115+
116+
expect(result.kind).toBe("blocked");
117+
expect(result.reason).toMatch(/cannot select/);
118+
});
119+
120+
it("rejects a missing handle without touching the preview", async () => {
121+
const getPreviewDocument = vi.fn(() => null);
122+
123+
const result = expectFailure(await studioSelect(selectionDeps({ getPreviewDocument }), " "));
124+
125+
expect(result.kind).toBe("invalid");
126+
expect(getPreviewDocument).not.toHaveBeenCalled();
127+
});
128+
129+
it("leaves the existing selection alone when it fails", async () => {
130+
const doc = previewDoc('<h1 id="headline">Ship it</h1>');
131+
const applySelection = vi.fn();
132+
133+
await studioSelect(
134+
selectionDeps({ getPreviewDocument: () => doc, applySelection }),
135+
"dom:missing",
136+
);
137+
138+
expect(applySelection).not.toHaveBeenCalled();
139+
});
140+
});
141+
142+
describe("studioSeek", () => {
143+
it("reports where the playhead landed, not what was requested", () => {
144+
// The player clamps against the ADAPTER's duration, which the wrapper
145+
// deliberately does not second-guess.
146+
let currentTime = 0;
147+
const result = studioSeek(
148+
selectionDeps({
149+
requestSeek: () => {
150+
currentTime = 10;
151+
},
152+
readPlayhead: () => ({ currentTime, duration: 10, isPlaying: false }),
153+
}),
154+
999,
155+
);
156+
157+
const ok = expectOk<StudioSeekResult>(result);
158+
expect(ok.playhead).toBe(10);
159+
expect(ok.moved).toBe(true);
160+
});
161+
162+
it("reports that playback stopped", () => {
163+
let isPlaying = true;
164+
let currentTime = 0;
165+
const result = studioSeek(
166+
selectionDeps({
167+
requestSeek: () => {
168+
currentTime = 2;
169+
isPlaying = false;
170+
},
171+
readPlayhead: () => ({ currentTime, duration: 10, isPlaying }),
172+
}),
173+
2,
174+
);
175+
176+
expect(expectOk<StudioSeekResult>(result).isPlaying).toBe(false);
177+
});
178+
179+
it("fails rather than claiming a seek the player never received", () => {
180+
// `requestSeek` is fire-and-forget: with no adapter mounted it silently does
181+
// nothing, and reporting ok would be a lie the agent builds on.
182+
const result = expectFailure(
183+
studioSeek(
184+
selectionDeps({ readPlayhead: () => ({ currentTime: 0, duration: 10, isPlaying: false }) }),
185+
5,
186+
),
187+
);
188+
189+
expect(result.kind).toBe("blocked");
190+
expect(result.reason).toMatch(/did not move/);
191+
});
192+
193+
it("succeeds when asked to seek to where the playhead already is", () => {
194+
const result = studioSeek(
195+
selectionDeps({ readPlayhead: () => ({ currentTime: 3, duration: 10, isPlaying: false }) }),
196+
3,
197+
);
198+
199+
// Nothing moved, but nothing failed either, and `moved` says which.
200+
const ok = expectOk<StudioSeekResult>(result);
201+
expect(ok.moved).toBe(false);
202+
expect(ok.playhead).toBe(3);
203+
});
204+
205+
it("rejects a non-finite time without calling the player", () => {
206+
const requestSeek = vi.fn();
207+
208+
for (const time of [Number.NaN, Number.POSITIVE_INFINITY]) {
209+
const result = expectFailure(studioSeek(selectionDeps({ requestSeek }), time));
210+
expect(result.kind).toBe("invalid");
211+
}
212+
expect(requestSeek).not.toHaveBeenCalled();
213+
});
214+
});

0 commit comments

Comments
 (0)