Skip to content
Merged
32 changes: 31 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { RefreshIcon } from "~/components/ui/refresh-icon";
import {
questionAttachmentDraftId,
countQuestionAttachments,
trackOpenQuestionAttachmentDraft,
useQuestionAttachmentPreparation,
changeQuestionAttachmentPreparation,
} from "../../questionAttachments";
Expand Down Expand Up @@ -1997,6 +1998,35 @@ 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];
// 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,
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.
Expand Down Expand Up @@ -2041,7 +2071,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
standaloneComposerImages,
} = useComposerImageGroups(
pendingSnapShotAnimations,
composerDraftTarget,
attachmentDraftTarget,
composerImages,
composerPreviewAnnotations,
);
Expand Down
141 changes: 138 additions & 3 deletions apps/web/src/components/desktop/SnapShotCoordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environ
import {
type DesktopPendingSnapShot,
EnvironmentId,
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
ProjectId,
ThreadId,
} from "@t3tools/contracts";
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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -291,6 +297,135 @@ 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<typeof capture> = 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("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(
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)",
Expand Down Expand Up @@ -330,15 +465,15 @@ 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();
expect(
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(
Expand Down
75 changes: 62 additions & 13 deletions apps/web/src/components/desktop/SnapShotCoordinator.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type DesktopPendingSnapShot,
type DesktopSnapShotEvent,
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
type ScopedThreadRef,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -33,6 +34,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";

Expand Down Expand Up @@ -64,6 +70,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<string, OpenQuestionAttachmentDraft | null>,
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(
Expand Down Expand Up @@ -137,10 +160,12 @@ async function afterNextPaint(): Promise<void> {
});
}

// `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<void> {
const store = useComposerDraftStore.getState();
updateSnapShotAnimationSource(item.id, item.source);
Expand All @@ -154,20 +179,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();
Comment thread
tiliakoos marked this conversation as resolved.
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.");
}
Expand Down Expand Up @@ -214,6 +248,7 @@ export function SnapShotCoordinator() {
);
const animateCaptures = useClientSettings((settings) => settings.snapShotAnimations);
const captureTargetsRef = useRef(new Map<string, Promise<CaptureTarget | null>>());
const questionPinsRef = useRef(new Map<string, OpenQuestionAttachmentDraft | null>());
const targetResolutionRef = useRef<Promise<CaptureTarget | null> | null>(null);
const drainingRef = useRef<Promise<void> | null>(null);
const rerunRequestedRef = useRef(false);
Expand Down Expand Up @@ -316,8 +351,14 @@ export function SnapShotCoordinator() {
}

try {
await deliverSnapShot(bridge, item, target);
// 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),
);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
captureTargetsRef.current.delete(item.id);
questionPinsRef.current.delete(item.id);
soundedCaptureIdsRef.current.delete(item.id);
} catch (error) {
await dismissSnapShotAnimation(item.id);
Expand Down Expand Up @@ -363,6 +404,7 @@ export function SnapShotCoordinator() {
const target = current ? resolveExistingSnapShotTarget(current, routeThreadRef) : null;
// Creating a new draft would navigate the renderer before a self-capture finishes.
if (target) {
resolveSnapShotAttachmentTarget(questionPinsRef.current, event.id, target);
void resolveSnapShotDeliveryTarget(captureTargetsRef.current, event.id, () =>
Promise.resolve(target),
);
Expand All @@ -378,6 +420,10 @@ export function SnapShotCoordinator() {
captureTargetsRef.current,
event.id,
resolveCaptureTarget,
).then(
(target) =>
target &&
resolveSnapShotAttachmentTarget(questionPinsRef.current, event.id, target),
),
pendingAnimationStartsRef.current,
);
Expand All @@ -388,7 +434,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,
Expand Down
Loading
Loading