From 8cc34e57ff532a15f67d10142239947f3c373e09 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:36:38 +0000 Subject: [PATCH] fix(desktop): do not trigger SnapShots while typing when feature is off SnapShotCoordinator kept draining leftover captures on chat updates, focus, and visibility after the feature was turned off, so the same pending file played a sound and stacked a toast while typing. Skip that work while SnapShots are off, stop a drain already in flight, and report an undeliverable capture once. Turning the feature off still kills the macOS both-Shift poller. Fixes #12769 --- .../src/snapShot/DesktopSnapShot.test.ts | 29 +++ .../desktop/SnapShotCoordinator.gate.test.tsx | 216 ++++++++++++++++++ .../desktop/SnapShotCoordinator.test.ts | 53 +++++ .../desktop/SnapShotCoordinator.tsx | 97 ++++++-- 4 files changed, 377 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/desktop/SnapShotCoordinator.gate.test.tsx 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 = () => {