@hyperframes/studio 0.8.139 → 0.8.140

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 (72) hide show
  1. package/dist/assets/{hyperframes-player-CckOoCJ8.js → hyperframes-player-BLuyxnK7.js} +1 -1
  2. package/dist/assets/{index-DsEkE5_G.js → index-CWfuDCoA.js} +1 -1
  3. package/dist/assets/{index-BmIPrb8I.js → index-Dr3Bo9uH.js} +1 -1
  4. package/dist/assets/{index-Br1Whc0f.js → index-VAHDM_zL.js} +204 -204
  5. package/dist/{chunk-E422JDF5.js → chunk-H2DUOXTF.js} +2 -1
  6. package/dist/index.d.ts +19 -3
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +6498 -5813
  9. package/dist/index.js.map +1 -1
  10. package/dist/player.js +1 -1
  11. package/package.json +8 -8
  12. package/src/components/TimelineToolbar.test.tsx +79 -1
  13. package/src/components/TimelineToolbar.tsx +14 -29
  14. package/src/components/editor/DomEditOverlay.tsx +2 -2
  15. package/src/components/editor/PropertyPanel.tsx +1 -0
  16. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  17. package/src/components/editor/manualEdits.ts +1 -1
  18. package/src/components/editor/manualEditsParsing.ts +14 -2
  19. package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
  20. package/src/components/editor/propertyPanelFill.tsx +36 -13
  21. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
  22. package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
  23. package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
  24. package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
  25. package/src/components/nle/NLEPreview.test.ts +44 -1
  26. package/src/components/nle/NLEPreview.tsx +7 -2
  27. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  28. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  29. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  30. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  31. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  32. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  33. package/src/hooks/useSdkSession.ts +4 -1
  34. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  35. package/src/hooks/useThumbnailStripSize.ts +23 -1
  36. package/src/index.ts +1 -0
  37. package/src/player/components/AudioWaveform.test.tsx +153 -1
  38. package/src/player/components/AudioWaveform.tsx +93 -37
  39. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  40. package/src/player/components/Timeline.test.ts +3 -1
  41. package/src/player/components/TimelineCanvas.tsx +4 -0
  42. package/src/player/components/TimelineClip.tsx +20 -6
  43. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  44. package/src/player/components/TimelineClipFades.tsx +4 -4
  45. package/src/player/components/TimelineClipJoins.tsx +23 -18
  46. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  47. package/src/player/components/TimelineGroupRow.tsx +1 -0
  48. package/src/player/components/TimelineLaneClip.tsx +127 -0
  49. package/src/player/components/TimelineLanes.test.tsx +51 -6
  50. package/src/player/components/TimelineLanes.tsx +166 -178
  51. package/src/player/components/TimelineRuler.tsx +2 -0
  52. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  53. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  54. package/src/player/components/VideoThumbnail.tsx +37 -9
  55. package/src/player/components/thumbnailUtils.test.ts +17 -3
  56. package/src/player/components/thumbnailUtils.ts +17 -1
  57. package/src/player/components/timelineLayout.test.ts +3 -3
  58. package/src/player/components/timelineMotion.test.ts +27 -0
  59. package/src/player/components/timelineMotion.ts +62 -0
  60. package/src/player/components/timelineZoomInput.test.ts +438 -0
  61. package/src/player/components/timelineZoomInput.ts +456 -0
  62. package/src/player/components/useTimelineGeometry.ts +0 -3
  63. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  64. package/src/player/components/useTimelinePlayhead.ts +53 -59
  65. package/src/player/components/useTimelineProviderState.tsx +16 -21
  66. package/src/player/components/useTimelineZoom.ts +7 -12
  67. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  68. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  69. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  70. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  71. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  72. /package/dist/{chunk-E422JDF5.js.map → chunk-H2DUOXTF.js.map} +0 -0
@@ -41,7 +41,11 @@ async function mountCoordinator(overrides: Partial<CoordinatorOptions> = {}) {
41
41
  return null;
42
42
  }
43
43
  await act(async () => root.render(<Probe />));
44
- return { captured, options };
44
+ const rerender = async (next: Partial<CoordinatorOptions>) => {
45
+ Object.assign(options, next);
46
+ await act(async () => root.render(<Probe />));
47
+ };
48
+ return { captured, options, rerender };
45
49
  }
46
50
 
47
51
  describe("external file change coordinator", () => {
@@ -64,6 +68,443 @@ describe("external file change coordinator", () => {
64
68
  vi.unstubAllGlobals();
65
69
  });
66
70
 
71
+ describe("SSE reconnect recovery", () => {
72
+ let source: EventTarget;
73
+ let close: ReturnType<typeof vi.fn>;
74
+
75
+ beforeEach(() => {
76
+ vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", undefined);
77
+ source = new EventTarget();
78
+ close = vi.fn();
79
+ vi.stubGlobal(
80
+ "EventSource",
81
+ class {
82
+ addEventListener = source.addEventListener.bind(source);
83
+ close = close;
84
+ },
85
+ );
86
+ });
87
+
88
+ const open = () =>
89
+ act(async () => {
90
+ source.dispatchEvent(new Event("open"));
91
+ });
92
+ const send = (payload: object) =>
93
+ act(async () => {
94
+ source.dispatchEvent(new MessageEvent("file-change", { data: JSON.stringify(payload) }));
95
+ });
96
+
97
+ it("delivers the held reconnect scope when a queued clean drain precedes React's commit", async () => {
98
+ let finishAcceptance = () => {};
99
+ const accepted = new Promise<void>((resolve) => {
100
+ finishAcceptance = resolve;
101
+ });
102
+ const blockedAtSecondDrain: Array<ExternalFileChangeCoordinatorHandle["blocked"]> = [];
103
+ const onAcceptedPersistedFileChange = vi.fn(() => finishAcceptance());
104
+ const drainPendingChanges = vi.fn(async () => {
105
+ if (drainPendingChanges.mock.calls.length === 1) {
106
+ return { status: "failed" as const, error: new Error("save rejected") };
107
+ }
108
+ blockedAtSecondDrain.push(captured.handle?.blocked ?? null);
109
+ return { status: "clean" as const };
110
+ });
111
+ const { captured, options } = await mountCoordinator({
112
+ activeCompPath: "film.html",
113
+ drainPendingChanges,
114
+ onAcceptedPersistedFileChange,
115
+ refreshFileTree: vi.fn(),
116
+ });
117
+ await open();
118
+ await act(async () => {
119
+ source.dispatchEvent(new Event("open"));
120
+ source.dispatchEvent(
121
+ new MessageEvent("file-change", {
122
+ data: JSON.stringify({
123
+ path: "scenes/nested.html",
124
+ affectsPreview: false,
125
+ affectedCompositions: ["scenes/nested.html"],
126
+ }),
127
+ }),
128
+ );
129
+ await accepted;
130
+ });
131
+
132
+ expect(blockedAtSecondDrain).toEqual([null]);
133
+ expect(drainPendingChanges).toHaveBeenCalledTimes(2);
134
+ expect(options.reloadPreview).toHaveBeenCalledOnce();
135
+ expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith(".");
136
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null);
137
+ expect(options.refreshFileTree).toHaveBeenCalledOnce();
138
+ expect(captured.handle?.blocked).toBeNull();
139
+ });
140
+
141
+ it("waits for pending edits before refreshing Preview, SDK, thumbnails and tree on each reconnect", async () => {
142
+ let finishDrain = () => {};
143
+ const pendingEdit = new Promise<void>((resolve) => {
144
+ finishDrain = resolve;
145
+ });
146
+ const { options } = await mountCoordinator({
147
+ drainPendingChanges: vi.fn(async () => {
148
+ await pendingEdit;
149
+ return { status: "clean" as const };
150
+ }),
151
+ refreshFileTree: vi.fn(),
152
+ });
153
+ await open();
154
+ expect(options.drainPendingChanges).not.toHaveBeenCalled();
155
+ await open();
156
+ expect(options.drainPendingChanges).toHaveBeenCalledOnce();
157
+ expect(options.reloadPreview).not.toHaveBeenCalled();
158
+ expect(options.reloadSdkSession).not.toHaveBeenCalled();
159
+ await act(async () => finishDrain());
160
+ expect(options.reloadPreview).toHaveBeenCalledOnce();
161
+ expect(options.reloadSdkSession).toHaveBeenCalledWith(".");
162
+ expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledWith(".", null);
163
+ expect(options.refreshFileTree).toHaveBeenCalledOnce();
164
+ await open();
165
+ expect(options.reloadPreview).toHaveBeenCalledTimes(2);
166
+ expect(options.reloadSdkSession).toHaveBeenCalledTimes(2);
167
+ expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledTimes(2);
168
+ expect(options.refreshFileTree).toHaveBeenCalledTimes(2);
169
+ });
170
+
171
+ it("reloads every session for a project-directory change, not just the current composition", async () => {
172
+ let finishDrain = () => {};
173
+ const pendingEdit = new Promise<void>((resolve) => {
174
+ finishDrain = resolve;
175
+ });
176
+ const { options } = await mountCoordinator({
177
+ activeCompPath: "scenes/intro.html",
178
+ drainPendingChanges: vi.fn(async () => {
179
+ await pendingEdit;
180
+ return { status: "clean" as const };
181
+ }),
182
+ });
183
+ await act(async () => {
184
+ source.dispatchEvent(
185
+ new MessageEvent("file-change", {
186
+ data: JSON.stringify({
187
+ path: ".",
188
+ projectId: "project-a",
189
+ affectsPreview: true,
190
+ affectedCompositions: null,
191
+ }),
192
+ }),
193
+ );
194
+ });
195
+ expect(options.drainPendingChanges).toHaveBeenCalledOnce();
196
+ expect(options.reloadPreview).not.toHaveBeenCalled();
197
+ expect(options.reloadSdkSession).not.toHaveBeenCalled();
198
+ await act(async () => finishDrain());
199
+ expect(options.reloadPreview).toHaveBeenCalledOnce();
200
+ expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith(".");
201
+ expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null);
202
+ });
203
+
204
+ it("holds a reconnect behind a persisted conflict until the user accepts the external file", async () => {
205
+ const conflict = new StudioFileConflictError({
206
+ filePath: "index.html",
207
+ currentVersion: "v2",
208
+ currentContent: "external",
209
+ attemptedContent: "studio",
210
+ });
211
+ const { captured, options } = await mountCoordinator({
212
+ drainPendingChanges: vi.fn(async () => ({ status: "conflict" as const, error: conflict })),
213
+ });
214
+ await open();
215
+ await open();
216
+ expect(options.persistConflictSnapshot).toHaveBeenCalledWith("project-a", conflict);
217
+ expect(captured.handle?.blocked).toMatchObject({ status: "conflict", error: conflict });
218
+ expect(options.reloadPreview).not.toHaveBeenCalled();
219
+ expect(options.reloadSdkSession).not.toHaveBeenCalled();
220
+ expect(options.onAcceptedPersistedFileChange).not.toHaveBeenCalled();
221
+ await act(async () => captured.handle?.useExternalFile());
222
+ expect(options.discardPendingChanges).toHaveBeenCalledOnce();
223
+ expect(options.reloadPreview).toHaveBeenCalledOnce();
224
+ expect(options.reloadSdkSession).toHaveBeenCalledWith(".");
225
+ });
226
+
227
+ it("reloads every session after navigation and when no composition is selected", async () => {
228
+ const { options, rerender } = await mountCoordinator();
229
+ await open();
230
+ await rerender({ activeCompPath: "scenes/next.html" });
231
+ await open();
232
+ expect(options.reloadSdkSession).toHaveBeenLastCalledWith(".");
233
+ await rerender({ activeCompPath: null });
234
+ await open();
235
+ expect(options.reloadSdkSession).toHaveBeenCalledTimes(2);
236
+ expect(options.reloadSdkSession).toHaveBeenLastCalledWith(".");
237
+ expect(close).not.toHaveBeenCalled();
238
+ });
239
+
240
+ it("keeps a reconnect's project-wide reload when a file change queues behind it", async () => {
241
+ let finishDrain = () => {};
242
+ const pendingEdit = new Promise<void>((resolve) => {
243
+ finishDrain = resolve;
244
+ });
245
+ const reloadSdkSession = vi.fn();
246
+ await mountCoordinator({
247
+ reloadSdkSession,
248
+ drainPendingChanges: vi
249
+ .fn()
250
+ .mockImplementationOnce(async () => {
251
+ await pendingEdit;
252
+ return { status: "clean" as const };
253
+ })
254
+ .mockImplementation(async () => ({ status: "clean" as const })),
255
+ });
256
+ await send({ path: "notes.md", version: "n1" });
257
+ await open();
258
+ await open();
259
+ await send({ path: "index.html", version: "v2" });
260
+ await act(async () => finishDrain());
261
+ expect(reloadSdkSession.mock.calls.map(([path]) => path)).toEqual(["notes.md", "."]);
262
+ });
263
+
264
+ it("delivers a held reconnect's project-wide reload when a later change saves cleanly", async () => {
265
+ const { options } = await mountCoordinator({
266
+ drainPendingChanges: vi
267
+ .fn()
268
+ .mockResolvedValueOnce({ status: "failed" as const, error: new Error("offline") })
269
+ .mockResolvedValue({ status: "clean" as const }),
270
+ getPendingCandidate: () => ({ path: "script.js", content: "unsaved script" }),
271
+ persistFailureSnapshot: vi.fn(async () => undefined),
272
+ });
273
+ await open();
274
+ await open();
275
+ expect(options.reloadSdkSession).not.toHaveBeenCalled();
276
+ await send({ path: "scenes/nested.html", version: "n1", affectsPreview: false });
277
+ expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith(".");
278
+ expect(options.reloadPreview).toHaveBeenCalledOnce();
279
+ expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null);
280
+ });
281
+
282
+ it("keeps the unsaved draft of the file open in the code panel when a reconnect cannot save it", async () => {
283
+ const failure = new Error("network unavailable");
284
+ const persistFailureSnapshot = vi.fn(async () => undefined);
285
+ const readProjectFile = vi.fn(async () => "disk script");
286
+ const onUseExternalFile = vi.fn();
287
+ const { captured, options } = await mountCoordinator({
288
+ drainPendingChanges: vi.fn(async () => ({ status: "failed" as const, error: failure })),
289
+ getPendingCandidate: () => ({ path: "script.js", content: "unsaved script" }),
290
+ persistFailureSnapshot,
291
+ readProjectFile,
292
+ onUseExternalFile,
293
+ });
294
+ await open();
295
+ await open();
296
+ expect(captured.handle?.blocked).toMatchObject({
297
+ status: "failed",
298
+ path: "script.js",
299
+ studioContent: "unsaved script",
300
+ });
301
+ expect(persistFailureSnapshot).toHaveBeenCalledExactlyOnceWith(
302
+ "project-a",
303
+ "script.js",
304
+ "unsaved script",
305
+ null,
306
+ null,
307
+ failure,
308
+ );
309
+ await act(async () => captured.handle?.useExternalFile());
310
+ expect(readProjectFile).toHaveBeenCalledWith("script.js");
311
+ expect(onUseExternalFile).toHaveBeenCalledWith("script.js", "disk script");
312
+ expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith(".");
313
+ });
314
+
315
+ it("keeps the drafted file's recovery off another file's external version", async () => {
316
+ const failure = new Error("network unavailable");
317
+ const persistFailureSnapshot = vi.fn(async () => undefined);
318
+ const { captured } = await mountCoordinator({
319
+ drainPendingChanges: vi.fn(async () => ({ status: "failed" as const, error: failure })),
320
+ getPendingCandidate: () => ({ path: "script.js", content: "unsaved script" }),
321
+ persistFailureSnapshot,
322
+ });
323
+ await send({ path: "index.html", version: "v2", content: "agent html" });
324
+ expect(persistFailureSnapshot).toHaveBeenCalledExactlyOnceWith(
325
+ "project-a",
326
+ "script.js",
327
+ "unsaved script",
328
+ null,
329
+ null,
330
+ failure,
331
+ );
332
+ expect(captured.handle?.blocked).toMatchObject({
333
+ path: "script.js",
334
+ payload: { path: "index.html" },
335
+ });
336
+ });
337
+
338
+ it("keeps foreign project changes out of recovery", async () => {
339
+ const { options } = await mountCoordinator();
340
+ await open();
341
+ await act(async () => {
342
+ source.dispatchEvent(
343
+ new MessageEvent("file-change", {
344
+ data: JSON.stringify({ path: "index.html", projectId: "project-b", version: "v2" }),
345
+ }),
346
+ );
347
+ });
348
+ expect(options.drainPendingChanges).not.toHaveBeenCalled();
349
+ await open();
350
+ expect(options.reloadPreview).toHaveBeenCalledOnce();
351
+ });
352
+
353
+ it("uses the new project's owner when a reconnect arrives during the previous drain", async () => {
354
+ let finishDrain = () => {};
355
+ const pendingEdit = new Promise<void>((resolve) => {
356
+ finishDrain = resolve;
357
+ });
358
+ const first = {
359
+ drainPendingChanges: vi.fn(async () => {
360
+ await pendingEdit;
361
+ return { status: "clean" as const };
362
+ }),
363
+ reloadPreview: vi.fn(),
364
+ reloadSdkSession: vi.fn(),
365
+ onAcceptedPersistedFileChange: vi.fn(),
366
+ };
367
+ const { rerender } = await mountCoordinator(first);
368
+ await open();
369
+ await open();
370
+ const next = {
371
+ projectId: "project-b",
372
+ activeCompPath: "scenes/next.html",
373
+ drainPendingChanges: vi.fn(async () => ({ status: "clean" as const })),
374
+ reloadPreview: vi.fn(),
375
+ reloadSdkSession: vi.fn(),
376
+ onAcceptedPersistedFileChange: vi.fn(),
377
+ };
378
+ await rerender(next);
379
+ await open();
380
+ expect(next.drainPendingChanges).not.toHaveBeenCalled();
381
+ await act(async () => finishDrain());
382
+ expect(first.drainPendingChanges).toHaveBeenCalledOnce();
383
+ expect(first.reloadPreview).not.toHaveBeenCalled();
384
+ expect(first.reloadSdkSession).not.toHaveBeenCalled();
385
+ expect(first.onAcceptedPersistedFileChange).not.toHaveBeenCalled();
386
+ expect(next.drainPendingChanges).toHaveBeenCalledOnce();
387
+ expect(next.reloadPreview).toHaveBeenCalledOnce();
388
+ expect(next.reloadSdkSession).toHaveBeenCalledExactlyOnceWith(".");
389
+ expect(next.onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null);
390
+ });
391
+
392
+ it("persists a queued reconnect conflict with the new project's owner", async () => {
393
+ let finishDrain = () => {};
394
+ const pendingEdit = new Promise<void>((resolve) => {
395
+ finishDrain = resolve;
396
+ });
397
+ const first = {
398
+ drainPendingChanges: vi.fn(async () => {
399
+ await pendingEdit;
400
+ return { status: "clean" as const };
401
+ }),
402
+ persistConflictSnapshot: vi.fn(async () => undefined),
403
+ reloadPreview: vi.fn(),
404
+ reloadSdkSession: vi.fn(),
405
+ };
406
+ const { captured, rerender } = await mountCoordinator(first);
407
+ await open();
408
+ await open();
409
+ const conflict = new StudioFileConflictError({
410
+ filePath: "scenes/next.html",
411
+ currentVersion: "v2",
412
+ currentContent: "external",
413
+ attemptedContent: "studio",
414
+ });
415
+ const next = {
416
+ projectId: "project-b",
417
+ activeCompPath: "scenes/next.html",
418
+ drainPendingChanges: vi.fn(async () => ({ status: "conflict" as const, error: conflict })),
419
+ persistConflictSnapshot: vi.fn(async () => undefined),
420
+ reloadPreview: vi.fn(),
421
+ reloadSdkSession: vi.fn(),
422
+ };
423
+ await rerender(next);
424
+ await open();
425
+ await act(async () => finishDrain());
426
+ expect(next.drainPendingChanges).toHaveBeenCalledOnce();
427
+ expect(next.persistConflictSnapshot).toHaveBeenCalledExactlyOnceWith("project-b", conflict);
428
+ expect(first.persistConflictSnapshot).not.toHaveBeenCalled();
429
+ expect(first.reloadPreview).not.toHaveBeenCalled();
430
+ expect(first.reloadSdkSession).not.toHaveBeenCalled();
431
+ expect(next.reloadPreview).not.toHaveBeenCalled();
432
+ expect(next.reloadSdkSession).not.toHaveBeenCalled();
433
+ expect(captured.handle?.blocked).toMatchObject({ status: "conflict", error: conflict });
434
+ });
435
+
436
+ it.each([
437
+ { label: "the project changes", projectId: "project-b", activeCompPath: "index.html" },
438
+ { label: "the project is left", projectId: null, activeCompPath: null },
439
+ ])("drops queued old-scope changes when $label", async ({ projectId, activeCompPath }) => {
440
+ let finishDrain = () => {};
441
+ const pendingEdit = new Promise<void>((resolve) => {
442
+ finishDrain = resolve;
443
+ });
444
+ const first = {
445
+ drainPendingChanges: vi.fn(async () => {
446
+ await pendingEdit;
447
+ return { status: "clean" as const };
448
+ }),
449
+ reloadPreview: vi.fn(),
450
+ reloadSdkSession: vi.fn(),
451
+ };
452
+ const { rerender } = await mountCoordinator(first);
453
+ await open();
454
+ await open();
455
+ await open();
456
+ const next = {
457
+ projectId,
458
+ activeCompPath,
459
+ drainPendingChanges: vi.fn(async () => ({ status: "clean" as const })),
460
+ reloadPreview: vi.fn(),
461
+ reloadSdkSession: vi.fn(),
462
+ };
463
+ await rerender(next);
464
+ await act(async () => finishDrain());
465
+ expect(first.drainPendingChanges).toHaveBeenCalledOnce();
466
+ expect(next.drainPendingChanges).not.toHaveBeenCalled();
467
+ expect(first.reloadPreview).not.toHaveBeenCalled();
468
+ expect(first.reloadSdkSession).not.toHaveBeenCalled();
469
+ expect(next.reloadPreview).not.toHaveBeenCalled();
470
+ expect(next.reloadSdkSession).not.toHaveBeenCalled();
471
+ });
472
+
473
+ it("still refreshes the tree for a queued outside change after switching compositions", async () => {
474
+ let finishDrain = () => {};
475
+ const pendingEdit = new Promise<void>((resolve) => {
476
+ finishDrain = resolve;
477
+ });
478
+ const refreshFileTree = vi.fn();
479
+ const onAcceptedPersistedFileChange = vi.fn();
480
+ const { rerender } = await mountCoordinator({
481
+ drainPendingChanges: vi.fn(async () => {
482
+ await pendingEdit;
483
+ return { status: "clean" as const };
484
+ }),
485
+ refreshFileTree,
486
+ onAcceptedPersistedFileChange,
487
+ });
488
+ await send({ path: "index.html", content: "external", version: "v2" });
489
+ await send({ path: "scenes/old.html", content: "agent", version: "v3" });
490
+ await rerender({ activeCompPath: "scenes/next.html" });
491
+ await act(async () => finishDrain());
492
+ expect(onAcceptedPersistedFileChange.mock.calls.map(([path]) => path)).toContain(
493
+ "scenes/old.html",
494
+ );
495
+ expect(refreshFileTree).toHaveBeenCalled();
496
+ });
497
+
498
+ it("ignores reconnects after leaving the active project", async () => {
499
+ const { options, rerender } = await mountCoordinator();
500
+ await open();
501
+ await rerender({ projectId: null });
502
+ await open();
503
+ expect(options.drainPendingChanges).not.toHaveBeenCalled();
504
+ expect(options.reloadPreview).not.toHaveBeenCalled();
505
+ });
506
+ });
507
+
67
508
  it("drains before reloading Preview and SDK exactly once", async () => {
68
509
  const order: string[] = [];
69
510
  const { captured } = await mountCoordinator({
@@ -431,16 +872,17 @@ describe("external file change coordinator", () => {
431
872
  expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
432
873
  });
433
874
 
434
- it("keeps the failed file's payload when a snapshot delete fails for another file", async () => {
875
+ it("keeps the failed draft blocked, owing both files, when deleting its snapshot fails", async () => {
876
+ const deleteConflictSnapshot = vi.fn(async () => {
877
+ throw new Error("delete failed");
878
+ });
435
879
  const { captured } = await mountCoordinator({
436
880
  drainPendingChanges: vi
437
881
  .fn()
438
882
  .mockResolvedValueOnce({ status: "failed" as const, error: new Error("offline") })
439
883
  .mockResolvedValueOnce({ status: "clean" as const }),
440
884
  getPendingCandidate: () => ({ path: "scene.html", content: "studio" }),
441
- deleteConflictSnapshot: vi.fn(async () => {
442
- throw new Error("delete failed");
443
- }),
885
+ deleteConflictSnapshot,
444
886
  });
445
887
  await act(async () =>
446
888
  handler?.({ path: "scene.html", content: "scene-external", version: "s1" }),
@@ -450,7 +892,95 @@ describe("external file change coordinator", () => {
450
892
  );
451
893
  expect(captured.handle?.blocked).toMatchObject({
452
894
  status: "failed",
453
- payload: { path: "scene.html", content: "scene-external", version: "s1" },
895
+ path: "scene.html",
896
+ payload: { path: "." },
897
+ });
898
+ expect(deleteConflictSnapshot).toHaveBeenCalledExactlyOnceWith("project-a", "scene.html");
899
+ });
900
+
901
+ it("keeps a failed draft's snapshot when a clean save was for another file", async () => {
902
+ const deleteConflictSnapshot = vi.fn(async () => undefined);
903
+ let candidate = { path: "script.js", content: "unsaved script" };
904
+ const { captured } = await mountCoordinator({
905
+ drainPendingChanges: vi
906
+ .fn()
907
+ .mockResolvedValueOnce({ status: "failed" as const, error: new Error("offline") })
908
+ .mockResolvedValue({ status: "clean" as const }),
909
+ getPendingCandidate: () => candidate,
910
+ persistFailureSnapshot: vi.fn(async () => undefined),
911
+ deleteConflictSnapshot,
912
+ });
913
+ await act(async () => handler?.({ path: "index.html", version: "v1" }));
914
+ expect(captured.handle?.blocked).toMatchObject({ status: "failed", path: "script.js" });
915
+ candidate = { path: "style.css", content: "saved style" };
916
+ await act(async () => handler?.({ path: "index.html", version: "v2" }));
917
+ expect(deleteConflictSnapshot).not.toHaveBeenCalled();
918
+ });
919
+
920
+ it("keeps a restored draft's snapshot when an unrelated change saves cleanly", async () => {
921
+ const deleteConflictSnapshot = vi.fn(async () => undefined);
922
+ const { captured, options } = await mountCoordinator({
923
+ recoveryFilePath: "script.js",
924
+ deleteConflictSnapshot,
925
+ loadConflictSnapshot: vi.fn(async () => ({
926
+ kind: "failed" as const,
927
+ projectId: "project-a",
928
+ filePath: "script.js",
929
+ externalVersion: null,
930
+ externalContent: null,
931
+ studioContent: "recover me",
932
+ failureMessage: "network unavailable",
933
+ createdAt: 100,
934
+ })),
935
+ });
936
+ await vi.waitFor(() => expect(captured.handle?.blocked?.status).toBe("failed"));
937
+ await act(async () => handler?.({ path: "index.html", version: "v2" }));
938
+ expect(deleteConflictSnapshot).not.toHaveBeenCalled();
939
+ expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith("index.html");
940
+ });
941
+
942
+ it("reloads the held change's scope too when Keep Studio settles a held conflict", async () => {
943
+ const conflict = new StudioFileConflictError({
944
+ filePath: "film.html",
945
+ currentVersion: "v2",
946
+ currentContent: "external",
947
+ attemptedContent: "studio",
948
+ });
949
+ const { captured, options } = await mountCoordinator({
950
+ drainPendingChanges: vi.fn(async () => ({ status: "conflict" as const, error: conflict })),
951
+ });
952
+ await act(async () => handler?.({ path: "film.html", version: "v2" }));
953
+ await act(async () =>
954
+ handler?.({
955
+ path: "scenes/nested.html",
956
+ version: "n1",
957
+ affectedCompositions: ["scenes/nested.html"],
958
+ }),
959
+ );
960
+ await act(async () => captured.handle?.keepStudioFile());
961
+ expect(options.overwriteConflict).toHaveBeenCalledWith(conflict);
962
+ expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith(".");
963
+ expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null);
964
+ });
965
+
966
+ it("names the conflicting file when its conflict snapshot cannot be saved", async () => {
967
+ const conflict = new StudioFileConflictError({
968
+ filePath: "script.js",
969
+ currentVersion: "v2",
970
+ currentContent: "external",
971
+ attemptedContent: "studio",
972
+ });
973
+ const { captured } = await mountCoordinator({
974
+ drainPendingChanges: vi.fn(async () => ({ status: "conflict" as const, error: conflict })),
975
+ persistConflictSnapshot: vi.fn(async () => {
976
+ throw new Error("storage full");
977
+ }),
978
+ });
979
+ await act(async () => handler?.({ path: "index.html", version: "v2" }));
980
+ expect(captured.handle?.blocked).toMatchObject({
981
+ status: "failed",
982
+ path: "script.js",
983
+ studioContent: "studio",
454
984
  });
455
985
  });
456
986
 
@@ -529,10 +1059,6 @@ describe("external file change coordinator", () => {
529
1059
  expect(onAcceptedPersistedFileChange).toHaveBeenCalledTimes(2);
530
1060
  });
531
1061
 
532
- // `hyperframes preview` serves file-change over SSE, where the delivery is a
533
- // MessageEvent whose `data` is a JSON STRING. Driven through the test adapter
534
- // because vitest defines `import.meta.hot`, so the EventSource rung is
535
- // unreachable here, which is exactly why decoding is shared by all rungs.
536
1062
  describe("SSE-shaped deliveries", () => {
537
1063
  const sseDelivery = (payload: unknown) =>
538
1064
  new MessageEvent("file-change", { data: JSON.stringify(payload) });