Skip to content

Commit ae6cc08

Browse files
fix(desktop): preserve snapshot delivery and image coordinates
1 parent 7308a71 commit ae6cc08

9 files changed

Lines changed: 337 additions & 34 deletions

File tree

apps/desktop/src/snapShot/DesktopSnapShot.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,7 @@ function concurrentCaptureFixture(platform: NodeJS.Platform, animations: boolean
554554
const images = new Map<string, Uint8Array>();
555555
const metadata = new Map<string, string>();
556556
const readyIds: string[] = [];
557+
const requestedIds: string[] = [];
557558
const bounds = { x: 10, y: 20, width: 800, height: 600 };
558559
const takeSnapshot = async () => {
559560
const index = state.snapshots++;
@@ -665,6 +666,12 @@ function concurrentCaptureFixture(platform: NodeJS.Platform, animations: boolean
665666
state.preparedWithoutOverlay &&= flashWindows.every((window) => window.destroyed);
666667
}),
667668
dispatchMenuAction: (action: string, options?: { readonly reveal?: boolean }) => {
669+
if (action.startsWith("snap-shot-requested:")) {
670+
return Effect.sync(() => {
671+
assert.isFalse(options?.reveal);
672+
requestedIds.push(action.slice("snap-shot-requested:".length));
673+
});
674+
}
668675
if (!action.startsWith("snap-shot-started:")) return Effect.void;
669676
if (state.failNextReveal && options?.reveal !== false) {
670677
state.failNextReveal = false;
@@ -684,6 +691,7 @@ function concurrentCaptureFixture(platform: NodeJS.Platform, animations: boolean
684691
second: second!,
685692
state,
686693
readyIds,
694+
requestedIds,
687695
layer,
688696
settings: {
689697
...DEFAULT_CLIENT_SETTINGS,
@@ -1100,10 +1108,13 @@ it.effect.each([
11001108
Effect.gen(function* () {
11011109
const service = yield* DesktopSnapShot.make;
11021110
yield* service.configure(fixture.settings);
1103-
fixture.first.pixels.resolve();
11041111
const first = yield* Effect.promise(fixture.trigger).pipe(
11051112
Effect.forkChild({ startImmediately: true }),
11061113
);
1114+
yield* Effect.promise(() => fixture.first.started.promise);
1115+
assert.lengthOf(fixture.requestedIds, 1);
1116+
assert.lengthOf(fixture.readyIds, 0);
1117+
fixture.first.pixels.resolve();
11071118
yield* Effect.promise(() => fixture.first.handoff.promise);
11081119
assert.lengthOf(fixture.readyIds, 0);
11091120

@@ -1128,6 +1139,7 @@ it.effect.each([
11281139
yield* Fiber.join(first);
11291140
assert.lengthOf(fixture.readyIds, 2);
11301141
assert.equal(new Set(fixture.readyIds).size, 2);
1142+
assert.deepEqual(fixture.readyIds, fixture.requestedIds.toReversed());
11311143
const newer = yield* service.read(fixture.readyIds[0]!);
11321144
const older = yield* service.read(fixture.readyIds[1]!);
11331145
assert.equal(newer.source.windowTitle, fixture.second.title);

apps/desktop/src/snapShot/DesktopSnapShot.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -831,8 +831,16 @@ export const make = Effect.gen(function* () {
831831
const notifyFailure = desktopWindow
832832
.dispatchMenuAction(CAPTURE_FAILED_ACTION)
833833
.pipe(Effect.catch(() => Effect.void));
834-
const setFailure = (message: string) =>
835-
Ref.update(stateRef, (state) => ({ ...state, message })).pipe(Effect.andThen(notifyFailure));
834+
const setFailure = (message: string, captureId?: string) =>
835+
Ref.update(stateRef, (state) => ({ ...state, message })).pipe(
836+
Effect.andThen(
837+
captureId
838+
? desktopWindow
839+
.dispatchMenuAction(`${CAPTURE_FAILED_ACTION}:${captureId}`)
840+
.pipe(Effect.catch(() => Effect.void))
841+
: notifyFailure,
842+
),
843+
);
836844
const setShortcutFailure = (shortcutMessage: string) =>
837845
Effect.sync(() => {
838846
shortcutVerified = false;
@@ -876,6 +884,9 @@ export const make = Effect.gen(function* () {
876884
flash.dispose();
877885
transition.dispose();
878886
yield* fileSystem.makeDirectory(captureDirectory, { recursive: true });
887+
yield* desktopWindow
888+
.dispatchMenuAction(`snap-shot-requested:${id}`, { reveal: false })
889+
.pipe(Effect.catch(() => Effect.void));
879890
const snapshot = yield* Effect.tryPromise({
880891
try: () =>
881892
captureSource({
@@ -977,7 +988,7 @@ export const make = Effect.gen(function* () {
977988
const prepared = yield* prepareCapture(settings, target).pipe(
978989
Effect.tapError((error) =>
979990
(error.captureId ? discardCapture(error.captureId) : Effect.void).pipe(
980-
Effect.andThen(setFailure(error.message)),
991+
Effect.andThen(setFailure(error.message, error.captureId)),
981992
),
982993
),
983994
snapshotMutex.withPermitsIfAvailable(1),

apps/web/src/components/chat/ChatComposer.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ import {
215215
shouldAnimateSnapShotArrival,
216216
subscribeToPendingSnapShotAnimations,
217217
} from "../../lib/snapShotAnimation";
218+
import { resizeSnapShotSource } from "../../lib/snapShotSource";
218219
import { basenameOfPath } from "../../pierre-icons";
219220
import { cn, randomUUID } from "~/lib/utils";
220221
import {
@@ -3675,7 +3676,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
36753676
mimeType: result.image.mimeType,
36763677
sizeBytes: result.image.sizeBytes,
36773678
dataUrl: result.image.dataUrl,
3678-
...(image.source ? { source: image.source } : {}),
3679+
...(image.source
3680+
? { source: resizeSnapShotSource(image.source, result.image.imageSize) }
3681+
: {}),
36793682
});
36803683
}
36813684
const { kept, droppedNames } = partitionStashAttachments(candidateAttachments);

apps/web/src/components/desktop/SnapShotCoordinator.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,38 @@ import {
1515
dismissFailedSnapShot,
1616
resolveExistingSnapShotTarget,
1717
resolveSnapShotTargetOnce,
18+
resolveSnapShotDeliveryTarget,
1819
} from "./SnapShotCoordinator";
1920
import {
2021
beginSnapShotAnimation,
2122
dismissAllSnapShotAnimations,
2223
getPendingSnapShotAnimations,
2324
setSnapShotAnimationDestination,
25+
scheduleSnapShotAnimationDestination,
2426
} from "../../lib/snapShotAnimation";
2527

28+
const storage = vi.hoisted(() => {
29+
const values = new Map<string, string>();
30+
const storage = {
31+
getItem: (key: string) => values.get(key) ?? null,
32+
setItem: vi.fn((key: string, value: string) => {
33+
values.set(key, value);
34+
}),
35+
removeItem: (key: string) => {
36+
values.delete(key);
37+
},
38+
clear: () => values.clear(),
39+
};
40+
vi.stubGlobal("localStorage", storage);
41+
return storage;
42+
});
43+
2644
const environmentId = EnvironmentId.make("snap-shot-environment");
2745
const projectRef = scopeProjectRef(environmentId, ProjectId.make("snap-shot-project"));
2846

2947
beforeEach(() => {
48+
storage.clear();
49+
vi.stubGlobal("localStorage", storage);
3050
useComposerDraftStore.setState({
3151
draftsByThreadKey: {},
3252
draftThreadsByThreadKey: {},
@@ -49,6 +69,7 @@ describe("window capture failures", () => {
4969
const soundedIds = new Set(["older", "newer"]);
5070
const dismissSnapShotAnimation = vi.fn(async () => undefined);
5171
vi.stubGlobal("window", {
72+
localStorage: storage,
5273
desktopBridge: {
5374
requestSnapShotPermissions: vi.fn(),
5475
getSnapShotState: vi.fn(),
@@ -170,6 +191,7 @@ describe("window capture delivery", () => {
170191
},
171192
};
172193
vi.stubGlobal("window", {
194+
localStorage: storage,
173195
desktopBridge: bridge,
174196
setTimeout,
175197
clearTimeout,
@@ -268,3 +290,93 @@ describe("window capture target resolution", () => {
268290
expect(resolveExistingSnapShotTarget(routeThreadRef, routeThreadRef)).toEqual(routeThreadRef);
269291
});
270292
});
293+
294+
describe("durable snapshot delivery", () => {
295+
it.each([false, true])(
296+
"retains a capture on quota failure and retries without duplicates (staged: %s)",
297+
async (staged) => {
298+
const target = scopeThreadRef(environmentId, ThreadId.make("quota-thread"));
299+
const capture = {
300+
id: "12345678-1234-1234-1234-123456789abc",
301+
name: "window.png",
302+
mimeType: "image/png" as const,
303+
sizeBytes: 3,
304+
dataUrl: "data:image/png;base64,AQID",
305+
source: {
306+
kind: "snap-shot" as const,
307+
capturedAt: "2026-09-01T00:00:00.000Z",
308+
appName: "Editor",
309+
windowTitle: "main.ts",
310+
},
311+
};
312+
const acknowledgeSnapShot = vi.fn(async () => undefined);
313+
const bridge = {
314+
readSnapShot: async () => capture,
315+
acknowledgeSnapShot,
316+
} as unknown as DesktopSnapShotBridge;
317+
vi.stubGlobal("window", { localStorage: storage, dispatchEvent: vi.fn() });
318+
const write = storage.setItem.getMockImplementation()!;
319+
storage.setItem.mockImplementation(() => {
320+
throw new Error("QuotaExceededError");
321+
});
322+
try {
323+
if (staged) {
324+
const store = useComposerDraftStore.getState();
325+
store.addImage(target, {
326+
type: "image",
327+
...capture,
328+
previewUrl: capture.dataUrl,
329+
file: new File([new Uint8Array([1, 2, 3])], capture.name, { type: capture.mimeType }),
330+
});
331+
void store.syncPersistedAttachments(target, [capture]);
332+
}
333+
await expect(deliverSnapShot(bridge, capture, target)).rejects.toThrow(
334+
"could not be saved",
335+
);
336+
expect(acknowledgeSnapShot).not.toHaveBeenCalled();
337+
expect(
338+
useComposerDraftStore.getState().getComposerDraft(target)?.nonPersistedImageIds,
339+
).toContain(capture.id);
340+
storage.setItem.mockImplementation(write);
341+
await deliverSnapShot(bridge, capture, target);
342+
expect(acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id);
343+
expect(useComposerDraftStore.getState().getComposerDraft(target)?.images).toHaveLength(1);
344+
expect(
345+
useComposerDraftStore.getState().getComposerDraft(target)?.persistedAttachments,
346+
).toHaveLength(1);
347+
} finally {
348+
storage.setItem.mockImplementation(write);
349+
}
350+
},
351+
);
352+
});
353+
354+
describe("snapshot destination ownership", () => {
355+
it.each(["unmount", "blur", "disabled animations"])(
356+
"keeps the original environment and thread after %s",
357+
async (reason) => {
358+
const original = scopeThreadRef(environmentId, ThreadId.make("original"));
359+
const next = scopeThreadRef(EnvironmentId.make("another-environment"), ThreadId.make("next"));
360+
const targets = new Map<string, Promise<typeof original | null>>();
361+
let current = original;
362+
const resolveTarget = async () => current;
363+
const requested = resolveSnapShotDeliveryTarget(targets, "capture", resolveTarget);
364+
if (reason !== "disabled animations") {
365+
beginSnapShotAnimation("capture", original);
366+
if (reason === "unmount") {
367+
const unmount = scheduleSnapShotAnimationDestination("capture", () => undefined);
368+
unmount();
369+
} else dismissAllSnapShotAnimations();
370+
}
371+
current = next;
372+
await requested;
373+
expect(getPendingSnapShotAnimations()).toHaveLength(0);
374+
expect(await resolveSnapShotDeliveryTarget(targets, "capture", resolveTarget)).toEqual(
375+
original,
376+
);
377+
expect(await resolveSnapShotDeliveryTarget(targets, "later-capture", resolveTarget)).toEqual(
378+
next,
379+
);
380+
},
381+
);
382+
});

0 commit comments

Comments
 (0)