History

feat(web): attach PDFs, ZIPs, and other files to a turn (#8236)

Theo Browne committed 2 months ago

Commit bcb855a1 parent 6a9d9f930 files · +3932 −263Browse files

Unified · Split

30 files
  1. apps/web/src/components/ChatView.logic.test.ts +26 −0
  2. apps/web/src/components/ChatView.logic.ts +17 −0
  3. apps/web/src/components/ChatView.tsx +215 −43
  4. apps/web/src/components/LegacySidebar.tsx +10 −2
  5. apps/web/src/components/Sidebar.tsx +1 −0
  6. apps/web/src/components/chat/ChatComposer.tsx +573 −63
  7. apps/web/src/components/chat/ComposerStashMenu.test.tsx +42 −0
  8. apps/web/src/components/chat/ComposerStashMenu.tsx +14 −2
  9. apps/web/src/components/chat/MessagesTimeline.test.tsx +110 −0
  10. apps/web/src/components/chat/MessagesTimeline.tsx +67 −2
  11. apps/web/src/components/chat/composerAttachmentFiles.test.ts +280 −0
  12. apps/web/src/components/chat/composerAttachmentFiles.ts +153 −0
  13. apps/web/src/components/settings/ProjectSettingsPanel.tsx +5 −2
  14. apps/web/src/composerDraftStore.test.ts +483 −0
  15. apps/web/src/composerDraftStore.ts +354 −19
  16. apps/web/src/hooks/useHandleNewThread.ts +14 −1
  17. apps/web/src/lib/attachmentUploadQueue.test.ts +660 −4
  18. apps/web/src/lib/attachmentUploadQueue.ts +309 −89
  19. apps/web/src/lib/attachmentUploadState.test.ts +3 −3
  20. apps/web/src/lib/attachmentUploadState.ts +9 −2
  21. apps/web/src/lib/composerDraftUploads.ts +21 −4
  22. apps/web/src/promptStashStore.test.ts +57 −0
  23. apps/web/src/promptStashStore.ts +7 −5
  24. apps/web/src/state/attachments.ts +2 −12
  25. apps/web/src/types.ts +18 −7
  26. docs/user/composer.md +20 −2
  27. packages/client-runtime/package.json +4 −0
  28. packages/client-runtime/src/state/attachments.test.ts +202 −0
  29. packages/client-runtime/src/state/attachments.ts +236 −0
  30. packages/client-runtime/src/state/runtime.ts +20 −1
apps/web/src/components/ChatView.logic.test.ts+26−0
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
3636 shouldDockDraftHeroForSubmission,
3737 shouldReleaseTimelineAnchorForToolActivity,
3838 shouldShowBranchMismatchBanner,
39+ shouldShowPlanFollowUpPrompt,
3940 shouldWriteThreadErrorToCurrentServerThread,
4041 } from "./ChatView.logic";
4142
@@ -591,6 +592,31 @@ describe("shouldShowBranchMismatchBanner", () => { · 549 unmodified lines
591592 });
592593 });
593594
595+describe("shouldShowPlanFollowUpPrompt", () => {
596+ const base = {
597+ pendingUserInputCount: 0,
598+ interactionMode: "plan" as const,
599+ latestTurnSettled: true,
600+ hasActionableProposedPlan: true,
601+ hasComposerAttachments: false,
602+ };
603+
604+ it("shows plan actions for a settled actionable plan without attachments", () => {
605+ expect(shouldShowPlanFollowUpPrompt(base)).toBe(true);
606+ });
607+
608+ it("hides plan actions while the composer has staged attachments", () => {
609+ expect(shouldShowPlanFollowUpPrompt({ ...base, hasComposerAttachments: true })).toBe(false);
610+ });
611+
612+ it("preserves the existing plan follow-up gates", () => {
613+ expect(shouldShowPlanFollowUpPrompt({ ...base, pendingUserInputCount: 1 })).toBe(false);
614+ expect(shouldShowPlanFollowUpPrompt({ ...base, interactionMode: "default" })).toBe(false);
615+ expect(shouldShowPlanFollowUpPrompt({ ...base, latestTurnSettled: false })).toBe(false);
616+ expect(shouldShowPlanFollowUpPrompt({ ...base, hasActionableProposedPlan: false })).toBe(false);
617+ });
618+});
619+
594620 describe("session branch mismatch dismissal", () => {
595621 it("tracks dismissed keys and treats other keys as active", () => {
596622 expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false);
apps/web/src/components/ChatView.logic.ts+17−0
@@ -4,6 +4,7 @@ import { · 3 unmodified lines
44 ProjectId,
55 type MessageId,
66 type ModelSelection,
7+ type ProviderInteractionMode,
78 type ProviderDriverKind,
89 type ServerProvider,
910 type ScopedProjectRef,
@@ -445,6 +446,22 @@ export function shouldShowBranchMismatchBanner(input: { · 435 unmodified lines
445446 return input.composerHasContent || input.wasShownForCurrentMismatch;
446447 }
447448
449+export function shouldShowPlanFollowUpPrompt(input: {
450+ pendingUserInputCount: number;
451+ interactionMode: ProviderInteractionMode;
452+ latestTurnSettled: boolean;
453+ hasActionableProposedPlan: boolean;
454+ hasComposerAttachments: boolean;
455+}): boolean {
456+ return (
457+ input.pendingUserInputCount === 0 &&
458+ input.interactionMode === "plan" &&
459+ input.latestTurnSettled &&
460+ input.hasActionableProposedPlan &&
461+ !input.hasComposerAttachments
462+ );
463+}
464+
448465 // Session-scoped (module-level so it survives ChatView remounts, e.g. route
449466 // changes). Durable cross-device dismissal is planned as a server-side ack.
450467 const sessionDismissedBranchMismatchKeys = new Set<string>();
apps/web/src/components/ChatView.tsx+215−43
@@ -1,5 +1,6 @@
11 import {
22 type ApprovalRequestId,
3+ type ChatFileAttachment,
34 DEFAULT_MODEL,
45 defaultInstanceIdForDriver,
56 type EnvironmentId,
@@ -17,6 +18,7 @@ import { · 11 unmodified lines
1718 type TurnId,
1819 type KeybindingCommand,
1920 OrchestrationThreadActivity,
21+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
2022 ProviderInteractionMode,
2123 ProviderDriverKind,
2224 RuntimeMode,
@@ -231,6 +233,7 @@ import { · 208 unmodified lines
231233 beginBackgroundDraftSubmissionByRef,
232234 clearBackgroundDraftSubmissionByRef,
233235 composerDraftHasUserContent,
236+ type ComposerFileAttachment,
234237 type ComposerImageAttachment,
235238 type DraftThreadEnvMode,
236239 finalizePromotedDraftThreadByRef,
@@ -257,6 +260,7 @@ import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/ · 20 unmodified lines
257260 import { projectEnvironment } from "../state/projects";
258261 import { useEnvironmentQuery } from "../state/query";
259262 import {
263+ environmentServerConfigsAtom,
260264 primaryServerAvailableEditorsAtom,
261265 primaryServerKeybindingsAtom,
262266 primaryServerSettingsAtom,
@@ -346,6 +350,7 @@ import { · 83 unmodified lines
346350 shouldDockDraftHeroForSubmission,
347351 shouldReleaseTimelineAnchorForToolActivity,
348352 shouldShowBranchMismatchBanner,
353+ shouldShowPlanFollowUpPrompt,
349354 getStartedThreadModelChangeBlockReason,
350355 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
351356 LastInvokedScriptByProjectSchema,
@@ -371,13 +376,19 @@ import { useComposerHandleContext } from "../composerHandleContext"; · 19 unmodified lines
371376 import {
372377 awaitAttachmentUploads,
373378 getUploadedAttachments,
374− releaseAttachmentUploads,
379+ releaseDraftAttachments,
375380 startAttachmentUpload,
376381 } from "../lib/attachmentUploadQueue";
377382 import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
378383 import { RightPanelSheet } from "./RightPanelSheet";
379384 import { previewEnvironment } from "../state/preview";
385+import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
386+import { appAtomRegistry } from "../rpc/atomRegistry";
387+import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles";
388+import { assetEnvironment } from "../state/assets";
389+import { readPreparedConnection } from "../state/session";
380390 import { useAtomCommand } from "../state/use-atom-command";
391+import { useAtomQueryRunner } from "../state/use-atom-query-runner";
381392 import { Button } from "./ui/button";
382393 import {
383394 AlertDialog,
@@ -398,10 +409,10 @@ import { · 14 unmodified lines
398409 resolveServerSelfUpdateCapability,
399410 serverUpdateGuidance,
400411 } from "../versionSkew";
401−import { useAssetUrls } from "../assets/assetUrls";
412+import { resolveAssetUrl, useAssetUrls } from "../assets/assetUrls";
402413
403−const IMAGE_ONLY_BOOTSTRAP_PROMPT =
404− "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
414+const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT =
415+ "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]";
405416 const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
406417 const EMPTY_PROVIDERS: ServerProvider[] = [];
407418 const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
@@ -1299,6 +1310,9 @@ function ChatViewContent(props: ChatViewProps) { · 891 unmodified lines
12991310 reportFailure: false,
13001311 });
13011312 const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
1313+ const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
1314+ reportFailure: false,
1315+ });
13021316 const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, {
13031317 reportFailure: false,
13041318 });
@@ -1384,8 +1398,13 @@ function ChatViewContent(props: ChatViewProps) { · 79 unmodified lines
13841398 const composerHasUnsentContent = useComposerDraftStore((store) =>
13851399 composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)),
13861400 );
1401+ const composerHasAttachments = useComposerDraftStore((store) => {
1402+ const draft = store.getComposerDraft(composerDraftTarget);
1403+ return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0;
1404+ });
13871405 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
13881406 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
1407+ const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);
13891408 const setComposerDraftTerminalContexts = useComposerDraftStore(
13901409 (store) => store.setTerminalContexts,
13911410 );
@@ -1412,6 +1431,7 @@ function ChatViewContent(props: ChatViewProps) { · 20 unmodified lines
14121431 );
14131432 const promptRef = useRef("");
14141433 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
1434+ const composerFilesRef = useRef<ComposerFileAttachment[]>([]);
14151435 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);
14161436 const composerElementContextsRef = useRef<ElementContextDraft[]>([]);
14171437 const localComposerRef = useRef<ChatComposerHandle | null>(null);
@@ -2124,6 +2144,12 @@ function ChatViewContent(props: ChatViewProps) { · 706 unmodified lines
21242144 const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null;
21252145 const supportsAttachmentUploads =
21262146 attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true;
2147+ const advertisedFileAttachmentBytes =
2148+ attachmentEnvironmentConfig?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null;
2149+ const maxFileAttachmentBytes =
2150+ advertisedFileAttachmentBytes === null
2151+ ? null
2152+ : clampFileAttachmentUploadBytes(advertisedFileAttachmentBytes);
21272153 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);
21282154 const versionMismatchDismissKey =
21292155 versionMismatch && activeThread
@@ -2403,11 +2429,13 @@ function ChatViewContent(props: ChatViewProps) { · 273 unmodified lines
24032429 null
24042430 );
24052431 }, [activeLatestTurn?.turnId, activePlan]);
2406− const showPlanFollowUpPrompt =
2407− pendingUserInputs.length === 0 &&
2408− interactionMode === "plan" &&
2409− latestTurnSettled &&
2410− hasActionableProposedPlan(activeProposedPlan);
2432+ const showPlanFollowUpPrompt = shouldShowPlanFollowUpPrompt({
2433+ pendingUserInputCount: pendingUserInputs.length,
2434+ interactionMode,
2435+ latestTurnSettled,
2436+ hasActionableProposedPlan: hasActionableProposedPlan(activeProposedPlan),
2437+ hasComposerAttachments: composerHasAttachments,
2438+ });
24112439 const activePendingApproval = pendingApprovals[0] ?? null;
24122440 const {
24132441 beginLocalDispatch,
@@ -2493,11 +2521,57 @@ function ChatViewContent(props: ChatViewProps) { · 79 unmodified lines
24932521 });
24942522 }, []);
24952523 const serverMessages = activeThread?.messages;
2524+ const downloadFileAttachment = useCallback(
2525+ async (attachment: ChatFileAttachment) => {
2526+ const connection = readPreparedConnection(environmentId);
2527+ if (!connection) {
2528+ toastManager.add({ type: "error", title: "The environment is not connected." });
2529+ return;
2530+ }
2531+
2532+ // fileName and mimeType ride in the signed claims so the download gets
2533+ // a real filename and Content-Type even when the anchor's `download`
2534+ // attribute is ignored (cross-origin environment servers).
2535+ const result = await createAttachmentAssetUrl({
2536+ environmentId,
2537+ input: {
2538+ resource: {
2539+ _tag: "attachment",
2540+ attachmentId: attachment.id,
2541+ fileName: attachment.name,
2542+ mimeType: attachment.mimeType,
2543+ },
2544+ },
2545+ });
2546+ if (result._tag === "Failure") {
2547+ const error = squashAtomCommandFailure(result);
2548+ toastManager.add({
2549+ type: "error",
2550+ title: `Could not download ${attachment.name}`,
2551+ description: error instanceof Error ? error.message : "The attachment is unavailable.",
2552+ });
2553+ return;
2554+ }
2555+
2556+ const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl);
2557+ if (!url) {
2558+ toastManager.add({ type: "error", title: `Could not download ${attachment.name}` });
2559+ return;
2560+ }
2561+ const anchor = document.createElement("a");
2562+ anchor.href = url;
2563+ anchor.download = attachment.name;
2564+ anchor.click();
2565+ },
2566+ [createAttachmentAssetUrl, environmentId],
2567+ );
24962568 const serverAttachmentIds = useMemo(() => {
24972569 const attachmentIds = new Set<string>();
24982570 for (const message of serverMessages ?? []) {
24992571 for (const attachment of message.attachments ?? []) {
2500− attachmentIds.add(attachment.id);
2572+ if (isImageAttachment(attachment)) {
2573+ attachmentIds.add(attachment.id);
2574+ }
25012575 }
25022576 }
25032577 return [...attachmentIds];
@@ -5410,6 +5484,7 @@ function ChatViewContent(props: ChatViewProps) { · 2906 unmodified lines
54105484 }
54115485 const {
54125486 images: sendContextImages,
5487+ files: composerFiles,
54135488 terminalContexts: composerTerminalContexts,
54145489 elementContexts: composerElementContexts,
54155490 previewAnnotations: sendContextPreviewAnnotations,
@@ -5420,9 +5495,17 @@ function ChatViewContent(props: ChatViewProps) { · 4 unmodified lines
54205495 selectedPromptEffort: ctxSelectedPromptEffort,
54215496 selectedModelSelection: ctxSelectedModelSelection,
54225497 } = sendCtx;
5498+ const annotationImageAlreadyAttached =
5499+ directAnnotation?.image !== undefined &&
5500+ sendContextImages.some((image) => image.id === directAnnotation.image?.id);
5501+ // A full composer (e.g. 8 files) cannot take the annotation screenshot;
5502+ // over the cap the server rejects the whole turn.
5503+ const annotationImageAppended =
5504+ directAnnotation?.image !== undefined &&
5505+ !annotationImageAlreadyAttached &&
5506+ sendContextImages.length + composerFiles.length < PROVIDER_SEND_TURN_MAX_ATTACHMENTS;
54235507 const composerImages =
5424− directAnnotation?.image &&
5425− !sendContextImages.some((image) => image.id === directAnnotation.image?.id)
5508+ directAnnotation?.image && annotationImageAppended
54265509 ? [...sendContextImages, directAnnotation.image]
54275510 : sendContextImages;
54285511 const composerPreviewAnnotations =
@@ -5434,9 +5517,13 @@ function ChatViewContent(props: ChatViewProps) { · 5 unmodified lines
54345517 ...sendContextPreviewAnnotations,
54355518 {
54365519 ...directAnnotation.annotation,
5437− screenshot: directAnnotation.annotation.screenshot
5438− ? { ...directAnnotation.annotation.screenshot, dataUrl: "" }
5439− : null,
5520+ // Claim an attached crop only when the screenshot really rides
5521+ // along; a cap-dropped image must not produce a lying prompt.
5522+ screenshot:
5523+ directAnnotation.annotation.screenshot &&
5524+ (annotationImageAppended || annotationImageAlreadyAttached)
5525+ ? { ...directAnnotation.annotation.screenshot, dataUrl: "" }
5526+ : null,
54405527 },
54415528 ]
54425529 : sendContextPreviewAnnotations;
@@ -5448,7 +5535,7 @@ function ChatViewContent(props: ChatViewProps) { · 5 unmodified lines
54485535 hasSendableContent,
54495536 } = deriveComposerSendState({
54505537 prompt: promptForSend,
5451− imageCount: composerImages.length,
5538+ imageCount: composerImages.length + composerFiles.length,
54525539 terminalContexts: composerTerminalContexts,
54535540 elementContextCount:
54545541 composerElementContexts.length +
@@ -5458,6 +5545,7 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines
54585545 const feedbackCommand =
54595546 ctxSelectedProvider === "codex" &&
54605547 composerImages.length === 0 &&
5548+ composerFiles.length === 0 &&
54615549 sendableComposerTerminalContexts.length === 0 &&
54625550 composerElementContexts.length === 0 &&
54635551 composerPreviewAnnotations.length === 0 &&
@@ -5550,7 +5638,13 @@ function ChatViewContent(props: ChatViewProps) { · 86 unmodified lines
55505638 );
55515639 return;
55525640 }
5553− if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) {
5641+ if (
5642+ !directAnnotation &&
5643+ showPlanFollowUpPrompt &&
5644+ activeProposedPlan &&
5645+ composerImages.length === 0 &&
5646+ composerFiles.length === 0
5647+ ) {
55545648 const followUp = resolvePlanFollowUpSubmission({
55555649 draftText: trimmed,
55565650 planMarkdown: activeProposedPlan.planMarkdown,
@@ -5579,6 +5673,7 @@ function ChatViewContent(props: ChatViewProps) { · 22 unmodified lines
55795673 const standaloneSlashCommand =
55805674 settings.planModeEnabled &&
55815675 composerImages.length === 0 &&
5676+ composerFiles.length === 0 &&
55825677 sendableComposerTerminalContexts.length === 0 &&
55835678 composerElementContexts.length === 0 &&
55845679 composerPreviewAnnotations.length === 0 &&
@@ -5635,6 +5730,8 @@ function ChatViewContent(props: ChatViewProps) { · 50 unmodified lines
56355730 }
56365731
56375732 const composerImagesSnapshot = [...composerImages];
5733+ const composerFilesSnapshot = [...composerFiles];
5734+ const composerAttachmentsSnapshot = [...composerImagesSnapshot, ...composerFilesSnapshot];
56385735 const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];
56395736 const composerElementContextsSnapshot = [...composerElementContexts];
56405737 const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations];
@@ -5656,21 +5753,57 @@ function ChatViewContent(props: ChatViewProps) { · 15 unmodified lines
56565753 model: ctxSelectedModel,
56575754 models: ctxSelectedProviderModels,
56585755 effort: ctxSelectedPromptEffort,
5659− text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
5756+ text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
56605757 });
56615758 if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) {
56625759 return;
56635760 }
56645761
5762+ const readLiveAttachmentCapabilities = () => {
5763+ const config = appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId) ?? null;
5764+ const liveSupportsAttachmentUploads =
5765+ config?.environment.capabilities.attachmentUploads === true;
5766+ return {
5767+ supportsAttachmentUploads: liveSupportsAttachmentUploads,
5768+ fileBlockReason: fileAttachmentCapabilityBlockReason({
5769+ files: composerFilesSnapshot,
5770+ attachmentUploadsCapabilityKnown: config !== null,
5771+ supportsAttachmentUploads: liveSupportsAttachmentUploads,
5772+ maxFileAttachmentBytes:
5773+ config?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null,
5774+ }),
5775+ };
5776+ };
5777+
56655778 sendInFlightRef.current = true;
5666− if (supportsAttachmentUploads && composerImagesSnapshot.length > 0) {
5667− for (const image of composerImagesSnapshot) {
5668− startAttachmentUpload({ environmentId, image });
5779+ const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities();
5780+ if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) {
5781+ sendInFlightRef.current = false;
5782+ setThreadError(threadIdForSend, attachmentCapabilitiesBeforeUpload.fileBlockReason);
5783+ return;
5784+ }
5785+ const turnUsesAttachmentUploads =
5786+ composerFilesSnapshot.length > 0
5787+ ? attachmentCapabilitiesBeforeUpload.supportsAttachmentUploads
5788+ : supportsAttachmentUploads;
5789+ if (turnUsesAttachmentUploads && composerAttachmentsSnapshot.length > 0) {
5790+ for (const attachment of composerAttachmentsSnapshot) {
5791+ startAttachmentUpload({
5792+ environmentId,
5793+ image: attachment,
5794+ draftTarget: composerDraftTarget,
5795+ });
56695796 }
5670− await awaitAttachmentUploads(composerImagesSnapshot.map((image) => image.id));
5671− if (getUploadedAttachments({ environmentId, images: composerImagesSnapshot }) === null) {
5797+ await awaitAttachmentUploads(composerAttachmentsSnapshot.map((attachment) => attachment.id));
5798+ const attachmentCapabilitiesAfterUpload = readLiveAttachmentCapabilities();
5799+ if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) {
56725800 sendInFlightRef.current = false;
5673− setThreadError(threadIdForSend, "Retry or remove failed image uploads before sending.");
5801+ setThreadError(threadIdForSend, attachmentCapabilitiesAfterUpload.fileBlockReason);
5802+ return;
5803+ }
5804+ if (getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null) {
5805+ sendInFlightRef.current = false;
5806+ setThreadError(threadIdForSend, "Retry or remove failed uploads before sending.");
56745807 return;
56755808 }
56765809 }
@@ -5699,6 +5832,16 @@ function ChatViewContent(props: ChatViewProps) { · 22 unmodified lines
56995832 void dockTransition.catch(() => resolveDockStarted?.());
57005833 await dockStarted;
57015834 }
5835+
5836+ const attachmentCapabilitiesBeforeDispatch = readLiveAttachmentCapabilities();
5837+ if (attachmentCapabilitiesBeforeDispatch.fileBlockReason !== null) {
5838+ sendInFlightRef.current = false;
5839+ setThreadError(threadIdForSend, attachmentCapabilitiesBeforeDispatch.fileBlockReason);
5840+ setDockedDraftHeroThreadKey((currentThreadKey) =>
5841+ currentThreadKey === activeThreadKey ? null : currentThreadKey,
5842+ );
5843+ return;
5844+ }
57025845 beginLocalDispatch({
57035846 preparingWorktree: Boolean(baseBranchForWorktree),
57045847 submissionIntent: resolvedSubmissionIntent,
@@ -5707,31 +5850,45 @@ function ChatViewContent(props: ChatViewProps) { · 2 unmodified lines
57075850 const messageIdForSend = newMessageId();
57085851 const messageCreatedAt = new Date().toISOString();
57095852 const turnAttachmentsPromise = Promise.all(
5710− composerImagesSnapshot.map(async (image) => {
5711− if (supportsAttachmentUploads) {
5712− const uploaded = getUploadedAttachments({ environmentId, images: [image] })?.[0];
5853+ composerAttachmentsSnapshot.map(async (attachment) => {
5854+ if (turnUsesAttachmentUploads) {
5855+ const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0];
57135856 if (!uploaded) {
5714− throw new Error(`Image '${image.name}' did not finish uploading.`);
5857+ throw new Error(`Attachment '${attachment.name}' did not finish uploading.`);
57155858 }
57165859 return uploaded;
57175860 }
5861+ if (attachment.type !== "image") {
5862+ throw new Error("This server does not support file attachments.");
5863+ }
57185864 return {
57195865 type: "image" as const,
5720− name: image.name,
5721− mimeType: image.mimeType,
5722− sizeBytes: image.sizeBytes,
5723− dataUrl: await readFileAsDataUrl(image.file),
5866+ name: attachment.name,
5867+ mimeType: attachment.mimeType,
5868+ sizeBytes: attachment.sizeBytes,
5869+ dataUrl: await readFileAsDataUrl(attachment.file),
57245870 };
57255871 }),
57265872 );
5727− const optimisticAttachments = composerImagesSnapshot.map((image) => ({
5728− type: "image" as const,
5729− id: image.id,
5730− name: image.name,
5731− mimeType: image.mimeType,
5732− sizeBytes: image.sizeBytes,
5733− previewUrl: image.previewUrl,
5734− }));
5873+ const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) =>
5874+ attachment.type === "image"
5875+ ? {
5876+ type: "image" as const,
5877+ id: attachment.id,
5878+ name: attachment.name,
5879+ mimeType: attachment.mimeType,
5880+ sizeBytes: attachment.sizeBytes,
5881+ previewUrl: attachment.previewUrl,
5882+ }
5883+ : {
5884+ type: "file" as const,
5885+ id: attachment.id,
5886+ name: attachment.name,
5887+ mimeType: attachment.mimeType,
5888+ sizeBytes: attachment.sizeBytes,
5889+ downloadable: false,
5890+ },
5891+ );
57355892 const shouldAnchorFirstMessage =
57365893 activeThread.latestTurn === null &&
57375894 !timelineMessages.some((message) => message.role === "user");
@@ -5793,6 +5950,8 @@ function ChatViewContent(props: ChatViewProps) { · 55 unmodified lines
57935950 if (!titleSeed) {
57945951 if (firstComposerImageName) {
57955952 titleSeed = `Image: ${firstComposerImageName}`;
5953+ } else if (composerFilesSnapshot[0]) {
5954+ titleSeed = `File: ${composerFilesSnapshot[0].name}`;
57965955 } else if (composerTerminalContextsSnapshot.length > 0) {
57975956 titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!);
57985957 } else if (composerElementContextsSnapshot.length > 0) {
@@ -5839,7 +5998,14 @@ function ChatViewContent(props: ChatViewProps) { · 40 unmodified lines
58395998 }
58405999 }
58416000
5842− const turnAttachmentsResult = await settlePromise(() => turnAttachmentsPromise);
6001+ const turnAttachmentsResult = await settlePromise(async () => {
6002+ const turnAttachments = await turnAttachmentsPromise;
6003+ const liveFileBlockReason = readLiveAttachmentCapabilities().fileBlockReason;
6004+ if (liveFileBlockReason !== null) {
6005+ throw new Error(liveFileBlockReason);
6006+ }
6007+ return turnAttachments;
6008+ });
58436009 if (failure === null && turnAttachmentsResult._tag === "Failure") {
58446010 failure = turnAttachmentsResult;
58456011 }
@@ -5909,8 +6075,8 @@ function ChatViewContent(props: ChatViewProps) { · 63 unmodified lines
59096075 failure = startResult;
59106076 } else {
59116077 turnStartSucceeded = true;
5912− if (supportsAttachmentUploads) {
5913− releaseAttachmentUploads(composerImagesSnapshot);
6078+ if (turnUsesAttachmentUploads) {
6079+ releaseDraftAttachments(composerAttachmentsSnapshot);
59146080 }
59156081 acknowledgeActiveThreadWoke();
59166082 if (backgroundThreadRef) {
@@ -5967,6 +6133,7 @@ function ChatViewContent(props: ChatViewProps) { · 50 unmodified lines
59676133 if (
59686134 promptRef.current.length === 0 &&
59696135 composerImagesRef.current.length === 0 &&
6136+ composerFilesRef.current.length === 0 &&
59706137 composerTerminalContextsRef.current.length === 0 &&
59716138 composerElementContextsRef.current.length === 0 &&
59726139 (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations
@@ -5985,10 +6152,12 @@ function ChatViewContent(props: ChatViewProps) { · 12 unmodified lines
59856152 promptRef.current = promptForSend;
59866153 const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry);
59876154 composerImagesRef.current = retryComposerImages;
6155+ composerFilesRef.current = composerFilesSnapshot;
59886156 composerTerminalContextsRef.current = composerTerminalContextsSnapshot;
59896157 composerElementContextsRef.current = composerElementContextsSnapshot;
59906158 setComposerDraftPrompt(composerDraftTarget, promptForSend);
59916159 addComposerDraftImages(composerDraftTarget, retryComposerImages);
6160+ addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot);
59926161 setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot);
59936162 setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot);
59946163 setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot);
@@ -6958,6 +7127,7 @@ function ChatViewContent(props: ChatViewProps) { · 963 unmodified lines
69587127 onRevertUserMessage={onRevertUserMessage}
69597128 isRevertingCheckpoint={isRevertingCheckpoint}
69607129 onImageExpand={onExpandTimelineImage}
7130+ onFileDownload={downloadFileAttachment}
69617131 markdownCwd={gitCwd ?? undefined}
69627132 resolvedTheme={resolvedTheme}
69637133 timestampFormat={timestampFormat}
@@ -7057,6 +7227,7 @@ function ChatViewContent(props: ChatViewProps) { · 93 unmodified lines
70577227 environmentId={environmentId}
70587228 attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown}
70597229 supportsAttachmentUploads={supportsAttachmentUploads}
7230+ maxFileAttachmentBytes={maxFileAttachmentBytes}
70607231 routeKind={routeKind}
70617232 routeThreadRef={routeThreadRef}
70627233 draftId={draftId}
@@ -7111,6 +7282,7 @@ function ChatViewContent(props: ChatViewProps) { · 48 unmodified lines
71117282 gitCwd={gitCwd}
71127283 promptRef={promptRef}
71137284 composerImagesRef={composerImagesRef}
7285+ composerFilesRef={composerFilesRef}
71147286 composerTerminalContextsRef={composerTerminalContextsRef}
71157287 composerElementContextsRef={composerElementContextsRef}
71167288 onSend={onSend}
apps/web/src/components/LegacySidebar.tsx+10−2
@@ -1471,7 +1471,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 1470 unmodified lines
14711471 return result;
14721472 }
14731473 const draftStore = useComposerDraftStore.getState();
1474− releaseProjectDraftUploads(memberProjectRef);
1474+ releaseProjectDraftUploads(
1475+ memberProjectRef,
1476+ sidebarThreads
1477+ .filter(
1478+ (thread) =>
1479+ thread.environmentId === member.environmentId && thread.projectId === member.id,
1480+ )
1481+ .map((thread) => scopeThreadRef(thread.environmentId, thread.id)),
1482+ );
14751483 const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef);
14761484 if (projectDraftThread) {
14771485 draftStore.clearDraftThread(projectDraftThread.draftId);
@@ -1479,7 +1487,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 1 unmodified line
14791487 draftStore.clearProjectDraftThreadId(memberProjectRef);
14801488 return result;
14811489 },
1482− [deleteProject],
1490+ [deleteProject, sidebarThreads],
14831491 );
14841492
14851493 const handleRemoveProject = useCallback(
apps/web/src/components/Sidebar.tsx+1−0
@@ -495,6 +495,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { · 494 unmodified lines
495495 // that only the persisted list is populated, hence max not sum.
496496 const attachmentCount =
497497 Math.max(composer.images.length, composer.persistedAttachments.length) +
498+ composer.files.length +
498499 composer.terminalContexts.length +
499500 composer.elementContexts.length +
500501 composer.previewAnnotations.length +
apps/web/src/components/chat/ChatComposer.tsx+573−63
@@ -13,7 +13,6 @@ import type { · 12 unmodified lines
1313 TurnId,
1414 } from "@t3tools/contracts";
1515 import {
16− isProviderSendTurnSupportedImageMimeType,
1716 ProviderDriverKind,
1817 ProviderInstanceId,
1918 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
@@ -51,9 +50,13 @@ import { · 31 unmodified lines
5150 makeComposerMentionDragHandlers,
5251 } from "./composerMentionDrag";
5352 import {
53+ type ComposerFileAttachment,
5454 type ComposerImageAttachment,
5555 type DraftId,
56+ type PersistedComposerFileAttachment,
5657 type PersistedComposerImageAttachment,
58+ composerFileNeedsReattach,
59+ composerTargetKey,
5760 hydrateImagesFromPersisted,
5861 useComposerDraftStore,
5962 useComposerThreadDraft,
@@ -73,16 +76,28 @@ import { · 13 unmodified lines
7376 type ComposerTaskStep,
7477 type ComposerTasksProgress,
7578 } from "./ComposerTasksBadge";
79+import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";
7680 import {
77− compressImageForStash,
78− isHeicImageFile,
79− prepareImageForAttachment,
80−} from "../../lib/imageCompression";
81+ fileAttachmentTooLargeMessage,
82+ formatAttachmentSize,
83+} from "@t3tools/client-runtime/state/attachments";
8184 import {
85+ attachmentsToReleaseOnUploadCapabilityLoss,
86+ classifyComposerAttachmentFile,
87+ fileAttachmentCapabilityBlockReason,
88+ fileAttachmentStagingLimit,
89+ normalizeComposerImageFileMimeType,
90+ shouldHandleComposerAttachmentPaste,
91+} from "./composerAttachmentFiles";
92+import {
93+ readAttachmentUpload,
8294 releaseAttachmentUpload,
95+ releaseDraftAttachment,
96+ releasePersistedAttachmentUpload,
8397 retryAttachmentUpload,
8498 startAttachmentUpload,
8599 useAttachmentUploadStore,
100+ verifyStashedAttachmentUpload,
86101 } from "../../lib/attachmentUploadQueue";
87102 import {
88103 attachmentUploadBlockReason,
@@ -241,6 +256,8 @@ import { toastManager } from "../ui/toast"; · 152 unmodified lines
241256 import {
242257 BotIcon,
243258 CircleAlertIcon,
259+ FileIcon,
260+ PaperclipIcon,
244261 PencilRulerIcon,
245262 type LucideIcon,
246263 LockIcon,
@@ -532,6 +549,7 @@ export interface ChatComposerHandle { · 285 unmodified lines
532549 getSendContext: () => {
533550 prompt: string;
534551 images: ComposerImageAttachment[];
552+ files: ComposerFileAttachment[];
535553 terminalContexts: TerminalContextDraft[];
536554 elementContexts: ElementContextDraft[];
537555 previewAnnotations: PreviewAnnotationPayload[];
@@ -557,6 +575,7 @@ export interface ChatComposerProps { · 19 unmodified lines
557575 environmentId: EnvironmentId;
558576 attachmentUploadsCapabilityKnown: boolean;
559577 supportsAttachmentUploads: boolean;
578+ maxFileAttachmentBytes: number | null;
560579 routeKind: "server" | "draft";
561580 routeThreadRef: ScopedThreadRef;
562581 draftId: DraftId | null;
@@ -630,6 +649,7 @@ export interface ChatComposerProps { · 67 unmodified lines
630649 // Refs the parent needs kept in sync
631650 promptRef: React.RefObject<string>;
632651 composerImagesRef: React.RefObject<ComposerImageAttachment[]>;
652+ composerFilesRef: React.RefObject<ComposerFileAttachment[]>;
633653 composerTerminalContextsRef: React.RefObject<TerminalContextDraft[]>;
634654 composerElementContextsRef: React.RefObject<ElementContextDraft[]>;
635655 composerRef: React.RefObject<ChatComposerHandle | null>;
@@ -675,6 +695,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 39 unmodified lines
675695 environmentId,
676696 attachmentUploadsCapabilityKnown,
677697 supportsAttachmentUploads,
698+ maxFileAttachmentBytes,
678699 routeKind,
679700 routeThreadRef,
680701 draftId,
@@ -721,6 +742,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 40 unmodified lines
721742 promptRef,
722743 composerRef,
723744 composerImagesRef,
745+ composerFilesRef,
724746 composerTerminalContextsRef,
725747 composerElementContextsRef,
726748 onSend,
@@ -745,21 +767,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 18 unmodified lines
745767 // Store subscriptions (prompt / images / terminal contexts)
746768 // ------------------------------------------------------------------
747769 const composerDraft = useComposerThreadDraft(composerDraftTarget);
770+ // Live target key, for async flows that must notice a thread switch that
771+ // happened while they awaited.
772+ const composerDraftTargetKeyRef = useRef("");
773+ composerDraftTargetKeyRef.current = composerTargetKey(composerDraftTarget);
748774 const prompt = composerDraft.prompt;
749775 const composerImages = composerDraft.images;
776+ const composerFiles = composerDraft.files;
750777 const composerTerminalContexts = composerDraft.terminalContexts;
751778 const composerElementContexts = composerDraft.elementContexts;
752779 const composerPreviewAnnotations = composerDraft.previewAnnotations;
753780 const composerReviewComments = composerDraft.reviewComments;
754781 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
755782 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
756− const attachmentBlockReason = supportsAttachmentUploads
757− ? attachmentUploadBlockReason({
758− imageIds: composerImages.map((image) => image.id),
759− uploadsByImageId,
760− environmentId,
761− })
762− : null;
783+ const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length;
784+ const fileStagingLimit = fileAttachmentStagingLimit({
785+ attachmentUploadsCapabilityKnown,
786+ supportsAttachmentUploads,
787+ maxFileAttachmentBytes,
788+ });
789+ const fileCapabilityBlockReason = fileAttachmentCapabilityBlockReason({
790+ files: composerFiles,
791+ attachmentUploadsCapabilityKnown,
792+ supportsAttachmentUploads,
793+ maxFileAttachmentBytes,
794+ });
795+ const attachmentBlockReason =
796+ fileCapabilityBlockReason ??
797+ (supportsAttachmentUploads
798+ ? needsReattachFileCount > 0
799+ ? needsReattachFileCount === 1
800+ ? "Attach the interrupted file again or remove it"
801+ : "Attach the interrupted files again or remove them"
802+ : attachmentUploadBlockReason({
803+ imageIds: [...composerImages, ...composerFiles].map((attachment) => attachment.id),
804+ uploadsByImageId,
805+ environmentId,
806+ })
807+ : null);
763808 const sendDisabledReason =
764809 externalSendDisabledReason ?? (activePendingProgress ? null : attachmentBlockReason);
765810 const isSendDisabled = sendDisabledReason !== null;
@@ -768,6 +813,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2 unmodified lines
768813 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
769814 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
770815 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
816+ const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);
817+ const removeComposerDraftFile = useComposerDraftStore((store) => store.removeFile);
818+ const setComposerDraftFileUpload = useComposerDraftStore((store) => store.setFileUpload);
771819 const insertComposerDraftTerminalContext = useComposerDraftStore(
772820 (store) => store.insertTerminalContext,
773821 );
@@ -802,15 +850,76 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 28 unmodified lines
802850 return;
803851 }
804852 if (!supportsAttachmentUploads) {
805− for (const image of composerImages) {
806− releaseAttachmentUpload(image.id);
853+ // The capability can flap on reconnect or version skew. Deleting a
854+ // persisted hydrated upload here would make the next send fail
855+ // verification while the file still sits in the draft.
856+ for (const attachment of attachmentsToReleaseOnUploadCapabilityLoss([
857+ ...composerImages,
858+ ...composerFiles,
859+ ])) {
860+ releaseAttachmentUpload(attachment.id);
807861 }
808862 return;
809863 }
810− for (const image of composerImages) {
811− startAttachmentUpload({ environmentId, image });
864+ const invalidFiles =
865+ maxFileAttachmentBytes === null
866+ ? composerFiles
867+ : composerFiles.filter((file) => file.sizeBytes > maxFileAttachmentBytes);
868+ for (const attachment of attachmentsToReleaseOnUploadCapabilityLoss(invalidFiles)) {
869+ releaseAttachmentUpload(attachment.id);
870+ }
871+ const uploadableFiles =
872+ maxFileAttachmentBytes === null
873+ ? []
874+ : composerFiles.filter((file) => file.sizeBytes <= maxFileAttachmentBytes);
875+ const uploadableAttachments = [...composerImages, ...uploadableFiles];
876+ for (const attachment of uploadableAttachments) {
877+ // A needs-reattach file has no bytes to upload and no upload to verify.
878+ if (attachment.type === "file" && composerFileNeedsReattach(attachment)) {
879+ continue;
880+ }
881+ startAttachmentUpload({ environmentId, image: attachment, draftTarget: composerDraftTarget });
882+ }
883+ }, [
884+ attachmentUploadsCapabilityKnown,
885+ composerDraftTarget,
886+ composerFiles,
887+ composerImages,
888+ environmentId,
889+ maxFileAttachmentBytes,
890+ supportsAttachmentUploads,
891+ ]);
892+
893+ useEffect(() => {
894+ for (const file of composerFiles) {
895+ if (
896+ !attachmentUploadsCapabilityKnown ||
897+ !supportsAttachmentUploads ||
898+ maxFileAttachmentBytes === null ||
899+ file.sizeBytes > maxFileAttachmentBytes
900+ ) {
901+ continue;
902+ }
903+ const upload = uploadsByImageId[file.id];
904+ if (upload?.status === "ready" && upload.environmentId === environmentId) {
905+ setComposerDraftFileUpload(
906+ composerDraftTarget,
907+ file.id,
908+ environmentId,
909+ upload.attachmentId,
910+ );
911+ }
812912 }
813− }, [attachmentUploadsCapabilityKnown, composerImages, environmentId, supportsAttachmentUploads]);
913+ }, [
914+ attachmentUploadsCapabilityKnown,
915+ composerDraftTarget,
916+ composerFiles,
917+ environmentId,
918+ maxFileAttachmentBytes,
919+ setComposerDraftFileUpload,
920+ supportsAttachmentUploads,
921+ uploadsByImageId,
922+ ]);
814923
815924 // ------------------------------------------------------------------
816925 // Model state
@@ -1068,6 +1177,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 251 unmodified lines
10681177 // Refs
10691178 // ------------------------------------------------------------------
10701179 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
1180+ const attachmentInputRef = useRef<HTMLInputElement>(null);
10711181 const composerFormRef = useRef<HTMLFormElement>(null);
10721182 const composerSurfaceRef = useRef<HTMLDivElement>(null);
10731183 const providerInputRejectedRef = useRef(false);
@@ -1102,7 +1212,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 28 unmodified lines
11021212 () =>
11031213 deriveComposerSendState({
11041214 prompt,
1105− imageCount: composerImages.length,
1215+ imageCount: composerImages.length + composerFiles.length,
11061216 terminalContexts: composerTerminalContexts,
11071217 elementContextCount:
11081218 composerElementContexts.length +
@@ -1111,6 +1221,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2 unmodified lines
11111221 }),
11121222 [
11131223 composerElementContexts.length,
1224+ composerFiles.length,
11141225 composerImages.length,
11151226 composerPreviewAnnotations.length,
11161227 composerReviewComments.length,
@@ -1395,6 +1506,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 278 unmodified lines
13951506 [composerDraftTarget, addComposerDraftImages],
13961507 );
13971508
1509+ const addComposerFilesToDraft = useCallback(
1510+ (files: ComposerFileAttachment[]) => {
1511+ addComposerDraftFiles(composerDraftTarget, files);
1512+ },
1513+ [addComposerDraftFiles, composerDraftTarget],
1514+ );
1515+
13981516 const removeComposerImageFromDraft = useCallback(
13991517 (imageId: string) => {
14001518 releaseAttachmentUpload(imageId);
@@ -1403,6 +1521,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2 unmodified lines
14031521 [composerDraftTarget, removeComposerDraftImage],
14041522 );
14051523
1524+ const removeComposerFileFromDraft = useCallback(
1525+ (fileId: string) => {
1526+ // Release by the draft attachment, not the bare queue key: a hydrated
1527+ // file's upload lives server-side under its persisted attachment id.
1528+ const file = composerFilesRef.current.find((candidate) => candidate.id === fileId);
1529+ if (file) {
1530+ releaseDraftAttachment(file);
1531+ } else {
1532+ releaseAttachmentUpload(fileId);
1533+ }
1534+ removeComposerDraftFile(composerDraftTarget, fileId);
1535+ },
1536+ [composerDraftTarget, composerFilesRef, removeComposerDraftFile],
1537+ );
1538+
14061539 const removeComposerTerminalContextFromDraft = useCallback(
14071540 (contextId: string) => {
14081541 const contextIndex = composerTerminalContexts.findIndex(
@@ -1459,6 +1592,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 50 unmodified lines
14591592 composerImagesRef.current = composerImages;
14601593 }, [composerImages, composerImagesRef]);
14611594
1595+ useEffect(() => {
1596+ composerFilesRef.current = composerFiles;
1597+ }, [composerFiles, composerFilesRef]);
1598+
14621599 useEffect(() => {
14631600 composerTerminalContextsRef.current = composerTerminalContexts;
14641601 }, [composerTerminalContexts, composerTerminalContextsRef]);
@@ -2127,9 +2264,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 662 unmodified lines
21272264 // ------------------------------------------------------------------
21282265 // Prompt stash (⌘S)
21292266 // ------------------------------------------------------------------
2130− // One global queue. Stashed prompts carry only text + images so they can be
2131− // restored into any thread or provider — stash, switch, restore is the
2132− // whole point.
2267+ // Files remain tied to the environment that owns their uploaded bytes.
21332268 const stashQueue = usePromptStashStore((state) => state.entries);
21342269 const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry);
21352270 const takeStashEntry = usePromptStashStore((state) => state.takeEntry);
@@ -2157,10 +2292,45 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 21 unmodified lines
21572292 }, []);
21582293
21592294 const restoreStashEntry = useCallback(
2160− (entry: PromptStashEntry) => {
2161− // Remove first so a double activation (click + Enter) can't restore twice.
2162− const { entry: taken, durable } = takeStashEntry(entry.id);
2163− if (!taken) return;
2295+ async (menuEntry: PromptStashEntry) => {
2296+ const filesToVerify = menuEntry.files ?? [];
2297+ if (filesToVerify.some((file) => file.environmentId !== environmentId)) {
2298+ toastManager.add({
2299+ type: "error",
2300+ title: "Stashed files belong to another environment",
2301+ description: "Restore this prompt in the environment that received its files.",
2302+ });
2303+ return;
2304+ }
2305+ setIsStashMenuOpen(false);
2306+
2307+ // The server sweeps pending uploads after 24 hours, so ask before
2308+ // reattaching. An expired upload restores as a needs-reattach row
2309+ // instead of a reference the next send would fail to verify. Verify
2310+ // BEFORE taking: the take removes the entry from durable storage, and a
2311+ // tab closed during this await must still find it there after reload.
2312+ const verifications = await Promise.all(
2313+ filesToVerify.map((file) =>
2314+ verifyStashedAttachmentUpload({ environmentId, attachmentId: file.attachmentId }),
2315+ ),
2316+ );
2317+ const expiredAttachmentIds = new Set(
2318+ filesToVerify
2319+ .filter((_, index) => verifications[index]?.status === "missing")
2320+ .map((file) => file.attachmentId),
2321+ );
2322+
2323+ // A thread switch during the verify await would mix the new thread's
2324+ // prompt with this invocation's captured target. Nothing was taken yet,
2325+ // so abort and leave the entry restorable where the user now is.
2326+ if (composerTargetKey(composerDraftTarget) !== composerDraftTargetKeyRef.current) {
2327+ return;
2328+ }
2329+
2330+ // The take is also the double-activation guard (click + Enter): the
2331+ // second caller finds the entry gone and stops here.
2332+ const { entry, durable } = takeStashEntry(menuEntry.id);
2333+ if (!entry) return;
21642334 if (!durable) {
21652335 toastManager.add({
21662336 type: "warning",
@@ -2170,7 +2340,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 3 unmodified lines
21702340 data: { hideCopyButton: true },
21712341 });
21722342 }
2173− setIsStashMenuOpen(false);
21742343
21752344 const currentPrompt = promptRef.current;
21762345 // An image-only stash must not append blank lines to whatever is
@@ -2189,6 +2358,121 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 12 unmodified lines
21892358 setComposerTrigger(null);
21902359 }
21912360
2361+ let unrestoredFileNames: string[] = [];
2362+ const expiredFileNames: string[] = [];
2363+ let restoredFileCount = 0;
2364+ const stashedFiles = entry.files ?? [];
2365+ if (stashedFiles.length > 0) {
2366+ const fileDedupKey = (file: {
2367+ readonly mimeType: string;
2368+ readonly sizeBytes: number;
2369+ readonly name: string;
2370+ }) => `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`;
2371+ const composerFilesNow = composerFilesRef.current;
2372+ const existingFileIds = new Set(composerFilesNow.map((file) => file.id));
2373+ const retainedUploadIds = new Set(
2374+ composerFilesNow.flatMap((file) =>
2375+ file.uploadedAttachmentId ? [file.uploadedAttachmentId] : [],
2376+ ),
2377+ );
2378+ const existingFileKeys = new Set(composerFilesNow.map(fileDedupKey));
2379+ const reattachMarkerKeys = new Set(
2380+ composerFilesNow.filter(composerFileNeedsReattach).map(fileDedupKey),
2381+ );
2382+ const duplicateFiles: PersistedComposerFileAttachment[] = [];
2383+ const markerReplacements: ComposerFileAttachment[] = [];
2384+ const appendedFiles: ComposerFileAttachment[] = [];
2385+ for (const file of stashedFiles) {
2386+ const expired = expiredAttachmentIds.has(file.attachmentId);
2387+ const key = fileDedupKey(file);
2388+ const restored: ComposerFileAttachment = {
2389+ type: "file",
2390+ id: file.id,
2391+ name: file.name,
2392+ mimeType: file.mimeType,
2393+ sizeBytes: file.sizeBytes,
2394+ file: null,
2395+ // An expired upload carries no ids, so it hydrates as a
2396+ // needs-reattach row and the "Attach again" flow takes over.
2397+ ...(expired
2398+ ? {}
2399+ : { uploadedAttachmentId: file.attachmentId, uploadEnvironmentId: environmentId }),
2400+ };
2401+ if (existingFileIds.has(file.id)) {
2402+ if (!expired && !retainedUploadIds.has(file.attachmentId)) {
2403+ duplicateFiles.push(file);
2404+ }
2405+ continue;
2406+ }
2407+ if (existingFileKeys.has(key)) {
2408+ if (reattachMarkerKeys.has(key)) {
2409+ // The draft row with this identity is a needs-reattach marker,
2410+ // not a real duplicate. Replace it (addFiles swaps a matching
2411+ // marker in place) instead of deleting the only uploaded copy.
2412+ reattachMarkerKeys.delete(key);
2413+ existingFileIds.add(file.id);
2414+ if (expired) {
2415+ // The draft's marker already says "attach again"; nothing to
2416+ // restore or release, but say why the stash copy is gone.
2417+ expiredFileNames.push(file.name);
2418+ } else {
2419+ retainedUploadIds.add(file.attachmentId);
2420+ markerReplacements.push(restored);
2421+ }
2422+ continue;
2423+ }
2424+ if (!expired && !retainedUploadIds.has(file.attachmentId)) {
2425+ duplicateFiles.push(file);
2426+ }
2427+ continue;
2428+ }
2429+ existingFileIds.add(file.id);
2430+ existingFileKeys.add(key);
2431+ if (expired) {
2432+ expiredFileNames.push(file.name);
2433+ } else {
2434+ retainedUploadIds.add(file.attachmentId);
2435+ }
2436+ appendedFiles.push(restored);
2437+ }
2438+ const capacity = Math.max(
2439+ 0,
2440+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS -
2441+ composerImagesRef.current.length -
2442+ composerFilesNow.length,
2443+ );
2444+ // Marker replacements reuse their marker's slot; only appended files
2445+ // consume capacity.
2446+ const filesToAppend = appendedFiles.slice(0, capacity);
2447+ const skippedFiles = appendedFiles.slice(capacity);
2448+ unrestoredFileNames = skippedFiles.map((file) => file.name);
2449+ // A non-durable take can resurrect the stash entry after a reload;
2450+ // deleting these uploads would leave it pointing at nothing.
2451+ if (durable) {
2452+ for (const file of duplicateFiles) {
2453+ releasePersistedAttachmentUpload({
2454+ id: file.id,
2455+ environmentId,
2456+ attachmentId: file.attachmentId,
2457+ });
2458+ }
2459+ for (const file of skippedFiles) {
2460+ if (file.uploadedAttachmentId) {
2461+ releasePersistedAttachmentUpload({
2462+ id: file.id,
2463+ environmentId,
2464+ attachmentId: file.uploadedAttachmentId,
2465+ });
2466+ }
2467+ }
2468+ }
2469+ const restoredFiles = [...markerReplacements, ...filesToAppend];
2470+ if (restoredFiles.length > 0) {
2471+ addComposerDraftFiles(composerDraftTarget, restoredFiles);
2472+ restoredFileCount = filesToAppend.length;
2473+ }
2474+ }
2475+
21922476 let unrestoredImageNames: string[] = [];
21932477 if (entry.attachments.length > 0) {
21942478 const existingIds = new Set(composerImagesRef.current.map((image) => image.id));
@@ -2203,7 +2487,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 8 unmodified lines
22032487 );
22042488 const capacity = Math.max(
22052489 0,
2206− PROVIDER_SEND_TURN_MAX_ATTACHMENTS - composerImagesRef.current.length,
2490+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS -
2491+ composerImagesRef.current.length -
2492+ composerFilesRef.current.length -
2493+ restoredFileCount,
22072494 );
22082495 const pending = entry.attachments.filter(
22092496 (attachment) =>
@@ -2242,13 +2529,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 32 unmodified lines
22422529 }
22432530 if (unrestoredImageNames.length > 0) {
22442531 missingImageReasons.push(
2245− `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-image limit.`,
2532+ `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`,
2533+ );
2534+ }
2535+ if (unrestoredFileNames.length > 0) {
2536+ missingImageReasons.push(
2537+ `${unrestoredFileNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`,
2538+ );
2539+ }
2540+ if (expiredFileNames.length > 0) {
2541+ missingImageReasons.push(
2542+ `${expiredFileNames.join(", ")}: stashed files are kept for 24 hours and this upload expired. Attach the file again.`,
22462543 );
22472544 }
22482545 if (missingImageReasons.length > 0) {
22492546 toastManager.add({
22502547 type: "warning",
2251− title: "Some images were not restored",
2548+ title: "Some attachments were not restored",
22522549 description: missingImageReasons.join(" "),
22532550 });
22542551 }
@@ -2262,9 +2559,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 7 unmodified lines
22622559 }
22632560 },
22642561 [
2562+ addComposerDraftFiles,
22652563 addComposerDraftImages,
22662564 composerDraftTarget,
2565+ composerFilesRef,
22672566 composerImagesRef,
2567+ environmentId,
22682568 promptRef,
22692569 setComposerDraftPrompt,
22702570 takeStashEntry,
@@ -2273,7 +2573,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2 unmodified lines
22732573
22742574 const deleteStashEntry = useCallback(
22752575 (entry: PromptStashEntry) => {
2276− const { durable } = takeStashEntry(entry.id);
2576+ const { entry: removed, durable } = takeStashEntry(entry.id);
2577+ if (durable && removed) {
2578+ for (const file of removed.files ?? []) {
2579+ releasePersistedAttachmentUpload({
2580+ id: file.id,
2581+ environmentId: file.environmentId,
2582+ attachmentId: file.attachmentId,
2583+ });
2584+ }
2585+ }
22772586 if (!durable) {
22782587 toastManager.add({
22792588 type: "warning",
@@ -2292,17 +2601,45 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 12 unmodified lines
22922601 // round-trip, so they are stripped from the stashed prompt.
22932602 const prompt = promptRef.current.split(INLINE_TERMINAL_CONTEXT_PLACEHOLDER).join("").trim();
22942603 const images = [...composerImagesRef.current];
2295− if (prompt.length === 0 && images.length === 0) {
2604+ const files = [...composerFilesRef.current];
2605+ if (prompt.length === 0 && images.length === 0 && files.length === 0) {
22962606 setIsStashMenuOpen((open) => !open);
22972607 return;
22982608 }
2609+ const stashedFiles: PersistedComposerFileAttachment[] = [];
2610+ for (const file of files) {
2611+ if (composerFileNeedsReattach(file)) {
2612+ toastManager.add({
2613+ type: "error",
2614+ title: "Attach dropped files again or remove them before stashing",
2615+ });
2616+ return;
2617+ }
2618+ const upload = readAttachmentUpload(file.id);
2619+ if (upload?.status !== "ready" || upload.environmentId !== environmentId) {
2620+ toastManager.add({
2621+ type: "error",
2622+ title: "Wait for file uploads before stashing this prompt",
2623+ });
2624+ return;
2625+ }
2626+ stashedFiles.push({
2627+ id: file.id,
2628+ name: file.name,
2629+ mimeType: file.mimeType,
2630+ sizeBytes: file.sizeBytes,
2631+ attachmentId: upload.attachmentId,
2632+ environmentId,
2633+ });
2634+ }
22992635 // A repeat ⌘S on the *same* still-unencoded snapshot would stash it
23002636 // twice. Guard on the snapshot itself rather than a bare boolean: once
23012637 // the composer has been cleared the user can type something genuinely
23022638 // new (or switch threads) while encoding continues, and that deserves its
23032639 // own entry.
23042640 const snapshotKey = `${String(composerDraftTarget)}${prompt}${images
2305− .map((image) => image.id)
2641+ .map((image) => `image:${image.id}`)
2642+ .concat(files.map((file) => `file:${file.id}`))
23062643 .join(",")}`;
23072644 if (stashInFlightRef.current.has(snapshotKey)) return;
23082645 stashInFlightRef.current.add(snapshotKey);
@@ -2320,6 +2657,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 11 unmodified lines
23202657 createdAt: new Date().toISOString(),
23212658 prompt,
23222659 attachments: [],
2660+ ...(stashedFiles.length > 0 ? { files: stashedFiles } : {}),
23232661 droppedImageNames: [],
23242662 unreadableImageNames: [],
23252663 pendingImageCount: images.length,
@@ -2352,9 +2690,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 26 unmodified lines
23522690 });
23532691 }
23542692
2355− // Only the prompt and images are cleared — terminal/element contexts,
2356− // preview annotations, and review comments are not stashable, so
2357− // destroying them here would be unrecoverable.
2693+ // Terminal and preview context stays behind because the stash cannot restore it.
23582694 promptRef.current = "";
23592695 clearComposerDraftPromptAndImages(stashTarget);
23602696 for (const image of images) {
@@ -2365,6 +2701,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 4 unmodified lines
23652701 pulseStashBadge();
23662702
23672703 if (evicted) {
2704+ for (const file of evicted.files ?? []) {
2705+ releasePersistedAttachmentUpload({
2706+ id: file.id,
2707+ environmentId: file.environmentId,
2708+ attachmentId: file.attachmentId,
2709+ });
2710+ }
23682711 toastManager.add({
23692712 type: "warning",
23702713 title: "Oldest stashed prompt discarded",
@@ -2440,7 +2783,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 69 unmodified lines
24402783 }, [
24412784 clearComposerDraftPromptAndImages,
24422785 composerDraftTarget,
2786+ composerFilesRef,
24432787 composerImagesRef,
2788+ environmentId,
24442789 finalizeStashEntryImages,
24452790 promptRef,
24462791 pulseStashBadge,
@@ -2586,14 +2931,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 139 unmodified lines
25862931 ]);
25872932
25882933 // ------------------------------------------------------------------
2589− // Callbacks: images
2934+ // Callbacks: attachments
25902935 // ------------------------------------------------------------------
2591− const addComposerImages = async (files: File[]) => {
2936+ const addComposerAttachments = async (files: File[]) => {
25922937 if (!activeThreadId || files.length === 0) return;
25932938 if (pendingUserInputs.length > 0) {
25942939 toastManager.add({
25952940 type: "error",
2596− title: "Attach images after answering plan questions.",
2941+ title: "Attach files after answering plan questions.",
25972942 });
25982943 return;
25992944 }
@@ -2606,34 +2951,75 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 6 unmodified lines
26062951 // accepted files reserve their attachment slots (via the pending counter)
26072952 // before the first await, keeping the total under the limit.
26082953 const pendingCount = pendingImageCompressionsRef.current.get(threadId) ?? 0;
2609− let reservedCount = composerImagesRef.current.length + pendingCount;
2610− const acceptedFiles: File[] = [];
2954+ let reservedCount =
2955+ composerImagesRef.current.length + composerFilesRef.current.length + pendingCount;
2956+ // A pick that matches a needs-reattach marker replaces it in the draft, so
2957+ // it must not consume a slot; a draft full of markers would otherwise hit
2958+ // the capacity error before the replacement path could run.
2959+ const reattachKeys = new Set(
2960+ composerFilesRef.current
2961+ .filter(composerFileNeedsReattach)
2962+ .map((file) => `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`),
2963+ );
2964+ const acceptedImages: File[] = [];
2965+ const acceptedFiles: ComposerFileAttachment[] = [];
26112966 let error: string | null = null;
26122967 for (const file of files) {
2613− const isHeicImage = isHeicImageFile(file);
2614− if (!file.type.startsWith("image/") && !isHeicImage) {
2615− error = `Unsupported file type for '${file.name}'. Please attach image files only.`;
2968+ const attachmentKind = classifyComposerAttachmentFile(file);
2969+ const replacesReattachMarker =
2970+ attachmentKind === "file" &&
2971+ reattachKeys.delete(
2972+ `${file.type || "application/octet-stream"}\u0000${file.size}\u0000${file.name || "file"}`,
2973+ );
2974+ if (!replacesReattachMarker && reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
2975+ error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`;
2976+ // Keep scanning: a later file in this batch can still replace a
2977+ // needs-reattach marker without needing a free slot.
26162978 continue;
26172979 }
2618− if (!isHeicImage && !isProviderSendTurnSupportedImageMimeType(file.type)) {
2980+ if (attachmentKind === "unsupported-image") {
26192981 error = `'${file.name}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.`;
26202982 continue;
26212983 }
2622− if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
2623− error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
2624− break;
2984+ if (attachmentKind === "image") {
2985+ acceptedImages.push(normalizeComposerImageFileMimeType(file));
2986+ } else {
2987+ if (fileStagingLimit === null) {
2988+ error = "This server does not support file attachments.";
2989+ continue;
2990+ }
2991+ if (file.size <= 0) {
2992+ error = `'${file.name}' is empty or could not be read.`;
2993+ continue;
2994+ }
2995+ if (file.size > fileStagingLimit) {
2996+ error = fileAttachmentTooLargeMessage(file.name, fileStagingLimit);
2997+ continue;
2998+ }
2999+ acceptedFiles.push({
3000+ type: "file",
3001+ id: randomUUID(),
3002+ name: file.name || "file",
3003+ mimeType: file.type || "application/octet-stream",
3004+ sizeBytes: file.size,
3005+ file,
3006+ });
3007+ }
3008+ if (!replacesReattachMarker) {
3009+ reservedCount += 1;
26253010 }
2626− acceptedFiles.push(file);
2627− reservedCount += 1;
26283011 }
26293012 setThreadError(threadId, error);
2630− if (acceptedFiles.length === 0) return;
3013+ if (acceptedFiles.length > 0) {
3014+ addComposerFilesToDraft(acceptedFiles);
3015+ }
3016+ if (acceptedImages.length === 0) return;
26313017
2632− pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedFiles.length);
3018+ pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedImages.length);
26333019 try {
26343020 const nextImages: ComposerImageAttachment[] = [];
26353021 let compressionError: string | null = null;
2636− for (const file of acceptedFiles) {
3022+ for (const file of acceptedImages) {
26373023 // Images over the wire cap are downscaled to fit rather than
26383024 // refused; files already within it pass through byte-for-byte.
26393025 const compressed = await prepareImageForAttachment(
@@ -2673,7 +3059,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 33 unmodified lines
26733059 }
26743060 } finally {
26753061 const remaining =
2676− (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedFiles.length;
3062+ (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedImages.length;
26773063 if (remaining > 0) {
26783064 pendingImageCompressionsRef.current.set(threadId, remaining);
26793065 } else {
@@ -2691,13 +3077,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 11 unmodified lines
26913077 // ------------------------------------------------------------------
26923078 const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {
26933079 const files = Array.from(event.clipboardData.files);
2694− if (files.length === 0) return;
2695− const imageFiles = files.filter(
2696− (file) => file.type.startsWith("image/") || isHeicImageFile(file),
2697− );
2698− if (imageFiles.length === 0) return;
3080+ // Claimable pastes go through even when plan questions are pending or the
3081+ // composer is at its attachment limit: `addComposerAttachments` surfaces
3082+ // those as a toast and a thread error. An early return here would swallow
3083+ // the paste with no feedback.
3084+ if (
3085+ files.length === 0 ||
3086+ !activeThreadId ||
3087+ !shouldHandleComposerAttachmentPaste({
3088+ files,
3089+ plainText: event.clipboardData.getData("text/plain"),
3090+ })
3091+ ) {
3092+ return;
3093+ }
26993094 event.preventDefault();
2700− void addComposerImages(imageFiles);
3095+ void addComposerAttachments(files);
27013096 };
27023097
27033098 const insertComposerTextAtEnd = (
@@ -2823,7 +3218,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 119 unmodified lines
28233218 composerEditorRef.current?.focusAt(cursor);
28243219 },
28253220 addDroppedFiles: (files: File[]) => {
2826− void addComposerImages(files);
3221+ void addComposerAttachments(files);
28273222 focusComposer();
28283223 },
28293224 insertTextAtEnd: insertComposerTextAtEnd,
@@ -2894,6 +3289,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 64 unmodified lines
28943289 getSendContext: () => ({
28953290 prompt: promptRef.current,
28963291 images: composerImagesRef.current,
3292+ files: composerFilesRef.current,
28973293 terminalContexts: composerTerminalContextsRef.current,
28983294 elementContexts: composerElementContextsRef.current,
28993295 previewAnnotations: composerPreviewAnnotations,
@@ -2919,13 +3315,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 19 unmodified lines
29193315 }),
29203316 [
29213317 activeThread,
2922− addComposerImages,
3318+ addComposerAttachments,
29233319 composerDraftTarget,
29243320 composerCursor,
29253321 composerTerminalContexts,
29263322 insertComposerDraftTerminalContext,
29273323 promptRef,
29283324 composerImagesRef,
3325+ composerFilesRef,
29293326 composerTerminalContextsRef,
29303327 composerElementContextsRef,
29313328 composerPreviewAnnotations,
@@ -3241,7 +3638,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 309 unmodified lines
32413638 ? {
32423639 uploadsByImageId,
32433640 onRetryUpload: (image: ComposerImageAttachment) =>
3244− retryAttachmentUpload({ environmentId, image }),
3641+ retryAttachmentUpload({
3642+ environmentId,
3643+ image,
3644+ draftTarget: composerDraftTarget,
3645+ }),
32453646 }
32463647 : {})}
32473648 onRemove={(annotationId) => {
@@ -3367,7 +3768,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 119 unmodified lines
33673768 size="icon-xs"
33683769 className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95"
33693770 onClick={() =>
3370− retryAttachmentUpload({ environmentId, image })
3771+ retryAttachmentUpload({
3772+ environmentId,
3773+ image,
3774+ draftTarget: composerDraftTarget,
3775+ })
33713776 }
33723777 aria-label={`Retry upload for ${image.name}`}
33733778 />
@@ -3398,6 +3803,78 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 24 unmodified lines
33983803 </div>
33993804 )}
34003805
3806+ {!isComposerCollapsedMobile &&
3807+ !isComposerApprovalState &&
3808+ pendingUserInputs.length === 0 &&
3809+ composerFiles.length > 0 && (
3810+ <div className="mb-3 flex flex-col gap-1">
3811+ {composerFiles.map((file) => {
3812+ const fileCanUpload =
3813+ supportsAttachmentUploads &&
3814+ maxFileAttachmentBytes !== null &&
3815+ file.sizeBytes <= maxFileAttachmentBytes;
3816+ const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined;
3817+ const needsReattach = composerFileNeedsReattach(file);
3818+ const canReattachFile =
3819+ fileStagingLimit !== null && file.sizeBytes <= fileStagingLimit;
3820+ return (
3821+ <div
3822+ key={file.id}
3823+ className="flex min-w-0 items-center gap-2 py-1 text-sm text-foreground"
3824+ >
3825+ <FileIcon className="size-4 shrink-0 text-secondary-label" />
3826+ <span className="min-w-0 flex-1 truncate">{file.name}</span>
3827+ <span className="shrink-0 text-xs text-secondary-label">
3828+ {needsReattach
3829+ ? canReattachFile
3830+ ? "Attach again"
3831+ : "Remove to send"
3832+ : upload?.status === "uploading"
3833+ ? formatAttachmentUploadProgress(upload.progress)
3834+ : formatAttachmentSize(file.sizeBytes)}
3835+ </span>
3836+ {!needsReattach && upload?.status === "failed" ? (
3837+ <Tooltip>
3838+ <TooltipTrigger
3839+ render={
3840+ <Button
3841+ variant="ghost"
3842+ size="icon-xs"
3843+ onClick={() =>
3844+ retryAttachmentUpload({
3845+ environmentId,
3846+ image: file,
3847+ draftTarget: composerDraftTarget,
3848+ })
3849+ }
3850+ aria-label={`Retry upload for ${file.name}`}
3851+ />
3852+ }
3853+ >
3854+ <RotateCcwIcon />
3855+ </TooltipTrigger>
3856+ <TooltipPopup
3857+ side="top"
3858+ className="max-w-64 whitespace-normal leading-tight"
3859+ >
3860+ {upload.reason}
3861+ </TooltipPopup>
3862+ </Tooltip>
3863+ ) : null}
3864+ <Button
3865+ variant="ghost"
3866+ size="icon-xs"
3867+ onClick={() => removeComposerFileFromDraft(file.id)}
3868+ aria-label={`Remove ${file.name}`}
3869+ >
3870+ <XIcon />
3871+ </Button>
3872+ </div>
3873+ );
3874+ })}
3875+ </div>
3876+ )}
3877+
34013878 <div className="relative">
34023879 <ComposerPromptEditor
34033880 editorRef={composerEditorRef}
@@ -3568,6 +4045,39 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 164 unmodified lines
35684045 }
35694046 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
35704047 >
4048+ {fileStagingLimit !== null && pendingUserInputs.length === 0 ? (
4049+ <>
4050+ <input
4051+ ref={attachmentInputRef}
4052+ type="file"
4053+ multiple
4054+ className="hidden"
4055+ onChange={(event) => {
4056+ const files = Array.from(event.currentTarget.files ?? []);
4057+ event.currentTarget.value = "";
4058+ void addComposerAttachments(files);
4059+ focusComposer();
4060+ }}
4061+ />
4062+ <Tooltip>
4063+ <TooltipTrigger
4064+ render={
4065+ <Button
4066+ type="button"
4067+ variant="ghost"
4068+ size="icon-sm"
4069+ onPointerDown={(event) => event.preventDefault()}
4070+ onClick={() => attachmentInputRef.current?.click()}
4071+ aria-label="Attach files"
4072+ />
4073+ }
4074+ >
4075+ <PaperclipIcon />
4076+ </TooltipTrigger>
4077+ <TooltipPopup>Attach files</TooltipPopup>
4078+ </Tooltip>
4079+ </>
4080+ ) : null}
35714081 {showMobilePendingAnswerActions ? null : inlineTasksBadge}
35724082 {showMobilePendingAnswerActions ? null : inlineStashBadge}
35734083 <ComposerFooterPrimaryActions
apps/web/src/components/chat/ComposerStashMenu.test.tsx+42−0
@@ -1,4 +1,5 @@
11 import { renderToStaticMarkup } from "react-dom/server";
2+import { EnvironmentId } from "@t3tools/contracts";
23 import { describe, expect, it } from "vite-plus/test";
34
45 import { ComposerStashMenu } from "./ComposerStashMenu";
@@ -89,4 +90,45 @@ describe("ComposerStashMenu", () => { · 84 unmodified lines
8990 expect(markup).toContain("size-3.5 stroke-2");
9091 expect(markup).not.toContain("bg-background/90");
9192 });
93+
94+ it("labels mixed file and image stashes without treating images as files", () => {
95+ const markup = renderToStaticMarkup(
96+ <ComposerStashMenu
97+ entries={[
98+ {
99+ id: "mixed-attachments",
100+ createdAt: new Date(0).toISOString(),
101+ prompt: "",
102+ attachments: [
103+ {
104+ id: "image-one",
105+ name: "before.png",
106+ mimeType: "image/png",
107+ sizeBytes: 128,
108+ dataUrl: "data:image/png;base64,AA==",
109+ },
110+ ],
111+ files: [
112+ {
113+ id: "file-one",
114+ name: "report.pdf",
115+ mimeType: "application/pdf",
116+ sizeBytes: 42,
117+ attachmentId: "pending-report-pdf",
118+ environmentId: EnvironmentId.make("environment-1"),
119+ },
120+ ],
121+ droppedImageNames: [],
122+ },
123+ ]}
124+ onRestore={() => {}}
125+ onDelete={() => {}}
126+ onClose={() => {}}
127+ />,
128+ );
129+
130+ expect(markup).toContain("(2 attachments)");
131+ expect(markup).toContain("size-3.5 text-secondary-label");
132+ expect(markup).not.toContain("(2 files)");
133+ });
92134 });
apps/web/src/components/chat/ComposerStashMenu.tsx+14−2
@@ -1,4 +1,4 @@
1−import { XIcon } from "lucide-react";
1+import { FileIcon, XIcon } from "lucide-react";
22 import { memo, useEffect, useRef, useState } from "react";
33
44 import { formatRelativeTimeLabel } from "../../timestampFormat";
@@ -20,7 +20,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string { · 15 unmodified lines
2020 return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed;
2121 }
2222 const imageCount = entry.attachments.length + entry.droppedImageNames.length;
23− return imageCount > 0 ? `(${imageCount} image${imageCount === 1 ? "" : "s"})` : "(empty)";
23+ const fileCount = entry.files?.length ?? 0;
24+ const attachmentCount = imageCount + fileCount;
25+ if (attachmentCount === 0) {
26+ return "(empty)";
27+ }
28+ const label = imageCount > 0 && fileCount > 0 ? "attachment" : fileCount > 0 ? "file" : "image";
29+ return `(${attachmentCount} ${label}${attachmentCount === 1 ? "" : "s"})`;
2430 }
2531
2632 /**
@@ -180,6 +186,12 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { · 153 unmodified lines
180186 ))}
181187 </span>
182188 ) : null}
189+ {(entry.files?.length ?? 0) > 0 ? (
190+ <span className="flex shrink-0 items-center gap-1 text-secondary-label text-xs">
191+ <FileIcon className="size-3.5 text-secondary-label" />
192+ {entry.files!.length}
193+ </span>
194+ ) : null}
183195 <span className="shrink-0 text-secondary-label text-xs max-sm:hidden">
184196 {formatRelativeTimeLabel(entry.createdAt)}
185197 </span>
apps/web/src/components/chat/MessagesTimeline.test.tsx+110−0
@@ -558,6 +558,116 @@ describe("MessagesTimeline", () => { · 557 unmodified lines
558558 expect(onAnchorReady).not.toHaveBeenCalled();
559559 });
560560
561+ it("renders generic attachments as download links instead of image previews", () => {
562+ const entry = {
563+ ...buildUserTimelineEntry("Read the report."),
564+ message: {
565+ ...buildUserTimelineEntry("Read the report.").message,
566+ attachments: [
567+ {
568+ type: "file" as const,
569+ id: "attachment-report-pdf",
570+ name: "report.pdf",
571+ mimeType: "application/pdf",
572+ sizeBytes: 42,
573+ previewUrl: "https://environment.test/api/assets/report.pdf",
574+ },
575+ ],
576+ },
577+ };
578+
579+ const markup = renderToStaticMarkup(
580+ <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
581+ );
582+
583+ expect(markup).toContain(
584+ '<a href="https://environment.test/api/assets/report.pdf" download="report.pdf" class="flex min-w-0 items-center gap-2 rounded-md py-1 text-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70">',
585+ );
586+ expect(markup).not.toContain('alt="report.pdf"');
587+ });
588+
589+ it("renders a file download button without creating its URL in advance", () => {
590+ const entry = {
591+ ...buildUserTimelineEntry("Read the report."),
592+ message: {
593+ ...buildUserTimelineEntry("Read the report.").message,
594+ attachments: [
595+ {
596+ type: "file" as const,
597+ id: "attachment-report-pdf",
598+ name: "report.pdf",
599+ mimeType: "application/pdf",
600+ sizeBytes: 42,
601+ },
602+ ],
603+ },
604+ };
605+
606+ const markup = renderToStaticMarkup(
607+ <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
608+ );
609+
610+ expect(markup).toContain(
611+ '<button type="button" aria-label="Download report.pdf" class="flex min-w-0 cursor-pointer items-center gap-2 rounded-md py-1 text-left text-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70">',
612+ );
613+ expect(markup).not.toContain("href=");
614+ });
615+
616+ it("does not download an optimistic file before the server supplies its attachment ID", () => {
617+ const entry = {
618+ ...buildUserTimelineEntry("Read the report."),
619+ message: {
620+ ...buildUserTimelineEntry("Read the report.").message,
621+ attachments: [
622+ {
623+ type: "file" as const,
624+ id: "composer-local-report",
625+ name: "report.pdf",
626+ mimeType: "application/pdf",
627+ sizeBytes: 42,
628+ downloadable: false,
629+ },
630+ ],
631+ },
632+ };
633+
634+ const markup = renderToStaticMarkup(
635+ <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
636+ );
637+
638+ expect(markup).toContain("report.pdf");
639+ expect(markup).not.toContain('aria-label="Download report.pdf"');
640+ });
641+
642+ it("renders unknown attachment types as inert rows instead of crashing", () => {
643+ const entry = {
644+ ...buildUserTimelineEntry("Play the recording."),
645+ message: {
646+ ...buildUserTimelineEntry("Play the recording.").message,
647+ attachments: [
648+ {
649+ // A newer server can introduce attachment types this build does
650+ // not know. They ride the open contract member.
651+ type: "recording",
652+ id: "attachment-voice-memo",
653+ name: "voice-memo.ogg",
654+ mimeType: "audio/ogg",
655+ sizeBytes: 42,
656+ },
657+ ],
658+ },
659+ };
660+
661+ const markup = renderToStaticMarkup(
662+ <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
663+ );
664+
665+ expect(markup).toContain("voice-memo.ogg");
666+ expect(markup).not.toContain('aria-label="Download voice-memo.ogg"');
667+ expect(markup).not.toContain('alt="voice-memo.ogg"');
668+ expect(markup).not.toContain("href=");
669+ });
670+
561671 it("keeps reserved end space when tool work starts while reading history", () => {
562672 const turnId = TurnId.make("turn-with-active-tool");
563673 const firstEntry = buildUserTimelineEntry("Run the command.");
apps/web/src/components/chat/MessagesTimeline.tsx+67−2
@@ -1,4 +1,5 @@
11 import {
2+ type ChatFileAttachment,
23 type EnvironmentId,
34 type MessageId,
45 type ScopedThreadRef,
@@ -14,6 +15,7 @@ import { · 9 unmodified lines
1415
1516 const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();
1617 const NOOP_OPEN_AGENTS = () => {};
18+const NOOP_DOWNLOAD_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
1719 import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
1820 import {
1921 createContext,
@@ -38,7 +40,12 @@ import { · 18 unmodified lines
3840 workEntrySignalsSevereFailure,
3941 workLogEntryIsToolLike,
4042 } from "../../session-logic";
41−import { type ChatImageAttachment, isImageAttachment, type TurnDiffSummary } from "../../types";
43+import {
44+ type ChatImageAttachment,
45+ isFileAttachment,
46+ isImageAttachment,
47+ type TurnDiffSummary,
48+} from "../../types";
4249 import {
4350 getRenderablePatch,
4451 resolveDiffThemeName,
@@ -51,7 +58,9 @@ import { · 6 unmodified lines
5158 ChevronDownIcon,
5259 ChevronRightIcon,
5360 CircleAlertIcon,
61+ DownloadIcon,
5462 EyeIcon,
63+ FileIcon,
5564 GlobeIcon,
5665 HammerIcon,
5766 MessageCircleIcon,
@@ -143,6 +152,7 @@ interface TimelineRowSharedState { · 85 unmodified lines
143152 activeThreadEnvironmentId: EnvironmentId;
144153 onRevertUserMessage: (messageId: MessageId) => void;
145154 onImageExpand: (preview: ExpandedImagePreview) => void;
155+ onFileDownload: (attachment: ChatFileAttachment) => void;
146156 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
147157 onToggleTurnFold: (turnId: TurnId) => void;
148158 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
@@ -221,6 +231,7 @@ interface MessagesTimelineProps { · 72 unmodified lines
221231 onRevertUserMessage: (messageId: MessageId) => void;
222232 isRevertingCheckpoint: boolean;
223233 onImageExpand: (preview: ExpandedImagePreview) => void;
234+ onFileDownload?: (attachment: ChatFileAttachment) => void;
224235 activeThreadEnvironmentId: EnvironmentId;
225236 markdownCwd: string | undefined;
226237 resolvedTheme: "light" | "dark";
@@ -266,6 +277,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 39 unmodified lines
266277 onRevertUserMessage,
267278 isRevertingCheckpoint,
268279 onImageExpand,
280+ onFileDownload = NOOP_DOWNLOAD_ATTACHMENT,
269281 activeThreadEnvironmentId,
270282 markdownCwd,
271283 resolvedTheme,
@@ -524,6 +536,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 252 unmodified lines
524536 activeThreadEnvironmentId,
525537 onRevertUserMessage,
526538 onImageExpand,
539+ onFileDownload,
527540 onOpenTurnDiff,
528541 onToggleTurnFold,
529542 onToggleWorkGroup,
@@ -540,6 +553,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 10 unmodified lines
540553 activeThreadEnvironmentId,
541554 onRevertUserMessage,
542555 onImageExpand,
556+ onFileDownload,
543557 onOpenTurnDiff,
544558 onToggleTurnFold,
545559 onToggleWorkGroup,
@@ -986,7 +1000,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 440 unmodified lines
9861000
9871001 function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
9881002 const ctx = use(TimelineRowCtx);
1003+ // The attachment union has an open member, so guards (not literal type
1004+ // comparisons) split it. Unknown types render as inert rows below the files.
9891005 const userImages = (row.message.attachments ?? []).filter(isImageAttachment);
1006+ const userFiles = (row.message.attachments ?? []).filter(isFileAttachment);
1007+ const unknownAttachments = (row.message.attachments ?? []).filter(
1008+ (attachment) => !isImageAttachment(attachment) && !isFileAttachment(attachment),
1009+ );
9901010 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);
9911011 const terminalContexts = displayedUserMessage.contexts;
9921012 const previewAnnotations: ParsedPreviewAnnotation[] = [];
@@ -1011,7 +1031,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" · 18 unmodified lines
10111031 <div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground">
10121032 {regularImages.length > 0 && (
10131033 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
1014− {regularImages.map((image: ChatImageAttachment) => (
1034+ {regularImages.map((image) => (
10151035 <div
10161036 key={image.id}
10171037 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
@@ -1049,6 +1069,51 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" · 31 unmodified lines
10491069 image={previewImages[index] ?? null}
10501070 />
10511071 ))}
1072+ {userFiles.length > 0 || unknownAttachments.length > 0 ? (
1073+ <div className="mb-2 flex flex-col gap-1">
1074+ {userFiles.map((file) => {
1075+ const content = (
1076+ <>
1077+ <FileIcon className="size-4 shrink-0 text-secondary-label" />
1078+ <span className="min-w-0 flex-1 truncate">{file.name}</span>
1079+ {file.downloadable === false ? null : (
1080+ <DownloadIcon className="size-4 shrink-0" />
1081+ )}
1082+ </>
1083+ );
1084+ return file.previewUrl ? (
1085+ <a
1086+ key={file.id}
1087+ href={file.previewUrl}
1088+ download={file.name}
1089+ className="flex min-w-0 items-center gap-2 rounded-md py-1 text-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1090+ >
1091+ {content}
1092+ </a>
1093+ ) : file.downloadable === false ? (
1094+ <div key={file.id} className="flex min-w-0 items-center gap-2 py-1 text-sm">
1095+ {content}
1096+ </div>
1097+ ) : (
1098+ <button
1099+ key={file.id}
1100+ type="button"
1101+ aria-label={`Download ${file.name}`}
1102+ onClick={() => ctx.onFileDownload(file)}
1103+ className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md py-1 text-left text-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1104+ >
1105+ {content}
1106+ </button>
1107+ );
1108+ })}
1109+ {unknownAttachments.map((attachment) => (
1110+ <div key={attachment.id} className="flex min-w-0 items-center gap-2 py-1 text-sm">
1111+ <FileIcon className="size-4 shrink-0 text-secondary-label" />
1112+ <span className="min-w-0 flex-1 truncate">{attachment.name}</span>
1113+ </div>
1114+ ))}
1115+ </div>
1116+ ) : null}
10521117 {elementContexts.length > 0 ? (
10531118 <div className="mb-2 flex flex-wrap gap-1.5">
10541119 {elementContexts.map((context) => (
apps/web/src/components/chat/composerAttachmentFiles.test.ts+280−0
@@ -0,0 +1,280 @@
1+import { EnvironmentId, PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contracts";
2+import { describe, expect, it } from "vite-plus/test";
3+
4+import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore";
5+import {
6+ attachmentsToReleaseOnUploadCapabilityLoss,
7+ classifyComposerAttachmentFile,
8+ fileAttachmentCapabilityBlockReason,
9+ fileAttachmentStagingLimit,
10+ inferImageMimeTypeFromName,
11+ normalizeComposerImageFileMimeType,
12+ shouldHandleComposerAttachmentPaste,
13+} from "./composerAttachmentFiles";
14+
15+describe("composer attachment files", () => {
16+ it("keeps supported images and HEIC photos on the image path", () => {
17+ expect(classifyComposerAttachmentFile({ name: "photo.png", type: "image/png" })).toBe("image");
18+ expect(classifyComposerAttachmentFile({ name: "photo.heic", type: "" })).toBe("image");
19+ });
20+
21+ it("rejects unsupported image types instead of attaching them as generic files", () => {
22+ expect(classifyComposerAttachmentFile({ name: "diagram.svg", type: "image/svg+xml" })).toBe(
23+ "unsupported-image",
24+ );
25+ expect(classifyComposerAttachmentFile({ name: "photo.tiff", type: "image/tiff" })).toBe(
26+ "unsupported-image",
27+ );
28+ expect(classifyComposerAttachmentFile({ name: "report.pdf", type: "application/pdf" })).toBe(
29+ "file",
30+ );
31+ });
32+
33+ it("preserves text paste when an application adds a synthetic generic file", () => {
34+ const file = new File(["clipboard"], "clipboard.rtf", { type: "application/rtf" });
35+
36+ expect(
37+ shouldHandleComposerAttachmentPaste({
38+ files: [file],
39+ plainText: "Copied text",
40+ }),
41+ ).toBe(false);
42+ });
43+
44+ it("claims unsupported image pastes so the composer can report them", () => {
45+ const images = [
46+ new File(["svg"], "diagram.svg", { type: "image/svg+xml" }),
47+ new File(["tiff"], "photo.tiff", { type: "image/tiff" }),
48+ ];
49+
50+ for (const image of images) {
51+ expect(
52+ shouldHandleComposerAttachmentPaste({
53+ files: [image],
54+ plainText: "Image caption",
55+ }),
56+ ).toBe(true);
57+ }
58+ });
59+
60+ it("claims generic file-only pastes so the composer can report validation errors", () => {
61+ const file = new File(["report"], "report.pdf", { type: "application/pdf" });
62+
63+ expect(shouldHandleComposerAttachmentPaste({ files: [file], plainText: "" })).toBe(true);
64+ });
65+
66+ it("routes empty and oversized generic files to composer feedback", () => {
67+ const empty = new File([], "empty.txt", { type: "text/plain" });
68+ const oversized = new File([new Uint8Array(1024)], "large.zip", {
69+ type: "application/zip",
70+ });
71+
72+ expect(shouldHandleComposerAttachmentPaste({ files: [empty], plainText: "" })).toBe(true);
73+ expect(shouldHandleComposerAttachmentPaste({ files: [oversized], plainText: "" })).toBe(true);
74+ });
75+
76+ it("ignores an empty clipboard", () => {
77+ expect(shouldHandleComposerAttachmentPaste({ files: [], plainText: "" })).toBe(false);
78+ });
79+
80+ it("falls back to the extension when an image arrives without a MIME type", () => {
81+ expect(classifyComposerAttachmentFile({ name: "photo.jpg", type: "" })).toBe("image");
82+ expect(classifyComposerAttachmentFile({ name: "shot.PNG", type: "" })).toBe("image");
83+ expect(classifyComposerAttachmentFile({ name: "archive.zip", type: "" })).toBe("file");
84+ expect(classifyComposerAttachmentFile({ name: "no-extension", type: "" })).toBe("file");
85+ expect(inferImageMimeTypeFromName("photo.jpg")).toBe("image/jpeg");
86+ expect(inferImageMimeTypeFromName("archive.zip")).toBeNull();
87+ });
88+
89+ it("infers supported image types from octet-stream files", () => {
90+ const jpeg = new File(["jpeg"], "photo.jpg", { type: "application/octet-stream" });
91+ const png = new File(["png"], "shot.PNG", { type: "application/octet-stream" });
92+
93+ expect(classifyComposerAttachmentFile(jpeg)).toBe("image");
94+ expect(classifyComposerAttachmentFile(png)).toBe("image");
95+ expect(normalizeComposerImageFileMimeType(jpeg).type).toBe("image/jpeg");
96+ expect(normalizeComposerImageFileMimeType(png).type).toBe("image/png");
97+ });
98+
99+ it("does not infer images for unknown extensions or specific conflicting MIME types", () => {
100+ const binary = new File(["binary"], "archive.bin", { type: "application/octet-stream" });
101+ const unknownDocument = new File(["pdf"], "report.pdf", {
102+ type: "application/octet-stream",
103+ });
104+ const document = new File(["pdf"], "photo.jpg", { type: "application/pdf" });
105+ const explicitImage = new File(["png"], "photo.jpg", { type: "image/png" });
106+
107+ expect(classifyComposerAttachmentFile(binary)).toBe("file");
108+ expect(classifyComposerAttachmentFile(unknownDocument)).toBe("file");
109+ expect(classifyComposerAttachmentFile(document)).toBe("file");
110+ expect(classifyComposerAttachmentFile(explicitImage)).toBe("image");
111+ expect(normalizeComposerImageFileMimeType(binary)).toBe(binary);
112+ expect(normalizeComposerImageFileMimeType(document)).toBe(document);
113+ expect(normalizeComposerImageFileMimeType(explicitImage)).toBe(explicitImage);
114+ });
115+
116+ it("uses the hard local limit while server config is unknown", () => {
117+ expect(
118+ fileAttachmentStagingLimit({
119+ attachmentUploadsCapabilityKnown: false,
120+ supportsAttachmentUploads: false,
121+ maxFileAttachmentBytes: null,
122+ }),
123+ ).toBe(PROVIDER_SEND_TURN_MAX_FILE_BYTES);
124+ expect(
125+ fileAttachmentCapabilityBlockReason({
126+ files: [{ name: "pending.zip", sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }],
127+ attachmentUploadsCapabilityKnown: false,
128+ supportsAttachmentUploads: false,
129+ maxFileAttachmentBytes: null,
130+ }),
131+ ).toBe("Waiting for the server before file attachments can send");
132+ });
133+
134+ it("rejects local staging and send when known config has no file support", () => {
135+ const unsupportedReason =
136+ "This server does not accept file attachments right now. Remove the files to send.";
137+ expect(
138+ fileAttachmentStagingLimit({
139+ attachmentUploadsCapabilityKnown: true,
140+ supportsAttachmentUploads: true,
141+ maxFileAttachmentBytes: null,
142+ }),
143+ ).toBeNull();
144+ expect(
145+ fileAttachmentStagingLimit({
146+ attachmentUploadsCapabilityKnown: true,
147+ supportsAttachmentUploads: false,
148+ maxFileAttachmentBytes: 50 * 1024 * 1024,
149+ }),
150+ ).toBeNull();
151+ expect(
152+ fileAttachmentCapabilityBlockReason({
153+ files: [{ name: "report.pdf", sizeBytes: 1024 }],
154+ attachmentUploadsCapabilityKnown: true,
155+ supportsAttachmentUploads: true,
156+ maxFileAttachmentBytes: null,
157+ }),
158+ ).toBe(unsupportedReason);
159+ expect(
160+ fileAttachmentCapabilityBlockReason({
161+ files: [{ name: "report.pdf", sizeBytes: 1024 }],
162+ attachmentUploadsCapabilityKnown: true,
163+ supportsAttachmentUploads: false,
164+ maxFileAttachmentBytes: 50 * 1024 * 1024,
165+ }),
166+ ).toBe(unsupportedReason);
167+ });
168+
169+ it("blocks retained files that exceed a newly lower server limit", () => {
170+ expect(
171+ fileAttachmentCapabilityBlockReason({
172+ files: [{ name: "large.zip", sizeBytes: 2 * 1024 * 1024 }],
173+ attachmentUploadsCapabilityKnown: true,
174+ supportsAttachmentUploads: true,
175+ maxFileAttachmentBytes: 1024 * 1024,
176+ }),
177+ ).toBe("'large.zip' exceeds the 1 MB attachment limit.");
178+ });
179+
180+ it("uses the confirmed server limit without exceeding the hard cap", () => {
181+ expect(
182+ fileAttachmentStagingLimit({
183+ attachmentUploadsCapabilityKnown: true,
184+ supportsAttachmentUploads: true,
185+ maxFileAttachmentBytes: 1024 * 1024,
186+ }),
187+ ).toBe(1024 * 1024);
188+ expect(
189+ fileAttachmentStagingLimit({
190+ attachmentUploadsCapabilityKnown: true,
191+ supportsAttachmentUploads: true,
192+ maxFileAttachmentBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES * 2,
193+ }),
194+ ).toBe(PROVIDER_SEND_TURN_MAX_FILE_BYTES);
195+ expect(
196+ fileAttachmentCapabilityBlockReason({
197+ files: [{ name: "report.pdf", sizeBytes: 1024 }],
198+ attachmentUploadsCapabilityKnown: true,
199+ supportsAttachmentUploads: true,
200+ maxFileAttachmentBytes: 50 * 1024 * 1024,
201+ }),
202+ ).toBeNull();
203+ });
204+
205+ it("does not block empty or image-only composers on legacy servers", () => {
206+ expect(
207+ fileAttachmentCapabilityBlockReason({
208+ files: [],
209+ attachmentUploadsCapabilityKnown: true,
210+ supportsAttachmentUploads: false,
211+ maxFileAttachmentBytes: null,
212+ }),
213+ ).toBeNull();
214+ expect(
215+ fileAttachmentCapabilityBlockReason({
216+ files: [],
217+ attachmentUploadsCapabilityKnown: false,
218+ supportsAttachmentUploads: false,
219+ maxFileAttachmentBytes: null,
220+ }),
221+ ).toBeNull();
222+ });
223+
224+ it("keeps draft-persisted file uploads when the upload capability flips off", () => {
225+ const environmentId = EnvironmentId.make("environment-1");
226+ const image: ComposerImageAttachment = {
227+ type: "image",
228+ id: "image-1",
229+ name: "photo.png",
230+ mimeType: "image/png",
231+ sizeBytes: 3,
232+ previewUrl: "blob:photo",
233+ file: new File([new Uint8Array([1, 2, 3])], "photo.png", { type: "image/png" }),
234+ };
235+ const uploadingFile: ComposerFileAttachment = {
236+ type: "file",
237+ id: "file-uploading",
238+ name: "fresh.pdf",
239+ mimeType: "application/pdf",
240+ sizeBytes: 3,
241+ file: new File([new Uint8Array([1, 2, 3])], "fresh.pdf", { type: "application/pdf" }),
242+ };
243+ const hydratedFile: ComposerFileAttachment = {
244+ type: "file",
245+ id: "file-hydrated",
246+ name: "report.pdf",
247+ mimeType: "application/pdf",
248+ sizeBytes: 3,
249+ file: null,
250+ uploadedAttachmentId: "pending-report-pdf",
251+ uploadEnvironmentId: environmentId,
252+ };
253+ const uploadedLocalFile: ComposerFileAttachment = {
254+ ...uploadingFile,
255+ id: "file-uploaded-local",
256+ uploadedAttachmentId: "pending-fresh-pdf",
257+ uploadEnvironmentId: environmentId,
258+ };
259+
260+ const released = attachmentsToReleaseOnUploadCapabilityLoss([
261+ image,
262+ uploadingFile,
263+ hydratedFile,
264+ uploadedLocalFile,
265+ ]);
266+
267+ expect(released.map((attachment) => attachment.id)).toEqual(["image-1", "file-uploading"]);
268+ });
269+
270+ it("claims image pastes even when clipboard text is present", () => {
271+ const image = new File(["image"], "photo.heic", { type: "image/heic" });
272+
273+ expect(
274+ shouldHandleComposerAttachmentPaste({
275+ files: [image],
276+ plainText: "Image caption",
277+ }),
278+ ).toBe(true);
279+ });
280+});
apps/web/src/components/chat/composerAttachmentFiles.ts+153−0
@@ -0,0 +1,153 @@
1+import {
2+ isProviderSendTurnSupportedImageMimeType,
3+ PROVIDER_SEND_TURN_MAX_FILE_BYTES,
4+} from "@t3tools/contracts";
5+import {
6+ clampFileAttachmentUploadBytes,
7+ fileAttachmentTooLargeMessage,
8+} from "@t3tools/client-runtime/state/attachments";
9+
10+import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore";
11+import { isHeicImageFile } from "../../lib/imageCompression";
12+
13+type ComposerAttachmentFileKind = "image" | "file" | "unsupported-image";
14+
15+interface FileAttachmentCapabilityState {
16+ readonly attachmentUploadsCapabilityKnown: boolean;
17+ readonly supportsAttachmentUploads: boolean;
18+ readonly maxFileAttachmentBytes: number | null;
19+}
20+
21+const IMAGE_MIME_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
22+ gif: "image/gif",
23+ jpeg: "image/jpeg",
24+ jpg: "image/jpeg",
25+ png: "image/png",
26+ webp: "image/webp",
27+};
28+
29+/**
30+ * Some sources (drags from other apps, files piped through a shell) hand over
31+ * a `File` with an empty or generic MIME type. Maps the extension to a
32+ * provider-supported image type so a plain `photo.jpg` still lands on the
33+ * image path; anything unrecognized stays a generic file.
34+ */
35+export function inferImageMimeTypeFromName(name: string): string | null {
36+ const dotIndex = name.lastIndexOf(".");
37+ if (dotIndex <= 0) {
38+ return null;
39+ }
40+ return IMAGE_MIME_TYPE_BY_EXTENSION[name.slice(dotIndex + 1).toLowerCase()] ?? null;
41+}
42+
43+function inferImageMimeTypeForUnknownFile(file: Pick<File, "name" | "type">): string | null {
44+ const mimeType = file.type.toLowerCase();
45+ if (mimeType !== "" && mimeType !== "application/octet-stream") {
46+ return null;
47+ }
48+ return inferImageMimeTypeFromName(file.name);
49+}
50+
51+/** Give extension-recognized images a concrete type before compression. */
52+export function normalizeComposerImageFileMimeType(file: File): File {
53+ const inferredMimeType = inferImageMimeTypeForUnknownFile(file);
54+ if (!inferredMimeType) {
55+ return file;
56+ }
57+ return new File([file], file.name, {
58+ type: inferredMimeType,
59+ lastModified: file.lastModified,
60+ });
61+}
62+
63+export function classifyComposerAttachmentFile(
64+ file: Pick<File, "name" | "type">,
65+): ComposerAttachmentFileKind {
66+ if (isHeicImageFile(file)) {
67+ return "image";
68+ }
69+ if (inferImageMimeTypeForUnknownFile(file)) {
70+ return "image";
71+ }
72+ if (!file.type.toLowerCase().startsWith("image/")) {
73+ return "file";
74+ }
75+ return isProviderSendTurnSupportedImageMimeType(file.type) ? "image" : "unsupported-image";
76+}
77+
78+/** Byte limit for adding a generic file to the local composer draft. */
79+export function fileAttachmentStagingLimit(input: FileAttachmentCapabilityState): number | null {
80+ if (!input.attachmentUploadsCapabilityKnown) {
81+ return PROVIDER_SEND_TURN_MAX_FILE_BYTES;
82+ }
83+ if (!input.supportsAttachmentUploads || input.maxFileAttachmentBytes === null) {
84+ return null;
85+ }
86+ return clampFileAttachmentUploadBytes(input.maxFileAttachmentBytes);
87+}
88+
89+/** Why retained generic files cannot send with the current server config. */
90+export function fileAttachmentCapabilityBlockReason(
91+ input: FileAttachmentCapabilityState & {
92+ readonly files: ReadonlyArray<{ readonly name: string; readonly sizeBytes: number }>;
93+ },
94+): string | null {
95+ if (input.files.length === 0) {
96+ return null;
97+ }
98+ if (!input.attachmentUploadsCapabilityKnown) {
99+ return "Waiting for the server before file attachments can send";
100+ }
101+ const maxFileAttachmentBytes = fileAttachmentStagingLimit(input);
102+ if (maxFileAttachmentBytes === null) {
103+ return "This server does not accept file attachments right now. Remove the files to send.";
104+ }
105+ const oversizedFile = input.files.find((file) => file.sizeBytes > maxFileAttachmentBytes);
106+ if (oversizedFile) {
107+ return fileAttachmentTooLargeMessage(oversizedFile.name, maxFileAttachmentBytes);
108+ }
109+ return null;
110+}
111+
112+/**
113+ * When `capabilities.attachmentUploads` flips off (reconnect, version skew),
114+ * tear down only uploads that have not been persisted onto a draft file.
115+ * Once `uploadedAttachmentId` is stamped, the draft references that server
116+ * copy after reload even if its local `File` is still available in memory.
117+ * Explicit attachment removal releases persisted uploads through
118+ * `releaseDraftAttachment`.
119+ */
120+export function attachmentsToReleaseOnUploadCapabilityLoss(
121+ attachments: ReadonlyArray<ComposerImageAttachment | ComposerFileAttachment>,
122+): Array<ComposerImageAttachment | ComposerFileAttachment> {
123+ return attachments.filter(
124+ (attachment) => !(attachment.type === "file" && attachment.uploadedAttachmentId !== undefined),
125+ );
126+}
127+
128+/**
129+ * Whether a paste's files should be claimed as composer attachments instead of
130+ * falling through to the default text paste. Deliberately no capacity or
131+ * pending-plan-question gate here: `addComposerAttachments` owns those limits
132+ * and reports them, while a gate at this layer would swallow the paste with no
133+ * feedback.
134+ */
135+export function shouldHandleComposerAttachmentPaste(input: {
136+ readonly files: ReadonlyArray<File>;
137+ readonly plainText: string;
138+}): boolean {
139+ if (
140+ input.files.some((file) => {
141+ const classification = classifyComposerAttachmentFile(file);
142+ return classification === "image" || classification === "unsupported-image";
143+ })
144+ ) {
145+ return true;
146+ }
147+
148+ if (input.plainText.length > 0) {
149+ return false;
150+ }
151+
152+ return input.files.some((file) => classifyComposerAttachmentFile(file) === "file");
153+}
apps/web/src/components/settings/ProjectSettingsPanel.tsx+5−2
@@ -6,7 +6,7 @@ import { · 5 unmodified lines
66 squashAtomCommandFailure,
77 type AtomCommandResult,
88 } from "@t3tools/client-runtime/state/runtime";
9−import { scopeProjectRef } from "@t3tools/client-runtime/environment";
9+import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
1010 import { AsyncResult } from "effect/unstable/reactivity";
1111 import {
1212 deriveProjectGroupingOverrideKey,
@@ -733,7 +733,10 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { · 720 unmodified lines
733733 return;
734734 }
735735 const projectRef = scopeProjectRef(member.environmentId, member.id);
736− releaseProjectDraftUploads(projectRef);
736+ releaseProjectDraftUploads(
737+ projectRef,
738+ memberThreads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)),
739+ );
737740 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);
738741 if (projectDraftThread) {
739742 draftStore.clearDraftThread(projectDraftThread.draftId);
apps/web/src/composerDraftStore.test.ts+483−0
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 ProjectId,
1212 ProviderDriverKind,
1313 ProviderInstanceId,
14+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1415 ThreadId,
1516 type ModelSelection,
1617 type ProviderOptionSelection,
@@ -65,7 +66,9 @@ import { · 48 unmodified lines
6566 markPromotedDraftThreadByRef,
6667 markPromotedDraftThreads,
6768 markPromotedDraftThreadsByRef,
69+ type ComposerFileAttachment,
6870 type ComposerImageAttachment,
71+ composerFileNeedsReattach,
6972 useComposerDraftStore,
7073 DraftId,
7174 } from "./composerDraftStore";
@@ -104,6 +107,18 @@ function makeImage(input: { · 32 unmodified lines
104107 };
105108 }
106109
110+function makeFile(id: string): ComposerFileAttachment {
111+ const file = new File(["report"], "report.pdf", { type: "application/pdf" });
112+ return {
113+ type: "file",
114+ id,
115+ name: file.name,
116+ mimeType: file.type,
117+ sizeBytes: file.size,
118+ file,
119+ };
120+}
121+
107122 function makeTerminalContext(input: {
108123 id: string;
109124 text?: string;
@@ -289,6 +304,280 @@ describe("composerDraftStore clearComposerContent", () => { · 179 unmodified lines
289304 });
290305 });
291306
307+describe("composerDraftStore file attachments", () => {
308+ const threadId = ThreadId.make("thread-files");
309+ const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
310+
311+ beforeEach(() => {
312+ resetComposerDraftStore();
313+ });
314+
315+ it("persists uploaded file references without including file contents", () => {
316+ const store = useComposerDraftStore.getState();
317+ store.addFiles(threadRef, [makeFile("file-1")]);
318+ store.setFileUpload(threadRef, "file-1", TEST_ENVIRONMENT_ID, "pending-report-pdf");
319+
320+ const persistApi = useComposerDraftStore.persist as unknown as {
321+ getOptions: () => {
322+ partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown;
323+ merge: (
324+ persistedState: unknown,
325+ currentState: ReturnType<typeof useComposerDraftStore.getState>,
326+ ) => ReturnType<typeof useComposerDraftStore.getState>;
327+ };
328+ };
329+ const options = persistApi.getOptions();
330+ const persisted = options.partialize(useComposerDraftStore.getState()) as {
331+ draftsByThreadKey: Record<string, { files?: Array<Record<string, unknown>> }>;
332+ };
333+
334+ expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual(
335+ [
336+ {
337+ id: "file-1",
338+ name: "report.pdf",
339+ mimeType: "application/pdf",
340+ sizeBytes: 6,
341+ attachmentId: "pending-report-pdf",
342+ environmentId: TEST_ENVIRONMENT_ID,
343+ },
344+ ],
345+ );
346+
347+ const hydrated = options.merge(persisted, useComposerDraftStore.getState());
348+ expect(hydrated.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual([
349+ {
350+ type: "file",
351+ id: "file-1",
352+ name: "report.pdf",
353+ mimeType: "application/pdf",
354+ sizeBytes: 6,
355+ file: null,
356+ uploadedAttachmentId: "pending-report-pdf",
357+ uploadEnvironmentId: TEST_ENVIRONMENT_ID,
358+ },
359+ ]);
360+ });
361+
362+ it("persists a pending file as a needs-reattach marker instead of dropping it", () => {
363+ const store = useComposerDraftStore.getState();
364+ // No setFileUpload: the upload never finished, so there is no attachment
365+ // id and the File handle cannot serialize.
366+ store.addFiles(threadRef, [makeFile("file-pending")]);
367+
368+ const persistApi = useComposerDraftStore.persist as unknown as {
369+ getOptions: () => {
370+ partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown;
371+ merge: (
372+ persistedState: unknown,
373+ currentState: ReturnType<typeof useComposerDraftStore.getState>,
374+ ) => ReturnType<typeof useComposerDraftStore.getState>;
375+ };
376+ };
377+ const options = persistApi.getOptions();
378+ const persisted = options.partialize(useComposerDraftStore.getState()) as {
379+ draftsByThreadKey: Record<string, { files?: Array<Record<string, unknown>> }>;
380+ };
381+
382+ expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual(
383+ [
384+ {
385+ id: "file-pending",
386+ name: "report.pdf",
387+ mimeType: "application/pdf",
388+ sizeBytes: 6,
389+ },
390+ ],
391+ );
392+
393+ const hydrated = options.merge(persisted, useComposerDraftStore.getState());
394+ const hydratedFiles =
395+ hydrated.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files;
396+ expect(hydratedFiles).toEqual([
397+ {
398+ type: "file",
399+ id: "file-pending",
400+ name: "report.pdf",
401+ mimeType: "application/pdf",
402+ sizeBytes: 6,
403+ file: null,
404+ },
405+ ]);
406+ expect(hydratedFiles?.every(composerFileNeedsReattach)).toBe(true);
407+ });
408+
409+ it("marks only the matching byte-less upload as missing", () => {
410+ const store = useComposerDraftStore.getState();
411+ const hydrated: ComposerFileAttachment = {
412+ ...makeFile("file-hydrated"),
413+ file: null,
414+ uploadedAttachmentId: "pending-old",
415+ uploadEnvironmentId: TEST_ENVIRONMENT_ID,
416+ };
417+ const local: ComposerFileAttachment = {
418+ ...makeFile("file-local"),
419+ name: "local.txt",
420+ mimeType: "text/plain",
421+ };
422+ store.addFiles(threadRef, [hydrated, local]);
423+ store.setFileUpload(threadRef, hydrated.id, TEST_ENVIRONMENT_ID, "pending-new");
424+ store.setFileUpload(threadRef, local.id, TEST_ENVIRONMENT_ID, "pending-local");
425+
426+ expect(
427+ store.markFileUploadMissing(threadRef, hydrated.id, OTHER_TEST_ENVIRONMENT_ID, "pending-new"),
428+ ).toBe(false);
429+ expect(
430+ store.markFileUploadMissing(threadRef, hydrated.id, TEST_ENVIRONMENT_ID, "pending-old"),
431+ ).toBe(false);
432+ expect(
433+ store.markFileUploadMissing(threadRef, local.id, TEST_ENVIRONMENT_ID, "pending-local"),
434+ ).toBe(false);
435+
436+ expect(store.getComposerDraft(threadRef)?.files).toMatchObject([
437+ {
438+ id: hydrated.id,
439+ uploadedAttachmentId: "pending-new",
440+ uploadEnvironmentId: TEST_ENVIRONMENT_ID,
441+ },
442+ {
443+ id: local.id,
444+ file: local.file,
445+ uploadedAttachmentId: "pending-local",
446+ uploadEnvironmentId: TEST_ENVIRONMENT_ID,
447+ },
448+ ]);
449+
450+ expect(
451+ store.markFileUploadMissing(threadRef, hydrated.id, TEST_ENVIRONMENT_ID, "pending-new"),
452+ ).toBe(true);
453+ const marker = store.getComposerDraft(threadRef)?.files[0];
454+ expect(marker && composerFileNeedsReattach(marker)).toBe(true);
455+ expect(marker?.uploadedAttachmentId).toBeUndefined();
456+ expect(marker?.uploadEnvironmentId).toBeUndefined();
457+ });
458+
459+ it("removes generic files when the composer is cleared", () => {
460+ const store = useComposerDraftStore.getState();
461+ store.addFiles(threadRef, [makeFile("file-clear")]);
462+
463+ store.clearComposerContent(threadRef);
464+
465+ expect(store.getComposerDraft(threadRef)).toBeNull();
466+ });
467+
468+ it("removes generic files when a prompt is moved into the stash", () => {
469+ const store = useComposerDraftStore.getState();
470+ store.setPrompt(threadRef, "Review the report");
471+ store.addFiles(threadRef, [makeFile("file-stash")]);
472+
473+ store.clearComposerPromptAndImages(threadRef);
474+
475+ expect(store.getComposerDraft(threadRef)).toBeNull();
476+ });
477+
478+ it("enforces the combined file and image limit across separate updates", () => {
479+ const store = useComposerDraftStore.getState();
480+ const images = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1 }, (_, index) =>
481+ makeImage({
482+ id: `image-${index}`,
483+ name: `image-${index}.png`,
484+ previewUrl: `blob:image-${index}`,
485+ }),
486+ );
487+ store.addImages(threadRef, images);
488+ store.addFiles(threadRef, [
489+ makeFile("file-accepted"),
490+ { ...makeFile("file-overflow"), name: "other.pdf" },
491+ ]);
492+ store.addImages(threadRef, [
493+ makeImage({ id: "image-overflow", name: "overflow.png", previewUrl: "blob:overflow" }),
494+ ]);
495+
496+ const draft = store.getComposerDraft(threadRef);
497+ expect(draft?.images).toHaveLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1);
498+ expect(draft?.files.map((file) => file.id)).toEqual(["file-accepted"]);
499+ });
500+
501+ it("replaces a needs-reattach marker when the same file is picked again", () => {
502+ const store = useComposerDraftStore.getState();
503+ // A hydrated marker: same metadata as the original pick, no bytes and no
504+ // server-side upload.
505+ const marker: ComposerFileAttachment = { ...makeFile("file-marker"), file: null };
506+ store.addFiles(threadRef, [marker]);
507+ expect(store.getComposerDraft(threadRef)?.files.every(composerFileNeedsReattach)).toBe(true);
508+
509+ // Following the "Attach again" instruction produces a fresh id with the
510+ // exact metadata the dedup key hashes.
511+ const repicked = makeFile("file-repicked");
512+ store.addFiles(threadRef, [repicked]);
513+
514+ const files = store.getComposerDraft(threadRef)?.files;
515+ expect(files?.map((file) => file.id)).toEqual(["file-repicked"]);
516+ expect(files?.[0]?.file).not.toBeNull();
517+ expect(files?.some(composerFileNeedsReattach)).toBe(false);
518+ });
519+
520+ it("replaces a needs-reattach marker with a stash-restored uploaded file", () => {
521+ const store = useComposerDraftStore.getState();
522+ const marker: ComposerFileAttachment = { ...makeFile("file-marker"), file: null };
523+ store.addFiles(threadRef, [marker]);
524+ expect(store.getComposerDraft(threadRef)?.files.every(composerFileNeedsReattach)).toBe(true);
525+
526+ // A stash restore carries a finished server-side upload instead of bytes.
527+ // Matching metadata must replace the marker, not be dropped as a
528+ // duplicate: the marker cannot send, and the restored ids are the only
529+ // valid copy.
530+ const restored: ComposerFileAttachment = {
531+ ...makeFile("file-restored"),
532+ file: null,
533+ uploadedAttachmentId: "pending-stash-pdf",
534+ uploadEnvironmentId: TEST_ENVIRONMENT_ID,
535+ };
536+ store.addFiles(threadRef, [restored]);
537+
538+ const files = store.getComposerDraft(threadRef)?.files;
539+ expect(files?.map((file) => file.id)).toEqual(["file-restored"]);
540+ expect(files?.[0]?.uploadedAttachmentId).toBe("pending-stash-pdf");
541+ expect(files?.[0]?.uploadEnvironmentId).toBe(TEST_ENVIRONMENT_ID);
542+ expect(files?.some(composerFileNeedsReattach)).toBe(false);
543+ });
544+
545+ it("still dedupes a re-pick against a file that does not need reattaching", () => {
546+ const store = useComposerDraftStore.getState();
547+ store.addFiles(threadRef, [makeFile("file-original")]);
548+
549+ store.addFiles(threadRef, [makeFile("file-duplicate")]);
550+
551+ expect(store.getComposerDraft(threadRef)?.files.map((file) => file.id)).toEqual([
552+ "file-original",
553+ ]);
554+ });
555+
556+ it("keeps the remaining file slot available after a duplicate is skipped", () => {
557+ const store = useComposerDraftStore.getState();
558+ store.addImages(
559+ threadRef,
560+ Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 2 }, (_, index) =>
561+ makeImage({
562+ id: `image-${index}`,
563+ name: `image-${index}.png`,
564+ previewUrl: `blob:image-${index}`,
565+ }),
566+ ),
567+ );
568+ store.addFiles(threadRef, [makeFile("file-original")]);
569+ store.addFiles(threadRef, [
570+ makeFile("file-duplicate"),
571+ { ...makeFile("file-unique"), name: "unique.pdf" },
572+ ]);
573+
574+ expect(store.getComposerDraft(threadRef)?.files.map((file) => file.id)).toEqual([
575+ "file-original",
576+ "file-unique",
577+ ]);
578+ });
579+});
580+
292581 describe("composerDraftStore moveComposerPromptAndImages", () => {
293582 const sourceDraftId = DraftId.make("draft-move-source");
294583 const destinationDraftId = DraftId.make("draft-move-destination");
@@ -335,6 +624,111 @@ describe("composerDraftStore moveComposerPromptAndImages", () => { · 40 unmodified lines
335624 expect(draftByKey(destinationDraftId)?.prompt).toBe(" explain this error");
336625 });
337626
627+ it("keeps hydrated file references on their original environment", () => {
628+ const sourceRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-file-source"));
629+ const destinationRef = scopeThreadRef(
630+ OTHER_TEST_ENVIRONMENT_ID,
631+ ThreadId.make("thread-file-destination"),
632+ );
633+ const store = useComposerDraftStore.getState();
634+ store.setPrompt(sourceRef, "review the report");
635+ store.addFiles(sourceRef, [
636+ {
637+ ...makeFile("file-hydrated"),
638+ file: null,
639+ uploadedAttachmentId: "pending-report-pdf",
640+ uploadEnvironmentId: TEST_ENVIRONMENT_ID,
641+ },
642+ ]);
643+
644+ store.moveComposerPromptAndImages(sourceRef, destinationRef);
645+
646+ expect(store.getComposerDraft(sourceRef)?.files.map((file) => file.id)).toEqual([
647+ "file-hydrated",
648+ ]);
649+ expect(store.getComposerDraft(destinationRef)?.files).toEqual([]);
650+ expect(store.getComposerDraft(destinationRef)?.prompt).toBe("review the report");
651+ });
652+
653+ it("moves files across environments when the original browser file remains available", () => {
654+ const sourceRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-file-source"));
655+ const destinationRef = scopeThreadRef(
656+ OTHER_TEST_ENVIRONMENT_ID,
657+ ThreadId.make("thread-file-destination"),
658+ );
659+ const store = useComposerDraftStore.getState();
660+ store.addFiles(sourceRef, [
661+ {
662+ ...makeFile("file-local"),
663+ uploadedAttachmentId: "pending-source-env",
664+ uploadEnvironmentId: TEST_ENVIRONMENT_ID,
665+ },
666+ ]);
667+
668+ store.moveComposerPromptAndImages(sourceRef, destinationRef);
669+
670+ expect(store.getComposerDraft(sourceRef)).toBeNull();
671+ const moved = store.getComposerDraft(destinationRef)?.files;
672+ expect(moved?.map((file) => file.id)).toEqual(["file-local"]);
673+ // The source-environment upload is unreachable from the destination; the
674+ // move drops it so the destination upload can mint its own.
675+ expect(moved?.[0]?.uploadedAttachmentId).toBeUndefined();
676+ expect(moved?.[0]?.uploadEnvironmentId).toBeUndefined();
677+ });
678+
679+ it("does not duplicate a file the destination already holds", () => {
680+ const sourceRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-dup-source"));
681+ const destinationRef = scopeThreadRef(
682+ TEST_ENVIRONMENT_ID,
683+ ThreadId.make("thread-dup-destination"),
684+ );
685+ const store = useComposerDraftStore.getState();
686+ // Same metadata key on both sides; the ids differ.
687+ store.addFiles(sourceRef, [makeFile("file-copy-a")]);
688+ store.addFiles(destinationRef, [makeFile("file-copy-b")]);
689+
690+ store.moveComposerPromptAndImages(sourceRef, destinationRef);
691+
692+ expect(store.getComposerDraft(destinationRef)?.files.map((file) => file.id)).toEqual([
693+ "file-copy-b",
694+ ]);
695+ expect(store.getComposerDraft(sourceRef)?.files.map((file) => file.id)).toEqual([
696+ "file-copy-a",
697+ ]);
698+ });
699+
700+ it("keeps overflow attachments on the source when the destination is nearly full", () => {
701+ const store = useComposerDraftStore.getState();
702+ store.addImages(
703+ destinationDraftId,
704+ Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1 }, (_, index) =>
705+ makeImage({
706+ id: `destination-${index}`,
707+ name: `destination-${index}.png`,
708+ previewUrl: `blob:destination-${index}`,
709+ }),
710+ ),
711+ );
712+ store.addImages(sourceDraftId, [
713+ makeImage({ id: "source-first", name: "first.png", previewUrl: "blob:first" }),
714+ makeImage({ id: "source-second", name: "second.png", previewUrl: "blob:second" }),
715+ ]);
716+ store.addFiles(sourceDraftId, [makeFile("source-file")]);
717+
718+ store.moveComposerPromptAndImages(sourceDraftId, destinationDraftId);
719+
720+ expect(store.getComposerDraft(destinationDraftId)?.images).toHaveLength(
721+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
722+ );
723+ expect(store.getComposerDraft(destinationDraftId)?.files).toEqual([]);
724+ expect(store.getComposerDraft(sourceDraftId)?.images.map((image) => image.id)).toEqual([
725+ "source-second",
726+ ]);
727+ expect(store.getComposerDraft(sourceDraftId)?.files.map((file) => file.id)).toEqual([
728+ "source-file",
729+ ]);
730+ });
731+
338732 it("is a no-op when source and destination are the same target", () => {
339733 const store = useComposerDraftStore.getState();
340734 store.setPrompt(sourceDraftId, "keep me");
@@ -1959,6 +2353,95 @@ describe("composerDraftStore model seed migration", () => { · 1618 unmodified lines
19592353 vi.useRealTimers();
19602354 }
19612355 });
2356+
2357+ it("keeps v8 file-only draft sessions and their seeded models", async () => {
2358+ vi.useFakeTimers();
2359+ try {
2360+ const uploadedDraftId = DraftId.make("draft-legacy-uploaded-file");
2361+ const markerDraftId = DraftId.make("draft-legacy-file-marker");
2362+ const uploadedThreadId = ThreadId.make("thread-legacy-uploaded-file");
2363+ const markerThreadId = ThreadId.make("thread-legacy-file-marker");
2364+ const staleSelection = modelSelection(CODEX_DRIVER, "gpt-5.4");
2365+ const storage = useComposerDraftStore.persist.getOptions().storage;
2366+ expect(storage).toBeDefined();
2367+ storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, {
2368+ version: 8,
2369+ state: {
2370+ draftsByThreadKey: {
2371+ [uploadedDraftId]: {
2372+ prompt: "",
2373+ attachments: [],
2374+ files: [
2375+ {
2376+ id: "file-uploaded",
2377+ name: "uploaded-report.pdf",
2378+ mimeType: "application/pdf",
2379+ sizeBytes: 128,
2380+ attachmentId: "attachment-uploaded",
2381+ environmentId: TEST_ENVIRONMENT_ID,
2382+ },
2383+ ],
2384+ modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2385+ activeProvider: CODEX_INSTANCE,
2386+ },
2387+ [markerDraftId]: {
2388+ prompt: "",
2389+ attachments: [],
2390+ files: [
2391+ {
2392+ id: "file-needs-reattach",
2393+ name: "local-notes.txt",
2394+ mimeType: "text/plain",
2395+ sizeBytes: 64,
2396+ },
2397+ ],
2398+ modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2399+ activeProvider: CODEX_INSTANCE,
2400+ runtimeMode: "approval-required",
2401+ },
2402+ },
2403+ draftThreadsByThreadKey: {
2404+ [uploadedDraftId]: draftThread(uploadedThreadId),
2405+ [markerDraftId]: draftThread(markerThreadId),
2406+ },
2407+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
2408+ stickyModelSelectionByProvider: {},
2409+ stickyActiveProvider: null,
2410+ },
2411+ } as never);
2412+ await vi.advanceTimersByTimeAsync(300);
2413+
2414+ await useComposerDraftStore.persist.rehydrate();
2415+
2416+ expect(draftByKey(uploadedDraftId)).toMatchObject({
2417+ files: [
2418+ {
2419+ id: "file-uploaded",
2420+ name: "uploaded-report.pdf",
2421+ uploadedAttachmentId: "attachment-uploaded",
2422+ uploadEnvironmentId: TEST_ENVIRONMENT_ID,
2423+ },
2424+ ],
2425+ modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2426+ activeProvider: CODEX_INSTANCE,
2427+ });
2428+ expect(draftByKey(markerDraftId)).toMatchObject({
2429+ files: [
2430+ {
2431+ id: "file-needs-reattach",
2432+ name: "local-notes.txt",
2433+ file: null,
2434+ },
2435+ ],
2436+ modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2437+ activeProvider: CODEX_INSTANCE,
2438+ runtimeMode: "approval-required",
2439+ });
2440+ expect(draftByKey(markerDraftId)?.files.every(composerFileNeedsReattach)).toBe(true);
2441+ } finally {
2442+ vi.useRealTimers();
2443+ }
2444+ });
19622445 });
19632446
19642447 describe("composerDraftStore provider-scoped option updates", () => {
apps/web/src/composerDraftStore.ts+354−19
@@ -2,13 +2,14 @@ import { · 1 unmodified line
22 DEFAULT_MODEL,
33 DEFAULT_MODEL_BY_PROVIDER,
44 defaultInstanceIdForDriver,
5− type EnvironmentId,
5+ EnvironmentId,
66 ModelSelection,
77 ProjectId,
88 ProviderInstanceId,
99 ProviderInteractionMode,
1010 ProviderDriverKind,
1111 ProviderOptionSelection,
12+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1213 PreviewAnnotationPayloadSchema,
1314 type PreviewAnnotationPayload,
1415 RuntimeMode,
@@ -33,7 +34,12 @@ import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model" · 18 unmodified lines
3334 import { useMemo } from "react";
3435 import { getLocalStorageItem } from "./hooks/useLocalStorage";
3536 import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection";
36−import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
37+import {
38+ DEFAULT_INTERACTION_MODE,
39+ DEFAULT_RUNTIME_MODE,
40+ type ChatFileAttachment,
41+ type ChatImageAttachment,
42+} from "./types";
3743 import {
3844 type TerminalContextDraft,
3945 ensureInlineTerminalContextPlaceholders,
@@ -93,6 +99,49 @@ export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "prev · 53 unmodified lines
9399 file: File;
94100 }
95101
102+export interface ComposerFileAttachment extends Omit<ChatFileAttachment, "previewUrl"> {
103+ file: File | null;
104+ uploadedAttachmentId?: string;
105+ uploadEnvironmentId?: EnvironmentId;
106+}
107+
108+/**
109+ * A hydrated draft file whose upload never finished before the reload has
110+ * neither bytes (`file` is only ever null after hydration) nor a server-side
111+ * upload. The composer renders it as a needs-reattach row: the user must
112+ * attach the file again or remove it before sending.
113+ */
114+export function composerFileNeedsReattach(file: ComposerFileAttachment): boolean {
115+ return file.file === null && file.uploadedAttachmentId === undefined;
116+}
117+
118+export const PersistedComposerFileAttachment = Schema.Struct({
119+ id: Schema.String,
120+ name: Schema.String,
121+ mimeType: Schema.String,
122+ sizeBytes: Schema.Number,
123+ attachmentId: Schema.String,
124+ environmentId: EnvironmentId,
125+});
126+export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type;
127+
128+/**
129+ * Draft-persisted file. Unlike a stash entry (which requires a finished
130+ * upload), a draft may hold a file whose upload never completed. Its `File`
131+ * handle cannot serialize, so it persists as a metadata-only marker (no
132+ * `attachmentId`) and hydrates as a needs-reattach row instead of vanishing.
133+ */
134+export const PersistedComposerDraftFileAttachment = Schema.Struct({
135+ id: Schema.String,
136+ name: Schema.String,
137+ mimeType: Schema.String,
138+ sizeBytes: Schema.Number,
139+ attachmentId: Schema.optionalKey(Schema.String),
140+ environmentId: Schema.optionalKey(EnvironmentId),
141+});
142+export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type;
143+const isPersistedComposerDraftFileAttachment = Schema.is(PersistedComposerDraftFileAttachment);
144+
96145 const PersistedTerminalContextDraft = Schema.Struct({
97146 id: Schema.String,
98147 threadId: ThreadId,
@@ -129,6 +178,7 @@ type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type; · 30 unmodified lines
129178 const PersistedComposerThreadDraftState = Schema.Struct({
130179 prompt: Schema.String,
131180 attachments: Schema.Array(PersistedComposerImageAttachment),
181+ files: Schema.optionalKey(Schema.Array(PersistedComposerDraftFileAttachment)),
132182 terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)),
133183 elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)),
134184 previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)),
@@ -255,6 +305,7 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ · 120 unmodified lines
255305 export interface ComposerThreadDraftState {
256306 prompt: string;
257307 images: ComposerImageAttachment[];
308+ files: ComposerFileAttachment[];
258309 nonPersistedImageIds: string[];
259310 persistedAttachments: PersistedComposerImageAttachment[];
260311 terminalContexts: TerminalContextDraft[];
@@ -305,6 +356,7 @@ export function composerDraftHasUserContent( · 44 unmodified lines
305356 return (
306357 draft.prompt.trim().length > 0 ||
307358 draft.images.length > 0 ||
359+ draft.files.length > 0 ||
308360 draft.persistedAttachments.length > 0 ||
309361 draft.terminalContexts.length > 0 ||
310362 draft.elementContexts.length > 0 ||
@@ -351,7 +403,7 @@ interface ProjectDraftSession extends DraftSessionState { · 40 unmodified lines
351403 * Raw `ThreadId` is intentionally excluded so callers cannot drop environment
352404 * identity for real threads.
353405 */
354−type ComposerThreadTarget = ScopedThreadRef | DraftId;
406+export type ComposerThreadTarget = ScopedThreadRef | DraftId;
355407
356408 /**
357409 * Persisted store for composer content plus draft-session metadata.
@@ -484,6 +536,20 @@ interface ComposerDraftStoreState { · 126 unmodified lines
484536 addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void;
485537 addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void;
486538 removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void;
539+ addFiles: (threadRef: ComposerThreadTarget, files: ComposerFileAttachment[]) => void;
540+ removeFile: (threadRef: ComposerThreadTarget, fileId: string) => void;
541+ setFileUpload: (
542+ threadRef: ComposerThreadTarget,
543+ fileId: string,
544+ environmentId: EnvironmentId,
545+ attachmentId: string,
546+ ) => void;
547+ markFileUploadMissing: (
548+ threadRef: ComposerThreadTarget,
549+ fileId: string,
550+ environmentId: EnvironmentId,
551+ attachmentId: string,
552+ ) => boolean;
487553 insertTerminalContext: (
488554 threadRef: ComposerThreadTarget,
489555 prompt: string,
@@ -534,19 +600,16 @@ interface ComposerDraftStoreState { · 44 unmodified lines
534600 ) => void;
535601 clearComposerContent: (threadRef: ComposerThreadTarget) => void;
536602 /**
537− * Clears only the prompt text and image attachments, preserving terminal /
603+ * Clears the prompt text and attachments, preserving terminal /
538604 * element contexts, preview annotations, and review comments. Used by the
539− * prompt stash, which can only round-trip text + images: clearing the
540− * session-bound contexts would destroy state nothing can restore.
605+ * prompt stash. Session-bound context stays in the source draft.
541606 */
542607 clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void;
543608 /**
544− * Moves the prompt text and image attachments from one composer target to
545− * another. Used when a draft changes project: the new project gets its own
546− * draft session and the typed content follows it. Session-bound extras
547− * (terminal / element contexts, preview annotations, review comments) stay
548− * on the source — they reference sessions of the source thread that the
549− * destination cannot use.
609+ * Moves prompt text and transferable attachments into another composer.
610+ * Attachments over the destination limit and uploaded files that belong to
611+ * another environment stay in the source draft. Terminal and element
612+ * context, preview annotations, and review comments also stay in the source.
550613 */
551614 moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void;
552615 }
@@ -615,6 +678,7 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftSt · 62 unmodified lines
615678 });
616679
617680 const EMPTY_IMAGES: ComposerImageAttachment[] = [];
681+const EMPTY_FILES: ComposerFileAttachment[] = [];
618682 const EMPTY_IDS: string[] = [];
619683 const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];
620684 const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = [];
@@ -622,6 +686,7 @@ const EMPTY_ELEMENT_CONTEXTS: ElementContextDraft[] = []; · 1 unmodified line
622686 const EMPTY_PREVIEW_ANNOTATIONS: PreviewAnnotationPayload[] = [];
623687 const EMPTY_REVIEW_COMMENTS: ReviewCommentContext[] = [];
624688 Object.freeze(EMPTY_IMAGES);
689+Object.freeze(EMPTY_FILES);
625690 Object.freeze(EMPTY_IDS);
626691 Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
627692 Object.freeze(EMPTY_ELEMENT_CONTEXTS);
@@ -637,6 +702,7 @@ const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>( · 9 unmodified lines
637702 const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({
638703 prompt: "",
639704 images: EMPTY_IMAGES,
705+ files: EMPTY_FILES,
640706 nonPersistedImageIds: EMPTY_IDS,
641707 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
642708 terminalContexts: EMPTY_TERMINAL_CONTEXTS,
@@ -659,6 +725,7 @@ export function createEmptyThreadDraft(): ComposerThreadDraftState { · 16 unmodified lines
659725 return {
660726 prompt: "",
661727 images: [],
728+ files: [],
662729 nonPersistedImageIds: [],
663730 persistedAttachments: [],
664731 terminalContexts: [],
@@ -733,6 +800,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { · 68 unmodified lines
733800 return (
734801 draft.prompt.length === 0 &&
735802 draft.images.length === 0 &&
803+ draft.files.length === 0 &&
736804 draft.persistedAttachments.length === 0 &&
737805 draft.terminalContexts.length === 0 &&
738806 draft.elementContexts.length === 0 &&
@@ -1263,7 +1331,7 @@ function logicalProjectDraftKey(logicalProjectKey: string): string { · 524 unmodified lines
12631331 * Draft sessions are keyed by `DraftId`. Real threads are keyed by
12641332 * `ScopedThreadRef` so environment identity is always preserved.
12651333 */
1266−function composerTargetKey(target: ScopedThreadRef | DraftId): string {
1334+export function composerTargetKey(target: ScopedThreadRef | DraftId): string {
12671335 if (typeof target === "string") {
12681336 return target.trim();
12691337 }
@@ -1717,6 +1785,9 @@ function normalizePersistedDraftsByThreadId( · 447 unmodified lines
17171785 return normalized ? [normalized] : [];
17181786 })
17191787 : [];
1788+ const files = Array.isArray(draftCandidate.files)
1789+ ? draftCandidate.files.filter(isPersistedComposerDraftFileAttachment)
1790+ : [];
17201791 const terminalContexts = Array.isArray(draftCandidate.terminalContexts)
17211792 ? draftCandidate.terminalContexts.flatMap((entry) => {
17221793 const normalized = normalizePersistedTerminalContextDraft(entry);
@@ -1796,6 +1867,7 @@ function normalizePersistedDraftsByThreadId( · 73 unmodified lines
17961867 if (
17971868 promptCandidate.length === 0 &&
17981869 attachments.length === 0 &&
1870+ files.length === 0 &&
17991871 terminalContexts.length === 0 &&
18001872 elementContexts.length === 0 &&
18011873 reviewComments.length === 0 &&
@@ -1820,6 +1892,7 @@ function normalizePersistedDraftsByThreadId( · 18 unmodified lines
18201892 nextDraftsByThreadKey[normalizedThreadKey] = {
18211893 prompt,
18221894 attachments,
1895+ ...(files.length > 0 ? { files } : {}),
18231896 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),
18241897 ...(elementContexts.length > 0 ? { elementContexts } : {}),
18251898 ...(reviewComments.length > 0 ? { reviewComments } : {}),
@@ -1842,6 +1915,7 @@ function persistedComposerDraftHasUserContent(draft: PersistedComposerThreadDraf · 16 unmodified lines
18421915 return (
18431916 draft.prompt.trim().length > 0 ||
18441917 draft.attachments.length > 0 ||
1918+ (draft.files?.length ?? 0) > 0 ||
18451919 (draft.terminalContexts?.length ?? 0) > 0 ||
18461920 (draft.elementContexts?.length ?? 0) > 0 ||
18471921 (draft.previewAnnotations?.length ?? 0) > 0 ||
@@ -1925,6 +1999,7 @@ function partializeComposerDraftStoreState( · 77 unmodified lines
19251999 if (
19262000 draft.prompt.length === 0 &&
19272001 draft.persistedAttachments.length === 0 &&
2002+ draft.files.length === 0 &&
19282003 draft.terminalContexts.length === 0 &&
19292004 draft.elementContexts.length === 0 &&
19302005 draft.previewAnnotations.length === 0 &&
@@ -1938,6 +2013,25 @@ function partializeComposerDraftStoreState( · 7 unmodified lines
19382013 const persistedDraft: DeepMutable<PersistedComposerThreadDraftState> = {
19392014 prompt: draft.prompt,
19402015 attachments: draft.persistedAttachments,
2016+ ...(draft.files.length > 0
2017+ ? {
2018+ // A file whose upload has not finished has no serializable bytes.
2019+ // It persists as a metadata-only marker so it can surface as a
2020+ // needs-reattach row after reload instead of silently vanishing.
2021+ files: draft.files.map((file) => ({
2022+ id: file.id,
2023+ name: file.name,
2024+ mimeType: file.mimeType,
2025+ sizeBytes: file.sizeBytes,
2026+ ...(file.uploadedAttachmentId && file.uploadEnvironmentId
2027+ ? {
2028+ attachmentId: file.uploadedAttachmentId,
2029+ environmentId: file.uploadEnvironmentId,
2030+ }
2031+ : {}),
2032+ })),
2033+ }
2034+ : {}),
19412035 ...(draft.terminalContexts.length > 0
19422036 ? {
19432037 terminalContexts: draft.terminalContexts.map((context) => ({
@@ -2219,6 +2313,21 @@ function toHydratedThreadDraft( · 275 unmodified lines
22192313 return {
22202314 prompt: persistedDraft.prompt,
22212315 images: hydrateImagesFromPersisted(persistedDraft.attachments),
2316+ files:
2317+ persistedDraft.files?.map((file) => ({
2318+ type: "file" as const,
2319+ id: file.id,
2320+ name: file.name,
2321+ mimeType: file.mimeType,
2322+ sizeBytes: file.sizeBytes,
2323+ file: null,
2324+ // A marker without an attachment id hydrates as needs-reattach: no
2325+ // bytes, no server-side upload, only the metadata to tell the user
2326+ // what to attach again.
2327+ ...(file.attachmentId !== undefined && file.environmentId !== undefined
2328+ ? { uploadedAttachmentId: file.attachmentId, uploadEnvironmentId: file.environmentId }
2329+ : {}),
2330+ })) ?? [],
22222331 nonPersistedImageIds: [],
22232332 persistedAttachments: [...persistedDraft.attachments],
22242333 terminalContexts:
@@ -3008,6 +3117,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 783 unmodified lines
30083117 }
30093118 continue;
30103119 }
3120+ if (
3121+ existing.images.length + existing.files.length + dedupedIncoming.length >=
3122+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS
3123+ ) {
3124+ if (!acceptedPreviewUrls.has(image.previewUrl)) {
3125+ revokeObjectPreviewUrl(image.previewUrl);
3126+ }
3127+ continue;
3128+ }
30113129 dedupedIncoming.push(image);
30123130 existingIds.add(image.id);
30133131 existingDedupKeys.add(dedupKey);
@@ -3062,6 +3180,160 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 48 unmodified lines
30623180 return { draftsByThreadKey: nextDraftsByThreadKey };
30633181 });
30643182 },
3183+ addFiles: (threadRef, files) => {
3184+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3185+ if (threadKey.length === 0 || files.length === 0) {
3186+ return;
3187+ }
3188+ set((state) => {
3189+ const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
3190+ const knownIds = new Set(existing.files.map((file) => file.id));
3191+ const knownFiles = new Map<string, ComposerFileAttachment>(
3192+ existing.files.map((file) => [
3193+ `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`,
3194+ file,
3195+ ]),
3196+ );
3197+ const accepted: ComposerFileAttachment[] = [];
3198+ // Needs-reattach markers replaced in place by a re-pick, keyed by
3199+ // the marker's id.
3200+ const replacements = new Map<string, ComposerFileAttachment>();
3201+ for (const file of files) {
3202+ const key = `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`;
3203+ if (knownIds.has(file.id)) {
3204+ continue;
3205+ }
3206+ const duplicate = knownFiles.get(key);
3207+ if (duplicate) {
3208+ // A needs-reattach marker persists exactly the metadata this
3209+ // key hashes, so re-picking the same file matches its marker.
3210+ // Dropping the pick as a duplicate would leave the draft
3211+ // blocked forever; replace the marker so the upload restarts.
3212+ if (composerFileNeedsReattach(duplicate) && !replacements.has(duplicate.id)) {
3213+ replacements.set(duplicate.id, file);
3214+ knownIds.add(file.id);
3215+ }
3216+ continue;
3217+ }
3218+ if (
3219+ existing.images.length + existing.files.length + accepted.length >=
3220+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS
3221+ ) {
3222+ break;
3223+ }
3224+ accepted.push(file);
3225+ knownIds.add(file.id);
3226+ knownFiles.set(key, file);
3227+ }
3228+ if (accepted.length === 0 && replacements.size === 0) {
3229+ return state;
3230+ }
3231+ const retained = existing.files.map((file) => replacements.get(file.id) ?? file);
3232+ return {
3233+ draftsByThreadKey: {
3234+ ...state.draftsByThreadKey,
3235+ [threadKey]: { ...existing, files: [...retained, ...accepted] },
3236+ },
3237+ };
3238+ });
3239+ },
3240+ removeFile: (threadRef, fileId) => {
3241+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3242+ if (threadKey.length === 0) {
3243+ return;
3244+ }
3245+ set((state) => {
3246+ const current = state.draftsByThreadKey[threadKey];
3247+ if (!current?.files.some((file) => file.id === fileId)) {
3248+ return state;
3249+ }
3250+ const nextDraft = {
3251+ ...current,
3252+ files: current.files.filter((file) => file.id !== fileId),
3253+ } satisfies ComposerThreadDraftState;
3254+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3255+ if (shouldRemoveDraft(nextDraft)) {
3256+ delete nextDraftsByThreadKey[threadKey];
3257+ } else {
3258+ nextDraftsByThreadKey[threadKey] = nextDraft;
3259+ }
3260+ return { draftsByThreadKey: nextDraftsByThreadKey };
3261+ });
3262+ },
3263+ setFileUpload: (threadRef, fileId, environmentId, attachmentId) => {
3264+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3265+ if (threadKey.length === 0) {
3266+ return;
3267+ }
3268+ set((state) => {
3269+ const current = state.draftsByThreadKey[threadKey];
3270+ const file = current?.files.find((entry) => entry.id === fileId);
3271+ if (
3272+ !current ||
3273+ !file ||
3274+ (file.uploadEnvironmentId === environmentId &&
3275+ file.uploadedAttachmentId === attachmentId)
3276+ ) {
3277+ return state;
3278+ }
3279+ return {
3280+ draftsByThreadKey: {
3281+ ...state.draftsByThreadKey,
3282+ [threadKey]: {
3283+ ...current,
3284+ files: current.files.map((entry) =>
3285+ entry.id === fileId
3286+ ? {
3287+ ...entry,
3288+ uploadedAttachmentId: attachmentId,
3289+ uploadEnvironmentId: environmentId,
3290+ }
3291+ : entry,
3292+ ),
3293+ },
3294+ },
3295+ };
3296+ });
3297+ },
3298+ markFileUploadMissing: (threadRef, fileId, environmentId, attachmentId) => {
3299+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3300+ if (threadKey.length === 0) {
3301+ return false;
3302+ }
3303+ let markedMissing = false;
3304+ set((state) => {
3305+ const current = state.draftsByThreadKey[threadKey];
3306+ const file = current?.files.find((entry) => entry.id === fileId);
3307+ if (
3308+ !current ||
3309+ !file ||
3310+ file.file !== null ||
3311+ file.uploadEnvironmentId !== environmentId ||
3312+ file.uploadedAttachmentId !== attachmentId
3313+ ) {
3314+ return state;
3315+ }
3316+ markedMissing = true;
3317+ return {
3318+ draftsByThreadKey: {
3319+ ...state.draftsByThreadKey,
3320+ [threadKey]: {
3321+ ...current,
3322+ files: current.files.map((entry) => {
3323+ if (entry.id !== fileId) {
3324+ return entry;
3325+ }
3326+ const marker = { ...entry };
3327+ delete marker.uploadedAttachmentId;
3328+ delete marker.uploadEnvironmentId;
3329+ return marker;
3330+ }),
3331+ },
3332+ },
3333+ };
3334+ });
3335+ return markedMissing;
3336+ },
30653337 insertTerminalContext: (threadRef, prompt, context, index) => {
30663338 const threadKey = resolveComposerDraftKey(get(), threadRef);
30673339 const threadId = resolveComposerThreadId(get(), threadRef);
@@ -3462,6 +3734,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 394 unmodified lines
34623734 ...current,
34633735 prompt: "",
34643736 images: [],
3737+ files: [],
34653738 nonPersistedImageIds: [],
34663739 persistedAttachments: [],
34673740 terminalContexts: [],
@@ -3495,6 +3768,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 27 unmodified lines
34953768 ...current,
34963769 prompt: ensureInlineTerminalContextPlaceholders("", current.terminalContexts.length),
34973770 images: [],
3771+ files: [],
34983772 nonPersistedImageIds: [],
34993773 persistedAttachments: [],
35003774 };
@@ -3519,6 +3793,59 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 18 unmodified lines
35193793 return state;
35203794 }
35213795 const destination = state.draftsByThreadKey[toKey] ?? createEmptyThreadDraft();
3796+ const destinationEnvironmentId =
3797+ typeof to === "string"
3798+ ? (state.draftThreadsByThreadKey[toKey]?.environmentId ??
3799+ parseScopedThreadKey(toKey)?.environmentId ??
3800+ null)
3801+ : to.environmentId;
3802+ // A file the destination already holds (same id, or same
3803+ // metadata key) stays behind instead of duplicating there.
3804+ const destinationFileIds = new Set(destination.files.map((file) => file.id));
3805+ const destinationFileKeys = new Set(
3806+ destination.files.map(
3807+ (file) => `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`,
3808+ ),
3809+ );
3810+ const transferableFiles = source.files.filter(
3811+ (file) =>
3812+ (file.file !== null || file.uploadEnvironmentId === destinationEnvironmentId) &&
3813+ !destinationFileIds.has(file.id) &&
3814+ !destinationFileKeys.has(
3815+ `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`,
3816+ ),
3817+ );
3818+ const remainingAttachmentSlots = Math.max(
3819+ 0,
3820+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS -
3821+ destination.images.length -
3822+ destination.files.length,
3823+ );
3824+ const movedImages = source.images.slice(0, remainingAttachmentSlots);
3825+ const movedImageIds = new Set(movedImages.map((image) => image.id));
3826+ const retainedImages = source.images.filter((image) => !movedImageIds.has(image.id));
3827+ const movedFiles = transferableFiles
3828+ .slice(0, remainingAttachmentSlots - movedImages.length)
3829+ .map((file) => {
3830+ // A byte-backed file moving across environments re-uploads at
3831+ // the destination. Keeping the source-environment upload id
3832+ // would mark it uploaded after a reload with no local bytes
3833+ // and no valid upload anywhere the destination can reach. The
3834+ // upload queue keeps the source upload as fallback, then
3835+ // deletes it after the destination upload succeeds. Abandoned
3836+ // uploads still expire through the server sweep.
3837+ if (
3838+ file.file === null ||
3839+ file.uploadEnvironmentId === undefined ||
3840+ file.uploadEnvironmentId === destinationEnvironmentId
3841+ ) {
3842+ return file;
3843+ }
3844+ const { uploadedAttachmentId: _a, uploadEnvironmentId: _e, ...rest } = file;
3845+ return rest;
3846+ });
3847+ const movedFileIds = new Set(movedFiles.map((file) => file.id));
3848+ const retainedFiles = source.files.filter((file) => !movedFileIds.has(file.id));
35223849 // Inline placeholders reference the source's terminal contexts,
35233850 // which stay behind; re-anchor the moved prompt to whatever
35243851 // contexts the destination already holds.
@@ -3529,14 +3856,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 4 unmodified lines
35293856 const nextDestination: ComposerThreadDraftState = {
35303857 ...destination,
35313858 prompt: movedPrompt,
3532− images: [...destination.images, ...source.images],
3859+ images: [...destination.images, ...movedImages],
3860+ files: [...destination.files, ...movedFiles],
35333861 nonPersistedImageIds: [
35343862 ...destination.nonPersistedImageIds,
3535− ...source.nonPersistedImageIds,
3863+ ...source.nonPersistedImageIds.filter((imageId) => movedImageIds.has(imageId)),
35363864 ],
35373865 persistedAttachments: [
35383866 ...destination.persistedAttachments,
3539− ...source.persistedAttachments,
3867+ ...source.persistedAttachments.filter((attachment) =>
3868+ movedImageIds.has(attachment.id),
3869+ ),
35403870 ],
35413871 };
35423872 // Same clearing shape as clearComposerPromptAndImages, but the
@@ -3545,9 +3875,14 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 2 unmodified lines
35453875 const nextSource: ComposerThreadDraftState = {
35463876 ...source,
35473877 prompt: ensureInlineTerminalContextPlaceholders("", source.terminalContexts.length),
3548− images: [],
3549− nonPersistedImageIds: [],
3550− persistedAttachments: [],
3878+ images: retainedImages,
3879+ files: retainedFiles,
3880+ nonPersistedImageIds: source.nonPersistedImageIds.filter(
3881+ (imageId) => !movedImageIds.has(imageId),
3882+ ),
3883+ persistedAttachments: source.persistedAttachments.filter(
3884+ (attachment) => !movedImageIds.has(attachment.id),
3885+ ),
35513886 };
35523887 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
35533888 if (shouldRemoveDraft(nextSource)) {
apps/web/src/hooks/useHandleNewThread.ts+14−1
@@ -33,6 +33,7 @@ import { primaryServerSettingsAtom } from "../state/server"; · 32 unmodified lines
3333 import { resolveThreadRouteTarget } from "../threadRoutes";
3434 import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
3535 import { useClientSettings } from "./useSettings";
36+import { toastManager } from "../components/ui/toast";
3637
3738 interface NewThreadWorkspaceOptions {
3839 branch?: string | null;
@@ -78,7 +79,7 @@ export function useNewThreadHandler() { · 39 unmodified lines
7879 startFromOrigin?: boolean;
7980 replace?: boolean;
8081 /**
81− * Move the viewed draft's typed content (prompt + images) into the
82+ * Move the viewed draft's typed content and transferable attachments into the
8283 * draft this request lands on. Set by the draft repo picker: the
8384 * user started writing in the wrong project and the text should
8485 * follow them. Explicit new-thread surfaces leave this unset and
@@ -156,6 +157,18 @@ export function useNewThreadHandler() { · 71 unmodified lines
156157 composerDraftHasUserContent(getComposerDraft(carryContentSourceDraftId))
157158 ) {
158159 moveComposerPromptAndImages(carryContentSourceDraftId, destinationDraftId);
160+ // The move caps at the destination's free slots and skips
161+ // duplicates, so images and files can both stay behind.
162+ const remainingDraft = getComposerDraft(carryContentSourceDraftId);
163+ const remainingCount =
164+ (remainingDraft?.files.length ?? 0) + (remainingDraft?.images.length ?? 0);
165+ if (remainingCount > 0) {
166+ toastManager.add({
167+ type: "warning",
168+ title: `${remainingCount} attachment${remainingCount === 1 ? " stayed" : "s stayed"} in the original draft`,
169+ description: "Return to the original draft or attach the files again.",
170+ });
171+ }
159172 }
160173 };
161174 const project = projects.find(
apps/web/src/lib/attachmentUploadQueue.test.ts+660−4
@@ -1,21 +1,36 @@
1−import { EnvironmentId } from "@t3tools/contracts";
1+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
23 import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
34
4−import type { ComposerImageAttachment } from "../composerDraftStore";
5+import {
6+ composerFileNeedsReattach,
7+ DraftId,
8+ useComposerDraftStore,
9+ type ComposerFileAttachment,
10+ type ComposerImageAttachment,
11+} from "../composerDraftStore";
512
613 const mocks = vi.hoisted(() => ({
14+ createAssetUrl: vi.fn(),
715 createUploadUrl: Symbol("create-upload-url"),
16+ executeAtomQuery: vi.fn(),
817 removeUpload: Symbol("remove-upload"),
918 runAtomCommand: vi.fn(),
1019 readPreparedConnection: vi.fn(),
1120 }));
1221
1322 vi.mock("@t3tools/client-runtime/state/runtime", () => ({
23+ executeAtomQuery: mocks.executeAtomQuery,
1424 runAtomCommand: mocks.runAtomCommand,
25+ squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error,
1526 }));
1627
1728 vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} }));
1829
30+vi.mock("../state/assets", () => ({
31+ assetEnvironment: { createUrl: mocks.createAssetUrl },
32+}));
33+
1934 vi.mock("../state/attachments", () => ({
2035 attachmentEnvironment: {
2136 createUploadUrl: mocks.createUploadUrl,
@@ -32,7 +47,9 @@ import { · 10 unmodified lines
3247 getUploadedAttachments,
3348 readAttachmentUpload,
3449 releaseAttachmentUpload,
35− releaseAttachmentUploads,
50+ releaseDraftAttachment,
51+ releaseDraftAttachments,
52+ releasePersistedAttachmentUpload,
3653 retryAttachmentUpload,
3754 startAttachmentUpload,
3855 useAttachmentUploadStore,
@@ -110,9 +127,27 @@ function makeImage(id: string): ComposerImageAttachment { · 71 unmodified lines
110127 };
111128 }
112129
130+function makeFile(id: string): ComposerFileAttachment {
131+ const file = new File([new Uint8Array([1, 2, 3])], `${id}.pdf`, {
132+ type: "application/pdf",
133+ });
134+ return {
135+ type: "file",
136+ id,
137+ name: file.name,
138+ mimeType: file.type,
139+ sizeBytes: file.size,
140+ file,
141+ };
142+}
143+
113144 describe("attachmentUploadQueue", () => {
114145 beforeEach(() => {
115146 TestXmlHttpRequest.requests = [];
147+ mocks.createAssetUrl.mockReset();
148+ mocks.createAssetUrl.mockImplementation((target: unknown) => target);
149+ mocks.executeAtomQuery.mockReset();
150+ mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} });
116151 mocks.runAtomCommand.mockReset();
117152 mocks.readPreparedConnection.mockReset();
118153 mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" });
@@ -176,7 +211,7 @@ describe("attachmentUploadQueue", () => { · 57 unmodified lines
176211 },
177212 ]);
178213
179− releaseAttachmentUploads([image]);
214+ releaseDraftAttachments([image]);
180215 expect(readAttachmentUpload(image.id)).toBeUndefined();
181216 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
182217 expect.anything(),
@@ -189,6 +224,543 @@ describe("attachmentUploadQueue", () => { · 6 unmodified lines
189224 );
190225 });
191226
227+ it("uploads generic files and sends file attachment references", async () => {
228+ const file = makeFile("report");
229+ startAttachmentUpload({ environmentId: firstEnvironment, image: file });
230+ await Promise.resolve();
231+
232+ expect(mocks.runAtomCommand).toHaveBeenCalledWith(
233+ expect.anything(),
234+ mocks.createUploadUrl,
235+ {
236+ environmentId: firstEnvironment,
237+ input: {
238+ type: "file",
239+ name: "report.pdf",
240+ mimeType: "application/pdf",
241+ sizeBytes: 3,
242+ },
243+ },
244+ expect.anything(),
245+ );
246+
247+ const settled = awaitAttachmentUploads([file.id]);
248+ TestXmlHttpRequest.requests[0]!.complete();
249+ await settled;
250+
251+ expect(getUploadedAttachments({ environmentId: firstEnvironment, images: [file] })).toEqual([
252+ {
253+ type: "file",
254+ id: "pending-environment-1-report.pdf",
255+ name: "report.pdf",
256+ mimeType: "application/pdf",
257+ sizeBytes: 3,
258+ },
259+ ]);
260+ });
261+
262+ it("uses the fallback MIME type for both the upload claim and request header", async () => {
263+ const bytes = new File([new Uint8Array([1, 2, 3])], "unknown.bin");
264+ const file: ComposerFileAttachment = {
265+ type: "file",
266+ id: "file-without-browser-mime",
267+ name: bytes.name,
268+ mimeType: "application/octet-stream",
269+ sizeBytes: bytes.size,
270+ file: bytes,
271+ };
272+
273+ startAttachmentUpload({ environmentId: firstEnvironment, image: file });
274+ await Promise.resolve();
275+
276+ expect(mocks.runAtomCommand).toHaveBeenCalledWith(
277+ expect.anything(),
278+ mocks.createUploadUrl,
279+ {
280+ environmentId: firstEnvironment,
281+ input: {
282+ type: "file",
283+ name: "unknown.bin",
284+ mimeType: "application/octet-stream",
285+ sizeBytes: 3,
286+ },
287+ },
288+ expect.anything(),
289+ );
290+ expect(TestXmlHttpRequest.requests[0]?.headers.get("Content-Type")).toBe(
291+ "application/octet-stream",
292+ );
293+
294+ const settled = awaitAttachmentUploads([file.id]);
295+ TestXmlHttpRequest.requests[0]!.complete();
296+ await settled;
297+ });
298+
299+ it("persists a background completion's ids to the draft with no composer mounted", async () => {
300+ const draftId = DraftId.make("draft-background-upload");
301+ const file = makeFile("background");
302+ const draftStore = useComposerDraftStore.getState();
303+ draftStore.addFiles(draftId, [file]);
304+ try {
305+ startAttachmentUpload({
306+ environmentId: firstEnvironment,
307+ image: file,
308+ draftTarget: draftId,
309+ });
310+ await Promise.resolve();
311+
312+ // No composer effect is subscribed; only the queue can stamp the draft.
313+ const settled = awaitAttachmentUploads([file.id]);
314+ TestXmlHttpRequest.requests[0]!.complete();
315+ await settled;
316+
317+ expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
318+ {
319+ id: file.id,
320+ uploadedAttachmentId: "pending-environment-1-background.pdf",
321+ uploadEnvironmentId: firstEnvironment,
322+ },
323+ ]);
324+ } finally {
325+ useComposerDraftStore.getState().clearComposerContent(draftId);
326+ }
327+ });
328+
329+ it("persists a pending upload on the same-environment draft that receives its file", async () => {
330+ const source = scopeThreadRef(firstEnvironment, ThreadId.make("thread-background-move-source"));
331+ const destination = scopeThreadRef(
332+ firstEnvironment,
333+ ThreadId.make("thread-background-move-destination"),
334+ );
335+ const file = makeFile("background-move");
336+ const store = useComposerDraftStore.getState();
337+ store.addFiles(source, [file]);
338+
339+ try {
340+ startAttachmentUpload({
341+ environmentId: firstEnvironment,
342+ image: file,
343+ draftTarget: source,
344+ });
345+ await Promise.resolve();
346+ store.moveComposerPromptAndImages(source, destination);
347+ const sourceAfterMove = store.getComposerDraft(source);
348+
349+ // The destination never starts the existing job again. Its completion
350+ // must find the file in current store state instead of the captured row.
351+ const settled = awaitAttachmentUploads([file.id]);
352+ TestXmlHttpRequest.requests[0]!.complete();
353+ await settled;
354+
355+ expect(store.getComposerDraft(source)).toEqual(sourceAfterMove);
356+ expect(store.getComposerDraft(destination)?.files).toMatchObject([
357+ {
358+ id: file.id,
359+ uploadedAttachmentId: "pending-environment-1-background-move.pdf",
360+ uploadEnvironmentId: firstEnvironment,
361+ },
362+ ]);
363+ } finally {
364+ store.clearComposerContent(source);
365+ store.clearComposerContent(destination);
366+ }
367+ });
368+
369+ it("does not stamp an old-environment upload after its file moves environments", async () => {
370+ const source = scopeThreadRef(
371+ firstEnvironment,
372+ ThreadId.make("thread-cross-environment-source"),
373+ );
374+ const destination = scopeThreadRef(
375+ secondEnvironment,
376+ ThreadId.make("thread-cross-environment-destination"),
377+ );
378+ const file = makeFile("cross-environment-move");
379+ const store = useComposerDraftStore.getState();
380+ store.addFiles(source, [file]);
381+
382+ try {
383+ startAttachmentUpload({
384+ environmentId: firstEnvironment,
385+ image: file,
386+ draftTarget: source,
387+ });
388+ await Promise.resolve();
389+ store.moveComposerPromptAndImages(source, destination);
390+
391+ const settled = awaitAttachmentUploads([file.id]);
392+ TestXmlHttpRequest.requests[0]!.complete();
393+ await settled;
394+
395+ expect(store.getComposerDraft(source)).toBeNull();
396+ const movedFile = store.getComposerDraft(destination)?.files[0];
397+ expect(movedFile?.id).toBe(file.id);
398+ expect(movedFile?.uploadedAttachmentId).toBeUndefined();
399+ expect(movedFile?.uploadEnvironmentId).toBeUndefined();
400+ } finally {
401+ store.clearComposerContent(source);
402+ store.clearComposerContent(destination);
403+ }
404+ });
405+
406+ it("verifies an uploaded file reference before restoring it", async () => {
407+ const file: ComposerFileAttachment = {
408+ ...makeFile("restored"),
409+ file: null,
410+ uploadedAttachmentId: "pending-restored-pdf",
411+ uploadEnvironmentId: firstEnvironment,
412+ };
413+
414+ startAttachmentUpload({ environmentId: firstEnvironment, image: file });
415+ await awaitAttachmentUploads([file.id]);
416+
417+ expect(readAttachmentUpload(file.id)).toEqual({
418+ status: "ready",
419+ environmentId: firstEnvironment,
420+ attachmentId: "pending-restored-pdf",
421+ });
422+ expect(mocks.createAssetUrl).toHaveBeenCalledWith({
423+ environmentId: firstEnvironment,
424+ input: { resource: { _tag: "attachment", attachmentId: "pending-restored-pdf" } },
425+ });
426+ expect(TestXmlHttpRequest.requests).toHaveLength(0);
427+ });
428+
429+ it("turns an expired persisted file into a marker that a re-pick can replace", async () => {
430+ const draftId = DraftId.make("draft-expired-upload");
431+ const file: ComposerFileAttachment = {
432+ ...makeFile("expired"),
433+ file: null,
434+ uploadedAttachmentId: "pending-expired-pdf",
435+ uploadEnvironmentId: firstEnvironment,
436+ };
437+ mocks.executeAtomQuery.mockResolvedValueOnce({
438+ _tag: "Failure",
439+ error: { _tag: "AssetAttachmentNotFoundError" },
440+ });
441+ useComposerDraftStore.getState().addFiles(draftId, [file]);
442+
443+ try {
444+ startAttachmentUpload({
445+ environmentId: firstEnvironment,
446+ image: file,
447+ draftTarget: draftId,
448+ });
449+ await awaitAttachmentUploads([file.id]);
450+
451+ const marker = useComposerDraftStore.getState().getComposerDraft(draftId)?.files[0];
452+ expect(readAttachmentUpload(file.id)).toBeUndefined();
453+ expect(marker?.uploadedAttachmentId).toBeUndefined();
454+ expect(marker?.uploadEnvironmentId).toBeUndefined();
455+ expect(marker && composerFileNeedsReattach(marker)).toBe(true);
456+
457+ const replacementBytes = new File([new Uint8Array([1, 2, 3])], file.name, {
458+ type: file.mimeType,
459+ });
460+ const replacement: ComposerFileAttachment = {
461+ type: "file",
462+ id: "expired-repicked",
463+ name: file.name,
464+ mimeType: file.mimeType,
465+ sizeBytes: file.sizeBytes,
466+ file: replacementBytes,
467+ };
468+ useComposerDraftStore.getState().addFiles(draftId, [replacement]);
469+ expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
470+ { id: replacement.id, file: replacementBytes },
471+ ]);
472+
473+ startAttachmentUpload({
474+ environmentId: firstEnvironment,
475+ image: replacement,
476+ draftTarget: draftId,
477+ });
478+ await Promise.resolve();
479+ const settled = awaitAttachmentUploads([replacement.id]);
480+ TestXmlHttpRequest.requests[0]!.complete();
481+ await settled;
482+
483+ expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
484+ {
485+ id: replacement.id,
486+ uploadedAttachmentId: "pending-environment-1-expired.pdf",
487+ uploadEnvironmentId: firstEnvironment,
488+ },
489+ ]);
490+ const removeCalls = mocks.runAtomCommand.mock.calls.filter(
491+ ([, command]) => command === mocks.removeUpload,
492+ );
493+ expect(removeCalls).toEqual([]);
494+ } finally {
495+ useComposerDraftStore.getState().clearComposerContent(draftId);
496+ }
497+ });
498+
499+ it("uploads the original file again when its persisted server upload expired", async () => {
500+ const file: ComposerFileAttachment = {
501+ ...makeFile("recoverable"),
502+ uploadedAttachmentId: "pending-expired-pdf",
503+ uploadEnvironmentId: firstEnvironment,
504+ };
505+ mocks.executeAtomQuery.mockResolvedValueOnce({
506+ _tag: "Failure",
507+ error: { _tag: "AssetAttachmentNotFoundError" },
508+ });
509+
510+ startAttachmentUpload({ environmentId: firstEnvironment, image: file });
511+ // The verify-then-reupload path crosses several awaits before the
512+ // transfer starts; drain microtasks until the XHR exists.
513+ for (let hop = 0; hop < 20 && TestXmlHttpRequest.requests.length === 0; hop += 1) {
514+ await Promise.resolve();
515+ }
516+
517+ const settled = awaitAttachmentUploads([file.id]);
518+ TestXmlHttpRequest.requests[0]!.complete();
519+ await settled;
520+
521+ expect(readAttachmentUpload(file.id)).toMatchObject({
522+ status: "ready",
523+ attachmentId: "pending-environment-1-recoverable.pdf",
524+ });
525+ });
526+
527+ it("removes a persisted upload when its draft is discarded during verification", async () => {
528+ const file: ComposerFileAttachment = {
529+ ...makeFile("checking"),
530+ file: null,
531+ uploadedAttachmentId: "pending-checking-pdf",
532+ uploadEnvironmentId: firstEnvironment,
533+ };
534+ let resolveVerification: (result: {
535+ readonly _tag: "Success";
536+ readonly value: object;
537+ }) => void = () => {};
538+ mocks.executeAtomQuery.mockReturnValueOnce(
539+ new Promise((resolve) => {
540+ resolveVerification = resolve;
541+ }),
542+ );
543+
544+ startAttachmentUpload({ environmentId: firstEnvironment, image: file });
545+ releaseDraftAttachment(file);
546+ resolveVerification({ _tag: "Success", value: {} });
547+
548+ expect(mocks.runAtomCommand).toHaveBeenCalledWith(
549+ expect.anything(),
550+ mocks.removeUpload,
551+ {
552+ environmentId: firstEnvironment,
553+ input: { attachmentId: "pending-checking-pdf" },
554+ },
555+ expect.anything(),
556+ );
557+ });
558+
559+ it("keeps the persisted upload when retrying after a transient verification failure", async () => {
560+ const draftId = DraftId.make("draft-transient-verification");
561+ const file: ComposerFileAttachment = {
562+ ...makeFile("flaky"),
563+ file: null,
564+ uploadedAttachmentId: "pending-flaky-pdf",
565+ uploadEnvironmentId: firstEnvironment,
566+ };
567+ mocks.executeAtomQuery.mockResolvedValueOnce({
568+ _tag: "Failure",
569+ error: new Error("socket closed"),
570+ });
571+ useComposerDraftStore.getState().addFiles(draftId, [file]);
572+
573+ try {
574+ startAttachmentUpload({
575+ environmentId: firstEnvironment,
576+ image: file,
577+ draftTarget: draftId,
578+ });
579+ await awaitAttachmentUploads([file.id]);
580+ expect(readAttachmentUpload(file.id)).toMatchObject({
581+ status: "failed",
582+ reason: "Uploaded file could not be verified. Retry when the server reconnects.",
583+ });
584+ expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
585+ {
586+ uploadedAttachmentId: "pending-flaky-pdf",
587+ uploadEnvironmentId: firstEnvironment,
588+ },
589+ ]);
590+
591+ // The persisted id is the only server copy of the bytes (`file` is null
592+ // after a reload), so the retry must verify it again, not delete it.
593+ retryAttachmentUpload({
594+ environmentId: firstEnvironment,
595+ image: file,
596+ draftTarget: draftId,
597+ });
598+ await awaitAttachmentUploads([file.id]);
599+
600+ expect(readAttachmentUpload(file.id)).toEqual({
601+ status: "ready",
602+ environmentId: firstEnvironment,
603+ attachmentId: "pending-flaky-pdf",
604+ });
605+ expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
606+ {
607+ uploadedAttachmentId: "pending-flaky-pdf",
608+ uploadEnvironmentId: firstEnvironment,
609+ },
610+ ]);
611+ const removeCalls = mocks.runAtomCommand.mock.calls.filter(
612+ ([, command]) => command === mocks.removeUpload,
613+ );
614+ expect(removeCalls).toEqual([]);
615+ } finally {
616+ useComposerDraftStore.getState().clearComposerContent(draftId);
617+ }
618+ });
619+
620+ it("keeps the persisted upload when an environment switch cancels its verification", async () => {
621+ const file: ComposerFileAttachment = {
622+ ...makeFile("moving"),
623+ file: null,
624+ uploadedAttachmentId: "pending-moving-pdf",
625+ uploadEnvironmentId: firstEnvironment,
626+ };
627+ let resolveVerification: (result: {
628+ readonly _tag: "Success";
629+ readonly value: object;
630+ }) => void = () => {};
631+ mocks.executeAtomQuery.mockReturnValueOnce(
632+ new Promise((resolve) => {
633+ resolveVerification = resolve;
634+ }),
635+ );
636+
637+ startAttachmentUpload({ environmentId: firstEnvironment, image: file });
638+ // Switching environments cancels the in-flight verification. The draft
639+ // still references the upload in the first environment, so the cancel
640+ // must not delete it.
641+ startAttachmentUpload({ environmentId: secondEnvironment, image: file });
642+ resolveVerification({ _tag: "Success", value: {} });
643+ await awaitAttachmentUploads([file.id]);
644+
645+ const persistedRemoveCalls = mocks.runAtomCommand.mock.calls.filter(
646+ ([, command, target]) =>
647+ command === mocks.removeUpload &&
648+ (target as { readonly input: { readonly attachmentId: string } }).input.attachmentId ===
649+ "pending-moving-pdf",
650+ );
651+ expect(persistedRemoveCalls).toEqual([]);
652+ });
653+
654+ it("cancels persisted-upload verification when a stash discards its file", async () => {
655+ const file: ComposerFileAttachment = {
656+ ...makeFile("stashed-checking"),
657+ file: null,
658+ uploadedAttachmentId: "pending-stashed-checking-pdf",
659+ uploadEnvironmentId: firstEnvironment,
660+ };
661+ let resolveVerification: (result: {
662+ readonly _tag: "Success";
663+ readonly value: object;
664+ }) => void = () => {};
665+ mocks.executeAtomQuery.mockReturnValueOnce(
666+ new Promise((resolve) => {
667+ resolveVerification = resolve;
668+ }),
669+ );
670+
671+ startAttachmentUpload({ environmentId: firstEnvironment, image: file });
672+ releasePersistedAttachmentUpload({
673+ id: file.id,
674+ environmentId: firstEnvironment,
675+ attachmentId: "pending-stashed-checking-pdf",
676+ });
677+ resolveVerification({ _tag: "Success", value: {} });
678+ await Promise.resolve();
679+
680+ expect(readAttachmentUpload(file.id)).toBeUndefined();
681+ expect(mocks.runAtomCommand).toHaveBeenCalledWith(
682+ expect.anything(),
683+ mocks.removeUpload,
684+ {
685+ environmentId: firstEnvironment,
686+ input: { attachmentId: "pending-stashed-checking-pdf" },
687+ },
688+ expect.anything(),
689+ );
690+ });
691+
692+ it("releases the persisted server upload when a hydrated draft is discarded after a reload", () => {
693+ // After a reload the in-memory queue is empty; the draft file only carries
694+ // its persisted attachment id. Discarding it must still delete the
695+ // server-side pending upload.
696+ const file: ComposerFileAttachment = {
697+ ...makeFile("hydrated"),
698+ file: null,
699+ uploadedAttachmentId: "pending-hydrated-pdf",
700+ uploadEnvironmentId: firstEnvironment,
701+ };
702+
703+ releaseDraftAttachment(file);
704+
705+ expect(mocks.runAtomCommand).toHaveBeenCalledWith(
706+ expect.anything(),
707+ mocks.removeUpload,
708+ {
709+ environmentId: firstEnvironment,
710+ input: { attachmentId: "pending-hydrated-pdf" },
711+ },
712+ expect.anything(),
713+ );
714+ });
715+
716+ it("routes a live persisted upload through the queue release exactly once", async () => {
717+ const file: ComposerFileAttachment = {
718+ ...makeFile("live"),
719+ file: null,
720+ uploadedAttachmentId: "pending-live-pdf",
721+ uploadEnvironmentId: firstEnvironment,
722+ };
723+ startAttachmentUpload({ environmentId: firstEnvironment, image: file });
724+ await awaitAttachmentUploads([file.id]);
725+ expect(readAttachmentUpload(file.id)).toMatchObject({ status: "ready" });
726+
727+ releaseDraftAttachment(file);
728+
729+ expect(readAttachmentUpload(file.id)).toBeUndefined();
730+ const removeCalls = mocks.runAtomCommand.mock.calls.filter(
731+ ([, command]) => command === mocks.removeUpload,
732+ );
733+ expect(removeCalls).toEqual([
734+ [
735+ expect.anything(),
736+ mocks.removeUpload,
737+ {
738+ environmentId: firstEnvironment,
739+ input: { attachmentId: "pending-live-pdf" },
740+ },
741+ expect.anything(),
742+ ],
743+ ]);
744+ });
745+
746+ it("deletes a persisted server upload even when browser upload state is gone", () => {
747+ releasePersistedAttachmentUpload({
748+ id: "stashed-report",
749+ environmentId: firstEnvironment,
750+ attachmentId: "pending-00000000-0000-4000-8000-000000000001-pdf",
751+ });
752+
753+ expect(mocks.runAtomCommand).toHaveBeenCalledWith(
754+ expect.anything(),
755+ mocks.removeUpload,
756+ {
757+ environmentId: firstEnvironment,
758+ input: { attachmentId: "pending-00000000-0000-4000-8000-000000000001-pdf" },
759+ },
760+ expect.anything(),
761+ );
762+ });
763+
192764 it("retries rejected uploads", async () => {
193765 const image = makeImage("image-retry");
194766 startAttachmentUpload({ environmentId: firstEnvironment, image });
@@ -277,6 +849,90 @@ describe("attachmentUploadQueue", () => { · 82 unmodified lines
277849 });
278850 });
279851
852+ it("releases a moved file's source upload only after its destination upload succeeds", async () => {
853+ const source = scopeThreadRef(firstEnvironment, ThreadId.make("thread-file-move-source"));
854+ const destination = scopeThreadRef(
855+ secondEnvironment,
856+ ThreadId.make("thread-file-move-destination"),
857+ );
858+ const file = makeFile("moved-report");
859+ const store = useComposerDraftStore.getState();
860+ store.addFiles(source, [file]);
861+
862+ try {
863+ startAttachmentUpload({
864+ environmentId: firstEnvironment,
865+ image: file,
866+ draftTarget: source,
867+ });
868+ await Promise.resolve();
869+ let settled = awaitAttachmentUploads([file.id]);
870+ TestXmlHttpRequest.requests[0]!.complete();
871+ await settled;
872+
873+ const sourceAttachmentId = store.getComposerDraft(source)?.files[0]?.uploadedAttachmentId;
874+ expect(sourceAttachmentId).toBe("pending-environment-1-moved-report.pdf");
875+
876+ store.moveComposerPromptAndImages(source, destination);
877+ const movedFile = store.getComposerDraft(destination)?.files[0];
878+ expect(movedFile).toMatchObject({
879+ id: file.id,
880+ file: file.file,
881+ });
882+ expect(movedFile?.uploadedAttachmentId).toBeUndefined();
883+ expect(movedFile?.uploadEnvironmentId).toBeUndefined();
884+
885+ startAttachmentUpload({
886+ environmentId: secondEnvironment,
887+ image: movedFile!,
888+ draftTarget: destination,
889+ });
890+ await Promise.resolve();
891+
892+ const sourceDeletesBeforeDestinationUpload = mocks.runAtomCommand.mock.calls.filter(
893+ ([, command, target]) =>
894+ command === mocks.removeUpload &&
895+ (
896+ target as {
897+ readonly environmentId: EnvironmentId;
898+ readonly input: { readonly attachmentId: string };
899+ }
900+ ).environmentId === firstEnvironment &&
901+ (target as { readonly input: { readonly attachmentId: string } }).input.attachmentId ===
902+ sourceAttachmentId,
903+ );
904+ expect(sourceDeletesBeforeDestinationUpload).toEqual([]);
905+
906+ settled = awaitAttachmentUploads([file.id]);
907+ TestXmlHttpRequest.requests[1]!.complete();
908+ await settled;
909+
910+ const sourceDeletesAfterDestinationUpload = mocks.runAtomCommand.mock.calls.filter(
911+ ([, command, target]) =>
912+ command === mocks.removeUpload &&
913+ (
914+ target as {
915+ readonly environmentId: EnvironmentId;
916+ readonly input: { readonly attachmentId: string };
917+ }
918+ ).environmentId === firstEnvironment &&
919+ (target as { readonly input: { readonly attachmentId: string } }).input.attachmentId ===
920+ sourceAttachmentId,
921+ );
922+ expect(sourceDeletesAfterDestinationUpload).toHaveLength(1);
923+ expect(store.getComposerDraft(destination)?.files).toMatchObject([
924+ {
925+ id: file.id,
926+ uploadedAttachmentId: "pending-environment-2-moved-report.pdf",
927+ uploadEnvironmentId: secondEnvironment,
928+ },
929+ ]);
930+ } finally {
931+ store.clearComposerContent(source);
932+ store.clearComposerContent(destination);
933+ }
934+ });
935+
280936 it("does not let stalled uploads block another environment", async () => {
281937 const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage);
282938 for (const image of images) {
apps/web/src/lib/attachmentUploadQueue.ts+309−89
@@ -3,12 +3,25 @@ import { · 2 unmodified lines
33 type ChatAttachment,
44 type EnvironmentId,
55 } from "@t3tools/contracts";
6+import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
67 import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
7−import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
8+import {
9+ deletePendingAttachmentUpload,
10+ runAttachmentUploadCycle,
11+ verifyPersistedAttachmentUpload,
12+ type PersistedAttachmentVerification,
13+} from "@t3tools/client-runtime/state/attachments";
814 import { create } from "zustand";
915
10−import type { ComposerImageAttachment } from "../composerDraftStore";
16+import {
17+ DraftId,
18+ useComposerDraftStore,
19+ type ComposerFileAttachment,
20+ type ComposerImageAttachment,
21+ type ComposerThreadTarget,
22+} from "../composerDraftStore";
1123 import { appAtomRegistry } from "../rpc/atomRegistry";
24+import { assetEnvironment } from "../state/assets";
1225 import { attachmentEnvironment } from "../state/attachments";
1326 import { readPreparedConnection } from "../state/session";
1427 import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState";
@@ -25,11 +38,23 @@ export const useAttachmentUploadStore = create<AttachmentUploadStore>(() => ({ · 10 unmodified lines
2538 }));
2639
2740 interface UploadJob {
28− readonly image: ComposerImageAttachment;
41+ readonly image: ComposerImageAttachment | ComposerFileAttachment;
2942 readonly environmentId: EnvironmentId;
43+ /**
44+ * The draft that owned this file when the job started. Completion resolves
45+ * the current owner because the file can move while the upload is pending.
46+ */
47+ readonly draftTarget?: ComposerThreadTarget;
3048 readonly previous?: ReadyAttachmentUpload;
49+ /**
50+ * The draft's persisted server-side upload, to verify instead of re-upload.
51+ * The draft owns this id; the queue never deletes it on cancel or retry.
52+ * Deleting it goes through `releasePersistedAttachmentUpload` only.
53+ */
54+ readonly persistedAttachmentId?: string;
3155 readonly settled: Promise<void>;
3256 resolveSettled: () => void;
57+ /** Only ids this queue minted itself. Cancel and retry may delete these. */
3358 attachmentId: string | null;
3459 cancelled: boolean;
3560 abort: (() => void) | null;
@@ -60,25 +85,73 @@ export function readAttachmentUpload(imageId: string): AttachmentUploadState | u · 24 unmodified lines
6085 return useAttachmentUploadStore.getState().uploadsByImageId[imageId];
6186 }
6287
88+/** Finds the file's current same-environment draft after any in-flight move. */
89+function resolveCurrentFileDraftTarget(job: UploadJob): ComposerThreadTarget | undefined {
90+ if (job.draftTarget === undefined || job.image.type !== "file") {
91+ return undefined;
92+ }
93+ const store = useComposerDraftStore.getState();
94+ for (const [key, draft] of Object.entries(store.draftsByThreadKey)) {
95+ if (!draft.files.some((file) => file.id === job.image.id)) {
96+ continue;
97+ }
98+ const draftSession = store.draftThreadsByThreadKey[key];
99+ if (draftSession !== undefined) {
100+ if (draftSession.environmentId === job.environmentId) {
101+ return DraftId.make(key);
102+ }
103+ continue;
104+ }
105+ // Tests and legacy callers can use a DraftId without session metadata.
106+ // Only its original job supplies enough environment identity to trust it.
107+ if (typeof job.draftTarget === "string" && job.draftTarget === key) {
108+ return DraftId.make(key);
109+ }
110+ const threadRef = parseScopedThreadKey(key);
111+ if (threadRef?.environmentId === job.environmentId) {
112+ return threadRef;
113+ }
114+ }
115+ return undefined;
116+}
117+
118+/**
119+ * Persists a finished upload's ids onto the draft that owns the file. The
120+ * mounted composer effect performs the same write for live UI updates, but a
121+ * background completion (user navigated away, upload finished, reload) must
122+ * not depend on a mounted composer to survive. `setFileUpload` no-ops when
123+ * the draft row is gone or already carries these ids.
124+ */
125+function stampDraftFileUpload(job: UploadJob, attachmentId: string): void {
126+ const draftTarget = resolveCurrentFileDraftTarget(job);
127+ if (draftTarget === undefined) {
128+ return;
129+ }
130+ useComposerDraftStore
131+ .getState()
132+ .setFileUpload(draftTarget, job.image.id, job.environmentId, attachmentId);
133+}
134+
63135 function deletePendingUpload(environmentId: EnvironmentId, attachmentId: string): void {
64− void runAtomCommand(
65− appAtomRegistry,
66− attachmentEnvironment.remove,
67− { environmentId, input: { attachmentId } },
68− { reportFailure: false, reportDefect: false },
69− );
136+ deletePendingAttachmentUpload({
137+ registry: appAtomRegistry,
138+ remove: attachmentEnvironment.remove,
139+ environmentId,
140+ attachmentId,
141+ });
70142 }
71143
72144 function uploadBytes(input: {
73145 readonly url: string;
74146 readonly file: File;
147+ readonly mimeType: string;
75148 readonly onProgress: (progress: number) => void;
76149 }): { readonly done: Promise<void>; readonly abort: () => void } {
77150 const xhr = new XMLHttpRequest();
78151 const done = new Promise<void>((resolve, reject) => {
79152 xhr.open("POST", input.url, true);
80153 xhr.timeout = UPLOAD_TIMEOUT_MS;
81− xhr.setRequestHeader("Content-Type", input.file.type);
154+ xhr.setRequestHeader("Content-Type", input.mimeType);
82155 xhr.upload.addEventListener("progress", (event) => {
83156 if (event.lengthComputable && event.total > 0) {
84157 input.onProgress(event.loaded / event.total);
@@ -101,109 +174,162 @@ function uploadBytes(input: { · 16 unmodified lines
101174 }
102175
103176 async function runUpload(job: UploadJob): Promise<void> {
104− const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find(
105− (supportedMimeType) => supportedMimeType === job.image.mimeType.toLowerCase(),
106− );
107− if (!mimeType) {
108− setUploadState(job.image.id, {
109− status: "failed",
177+ if (job.persistedAttachmentId) {
178+ const verification = await verifyPersistedAttachmentUpload({
179+ registry: appAtomRegistry,
180+ createAssetUrl: assetEnvironment.createUrl,
110181 environmentId: job.environmentId,
111− reason: "Unsupported image type",
112− ...(job.previous ? { previous: job.previous } : {}),
182+ attachmentId: job.persistedAttachmentId,
113183 });
114− return;
115− }
116−
117− const minted = await runAtomCommand(
118− appAtomRegistry,
119− attachmentEnvironment.createUploadUrl,
120− {
121− environmentId: job.environmentId,
122− input: {
123− name: job.image.name,
124− mimeType,
125− sizeBytes: job.image.file.size,
126− },
127− },
128− { reportFailure: false },
129− );
130− if (job.cancelled) {
131− if (minted._tag === "Success") {
132− deletePendingUpload(job.environmentId, minted.value.attachmentId);
184+ if (job.cancelled) {
185+ return;
186+ }
187+ if (verification.status === "verified") {
188+ setUploadState(job.image.id, {
189+ status: "ready",
190+ environmentId: job.environmentId,
191+ attachmentId: job.persistedAttachmentId,
192+ });
193+ stampDraftFileUpload(job, job.persistedAttachmentId);
194+ return;
195+ }
196+ if (verification.status === "missing" && !job.image.file && job.image.type === "file") {
197+ const draftTarget = resolveCurrentFileDraftTarget(job);
198+ if (
199+ draftTarget !== undefined &&
200+ useComposerDraftStore
201+ .getState()
202+ .markFileUploadMissing(
203+ draftTarget,
204+ job.image.id,
205+ job.environmentId,
206+ job.persistedAttachmentId,
207+ )
208+ ) {
209+ clearUploadState(job.image.id);
210+ return;
211+ }
212+ }
213+ if (verification.status === "failed" || !job.image.file) {
214+ // No `attachmentId` here: a failed state's id marks a pending upload
215+ // this queue minted, which retry and release then delete. The persisted
216+ // id is the only server copy of a hydrated file, so a transient
217+ // verification failure must leave it in place for the next retry.
218+ setUploadState(job.image.id, {
219+ status: "failed",
220+ environmentId: job.environmentId,
221+ reason:
222+ verification.status === "missing"
223+ ? "Uploaded file expired. Remove it and attach it again."
224+ : "Uploaded file could not be verified. Retry when the server reconnects.",
225+ ...(job.previous ? { previous: job.previous } : {}),
226+ });
227+ return;
133228 }
134− return;
135229 }
136− if (minted._tag !== "Success") {
230+
231+ const mimeType =
232+ job.image.type === "file"
233+ ? job.image.mimeType.toLowerCase()
234+ : PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find(
235+ (supportedMimeType) => supportedMimeType === job.image.mimeType.toLowerCase(),
236+ );
237+ if (!mimeType) {
137238 setUploadState(job.image.id, {
138239 status: "failed",
139240 environmentId: job.environmentId,
140− reason: "Upload could not start",
241+ reason: "Unsupported image type",
141242 ...(job.previous ? { previous: job.previous } : {}),
142243 });
143244 return;
144245 }
145− job.attachmentId = minted.value.attachmentId;
146−
147− const connection = readPreparedConnection(job.environmentId);
148− const url = connection ? resolveAssetUrl(connection.httpBaseUrl, minted.value.relativeUrl) : null;
149− if (!url) {
246+ const file = job.image.file;
247+ if (!file) {
150248 setUploadState(job.image.id, {
151249 status: "failed",
152250 environmentId: job.environmentId,
153− reason: "Not connected",
154− attachmentId: minted.value.attachmentId,
251+ reason: "Original file is no longer available",
155252 ...(job.previous ? { previous: job.previous } : {}),
156253 });
157254 return;
158255 }
159256
160257 let lastStep = -1;
161− const upload = uploadBytes({
162− url,
163− file: job.image.file,
164− onProgress: (progress) => {
165− const step = Math.floor(progress * 20);
166− if (step === lastStep || job.cancelled) {
167− return;
258+ const result = await runAttachmentUploadCycle({
259+ registry: appAtomRegistry,
260+ createUploadUrl: attachmentEnvironment.createUploadUrl,
261+ remove: attachmentEnvironment.remove,
262+ environmentId: job.environmentId,
263+ upload: {
264+ ...(job.image.type === "file" ? { type: "file" as const } : {}),
265+ name: job.image.name,
266+ mimeType,
267+ sizeBytes: file.size,
268+ },
269+ resolveUploadUrl: (relativeUrl) => {
270+ const connection = readPreparedConnection(job.environmentId);
271+ return connection ? resolveAssetUrl(connection.httpBaseUrl, relativeUrl) : null;
272+ },
273+ transport: (url) =>
274+ uploadBytes({
275+ url,
276+ file,
277+ mimeType,
278+ onProgress: (progress) => {
279+ const step = Math.floor(progress * 20);
280+ if (step === lastStep || job.cancelled) {
281+ return;
282+ }
283+ lastStep = step;
284+ setUploadState(job.image.id, {
285+ status: "uploading",
286+ environmentId: job.environmentId,
287+ progress,
288+ ...(job.previous ? { previous: job.previous } : {}),
289+ });
290+ },
291+ }),
292+ onMinted: (attachmentId) => {
293+ if (job.cancelled) {
294+ return "cancel";
168295 }
169− lastStep = step;
170− setUploadState(job.image.id, {
171− status: "uploading",
172− environmentId: job.environmentId,
173− progress,
174− ...(job.previous ? { previous: job.previous } : {}),
175− });
296+ job.attachmentId = attachmentId;
297+ return "continue";
298+ },
299+ onTransferStart: (abort) => {
300+ job.abort = abort;
176301 },
177302 });
178− job.abort = upload.abort;
179−
180− try {
181− await upload.done;
182− if (job.cancelled) {
183− return;
184− }
303+ job.abort = null;
304+ if (result.status === "cancelled" || job.cancelled) {
305+ return;
306+ }
307+ if (result.status === "uploaded") {
185308 setUploadState(job.image.id, {
186309 status: "ready",
187310 environmentId: job.environmentId,
188− attachmentId: minted.value.attachmentId,
311+ attachmentId: result.attachmentId,
189312 });
313+ stampDraftFileUpload(job, result.attachmentId);
190314 if (job.previous) {
191315 deletePendingUpload(job.previous.environmentId, job.previous.attachmentId);
192316 }
193− } catch (error) {
194− if (job.cancelled) {
195− return;
196− }
197− setUploadState(job.image.id, {
198− status: "failed",
199− environmentId: job.environmentId,
200− reason: error instanceof Error ? error.message : "Upload failed",
201− attachmentId: minted.value.attachmentId,
202− ...(job.previous ? { previous: job.previous } : {}),
203− });
204− } finally {
205− job.abort = null;
317+ return;
206318 }
319+ setUploadState(job.image.id, {
320+ status: "failed",
321+ environmentId: job.environmentId,
322+ reason:
323+ result.step === "mint"
324+ ? "Upload could not start"
325+ : result.step === "resolve-url"
326+ ? "Not connected"
327+ : result.error instanceof Error
328+ ? result.error.message
329+ : "Upload failed",
330+ ...(result.attachmentId ? { attachmentId: result.attachmentId } : {}),
331+ ...(job.previous ? { previous: job.previous } : {}),
332+ });
207333 }
208334
209335 function pumpUploads(): void {
@@ -249,7 +375,9 @@ function pumpUploads(): void { · 39 unmodified lines
249375
250376 export function startAttachmentUpload(input: {
251377 readonly environmentId: EnvironmentId;
252− readonly image: ComposerImageAttachment;
378+ readonly image: ComposerImageAttachment | ComposerFileAttachment;
379+ /** Draft that owns the file; lets a background completion persist its ids. */
380+ readonly draftTarget?: ComposerThreadTarget;
253381 }): void {
254382 const existingJob = jobsByImageId.get(input.image.id);
255383 if (existingJob?.environmentId === input.environmentId) {
@@ -287,7 +415,13 @@ export function startAttachmentUpload(input: { · 31 unmodified lines
287415 const job: UploadJob = {
288416 image: input.image,
289417 environmentId: input.environmentId,
418+ ...(input.draftTarget !== undefined ? { draftTarget: input.draftTarget } : {}),
290419 ...(previous ? { previous } : {}),
420+ ...(input.image.type === "file" &&
421+ input.image.uploadEnvironmentId === input.environmentId &&
422+ input.image.uploadedAttachmentId
423+ ? { persistedAttachmentId: input.image.uploadedAttachmentId }
424+ : {}),
291425 settled,
292426 resolveSettled,
293427 attachmentId: null,
@@ -306,6 +440,11 @@ export function startAttachmentUpload(input: { · 12 unmodified lines
306440 pumpUploads();
307441 }
308442
443+/**
444+ * Stops the job and deletes only the pending upload it minted itself. A
445+ * persisted draft upload survives cancellation (an environment switch cancels
446+ * the old job, and the draft still references that server copy).
447+ */
309448 export function cancelAttachmentUpload(imageId: string): void {
310449 const job = jobsByImageId.get(imageId);
311450 if (!job) {
@@ -340,12 +479,42 @@ export function releaseAttachmentUpload(imageId: string): void { · 28 unmodified lines
340479 clearUploadState(imageId);
341480 }
342481
482+export function releasePersistedAttachmentUpload(input: {
483+ readonly id: string;
484+ readonly environmentId: EnvironmentId;
485+ readonly attachmentId: string;
486+}): void {
487+ const upload = readAttachmentUpload(input.id);
488+ if (
489+ upload?.status === "ready" &&
490+ upload.environmentId === input.environmentId &&
491+ upload.attachmentId === input.attachmentId
492+ ) {
493+ releaseAttachmentUpload(input.id);
494+ return;
495+ }
496+ const job = jobsByImageId.get(input.id);
497+ if (
498+ job?.environmentId === input.environmentId &&
499+ job.persistedAttachmentId === input.attachmentId
500+ ) {
501+ // Tears down the in-flight verification or re-upload. The queue only
502+ // deletes ids it minted, so the persisted id still needs the delete below.
503+ releaseAttachmentUpload(input.id);
504+ }
505+ deletePendingUpload(input.environmentId, input.attachmentId);
506+}
507+
343508 export function retryAttachmentUpload(input: {
344509 readonly environmentId: EnvironmentId;
345− readonly image: ComposerImageAttachment;
510+ readonly image: ComposerImageAttachment | ComposerFileAttachment;
511+ readonly draftTarget?: ComposerThreadTarget;
346512 }): void {
347513 const previous = readAttachmentUpload(input.image.id);
348514 cancelAttachmentUpload(input.image.id);
515+ // A failed state's `attachmentId` is always one this queue minted, so this
516+ // never deletes a persisted draft upload. Retrying a hydrated file whose
517+ // verification failed leaves the server copy alone and verifies it again.
349518 if (previous?.status === "failed" && previous.attachmentId) {
350519 deletePendingUpload(previous.environmentId, previous.attachmentId);
351520 }
@@ -357,13 +526,30 @@ export function retryAttachmentUpload(input: { · 5 unmodified lines
357526 startAttachmentUpload(input);
358527 }
359528
529+/**
530+ * Checks that a stashed upload still exists on the server. Pending uploads
531+ * are swept after 24 hours, so a stash restore asks first instead of handing
532+ * the composer a dead reference.
533+ */
534+export function verifyStashedAttachmentUpload(input: {
535+ readonly environmentId: EnvironmentId;
536+ readonly attachmentId: string;
537+}): Promise<PersistedAttachmentVerification> {
538+ return verifyPersistedAttachmentUpload({
539+ registry: appAtomRegistry,
540+ createAssetUrl: assetEnvironment.createUrl,
541+ environmentId: input.environmentId,
542+ attachmentId: input.attachmentId,
543+ });
544+}
545+
360546 export async function awaitAttachmentUploads(imageIds: ReadonlyArray<string>): Promise<void> {
361547 await Promise.all(imageIds.map((imageId) => jobsByImageId.get(imageId)?.settled));
362548 }
363549
364550 export function getUploadedAttachments(input: {
365551 readonly environmentId: EnvironmentId;
366− readonly images: ReadonlyArray<ComposerImageAttachment>;
552+ readonly images: ReadonlyArray<ComposerImageAttachment | ComposerFileAttachment>;
367553 }): ChatAttachment[] | null {
368554 const attachments: ChatAttachment[] = [];
369555 for (const image of input.images) {
@@ -372,7 +558,7 @@ export function getUploadedAttachments(input: { · 2 unmodified lines
372558 return null;
373559 }
374560 attachments.push({
375− type: "image",
561+ type: image.type,
376562 id: upload.attachmentId,
377563 name: image.name,
378564 mimeType: image.mimeType,
@@ -382,8 +568,42 @@ export function getUploadedAttachments(input: { · 3 unmodified lines
382568 return attachments;
383569 }
384570
385−export function releaseAttachmentUploads(images: ReadonlyArray<ComposerImageAttachment>): void {
386− for (const image of images) {
387− releaseAttachmentUpload(image.id);
571+/**
572+ * The one owner for discarding a draft attachment's server-side upload. The
573+ * queue-keyed release only sees in-memory state, so after a reload it finds
574+ * nothing and the pending upload leaks. When the draft carries a persisted
575+ * `uploadedAttachmentId` (which survives reloads), route through the persisted
576+ * release; it still prefers the queue path when the queue owns that same
577+ * attachment. Every draft discard path must funnel through here.
578+ */
579+export function releaseDraftAttachment(
580+ attachment: ComposerImageAttachment | ComposerFileAttachment,
581+): void {
582+ if (
583+ attachment.type === "file" &&
584+ attachment.uploadedAttachmentId !== undefined &&
585+ attachment.uploadEnvironmentId !== undefined
586+ ) {
587+ releasePersistedAttachmentUpload({
588+ id: attachment.id,
589+ environmentId: attachment.uploadEnvironmentId,
590+ attachmentId: attachment.uploadedAttachmentId,
591+ });
592+ // A failed re-upload after verification can hold a newer minted
593+ // attachment under the queue key. Release whatever is left so neither
594+ // copy stays behind. (The pending delete is idempotent server-side.)
595+ if (jobsByImageId.has(attachment.id) || readAttachmentUpload(attachment.id)) {
596+ releaseAttachmentUpload(attachment.id);
597+ }
598+ return;
599+ }
600+ releaseAttachmentUpload(attachment.id);
601+}
602+
603+export function releaseDraftAttachments(
604+ attachments: ReadonlyArray<ComposerImageAttachment | ComposerFileAttachment>,
605+): void {
606+ for (const attachment of attachments) {
607+ releaseDraftAttachment(attachment);
388608 }
389609 }
apps/web/src/lib/attachmentUploadState.test.ts+3−3
@@ -34,7 +34,7 @@ describe("attachmentUploadBlockReason", () => { · 33 unmodified lines
3434 "image-1": { status: "uploading", environmentId, progress: 0.5 },
3535 },
3636 }),
37− ).toBe("Images still uploading");
37+ ).toBe("Attachments still uploading");
3838 });
3939
4040 it("asks the user to retry or remove failed uploads", () => {
@@ -46,7 +46,7 @@ describe("attachmentUploadBlockReason", () => { · 5 unmodified lines
4646 "image-1": { status: "failed", environmentId, reason: "Upload failed" },
4747 },
4848 }),
49− ).toBe("Retry or remove the failed image");
49+ ).toBe("Retry or remove the failed attachment");
5050 });
5151
5252 it("does not accept an upload from another environment", () => {
@@ -62,7 +62,7 @@ describe("attachmentUploadBlockReason", () => { · 9 unmodified lines
6262 },
6363 },
6464 }),
65− ).toBe("Image still uploading");
65+ ).toBe("Attachment still uploading");
6666 });
6767 });
6868
apps/web/src/lib/attachmentUploadState.ts+9−2
@@ -18,6 +18,11 @@ export type AttachmentUploadState = · 17 unmodified lines
1818 readonly status: "failed";
1919 readonly environmentId: EnvironmentId;
2020 readonly reason: string;
21+ /**
22+ * A pending upload the queue minted for this failed attempt. Retry and
23+ * release delete it, so it must never carry a draft's persisted
24+ * attachment id.
25+ */
2126 readonly attachmentId?: string;
2227 readonly previous?: ReadyAttachmentUpload;
2328 };
@@ -40,10 +45,12 @@ export function attachmentUploadBlockReason(input: { · 16 unmodified lines
4045 }
4146
4247 if (failed > 0) {
43− return failed === 1 ? "Retry or remove the failed image" : "Retry or remove the failed images";
48+ return failed === 1
49+ ? "Retry or remove the failed attachment"
50+ : "Retry or remove the failed attachments";
4451 }
4552 if (pending > 0) {
46− return pending === 1 ? "Image still uploading" : "Images still uploading";
53+ return pending === 1 ? "Attachment still uploading" : "Attachments still uploading";
4754 }
4855 return null;
4956 }
apps/web/src/lib/composerDraftUploads.ts+21−4
@@ -1,23 +1,40 @@
11 import type { ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts";
2+import { scopedThreadKey } from "@t3tools/client-runtime/environment";
23
34 import { type DraftId, useComposerDraftStore } from "../composerDraftStore";
4−import { releaseAttachmentUploads } from "./attachmentUploadQueue";
5+import { releaseDraftAttachments } from "./attachmentUploadQueue";
56
67 export function releaseComposerDraftUploads(target: ScopedThreadRef | DraftId): void {
78 const draft = useComposerDraftStore.getState().getComposerDraft(target);
89 if (draft) {
9− releaseAttachmentUploads(draft.images);
10+ releaseDraftAttachments([...draft.images, ...draft.files]);
1011 }
1112 }
1213
13−export function releaseProjectDraftUploads(projectRef: ScopedProjectRef): void {
14+/**
15+ * Releases every upload a deleted project's drafts still hold. Draft-thread
16+ * sessions carry their project ref, but drafts on the project's real threads
17+ * live in `draftsByThreadKey` under scoped thread keys with no project in the
18+ * key, so the caller passes the project's thread refs alongside.
19+ */
20+export function releaseProjectDraftUploads(
21+ projectRef: ScopedProjectRef,
22+ projectThreadRefs: ReadonlyArray<ScopedThreadRef> = [],
23+): void {
1424 const store = useComposerDraftStore.getState();
1525 for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) {
1626 if (
1727 session.environmentId === projectRef.environmentId &&
1828 session.projectId === projectRef.projectId
1929 ) {
20− releaseAttachmentUploads(store.draftsByThreadKey[draftKey]?.images ?? []);
30+ const draft = store.draftsByThreadKey[draftKey];
31+ releaseDraftAttachments(draft ? [...draft.images, ...draft.files] : []);
32+ }
33+ }
34+ for (const threadRef of projectThreadRefs) {
35+ const draft = store.draftsByThreadKey[scopedThreadKey(threadRef)];
36+ if (draft) {
37+ releaseDraftAttachments([...draft.images, ...draft.files]);
2138 }
2239 }
2340 }
apps/web/src/promptStashStore.test.ts+57−0
@@ -1,4 +1,5 @@
11 import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
2+import { EnvironmentId } from "@t3tools/contracts";
23
34 import { removeLocalStorageItem } from "./hooks/useLocalStorage";
45
@@ -162,6 +163,62 @@ describe("promptStashStore", () => { · 157 unmodified lines
162163 expect(entry?.pendingImageCount).toBe(0);
163164 });
164165
166+ it("takeEntry returns images and drop metadata finalized after a menu snapshot", () => {
167+ const store = usePromptStashStore.getState();
168+ store.stashEntry({ ...makeEntry({ id: "pending-restore" }), pendingImageCount: 1 });
169+ const menuSnapshot = usePromptStashStore.getState().entries[0];
170+ expect(menuSnapshot?.attachments).toEqual([]);
171+
172+ store.finalizeEntryImages("pending-restore", {
173+ attachments: [
174+ {
175+ id: "img-finalized",
176+ name: "finalized.webp",
177+ mimeType: "image/webp",
178+ sizeBytes: 12,
179+ dataUrl: "data:image/webp;base64,BBBB",
180+ },
181+ ],
182+ droppedImageNames: ["too-large.png"],
183+ unreadableImageNames: ["unreadable.png"],
184+ });
185+
186+ const { entry: taken } = store.takeEntry("pending-restore");
187+ expect(taken).not.toBe(menuSnapshot);
188+ expect(taken).toMatchObject({
189+ attachments: [
190+ {
191+ id: "img-finalized",
192+ name: "finalized.webp",
193+ },
194+ ],
195+ droppedImageNames: ["too-large.png"],
196+ unreadableImageNames: ["unreadable.png"],
197+ pendingImageCount: 0,
198+ });
199+ });
200+
201+ it("preserves uploaded file references without storing file contents", () => {
202+ const store = usePromptStashStore.getState();
203+ const file = {
204+ id: "file-1",
205+ name: "report.pdf",
206+ mimeType: "application/pdf",
207+ sizeBytes: 42,
208+ attachmentId: "pending-report-pdf",
209+ environmentId: EnvironmentId.make("environment-1"),
210+ };
211+
212+ store.stashEntry({ ...makeEntry({ id: "with-file" }), files: [file] });
213+ store.finalizeEntryImages("with-file", {
214+ attachments: [],
215+ droppedImageNames: [],
216+ unreadableImageNames: [],
217+ });
218+
219+ expect(usePromptStashStore.getState().entries[0]?.files).toEqual([file]);
220+ });
221+
165222 it("finalizeEntryImages reports false when the entry was already taken", () => {
166223 const store = usePromptStashStore.getState();
167224 store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 });
apps/web/src/promptStashStore.ts+7−5
@@ -1,7 +1,10 @@
11 import * as Schema from "effect/Schema";
22 import { create } from "zustand";
33
4−import { PersistedComposerImageAttachment } from "./composerDraftStore";
4+import {
5+ PersistedComposerFileAttachment,
6+ PersistedComposerImageAttachment,
7+} from "./composerDraftStore";
58 import { createMemoryStorage, type StateStorage } from "./lib/storage";
69
710 export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2";
@@ -27,16 +30,15 @@ export const MAX_STASH_ENTRIES = 20; · 19 unmodified lines
2730 export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000;
2831
2932 /**
30− * A stashed prompt carries only what every provider can accept: text and
31− * image attachments. Deliberately no provider instance or model selection —
32− * the point of stashing is to move a prompt into a different thread or
33− * provider, so restoring must never drag the old model choice along.
33+ * Stashed files keep signed-upload references instead of storing their bytes.
34+ * Image payloads remain subject to the localStorage budget.
3435 */
3536 const StashEntrySchema = Schema.Struct({
3637 id: Schema.String,
3738 createdAt: Schema.String,
3839 prompt: Schema.String,
3940 attachments: Schema.Array(PersistedComposerImageAttachment),
41+ files: Schema.optionalKey(Schema.Array(PersistedComposerFileAttachment)),
4042 /** Names of images that exceeded the attachment budget and were not saved. */
4143 droppedImageNames: Schema.Array(Schema.String),
4244 /**
apps/web/src/state/attachments.ts+2−12
@@ -1,15 +1,5 @@
1−import { WS_METHODS } from "@t3tools/contracts";
2−import { createEnvironmentRpcCommand } from "@t3tools/client-runtime/state/runtime";
1+import { createAttachmentEnvironmentAtoms } from "@t3tools/client-runtime/state/attachments";
32
43 import { connectionAtomRuntime } from "../connection/runtime";
54
6−export const attachmentEnvironment = {
7− createUploadUrl: createEnvironmentRpcCommand(connectionAtomRuntime, {
8− label: "environment-command:attachments:create-upload-url",
9− tag: WS_METHODS.attachmentsCreateUploadUrl,
10− }),
11− remove: createEnvironmentRpcCommand(connectionAtomRuntime, {
12− label: "environment-command:attachments:delete",
13− tag: WS_METHODS.attachmentsDelete,
14− }),
15−};
5+export const attachmentEnvironment = createAttachmentEnvironmentAtoms(connectionAtomRuntime);
apps/web/src/types.ts+18−7
@@ -1,6 +1,7 @@
11 import type {
2− ChatAttachment as ContractChatAttachment,
2+ ChatFileAttachment as ContractChatFileAttachment,
33 ChatImageAttachment as ContractChatImageAttachment,
4+ ChatUnknownAttachment as ContractChatUnknownAttachment,
45 OrchestrationCheckpointFile,
56 OrchestrationCheckpointSummary,
67 OrchestrationLatestTurn,
@@ -36,18 +37,28 @@ export interface ChatImageAttachment extends ContractChatImageAttachment { · 29 unmodified lines
3637 readonly previewUrl?: string;
3738 }
3839
39−// Non-image members pass through with the contract shape. The web UI renders
40−// them once it grows file support; until then they only need to typecheck.
41−export type ChatAttachment =
42− | ChatImageAttachment
43− | Exclude<ContractChatAttachment, ContractChatImageAttachment>;
40+export interface ChatFileAttachment extends ContractChatFileAttachment {
41+ readonly previewUrl?: string;
42+ readonly downloadable?: boolean;
43+}
44+
45+// Attachment types this build does not know pass through with the contract
46+// shape. The UI renders them as inert rows so a newer server cannot crash an
47+// older client.
48+export type ChatUnknownAttachment = ContractChatUnknownAttachment;
49+
50+export type ChatAttachment = ChatImageAttachment | ChatFileAttachment | ChatUnknownAttachment;
4451
4552 // The union has an open member (`type: string`), so a literal comparison does
46−// not narrow. Use this guard wherever image-only fields are read.
53+// not narrow. Use these guards wherever type-specific fields are read.
4754 export function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment {
4855 return attachment.type === "image";
4956 }
5057
58+export function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment {
59+ return attachment.type === "file";
60+}
61+
5162 export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> {
5263 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
5364 }
docs/user/composer.md+20−2
@@ -4,8 +4,17 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee · 3 unmodified lines
44 composer and shows how many characters need to be removed. Shorten the draft or split it into
55 multiple messages, then send again in the same thread.
66
7−On servers that support direct uploads, images upload as soon as you add them. The send button
8−becomes available after every upload finishes. Failed uploads can be retried or removed.
7+You can attach images up to 10 MB. On servers that support file uploads, web and desktop can also
8+attach text files, PDFs, ZIP archives, and other files. Each file can be up to the limit advertised
9+by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files
10+upload directly to the environment, where your agent can read, copy, or edit them by their file path.
11+
12+On web and desktop, attachments upload as soon as you add them. The send button becomes available
13+after every upload finishes. Failed uploads can be retried or removed. On mobile, attachments are
14+currently limited to images.
15+
16+If you reload before a file finishes uploading, the draft keeps the file's name and shows **Attach
17+again** next to it. Attach the file again or remove it, then send.
918
1019 On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into
1120 the composer or paste them into a message.
@@ -14,6 +23,15 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such · 2 unmodified lines
1423 GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list
1524 when starting a thread or changing an existing thread's model.
1625
26+## Prompt stash
27+
28+Use the default shortcut, `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux, to stash the current
29+prompt and its attachments after all file uploads finish. Restore the entry later from the stash
30+menu. Stashes that contain files must be restored in the environment where those files were
31+uploaded. Stashed files stay uploaded on the server for 24 hours. If you restore an entry after
32+that, the file comes back with **Attach again** next to it. Attach the file again or remove it, then
33+send.
34+
1735 ## Commands and skills
1836
1937 Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source,
packages/client-runtime/package.json+4−0
@@ -55,6 +55,10 @@ · 54 unmodified lines
5555 "types": "./src/state/assets.ts",
5656 "default": "./src/state/assets.ts"
5757 },
58+ "./state/attachments": {
59+ "types": "./src/state/attachments.ts",
60+ "default": "./src/state/attachments.ts"
61+ },
5862 "./state/connections": {
5963 "types": "./src/state/connections.ts",
6064 "default": "./src/state/connections.ts"
packages/client-runtime/src/state/attachments.test.ts+202−0
@@ -0,0 +1,202 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import {
3+ EnvironmentId,
4+ PROVIDER_SEND_TURN_MAX_FILE_BYTES,
5+ type AttachmentCreateUploadUrlInput,
6+ type AttachmentCreateUploadUrlResult,
7+ type AttachmentDeleteInput,
8+} from "@t3tools/contracts";
9+import * as Effect from "effect/Effect";
10+import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
11+
12+import type { AtomCommand } from "./runtime.ts";
13+import {
14+ clampFileAttachmentUploadBytes,
15+ fileAttachmentTooLargeMessage,
16+ formatAttachmentSize,
17+ runAttachmentUploadCycle,
18+ verifyPersistedAttachmentUpload,
19+} from "./attachments.ts";
20+
21+const environmentId = EnvironmentId.make("environment-1");
22+// The cycle threads the registry through to the commands untouched, so the
23+// fakes below can ignore it.
24+const registry = {} as AtomRegistry.AtomRegistry;
25+
26+type CreateUploadUrlCommand = AtomCommand<
27+ { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput },
28+ AttachmentCreateUploadUrlResult,
29+ never
30+>;
31+
32+type RemoveCommand = AtomCommand<
33+ { readonly environmentId: EnvironmentId; readonly input: AttachmentDeleteInput },
34+ unknown,
35+ never
36+>;
37+
38+function makeCreateUploadUrl(attachmentId: string): CreateUploadUrlCommand {
39+ return {
40+ label: "test:create-upload-url",
41+ run: async () =>
42+ AsyncResult.success<AttachmentCreateUploadUrlResult, never>({
43+ attachmentId,
44+ relativeUrl: `/api/attachments/upload/${attachmentId}`,
45+ expiresAt: 1,
46+ }),
47+ };
48+}
49+
50+const removeCalls: string[] = [];
51+const remove: RemoveCommand = {
52+ label: "test:remove",
53+ run: async (_registry, input) => {
54+ removeCalls.push(input.input.attachmentId);
55+ return AsyncResult.success(undefined);
56+ },
57+};
58+
59+const uploadInput: AttachmentCreateUploadUrlInput = {
60+ type: "file",
61+ name: "report.pdf",
62+ mimeType: "application/pdf",
63+ sizeBytes: 3,
64+};
65+
66+describe("runAttachmentUploadCycle", () => {
67+ it("mints, transfers, and reports the attachment id", async () => {
68+ const transferred: string[] = [];
69+ const result = await runAttachmentUploadCycle({
70+ registry,
71+ createUploadUrl: makeCreateUploadUrl("pending-1"),
72+ remove,
73+ environmentId,
74+ upload: uploadInput,
75+ resolveUploadUrl: (relativeUrl) => `https://environment.test${relativeUrl}`,
76+ transport: (url) => {
77+ transferred.push(url);
78+ return { done: Promise.resolve(), abort: () => {} };
79+ },
80+ });
81+
82+ expect(result).toEqual({ status: "uploaded", attachmentId: "pending-1" });
83+ expect(transferred).toEqual(["https://environment.test/api/attachments/upload/pending-1"]);
84+ });
85+
86+ it("deletes the fresh mint when the caller cancels at onMinted", async () => {
87+ removeCalls.length = 0;
88+ const result = await runAttachmentUploadCycle({
89+ registry,
90+ createUploadUrl: makeCreateUploadUrl("pending-cancelled"),
91+ remove,
92+ environmentId,
93+ upload: uploadInput,
94+ resolveUploadUrl: () => "https://environment.test/upload",
95+ transport: () => {
96+ throw new Error("transport must not run after cancel");
97+ },
98+ onMinted: () => "cancel",
99+ });
100+
101+ expect(result).toEqual({ status: "cancelled", attachmentId: "pending-cancelled" });
102+ expect(removeCalls).toEqual(["pending-cancelled"]);
103+ });
104+
105+ it("keeps the minted id on transfer failure so the caller can retry or release", async () => {
106+ removeCalls.length = 0;
107+ const result = await runAttachmentUploadCycle({
108+ registry,
109+ createUploadUrl: makeCreateUploadUrl("pending-failed"),
110+ remove,
111+ environmentId,
112+ upload: uploadInput,
113+ resolveUploadUrl: () => "https://environment.test/upload",
114+ transport: () => ({
115+ done: Promise.reject(new Error("Upload rejected (413)")),
116+ abort: () => {},
117+ }),
118+ });
119+
120+ expect(result).toMatchObject({
121+ status: "failed",
122+ step: "transfer",
123+ attachmentId: "pending-failed",
124+ });
125+ expect(removeCalls).toEqual([]);
126+ });
127+});
128+
129+describe("verifyPersistedAttachmentUpload", () => {
130+ it("hits the server on every verification instead of reusing a cached failure", async () => {
131+ // Mirrors the app's asset URL query atom: SWR-cached with a long stale
132+ // window and kept alive across calls. Without a forced refresh, the
133+ // second verification would read the cached failure and never retry.
134+ let lookups = 0;
135+ const assetUrlAtom = Atom.make(
136+ // Async like the real RPC, so the first read is still in flight when
137+ // the query decides whether a refresh is needed.
138+ Effect.promise(() => Promise.resolve()).pipe(
139+ Effect.flatMap(() => {
140+ lookups += 1;
141+ return lookups === 1
142+ ? Effect.fail({ _tag: "TransportError" } as const)
143+ : Effect.succeed({ url: "/api/assets/pending-1" });
144+ }),
145+ ),
146+ ).pipe(Atom.swr({ staleTime: 60_000 }), Atom.keepAlive);
147+ const liveRegistry = AtomRegistry.make();
148+
149+ const verify = () =>
150+ verifyPersistedAttachmentUpload({
151+ registry: liveRegistry,
152+ createAssetUrl: () => assetUrlAtom,
153+ environmentId,
154+ attachmentId: "pending-1",
155+ });
156+
157+ const first = await verify();
158+ expect(first).toMatchObject({ status: "failed" });
159+
160+ const second = await verify();
161+ expect(second).toEqual({ status: "verified" });
162+ expect(lookups).toBe(2);
163+ });
164+});
165+
166+describe("file attachment limits", () => {
167+ it("clamps the advertised limit to the turn contract cap", () => {
168+ expect(clampFileAttachmentUploadBytes(1024)).toBe(1024);
169+ expect(clampFileAttachmentUploadBytes(PROVIDER_SEND_TURN_MAX_FILE_BYTES * 2)).toBe(
170+ PROVIDER_SEND_TURN_MAX_FILE_BYTES,
171+ );
172+ });
173+
174+ it("formats attachment row sizes", () => {
175+ expect(formatAttachmentSize(3 * 1024 * 1024)).toBe("3.0 MB");
176+ expect(formatAttachmentSize(1)).toBe("1 KB");
177+ });
178+
179+ it("formats small upload limits without rounding them to zero MB", () => {
180+ expect(fileAttachmentTooLargeMessage("tiny.txt", 1)).toBe(
181+ "'tiny.txt' exceeds the 1 byte attachment limit.",
182+ );
183+ expect(fileAttachmentTooLargeMessage("small.txt", 1024)).toBe(
184+ "'small.txt' exceeds the 1 KB attachment limit.",
185+ );
186+ expect(fileAttachmentTooLargeMessage("exact.txt", 1025)).toBe(
187+ "'exact.txt' exceeds the 1025 bytes attachment limit.",
188+ );
189+ expect(fileAttachmentTooLargeMessage("medium.zip", 512 * 1024)).toBe(
190+ "'medium.zip' exceeds the 512 KB attachment limit.",
191+ );
192+ });
193+
194+ it("keeps whole-MB upload limits for standard server caps", () => {
195+ expect(fileAttachmentTooLargeMessage("one.bin", 1024 * 1024)).toBe(
196+ "'one.bin' exceeds the 1 MB attachment limit.",
197+ );
198+ expect(fileAttachmentTooLargeMessage("big.zip", 50 * 1024 * 1024)).toBe(
199+ "'big.zip' exceeds the 50 MB attachment limit.",
200+ );
201+ });
202+});
packages/client-runtime/src/state/attachments.ts+236−0
@@ -0,0 +1,236 @@
1+import {
2+ PROVIDER_SEND_TURN_MAX_FILE_BYTES,
3+ WS_METHODS,
4+ type AttachmentCreateUploadUrlInput,
5+ type AttachmentCreateUploadUrlResult,
6+ type AttachmentDeleteInput,
7+ type EnvironmentId,
8+} from "@t3tools/contracts";
9+import type { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
10+
11+import type { EnvironmentRegistry } from "../connection/registry.ts";
12+import {
13+ createEnvironmentRpcCommand,
14+ executeAtomQuery,
15+ runAtomCommand,
16+ squashAtomCommandFailure,
17+ type AtomCommand,
18+} from "./runtime.ts";
19+
20+/**
21+ * RPC commands for pending chat attachment uploads. Mirrors
22+ * `createAssetEnvironmentAtoms`: each client instantiates it with its own
23+ * connection runtime (`attachmentEnvironment` in web and mobile).
24+ */
25+export function createAttachmentEnvironmentAtoms<R, E>(
26+ runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
27+) {
28+ return {
29+ createUploadUrl: createEnvironmentRpcCommand(runtime, {
30+ label: "environment-command:attachments:create-upload-url",
31+ tag: WS_METHODS.attachmentsCreateUploadUrl,
32+ }),
33+ remove: createEnvironmentRpcCommand(runtime, {
34+ label: "environment-command:attachments:delete",
35+ tag: WS_METHODS.attachmentsDelete,
36+ }),
37+ };
38+}
39+
40+/**
41+ * Whether a failed asset lookup means the attachment no longer exists on the
42+ * server, as opposed to a transient transport failure. Pending uploads expire,
43+ * so this is the signal to upload the bytes again rather than retry the lookup.
44+ *
45+ * A structural `_tag` check rather than a schema check: the squashed cause of
46+ * a failed RPC is not guaranteed to be a decoded error class instance, only a
47+ * tagged value.
48+ */
49+export function isAssetAttachmentNotFoundFailure(error: unknown): boolean {
50+ return (
51+ typeof error === "object" &&
52+ error !== null &&
53+ "_tag" in error &&
54+ error._tag === "AssetAttachmentNotFoundError"
55+ );
56+}
57+
58+export type PersistedAttachmentVerification =
59+ | { readonly status: "verified" }
60+ | { readonly status: "missing" }
61+ | { readonly status: "failed"; readonly error: unknown };
62+
63+/**
64+ * Checks that a previously uploaded pending attachment still exists on the
65+ * server by minting an asset URL for it. `verified` means the send can reuse
66+ * the stored bytes, `missing` means the upload expired and the bytes must be
67+ * uploaded again, `failed` means the server could not be asked.
68+ */
69+export async function verifyPersistedAttachmentUpload<A, E>(input: {
70+ readonly registry: AtomRegistry.AtomRegistry;
71+ readonly createAssetUrl: (query: {
72+ readonly environmentId: EnvironmentId;
73+ readonly input: {
74+ readonly resource: { readonly _tag: "attachment"; readonly attachmentId: string };
75+ };
76+ }) => Atom.Atom<AsyncResult.AsyncResult<A, E>>;
77+ readonly environmentId: EnvironmentId;
78+ readonly attachmentId: string;
79+}): Promise<PersistedAttachmentVerification> {
80+ const result = await executeAtomQuery(
81+ input.registry,
82+ input.createAssetUrl({
83+ environmentId: input.environmentId,
84+ input: { resource: { _tag: "attachment", attachmentId: input.attachmentId } },
85+ }),
86+ // `refresh` forces a server round trip: the asset URL query atom caches
87+ // results (SWR), so a retry right after a transient failure would
88+ // otherwise re-observe the cached failure and never ask the server.
89+ { reportFailure: false, reportDefect: false, refresh: true },
90+ );
91+ if (result._tag === "Success") {
92+ return { status: "verified" };
93+ }
94+ const error = squashAtomCommandFailure(result);
95+ return isAssetAttachmentNotFoundFailure(error)
96+ ? { status: "missing" }
97+ : { status: "failed", error };
98+}
99+
100+type AttachmentCreateUploadUrlCommand<E> = AtomCommand<
101+ { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput },
102+ AttachmentCreateUploadUrlResult,
103+ E
104+>;
105+
106+type AttachmentRemoveCommand<E> = AtomCommand<
107+ { readonly environmentId: EnvironmentId; readonly input: AttachmentDeleteInput },
108+ unknown,
109+ E
110+>;
111+
112+/** Fire-and-forget delete of a pending upload the client no longer references. */
113+export function deletePendingAttachmentUpload<E>(input: {
114+ readonly registry: AtomRegistry.AtomRegistry;
115+ readonly remove: AttachmentRemoveCommand<E>;
116+ readonly environmentId: EnvironmentId;
117+ readonly attachmentId: string;
118+}): void {
119+ void runAtomCommand(
120+ input.registry,
121+ input.remove,
122+ { environmentId: input.environmentId, input: { attachmentId: input.attachmentId } },
123+ { reportFailure: false, reportDefect: false },
124+ );
125+}
126+
127+/** A running byte transfer: resolves when the server accepted the bytes. */
128+export interface AttachmentByteUpload {
129+ readonly done: Promise<void>;
130+ readonly abort: () => void;
131+}
132+
133+export type AttachmentUploadCycleResult =
134+ | { readonly status: "uploaded"; readonly attachmentId: string }
135+ | { readonly status: "cancelled"; readonly attachmentId: string | null }
136+ | {
137+ readonly status: "failed";
138+ readonly step: "mint" | "resolve-url" | "transfer";
139+ readonly attachmentId: string | null;
140+ readonly error: unknown;
141+ };
142+
143+/**
144+ * The platform-neutral upload cycle: mint a signed upload URL, resolve it
145+ * against the environment's HTTP base, and hand the bytes to a
146+ * platform-specific transport (XHR on web, `expo-file-system` on mobile).
147+ *
148+ * The cycle never deletes the minted pending upload on failure: callers keep
149+ * the returned `attachmentId` and decide between retry and release. The one
150+ * exception is `onMinted` returning `"cancel"`, where the caller already
151+ * abandoned the upload and the fresh mint is deleted before returning.
152+ */
153+export async function runAttachmentUploadCycle<E, RE>(input: {
154+ readonly registry: AtomRegistry.AtomRegistry;
155+ readonly createUploadUrl: AttachmentCreateUploadUrlCommand<E>;
156+ readonly remove: AttachmentRemoveCommand<RE>;
157+ readonly environmentId: EnvironmentId;
158+ readonly upload: AttachmentCreateUploadUrlInput;
159+ readonly resolveUploadUrl: (relativeUrl: string) => string | null;
160+ readonly transport: (url: string) => AttachmentByteUpload;
161+ /** Observe the minted id (for cancellation bookkeeping) before bytes move. */
162+ readonly onMinted?: (attachmentId: string) => "continue" | "cancel";
163+ readonly onTransferStart?: (abort: () => void) => void;
164+}): Promise<AttachmentUploadCycleResult> {
165+ const minted = await runAtomCommand(
166+ input.registry,
167+ input.createUploadUrl,
168+ { environmentId: input.environmentId, input: input.upload },
169+ { reportFailure: false },
170+ );
171+ if (minted._tag !== "Success") {
172+ return {
173+ status: "failed",
174+ step: "mint",
175+ attachmentId: null,
176+ error: squashAtomCommandFailure(minted),
177+ };
178+ }
179+ const attachmentId = minted.value.attachmentId;
180+ if (input.onMinted?.(attachmentId) === "cancel") {
181+ deletePendingAttachmentUpload({
182+ registry: input.registry,
183+ remove: input.remove,
184+ environmentId: input.environmentId,
185+ attachmentId,
186+ });
187+ return { status: "cancelled", attachmentId };
188+ }
189+
190+ const url = input.resolveUploadUrl(minted.value.relativeUrl);
191+ if (!url) {
192+ return {
193+ status: "failed",
194+ step: "resolve-url",
195+ attachmentId,
196+ error: new Error("The environment is not connected."),
197+ };
198+ }
199+
200+ const transfer = input.transport(url);
201+ input.onTransferStart?.(transfer.abort);
202+ try {
203+ await transfer.done;
204+ } catch (error) {
205+ return { status: "failed", step: "transfer", attachmentId, error };
206+ }
207+ return { status: "uploaded", attachmentId };
208+}
209+
210+/**
211+ * The effective per-file byte limit for a server that advertises
212+ * `capabilities.fileAttachments.maxUploadBytes`. The contract caps what a
213+ * turn may reference, so a larger advertised value must not admit files the
214+ * send would then refuse.
215+ */
216+export function clampFileAttachmentUploadBytes(advertisedMaxUploadBytes: number): number {
217+ return Math.min(advertisedMaxUploadBytes, PROVIDER_SEND_TURN_MAX_FILE_BYTES);
218+}
219+
220+/** "3.2 MB" / "48 KB" label for attachment rows. Never shows "0 KB". */
221+export function formatAttachmentSize(sizeBytes: number): string {
222+ return sizeBytes >= 1024 * 1024
223+ ? `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`
224+ : `${Math.max(1, Math.ceil(sizeBytes / 1024))} KB`;
225+}
226+
227+/** User-facing rejection for a file over the effective upload limit. */
228+export function fileAttachmentTooLargeMessage(name: string, maxUploadBytes: number): string {
229+ const maxUploadSize =
230+ maxUploadBytes >= 1024 * 1024 && maxUploadBytes % (1024 * 1024) === 0
231+ ? `${maxUploadBytes / (1024 * 1024)} MB`
232+ : maxUploadBytes >= 1024 && maxUploadBytes % 1024 === 0
233+ ? `${maxUploadBytes / 1024} KB`
234+ : `${maxUploadBytes} ${maxUploadBytes === 1 ? "byte" : "bytes"}`;
235+ return `'${name}' exceeds the ${maxUploadSize} attachment limit.`;
236+}
packages/client-runtime/src/state/runtime.ts+20−1
@@ -327,15 +327,34 @@ export async function executeAtomCommand<A, E>( · 326 unmodified lines
327327 return result;
328328 }
329329
330+export interface AtomQueryOptions extends AtomCommandOptions {
331+ /**
332+ * Force a fresh execution instead of accepting a value the atom already
333+ * holds (e.g. an SWR-cached result within its stale window). Used by
334+ * verification flows where a cached failure must not satisfy a retry.
335+ */
336+ readonly refresh?: boolean;
337+}
338+
330339 export async function executeAtomQuery<A, E>(
331340 registry: AtomRegistry.AtomRegistry,
332341 atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>,
333− options: AtomCommandOptions = {},
342+ options: AtomQueryOptions = {},
334343 reporter: AtomCommandReporter = console,
335344 ): Promise<AtomCommandResult<A, E>> {
336345 const query = Effect.scoped(
337346 Effect.gen(function* () {
338347 yield* AtomRegistry.mount(registry, atom);
348+ if (options.refresh) {
349+ yield* Effect.sync(() => {
350+ // Only a settled value can be a leftover from an earlier read; a
351+ // computation that mounting just started is already fresh.
352+ const current = registry.get(atom);
353+ if (current._tag !== "Initial" && !current.waiting) {
354+ registry.refresh(atom);
355+ }
356+ });
357+ }
339358 return yield* AtomRegistry.getResult(registry, atom, {
340359 suspendOnWaiting: true,
341360 });