Skip to content
Closed
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
30 changes: 28 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { RefreshIcon } from "~/components/ui/refresh-icon";
import {
questionAttachmentDraftId,
countQuestionAttachments,
trackOpenQuestionAttachmentDraft,
useQuestionAttachmentPreparation,
changeQuestionAttachmentPreparation,
} from "../../questionAttachments";
Expand Down Expand Up @@ -1568,6 +1569,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
: null;
const attachmentDraftTarget = questionAttachmentTarget ?? composerDraftTarget;
const attachmentDraft = useComposerThreadDraft(attachmentDraftTarget);
// Same gate as dropped files: SnapShots only follow a question that can still take attachments.
const questionAcceptsSnapShots =
supportsQuestionAttachments &&
activePendingProgress?.activeQuestion?.allowCustomAnswer !== false &&
!activePendingIsResponding;
const questionRequest = pendingUserInputs[0];
useEffect(() => {
if (!questionRequest || !questionAttachmentTarget || !questionAcceptsSnapShots) return;
const questionKeys = questionRequest.questions.map((question) =>
questionAttachmentDraftId(
environmentId,
activeThreadId!,
questionRequest.requestId,
question.id,
),
);
return trackOpenQuestionAttachmentDraft(routeThreadRef, questionAttachmentTarget, questionKeys);
}, [
activeThreadId,
environmentId,
questionAcceptsSnapShots,
questionAttachmentTarget,
questionRequest,
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 @@ -1605,8 +1631,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
getPendingSnapShotAnimations,
);
const pendingSnapShotIds = useMemo(
() => pendingSnapShotAnimationIdsForTarget(pendingSnapShotAnimations, composerDraftTarget),
[composerDraftTarget, pendingSnapShotAnimations],
() => pendingSnapShotAnimationIdsForTarget(pendingSnapShotAnimations, attachmentDraftTarget),
Comment thread
Gigioxx marked this conversation as resolved.
[attachmentDraftTarget, pendingSnapShotAnimations],
);
const pendingSnapShotIdSet = useMemo(() => new Set(pendingSnapShotIds), [pendingSnapShotIds]);
const uncommittedSnapShotIds = pendingSnapShotIds.filter(
Expand Down
121 changes: 121 additions & 0 deletions apps/web/src/components/desktop/SnapShotCoordinator.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import {
ApprovalRequestId,
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 @@ -291,6 +298,120 @@ describe("window capture target resolution", () => {
});
});

describe("open question delivery", () => {
it("attaches a capture to the open question draft instead of the thread draft", async () => {
const threadRef = scopeThreadRef(environmentId, ThreadId.make("question-thread"));
const questionDraft = questionAttachmentDraftId(
environmentId,
threadRef.threadId,
ApprovalRequestId.make("request-1"),
"question-1",
);
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 bridge = {
readSnapShot: async () => capture,
acknowledgeSnapShot: vi.fn(async () => undefined),
} as unknown as DesktopSnapShotBridge;
vi.stubGlobal("window", { localStorage: storage, dispatchEvent: vi.fn() });
const pins = new Map<string, DraftId | null>();
const untrack = trackOpenQuestionAttachmentDraft(threadRef, questionDraft, [questionDraft]);
try {
const target = resolveSnapShotAttachmentTarget(pins, capture.id, threadRef);
expect(target).toBe(questionDraft);
await deliverSnapShot(bridge, capture, target);
const store = useComposerDraftStore.getState();
expect(store.getComposerDraft(questionDraft)?.images.map(({ id }) => id)).toEqual([
capture.id,
]);
expect(store.getComposerDraft(threadRef)?.images ?? []).toHaveLength(0);
} finally {
untrack();
}
expect(resolveSnapShotAttachmentTarget(pins, capture.id, threadRef)).toEqual(threadRef);
});

it("keeps a capture on the question it was pinned to, never a newer one", () => {
const threadRef = scopeThreadRef(environmentId, ThreadId.make("question-thread"));
const requestId = ApprovalRequestId.make("request-1");
const first = questionAttachmentDraftId(environmentId, threadRef.threadId, requestId, "q1");
const second = questionAttachmentDraftId(environmentId, threadRef.threadId, requestId, "q2");
const pins = new Map<string, DraftId | null>();

const untrackNone = trackOpenQuestionAttachmentDraft(threadRef, first, [first, second]);
untrackNone();
expect(resolveSnapShotAttachmentTarget(pins, "before-question", threadRef)).toEqual(threadRef);
const untrackFirst = trackOpenQuestionAttachmentDraft(threadRef, first, [first, second]);
expect(resolveSnapShotAttachmentTarget(pins, "before-question", threadRef)).toEqual(threadRef);
expect(resolveSnapShotAttachmentTarget(pins, "during-first", threadRef)).toBe(first);
untrackFirst();
const untrackSecond = trackOpenQuestionAttachmentDraft(threadRef, second, [first, second]);
expect(resolveSnapShotAttachmentTarget(pins, "during-first", threadRef)).toEqual(threadRef);
expect(resolveSnapShotAttachmentTarget(pins, "during-second", threadRef)).toBe(second);
untrackSecond();
});

it("refuses a capture once the other questions of the request use the shared limit", async () => {
const threadRef = scopeThreadRef(environmentId, ThreadId.make("question-thread"));
const requestId = ApprovalRequestId.make("request-1");
const first = questionAttachmentDraftId(environmentId, threadRef.threadId, requestId, "q1");
const second = questionAttachmentDraftId(environmentId, threadRef.threadId, requestId, "q2");
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",
file: new File([new Uint8Array([1, 2, 3])], `staged-${index}.png`, { type: "image/png" }),
})),
);
const capture = {
id: "12345678-1234-1234-1234-123456789abd",
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 bridge = {
readSnapShot: async () => capture,
acknowledgeSnapShot: vi.fn(async () => undefined),
} as unknown as DesktopSnapShotBridge;
vi.stubGlobal("window", { localStorage: storage, dispatchEvent: vi.fn() });
const untrack = trackOpenQuestionAttachmentDraft(threadRef, second, [first, second]);
try {
await expect(deliverSnapShot(bridge, capture, second)).rejects.toThrow(
"Remove an attachment",
);
expect(useComposerDraftStore.getState().getComposerDraft(second)?.images ?? []).toHaveLength(
0,
);
expect(bridge.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
71 changes: 59 additions & 12 deletions apps/web/src/components/desktop/SnapShotCoordinator.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
type DesktopPendingSnapShot,
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
type ScopedThreadRef,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -31,6 +32,10 @@ import {
getDesktopSnapShotBridge,
type DesktopSnapShotBridge,
} from "../../lib/desktopSnapShot";
import {
countOpenQuestionRequestAttachments,
openQuestionAttachmentDraft,
} from "../../questionAttachments";
import { readFileAsDataUrl } from "../ChatView.logic";
import { stackedThreadToast, toastManager } from "../ui/toast";

Expand All @@ -57,6 +62,24 @@ export function resolveExistingSnapShotTarget(
: null;
}

// While a question is open its draft owns attachments, so captures land where drag-drop does.
// The question is pinned when the capture is first seen so the animation and the delivery agree;
// a question that closed before delivery falls back to the thread draft instead of a newer one.
export function resolveSnapShotAttachmentTarget(
pins: Map<string, DraftId | null>,
id: string,
target: CaptureTarget,
): CaptureTarget {
if (typeof target === "string") return target;
const open = openQuestionAttachmentDraft(target);
const pinned = pins.get(id);
if (pinned === undefined) {
pins.set(id, open);
return open ?? target;
}
return pinned !== null && pinned === open ? pinned : target;
}

const NEXT_PAINT_FALLBACK_MS = 100;

export async function beginSnapShotAnimationWhenReady(
Expand Down Expand Up @@ -150,18 +173,28 @@ export async function deliverSnapShot(
const dataUrl = compressed.recompressed ? await readFileAsDataUrl(file) : capture.dataUrl;
const alreadyAttached =
store.getComposerDraft(target)?.images.some(({ id }) => id === capture.id) ?? false;
// Every question of a request shares one attachment limit, which `addImages` only checks per draft.
const fitsQuestionLimit =
typeof target !== "string" ||
countOpenQuestionRequestAttachments(target) < PROVIDER_SEND_TURN_MAX_ATTACHMENTS;
// `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,
})
(!fitsQuestionLimit ||
!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 @@ -208,6 +241,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, DraftId | null>());
const lastTargetRef = useRef<CaptureTarget | null>(null);
const targetResolutionRef = useRef<Promise<CaptureTarget | null> | null>(null);
const drainingRef = useRef<Promise<void> | null>(null);
Expand Down Expand Up @@ -293,8 +327,13 @@ 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);
Expand Down Expand Up @@ -341,6 +380,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),
);
Expand All @@ -356,6 +396,10 @@ export function SnapShotCoordinator() {
captureTargetsRef.current,
event.id,
resolveCaptureTarget,
).then(
(target) =>
target &&
resolveSnapShotAttachmentTarget(questionPinsRef.current, event.id, target),
),
pendingAnimationStartsRef.current,
);
Expand All @@ -366,7 +410,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
36 changes: 35 additions & 1 deletion apps/web/src/questionAttachments.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import type {
ApprovalRequestId,
EnvironmentId,
ScopedThreadRef,
ThreadId,
} from "@t3tools/contracts";
import { create } from "zustand";
import { DraftId, useComposerDraftStore } from "./composerDraftStore";
import { releaseDraftAttachments } from "./lib/attachmentUploadQueue";
Expand All @@ -21,6 +27,34 @@ export function questionAttachmentDraftId(
);
}

const openQuestionDrafts = new Map<string, DraftId>();
const openQuestionKeys = new Map<DraftId, ReadonlyArray<DraftId>>();

/** The question draft currently receiving attachments for a thread, so SnapShot delivery lands beside drag-drop files instead of on the hidden thread composer. */
export function openQuestionAttachmentDraft(threadRef: ScopedThreadRef): DraftId | null {
return openQuestionDrafts.get(scopedThreadKey(threadRef)) ?? null;
}

/** `questionKeys` lists every question draft of the request, since they share one attachment limit. */
export function trackOpenQuestionAttachmentDraft(
threadRef: ScopedThreadRef,
draftId: DraftId,
questionKeys: ReadonlyArray<DraftId>,
): () => void {
const key = scopedThreadKey(threadRef);
openQuestionDrafts.set(key, draftId);
openQuestionKeys.set(draftId, questionKeys);
return () => {
if (openQuestionDrafts.get(key) === draftId) openQuestionDrafts.delete(key);
if (openQuestionKeys.get(draftId) === questionKeys) openQuestionKeys.delete(draftId);
};
}

/** Attachments reserved across the open request that `draftId` answers; 0 for non-question drafts. */
export function countOpenQuestionRequestAttachments(draftId: DraftId): number {
return countQuestionAttachments(openQuestionKeys.get(draftId) ?? []);
}

export const useQuestionAttachmentPreparation = create<{ counts: Record<string, number> }>(() => ({
counts: {},
}));
Expand Down
Loading