# Files changed · PR \#23832 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23832/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(web\): pasted images show their upload tile instantly](https://git.cafe/pingdotgg/t3code/pull/23832.md)

Version: 19

Head commit: 3b5b33831afbfd8ae4eb875f097dc26bac4743bc

Base commit: 23bd753806eb9db5a315fbf9ac6563d355f20b3a

Comparison base: 23bd753806eb9db5a315fbf9ac6563d355f20b3a

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/components/chat/ChatComposer.tsx

Status: modified; +12; −7

```diff
@@ -290,6 +290,10 @@
 import { useComposerStash } from "./useComposerStash";
 import { useComposerContextImport } from "./useComposerContextImport";
 import { useComposerAttachmentIntake } from "./useComposerAttachmentIntake";
+import {
+  countPreparingComposerImages,
+  usePreparingComposerImages,
+} from "./preparingComposerImages";
 import { ComposerControlsBar } from "./ComposerControlsBar";
 import { useComposerCommandKey } from "./useComposerCommandKey";
 
@@ -746,6 +750,7 @@
     uncommittedSnapShotIds,
     standaloneComposerImages,
   } = useComposerImageGroups(attachmentDraftTarget, composerImages, composerPreviewAnnotations);
+  const preparingComposerImages = usePreparingComposerImages(attachmentTargetKey);
   const nonPersistedComposerImageIds = attachmentDraft.nonPersistedImageIds;
   const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
   const openPrLink = useOpenPrLink(routeThreadRef);
@@ -1124,7 +1129,6 @@
   const composerScrollCollapseEligibleRef = useRef(false);
   const windowRefocusInFlightRef = useRef(false);
   const composerScrollGestureRef = useRef(createComposerScrollGestureState());
-  const pendingImageCompressionsRef = useRef<Map<string, number>>(new Map());
   const pendingThreadImportsRef = useRef<Map<string, number>>(new Map());
   const isRevertingCheckpointRef = useRef(isRevertingCheckpoint);
   useLayoutEffect(() => {
@@ -1434,7 +1438,6 @@
     composerContextRecords,
     composerImages,
     composerFilesRef,
-    pendingImageCompressionsRef,
     pendingThreadImportsRef,
     addComposerFilesToDraft,
   });
@@ -2123,7 +2126,7 @@
     // A send while a pasted image is still compressing would strand that image:
     // the turn snapshot wouldn't include it, and it would surface in the *next*
     // draft instead.
+    if (activeThreadId && countPreparingComposerImages(attachmentTargetKey) > 0) {
-    if (activeThreadId && (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0) {
       event?.preventDefault();
       toastManager.add({
         type: "info",
@@ -2361,7 +2364,8 @@
     environmentUnavailable !== null ||
     composerSubmissionError !== null ||
     providerInputSubmissionError !== null ||
+    hasImageAttachmentAttention ||
-    hasImageAttachmentAttention;
+    preparingComposerImages.length > 0;
   const isComposerResting = shouldUseRestingComposerLayout({
     isExistingThread: routeKind === "server" && activeThreadId !== null,
     isMobileViewport,
@@ -2734,7 +2738,6 @@
       composerFilesRef,
       composerEditorRef,
       isRevertingCheckpointRef,
-      pendingImageCompressionsRef,
       setThreadError,
       countReservedAttachments,
       addComposerImage,
@@ -3103,7 +3106,7 @@
       },
       addDroppedFolders: addComposerFoldersStable,
       hasPendingAttachments: () =>
-        (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0 ||
+        countPreparingComposerImages(attachmentTargetKey) > 0 ||
         pendingDraftWork.has(attachmentTargetKey),
       insertTextAtEnd: insertComposerTextAtEndStable,
       pasteTextAtEnd: (text: string, options) => {
@@ -3367,10 +3370,12 @@
     !isComposerCollapsedMobile &&
     (uncommittedSnapShotIds.length > 0 ||
       composerVideos.length > 0 ||
-      expandedComposerImages.length > 0) ? (
+      expandedComposerImages.length > 0 ||
+      preparingComposerImages.length > 0) ? (
       <ComposerAttachmentMediaRow
         images={composerImages}
         expandedImages={expandedComposerImages}
+        preparingImages={preparingComposerImages}
         videos={composerVideos}
         pendingSnapShotAnimations={pendingSnapShotAnimations}
         pendingSnapShotIds={pendingSnapShotIds}
```

## apps/web/src/components/chat/ComposerAttachmentStrip.tsx

Status: modified; +33; −21

```diff
@@ -34,6 +34,7 @@
 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
 import { ComposerImageThumbnail } from "./ComposerImageThumbnail";
 import { composerFloatingLayerProps } from "./composerEventScope";
+import type { PreparingComposerImage } from "./preparingComposerImages";
 import {
   attachVideoThumbnail,
   buildExpandedImagePreview,
@@ -109,6 +110,7 @@
 export function ComposerAttachmentMediaRow({
   images,
   expandedImages,
+  preparingImages,
   videos,
   pendingSnapShotAnimations,
   pendingSnapShotIds,
@@ -127,6 +129,7 @@
 }: {
   readonly images: ReadonlyArray<ComposerImageAttachment>;
   readonly expandedImages: ReadonlyArray<ComposerImageAttachment>;
+  readonly preparingImages: ReadonlyArray<PreparingComposerImage>;
   readonly videos: ReadonlyArray<ComposerFileAttachment>;
   readonly pendingSnapShotAnimations: ReadonlyArray<PendingSnapShotAnimation>;
   readonly pendingSnapShotIds: ReadonlyArray<string>;
@@ -156,14 +159,20 @@
           : "flex-wrap",
       )}
     >
+      {/* A preparing image renders through the same tile, keyed by the id its compressed image
+          takes, so the thumbnail element survives the swap. */}
+      {[
-      {expandedImages
+        ...expandedImages.map((image) => ({ image, ready: image })),
+        ...preparingImages.map((image) => ({ image, ready: null })),
+      ]
+        .map(({ image, ready }) => {
+          const upload =
+            ready && supportsAttachmentUploads ? uploadsByImageId[image.id] : undefined;
+          const source = ready?.source;
-        .map((image) => {
-          const upload = supportsAttachmentUploads ? uploadsByImageId[image.id] : undefined;
           const snapShotAnimationPending =
-            image.source?.kind === "snap-shot" && pendingSnapShotIdSet.has(image.id);
+            source?.kind === "snap-shot" && pendingSnapShotIdSet.has(image.id);
           const snapShotArrival =
-            image.source?.kind === "snap-shot" &&
+            source?.kind === "snap-shot" && shouldAnimateSnapShotArrival(source.capturedAt);
-            shouldAnimateSnapShotArrival(image.source.capturedAt);
           return (
             <SnapShotAttachmentFrame
               key={image.id}
@@ -173,20 +182,21 @@
               arrival={snapShotArrival}
               animateArrival={snapShotAnimations && !snapShotAnimationPending && snapShotArrival}
               animationId={snapShotAnimationPending ? image.id : undefined}
-              animationSource={snapShotAnimationPending ? image.source : undefined}
+              animationSource={snapShotAnimationPending ? source : undefined}
               className={cn(
                 "group/attachment shrink-0 snap-start bg-background",
-                image.source?.kind === "snap-shot"
+                source?.kind === "snap-shot"
                   ? SNAP_SHOT_ATTACHMENT_FRAME_CLASS
                   : "relative h-16 w-16 overflow-hidden rounded-lg border border-border/80",
                 snapShotAnimationPending && "invisible",
               )}
             >
+              {!ready || ready.previewUrl ? (
-              {image.previewUrl ? (
                 <button
                   type="button"
-                  className="h-full w-full cursor-zoom-in"
+                  className={cn("h-full w-full", ready && "cursor-zoom-in")}
                   aria-label={`Preview ${image.name}`}
+                  disabled={!ready}
                   onClick={() => {
                     const preview = buildExpandedImagePreview(images, image.id);
                     if (!preview) return;
@@ -198,11 +208,11 @@
                     alt={image.name}
                     className="h-full w-full object-cover"
                     fallback={
+                      ready && isHeicImageFile({ name: ready.name, type: ready.mimeType }) ? (
-                      isHeicImageFile({ name: image.name, type: image.mimeType }) ? (
                         <AttachmentImage
-                          name={image.name}
-                          mimeType={image.mimeType}
-                          src={image.previewUrl}
+                          name={ready.name}
+                          mimeType={ready.mimeType}
+                          src={ready.previewUrl}
                           alt={image.name}
                           className="h-full w-full object-cover"
                         />
@@ -219,9 +229,9 @@
                   {image.name}
                 </div>
               )}
-              {image.source?.kind === "snap-shot" ? (
+              {source?.kind === "snap-shot" ? (
                 <SnapShotAttachmentDetails
+                  source={source}
-                  source={image.source}
                   className={cn(
                     upload?.status === "uploading" && "bottom-4",
                     upload?.status === "failed" && "bottom-8",
@@ -246,12 +256,14 @@
                   </TooltipPopup>
                 </Tooltip>
               )}
-              {upload?.status === "uploading" && (
+              {(ready ? upload?.status === "uploading" : supportsAttachmentUploads) && (
                 <span className="pointer-events-none absolute inset-x-0 bottom-0 bg-background/85 px-1 text-center text-3xs text-foreground">
+                  {formatAttachmentUploadProgress(
+                    upload?.status === "uploading" ? upload.progress : 0,
+                  )}
-                  {formatAttachmentUploadProgress(upload.progress)}
                 </span>
               )}
-              {upload?.status === "failed" && (
+              {ready && upload?.status === "failed" && (
                 <Tooltip>
                   <TooltipTrigger
                     render={
@@ -262,7 +274,7 @@
                         onClick={() =>
                           retryAttachmentUpload({
                             environmentId,
-                            image,
+                            image: ready,
                             draftTarget: attachmentDraftTarget,
                           })
                         }
@@ -279,7 +291,7 @@
               <span
                 className={cn(
                   "absolute right-1 top-1 flex",
+                  source?.kind === "snap-shot" &&
-                  image.source?.kind === "snap-shot" &&
                     "opacity-0 transition-opacity pointer-coarse:opacity-100 group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100",
                 )}
               >
```

## apps/web/src/components/chat/ComposerImageThumbnail.tsx

Status: modified; +5; −2

```diff
@@ -1,6 +1,9 @@
 import { memo, useEffect, useState, type ReactNode } from "react";
 
+import {
+  createComposerImageThumbnail,
+  readComposerImageThumbnail,
+} from "../../lib/imageCompression";
-import { createComposerImageThumbnail } from "../../lib/imageCompression";
 
 /** Keep full-resolution image decoding out of composer rerenders. */
 export const ComposerImageThumbnail = memo(function ComposerImageThumbnail({
@@ -24,6 +27,6 @@
       active = false;
     };
   }, [file]);
+  const src = preview?.file === file ? preview.src : (readComposerImageThumbnail(file) ?? null);
-  const src = preview?.file === file ? preview.src : null;
   return src ? <img src={src} alt={alt} className={className} /> : fallback;
 });
```

## apps/web/src/components/chat/preparingComposerImages.ts

Status: added; +75; −0

```diff
@@ -1,0 +1,75 @@
+import { create } from "zustand";
+
+export interface PreparingComposerImage {
+  readonly id: string;
+  readonly name: string;
+  /** The uncompressed source, thumbnailed so the placeholder shows the image itself. */
+  readonly file: File;
+}
+
+const NO_PREPARING_IMAGES: ReadonlyArray<PreparingComposerImage> = [];
+
+/**
+ * Images accepted into a draft but still being compressed, keyed by attachment target. Large
+ * screenshots take a noticeable moment to re-encode, so the strip shows these right away and
+ * they hold their attachment slots until the real image joins the draft.
+ */
+const usePreparingComposerImageStore = create<{
+  readonly byTargetKey: Readonly<Record<string, ReadonlyArray<PreparingComposerImage>>>;
+}>(() => ({ byTargetKey: {} }));
+
+export function usePreparingComposerImages(targetKey: string) {
+  return usePreparingComposerImageStore(
+    (state) => state.byTargetKey[targetKey] ?? NO_PREPARING_IMAGES,
+  );
+}
+
+export function countPreparingComposerImages(targetKey: string): number {
+  return usePreparingComposerImageStore.getState().byTargetKey[targetKey]?.length ?? 0;
+}
+
+export function addPreparingComposerImages(
+  targetKey: string,
+  images: ReadonlyArray<PreparingComposerImage>,
+): void {
+  usePreparingComposerImageStore.setState((state) => ({
+    byTargetKey: {
+      ...state.byTargetKey,
+      [targetKey]: [...(state.byTargetKey[targetKey] ?? NO_PREPARING_IMAGES), ...images],
+    },
+  }));
+}
+
+export function isPreparingComposerImage(targetKey: string, imageId: string): boolean {
+  return (
+    usePreparingComposerImageStore
+      .getState()
+      .byTargetKey[targetKey]?.some((image) => image.id === imageId) ?? false
+  );
+}
+
+/** Returns the ids that were still preparing; any other id was cancelled from the strip. */
+export function removePreparingComposerImages(
+  targetKey: string,
+  imageIds: ReadonlySet<string>,
+): ReadonlySet<string> {
+  const current =
+    usePreparingComposerImageStore.getState().byTargetKey[targetKey] ?? NO_PREPARING_IMAGES;
+  const removed = new Set(
+    current.filter((image) => imageIds.has(image.id)).map((image) => image.id),
+  );
+  if (removed.size === 0) return removed;
+  usePreparingComposerImageStore.setState((state) => {
+    const remaining = (state.byTargetKey[targetKey] ?? NO_PREPARING_IMAGES).filter(
+      (image) => !removed.has(image.id),
+    );
+    const byTargetKey = { ...state.byTargetKey };
+    if (remaining.length > 0) {
+      byTargetKey[targetKey] = remaining;
+    } else {
+      delete byTargetKey[targetKey];
+    }
+    return { byTargetKey };
+  });
+  return removed;
+}
```

## apps/web/src/components/chat/useComposerAttachmentIntake.ts

Status: modified; +97; −73

```diff
@@ -38,11 +38,19 @@
   pastedTextDisposition,
   wouldTextPasteExceedLimit,
 } from "@t3tools/client-runtime/text-paste";
+import {
+  inheritComposerImageThumbnail,
-import { prepareImageForAttachment } from "../../lib/imageCompression";
+  prepareImageForAttachment,
+} from "../../lib/imageCompression";
 import { requestConfirmDialog } from "~/confirmDialog";
 import { toastManager } from "../ui/toast";
 import { type RefObject, useRef } from "react";
 import { videoMimeType } from "../../types";
+import {
+  addPreparingComposerImages,
+  isPreparingComposerImage,
+  removePreparingComposerImages,
+} from "./preparingComposerImages";
 
 /**
  * Takes files into the draft: validates and reserves slots, compresses images, inserts
@@ -65,7 +73,6 @@
   readonly composerFilesRef: ChatComposerProps["composerFilesRef"];
   readonly composerEditorRef: RefObject<ComposerPromptEditorHandle | null>;
   readonly isRevertingCheckpointRef: RefObject<boolean>;
-  readonly pendingImageCompressionsRef: RefObject<Map<string, number>>;
   readonly setThreadError: ChatComposerProps["setThreadError"];
   readonly countReservedAttachments: () => number;
   readonly addComposerImage: (image: ComposerImageAttachment) => string[];
@@ -94,7 +101,6 @@
     composerFilesRef,
     composerEditorRef,
     isRevertingCheckpointRef,
-    pendingImageCompressionsRef,
     setThreadError,
     countReservedAttachments,
     addComposerImage,
@@ -142,9 +148,8 @@
         stripInlineContextReferences(promptRef.current).trim().length > 0);
 
     // Validation happens synchronously so concurrent pastes see each other:
+    // accepted files reserve their attachment slots (as preparing images)
-    // accepted files reserve their attachment slots (via the pending counter)
     // before the first await, keeping the total under the limit.
-    const pendingCount = pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0;
     let reservedCount = countReservedAttachments();
     // A pick that matches a needs-reattach marker replaces it in the draft, so
     // it must not consume a slot; a draft full of markers would otherwise hit
@@ -242,81 +247,100 @@
     }
     if (acceptedImages.length === 0) return insertedAny;
 
+    // The draft image reuses its placeholder's id and thumbnail so the strip tile stays exactly
+    // as it is when the compressed file replaces it.
+    const preparingImages = acceptedImages.map((file) => ({
+      id: randomUUID(),
+      name: file.name || "image",
+      file,
+    }));
-    pendingImageCompressionsRef.current.set(
-      attachmentTargetKey,
-      pendingCount + acceptedImages.length,
-    );
+    addPreparingComposerImages(attachmentTargetKey, preparingImages);
     if (questionAttachmentTarget)
       changeQuestionAttachmentPreparation(questionAttachmentTarget, acceptedImages.length);
+    const compressed: { id: string; file: File }[] = [];
+    // Keyed by image so a failure whose tile was removed (even mid-compression) is not reported.
+    const compressionErrors = new Map<string, string>();
+    for (const { id, file } of preparingImages) {
+      // Removed from the strip while an earlier image was compressing.
+      if (!isPreparingComposerImage(attachmentTargetKey, id)) continue;
+      // A rejection must still reach the cleanup below: a leaked
+      // placeholder would block sending from this draft for good.
+      const result = await prepareImageForAttachment(
+        file,
+        PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
+      ).catch(() => ({ ok: false, reason: "unreadable" }) as const);
+      if (!result.ok) {
+        compressionErrors.set(
+          id,
+          result.reason === "unreadable"
-    const compressAndStoreImages = async (): Promise<boolean> => {
-      const nextImages: ComposerImageAttachment[] = [];
-      let compressionError: string | null = null;
+            ? `'${file.name}' could not be read as an image.`
+            : `'${file.name}' is too large to attach, even after compression.`,
-      for (const file of acceptedImages) {
-        const compressed = await prepareImageForAttachment(
-          file,
-          PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
         );
-        if (!compressed.ok) {
-          compressionError =
-            compressed.reason === "unreadable"
-              ? `'${file.name}' could not be read as an image.`
-              : `'${file.name}' is too large to attach, even after compression.`;
-          continue;
-        }
-        const attachmentFile = compressed.file;
-        const previewUrl = URL.createObjectURL(attachmentFile);
-        nextImages.push({
-          type: "image",
-          id: randomUUID(),
-          name: attachmentFile.name || "image",
-          mimeType: attachmentFile.type,
-          sizeBytes: attachmentFile.size,
-          previewUrl,
-          file: attachmentFile,
-        });
-      }
-      if (
-        questionAttachmentTarget &&
-        !useQuestionAttachmentPreparation.getState().counts[questionAttachmentTarget]
-      ) {
-        for (const image of nextImages) URL.revokeObjectURL(image.previewUrl);
-        return false;
-      }
-      const storedImageIds = new Set(
-        nextImages.length === 1 && nextImages[0]
-          ? addComposerImage(nextImages[0])
-          : nextImages.length > 1
-            ? addComposerImagesToDraft(nextImages)
-            : [],
-      );
-      const storedImages = nextImages.filter((image) => storedImageIds.has(image.id));
-      if (storedImages.length > 0 && imageAttachmentsGetChips) {
-        insertedAny =
-          insertAttachmentReferences(storedImages.map(imageContextReference)) || insertedAny;
+        continue;
       }
+      inheritComposerImageThumbnail(file, result.file);
+      compressed.push({ id, file: result.file });
+    }
+    const questionDiscarded =
+      questionAttachmentTarget !== null &&
+      !useQuestionAttachmentPreparation.getState().counts[questionAttachmentTarget];
+    // Same tick as the draft insert below, so the strip swaps placeholders for
+    // real tiles in one render. A placeholder removed meanwhile stays removed.
+    const stillPreparingIds = removePreparingComposerImages(
+      attachmentTargetKey,
+      new Set(preparingImages.map((image) => image.id)),
+    );
+    if (questionAttachmentTarget)
+      changeQuestionAttachmentPreparation(questionAttachmentTarget, -stillPreparingIds.size);
+    if (questionDiscarded) return false;
+    const nextImages: ComposerImageAttachment[] = compressed
+      .filter(({ id }) => stillPreparingIds.has(id))
+      .map(({ id, file }) => ({
+        type: "image",
+        id,
+        name: file.name || "image",
+        mimeType: file.type,
+        sizeBytes: file.size,
+        previewUrl: URL.createObjectURL(file),
+        file,
+      }));
+    const storedImageIds = new Set(
+      nextImages.length === 1 && nextImages[0]
+        ? addComposerImage(nextImages[0])
+        : nextImages.length > 1
+          ? addComposerImagesToDraft(nextImages)
+          : [],
+    );
+    const storedImages = nextImages.filter((image) => storedImageIds.has(image.id));
+    // The draft may be gone by now; revoking a URL the store already revoked is harmless.
+    for (const image of nextImages) {
+      if (!storedImageIds.has(image.id)) URL.revokeObjectURL(image.previewUrl);
+    }
-      // Only failures are reported here. Success must not pass `null`: by
-      // now other work (a failed send, an overlapping paste) may have set a
-      // thread error this call knows nothing about, and clearing it would
-      // swallow that message.
+    if (storedImages.length > 0 && imageAttachmentsGetChips) {
-      if (compressionError !== null) {
-        setThreadError(threadId, compressionError);
-      }
-      return insertedAny;
-    };
-    return await compressAndStoreImages().finally(() => {
-      if (questionAttachmentTarget)
-        changeQuestionAttachmentPreparation(questionAttachmentTarget, -acceptedImages.length);
-      const remaining =
-        (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) - acceptedImages.length;
-      if (remaining > 0) {
-        pendingImageCompressionsRef.current.set(attachmentTargetKey, remaining);
-      } else {
-        pendingImageCompressionsRef.current.delete(attachmentTargetKey);
-      }
-    });
+      insertedAny =
+        insertAttachmentReferences(storedImages.map(imageContextReference)) || insertedAny;
+    }
+    // Only failures are reported here. Success must not pass `null`: by
+    // now other work (a failed send, an overlapping paste) may have set a
+    // thread error this call knows nothing about, and clearing it would
+    // swallow that message.
+    const compressionError = [...compressionErrors]
+      .filter(([id]) => stillPreparingIds.has(id))
+      .map(([, message]) => message)
+      .at(-1);
+    if (compressionError !== undefined) {
+      setThreadError(threadId, compressionError);
+    }
+    return insertedAny;
   };
 
   const removeComposerImage = (imageId: string) => {
+    if (removePreparingComposerImages(attachmentTargetKey, new Set([imageId])).size > 0) {
+      // Its compression finishes in the background and is dropped.
+      if (questionAttachmentTarget)
+        changeQuestionAttachmentPreparation(questionAttachmentTarget, -1);
+      return;
+    }
     const image = composerImagesRef.current.find((candidate) => candidate.id === imageId);
     const referenced = collectInlineContextIds(promptRef.current).includes(
       image ? imageContextReference(image).contextId : "",
```

## apps/web/src/components/chat/useComposerContextImport.ts

Status: modified; +2; −3

```diff
@@ -47,6 +47,7 @@
 import { useAtomCommand } from "../../state/use-atom-command";
 import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
 import { useStableCallback } from "../../hooks/useStableCallback";
+import { countPreparingComposerImages } from "./preparingComposerImages";
 
 async function downloadAttachmentBlob(url: string): Promise<Blob | null> {
   try {
@@ -74,7 +75,6 @@
   readonly composerContextRecords: ComposerDraftContextRecords;
   readonly composerImages: ReadonlyArray<ComposerImageAttachment>;
   readonly composerFilesRef: ChatComposerProps["composerFilesRef"];
-  readonly pendingImageCompressionsRef: RefObject<Map<string, number>>;
   readonly pendingThreadImportsRef: RefObject<Map<string, number>>;
   readonly addComposerFilesToDraft: (files: ComposerFileAttachment[]) => string[];
 }) {
@@ -91,7 +91,6 @@
     composerContextRecords,
     composerImages,
     composerFilesRef,
-    pendingImageCompressionsRef,
     pendingThreadImportsRef,
     addComposerFilesToDraft,
   } = input;
@@ -128,7 +127,7 @@
     return (
       (draft?.images.length ?? 0) +
       (draft?.files.length ?? 0) +
-      (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) +
+      countPreparingComposerImages(attachmentTargetKey) +
       (pendingThreadImportsRef.current.get(attachmentTargetKey) ?? 0) +
       countQuestionAttachments(otherQuestionKeys)
     );
```

## apps/web/src/lib/imageCompression.test.ts

Status: modified; +19; −0

```diff
@@ -3,9 +3,12 @@
 import {
   compressImageForStash,
   compressImageToByteLimit,
+  createComposerImageThumbnail,
+  inheritComposerImageThumbnail,
   MAX_COMPRESSIBLE_SOURCE_BYTES,
   MAX_STASH_IMAGE_DATA_URL_CHARS,
   prepareImageForAttachment,
+  readComposerImageThumbnail,
 } from "./imageCompression";
 
 import type { SnapShotSource } from "@t3tools/contracts";
@@ -243,6 +246,22 @@
   });
 });
 
+describe("composer image thumbnails", () => {
+  it("gives the compressed file its source's thumbnail without decoding it again", async () => {
+    stubCanvasPipeline(() => 3);
+    const source = makeFile(3);
+    const compressed = makeFile(2, "image/webp");
+    const sourceThumbnail = await createComposerImageThumbnail(source);
+    expect(sourceThumbnail).toMatch(/^data:image\/png;base64,/);
+
+    inheritComposerImageThumbnail(source, compressed);
+
+    expect(readComposerImageThumbnail(compressed)).toBe(sourceThumbnail);
+    expect(await createComposerImageThumbnail(compressed)).toBe(sourceThumbnail);
+    expect(createImageBitmap).toHaveBeenCalledTimes(1);
+  });
+});
+
 describe("HEIC attachment preparation", () => {
   it("converts a HEIC photo with a missing MIME type into a named JPEG", async () => {
     const original = makeHeicFile({
```

## apps/web/src/lib/imageCompression.ts

Status: modified; +64; −32

```diff
@@ -205,43 +205,75 @@
 }
 
 const composerThumbnails = new WeakMap<File, Promise<string | null>>();
+const settledComposerThumbnails = new WeakMap<File, string | null>();
 
-export function createComposerImageThumbnail(file: File): Promise<string | null> {
-  const cached = composerThumbnails.get(file);
-  if (cached) return cached;
-  const thumbnail = (async () => {
-    if (!canRecompress()) return null;
-    let bitmap: ImageBitmap | undefined;
-    try {
-      bitmap = await createImageBitmap(file);
-      const side = Math.min(bitmap.width, bitmap.height);
-      if (side <= 0) return null;
-      const dimension = Math.min(256, side);
-      const surface = createCanvas(dimension, dimension);
-      if (!surface) return null;
-      surface.context.drawImage(
-        bitmap,
-        (bitmap.width - side) / 2,
-        (bitmap.height - side) / 2,
-        side,
-        side,
-        0,
-        0,
-        dimension,
-        dimension,
-      );
-      const blob = await encodeCanvas(surface.canvas, 1, "image/png");
-      return blob ? await readFileAsDataUrl(blob) : null;
-    } catch {
-      return null;
-    } finally {
-      bitmap?.close();
-    }
-  })();
+function cacheComposerImageThumbnail(
+  file: File,
+  thumbnail: Promise<string | null>,
+): Promise<string | null> {
   composerThumbnails.set(file, thumbnail);
+  void thumbnail.then((src) => settledComposerThumbnails.set(file, src));
   return thumbnail;
 }
 
+/** `undefined` until the thumbnail settles, so a tile can paint a known thumbnail immediately. */
+export function readComposerImageThumbnail(file: File): string | null | undefined {
+  return settledComposerThumbnails.get(file);
+}
+
+export function createComposerImageThumbnail(file: File): Promise<string | null> {
+  return (
+    composerThumbnails.get(file) ??
+    cacheComposerImageThumbnail(file, renderComposerImageThumbnail(file))
+  );
+}
+
+/**
+ * Pasted images are re-encoded after their placeholder already shows the source's thumbnail.
+ * Reusing it keeps the tile from flashing back to its fallback while the new file is decoded.
+ */
+export function inheritComposerImageThumbnail(source: File, target: File): void {
+  const sourceThumbnail = composerThumbnails.get(source);
+  if (source === target || !sourceThumbnail || composerThumbnails.has(target)) return;
+  const settled = settledComposerThumbnails.get(source);
+  if (settled) settledComposerThumbnails.set(target, settled);
+  cacheComposerImageThumbnail(
+    target,
+    sourceThumbnail.then((src) => src ?? renderComposerImageThumbnail(target)),
+  );
+}
+
+async function renderComposerImageThumbnail(file: File): Promise<string | null> {
+  // Placeholders thumbnail the uncompressed source, which can be big enough to OOM the decode.
+  if (!canRecompress() || file.size > MAX_COMPRESSIBLE_SOURCE_BYTES) return null;
+  let bitmap: ImageBitmap | undefined;
+  try {
+    bitmap = await createImageBitmap(file);
+    const side = Math.min(bitmap.width, bitmap.height);
+    if (side <= 0) return null;
+    const dimension = Math.min(256, side);
+    const surface = createCanvas(dimension, dimension);
+    if (!surface) return null;
+    surface.context.drawImage(
+      bitmap,
+      (bitmap.width - side) / 2,
+      (bitmap.height - side) / 2,
+      side,
+      side,
+      0,
+      0,
+      dimension,
+      dimension,
+    );
+    const blob = await encodeCanvas(surface.canvas, 1, "image/png");
+    return blob ? await readFileAsDataUrl(blob) : null;
+  } catch {
+    return null;
+  } finally {
+    bitmap?.close();
+  }
+}
+
 async function encodeWithinBudget(
   bitmap: ImageBitmap,
   maxDimension: number,
```
