Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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