authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-28 21:57:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 21:57:54-07:00
logbcb855a633566c2dc70239155ddcd28f0fa736c4
tree140bf4caa323ae977b94ae0e3b567c748c4d05d0
parent6a9d9f98826e1dac266c0d2aa324e71fe77fa3e7
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

30 files changed, 3932 insertions(+), 263 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+26
......@@ -36,6 +36,7 @@ import {
3636 shouldDockDraftHeroForSubmission,
3737 shouldReleaseTimelineAnchorForToolActivity,
3838 shouldShowBranchMismatchBanner,
39 shouldShowPlanFollowUpPrompt,
3940 shouldWriteThreadErrorToCurrentServerThread,
4041} from "./ChatView.logic";
4142
......@@ -591,6 +592,31 @@ describe("shouldShowBranchMismatchBanner", () => {
591592 });
592593});
593594
595describe("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
594620describe("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
......@@ -4,6 +4,7 @@ import {
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: {
445446 return input.composerHasContent || input.wasShownForCurrentMismatch;
446447}
447448
449export 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.
450467const sessionDismissedBranchMismatchKeys = new Set<string>();
apps/web/src/components/ChatView.tsx+215-43
......@@ -1,5 +1,6 @@
11import {
22 type ApprovalRequestId,
3 type ChatFileAttachment,
34 DEFAULT_MODEL,
45 defaultInstanceIdForDriver,
56 type EnvironmentId,
......@@ -17,6 +18,7 @@ import {
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 {
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/
257260import { projectEnvironment } from "../state/projects";
258261import { useEnvironmentQuery } from "../state/query";
259262import {
263 environmentServerConfigsAtom,
260264 primaryServerAvailableEditorsAtom,
261265 primaryServerKeybindingsAtom,
262266 primaryServerSettingsAtom,
......@@ -346,6 +350,7 @@ import {
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";
371376import {
372377 awaitAttachmentUploads,
373378 getUploadedAttachments,
374 releaseAttachmentUploads,
379 releaseDraftAttachments,
375380 startAttachmentUpload,
376381} from "../lib/attachmentUploadQueue";
377382import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
378383import { RightPanelSheet } from "./RightPanelSheet";
379384import { previewEnvironment } from "../state/preview";
385import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
386import { appAtomRegistry } from "../rpc/atomRegistry";
387import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles";
388import { assetEnvironment } from "../state/assets";
389import { readPreparedConnection } from "../state/session";
380390import { useAtomCommand } from "../state/use-atom-command";
391import { useAtomQueryRunner } from "../state/use-atom-query-runner";
381392import { Button } from "./ui/button";
382393import {
383394 AlertDialog,
......@@ -398,10 +409,10 @@ import {
398409 resolveServerSelfUpdateCapability,
399410 serverUpdateGuidance,
400411} from "../versionSkew";
401import { useAssetUrls } from "../assets/assetUrls";
412import { resolveAssetUrl, useAssetUrls } from "../assets/assetUrls";
402413
403const IMAGE_ONLY_BOOTSTRAP_PROMPT =
404 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
414const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT =
415 "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]";
405416const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
406417const EMPTY_PROVIDERS: ServerProvider[] = [];
407418const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
......@@ -1299,6 +1310,9 @@ function ChatViewContent(props: ChatViewProps) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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
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
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
......@@ -495,6 +495,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
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 {
1313 TurnId,
1414} from "@t3tools/contracts";
1515import {
16 isProviderSendTurnSupportedImageMimeType,
1716 ProviderDriverKind,
1817 ProviderInstanceId,
1918 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
......@@ -51,9 +50,13 @@ import {
5150 makeComposerMentionDragHandlers,
5251} from "./composerMentionDrag";
5352import {
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 {
7376 type ComposerTaskStep,
7477 type ComposerTasksProgress,
7578} from "./ComposerTasksBadge";
79import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";
7680import {
77 compressImageForStash,
78 isHeicImageFile,
79 prepareImageForAttachment,
80} from "../../lib/imageCompression";
81 fileAttachmentTooLargeMessage,
82 formatAttachmentSize,
83} from "@t3tools/client-runtime/state/attachments";
8184import {
85 attachmentsToReleaseOnUploadCapabilityLoss,
86 classifyComposerAttachmentFile,
87 fileAttachmentCapabilityBlockReason,
88 fileAttachmentStagingLimit,
89 normalizeComposerImageFileMimeType,
90 shouldHandleComposerAttachmentPaste,
91} from "./composerAttachmentFiles";
92import {
93 readAttachmentUpload,
8294 releaseAttachmentUpload,
95 releaseDraftAttachment,
96 releasePersistedAttachmentUpload,
8397 retryAttachmentUpload,
8498 startAttachmentUpload,
8599 useAttachmentUploadStore,
100 verifyStashedAttachmentUpload,
86101} from "../../lib/attachmentUploadQueue";
87102import {
88103 attachmentUploadBlockReason,
......@@ -241,6 +256,8 @@ import { toastManager } from "../ui/toast";
241256import {
242257 BotIcon,
243258 CircleAlertIcon,
259 FileIcon,
260 PaperclipIcon,
244261 PencilRulerIcon,
245262 type LucideIcon,
246263 LockIcon,
......@@ -532,6 +549,7 @@ export interface ChatComposerHandle {
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 {
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 {
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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
......@@ -1,4 +1,5 @@
11import { renderToStaticMarkup } from "react-dom/server";
2import { EnvironmentId } from "@t3tools/contracts";
23import { describe, expect, it } from "vite-plus/test";
34
45import { ComposerStashMenu } from "./ComposerStashMenu";
......@@ -89,4 +90,45 @@ describe("ComposerStashMenu", () => {
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 @@
1import { XIcon } from "lucide-react";
1import { FileIcon, XIcon } from "lucide-react";
22import { memo, useEffect, useRef, useState } from "react";
33
44import { formatRelativeTimeLabel } from "../../timestampFormat";
......@@ -20,7 +20,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string {
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: {
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
......@@ -558,6 +558,116 @@ describe("MessagesTimeline", () => {
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 @@
11import {
2 type ChatFileAttachment,
23 type EnvironmentId,
34 type MessageId,
45 type ScopedThreadRef,
......@@ -14,6 +15,7 @@ import {
1415
1516const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();
1617const NOOP_OPEN_AGENTS = () => {};
18const NOOP_DOWNLOAD_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
1719import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
1820import {
1921 createContext,
......@@ -38,7 +40,12 @@ import {
3840 workEntrySignalsSevereFailure,
3941 workLogEntryIsToolLike,
4042} from "../../session-logic";
41import { type ChatImageAttachment, isImageAttachment, type TurnDiffSummary } from "../../types";
43import {
44 type ChatImageAttachment,
45 isFileAttachment,
46 isImageAttachment,
47 type TurnDiffSummary,
48} from "../../types";
4249import {
4350 getRenderablePatch,
4451 resolveDiffThemeName,
......@@ -51,7 +58,9 @@ import {
5158 ChevronDownIcon,
5259 ChevronRightIcon,
5360 CircleAlertIcon,
61 DownloadIcon,
5462 EyeIcon,
63 FileIcon,
5564 GlobeIcon,
5665 HammerIcon,
5766 MessageCircleIcon,
......@@ -143,6 +152,7 @@ interface TimelineRowSharedState {
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 {
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({
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({
524536 activeThreadEnvironmentId,
525537 onRevertUserMessage,
526538 onImageExpand,
539 onFileDownload,
527540 onOpenTurnDiff,
528541 onToggleTurnFold,
529542 onToggleWorkGroup,
......@@ -540,6 +553,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
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
9861000
9871001function 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"
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"
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 created+280
......@@ -0,0 +1,280 @@
1import { EnvironmentId, PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore";
5import {
6 attachmentsToReleaseOnUploadCapabilityLoss,
7 classifyComposerAttachmentFile,
8 fileAttachmentCapabilityBlockReason,
9 fileAttachmentStagingLimit,
10 inferImageMimeTypeFromName,
11 normalizeComposerImageFileMimeType,
12 shouldHandleComposerAttachmentPaste,
13} from "./composerAttachmentFiles";
14
15describe("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 created+153
......@@ -0,0 +1,153 @@
1import {
2 isProviderSendTurnSupportedImageMimeType,
3 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
4} from "@t3tools/contracts";
5import {
6 clampFileAttachmentUploadBytes,
7 fileAttachmentTooLargeMessage,
8} from "@t3tools/client-runtime/state/attachments";
9
10import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore";
11import { isHeicImageFile } from "../../lib/imageCompression";
12
13type ComposerAttachmentFileKind = "image" | "file" | "unsupported-image";
14
15interface FileAttachmentCapabilityState {
16 readonly attachmentUploadsCapabilityKnown: boolean;
17 readonly supportsAttachmentUploads: boolean;
18 readonly maxFileAttachmentBytes: number | null;
19}
20
21const 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 */
35export 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
43function 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. */
52export 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
63export 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. */
79export 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. */
90export 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 */
120export 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 */
135export 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 {
66 squashAtomCommandFailure,
77 type AtomCommandResult,
88} from "@t3tools/client-runtime/state/runtime";
9import { scopeProjectRef } from "@t3tools/client-runtime/environment";
9import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
1010import { AsyncResult } from "effect/unstable/reactivity";
1111import {
1212 deriveProjectGroupingOverrideKey,
......@@ -733,7 +733,10 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
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
......@@ -11,6 +11,7 @@ import {
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 {
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: {
104107 };
105108}
106109
110function 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
107122function makeTerminalContext(input: {
108123 id: string;
109124 text?: string;
......@@ -289,6 +304,280 @@ describe("composerDraftStore clearComposerContent", () => {
289304 });
290305});
291306
307describe("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
292581describe("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", () => {
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", () => {
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
19642447describe("composerDraftStore provider-scoped option updates", () => {
apps/web/src/composerDraftStore.ts+354-19
......@@ -2,13 +2,14 @@ import {
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"
3334import { useMemo } from "react";
3435import { getLocalStorageItem } from "./hooks/useLocalStorage";
3536import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection";
36import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
37import {
38 DEFAULT_INTERACTION_MODE,
39 DEFAULT_RUNTIME_MODE,
40 type ChatFileAttachment,
41 type ChatImageAttachment,
42} from "./types";
3743import {
3844 type TerminalContextDraft,
3945 ensureInlineTerminalContextPlaceholders,
......@@ -93,6 +99,49 @@ export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "prev
9399 file: File;
94100}
95101
102export 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 */
114export function composerFileNeedsReattach(file: ComposerFileAttachment): boolean {
115 return file.file === null && file.uploadedAttachmentId === undefined;
116}
117
118export 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});
126export 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 */
134export 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});
142export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type;
143const isPersistedComposerDraftFileAttachment = Schema.is(PersistedComposerDraftFileAttachment);
144
96145const PersistedTerminalContextDraft = Schema.Struct({
97146 id: Schema.String,
98147 threadId: ThreadId,
......@@ -129,6 +178,7 @@ type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type;
129178const 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({
255305export 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(
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 {
351403 * Raw `ThreadId` is intentionally excluded so callers cannot drop environment
352404 * identity for real threads.
353405 */
354type ComposerThreadTarget = ScopedThreadRef | DraftId;
406export type ComposerThreadTarget = ScopedThreadRef | DraftId;
355407
356408/**
357409 * Persisted store for composer content plus draft-session metadata.
......@@ -484,6 +536,20 @@ interface ComposerDraftStoreState {
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 {
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
615678});
616679
617680const EMPTY_IMAGES: ComposerImageAttachment[] = [];
681const EMPTY_FILES: ComposerFileAttachment[] = [];
618682const EMPTY_IDS: string[] = [];
619683const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];
620684const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = [];
......@@ -622,6 +686,7 @@ const EMPTY_ELEMENT_CONTEXTS: ElementContextDraft[] = [];
622686const EMPTY_PREVIEW_ANNOTATIONS: PreviewAnnotationPayload[] = [];
623687const EMPTY_REVIEW_COMMENTS: ReviewCommentContext[] = [];
624688Object.freeze(EMPTY_IMAGES);
689Object.freeze(EMPTY_FILES);
625690Object.freeze(EMPTY_IDS);
626691Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
627692Object.freeze(EMPTY_ELEMENT_CONTEXTS);
......@@ -637,6 +702,7 @@ const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>(
637702const 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 {
659725 return {
660726 prompt: "",
661727 images: [],
728 files: [],
662729 nonPersistedImageIds: [],
663730 persistedAttachments: [],
664731 terminalContexts: [],
......@@ -733,6 +800,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
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 {
12631331 * Draft sessions are keyed by `DraftId`. Real threads are keyed by
12641332 * `ScopedThreadRef` so environment identity is always preserved.
12651333 */
1266function composerTargetKey(target: ScopedThreadRef | DraftId): string {
1334export function composerTargetKey(target: ScopedThreadRef | DraftId): string {
12671335 if (typeof target === "string") {
12681336 return target.trim();
12691337 }
......@@ -1717,6 +1785,9 @@ function normalizePersistedDraftsByThreadId(
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(
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(
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
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(
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(
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(
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>()(
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>()(
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>()(
34623734 ...current,
34633735 prompt: "",
34643736 images: [],
3737 files: [],
34653738 nonPersistedImageIds: [],
34663739 persistedAttachments: [],
34673740 terminalContexts: [],
......@@ -3495,6 +3768,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
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>()(
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>()(
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>()(
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";
3333import { resolveThreadRouteTarget } from "../threadRoutes";
3434import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
3535import { useClientSettings } from "./useSettings";
36import { toastManager } from "../components/ui/toast";
3637
3738interface NewThreadWorkspaceOptions {
3839 branch?: string | null;
......@@ -78,7 +79,7 @@ export function useNewThreadHandler() {
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() {
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 @@
1import { EnvironmentId } from "@t3tools/contracts";
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
23import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
34
4import type { ComposerImageAttachment } from "../composerDraftStore";
5import {
6 composerFileNeedsReattach,
7 DraftId,
8 useComposerDraftStore,
9 type ComposerFileAttachment,
10 type ComposerImageAttachment,
11} from "../composerDraftStore";
512
613const 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
1322vi.mock("@t3tools/client-runtime/state/runtime", () => ({
23 executeAtomQuery: mocks.executeAtomQuery,
1424 runAtomCommand: mocks.runAtomCommand,
25 squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error,
1526}));
1627
1728vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} }));
1829
30vi.mock("../state/assets", () => ({
31 assetEnvironment: { createUrl: mocks.createAssetUrl },
32}));
33
1934vi.mock("../state/attachments", () => ({
2035 attachmentEnvironment: {
2136 createUploadUrl: mocks.createUploadUrl,
......@@ -32,7 +47,9 @@ import {
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 {
110127 };
111128}
112129
130function 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
113144describe("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", () => {
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", () => {
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", () => {
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 {
33 type ChatAttachment,
44 type EnvironmentId,
55} from "@t3tools/contracts";
6import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
67import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
7import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
8import {
9 deletePendingAttachmentUpload,
10 runAttachmentUploadCycle,
11 verifyPersistedAttachmentUpload,
12 type PersistedAttachmentVerification,
13} from "@t3tools/client-runtime/state/attachments";
814import { create } from "zustand";
915
10import type { ComposerImageAttachment } from "../composerDraftStore";
16import {
17 DraftId,
18 useComposerDraftStore,
19 type ComposerFileAttachment,
20 type ComposerImageAttachment,
21 type ComposerThreadTarget,
22} from "../composerDraftStore";
1123import { appAtomRegistry } from "../rpc/atomRegistry";
24import { assetEnvironment } from "../state/assets";
1225import { attachmentEnvironment } from "../state/attachments";
1326import { readPreparedConnection } from "../state/session";
1427import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState";
......@@ -25,11 +38,23 @@ export const useAttachmentUploadStore = create<AttachmentUploadStore>(() => ({
2538}));
2639
2740interface 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
6085 return useAttachmentUploadStore.getState().uploadsByImageId[imageId];
6186}
6287
88/** Finds the file's current same-environment draft after any in-flight move. */
89function 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 */
125function 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
63135function 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
72144function 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: {
101174}
102175
103176async 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
209335function pumpUploads(): void {
......@@ -249,7 +375,9 @@ function pumpUploads(): void {
249375
250376export 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: {
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: {
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 */
309448export function cancelAttachmentUpload(imageId: string): void {
310449 const job = jobsByImageId.get(imageId);
311450 if (!job) {
......@@ -340,12 +479,42 @@ export function releaseAttachmentUpload(imageId: string): void {
340479 clearUploadState(imageId);
341480}
342481
482export 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
343508export 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: {
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 */
534export 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
360546export async function awaitAttachmentUploads(imageIds: ReadonlyArray<string>): Promise<void> {
361547 await Promise.all(imageIds.map((imageId) => jobsByImageId.get(imageId)?.settled));
362548}
363549
364550export 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: {
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: {
382568 return attachments;
383569}
384570
385export 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 */
579export 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
603export 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", () => {
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", () => {
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", () => {
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 =
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: {
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 @@
11import type { ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts";
2import { scopedThreadKey } from "@t3tools/client-runtime/environment";
23
34import { type DraftId, useComposerDraftStore } from "../composerDraftStore";
4import { releaseAttachmentUploads } from "./attachmentUploadQueue";
5import { releaseDraftAttachments } from "./attachmentUploadQueue";
56
67export 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
13export 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 */
20export 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
......@@ -1,4 +1,5 @@
11import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
2import { EnvironmentId } from "@t3tools/contracts";
23
34import { removeLocalStorageItem } from "./hooks/useLocalStorage";
45
......@@ -162,6 +163,62 @@ describe("promptStashStore", () => {
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 @@
11import * as Schema from "effect/Schema";
22import { create } from "zustand";
33
4import { PersistedComposerImageAttachment } from "./composerDraftStore";
4import {
5 PersistedComposerFileAttachment,
6 PersistedComposerImageAttachment,
7} from "./composerDraftStore";
58import { createMemoryStorage, type StateStorage } from "./lib/storage";
69
710export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2";
......@@ -27,16 +30,15 @@ export const MAX_STASH_ENTRIES = 20;
2730export 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 */
3536const 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 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import { createEnvironmentRpcCommand } from "@t3tools/client-runtime/state/runtime";
1import { createAttachmentEnvironmentAtoms } from "@t3tools/client-runtime/state/attachments";
32
43import { connectionAtomRuntime } from "../connection/runtime";
54
6export 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};
5export const attachmentEnvironment = createAttachmentEnvironmentAtoms(connectionAtomRuntime);
apps/web/src/types.ts+18-7
......@@ -1,6 +1,7 @@
11import 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 {
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.
41export type ChatAttachment =
42 | ChatImageAttachment
43 | Exclude<ContractChatAttachment, ContractChatImageAttachment>;
40export 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.
48export type ChatUnknownAttachment = ContractChatUnknownAttachment;
49
50export 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.
4754export function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment {
4855 return attachment.type === "image";
4956}
5057
58export function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment {
59 return attachment.type === "file";
60}
61
5162export 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
44composer and shows how many characters need to be removed. Shorten the draft or split it into
55multiple messages, then send again in the same thread.
66
7On servers that support direct uploads, images upload as soon as you add them. The send button
8becomes available after every upload finishes. Failed uploads can be retried or removed.
7You can attach images up to 10 MB. On servers that support file uploads, web and desktop can also
8attach text files, PDFs, ZIP archives, and other files. Each file can be up to the limit advertised
9by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files
10upload directly to the environment, where your agent can read, copy, or edit them by their file path.
11
12On web and desktop, attachments upload as soon as you add them. The send button becomes available
13after every upload finishes. Failed uploads can be retried or removed. On mobile, attachments are
14currently limited to images.
15
16If you reload before a file finishes uploading, the draft keeps the file's name and shows **Attach
17again** next to it. Attach the file again or remove it, then send.
918
1019On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into
1120the composer or paste them into a message.
......@@ -14,6 +23,15 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such
1423GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list
1524when starting a thread or changing an existing thread's model.
1625
26## Prompt stash
27
28Use the default shortcut, `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux, to stash the current
29prompt and its attachments after all file uploads finish. Restore the entry later from the stash
30menu. Stashes that contain files must be restored in the environment where those files were
31uploaded. Stashed files stay uploaded on the server for 24 hours. If you restore an entry after
32that, the file comes back with **Attach again** next to it. Attach the file again or remove it, then
33send.
34
1735## Commands and skills
1836
1937Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source,
packages/client-runtime/package.json+4
......@@ -55,6 +55,10 @@
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 created+202
......@@ -0,0 +1,202 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 EnvironmentId,
4 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
5 type AttachmentCreateUploadUrlInput,
6 type AttachmentCreateUploadUrlResult,
7 type AttachmentDeleteInput,
8} from "@t3tools/contracts";
9import * as Effect from "effect/Effect";
10import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
11
12import type { AtomCommand } from "./runtime.ts";
13import {
14 clampFileAttachmentUploadBytes,
15 fileAttachmentTooLargeMessage,
16 formatAttachmentSize,
17 runAttachmentUploadCycle,
18 verifyPersistedAttachmentUpload,
19} from "./attachments.ts";
20
21const environmentId = EnvironmentId.make("environment-1");
22// The cycle threads the registry through to the commands untouched, so the
23// fakes below can ignore it.
24const registry = {} as AtomRegistry.AtomRegistry;
25
26type CreateUploadUrlCommand = AtomCommand<
27 { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput },
28 AttachmentCreateUploadUrlResult,
29 never
30>;
31
32type RemoveCommand = AtomCommand<
33 { readonly environmentId: EnvironmentId; readonly input: AttachmentDeleteInput },
34 unknown,
35 never
36>;
37
38function 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
50const removeCalls: string[] = [];
51const 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
59const uploadInput: AttachmentCreateUploadUrlInput = {
60 type: "file",
61 name: "report.pdf",
62 mimeType: "application/pdf",
63 sizeBytes: 3,
64};
65
66describe("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
129describe("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
166describe("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 created+236
......@@ -0,0 +1,236 @@
1import {
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";
9import type { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
10
11import type { EnvironmentRegistry } from "../connection/registry.ts";
12import {
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 */
25export 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 */
49export 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
58export 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 */
69export 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
100type AttachmentCreateUploadUrlCommand<E> = AtomCommand<
101 { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput },
102 AttachmentCreateUploadUrlResult,
103 E
104>;
105
106type 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. */
113export 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. */
128export interface AttachmentByteUpload {
129 readonly done: Promise<void>;
130 readonly abort: () => void;
131}
132
133export 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 */
153export 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 */
216export 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". */
221export 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. */
228export 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>(
327327 return result;
328328}
329329
330export 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
330339export 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 });