1import {
2 type DesktopPendingSnapShot,
3 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
4 type ScopedThreadRef,
5} from "@t3tools/contracts";
6import { useCallback, useEffect, useRef } from "react";
7
8import {
9 type DraftId,
10 type PersistedComposerImageAttachment,
11 useComposerDraftStore,
12} from "../../composerDraftStore";
13import { useHandleNewThread } from "../../hooks/useHandleNewThread";
14import { useClientSettings } from "../../hooks/useSettings";
15import { readThreadShell } from "../../state/entities";
16import { compressImageToByteLimit, dataUrlToFile } from "../../lib/imageCompression";
17import { resolveThreadActionProjectRef } from "../../lib/chatThreadActions";
18import {
19 beginSnapShotAnimation,
20 dismissAllSnapShotAnimations,
21 dismissSnapShotAnimation,
22 finishSnapShotAnimation,
23 getPendingSnapShotAnimations,
24 updateSnapShotAnimationSource,
25 waitForSnapShotAnimationDestination,
26} from "../../lib/snapShotAnimation";
27import { resizeSnapShotSource } from "../../lib/snapShotSource";
28import { playSnapShotSound } from "../../lib/snapShotSound";
29import {
30 dispatchSnapShotComposerFocus,
31 getDesktopSnapShotBridge,
32 type DesktopSnapShotBridge,
33} from "../../lib/desktopSnapShot";
34import { readFileAsDataUrl } from "../ChatView.logic";
35import { stackedThreadToast, toastManager } from "../ui/toast";
36
37type CaptureTarget = DraftId | ScopedThreadRef;
38
39export function resolveExistingSnapShotTarget(
40 target: CaptureTarget,
41 routeThreadRef: ScopedThreadRef | null,
42): CaptureTarget | null {
43 const store = useComposerDraftStore.getState();
44 if (typeof target === "string") {
45 const draftSession = store.getDraftSession(target);
46 if (draftSession?.promotedTo) return draftSession.promotedTo;
47 return draftSession ? target : null;
48 }
49 const targetIsCurrentRoute =
50 routeThreadRef !== null &&
51 routeThreadRef.environmentId === target.environmentId &&
52 routeThreadRef.threadId === target.threadId;
53 return targetIsCurrentRoute ||
54 store.getDraftSessionByRef(target) !== null ||
55 readThreadShell(target) !== null
56 ? target
57 : null;
58}
59
60const NEXT_PAINT_FALLBACK_MS = 100;
61
62export async function beginSnapShotAnimationWhenReady(
63 id: string,
64 target: Promise<CaptureTarget | null>,
65 pendingStarts: Set<string>,
66): Promise<void> {
67 pendingStarts.add(id);
68 try {
69 const resolvedTarget = await target;
70 if (pendingStarts.delete(id) && resolvedTarget) {
71 beginSnapShotAnimation(id, resolvedTarget);
72 }
73 } finally {
74 pendingStarts.delete(id);
75 }
76}
77
78export function dismissFailedSnapShot(
79 id: string | undefined,
80 soundedIds: Set<string>,
81 pendingStarts: Set<string>,
82): void {
83 if (id) {
84 pendingStarts.delete(id);
85 soundedIds.delete(id);
86 void dismissSnapShotAnimation(id);
87 } else {
88 pendingStarts.clear();
89 soundedIds.clear();
90 dismissAllSnapShotAnimations();
91 }
92}
93
94export function resolveSnapShotTargetOnce(
95 resolutionRef: { current: Promise<CaptureTarget | null> | null },
96 resolveTarget: () => Promise<CaptureTarget | null>,
97): Promise<CaptureTarget | null> {
98 if (resolutionRef.current) return resolutionRef.current;
99 const resolution = resolveTarget().finally(() => {
100 if (resolutionRef.current === resolution) resolutionRef.current = null;
101 });
102 resolutionRef.current = resolution;
103 return resolution;
104}
105
106// A capture keeps its destination even after its animation unmounts or the window blurs.
107export function resolveSnapShotDeliveryTarget(
108 targets: Map<string, Promise<CaptureTarget | null>>,
109 id: string,
110 resolveTarget: () => Promise<CaptureTarget | null>,
111): Promise<CaptureTarget | null> {
112 const existing = targets.get(id);
113 if (existing) return existing;
114 const target = resolveTarget().catch(() => null);
115 targets.set(id, target);
116 void target.then((resolved) => {
117 if (!resolved && targets.get(id) === target) targets.delete(id);
118 });
119 return target;
120}
121
122async function afterNextPaint(): Promise<void> {
123 await new Promise<void>((resolve) => {
124 const fallback = window.setTimeout(resolve, NEXT_PAINT_FALLBACK_MS);
125 requestAnimationFrame(() => {
126 requestAnimationFrame(() => {
127 window.clearTimeout(fallback);
128 resolve();
129 });
130 });
131 });
132}
133
134export async function deliverSnapShot(
135 bridge: DesktopSnapShotBridge,
136 item: DesktopPendingSnapShot,
137 target: CaptureTarget,
138): Promise<void> {
139 const store = useComposerDraftStore.getState();
140 updateSnapShotAnimationSource(item.id, item.source);
141 const capture = await bridge.readSnapShot(item.id);
142 const original = dataUrlToFile(capture.dataUrl, capture.name, capture.mimeType);
143 const compressed = await compressImageToByteLimit(original, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES);
144 if (!compressed.ok) {
145 finishSnapShotAnimation(item.id);
146 throw new Error("The captured window is too large to attach.");
147 }
148 const file = compressed.file;
149 const source = resizeSnapShotSource(capture.source, compressed.imageSize);
150 const dataUrl = compressed.recompressed ? await readFileAsDataUrl(file) : capture.dataUrl;
151 const alreadyAttached =
152 store.getComposerDraft(target)?.images.some(({ id }) => id === capture.id) ?? false;
153 if (
154 !alreadyAttached &&
155 !store.addImage(target, {
156 type: "image",
157 id: capture.id,
158 name: file.name,
159 mimeType: file.type,
160 sizeBytes: file.size,
161 previewUrl: dataUrl,
162 file,
163 source,
164 })
165 ) {
166 throw new Error("Remove an attachment, then try this capture again.");
167 }
168 const persisted: PersistedComposerImageAttachment = {
169 id: capture.id,
170 name: file.name,
171 mimeType: file.type,
172 sizeBytes: file.size,
173 dataUrl,
174 source,
175 };
176 const persistedAttachments =
177 store
178 .getComposerDraft(target)
179 ?.persistedAttachments.filter((attachment) => attachment.id !== capture.id) ?? [];
180 await store.syncPersistedAttachments(target, [...persistedAttachments, persisted]);
181 if (!store.getComposerDraft(target)?.persistedAttachments.some(({ id }) => id === capture.id)) {
182 throw new Error("The captured window could not be saved to the draft.");
183 }
184
185 // Reveal the attachment under the flying capture before the desktop tears the overlay down,
186 // otherwise the tile is missing for the frames between the landing and its first paint.
187 if (getPendingSnapShotAnimations().some((animation) => animation.id === capture.id)) {
188 await afterNextPaint();
189 await waitForSnapShotAnimationDestination(capture.id).catch(() => undefined);
190 finishSnapShotAnimation(capture.id);
191 await afterNextPaint();
192 }
193 await bridge.acknowledgeSnapShot(capture.id);
194 dispatchSnapShotComposerFocus();
195}
196
197export function SnapShotCoordinator() {
198 const {
199 activeDraftThread,
200 activeThread,
201 defaultProjectRef,
202 handleNewThread,
203 routeDraftId,
204 routeThreadRef,
205 } = useHandleNewThread();
206 const captureSound = useClientSettings((settings) =>
207 settings.snapShotPlaySound ? settings.snapShotSound : null,
208 );
209 const animateCaptures = useClientSettings((settings) => settings.snapShotAnimations);
210 const captureTargetsRef = useRef(new Map<string, Promise<CaptureTarget | null>>());
211 const lastTargetRef = useRef<CaptureTarget | null>(null);
212 const targetResolutionRef = useRef<Promise<CaptureTarget | null> | null>(null);
213 const drainingRef = useRef<Promise<void> | null>(null);
214 const rerunRequestedRef = useRef(false);
215 const soundedCaptureIdsRef = useRef(new Set<string>());
216 const pendingAnimationStartsRef = useRef(new Set<string>());
217
218 const currentTarget = routeThreadRef ?? routeDraftId;
219 if (currentTarget) lastTargetRef.current = currentTarget;
220
221 const resolveTarget = useCallback(async (): Promise<CaptureTarget | null> => {
222 const lastTarget = lastTargetRef.current;
223 if (lastTarget) {
224 const existingTarget = resolveExistingSnapShotTarget(lastTarget, routeThreadRef);
225 if (existingTarget) {
226 lastTargetRef.current = existingTarget;
227 return existingTarget;
228 }
229 lastTargetRef.current = null;
230 }
231 const projectRef = resolveThreadActionProjectRef({
232 activeDraftThread,
233 activeThread: activeThread ?? undefined,
234 defaultProjectRef,
235 handleNewThread,
236 });
237 if (!projectRef) return null;
238 const created = await handleNewThread(projectRef);
239 if (!created) return null;
240 lastTargetRef.current = created.draftId;
241 return created.draftId;
242 }, [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef]);
243
244 const resolveCaptureTarget = useCallback(
245 () => resolveSnapShotTargetOnce(targetResolutionRef, resolveTarget),
246 [resolveTarget],
247 );
248
249 const playCaptureSound = useCallback(
250 (id: string) => {
251 if (!captureSound || soundedCaptureIdsRef.current.has(id)) return;
252 soundedCaptureIdsRef.current.add(id);
253 try {
254 playSnapShotSound(captureSound);
255 } catch {}
256 },
257 [captureSound],
258 );
259
260 const drain = useCallback(async () => {
261 const bridge = getDesktopSnapShotBridge();
262 if (!bridge) return;
263 if (drainingRef.current) {
264 rerunRequestedRef.current = true;
265 return drainingRef.current;
266 }
267
268 const operation = (async () => {
269 do {
270 rerunRequestedRef.current = false;
271 const pending = await bridge.listPendingSnapShots();
272 for (const item of pending) {
273 playCaptureSound(item.id);
274 const capturedTarget = await resolveSnapShotDeliveryTarget(
275 captureTargetsRef.current,
276 item.id,
277 resolveCaptureTarget,
278 );
279 const target = capturedTarget
280 ? resolveExistingSnapShotTarget(capturedTarget, routeThreadRef)
281 : null;
282 if (!target) {
283 await dismissSnapShotAnimation(item.id);
284 soundedCaptureIdsRef.current.delete(item.id);
285 toastManager.add(
286 stackedThreadToast({
287 type: "error",
288 title: "Snapshot taken, but no project is available",
289 description: "Add a project, then capture the window again.",
290 }),
291 );
292 continue;
293 }
294
295 try {
296 await deliverSnapShot(bridge, item, target);
297 captureTargetsRef.current.delete(item.id);
298 soundedCaptureIdsRef.current.delete(item.id);
299 } catch (error) {
300 await dismissSnapShotAnimation(item.id);
301 soundedCaptureIdsRef.current.delete(item.id);
302 toastManager.add(
303 stackedThreadToast({
304 type: "error",
305 title: "Snapshot failed",
306 description: `Capture ${item.id}: ${
307 error instanceof Error ? error.message : "Try the capture again."
308 }`,
309 }),
310 );
311 }
312 }
313 } while (rerunRequestedRef.current);
314 })()
315 .catch((error: unknown) => {
316 dismissAllSnapShotAnimations();
317 toastManager.add(
318 stackedThreadToast({
319 type: "error",
320 title: "Snapshot failed",
321 description: error instanceof Error ? error.message : "Try the capture again.",
322 }),
323 );
324 })
325 .finally(() => {
326 drainingRef.current = null;
327 });
328 drainingRef.current = operation;
329 return operation;
330 }, [playCaptureSound, resolveCaptureTarget, routeThreadRef]);
331
332 useEffect(() => {
333 const bridge = getDesktopSnapShotBridge();
334 if (!bridge) return;
335 void drain();
336 const unsubscribe = bridge.onSnapShotEvent((event) => {
337 switch (event.type) {
338 case "requested": {
339 const current = lastTargetRef.current;
340 const target = current ? resolveExistingSnapShotTarget(current, routeThreadRef) : null;
341 // Creating a new draft would navigate the renderer before a self-capture finishes.
342 // Pin existing drafts now; create a destination after acquisition when none exists.
343 if (target) {
344 void resolveSnapShotDeliveryTarget(captureTargetsRef.current, event.id, () =>
345 Promise.resolve(target),
346 );
347 }
348 return;
349 }
350 case "started": {
351 playCaptureSound(event.id);
352 if (animateCaptures && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
353 void beginSnapShotAnimationWhenReady(
354 event.id,
355 resolveSnapShotDeliveryTarget(
356 captureTargetsRef.current,
357 event.id,
358 resolveCaptureTarget,
359 ),
360 pendingAnimationStartsRef.current,
361 );
362 }
363 return;
364 }
365 case "ready":
366 void drain();
367 return;
368 case "failed": {
369 if (event.id) captureTargetsRef.current.delete(event.id);
370 dismissFailedSnapShot(
371 event.id,
372 soundedCaptureIdsRef.current,
373 pendingAnimationStartsRef.current,
374 );
375 void bridge.getSnapShotState().then((state) => {
376 toastManager.add(
377 stackedThreadToast({
378 type: "error",
379 title: "Snapshot failed",
380 description: state.message ?? "Try the capture again.",
381 }),
382 );
383 });
384 return;
385 }
386 case "shortcut-changed":
387 return;
388 }
389 });
390 return unsubscribe;
391 }, [animateCaptures, drain, playCaptureSound, resolveCaptureTarget, routeThreadRef]);
392
393 useEffect(() => {
394 const dismissOnBlur = () => {
395 pendingAnimationStartsRef.current.clear();
396 dismissAllSnapShotAnimations();
397 };
398 const drainOnFocus = () => void drain();
399 const handleVisibilityChange = () => {
400 if (document.visibilityState === "hidden") dismissOnBlur();
401 else void drain();
402 };
403 window.addEventListener("blur", dismissOnBlur);
404 window.addEventListener("focus", drainOnFocus);
405 document.addEventListener("visibilitychange", handleVisibilityChange);
406 return () => {
407 window.removeEventListener("blur", dismissOnBlur);
408 window.removeEventListener("focus", drainOnFocus);
409 document.removeEventListener("visibilitychange", handleVisibilityChange);
410 };
411 }, [drain]);
412
413 return null;
414}