diff --git a/apps/mobile/src/lib/queuedAttachmentRecall.test.ts b/apps/mobile/src/lib/queuedAttachmentRecall.test.ts new file mode 100644 index 000000000000..eebe4ba7b00b --- /dev/null +++ b/apps/mobile/src/lib/queuedAttachmentRecall.test.ts @@ -0,0 +1,153 @@ +import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +// `./uuid` pulls in expo-crypto (and through it react-native), which cannot be +// loaded in this environment; the sibling composer-image test mocks it the same way. +let recalledIdCounter = 0; +vi.mock("./uuid", () => ({ + uuidv4: () => `recalled-${(recalledIdCounter += 1)}`, +})); + +import { + describeQueuedAttachmentCapacity, + formatMissingAttachmentsError, + recallQueuedAttachments, + type QueuedAttachmentRecallDeps, + type RecallableQueuedAttachment, +} from "./queuedAttachmentRecall"; + +const attachment = ( + overrides: Partial = {}, +): RecallableQueuedAttachment => ({ + id: "attachment-1", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 4, + ...overrides, +}); + +const deps = (overrides: Partial = {}): QueuedAttachmentRecallDeps => ({ + urlById: new Map([["attachment-1", "https://assets.test/attachment-1"]]), + fetchDataUrl: async () => "data:image/png;base64,cG5nIQ==", + ...overrides, +}); + +describe("recallQueuedAttachments", () => { + it("rebuilds a draft attachment carrying the bytes inline", async () => { + const result = await recallQueuedAttachments([attachment()], deps()); + + expect(result.missing).toEqual([]); + expect(result.images).toHaveLength(1); + const image = result.images[0]!; + expect(image.type).toBe("image"); + expect(image.name).toBe("screenshot.png"); + expect(image.mimeType).toBe("image/png"); + expect(image.sizeBytes).toBe(4); + expect(image.dataUrl).toBe("data:image/png;base64,cG5nIQ=="); + expect(image.previewUri).toBe("data:image/png;base64,cG5nIQ=="); + }); + + it("gives the recalled draft a fresh id so it outlives the removed queue entry", async () => { + const result = await recallQueuedAttachments([attachment()], deps()); + + expect(result.images[0]!.id).not.toBe("attachment-1"); + expect(result.images[0]!.id).toMatch(/^recalled-/); + }); + + it("reads each attachment from its own signed url", async () => { + const requested: string[] = []; + const result = await recallQueuedAttachments( + [attachment(), attachment({ id: "attachment-2", name: "diagram.png" })], + deps({ + urlById: new Map([ + ["attachment-1", "https://assets.test/one"], + ["attachment-2", "https://assets.test/two"], + ]), + fetchDataUrl: async (url: string) => { + requested.push(url); + return "data:image/png;base64,eA=="; + }, + }), + ); + + expect(requested).toEqual(["https://assets.test/one", "https://assets.test/two"]); + expect(result.images.map((image) => image.name)).toEqual(["screenshot.png", "diagram.png"]); + }); + + it("reports an attachment whose url has not resolved instead of dropping it silently", async () => { + const result = await recallQueuedAttachments([attachment()], deps({ urlById: new Map() })); + + expect(result.images).toEqual([]); + expect(result.missing).toEqual(["screenshot.png"]); + }); + + it("keeps the readable attachments when one read fails", async () => { + const result = await recallQueuedAttachments( + [attachment(), attachment({ id: "attachment-2", name: "broken.png" })], + deps({ + urlById: new Map([ + ["attachment-1", "https://assets.test/one"], + ["attachment-2", "https://assets.test/two"], + ]), + fetchDataUrl: async (url: string) => { + if (url.endsWith("two")) throw new Error("gone"); + return "data:image/png;base64,eA=="; + }, + }), + ); + + expect(result.images.map((image) => image.name)).toEqual(["screenshot.png"]); + expect(result.missing).toEqual(["broken.png"]); + }); + + it("returns nothing for a queued message with no attachments", async () => { + const result = await recallQueuedAttachments([], deps()); + + expect(result).toEqual({ images: [], missing: [] }); + }); +}); + +describe("formatMissingAttachmentsError", () => { + it("stays silent when everything was restored", () => { + expect(formatMissingAttachmentsError([])).toBeNull(); + }); + + it("names the single attachment that was left behind", () => { + expect(formatMissingAttachmentsError(["screenshot.png"])).toBe( + "'screenshot.png' could not be loaded, so the message is still queued. Try again — if it keeps failing, the image is no longer on the server and the message has to be sent or replaced as it is.", + ); + }); + + it("counts them once several were left behind", () => { + expect(formatMissingAttachmentsError(["a.png", "b.png"])).toBe( + "2 attachments could not be loaded, so the message is still queued. Try again — if it keeps failing, the image is no longer on the server and the message has to be sent or replaced as it is.", + ); + }); +}); + +describe("describeQueuedAttachmentCapacity", () => { + it("allows an edit that fits in the composer", () => { + expect(describeQueuedAttachmentCapacity(3, 2)).toBeNull(); + }); + + it("allows an edit that exactly fills the remaining room", () => { + expect(describeQueuedAttachmentCapacity(3, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 3)).toBeNull(); + }); + + it("refuses an edit that would overflow, rather than restoring only some pictures", () => { + const message = describeQueuedAttachmentCapacity(3, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 2); + + expect(message).toContain(`${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-image limit`); + expect(message).toContain("Remove some images from the composer first."); + }); + + it("says image, singular, for one attachment", () => { + expect(describeQueuedAttachmentCapacity(1, PROVIDER_SEND_TURN_MAX_ATTACHMENTS)).toContain( + "bring back 1 image,", + ); + }); + + it("never blocks a message with no attachments", () => { + expect(describeQueuedAttachmentCapacity(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/queuedAttachmentRecall.ts b/apps/mobile/src/lib/queuedAttachmentRecall.ts new file mode 100644 index 000000000000..755e3b7576ca --- /dev/null +++ b/apps/mobile/src/lib/queuedAttachmentRecall.ts @@ -0,0 +1,125 @@ +import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; + +import type { DraftComposerImageAttachment } from "./composerImages"; +import { uuidv4 } from "./uuid"; + +/** + * Rebuilding composer attachments when a server-queued message is edited. + * + * Server-queued attachments carry only `{id, name, mimeType, sizeBytes}`; the + * bytes live on the server. Composer drafts need the bytes inline as a data + * URL, so editing has to read each attachment back through its signed asset + * URL first. Locally-queued (outbox) messages already hold their data URLs and + * skip all of this. + * + * Callers must do this *before* removing the queued message. The removal is + * what makes the edit destructive: once the queued entry is gone, its + * attachment files are pruned server-side and a failed fetch has no second + * chance — so a caller that cannot restore everything must leave the message + * queued rather than remove it. + */ + +export interface RecallableQueuedAttachment { + readonly id: string; + readonly name: string; + readonly mimeType: string; + readonly sizeBytes: number; +} + +export interface QueuedAttachmentRecallResult { + readonly images: ReadonlyArray; + /** Attachments whose bytes could not be read back, by display name. */ + readonly missing: ReadonlyArray; +} + +export interface QueuedAttachmentRecallDeps { + readonly urlById: ReadonlyMap; + readonly fetchDataUrl: (url: string) => Promise; +} + +export const defaultFetchAttachmentDataUrl = async (url: string): Promise => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Attachment request failed with status ${response.status}.`); + } + const blob = await response.blob(); + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => { + reject(reader.error ?? new Error("Attachment could not be read.")); + }; + reader.onload = () => { + const result = reader.result; + if (typeof result !== "string") { + reject(new Error("Attachment could not be read.")); + return; + } + resolve(result); + }; + reader.readAsDataURL(blob); + }); +}; + +/** + * Fetches every queued attachment back into a composer-ready draft image. Failures + * are collected rather than thrown so the caller sees the whole picture at + * once: because removing the queued message prunes its files, a caller that + * cannot restore everything must abandon the edit rather than restore part. + */ +export async function recallQueuedAttachments( + attachments: ReadonlyArray, + deps: QueuedAttachmentRecallDeps, +): Promise { + const images: DraftComposerImageAttachment[] = []; + const missing: string[] = []; + + for (const attachment of attachments) { + const url = deps.urlById.get(attachment.id); + if (!url) { + missing.push(attachment.name); + continue; + } + try { + const dataUrl = await deps.fetchDataUrl(url); + images.push({ + // A fresh id keeps the recalled draft independent of the queued entry + // that is about to be removed, matching how picked images are staged. + id: uuidv4(), + type: "image", + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl, + previewUri: dataUrl, + }); + } catch { + missing.push(attachment.name); + } + } + + return { images, missing }; +} + +export function formatMissingAttachmentsError(missing: ReadonlyArray): string | null { + if (missing.length === 0) return null; + return missing.length === 1 + ? `'${missing[0]}' could not be loaded, so the message is still queued. Try again — if it keeps failing, the image is no longer on the server and the message has to be sent or replaced as it is.` + : `${missing.length} attachments could not be loaded, so the message is still queued. Try again — if it keeps failing, the image is no longer on the server and the message has to be sent or replaced as it is.`; +} + +/** + * Refuses the edit up front when the composer has no room for the queued + * message's pictures. Restoring only some of them would drop the rest for good, + * since removing the queued message deletes its attachment files server-side. + */ +export function describeQueuedAttachmentCapacity( + queuedCount: number, + draftImageCount: number, +): string | null { + if (queuedCount === 0) return null; + const capacity = Math.max(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - draftImageCount); + if (queuedCount <= capacity) return null; + return `Editing this message would bring back ${queuedCount} image${ + queuedCount === 1 ? "" : "s" + }, past the ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-image limit. Remove some images from the composer first.`; +} diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index b8b827585ea2..f93fcd76398b 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { usePreparedConnection } from "./session"; @@ -12,6 +13,10 @@ const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial(false)) Atom.withLabel("mobile-asset-url:empty"), ); +const EMPTY_ASSET_URLS_ATOM = Atom.make([] as Array>).pipe( + Atom.withLabel("mobile-asset-urls:empty"), +); + export function useAssetUrl( environmentId: EnvironmentId | null, resource: AssetResource | null, @@ -27,3 +32,31 @@ export function useAssetUrl( } return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); } + +/** + * Batch sibling of {@link useAssetUrl}, for a set of resources whose size is + * only known at render time (a thread's attachments, say) and so cannot be + * resolved with one hook call each. + */ +export function useAssetUrls( + environmentId: EnvironmentId | null, + resources: ReadonlyArray, +): ReadonlyArray { + const preparedConnection = usePreparedConnection(environmentId); + const results = useAtomValue( + environmentId === null || resources.length === 0 + ? EMPTY_ASSET_URLS_ATOM + : assetEnvironment.createUrls({ environmentId, resources }), + ); + return useMemo( + () => + preparedConnection._tag === "None" + ? resources.map(() => null) + : results.map((result) => + AsyncResult.isSuccess(result) + ? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl) + : null, + ), + [preparedConnection, resources, results], + ); +} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 66466ede037a..20e3af2279b0 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -23,6 +23,12 @@ import { pickComposerImages, } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { + defaultFetchAttachmentDataUrl, + describeQueuedAttachmentCapacity, + formatMissingAttachmentsError, + recallQueuedAttachments, +} from "../lib/queuedAttachmentRecall"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildThreadFeed, promoteSteeredQueuedMessages } from "../lib/threadActivity"; import { appAtomRegistry } from "../state/atom-registry"; @@ -42,6 +48,7 @@ import { setPendingConnectionError, useRemoteConnectionStatus, } from "../state/use-remote-environment-registry"; +import { useAssetUrls } from "../state/assets"; import { orchestrationEnvironment } from "../state/orchestration"; import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; @@ -360,6 +367,40 @@ export function useThreadComposerState() { [selectedThreadShell, steerQueuedMessage, steeringQueuedMessageIds], ); + // Server-queued attachments are resolved up front so editing one can put its + // pictures back in the composer; the bytes only exist behind a signed URL. + const serverQueuedAttachmentIds = useMemo(() => { + const attachmentIds = new Set(); + for (const queued of serverQueuedMessages ?? []) { + for (const attachment of queued.attachments) { + attachmentIds.add(attachment.id); + } + } + return [...attachmentIds]; + }, [serverQueuedMessages]); + const serverQueuedAttachmentResources = useMemo( + () => + serverQueuedAttachmentIds.map((attachmentId) => ({ + _tag: "attachment" as const, + attachmentId, + })), + [serverQueuedAttachmentIds], + ); + const serverQueuedAttachmentUrls = useAssetUrls( + selectedThreadShell?.environmentId ?? null, + serverQueuedAttachmentResources, + ); + const serverQueuedAttachmentUrlById = useMemo( + () => + new Map( + serverQueuedAttachmentIds.flatMap((attachmentId, index) => { + const url = serverQueuedAttachmentUrls[index]; + return url ? [[attachmentId, url] as const] : []; + }), + ), + [serverQueuedAttachmentIds, serverQueuedAttachmentUrls], + ); + const onEditQueuedMessage = useCallback( async (messageId: MessageId, source: "local" | "server") => { if (!selectedThreadShell) { @@ -390,12 +431,38 @@ export function useThreadComposerState() { (candidate) => candidate.messageId === messageId, ); if (!message) return; + // Removing a queued message deletes its attachment files on the server, + // so everything that could lose a picture happens before the removal and + // backs out of the whole edit instead. + const capacityError = describeQueuedAttachmentCapacity( + message.attachments.length, + getComposerDraftSnapshot(threadKey).attachments.length, + ); + if (capacityError !== null) { + setPendingConnectionError(capacityError); + return; + } + const recalled = await recallQueuedAttachments(message.attachments, { + urlById: serverQueuedAttachmentUrlById, + fetchDataUrl: defaultFetchAttachmentDataUrl, + }); + // Signed URLs resolve asynchronously, so an edit tapped early — or one + // that hits a network blip — finds nothing to read. Leave the message + // queued: it and its pictures are intact, and the edit can be retried. + const missingError = formatMissingAttachmentsError(recalled.missing); + if (missingError !== null) { + setPendingConnectionError(missingError); + return; + } const result = await removeServerQueuedMessage({ environmentId: selectedThreadShell.environmentId, input: { threadId: selectedThreadShell.id, messageId }, }); if (result._tag !== "Success") return; setComposerDraftText(threadKey, message.text); + if (recalled.images.length > 0) { + appendComposerDraftAttachments(threadKey, recalled.images); + } } }, [ @@ -403,6 +470,7 @@ export function useThreadComposerState() { selectedThreadDetail, selectedThreadQueuedMessages, selectedThreadShell, + serverQueuedAttachmentUrlById, ], ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dc925b931ba8..4af9d8ef6f8d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -361,6 +361,12 @@ import { serverUpdateGuidance, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; +import { + defaultFetchAttachmentBlob, + describeQueuedAttachmentCapacity, + formatMissingAttachmentsError, + recallQueuedAttachments, +} from "./chat/queuedAttachmentRecall"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; @@ -1242,6 +1248,13 @@ function ChatViewContent(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); + // Lets async handlers tell "still on the thread I started from" from "the user + // has since navigated away", which decides whether the live composer handle is + // still the right place to put recalled content. + const routeThreadKeyRef = useRef(routeThreadKey); + useEffect(() => { + routeThreadKeyRef.current = routeThreadKey; + }, [routeThreadKey]); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, @@ -2510,6 +2523,10 @@ function ChatViewContent(props: ChatViewProps) { }); }, []); const serverMessages = activeThread?.messages; + // Queued messages are included so editing one can put its pictures back in + // the composer: a queued attachment names bytes the server holds, and the + // only way to recover them client-side is a signed asset URL. + const serverQueuedMessages = activeThread?.queuedMessages; const serverAttachmentIds = useMemo(() => { const attachmentIds = new Set(); for (const message of serverMessages ?? []) { @@ -2517,8 +2534,13 @@ function ChatViewContent(props: ChatViewProps) { attachmentIds.add(attachment.id); } } + for (const queued of serverQueuedMessages ?? []) { + for (const attachment of queued.attachments) { + attachmentIds.add(attachment.id); + } + } return [...attachmentIds]; - }, [serverMessages]); + }, [serverMessages, serverQueuedMessages]); const serverAttachmentResources = useMemo( () => serverAttachmentIds.map((attachmentId) => ({ @@ -5840,15 +5862,49 @@ function ChatViewContent(props: ChatViewProps) { (message) => message.messageId === messageId, ); if (!queuedMessage) return; + // Removing a queued message deletes its attachment files on the server, so + // every step that could lose a picture happens before the removal and backs + // out of the whole edit instead. + const threadId = activeThread.id; + const draftTarget = composerDraftTarget; + const draftTargetKey = routeThreadKey; + const capacityError = describeQueuedAttachmentCapacity( + queuedMessage.attachments.length, + useComposerDraftStore.getState().getComposerDraft(draftTarget)?.images.length ?? 0, + ); + if (capacityError !== null) { + setThreadError(threadId, capacityError); + return; + } + const recalled = await recallQueuedAttachments(queuedMessage.attachments, { + urlById: serverAttachmentUrlById, + fetchBlob: defaultFetchAttachmentBlob, + createObjectUrl: (file) => URL.createObjectURL(file), + }); + const revokeRecalled = () => { + for (const image of recalled.images) { + URL.revokeObjectURL(image.previewUrl); + } + }; + // Signed URLs resolve asynchronously, so an edit clicked early — or one that + // hits a network blip — finds nothing to fetch. Leave the message queued: + // it and its pictures are still intact, and the edit can be retried. + const missingError = formatMissingAttachmentsError(recalled.missing); + if (missingError !== null) { + revokeRecalled(); + setThreadError(threadId, missingError); + return; + } const result = await removeQueuedThreadMessage({ environmentId, - input: { threadId: activeThread.id, messageId }, + input: { threadId, messageId }, }); if (result._tag === "Failure") { + revokeRecalled(); if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); setThreadError( - activeThread.id, + threadId, error instanceof Error ? error.message : "Failed to remove the queued message for editing.", @@ -5856,7 +5912,18 @@ function ChatViewContent(props: ChatViewProps) { } return; } - composerRef.current?.recallQueuedMessage(queuedMessage.text); + // Both text and pictures go to the draft target captured before the awaits, + // never the composer's current one: switching threads mid-fetch must not + // split the message across two drafts, or drop it if this one unmounted. + setComposerDraftPrompt(draftTarget, queuedMessage.text); + if (recalled.images.length > 0) { + addComposerDraftImages(draftTarget, [...recalled.images]); + } + // The handle only adds composer-local polish — input history and cursor — + // so it is worth calling solely while its composer is still this thread's. + if (routeThreadKeyRef.current === draftTargetKey) { + composerRef.current?.recallQueuedMessage(queuedMessage.text); + } }; const onRespondToApproval = useCallback( diff --git a/apps/web/src/components/chat/queuedAttachmentRecall.test.ts b/apps/web/src/components/chat/queuedAttachmentRecall.test.ts new file mode 100644 index 000000000000..81f646f2c9db --- /dev/null +++ b/apps/web/src/components/chat/queuedAttachmentRecall.test.ts @@ -0,0 +1,155 @@ +import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + describeQueuedAttachmentCapacity, + formatMissingAttachmentsError, + recallQueuedAttachments, + type RecallableQueuedAttachment, +} from "./queuedAttachmentRecall"; + +const attachment = ( + overrides: Partial = {}, +): RecallableQueuedAttachment => ({ + id: "attachment-1", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 4, + ...overrides, +}); + +const deps = (overrides: Partial[1]> = {}) => ({ + urlById: new Map([["attachment-1", "https://assets.test/attachment-1"]]), + fetchBlob: async () => new Blob(["png!"], { type: "image/png" }), + createObjectUrl: (file: File) => `blob:${file.name}`, + ...overrides, +}); + +describe("recallQueuedAttachments", () => { + it("rebuilds a composer image with the bytes fetched back", async () => { + const result = await recallQueuedAttachments([attachment()], deps()); + + expect(result.missing).toEqual([]); + expect(result.images).toHaveLength(1); + const image = result.images[0]!; + expect(image.type).toBe("image"); + expect(image.name).toBe("screenshot.png"); + expect(image.mimeType).toBe("image/png"); + expect(image.sizeBytes).toBe(4); + expect(image.previewUrl).toBe("blob:screenshot.png"); + expect(image.file).toBeInstanceOf(File); + expect(await image.file.text()).toBe("png!"); + }); + + it("gives the recalled draft a fresh id so it outlives the removed queue entry", async () => { + const result = await recallQueuedAttachments([attachment()], deps()); + + expect(result.images[0]!.id).not.toBe("attachment-1"); + }); + + it("fetches each attachment from its own signed url", async () => { + const requested: string[] = []; + const result = await recallQueuedAttachments( + [attachment(), attachment({ id: "attachment-2", name: "diagram.png" })], + deps({ + urlById: new Map([ + ["attachment-1", "https://assets.test/one"], + ["attachment-2", "https://assets.test/two"], + ]), + fetchBlob: async (url: string) => { + requested.push(url); + return new Blob(["x"], { type: "image/png" }); + }, + }), + ); + + expect(requested).toEqual(["https://assets.test/one", "https://assets.test/two"]); + expect(result.images.map((image) => image.name)).toEqual(["screenshot.png", "diagram.png"]); + }); + + it("reports an attachment whose url has not resolved instead of dropping it silently", async () => { + const result = await recallQueuedAttachments([attachment()], deps({ urlById: new Map() })); + + expect(result.images).toEqual([]); + expect(result.missing).toEqual(["screenshot.png"]); + }); + + it("keeps the readable attachments when one fetch fails", async () => { + const result = await recallQueuedAttachments( + [attachment(), attachment({ id: "attachment-2", name: "broken.png" })], + deps({ + urlById: new Map([ + ["attachment-1", "https://assets.test/one"], + ["attachment-2", "https://assets.test/two"], + ]), + fetchBlob: async (url: string) => { + if (url.endsWith("two")) throw new Error("gone"); + return new Blob(["x"], { type: "image/png" }); + }, + }), + ); + + expect(result.images.map((image) => image.name)).toEqual(["screenshot.png"]); + expect(result.missing).toEqual(["broken.png"]); + }); + + it("falls back to the blob's own type when the queued mime type is empty", async () => { + const result = await recallQueuedAttachments( + [attachment({ mimeType: "" })], + deps({ fetchBlob: async () => new Blob(["x"], { type: "image/webp" }) }), + ); + + expect(result.images[0]!.mimeType).toBe("image/webp"); + }); + + it("returns nothing for a queued message with no attachments", async () => { + const result = await recallQueuedAttachments([], deps()); + + expect(result).toEqual({ images: [], missing: [] }); + }); +}); + +describe("formatMissingAttachmentsError", () => { + it("stays silent when everything was restored", () => { + expect(formatMissingAttachmentsError([])).toBeNull(); + }); + + it("names the single attachment that was left behind", () => { + expect(formatMissingAttachmentsError(["screenshot.png"])).toBe( + "'screenshot.png' could not be loaded, so the message is still queued. Try again — if it keeps failing, the image is no longer on the server and the message has to be sent or replaced as it is.", + ); + }); + + it("counts them once several were left behind", () => { + expect(formatMissingAttachmentsError(["a.png", "b.png"])).toBe( + "2 attachments could not be loaded, so the message is still queued. Try again — if it keeps failing, the image is no longer on the server and the message has to be sent or replaced as it is.", + ); + }); +}); + +describe("describeQueuedAttachmentCapacity", () => { + it("allows an edit that fits in the composer", () => { + expect(describeQueuedAttachmentCapacity(3, 2)).toBeNull(); + }); + + it("allows an edit that exactly fills the remaining room", () => { + expect(describeQueuedAttachmentCapacity(3, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 3)).toBeNull(); + }); + + it("refuses an edit that would overflow, rather than restoring only some pictures", () => { + const message = describeQueuedAttachmentCapacity(3, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 2); + + expect(message).toContain(`${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-image limit`); + expect(message).toContain("Remove some images from the composer first."); + }); + + it("says image, singular, for one attachment", () => { + expect(describeQueuedAttachmentCapacity(1, PROVIDER_SEND_TURN_MAX_ATTACHMENTS)).toContain( + "bring back 1 image,", + ); + }); + + it("never blocks a message with no attachments", () => { + expect(describeQueuedAttachmentCapacity(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/queuedAttachmentRecall.ts b/apps/web/src/components/chat/queuedAttachmentRecall.ts new file mode 100644 index 000000000000..e994c6091087 --- /dev/null +++ b/apps/web/src/components/chat/queuedAttachmentRecall.ts @@ -0,0 +1,114 @@ +import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; + +import type { ComposerImageAttachment } from "../../composerDraftStore"; +import { randomUUID } from "../../lib/utils"; + +/** + * Rebuilding composer attachments when a queued message is edited. + * + * A queued message's attachments name bytes the server holds — `{id, name, + * mimeType, sizeBytes}` and nothing more. The composer needs the bytes + * themselves (`File` + an object URL), so recalling a queued message for + * editing has to fetch each attachment back through its signed asset URL. + * + * Callers must do this *before* removing the queued message. The removal is + * what makes the edit destructive: once the queued entry is gone, its + * attachment files are pruned server-side and a failed fetch has no second + * chance — so a caller that cannot restore everything must leave the message + * queued rather than remove it. + */ + +export interface RecallableQueuedAttachment { + readonly id: string; + readonly name: string; + readonly mimeType: string; + readonly sizeBytes: number; +} + +export interface QueuedAttachmentRecallResult { + readonly images: ReadonlyArray; + /** Attachments whose bytes could not be fetched back, by display name. */ + readonly missing: ReadonlyArray; +} + +export interface QueuedAttachmentRecallDeps { + /** Signed asset URL for an attachment id, when one has resolved yet. */ + readonly urlById: ReadonlyMap; + readonly fetchBlob: (url: string) => Promise; + readonly createObjectUrl: (file: File) => string; +} + +export const defaultFetchAttachmentBlob = async (url: string): Promise => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Attachment request failed with status ${response.status}.`); + } + return await response.blob(); +}; + +/** + * Fetches every queued attachment back into a composer-ready image. Failures + * are collected rather than thrown so the caller sees the whole picture at + * once: because removing the queued message prunes its files, a caller that + * cannot restore everything must abandon the edit rather than restore part. + */ +export const recallQueuedAttachments = async ( + attachments: ReadonlyArray, + deps: QueuedAttachmentRecallDeps, +): Promise => { + const images: ComposerImageAttachment[] = []; + const missing: string[] = []; + + for (const attachment of attachments) { + const url = deps.urlById.get(attachment.id); + if (!url) { + missing.push(attachment.name); + continue; + } + try { + const blob = await deps.fetchBlob(url); + // A fresh id keeps the recalled draft independent of the queued entry + // that is about to be removed, matching how pasted images are staged. + const file = new File([blob], attachment.name, { + type: attachment.mimeType || blob.type, + }); + images.push({ + type: "image", + id: randomUUID(), + name: attachment.name, + mimeType: file.type, + sizeBytes: file.size, + previewUrl: deps.createObjectUrl(file), + file, + }); + } catch { + missing.push(attachment.name); + } + } + + return { images, missing }; +}; + +export const formatMissingAttachmentsError = (missing: ReadonlyArray): string | null => { + if (missing.length === 0) return null; + return missing.length === 1 + ? `'${missing[0]}' could not be loaded, so the message is still queued. Try again — if it keeps failing, the image is no longer on the server and the message has to be sent or replaced as it is.` + : `${missing.length} attachments could not be loaded, so the message is still queued. Try again — if it keeps failing, the image is no longer on the server and the message has to be sent or replaced as it is.`; +}; + +/** + * Refuses the edit up front when the composer has no room for the queued + * message's pictures. Restoring only some of them would drop the rest for good, + * since removing the queued message deletes its attachment files server-side. + */ +export const describeQueuedAttachmentCapacity = ( + queuedCount: number, + draftImageCount: number, +): string | null => { + if (queuedCount === 0) return null; + const capacity = Math.max(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - draftImageCount); + if (queuedCount <= capacity) return null; + return `Editing this message would bring back ${queuedCount} image${ + queuedCount === 1 ? "" : "s" + }, past the ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-image limit. Remove some images from the composer first.`; +};