From c5b865ac5cf0f4b485ea43d1ab9a9547e2dbaaaf Mon Sep 17 00:00:00 2001 From: "Nick T." <73005126+tiliakoos@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:08:11 -0400 Subject: [PATCH 1/2] fix(web): SnapShots taken during a question attach to that question The coordinator delivered shortcut captures to the thread draft while the composer staged question attachments in a per-question draft, so a capture taken during a question stayed hidden until the answer was sent. --- apps/web/src/components/chat/ChatComposer.tsx | 32 ++++- .../desktop/SnapShotCoordinator.test.ts | 119 +++++++++++++++++- .../desktop/SnapShotCoordinator.tsx | 72 +++++++++-- apps/web/src/questionAttachments.ts | 37 +++++- 4 files changed, 241 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8cd297925f31..106e5f59cc85 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -12,6 +12,7 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { questionAttachmentDraftId, countQuestionAttachments, + trackOpenQuestionAttachmentDraft, useQuestionAttachmentPreparation, changeQuestionAttachmentPreparation, } from "../../questionAttachments"; @@ -1810,6 +1811,33 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : null; const attachmentDraftTarget = questionAttachmentTarget ?? composerDraftTarget; const attachmentDraft = useComposerThreadDraft(attachmentDraftTarget); + // SnapShots follow the question under the attach button's gate, and only until its answer sends. + const questionTakesSnapShots = + supportsQuestionAttachments && + activePendingProgress?.activeQuestion?.allowCustomAnswer !== false && + !activePendingIsResponding; + const questionRequest = pendingUserInputs[0]; + useEffect(() => { + if (!questionAttachmentTarget || !questionRequest || !questionTakesSnapShots) return; + return trackOpenQuestionAttachmentDraft(routeThreadRef, { + draftId: questionAttachmentTarget, + requestKeys: questionRequest.questions.map((question) => + questionAttachmentDraftId( + environmentId, + activeThreadId!, + questionRequest.requestId, + question.id, + ), + ), + }); + }, [ + activeThreadId, + environmentId, + questionAttachmentTarget, + questionRequest, + questionTakesSnapShots, + routeThreadRef, + ]); const attachmentTargetKey = composerTargetKey(attachmentDraftTarget); // An import that finishes after a draft change must compare against the draft open *now*, not // the one captured in the closure that started it. @@ -1848,8 +1876,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) getPendingSnapShotAnimations, ); const pendingSnapShotIds = useMemo( - () => pendingSnapShotAnimationIdsForTarget(pendingSnapShotAnimations, composerDraftTarget), - [composerDraftTarget, pendingSnapShotAnimations], + () => pendingSnapShotAnimationIdsForTarget(pendingSnapShotAnimations, attachmentDraftTarget), + [attachmentDraftTarget, pendingSnapShotAnimations], ); const pendingSnapShotIdSet = useMemo(() => new Set(pendingSnapShotIds), [pendingSnapShotIds]); const uncommittedSnapShotIds = pendingSnapShotIds.filter( diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.test.ts b/apps/web/src/components/desktop/SnapShotCoordinator.test.ts index eb0a6fbb1352..4dd2cb6f4fde 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.test.ts +++ b/apps/web/src/components/desktop/SnapShotCoordinator.test.ts @@ -2,6 +2,7 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environ import { type DesktopPendingSnapShot, EnvironmentId, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProjectId, ThreadId, } from "@t3tools/contracts"; @@ -9,11 +10,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import type { DesktopSnapShotBridge } from "../../lib/desktopSnapShot"; +import { + questionAttachmentDraftId, + trackOpenQuestionAttachmentDraft, +} from "../../questionAttachments"; import { beginSnapShotAnimationWhenReady, deliverSnapShot, dismissFailedSnapShot, resolveExistingSnapShotTarget, + resolveSnapShotAttachmentTarget, resolveSnapShotTargetOnce, resolveSnapShotDeliveryTarget, } from "./SnapShotCoordinator"; @@ -224,7 +230,7 @@ describe("window capture delivery", () => { item.source, ); - const delivery = deliverSnapShot(bridge, item, target); + const delivery = deliverSnapShot(bridge, item, () => target); await vi.advanceTimersByTimeAsync(0); const draft = useComposerDraftStore.getState().getComposerDraft(target); @@ -291,6 +297,113 @@ describe("window capture target resolution", () => { }); }); +describe("open question delivery", () => { + const threadRef = scopeThreadRef(environmentId, ThreadId.make("question-thread")); + const first = questionAttachmentDraftId(environmentId, threadRef.threadId, "request-1", "q1"); + const second = questionAttachmentDraftId(environmentId, threadRef.threadId, "request-1", "q2"); + const request = { draftId: first, requestKeys: [first, second] }; + 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", + }, + }; + const makeBridge = (readSnapShot: () => Promise = async () => capture) => { + const acknowledgeSnapShot = vi.fn(async () => undefined); + const bridge = { readSnapShot, acknowledgeSnapShot } as unknown as DesktopSnapShotBridge; + return { bridge, acknowledgeSnapShot }; + }; + + it("attaches a capture to the open question draft and leaves the thread draft alone", async () => { + vi.stubGlobal("window", { localStorage: storage, dispatchEvent: vi.fn() }); + const pins = new Map(); + const untrack = trackOpenQuestionAttachmentDraft(threadRef, request); + try { + const { bridge, acknowledgeSnapShot } = makeBridge(); + await deliverSnapShot(bridge, capture, () => + resolveSnapShotAttachmentTarget(pins, capture.id, threadRef), + ); + const store = useComposerDraftStore.getState(); + expect(store.getComposerDraft(first)?.images.map(({ id }) => id)).toEqual([capture.id]); + expect(store.getComposerDraft(threadRef)?.images ?? []).toHaveLength(0); + expect(acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id); + } finally { + untrack(); + } + }); + + it("keeps a capture on the question it was pinned to, never a newer one", () => { + const pins = new Map(); + expect(resolveSnapShotAttachmentTarget(pins, "before", threadRef)).toEqual(threadRef); + const untrackFirst = trackOpenQuestionAttachmentDraft(threadRef, request); + expect(resolveSnapShotAttachmentTarget(pins, "before", threadRef)).toEqual(threadRef); + expect(resolveSnapShotAttachmentTarget(pins, "during-first", threadRef)).toBe(first); + const untrackSecond = trackOpenQuestionAttachmentDraft(threadRef, { + ...request, + draftId: second, + }); + // A stale cleanup from the previous question must not drop the newer registration. + untrackFirst(); + expect(resolveSnapShotAttachmentTarget(pins, "during-first", threadRef)).toEqual(threadRef); + expect(resolveSnapShotAttachmentTarget(pins, "during-second", threadRef)).toBe(second); + untrackSecond(); + expect(resolveSnapShotAttachmentTarget(pins, "during-second", threadRef)).toEqual(threadRef); + }); + + it("falls back to the thread draft when the question closes while the capture is read", async () => { + vi.stubGlobal("window", { localStorage: storage, dispatchEvent: vi.fn() }); + const pins = new Map(); + const untrack = trackOpenQuestionAttachmentDraft(threadRef, request); + const { bridge, acknowledgeSnapShot } = makeBridge(async () => { + untrack(); + return capture; + }); + const resolveTarget = () => resolveSnapShotAttachmentTarget(pins, capture.id, threadRef); + expect(resolveTarget()).toBe(first); + await deliverSnapShot(bridge, capture, resolveTarget); + const store = useComposerDraftStore.getState(); + expect(store.getComposerDraft(first)?.images ?? []).toHaveLength(0); + expect(store.getComposerDraft(threadRef)?.images.map(({ id }) => id)).toEqual([capture.id]); + expect(acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id); + }); + + it("refuses a capture once the request's other questions hold the shared limit", async () => { + vi.stubGlobal("window", { localStorage: storage, dispatchEvent: vi.fn() }); + useComposerDraftStore.getState().addImages( + first, + Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS }, (_, index) => ({ + type: "image" as const, + id: `staged-${index}`, + name: `staged-${index}.png`, + mimeType: "image/png", + sizeBytes: 3, + previewUrl: `data:image/png;base64,AQID${index}`, + file: new File([new Uint8Array([1, 2, 3])], `staged-${index}.png`, { type: "image/png" }), + })), + ); + const untrack = trackOpenQuestionAttachmentDraft(threadRef, { ...request, draftId: second }); + try { + const { bridge, acknowledgeSnapShot } = makeBridge(); + await expect(deliverSnapShot(bridge, capture, () => second)).rejects.toThrow( + "Remove an attachment", + ); + expect(useComposerDraftStore.getState().getComposerDraft(second)?.images ?? []).toHaveLength( + 0, + ); + expect(acknowledgeSnapShot).not.toHaveBeenCalled(); + } finally { + untrack(); + } + }); +}); + describe("durable snapshot delivery", () => { it.each([false, true])( "retains a capture on quota failure and retries without duplicates (staged: %s)", @@ -330,7 +443,7 @@ describe("durable snapshot delivery", () => { }); void store.syncPersistedAttachments(target, [capture]); } - await expect(deliverSnapShot(bridge, capture, target)).rejects.toThrow( + await expect(deliverSnapShot(bridge, capture, () => target)).rejects.toThrow( "could not be saved", ); expect(acknowledgeSnapShot).not.toHaveBeenCalled(); @@ -338,7 +451,7 @@ describe("durable snapshot delivery", () => { useComposerDraftStore.getState().getComposerDraft(target)?.nonPersistedImageIds, ).toContain(capture.id); storage.setItem.mockImplementation(write); - await deliverSnapShot(bridge, capture, target); + await deliverSnapShot(bridge, capture, () => target); expect(acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id); expect(useComposerDraftStore.getState().getComposerDraft(target)?.images).toHaveLength(1); expect( diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.tsx b/apps/web/src/components/desktop/SnapShotCoordinator.tsx index e7ac04b0d068..1b00e808a0b7 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.tsx +++ b/apps/web/src/components/desktop/SnapShotCoordinator.tsx @@ -1,5 +1,6 @@ import { type DesktopPendingSnapShot, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ScopedThreadRef, } from "@t3tools/contracts"; @@ -31,6 +32,11 @@ import { getDesktopSnapShotBridge, type DesktopSnapShotBridge, } from "../../lib/desktopSnapShot"; +import { + countQuestionRequestAttachments, + type OpenQuestionAttachmentDraft, + openQuestionAttachmentDraft, +} from "../../questionAttachments"; import { readFileAsDataUrl } from "../ChatView.logic"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -57,6 +63,23 @@ export function resolveExistingSnapShotTarget( : null; } +// The question open when a capture is first seen owns it, so the animation and the delivery agree. +// Once that question closes, the capture goes to the thread draft, never to a newer question. +export function resolveSnapShotAttachmentTarget( + pins: Map, + id: string, + target: CaptureTarget, +): CaptureTarget { + if (typeof target === "string") return target; + const open = openQuestionAttachmentDraft(target); + let pinned = pins.get(id); + if (pinned === undefined) { + pinned = open; + pins.set(id, open); + } + return pinned && pinned.draftId === open?.draftId ? pinned.draftId : target; +} + const NEXT_PAINT_FALLBACK_MS = 100; export async function beginSnapShotAnimationWhenReady( @@ -131,10 +154,12 @@ async function afterNextPaint(): Promise { }); } +// `resolveTarget` runs after the capture is read and compressed, so a question that closed in the +// meantime does not get its draft recreated with an image its answer can no longer carry. export async function deliverSnapShot( bridge: DesktopSnapShotBridge, item: DesktopPendingSnapShot, - target: CaptureTarget, + resolveTarget: () => CaptureTarget, ): Promise { const store = useComposerDraftStore.getState(); updateSnapShotAnimationSource(item.id, item.source); @@ -148,20 +173,29 @@ export async function deliverSnapShot( const file = compressed.file; const source = resizeSnapShotSource(capture.source, compressed.imageSize); const dataUrl = compressed.recompressed ? await readFileAsDataUrl(file) : capture.dataUrl; + const target = resolveTarget(); const alreadyAttached = store.getComposerDraft(target)?.images.some(({ id }) => id === capture.id) ?? false; + // The questions of one request share a limit that `addImages` only checks per draft, and + // `addImage` refuses drafts without a thread session, which a question draft never has. if ( !alreadyAttached && - !store.addImage(target, { - type: "image", - id: capture.id, - name: file.name, - mimeType: file.type, - sizeBytes: file.size, - previewUrl: dataUrl, - file, - source, - }) + ((typeof target === "string" && + countQuestionRequestAttachments(target) >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) || + !store + .addImages(target, [ + { + type: "image", + id: capture.id, + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + previewUrl: dataUrl, + file, + source, + }, + ]) + .includes(capture.id)) ) { throw new Error("Remove an attachment, then try this capture again."); } @@ -208,6 +242,7 @@ export function SnapShotCoordinator() { ); const animateCaptures = useClientSettings((settings) => settings.snapShotAnimations); const captureTargetsRef = useRef(new Map>()); + const questionPinsRef = useRef(new Map()); const lastTargetRef = useRef(null); const targetResolutionRef = useRef | null>(null); const drainingRef = useRef | null>(null); @@ -293,8 +328,11 @@ export function SnapShotCoordinator() { } try { - await deliverSnapShot(bridge, item, target); + await deliverSnapShot(bridge, item, () => + resolveSnapShotAttachmentTarget(questionPinsRef.current, item.id, target), + ); captureTargetsRef.current.delete(item.id); + questionPinsRef.current.delete(item.id); soundedCaptureIdsRef.current.delete(item.id); } catch (error) { await dismissSnapShotAnimation(item.id); @@ -341,6 +379,7 @@ export function SnapShotCoordinator() { // Creating a new draft would navigate the renderer before a self-capture finishes. // Pin existing drafts now; create a destination after acquisition when none exists. if (target) { + resolveSnapShotAttachmentTarget(questionPinsRef.current, event.id, target); void resolveSnapShotDeliveryTarget(captureTargetsRef.current, event.id, () => Promise.resolve(target), ); @@ -356,6 +395,10 @@ export function SnapShotCoordinator() { captureTargetsRef.current, event.id, resolveCaptureTarget, + ).then( + (target) => + target && + resolveSnapShotAttachmentTarget(questionPinsRef.current, event.id, target), ), pendingAnimationStartsRef.current, ); @@ -366,7 +409,10 @@ export function SnapShotCoordinator() { void drain(); return; case "failed": { - if (event.id) captureTargetsRef.current.delete(event.id); + if (event.id) { + captureTargetsRef.current.delete(event.id); + questionPinsRef.current.delete(event.id); + } dismissFailedSnapShot( event.id, soundedCaptureIdsRef.current, diff --git a/apps/web/src/questionAttachments.ts b/apps/web/src/questionAttachments.ts index bb32ae9d8d27..8235e5cfc342 100644 --- a/apps/web/src/questionAttachments.ts +++ b/apps/web/src/questionAttachments.ts @@ -1,4 +1,5 @@ -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; import { DraftId, useComposerDraftStore } from "./composerDraftStore"; import { releaseDraftAttachments } from "./lib/attachmentUploadQueue"; @@ -21,6 +22,40 @@ export function questionAttachmentDraftId( ); } +export type OpenQuestionAttachmentDraft = { + readonly draftId: DraftId; + /** Every question draft of the request, since they share one attachment limit. */ + readonly requestKeys: ReadonlyArray; +}; + +const openQuestionDrafts = new Map(); + +/** The question draft taking attachments on a thread, so SnapShots land where dropped files do. */ +export function openQuestionAttachmentDraft( + threadRef: ScopedThreadRef, +): OpenQuestionAttachmentDraft | null { + return openQuestionDrafts.get(scopedThreadKey(threadRef)) ?? null; +} + +/** Attachments staged or preparing across the request a question draft answers; 0 for other drafts. */ +export function countQuestionRequestAttachments(draftId: DraftId): number { + for (const draft of openQuestionDrafts.values()) { + if (draft.draftId === draftId) return countQuestionAttachments(draft.requestKeys); + } + return 0; +} + +export function trackOpenQuestionAttachmentDraft( + threadRef: ScopedThreadRef, + draft: OpenQuestionAttachmentDraft, +): () => void { + const key = scopedThreadKey(threadRef); + openQuestionDrafts.set(key, draft); + return () => { + if (openQuestionDrafts.get(key) === draft) openQuestionDrafts.delete(key); + }; +} + export const useQuestionAttachmentPreparation = create<{ counts: Record }>(() => ({ counts: {}, })); From d621d99d02a61cac3262c87df5a1cb51b11b0238 Mon Sep 17 00:00:00 2001 From: "Nick T." <73005126+tiliakoos@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:04:08 -0400 Subject: [PATCH 2/2] fix(web): SnapShot question pins are set before the read and in the question's commit A capture the drain found was pinned only once it was read, so a question opening meanwhile could claim it, and the composer registered the open question in a passive effect, a frame after the question's UI changed. --- apps/web/src/components/chat/ChatComposer.tsx | 4 +++- .../desktop/SnapShotCoordinator.test.ts | 22 +++++++++++++++++++ .../desktop/SnapShotCoordinator.tsx | 3 +++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 34eb1a97d5f8..5ed8bece70ee 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2004,7 +2004,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress?.activeQuestion?.allowCustomAnswer !== false && !activePendingIsResponding; const questionRequest = pendingUserInputs[0]; - useEffect(() => { + // A layout effect, so the registry changes in the same commit as the question's UI: a capture + // resolved between the paint and a passive effect would otherwise see the old question state. + useLayoutEffect(() => { if (!questionAttachmentTarget || !questionRequest || !questionTakesSnapShots) return; return trackOpenQuestionAttachmentDraft(routeThreadRef, { draftId: questionAttachmentTarget, diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.test.ts b/apps/web/src/components/desktop/SnapShotCoordinator.test.ts index 4dd2cb6f4fde..0029b2c5a15c 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.test.ts +++ b/apps/web/src/components/desktop/SnapShotCoordinator.test.ts @@ -374,6 +374,28 @@ describe("open question delivery", () => { expect(acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id); }); + it("keeps a capture pinned before any question off a question that opens during the read", async () => { + vi.stubGlobal("window", { localStorage: storage, dispatchEvent: vi.fn() }); + const pins = new Map(); + const resolveTarget = () => resolveSnapShotAttachmentTarget(pins, capture.id, threadRef); + // The drain pins a capture it never saw requested before reading it. + expect(resolveTarget()).toEqual(threadRef); + let untrack = () => {}; + const { bridge, acknowledgeSnapShot } = makeBridge(async () => { + untrack = trackOpenQuestionAttachmentDraft(threadRef, request); + return capture; + }); + try { + await deliverSnapShot(bridge, capture, resolveTarget); + const store = useComposerDraftStore.getState(); + expect(store.getComposerDraft(first)?.images ?? []).toHaveLength(0); + expect(store.getComposerDraft(threadRef)?.images.map(({ id }) => id)).toEqual([capture.id]); + expect(acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id); + } finally { + untrack(); + } + }); + it("refuses a capture once the request's other questions hold the shared limit", async () => { vi.stubGlobal("window", { localStorage: storage, dispatchEvent: vi.fn() }); useComposerDraftStore.getState().addImages( diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.tsx b/apps/web/src/components/desktop/SnapShotCoordinator.tsx index 5e537ff47e66..2f5f4c1d5fae 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.tsx +++ b/apps/web/src/components/desktop/SnapShotCoordinator.tsx @@ -351,6 +351,9 @@ export function SnapShotCoordinator() { } try { + // A capture this coordinator never saw requested (a reload, a blurred window) is + // pinned here, before the read, so a question that opens meanwhile cannot claim it. + resolveSnapShotAttachmentTarget(questionPinsRef.current, item.id, target); await deliverSnapShot(bridge, item, () => resolveSnapShotAttachmentTarget(questionPinsRef.current, item.id, target), );