@hyperframes/studio 0.8.16 → 0.8.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-iAIHATIw.js → hyperframes-player-DejBDgXB.js} +1 -1
- package/dist/assets/index-BX3KHhGX.js +71 -0
- package/dist/assets/{index-D8o3ZIo2.js → index-CU6o8PuW.js} +128 -128
- package/dist/assets/{index-Cf-mbMRL.js → index-CklNVmi3.js} +1 -1
- package/dist/assets/{index-YmetcS6L.js → index-uu4Zd3BU.js} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +1638 -1280
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +1 -1
- package/src/components/editor/DomEditCropHandles.tsx +1 -1
- package/src/components/editor/DomEditOverlay.tsx +1 -1
- package/src/components/editor/DomEditSelectionChrome.tsx +1 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +38 -4
- package/src/components/editor/propertyPanelCommitField.tsx +1 -1
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +3 -3
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +1 -1
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +8 -8
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
- package/src/components/editor/propertyPanelStyleSections.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +1 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +8 -2
- package/src/components/editor/useInspectorGestureTransaction.ts +3 -3
- package/src/hooks/domEditCommitRunner.ts +47 -0
- package/src/hooks/useDomEditPositionPatchCommit.test.tsx +116 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +6 -1
- package/src/hooks/useDomEditTextCommits.test.tsx +175 -21
- package/src/hooks/useDomEditTextCommits.ts +17 -9
- package/src/hooks/useDomEditWiring.ts +1 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
- package/src/hooks/useDomGeometryCommits.ts +10 -2
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +81 -31
- package/src/hooks/useElementLifecycleOps.ts +11 -5
- package/src/hooks/useGsapSelectionHandlers.ts +4 -2
- package/src/utils/studioUiPreferences.ts +11 -0
- package/src/webmcp/StudioAgentTools.tsx +46 -0
- package/src/webmcp/handles.test.ts +130 -0
- package/src/webmcp/handles.ts +129 -0
- package/src/webmcp/polyfill.test.ts +98 -0
- package/src/webmcp/polyfill.ts +60 -0
- package/src/webmcp/registrar.test.ts +150 -0
- package/src/webmcp/registrar.ts +115 -0
- package/src/webmcp/toolResult.ts +67 -0
- package/src/webmcp/tools/lookTools.test.ts +225 -0
- package/src/webmcp/tools/lookTools.ts +201 -0
- package/src/webmcp/types.ts +90 -0
- package/src/webmcp/useStudioAgentTools.test.tsx +221 -0
- package/src/webmcp/useStudioAgentTools.ts +119 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { mountReactHarness } from "../hooks/domSelectionTestHarness";
|
|
5
|
+
import { writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
6
|
+
import { useStudioAgentTools, type StudioAgentToolsDeps } from "./useStudioAgentTools";
|
|
7
|
+
import type { ModelContext, ModelContextRegisterToolOptions, ModelContextTool } from "./types";
|
|
8
|
+
import type { StudioLookSnapshot } from "./tools/lookTools";
|
|
9
|
+
|
|
10
|
+
const trackEvent = vi.hoisted(() => vi.fn());
|
|
11
|
+
vi.mock("../telemetry/client", () => ({ trackEvent }));
|
|
12
|
+
|
|
13
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
14
|
+
|
|
15
|
+
let cleanup: (() => void) | null = null;
|
|
16
|
+
|
|
17
|
+
function snapshot(overrides: Partial<StudioLookSnapshot> = {}): StudioLookSnapshot {
|
|
18
|
+
return {
|
|
19
|
+
projectId: "demo",
|
|
20
|
+
compositionPath: "index.html",
|
|
21
|
+
currentTime: 0,
|
|
22
|
+
duration: 10,
|
|
23
|
+
isPlaying: false,
|
|
24
|
+
elements: [],
|
|
25
|
+
selection: null,
|
|
26
|
+
selectionAnimationCount: 0,
|
|
27
|
+
history: { canUndo: false, canRedo: false, undoLabel: null, redoLabel: null },
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Install a fake `document.modelContext` and report what got registered. */
|
|
33
|
+
function installModelContext() {
|
|
34
|
+
const registered: ModelContextTool[] = [];
|
|
35
|
+
const registerTool = vi.fn(
|
|
36
|
+
async (tool: ModelContextTool, _options?: ModelContextRegisterToolOptions) => {
|
|
37
|
+
registered.push(tool);
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
const modelContext: ModelContext = { registerTool };
|
|
41
|
+
Object.defineProperty(document, "modelContext", {
|
|
42
|
+
value: modelContext,
|
|
43
|
+
configurable: true,
|
|
44
|
+
writable: true,
|
|
45
|
+
});
|
|
46
|
+
return { registered, registerTool };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function removeModelContext() {
|
|
50
|
+
Reflect.deleteProperty(document, "modelContext");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mountTools(deps: StudioAgentToolsDeps) {
|
|
54
|
+
function Probe({ current }: { current: StudioAgentToolsDeps }) {
|
|
55
|
+
useStudioAgentTools(current);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const root = mountReactHarness(<Probe current={deps} />);
|
|
59
|
+
cleanup = () => act(() => root.unmount());
|
|
60
|
+
return {
|
|
61
|
+
rerenderWith(next: StudioAgentToolsDeps) {
|
|
62
|
+
act(() => root.render(<Probe current={next} />));
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
window.localStorage.clear();
|
|
69
|
+
trackEvent.mockReset();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
cleanup?.();
|
|
74
|
+
cleanup = null;
|
|
75
|
+
removeModelContext();
|
|
76
|
+
window.localStorage.clear();
|
|
77
|
+
vi.restoreAllMocks();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("useStudioAgentTools", () => {
|
|
81
|
+
it("registers the tool set once on mount", async () => {
|
|
82
|
+
const { registered } = installModelContext();
|
|
83
|
+
|
|
84
|
+
await act(async () => {
|
|
85
|
+
mountTools({ getSnapshot: () => snapshot() });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(registered.map((tool) => tool.name)).toEqual(["studio_look"]);
|
|
89
|
+
expect(trackEvent).toHaveBeenCalledWith("webmcp.native_present");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not re-register when the deps object changes identity", async () => {
|
|
93
|
+
// The regression test for the whole design. The DomEdit actions object
|
|
94
|
+
// changes identity on nearly every interaction; if registration depended on
|
|
95
|
+
// it, the signal would abort and unregister the tools each time.
|
|
96
|
+
const { registerTool } = installModelContext();
|
|
97
|
+
|
|
98
|
+
let harness: ReturnType<typeof mountTools> | null = null;
|
|
99
|
+
await act(async () => {
|
|
100
|
+
harness = mountTools({ getSnapshot: () => snapshot() });
|
|
101
|
+
});
|
|
102
|
+
expect(registerTool).toHaveBeenCalledTimes(1);
|
|
103
|
+
|
|
104
|
+
await act(async () => {
|
|
105
|
+
harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 5 }) });
|
|
106
|
+
harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 6 }) });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(registerTool).toHaveBeenCalledTimes(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("executes against the LATEST deps, not the ones present at registration", async () => {
|
|
113
|
+
// The other half of the ref: registering once must not freeze the state the
|
|
114
|
+
// tools read, or every answer after the first render would be stale.
|
|
115
|
+
const { registered } = installModelContext();
|
|
116
|
+
|
|
117
|
+
let harness: ReturnType<typeof mountTools> | null = null;
|
|
118
|
+
await act(async () => {
|
|
119
|
+
harness = mountTools({ getSnapshot: () => snapshot({ currentTime: 1 }) });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await act(async () => {
|
|
123
|
+
harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 42 }) });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const look = registered[0];
|
|
127
|
+
if (!look) throw new Error("expected studio_look to be registered");
|
|
128
|
+
const result = (await look.execute({}, { signal: new AbortController().signal })) as {
|
|
129
|
+
ok: boolean;
|
|
130
|
+
playhead: number;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
expect(result.ok).toBe(true);
|
|
134
|
+
expect(result.playhead).toBe(42);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("unregisters on unmount by aborting the registration signal", async () => {
|
|
138
|
+
const { registerTool } = installModelContext();
|
|
139
|
+
|
|
140
|
+
await act(async () => {
|
|
141
|
+
mountTools({ getSnapshot: () => snapshot() });
|
|
142
|
+
});
|
|
143
|
+
const signal = registerTool.mock.calls[0]?.[1]?.signal;
|
|
144
|
+
expect(signal?.aborted).toBe(false);
|
|
145
|
+
|
|
146
|
+
cleanup?.();
|
|
147
|
+
cleanup = null;
|
|
148
|
+
|
|
149
|
+
expect(signal?.aborted).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("registers nothing when the browser has no WebMCP", async () => {
|
|
153
|
+
removeModelContext();
|
|
154
|
+
|
|
155
|
+
await act(async () => {
|
|
156
|
+
mountTools({ getSnapshot: () => snapshot() });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// The assertion is that mounting did not throw; a browser without the API
|
|
160
|
+
// must still boot Studio.
|
|
161
|
+
expect(document).not.toHaveProperty("modelContext");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("registers nothing when the preference is turned off", async () => {
|
|
165
|
+
writeStudioUiPreferences({ agentToolsEnabled: false });
|
|
166
|
+
const { registerTool } = installModelContext();
|
|
167
|
+
|
|
168
|
+
await act(async () => {
|
|
169
|
+
mountTools({ getSnapshot: () => snapshot() });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(registerTool).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("registers when the preference is absent, because on is the default", async () => {
|
|
176
|
+
const { registerTool } = installModelContext();
|
|
177
|
+
|
|
178
|
+
await act(async () => {
|
|
179
|
+
mountTools({ getSnapshot: () => snapshot() });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(registerTool).toHaveBeenCalledTimes(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("reports a non-abort registration failure through production telemetry", async () => {
|
|
186
|
+
const { registerTool } = installModelContext();
|
|
187
|
+
registerTool.mockRejectedValue(new DOMException("blocked", "NotAllowedError"));
|
|
188
|
+
|
|
189
|
+
await act(async () => {
|
|
190
|
+
mountTools({ getSnapshot: () => snapshot() });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(trackEvent).toHaveBeenCalledWith("webmcp_registration_failed", {
|
|
194
|
+
error_name: "NotAllowedError",
|
|
195
|
+
tool_name: "studio_look",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("reports a tool that throws as an internal failure instead of rejecting", async () => {
|
|
200
|
+
const { registered } = installModelContext();
|
|
201
|
+
|
|
202
|
+
await act(async () => {
|
|
203
|
+
mountTools({
|
|
204
|
+
getSnapshot: () => {
|
|
205
|
+
throw new TypeError("handler signature moved");
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
210
|
+
|
|
211
|
+
const look = registered[0];
|
|
212
|
+
if (!look) throw new Error("expected studio_look to be registered");
|
|
213
|
+
const result = (await look.execute({}, { signal: new AbortController().signal })) as {
|
|
214
|
+
ok: boolean;
|
|
215
|
+
kind: string;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
expect(result.ok).toBe(false);
|
|
219
|
+
expect(result.kind).toBe("internal");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { trackEvent } from "../telemetry/client";
|
|
3
|
+
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
4
|
+
import { makeStudioDebugLogger } from "../utils/studioDebug";
|
|
5
|
+
import { loadModelContextPolyfill } from "./polyfill";
|
|
6
|
+
import { registerStudioTools, type ToolRegistrationReport } from "./registrar";
|
|
7
|
+
import { runToolBody, type ToolResult } from "./toolResult";
|
|
8
|
+
import { getModelContext, type ModelContext, type ModelContextTool } from "./types";
|
|
9
|
+
import {
|
|
10
|
+
buildStudioLook,
|
|
11
|
+
STUDIO_LOOK_DESCRIPTION,
|
|
12
|
+
STUDIO_LOOK_INPUT_SCHEMA,
|
|
13
|
+
type StudioLook,
|
|
14
|
+
type StudioLookInput,
|
|
15
|
+
type StudioLookSnapshot,
|
|
16
|
+
} from "./tools/lookTools";
|
|
17
|
+
|
|
18
|
+
const log = makeStudioDebugLogger("webmcp");
|
|
19
|
+
|
|
20
|
+
function reportRegistration(report: ToolRegistrationReport, native: boolean): void {
|
|
21
|
+
log("registered", { native, ...report });
|
|
22
|
+
for (const failure of report.failed) {
|
|
23
|
+
trackEvent("webmcp_registration_failed", {
|
|
24
|
+
error_name: failure.name,
|
|
25
|
+
tool_name: failure.tool,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StudioAgentToolsDeps {
|
|
31
|
+
/** Read Studio's current state. Called per tool invocation, never cached. */
|
|
32
|
+
getSnapshot: () => StudioLookSnapshot;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the tool list once.
|
|
37
|
+
*
|
|
38
|
+
* Every `execute` reads `depsRef.current` at CALL time rather than closing over
|
|
39
|
+
* a snapshot, which is what lets the list be built once and still see live
|
|
40
|
+
* state. That is the whole point of the ref: see the registration note below.
|
|
41
|
+
*/
|
|
42
|
+
function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }): ModelContextTool[] {
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
name: "studio_look",
|
|
46
|
+
title: "Look at the composition",
|
|
47
|
+
description: STUDIO_LOOK_DESCRIPTION,
|
|
48
|
+
inputSchema: STUDIO_LOOK_INPUT_SCHEMA,
|
|
49
|
+
annotations: {
|
|
50
|
+
readOnlyHint: true,
|
|
51
|
+
// The labels, text and ids come from the user's composition, which can
|
|
52
|
+
// contain anything. This is a hint to the agent, not a sanitiser.
|
|
53
|
+
untrustedContentHint: true,
|
|
54
|
+
},
|
|
55
|
+
execute: (input): Promise<ToolResult<StudioLook>> =>
|
|
56
|
+
runToolBody("studio_look", async () =>
|
|
57
|
+
buildStudioLook(depsRef.current.getSnapshot(), input as StudioLookInput),
|
|
58
|
+
),
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Register Studio's tools with the browser, exactly once per mount.
|
|
65
|
+
*
|
|
66
|
+
* The effect has an EMPTY dependency array on purpose, and the deps live in a
|
|
67
|
+
* ref that every render refreshes. The obvious alternative — depend on the
|
|
68
|
+
* handlers — re-runs on nearly every interaction, because the DomEdit actions
|
|
69
|
+
* object changes identity whenever the selection or the element list does.
|
|
70
|
+
* Re-running means the registration signal aborts and unregisters everything,
|
|
71
|
+
* `toolchange` fires constantly so a connected agent watches the tool list
|
|
72
|
+
* churn, and the spec warns that a quick unregister-then-reregister can apply
|
|
73
|
+
* an old call's arguments against the new schema.
|
|
74
|
+
*
|
|
75
|
+
* `useStudioTestHooks` carries a comment about the same class of bug already hit
|
|
76
|
+
* in this codebase, where effect teardown revoked a lease moments after it was
|
|
77
|
+
* taken because writing state changed the effect's dependency identities.
|
|
78
|
+
*
|
|
79
|
+
* Any fallback must be awaited inside this effect before registration and then
|
|
80
|
+
* re-read here. Installing one from a sibling effect would race this mount-only
|
|
81
|
+
* lookup. Hot-module replacement can still create a brief unregister/register
|
|
82
|
+
* window in development; production has one document-scoped registration.
|
|
83
|
+
*/
|
|
84
|
+
export function useStudioAgentTools(deps: StudioAgentToolsDeps): void {
|
|
85
|
+
const depsRef = useRef(deps);
|
|
86
|
+
depsRef.current = deps;
|
|
87
|
+
|
|
88
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (readStudioUiPreferences().agentToolsEnabled === false) {
|
|
91
|
+
log("skipped", { why: "disabled by preference" });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
|
|
97
|
+
void (async () => {
|
|
98
|
+
const native: ModelContext | null = getModelContext();
|
|
99
|
+
if (native) trackEvent("webmcp.native_present");
|
|
100
|
+
// Native browsers never download the polyfill.
|
|
101
|
+
const modelContext = native ?? (await loadModelContextPolyfill());
|
|
102
|
+
if (!modelContext) {
|
|
103
|
+
log("skipped", { why: "no model context, native or polyfilled" });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// The import is async, so the component may already be gone.
|
|
107
|
+
if (controller.signal.aborted) return;
|
|
108
|
+
|
|
109
|
+
const report = await registerStudioTools(
|
|
110
|
+
modelContext,
|
|
111
|
+
buildStudioTools(depsRef),
|
|
112
|
+
controller.signal,
|
|
113
|
+
);
|
|
114
|
+
reportRegistration(report, native !== null);
|
|
115
|
+
})();
|
|
116
|
+
|
|
117
|
+
return () => controller.abort();
|
|
118
|
+
}, []);
|
|
119
|
+
}
|