diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 205a72a55265..869b0bb0eacd 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -3839,6 +3839,35 @@ it.effect("registers macOS modifier pairs through the flags poller", () => { ).pipe(Effect.provide(testLayer("darwin"))); }); +it.effect("releases the macOS both-Shift poller when snapshots are turned off", () => { + spawnedPollers.length = 0; + accessibilityTrustedMock.mockReturnValue(true); + mediaAccessStatusMock.mockReturnValue("granted"); + const settings = { + ...DEFAULT_CLIENT_SETTINGS, + snapShotEnabled: true, + snapShotShortcut: { kind: "both-shift-keys" }, + } satisfies ClientSettings; + + return Effect.scoped( + Effect.gen(function* () { + const service = yield* DesktopSnapShot.make; + yield* service.configure(settings); + assert.lengthOf(spawnedPollers, 1); + const poller = spawnedPollers[0]!; + assert.deepEqual(poller.args.slice(-2), ["2", "4"]); + assert.lengthOf(poller.kill.mock.calls, 0); + assert.isTrue((yield* service.state).shortcutRegistered); + + yield* service.configure({ ...settings, snapShotEnabled: false }); + assert.lengthOf(poller.kill.mock.calls, 1); + assert.isFalse((yield* service.state).shortcutRegistered); + const failure = yield* Effect.flip(service.capture); + assert.equal(failure.operation, "disabled"); + }), + ).pipe(Effect.provide(testLayer("darwin"))); +}); + it.effect("waits to apply settings while permissions are pending", () => { accessibilityTrustedMock.mockReturnValue(true); mediaAccessStatusMock.mockReturnValueOnce("not-determined").mockReturnValue("granted"); diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.gate.test.tsx b/apps/web/src/components/desktop/SnapShotCoordinator.gate.test.tsx new file mode 100644 index 000000000000..b30eab219538 --- /dev/null +++ b/apps/web/src/components/desktop/SnapShotCoordinator.gate.test.tsx @@ -0,0 +1,216 @@ +import type { DesktopPendingSnapShot, DesktopSnapShotEvent } from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const pendingCapture: DesktopPendingSnapShot = { + id: "12345678-1234-1234-1234-123456789abc", + name: "window.png", + mimeType: "image/png", + sizeBytes: 3, + source: { + kind: "snap-shot", + capturedAt: "2026-09-01T00:00:00.000Z", + appName: "Editor", + windowTitle: "main.ts", + }, +}; + +const state = vi.hoisted(() => ({ + enabled: false, + activeDraftThread: null as { environmentId: string; projectId: string } | null, + handleNewThread: vi.fn(async () => null), + playSound: vi.fn(), + toast: vi.fn(), + listPendingSnapShots: vi.fn(async (): Promise> => []), + onSnapShotEvent: vi.fn((_listener: (event: DesktopSnapShotEvent) => void) => () => undefined), +})); + +vi.mock("../../hooks/useSettings", () => ({ + useClientSettings: ( + select: (settings: { + snapShotEnabled: boolean; + snapShotPlaySound: boolean; + snapShotSound: "soft-pop"; + snapShotAnimations: boolean; + }) => unknown, + ) => + select({ + snapShotEnabled: state.enabled, + snapShotPlaySound: true, + snapShotSound: "soft-pop", + snapShotAnimations: true, + }), +})); + +vi.mock("../../hooks/useHandleNewThread", () => ({ + useHandleNewThread: () => ({ + activeDraftThread: state.activeDraftThread, + activeThread: null, + defaultProjectRef: null, + handleNewThread: state.handleNewThread, + routeDraftId: null, + routeThreadRef: null, + }), +})); + +vi.mock("../../lib/snapShotSound", () => ({ + playSnapShotSound: (sound: string) => { + state.playSound(sound); + }, +})); + +vi.mock("../../lib/desktopSnapShot", () => ({ + getDesktopSnapShotBridge: () => ({ + listPendingSnapShots: state.listPendingSnapShots, + readSnapShot: vi.fn(), + acknowledgeSnapShot: vi.fn(async () => undefined), + onSnapShotEvent: state.onSnapShotEvent, + getSnapShotState: vi.fn(async () => ({ message: null })), + dismissSnapShotAnimation: vi.fn(async () => undefined), + }), + dispatchSnapShotComposerFocus: () => undefined, +})); + +vi.mock("../ui/toast", () => ({ + toastManager: { + add: (toast: { title?: string }) => { + state.toast(toast); + }, + }, + stackedThreadToast: (toast: { title: string; description?: string }) => toast, +})); + +import { SnapShotCoordinator } from "./SnapShotCoordinator"; + +let renderer: ReactTestRenderer | undefined; +const listeners = new Map void>>(); + +function addEventListener(type: string, listener: () => void) { + const set = listeners.get(type) ?? new Set(); + set.add(listener); + listeners.set(type, set); +} + +function removeEventListener(type: string, listener: () => void) { + listeners.get(type)?.delete(listener); +} + +async function render() { + await act(async () => { + if (renderer) renderer.update(); + else renderer = create(); + }); +} + +async function flush() { + await act(async () => { + await Promise.resolve(); + }); +} + +beforeEach(() => { + listeners.clear(); + state.enabled = false; + state.activeDraftThread = { environmentId: "env", projectId: "proj" }; + state.handleNewThread.mockReset(); + state.handleNewThread.mockResolvedValue(null); + state.playSound.mockReset(); + state.toast.mockReset(); + state.listPendingSnapShots.mockReset(); + state.listPendingSnapShots.mockResolvedValue([]); + state.onSnapShotEvent.mockReset(); + state.onSnapShotEvent.mockImplementation(() => () => undefined); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener, + removeEventListener, + matchMedia: () => ({ matches: false }), + }); + vi.stubGlobal("document", { + visibilityState: "visible", + addEventListener, + removeEventListener, + }); +}); + +afterEach(async () => { + await act(() => { + renderer?.unmount(); + }); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("SnapShots while the feature is off", () => { + it("does not list, sound, or toast when chat state changes or the window focuses", async () => { + await render(); + state.activeDraftThread = { environmentId: "env", projectId: "proj-typed" }; + await render(); + await act(async () => { + for (const listener of listeners.get("focus") ?? []) listener(); + }); + await flush(); + + expect(state.listPendingSnapShots).not.toHaveBeenCalled(); + expect(state.onSnapShotEvent).not.toHaveBeenCalled(); + expect(state.playSound).not.toHaveBeenCalled(); + expect(state.toast).not.toHaveBeenCalled(); + expect(state.handleNewThread).not.toHaveBeenCalled(); + }); + + it("drops an in-flight pending list once the feature turns off", async () => { + let finishList: (captures: ReadonlyArray) => void = () => undefined; + state.listPendingSnapShots.mockImplementation( + () => + new Promise>((resolve) => { + finishList = resolve; + }), + ); + state.enabled = true; + await render(); + expect(state.listPendingSnapShots).toHaveBeenCalledTimes(1); + + state.enabled = false; + await render(); + finishList([pendingCapture]); + await flush(); + + expect(state.playSound).not.toHaveBeenCalled(); + expect(state.toast).not.toHaveBeenCalled(); + expect(state.handleNewThread).not.toHaveBeenCalled(); + }); + + it("lists again after the feature is turned back on", async () => { + await render(); + expect(state.listPendingSnapShots).not.toHaveBeenCalled(); + + state.enabled = true; + await render(); + await flush(); + + expect(state.listPendingSnapShots).toHaveBeenCalledTimes(1); + }); + + it("sounds and toasts an undeliverable capture once across later chat updates", async () => { + state.enabled = true; + state.listPendingSnapShots.mockResolvedValue([pendingCapture]); + await render(); + await vi.waitFor(() => { + expect(state.toast).toHaveBeenCalledTimes(1); + }); + expect(state.playSound).toHaveBeenCalledTimes(1); + expect(state.toast.mock.calls[0]?.[0]).toMatchObject({ + title: "Snapshot taken, but no project is available", + }); + + state.activeDraftThread = { environmentId: "env", projectId: "proj-typed" }; + await render(); + await vi.waitFor(() => { + expect(state.listPendingSnapShots.mock.calls.length).toBeGreaterThan(1); + }); + + expect(state.toast).toHaveBeenCalledTimes(1); + expect(state.playSound).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.test.ts b/apps/web/src/components/desktop/SnapShotCoordinator.test.ts index eb0a6fbb1352..bac10a58a442 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.test.ts +++ b/apps/web/src/components/desktop/SnapShotCoordinator.test.ts @@ -16,6 +16,7 @@ import { resolveExistingSnapShotTarget, resolveSnapShotTargetOnce, resolveSnapShotDeliveryTarget, + shouldReportUndeliverableSnapShot, } from "./SnapShotCoordinator"; import { beginSnapShotAnimation, @@ -133,6 +134,58 @@ describe("window capture failures", () => { }); }); +describe("undeliverable capture reporting", () => { + it("reports a pending capture once however often the drain meets it again", () => { + const reported = new Set(); + + expect(shouldReportUndeliverableSnapShot("capture-1", reported)).toBe(true); + expect(shouldReportUndeliverableSnapShot("capture-1", reported)).toBe(false); + expect(shouldReportUndeliverableSnapShot("capture-2", reported)).toBe(true); + + reported.delete("capture-1"); + expect(shouldReportUndeliverableSnapShot("capture-1", reported)).toBe(true); + }); +}); + +describe("delivery stops when the feature turns off", () => { + it("leaves the capture pending when the file read finishes after the feature is off", async () => { + const target = DraftId.make("snap-shot-draft"); + const capture = { + id: "12345678-1234-1234-1234-123456789abc", + name: "window.png", + mimeType: "image/png" as const, + sizeBytes: 3, + dataUrl: "data:image/png;base64,AQID", + source: { + kind: "snap-shot" as const, + capturedAt: "2026-09-01T00:00:00.000Z", + appName: "Editor", + windowTitle: "main.ts", + }, + }; + let finishRead: (value: typeof capture) => void = () => undefined; + const acknowledgeSnapShot = vi.fn(async () => undefined); + const bridge = { + readSnapShot: vi.fn( + () => + new Promise((resolve) => { + finishRead = resolve; + }), + ), + acknowledgeSnapShot, + } as unknown as DesktopSnapShotBridge; + let enabled = true; + + const delivery = deliverSnapShot(bridge, capture, target, () => enabled); + enabled = false; + finishRead(capture); + + await expect(delivery).resolves.toBe(false); + expect(acknowledgeSnapShot).not.toHaveBeenCalled(); + expect(useComposerDraftStore.getState().getComposerDraft(target)?.images ?? []).toHaveLength(0); + }); +}); + describe("window capture delivery", () => { it.each([ { target: DraftId.make("snap-shot-draft"), accessibleText: undefined }, diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.tsx b/apps/web/src/components/desktop/SnapShotCoordinator.tsx index e7ac04b0d068..28104809d27c 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.tsx +++ b/apps/web/src/components/desktop/SnapShotCoordinator.tsx @@ -3,7 +3,7 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ScopedThreadRef, } from "@t3tools/contracts"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { type DraftId, @@ -91,6 +91,13 @@ export function dismissFailedSnapShot( } } +/** True the first time a pending capture cannot be attached, so later drains stay quiet. */ +export function shouldReportUndeliverableSnapShot(id: string, reportedIds: Set): boolean { + if (reportedIds.has(id)) return false; + reportedIds.add(id); + return true; +} + export function resolveSnapShotTargetOnce( resolutionRef: { current: Promise | null }, resolveTarget: () => Promise, @@ -135,12 +142,23 @@ export async function deliverSnapShot( bridge: DesktopSnapShotBridge, item: DesktopPendingSnapShot, target: CaptureTarget, -): Promise { + isEnabled: () => boolean = () => true, +): Promise { + if (!isEnabled()) return false; const store = useComposerDraftStore.getState(); updateSnapShotAnimationSource(item.id, item.source); const capture = await bridge.readSnapShot(item.id); + // The file read outlives the toggle. Leave it pending instead of attaching it. + if (!isEnabled()) { + await dismissSnapShotAnimation(item.id); + return false; + } const original = dataUrlToFile(capture.dataUrl, capture.name, capture.mimeType); const compressed = await compressImageToByteLimit(original, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES); + if (!isEnabled()) { + await dismissSnapShotAnimation(item.id); + return false; + } if (!compressed.ok) { finishSnapShotAnimation(item.id); throw new Error("The captured window is too large to attach."); @@ -148,6 +166,10 @@ export async function deliverSnapShot( const file = compressed.file; const source = resizeSnapShotSource(capture.source, compressed.imageSize); const dataUrl = compressed.recompressed ? await readFileAsDataUrl(file) : capture.dataUrl; + if (!isEnabled()) { + await dismissSnapShotAnimation(item.id); + return false; + } const alreadyAttached = store.getComposerDraft(target)?.images.some(({ id }) => id === capture.id) ?? false; if ( @@ -190,8 +212,13 @@ export async function deliverSnapShot( finishSnapShotAnimation(capture.id); await afterNextPaint(); } + if (!isEnabled()) { + await dismissSnapShotAnimation(item.id); + return false; + } await bridge.acknowledgeSnapShot(capture.id); dispatchSnapShotComposerFocus(); + return true; } export function SnapShotCoordinator() { @@ -207,12 +234,22 @@ export function SnapShotCoordinator() { settings.snapShotPlaySound ? settings.snapShotSound : null, ); const animateCaptures = useClientSettings((settings) => settings.snapShotAnimations); + const enabled = useClientSettings((settings) => settings.snapShotEnabled); + // In-flight reads and target resolution outlive the render that saw the toggle. + const enabledRef = useRef(enabled); + useLayoutEffect(() => { + enabledRef.current = enabled; + }, [enabled]); + const notify = useCallback((toast: Parameters[0]) => { + if (enabledRef.current) toastManager.add(toast); + }, []); const captureTargetsRef = useRef(new Map>()); const lastTargetRef = useRef(null); const targetResolutionRef = useRef | null>(null); const drainingRef = useRef | null>(null); const rerunRequestedRef = useRef(false); const soundedCaptureIdsRef = useRef(new Set()); + const undeliverableCaptureIdsRef = useRef(new Set()); const pendingAnimationStartsRef = useRef(new Set()); const currentTarget = routeThreadRef ?? routeDraftId; @@ -248,7 +285,7 @@ export function SnapShotCoordinator() { const playCaptureSound = useCallback( (id: string) => { - if (!captureSound || soundedCaptureIdsRef.current.has(id)) return; + if (!enabledRef.current || !captureSound || soundedCaptureIdsRef.current.has(id)) return; soundedCaptureIdsRef.current.add(id); try { playSnapShotSound(captureSound); @@ -258,6 +295,9 @@ export function SnapShotCoordinator() { ); const drain = useCallback(async () => { + // Composer updates, focus, and visibility all reach this. While SnapShots + // are off, those passes must not list, sound, or toast leftover captures. + if (!enabled || !enabledRef.current) return; const bridge = getDesktopSnapShotBridge(); if (!bridge) return; if (drainingRef.current) { @@ -267,39 +307,51 @@ export function SnapShotCoordinator() { const operation = (async () => { do { + if (!enabledRef.current) return; rerunRequestedRef.current = false; const pending = await bridge.listPendingSnapShots(); + if (!enabledRef.current) return; for (const item of pending) { + if (!enabledRef.current) return; playCaptureSound(item.id); const capturedTarget = await resolveSnapShotDeliveryTarget( captureTargetsRef.current, item.id, resolveCaptureTarget, ); + if (!enabledRef.current) return; const target = capturedTarget ? resolveExistingSnapShotTarget(capturedTarget, routeThreadRef) : null; if (!target) { await dismissSnapShotAnimation(item.id); - soundedCaptureIdsRef.current.delete(item.id); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Snapshot taken, but no project is available", - description: "Add a project, then capture the window again.", - }), - ); + // The file stays pending and comes back on the next drain. + if ( + enabledRef.current && + shouldReportUndeliverableSnapShot(item.id, undeliverableCaptureIdsRef.current) + ) { + notify( + stackedThreadToast({ + type: "error", + title: "Snapshot taken, but no project is available", + description: "Add a project, then capture the window again.", + }), + ); + } continue; } try { - await deliverSnapShot(bridge, item, target); + const delivered = await deliverSnapShot(bridge, item, target, () => enabledRef.current); + if (!delivered || !enabledRef.current) return; captureTargetsRef.current.delete(item.id); soundedCaptureIdsRef.current.delete(item.id); + undeliverableCaptureIdsRef.current.delete(item.id); } catch (error) { + if (!enabledRef.current) return; await dismissSnapShotAnimation(item.id); soundedCaptureIdsRef.current.delete(item.id); - toastManager.add( + notify( stackedThreadToast({ type: "error", title: "Snapshot failed", @@ -314,7 +366,7 @@ export function SnapShotCoordinator() { })() .catch((error: unknown) => { dismissAllSnapShotAnimations(); - toastManager.add( + notify( stackedThreadToast({ type: "error", title: "Snapshot failed", @@ -327,9 +379,10 @@ export function SnapShotCoordinator() { }); drainingRef.current = operation; return operation; - }, [playCaptureSound, resolveCaptureTarget, routeThreadRef]); + }, [enabled, notify, playCaptureSound, resolveCaptureTarget, routeThreadRef]); useEffect(() => { + if (!enabled) return; const bridge = getDesktopSnapShotBridge(); if (!bridge) return; void drain(); @@ -356,7 +409,7 @@ export function SnapShotCoordinator() { captureTargetsRef.current, event.id, resolveCaptureTarget, - ), + ).then((target) => (enabledRef.current ? target : null)), pendingAnimationStartsRef.current, ); } @@ -373,7 +426,7 @@ export function SnapShotCoordinator() { pendingAnimationStartsRef.current, ); void bridge.getSnapShotState().then((state) => { - toastManager.add( + notify( stackedThreadToast({ type: "error", title: "Snapshot failed", @@ -388,7 +441,15 @@ export function SnapShotCoordinator() { } }); return unsubscribe; - }, [animateCaptures, drain, playCaptureSound, resolveCaptureTarget, routeThreadRef]); + }, [ + animateCaptures, + drain, + enabled, + notify, + playCaptureSound, + resolveCaptureTarget, + routeThreadRef, + ]); useEffect(() => { const dismissOnBlur = () => {