@hyperframes/studio 0.8.23 → 0.8.25

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.
Files changed (90) hide show
  1. package/dist/assets/{hyperframes-player-oZ6g4tHJ.js → hyperframes-player-CTP_00ix.js} +1 -1
  2. package/dist/assets/{index-UyvIa_cE.js → index-Bjb4YqS_.js} +1 -1
  3. package/dist/assets/{index-Tiu74FPg.js → index-DSk_ALFr.js} +218 -218
  4. package/dist/assets/{index-DW-H7Y2b.js → index-Di-8Jxqc.js} +1 -1
  5. package/dist/assets/index-DnRfAiK2.css +1 -0
  6. package/dist/{chunk-6BT6DTB4.js → chunk-A7S5SGNA.js} +2 -2
  7. package/dist/chunk-A7S5SGNA.js.map +1 -0
  8. package/dist/{domEditingLayers-URA7BLWE.js → domEditingLayers-JMMVOGTO.js} +2 -2
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6206 -5011
  12. package/dist/index.js.map +1 -1
  13. package/package.json +8 -7
  14. package/src/components/editor/TopologyLens.test.tsx +294 -0
  15. package/src/components/editor/TopologyLens.tsx +206 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  17. package/src/components/editor/domEditOverlayGeometryBatch.test.ts +18 -0
  18. package/src/components/editor/domEditingLayers.test.ts +9 -0
  19. package/src/components/editor/domEditingLayers.ts +8 -8
  20. package/src/components/editor/topologyLensGeometry.test.ts +44 -0
  21. package/src/components/editor/topologyLensGeometry.ts +119 -0
  22. package/src/components/editor/topologyLensState.test.ts +116 -0
  23. package/src/components/editor/topologyLensState.ts +72 -0
  24. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +126 -0
  25. package/src/components/nle/PreviewOverlays.tsx +24 -18
  26. package/src/components/sidebar/CompositionsTab.drag.test.tsx +16 -0
  27. package/src/components/sidebar/CompositionsTab.tsx +10 -3
  28. package/src/components/ui/HyperframesLoader.tsx +3 -48
  29. package/src/components/ui/HyperframesMark.tsx +53 -0
  30. package/src/contexts/DomEditContext.tsx +12 -0
  31. package/src/hooks/domEditCommitRunner.test.ts +59 -0
  32. package/src/hooks/domEditCommitRunner.ts +55 -20
  33. package/src/hooks/domEditCommitTypes.ts +8 -1
  34. package/src/hooks/domEditTextCommitPlan.ts +47 -0
  35. package/src/hooks/useDomEditAttributeCommits.ts +4 -1
  36. package/src/hooks/useDomEditCommits.test.tsx +207 -8
  37. package/src/hooks/useDomEditCommits.ts +54 -14
  38. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
  40. package/src/hooks/useDomEditSession.ts +6 -2
  41. package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
  42. package/src/hooks/useDomEditTextCommits.ts +64 -86
  43. package/src/hooks/useDomEditWiring.ts +1 -1
  44. package/src/hooks/useDomSelection.ts +2 -0
  45. package/src/hooks/useDomSelectionTypes.ts +2 -0
  46. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
  47. package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
  48. package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
  49. package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
  50. package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
  51. package/src/hooks/useGsapAnimationOps.ts +4 -4
  52. package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
  53. package/src/hooks/useGsapAwareEditing.ts +31 -10
  54. package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
  55. package/src/hooks/useGsapSelectionHandlers.ts +21 -11
  56. package/src/hooks/useRenderClipContent.test.ts +27 -0
  57. package/src/hooks/useRenderClipContent.ts +5 -0
  58. package/src/hooks/useStudioExternalFileChanges.ts +5 -0
  59. package/src/player/components/CompositionThumbnail.test.ts +66 -1
  60. package/src/player/components/CompositionThumbnail.tsx +6 -0
  61. package/src/player/store/playerStore.test.ts +24 -0
  62. package/src/player/store/thumbnailSlice.ts +6 -0
  63. package/src/styles/studio.css +179 -0
  64. package/src/utils/domEditSaveQueue.test.ts +5 -3
  65. package/src/utils/domEditSaveQueue.ts +6 -1
  66. package/src/webmcp/StudioAgentTools.test.ts +70 -0
  67. package/src/webmcp/StudioAgentTools.tsx +89 -21
  68. package/src/webmcp/handles.test.ts +172 -15
  69. package/src/webmcp/handles.ts +152 -35
  70. package/src/webmcp/tools/animationTools.test.ts +241 -60
  71. package/src/webmcp/tools/animationTools.ts +191 -104
  72. package/src/webmcp/tools/contentTools.test.ts +103 -41
  73. package/src/webmcp/tools/contentTools.ts +149 -105
  74. package/src/webmcp/tools/inspectTools.test.ts +47 -5
  75. package/src/webmcp/tools/inspectTools.ts +59 -19
  76. package/src/webmcp/tools/lookTools.test.ts +185 -38
  77. package/src/webmcp/tools/lookTools.ts +134 -21
  78. package/src/webmcp/tools/selectionTools.test.ts +105 -9
  79. package/src/webmcp/tools/selectionTools.ts +34 -19
  80. package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
  81. package/src/webmcp/tools/transformTools.test.ts +140 -54
  82. package/src/webmcp/tools/transformTools.ts +215 -56
  83. package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
  84. package/src/webmcp/useStudioAgentTools.ts +27 -15
  85. package/src/webmcp/webmcpTestUtils.ts +37 -0
  86. package/src/webmcp/writeCoordinator.test.ts +375 -0
  87. package/src/webmcp/writeCoordinator.ts +473 -0
  88. package/dist/assets/index-yGhfxxoL.css +0 -1
  89. package/dist/chunk-6BT6DTB4.js.map +0 -1
  90. /package/dist/{domEditingLayers-URA7BLWE.js.map → domEditingLayers-JMMVOGTO.js.map} +0 -0
@@ -3,9 +3,11 @@ import { act } from "react";
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { mountReactHarness } from "../hooks/domSelectionTestHarness";
5
5
  import { writeStudioUiPreferences } from "../utils/studioUiPreferences";
6
+ import { mintElementHandle } from "./handles";
6
7
  import { useStudioAgentTools, type StudioAgentToolsDeps } from "./useStudioAgentTools";
7
8
  import type { ModelContext, ModelContextRegisterToolOptions, ModelContextTool } from "./types";
8
9
  import type { StudioLookSnapshot } from "./tools/lookTools";
10
+ import { previewDoc, selectionFor } from "./webmcpTestUtils";
9
11
 
10
12
  const trackEvent = vi.hoisted(() => vi.fn());
11
13
  vi.mock("../telemetry/client", () => ({ trackEvent }));
@@ -22,6 +24,7 @@ function snapshot(overrides: Partial<StudioLookSnapshot> = {}): StudioLookSnapsh
22
24
  duration: 10,
23
25
  isPlaying: false,
24
26
  elements: [],
27
+ scene: { status: "ready", items: [], drillInItem: null },
25
28
  selection: null,
26
29
  selectionAnimationCount: 0,
27
30
  history: { canUndo: false, canRedo: false, undoLabel: null, redoLabel: null },
@@ -50,10 +53,11 @@ function deps(overrides: Partial<StudioAgentToolsDeps> = {}): StudioAgentToolsDe
50
53
  moveTo: async () => undefined,
51
54
  resizeTo: async () => undefined,
52
55
  rotateTo: async () => undefined,
53
- addAnimation: () => undefined,
56
+ addAnimation: async () => true,
54
57
  updateAnimation: async () => true,
55
58
  addKeyframe: async () => undefined,
56
- deleteAnimation: () => undefined,
59
+ deleteAnimation: async () => true,
60
+ getAnimationsForSelection: async () => [],
57
61
  getGsapDiagnostics: () => ({
58
62
  animations: [],
59
63
  multipleTimelines: false,
@@ -63,6 +67,40 @@ function deps(overrides: Partial<StudioAgentToolsDeps> = {}): StudioAgentToolsDe
63
67
  };
64
68
  }
65
69
 
70
+ async function executeRegistered<T>(
71
+ registered: ModelContextTool[],
72
+ name: string,
73
+ input: object,
74
+ signal: AbortSignal = new AbortController().signal,
75
+ ): Promise<T> {
76
+ const tool = registered.find((candidate) => candidate.name === name);
77
+ if (!tool) throw new Error(`expected ${name} to be registered`);
78
+ return (await tool.execute(input, { signal })) as T;
79
+ }
80
+
81
+ function mountedTargetDeps(overrides: Partial<StudioAgentToolsDeps> = {}) {
82
+ const doc = previewDoc('<h1 id="human">Human</h1><h1 id="agent">Agent</h1>');
83
+ const human = doc.getElementById("human") as HTMLElement;
84
+ const agent = doc.getElementById("agent") as HTMLElement;
85
+ const agentHandle = mintElementHandle({
86
+ projectId: "demo",
87
+ domId: "agent",
88
+ sourceFile: "index.html",
89
+ activeCompositionPath: "index.html",
90
+ });
91
+ if (!agentHandle) throw new Error("expected agent handle");
92
+ return {
93
+ agent,
94
+ agentHandle,
95
+ currentSelection: selectionFor(human),
96
+ deps: deps({
97
+ getPreviewDocument: () => doc,
98
+ buildSelection: async (element) => selectionFor(element),
99
+ ...overrides,
100
+ }),
101
+ };
102
+ }
103
+
66
104
  /** Install a fake `document.modelContext` and report what got registered. */
67
105
  function installModelContext() {
68
106
  const registered: ModelContextTool[] = [];
@@ -80,6 +118,12 @@ function installModelContext() {
80
118
  return { registered, registerTool };
81
119
  }
82
120
 
121
+ async function executeFirstRegistered<T>(registered: ModelContextTool[]): Promise<T> {
122
+ const look = registered[0];
123
+ if (!look) throw new Error("expected studio_look to be registered");
124
+ return (await look.execute({}, { signal: new AbortController().signal })) as T;
125
+ }
126
+
83
127
  function removeModelContext() {
84
128
  Reflect.deleteProperty(document, "modelContext");
85
129
  }
@@ -170,12 +214,10 @@ describe("useStudioAgentTools", () => {
170
214
  harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 42 }) }));
171
215
  });
172
216
 
173
- const look = registered[0];
174
- if (!look) throw new Error("expected studio_look to be registered");
175
- const result = (await look.execute({}, { signal: new AbortController().signal })) as {
217
+ const result = await executeFirstRegistered<{
176
218
  ok: boolean;
177
219
  playhead: number;
178
- };
220
+ }>(registered);
179
221
 
180
222
  expect(result.ok).toBe(true);
181
223
  expect(result.playhead).toBe(42);
@@ -257,14 +299,272 @@ describe("useStudioAgentTools", () => {
257
299
  });
258
300
  vi.spyOn(console, "error").mockImplementation(() => {});
259
301
 
260
- const look = registered[0];
261
- if (!look) throw new Error("expected studio_look to be registered");
262
- const result = (await look.execute({}, { signal: new AbortController().signal })) as {
302
+ const result = await executeFirstRegistered<{
263
303
  ok: boolean;
264
304
  kind: string;
265
- };
305
+ }>(registered);
266
306
 
267
307
  expect(result.ok).toBe(false);
268
308
  expect(result.kind).toBe("internal");
269
309
  });
310
+
311
+ it("requires a source-safe handle on every source-writing tool", async () => {
312
+ const { registered } = installModelContext();
313
+ await act(async () => mountTools(deps()));
314
+
315
+ for (const name of [
316
+ "studio_set_text",
317
+ "studio_set_style",
318
+ "studio_transform",
319
+ "studio_add_animation",
320
+ "studio_update_animation",
321
+ "studio_add_keyframe",
322
+ "studio_delete_animation",
323
+ ]) {
324
+ const schema = registered.find((tool) => tool.name === name)?.inputSchema as {
325
+ required?: string[];
326
+ };
327
+ expect(schema.required, name).toContain("handle");
328
+ }
329
+ });
330
+
331
+ it("binds a text write to the explicit handle even when human selection points elsewhere", async () => {
332
+ const { registered } = installModelContext();
333
+ const targeted = mountedTargetDeps();
334
+ const setText = vi.fn(
335
+ async () =>
336
+ ({
337
+ ok: true,
338
+ persistence: {
339
+ sourceFile: "index.html",
340
+ version: '"sha256:after"',
341
+ changed: true,
342
+ },
343
+ }) as const,
344
+ );
345
+ await act(async () => mountTools({ ...targeted.deps, setText }));
346
+
347
+ const result = await executeRegistered<{
348
+ ok: boolean;
349
+ stage: string;
350
+ changed: boolean;
351
+ target: { handle: string; sourceFile: string };
352
+ }>(registered, "studio_set_text", {
353
+ handle: targeted.agentHandle,
354
+ text: "Edited by agent",
355
+ });
356
+
357
+ expect(setText).toHaveBeenCalledWith(
358
+ expect.objectContaining({ element: targeted.agent }),
359
+ "Edited by agent",
360
+ "self",
361
+ );
362
+ expect(result).toMatchObject({
363
+ ok: true,
364
+ stage: "saved",
365
+ changed: true,
366
+ target: { handle: targeted.agentHandle, sourceFile: "index.html" },
367
+ });
368
+ });
369
+
370
+ it("refuses a locked target and an early abort before invoking the write actor", async () => {
371
+ const { registered } = installModelContext();
372
+ const setText = vi.fn();
373
+ const targeted = mountedTargetDeps({
374
+ buildSelection: async (element) => selectionFor(element, { isInsideLockedComposition: true }),
375
+ });
376
+ await act(async () => mountTools({ ...targeted.deps, setText }));
377
+
378
+ const locked = await executeRegistered<{ ok: boolean; stage: string }>(
379
+ registered,
380
+ "studio_set_text",
381
+ { handle: targeted.agentHandle, text: "No" },
382
+ );
383
+ const controller = new AbortController();
384
+ controller.abort();
385
+ const aborted = await executeRegistered<{ ok: boolean; stage: string; cancelRequested: true }>(
386
+ registered,
387
+ "studio_set_text",
388
+ { handle: targeted.agentHandle, text: "No" },
389
+ controller.signal,
390
+ );
391
+
392
+ expect(locked).toMatchObject({ ok: false, stage: "refused" });
393
+ expect(aborted).toMatchObject({ ok: false, stage: "refused", cancelRequested: true });
394
+ expect(setText).not.toHaveBeenCalled();
395
+ });
396
+
397
+ it("reports dispatched, saved, and verified only from their corresponding evidence", async () => {
398
+ const { registered } = installModelContext();
399
+ const targeted = mountedTargetDeps();
400
+ const box = { x: 0, y: 0, width: 100, height: 50 };
401
+ await act(async () =>
402
+ mountTools({
403
+ ...targeted.deps,
404
+ setText: async () => ({
405
+ ok: true,
406
+ persistence: {
407
+ sourceFile: "index.html",
408
+ version: '"sha256:after"',
409
+ changed: true,
410
+ },
411
+ }),
412
+ addAnimation: async () => true,
413
+ readBox: () => ({ ...box }),
414
+ resizeTo: async (_selection, next) => {
415
+ Object.assign(box, next);
416
+ return {
417
+ ok: true,
418
+ persistence: {
419
+ sourceFile: "index.html",
420
+ version: '"sha256:transform"',
421
+ changed: true,
422
+ },
423
+ } as const;
424
+ },
425
+ }),
426
+ );
427
+
428
+ const dispatched = await executeRegistered<{ stage: string }>(
429
+ registered,
430
+ "studio_add_animation",
431
+ { handle: targeted.agentHandle, method: "to" },
432
+ );
433
+ const saved = await executeRegistered<{ stage: string }>(registered, "studio_set_text", {
434
+ handle: targeted.agentHandle,
435
+ text: "Saved",
436
+ });
437
+ const verified = await executeRegistered<{ stage: string }>(registered, "studio_transform", {
438
+ handle: targeted.agentHandle,
439
+ width: 200,
440
+ height: 80,
441
+ });
442
+
443
+ expect(dispatched.stage).toBe("dispatched");
444
+ expect(saved.stage).toBe("saved");
445
+ expect(verified.stage).toBe("verified");
446
+ });
447
+
448
+ it("reports a matched no-op with its durable version without fabricating a change", async () => {
449
+ const { registered } = installModelContext();
450
+ const targeted = mountedTargetDeps();
451
+ await act(async () =>
452
+ mountTools({
453
+ ...targeted.deps,
454
+ setText: async () => ({
455
+ ok: true,
456
+ persistence: {
457
+ sourceFile: "index.html",
458
+ version: '"sha256:current"',
459
+ changed: false,
460
+ },
461
+ }),
462
+ }),
463
+ );
464
+
465
+ const result = await executeRegistered<{
466
+ ok: boolean;
467
+ stage: string;
468
+ changed: boolean;
469
+ evidence: { version: string };
470
+ }>(registered, "studio_set_text", {
471
+ handle: targeted.agentHandle,
472
+ text: "Agent",
473
+ });
474
+
475
+ expect(result).toMatchObject({
476
+ ok: true,
477
+ stage: "saved",
478
+ changed: false,
479
+ evidence: { version: '"sha256:current"' },
480
+ });
481
+ });
482
+
483
+ it("reports the actual saved outcome and late cancellation without promising rollback", async () => {
484
+ const { registered } = installModelContext();
485
+ const targeted = mountedTargetDeps();
486
+ let finish: (() => void) | undefined;
487
+ const setText = vi.fn(
488
+ () =>
489
+ new Promise<{
490
+ ok: true;
491
+ persistence: { sourceFile: string; version: string; changed: true };
492
+ }>((resolve) => {
493
+ finish = () =>
494
+ resolve({
495
+ ok: true,
496
+ persistence: {
497
+ sourceFile: "index.html",
498
+ version: '"sha256:late"',
499
+ changed: true,
500
+ },
501
+ });
502
+ }),
503
+ );
504
+ await act(async () => mountTools({ ...targeted.deps, setText }));
505
+ const controller = new AbortController();
506
+
507
+ const pending = executeRegistered<{
508
+ ok: boolean;
509
+ stage: string;
510
+ cancelRequested?: boolean;
511
+ }>(
512
+ registered,
513
+ "studio_set_text",
514
+ { handle: targeted.agentHandle, text: "Late" },
515
+ controller.signal,
516
+ );
517
+ await vi.waitFor(() => expect(setText).toHaveBeenCalledTimes(1));
518
+ controller.abort();
519
+ finish?.();
520
+
521
+ expect(await pending).toMatchObject({
522
+ ok: true,
523
+ stage: "saved",
524
+ cancelRequested: true,
525
+ });
526
+ expect(setText).toHaveBeenCalledTimes(1);
527
+ });
528
+
529
+ it("aggregates partial style evidence and uses the weakest applied assurance", async () => {
530
+ const { registered } = installModelContext();
531
+ const targeted = mountedTargetDeps();
532
+ const setStyle = vi.fn(async (_selection, property: string) => {
533
+ if (property === "left") return { ok: false, reason: "geometry-property" } as const;
534
+ if (property === "opacity") return { ok: true } as const;
535
+ return {
536
+ ok: true,
537
+ persistence: {
538
+ sourceFile: "index.html",
539
+ version: '"sha256:color"',
540
+ changed: true,
541
+ },
542
+ } as const;
543
+ });
544
+ await act(async () => mountTools({ ...targeted.deps, setStyle }));
545
+
546
+ const result = await executeRegistered<{
547
+ ok: boolean;
548
+ stage: string;
549
+ partial: boolean;
550
+ applied: Record<string, string>;
551
+ rejected: Record<string, string>;
552
+ propertyReceipts: Record<string, { stage: string }>;
553
+ }>(registered, "studio_set_style", {
554
+ handle: targeted.agentHandle,
555
+ styles: { color: "red", left: "10px", opacity: "0.5" },
556
+ });
557
+
558
+ expect(result).toMatchObject({
559
+ ok: true,
560
+ stage: "dispatched",
561
+ partial: true,
562
+ applied: { color: "red", opacity: "0.5" },
563
+ rejected: { left: "geometry-property" },
564
+ propertyReceipts: {
565
+ color: { stage: "saved" },
566
+ opacity: { stage: "dispatched" },
567
+ },
568
+ });
569
+ });
270
570
  });
@@ -177,8 +177,10 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
177
177
  description: STUDIO_SET_TEXT_DESCRIPTION,
178
178
  inputSchema: STUDIO_SET_TEXT_INPUT_SCHEMA,
179
179
  annotations: { readOnlyHint: false, untrustedContentHint: true },
180
- execute: (input): Promise<ToolResult<StudioSetTextResult>> =>
181
- runToolBody("studio_set_text", () => studioSetText(depsRef.current, input)),
180
+ execute: (input, { signal }): Promise<ToolResult<StudioSetTextResult>> =>
181
+ runToolBody<StudioSetTextResult>("studio_set_text", () =>
182
+ studioSetText(depsRef.current, input, signal),
183
+ ),
182
184
  },
183
185
  {
184
186
  name: "studio_set_style",
@@ -186,8 +188,10 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
186
188
  description: STUDIO_SET_STYLE_DESCRIPTION,
187
189
  inputSchema: STUDIO_SET_STYLE_INPUT_SCHEMA,
188
190
  annotations: { readOnlyHint: false },
189
- execute: (input): Promise<ToolResult<StudioSetStyleResult>> =>
190
- runToolBody("studio_set_style", () => studioSetStyle(depsRef.current, input)),
191
+ execute: (input, { signal }): Promise<ToolResult<StudioSetStyleResult>> =>
192
+ runToolBody<StudioSetStyleResult>("studio_set_style", () =>
193
+ studioSetStyle(depsRef.current, input, signal),
194
+ ),
191
195
  },
192
196
  {
193
197
  name: "studio_transform",
@@ -195,9 +199,9 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
195
199
  description: STUDIO_TRANSFORM_DESCRIPTION,
196
200
  inputSchema: STUDIO_TRANSFORM_INPUT_SCHEMA,
197
201
  annotations: { readOnlyHint: false },
198
- execute: (input): Promise<ToolResult<StudioTransformResult>> =>
199
- runToolBody("studio_transform", () =>
200
- studioTransform(depsRef.current, input as StudioTransformInput),
202
+ execute: (input, { signal }): Promise<ToolResult<StudioTransformResult>> =>
203
+ runToolBody<StudioTransformResult>("studio_transform", () =>
204
+ studioTransform(depsRef.current, input as StudioTransformInput, signal),
201
205
  ),
202
206
  },
203
207
  {
@@ -206,8 +210,10 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
206
210
  description: STUDIO_ADD_ANIMATION_DESCRIPTION,
207
211
  inputSchema: STUDIO_ADD_ANIMATION_INPUT_SCHEMA,
208
212
  annotations: { readOnlyHint: false },
209
- execute: (input): Promise<ToolResult<StudioAddAnimationResult>> =>
210
- runToolBody("studio_add_animation", () => studioAddAnimation(depsRef.current, input)),
213
+ execute: (input, { signal }): Promise<ToolResult<StudioAddAnimationResult>> =>
214
+ runToolBody<StudioAddAnimationResult>("studio_add_animation", () =>
215
+ studioAddAnimation(depsRef.current, input, signal),
216
+ ),
211
217
  },
212
218
  {
213
219
  name: "studio_update_animation",
@@ -215,8 +221,10 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
215
221
  description: STUDIO_UPDATE_ANIMATION_DESCRIPTION,
216
222
  inputSchema: STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA,
217
223
  annotations: { readOnlyHint: false },
218
- execute: (input): Promise<ToolResult<StudioUpdateAnimationResult>> =>
219
- runToolBody("studio_update_animation", () => studioUpdateAnimation(depsRef.current, input)),
224
+ execute: (input, { signal }): Promise<ToolResult<StudioUpdateAnimationResult>> =>
225
+ runToolBody<StudioUpdateAnimationResult>("studio_update_animation", () =>
226
+ studioUpdateAnimation(depsRef.current, input, signal),
227
+ ),
220
228
  },
221
229
  {
222
230
  name: "studio_add_keyframe",
@@ -224,8 +232,10 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
224
232
  description: STUDIO_ADD_KEYFRAME_DESCRIPTION,
225
233
  inputSchema: STUDIO_ADD_KEYFRAME_INPUT_SCHEMA,
226
234
  annotations: { readOnlyHint: false },
227
- execute: (input): Promise<ToolResult<StudioAddKeyframeResult>> =>
228
- runToolBody("studio_add_keyframe", () => studioAddKeyframe(depsRef.current, input)),
235
+ execute: (input, { signal }): Promise<ToolResult<StudioAddKeyframeResult>> =>
236
+ runToolBody<StudioAddKeyframeResult>("studio_add_keyframe", () =>
237
+ studioAddKeyframe(depsRef.current, input, signal),
238
+ ),
229
239
  },
230
240
  {
231
241
  name: "studio_delete_animation",
@@ -233,8 +243,10 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
233
243
  description: STUDIO_DELETE_ANIMATION_DESCRIPTION,
234
244
  inputSchema: STUDIO_DELETE_ANIMATION_INPUT_SCHEMA,
235
245
  annotations: { readOnlyHint: false },
236
- execute: (input): Promise<ToolResult<StudioDeleteAnimationResult>> =>
237
- runToolBody("studio_delete_animation", () => studioDeleteAnimation(depsRef.current, input)),
246
+ execute: (input, { signal }): Promise<ToolResult<StudioDeleteAnimationResult>> =>
247
+ runToolBody<StudioDeleteAnimationResult>("studio_delete_animation", () =>
248
+ studioDeleteAnimation(depsRef.current, input, signal),
249
+ ),
238
250
  },
239
251
  ];
240
252
  }
@@ -8,6 +8,9 @@
8
8
  import { expect } from "vitest";
9
9
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
10
  import type { ToolFailure, ToolResult } from "./toolResult";
11
+ import type { SelectionToolDeps } from "./tools/selectionTools";
12
+ import { mintElementHandle } from "./handles";
13
+ import type { TargetedWriteDeps } from "./writeCoordinator";
11
14
 
12
15
  /**
13
16
  * An element inside a real iframe, which is where Studio's chrome expects to
@@ -78,6 +81,40 @@ export function selectionFor(
78
81
  };
79
82
  }
80
83
 
84
+ export function selectionToolDeps(overrides: Partial<SelectionToolDeps> = {}): SelectionToolDeps {
85
+ return {
86
+ getPreviewDocument: () => null,
87
+ getCompositionPath: () => "index.html",
88
+ getProjectId: () => "project-a",
89
+ buildSelection: async (element) => selectionFor(element),
90
+ applySelection: () => undefined,
91
+ requestSeek: () => undefined,
92
+ readPlayhead: () => ({ currentTime: 0, duration: 10, isPlaying: false }),
93
+ ...overrides,
94
+ };
95
+ }
96
+
97
+ export function sourceHandle(domId: string, projectId = "project-a"): string {
98
+ const handle = mintElementHandle({
99
+ projectId,
100
+ domId,
101
+ sourceFile: "index.html",
102
+ activeCompositionPath: "index.html",
103
+ });
104
+ if (!handle) throw new Error(`expected source handle for #${domId}`);
105
+ return handle;
106
+ }
107
+
108
+ export function targetedWriteDeps(selection: DomEditSelection): TargetedWriteDeps {
109
+ return {
110
+ getPreviewDocument: () => selection.element.ownerDocument,
111
+ getProjectId: () => "project-a",
112
+ getWriteBlockedReason: () => null,
113
+ buildSelection: async () => selection,
114
+ applySelection: () => undefined,
115
+ };
116
+ }
117
+
81
118
  export function expectOk<T>(result: ToolResult<T>): { ok: true } & T {
82
119
  expect(result.ok, `expected ok, got ${JSON.stringify(result)}`).toBe(true);
83
120
  if (!result.ok) throw new Error("unreachable");