Skip to content

Commit f780203

Browse files
committed
fix(studio): harden targeted agent writes
1 parent 1a31bc1 commit f780203

32 files changed

Lines changed: 681 additions & 154 deletions

‎docs/guides/webmcp.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,9 @@ CSS selectors.
9898
| `studio_add_keyframe` | Adds a keyframe to an animation |
9999
| `studio_delete_animation` | Removes an animation |
100100

101-
Element writes run through the same commit actors Studio uses. A successful durable write names the
102-
source file and content version that accepted it, and it enters the same undo history.
101+
Element writes run through the same commit actors Studio uses. When that actor forwards versioned
102+
durability evidence, the receipt names the source file and content version, and the edit enters the
103+
same undo history. Actors without that evidence stay at `dispatched`.
103104

104105
## Two rules worth knowing
105106

‎packages/studio-server/src/routes/thumbnail.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,10 +388,30 @@ describe("registerThumbnailRoutes", () => {
388388
expect(await (await app.request(url)).text()).toBe("shared");
389389

390390
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(1);
391-
expect(getProjectSignature).toHaveBeenCalledTimes(3);
391+
expect(getProjectSignature).toHaveBeenCalledTimes(4);
392392
expect(getProjectSignature).toHaveBeenNthCalledWith(1, project.dir);
393393
});
394394

395+
it("does not cache generated pixels under a signature that changed in flight", async () => {
396+
const adapter = createAdapter();
397+
const project = await adapter.resolveProject("demo");
398+
if (!project) throw new Error("missing project");
399+
const signatures = ["old", "new", "old", "old"];
400+
adapter.getProjectSignature = vi.fn(() => signatures.shift() ?? "old");
401+
adapter.generateThumbnail = vi
402+
.fn()
403+
.mockResolvedValueOnce(Buffer.from("rendered-after-change"))
404+
.mockResolvedValueOnce(Buffer.from("rendered-old"));
405+
const app = new Hono();
406+
registerThumbnailRoutes(app, adapter);
407+
const url = "http://localhost/projects/demo/thumbnail/index.html?t=3";
408+
409+
expect(await (await app.request(url)).text()).toBe("rendered-after-change");
410+
expect(existsSync(join(project.dir, ".thumbnails"))).toBe(false);
411+
expect(await (await app.request(url)).text()).toBe("rendered-old");
412+
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
413+
});
414+
395415
it("keeps changed studio motion separated in the disk cache", async () => {
396416
const adapter = createAdapter();
397417
const project = await adapter.resolveProject("demo");

‎packages/studio-server/src/routes/thumbnail.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,17 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
212212
signal,
213213
});
214214
if (!generated) return null;
215+
const afterGeneration = await resolveProjectAndSignature(adapter, project.id);
216+
if (
217+
!afterGeneration ||
218+
afterGeneration.project.dir !== project.dir ||
219+
afterGeneration.signature !== projectSignature
220+
) {
221+
// The browser may have rendered content written after this request
222+
// captured its cache identity. Return the pixels to this caller,
223+
// but never file them under a signature they do not prove.
224+
return generated;
225+
}
215226
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
216227
writeThumbnailAtomically(cachePath, generated);
217228
return generated;

‎packages/studio/src/components/editor/domEditingLayers.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,15 @@ describe("resolveDomEditSelection — data-hf-group capture", () => {
126126
expect(selection?.selector).toBe('[data-hf-group="Group 1"]');
127127
});
128128

129+
it("resolves an explicit agent target without promoting it to the group", async () => {
130+
const { parent, child } = buildNestedGroups();
131+
const selection = await resolveDomEditSelection(child, { ...opts, exactTarget: true });
132+
document.body.removeChild(parent);
133+
134+
expect(selection?.element).toBe(child);
135+
expect(selection?.id).toBe("child");
136+
});
137+
129138
it("selects the next nested group when drilled into the outer group", async () => {
130139
const { parent, outer, inner, child } = buildNestedGroups();
131140
const selection = await resolveDomEditSelection(child, { ...opts, activeGroupElement: outer });

‎packages/studio/src/components/editor/domEditingLayers.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -281,10 +281,6 @@ export function resolveDomEditCapabilities(args: {
281281
).capabilities;
282282
}
283283

284-
// ─── Element label ────────────────────────────────────────────────────────────
285-
286-
// ─── Source probe ────────────────────────────────────────────────────────────
287-
288284
async function probeSourceElement(
289285
projectId: string,
290286
sourceFile: string,
@@ -310,17 +306,21 @@ async function probeSourceElement(
310306
}
311307
}
312308

313-
// ─── Selection resolution ────────────────────────────────────────────────────
314-
315309
// fallow-ignore-next-line complexity
316310
export async function resolveDomEditSelection(
317311
startEl: HTMLElement | null,
318-
options: DomEditContextOptions & { projectId?: string | null; skipSourceProbe?: boolean },
312+
options: DomEditContextOptions & {
313+
projectId?: string | null;
314+
skipSourceProbe?: boolean;
315+
exactTarget?: boolean;
316+
},
319317
): Promise<DomEditSelection | null> {
320318
if (!startEl) return null;
321319
const doc = startEl.ownerDocument;
322320

323-
let capture = resolveGroupCapture(startEl, options.activeGroupElement ?? null);
321+
let capture = options.exactTarget
322+
? ({ kind: "unit", element: startEl } as const)
323+
: resolveGroupCapture(startEl, options.activeGroupElement ?? null);
324324
if (capture.kind === "out-of-scope") {
325325
// Drill-in is non-sticky: clicking/hovering OUTSIDE the drilled-into group
326326
// exits it and resolves the target normally, rather than selecting nothing

‎packages/studio/src/contexts/DomEditContext.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface DomEditActionsValue extends Pick<
3232
| "handleDomTextFieldStyleCommit"
3333
| "handleDomAddTextField"
3434
| "handleDomRemoveTextField"
35+
| "getGsapAnimationsForSelection"
3536
| "handleAskAgent"
3637
| "handleAgentModalSubmit"
3738
| "handleBlockedDomMove"
@@ -174,6 +175,7 @@ export function DomEditProvider({
174175
handleDomTextFieldStyleCommit,
175176
handleDomAddTextField,
176177
handleDomRemoveTextField,
178+
getGsapAnimationsForSelection,
177179
handleAskAgent,
178180
handleAgentModalSubmit,
179181
handleBlockedDomMove,
@@ -265,6 +267,7 @@ export function DomEditProvider({
265267
handleDomTextFieldStyleCommit,
266268
handleDomAddTextField,
267269
handleDomRemoveTextField,
270+
getGsapAnimationsForSelection,
268271
handleAskAgent,
269272
handleAgentModalSubmit,
270273
handleBlockedDomMove,
@@ -338,6 +341,7 @@ export function DomEditProvider({
338341
handleDomTextFieldStyleCommit,
339342
handleDomAddTextField,
340343
handleDomRemoveTextField,
344+
getGsapAnimationsForSelection,
341345
handleAskAgent,
342346
handleAgentModalSubmit,
343347
handleBlockedDomMove,

‎packages/studio/src/hooks/useDomEditCommits.test.tsx‎

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { DomEditSelection, DomEditTextField } from "../components/editor/do
88
import type { ImportedFontAsset } from "../components/editor/fontAssets";
99
import { usePlayerStore } from "../player";
1010
import { StudioSaveHttpError } from "../utils/studioSaveDiagnostics";
11+
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
1112
import { trackStudioEvent } from "../utils/studioTelemetry";
1213
import type { CutoverResult } from "../utils/sdkCutover";
1314
import { useDomEditCommits } from "./useDomEditCommits";
@@ -39,6 +40,7 @@ interface RenderDomEditCommitsOptions {
3940
importedFontAssets?: ImportedFontAsset[];
4041
writeProjectFile?: (path: string, content: string, expectedContent?: string) => Promise<void>;
4142
onTrySdkPersist?: () => Promise<CutoverResult>;
43+
queueDomEditSave?: <T>(save: () => Promise<T>) => Promise<T>;
4244
}
4345

4446
type FetchHandler = (
@@ -214,7 +216,7 @@ function renderDomEditCommits(
214216
activeCompPath: "index.html",
215217
previewIframeRef,
216218
showToast,
217-
queueDomEditSave: async (save) => save(),
219+
queueDomEditSave: options.queueDomEditSave ?? (async (save) => save()),
218220
writeProjectFile: options.writeProjectFile ?? (async () => {}),
219221
domEditSaveTimestampRef,
220222
editHistory: { recordEdit },
@@ -1019,6 +1021,96 @@ describe("useDomEditCommits style persist handling", () => {
10191021
}
10201022
});
10211023

1024+
it("serializes the full read, write, and history transaction across overlapping commits", async () => {
1025+
const firstPatch = createDeferred<Response>();
1026+
let readCount = 0;
1027+
let patchCount = 0;
1028+
vi.stubGlobal(
1029+
"fetch",
1030+
vi.fn(async (input: Parameters<typeof fetch>[0]) => {
1031+
const url = requestUrl(input);
1032+
if (url.includes("/api/projects/p1/files/")) {
1033+
readCount += 1;
1034+
return jsonResponse({
1035+
content:
1036+
readCount === 1
1037+
? '<div data-hf-id="hf-card" style="color: red">Card</div>'
1038+
: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
1039+
});
1040+
}
1041+
if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
1042+
patchCount += 1;
1043+
if (patchCount === 1) return firstPatch.promise;
1044+
return jsonResponse({
1045+
ok: true,
1046+
changed: true,
1047+
matched: true,
1048+
content: '<div data-hf-id="hf-card" style="color: green">Card</div>',
1049+
path: "index.html",
1050+
version: '"sha256:green"',
1051+
});
1052+
}
1053+
throw new Error(`Unexpected fetch: ${url}`);
1054+
}),
1055+
);
1056+
const queue = createDomEditSaveQueue();
1057+
const { iframe, element } = createPreviewElement();
1058+
const rendered = renderDomEditCommits(createSelection(element), iframe, {
1059+
queueDomEditSave: queue.enqueue,
1060+
});
1061+
1062+
try {
1063+
const first = rendered.hook.handleDomStyleCommit("color", "blue");
1064+
await flushAsyncWork();
1065+
const second = rendered.hook.handleDomStyleCommit("color", "green");
1066+
await flushAsyncWork();
1067+
1068+
expect(readCount).toBe(1);
1069+
expect(patchCount).toBe(1);
1070+
1071+
firstPatch.resolve(
1072+
jsonResponse({
1073+
ok: true,
1074+
changed: true,
1075+
matched: true,
1076+
content: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
1077+
path: "index.html",
1078+
version: '"sha256:blue"',
1079+
}),
1080+
);
1081+
await first;
1082+
await second;
1083+
1084+
expect(readCount).toBe(2);
1085+
expect(patchCount).toBe(2);
1086+
expect(rendered.recordEdit).toHaveBeenNthCalledWith(
1087+
1,
1088+
expect.objectContaining({
1089+
files: {
1090+
"index.html": expect.objectContaining({
1091+
before: expect.stringContaining("color: red"),
1092+
after: expect.stringContaining("color: blue"),
1093+
}),
1094+
},
1095+
}),
1096+
);
1097+
expect(rendered.recordEdit).toHaveBeenNthCalledWith(
1098+
2,
1099+
expect.objectContaining({
1100+
files: {
1101+
"index.html": expect.objectContaining({
1102+
before: expect.stringContaining("color: blue"),
1103+
after: expect.stringContaining("color: green"),
1104+
}),
1105+
},
1106+
}),
1107+
);
1108+
} finally {
1109+
queue.destroy();
1110+
rendered.cleanup();
1111+
}
1112+
});
1113+
10221114
it("preserves the SDK cutover version as the style commit's durable evidence", async () => {
10231115
const fetchMock = stubPatchFetch({ ok: true, changed: true, matched: true });
10241116
const { iframe, element } = createPreviewElement();
@@ -1520,9 +1612,7 @@ describe("useDomEditCommits attribute persist handling", () => {
15201612
// optimistic apply) and succeeds before the older one rejects. Without the
15211613
// per-key version guard, the stale rejection would revert to the older
15221614
// commit's own previousValue (null) and stomp the newer commit's value.
1523-
const firstCommit = act(async () => {
1524-
await rendered.hook.handleDomHtmlAttributeCommit("muted", "first-value");
1525-
});
1615+
const firstCommit = rendered.hook.handleDomHtmlAttributeCommit("muted", "first-value");
15261616
await act(async () => {
15271617
await rendered.hook.handleDomHtmlAttributeCommit("muted", "second-value");
15281618
});

‎packages/studio/src/hooks/useDomEditCommits.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export function useDomEditCommits({
139139
const reportedUnresolvableRef = useRef(new Set<string>());
140140

141141
// fallow-ignore-next-line complexity
142-
const persistDomEditOperations: PersistDomEditOperations = useCallback(
142+
const performPersistDomEditOperations: PersistDomEditOperations = useCallback(
143143
// fallow-ignore-next-line complexity
144144
async (selection, operations, options) => {
145145
const pid = projectIdRef.current;
@@ -297,6 +297,12 @@ export function useDomEditCommits({
297297
],
298298
);
299299

300+
const persistDomEditOperations: PersistDomEditOperations = useCallback(
301+
(selection, operations, options) =>
302+
queueDomEditSave(() => performPersistDomEditOperations(selection, operations, options)),
303+
[performPersistDomEditOperations, queueDomEditSave],
304+
);
305+
300306
const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback(
301307
(batches, options) =>
302308
queueDomEditSave(
@@ -413,7 +419,6 @@ export function useDomEditCommits({
413419
const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
414420
activeCompPath,
415421
persistDomEditOperations,
416-
queueDomEditSave,
417422
showToast,
418423
});
419424

‎packages/studio/src/hooks/useDomEditPositionPatchCommit.test.tsx‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,13 @@ function renderCommit(params: Parameters<typeof useDomEditPositionPatchCommit>[0
5555
return captured.commit;
5656
}
5757

58-
function paramsWith(queueDomEditSave: (save: () => Promise<void>) => Promise<void>) {
58+
function paramsWith(persistDomEditOperations: () => Promise<undefined>) {
5959
const showToast = vi.fn();
6060
return {
6161
showToast,
6262
params: {
6363
activeCompPath: "index.html",
64-
persistDomEditOperations: vi.fn().mockResolvedValue(undefined),
65-
queueDomEditSave,
64+
persistDomEditOperations,
6665
showToast,
6766
},
6867
};
@@ -104,7 +103,7 @@ describe("useDomEditPositionPatchCommit", () => {
104103
});
105104

106105
it("resolves when the write lands", async () => {
107-
const { showToast, params } = paramsWith((save) => save());
106+
const { showToast, params } = paramsWith(() => Promise.resolve(undefined));
108107
const commit = renderCommit(params);
109108

110109
await act(async () => {

0 commit comments

Comments
 (0)