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 {...@@ -36,6 +36,7 @@ import {
36 shouldDockDraftHeroForSubmission,36 shouldDockDraftHeroForSubmission,
37 shouldReleaseTimelineAnchorForToolActivity,37 shouldReleaseTimelineAnchorForToolActivity,
38 shouldShowBranchMismatchBanner,38 shouldShowBranchMismatchBanner,
39 shouldShowPlanFollowUpPrompt,
39 shouldWriteThreadErrorToCurrentServerThread,40 shouldWriteThreadErrorToCurrentServerThread,
40} from "./ChatView.logic";41} from "./ChatView.logic";
4142
...@@ -591,6 +592,31 @@ describe("shouldShowBranchMismatchBanner", () => {...@@ -591,6 +592,31 @@ describe("shouldShowBranchMismatchBanner", () => {
591 });592 });
592});593});
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
594describe("session branch mismatch dismissal", () => {620describe("session branch mismatch dismissal", () => {
595 it("tracks dismissed keys and treats other keys as active", () => {621 it("tracks dismissed keys and treats other keys as active", () => {
596 expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false);622 expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false);
apps/web/src/components/ChatView.logic.ts+17
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 ProjectId,4 ProjectId,
5 type MessageId,5 type MessageId,
6 type ModelSelection,6 type ModelSelection,
7 type ProviderInteractionMode,
7 type ProviderDriverKind,8 type ProviderDriverKind,
8 type ServerProvider,9 type ServerProvider,
9 type ScopedProjectRef,10 type ScopedProjectRef,
...@@ -445,6 +446,22 @@ export function shouldShowBranchMismatchBanner(input: {...@@ -445,6 +446,22 @@ export function shouldShowBranchMismatchBanner(input: {
445 return input.composerHasContent || input.wasShownForCurrentMismatch;446 return input.composerHasContent || input.wasShownForCurrentMismatch;
446}447}
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
448// Session-scoped (module-level so it survives ChatView remounts, e.g. route465// Session-scoped (module-level so it survives ChatView remounts, e.g. route
449// changes). Durable cross-device dismissal is planned as a server-side ack.466// changes). Durable cross-device dismissal is planned as a server-side ack.
450const sessionDismissedBranchMismatchKeys = new Set<string>();467const sessionDismissedBranchMismatchKeys = new Set<string>();
apps/web/src/components/ChatView.tsx+215-43
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 type ApprovalRequestId,2 type ApprovalRequestId,
3 type ChatFileAttachment,
3 DEFAULT_MODEL,4 DEFAULT_MODEL,
4 defaultInstanceIdForDriver,5 defaultInstanceIdForDriver,
5 type EnvironmentId,6 type EnvironmentId,
...@@ -17,6 +18,7 @@ import {...@@ -17,6 +18,7 @@ import {
17 type TurnId,18 type TurnId,
18 type KeybindingCommand,19 type KeybindingCommand,
19 OrchestrationThreadActivity,20 OrchestrationThreadActivity,
21 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
20 ProviderInteractionMode,22 ProviderInteractionMode,
21 ProviderDriverKind,23 ProviderDriverKind,
22 RuntimeMode,24 RuntimeMode,
...@@ -231,6 +233,7 @@ import {...@@ -231,6 +233,7 @@ import {
231 beginBackgroundDraftSubmissionByRef,233 beginBackgroundDraftSubmissionByRef,
232 clearBackgroundDraftSubmissionByRef,234 clearBackgroundDraftSubmissionByRef,
233 composerDraftHasUserContent,235 composerDraftHasUserContent,
236 type ComposerFileAttachment,
234 type ComposerImageAttachment,237 type ComposerImageAttachment,
235 type DraftThreadEnvMode,238 type DraftThreadEnvMode,
236 finalizePromotedDraftThreadByRef,239 finalizePromotedDraftThreadByRef,
...@@ -257,6 +260,7 @@ import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/...@@ -257,6 +260,7 @@ import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/
257import { projectEnvironment } from "../state/projects";260import { projectEnvironment } from "../state/projects";
258import { useEnvironmentQuery } from "../state/query";261import { useEnvironmentQuery } from "../state/query";
259import {262import {
263 environmentServerConfigsAtom,
260 primaryServerAvailableEditorsAtom,264 primaryServerAvailableEditorsAtom,
261 primaryServerKeybindingsAtom,265 primaryServerKeybindingsAtom,
262 primaryServerSettingsAtom,266 primaryServerSettingsAtom,
...@@ -346,6 +350,7 @@ import {...@@ -346,6 +350,7 @@ import {
346 shouldDockDraftHeroForSubmission,350 shouldDockDraftHeroForSubmission,
347 shouldReleaseTimelineAnchorForToolActivity,351 shouldReleaseTimelineAnchorForToolActivity,
348 shouldShowBranchMismatchBanner,352 shouldShowBranchMismatchBanner,
353 shouldShowPlanFollowUpPrompt,
349 getStartedThreadModelChangeBlockReason,354 getStartedThreadModelChangeBlockReason,
350 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,355 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
351 LastInvokedScriptByProjectSchema,356 LastInvokedScriptByProjectSchema,
...@@ -371,13 +376,19 @@ import { useComposerHandleContext } from "../composerHandleContext";...@@ -371,13 +376,19 @@ import { useComposerHandleContext } from "../composerHandleContext";
371import {376import {
372 awaitAttachmentUploads,377 awaitAttachmentUploads,
373 getUploadedAttachments,378 getUploadedAttachments,
374 releaseAttachmentUploads,379 releaseDraftAttachments,
375 startAttachmentUpload,380 startAttachmentUpload,
376} from "../lib/attachmentUploadQueue";381} from "../lib/attachmentUploadQueue";
377import { sanitizeThreadErrorMessage } from "~/rpc/transportError";382import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
378import { RightPanelSheet } from "./RightPanelSheet";383import { RightPanelSheet } from "./RightPanelSheet";
379import { previewEnvironment } from "../state/preview";384import { 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";
380import { useAtomCommand } from "../state/use-atom-command";390import { useAtomCommand } from "../state/use-atom-command";
391import { useAtomQueryRunner } from "../state/use-atom-query-runner";
381import { Button } from "./ui/button";392import { Button } from "./ui/button";
382import {393import {
383 AlertDialog,394 AlertDialog,
...@@ -398,10 +409,10 @@ import {...@@ -398,10 +409,10 @@ import {
398 resolveServerSelfUpdateCapability,409 resolveServerSelfUpdateCapability,
399 serverUpdateGuidance,410 serverUpdateGuidance,
400} from "../versionSkew";411} from "../versionSkew";
401import { useAssetUrls } from "../assets/assetUrls";412import { resolveAssetUrl, useAssetUrls } from "../assets/assetUrls";
402413
403const IMAGE_ONLY_BOOTSTRAP_PROMPT =414const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT =
404 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";415 "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]";
405const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];416const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
406const EMPTY_PROVIDERS: ServerProvider[] = [];417const EMPTY_PROVIDERS: ServerProvider[] = [];
407const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];418const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
...@@ -1299,6 +1310,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1299,6 +1310,9 @@ function ChatViewContent(props: ChatViewProps) {
1299 reportFailure: false,1310 reportFailure: false,
1300 });1311 });
1301 const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });1312 const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
1313 const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
1314 reportFailure: false,
1315 });
1302 const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, {1316 const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, {
1303 reportFailure: false,1317 reportFailure: false,
1304 });1318 });
...@@ -1384,8 +1398,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1384,8 +1398,13 @@ function ChatViewContent(props: ChatViewProps) {
1384 const composerHasUnsentContent = useComposerDraftStore((store) =>1398 const composerHasUnsentContent = useComposerDraftStore((store) =>
1385 composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)),1399 composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)),
1386 );1400 );
1401 const composerHasAttachments = useComposerDraftStore((store) => {
1402 const draft = store.getComposerDraft(composerDraftTarget);
1403 return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0;
1404 });
1387 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);1405 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
1388 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);1406 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
1407 const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);
1389 const setComposerDraftTerminalContexts = useComposerDraftStore(1408 const setComposerDraftTerminalContexts = useComposerDraftStore(
1390 (store) => store.setTerminalContexts,1409 (store) => store.setTerminalContexts,
1391 );1410 );
...@@ -1412,6 +1431,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1412,6 +1431,7 @@ function ChatViewContent(props: ChatViewProps) {
1412 );1431 );
1413 const promptRef = useRef("");1432 const promptRef = useRef("");
1414 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);1433 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
1434 const composerFilesRef = useRef<ComposerFileAttachment[]>([]);
1415 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);1435 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);
1416 const composerElementContextsRef = useRef<ElementContextDraft[]>([]);1436 const composerElementContextsRef = useRef<ElementContextDraft[]>([]);
1417 const localComposerRef = useRef<ChatComposerHandle | null>(null);1437 const localComposerRef = useRef<ChatComposerHandle | null>(null);
...@@ -2124,6 +2144,12 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2124,6 +2144,12 @@ function ChatViewContent(props: ChatViewProps) {
2124 const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null;2144 const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null;
2125 const supportsAttachmentUploads =2145 const supportsAttachmentUploads =
2126 attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true;2146 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);
2127 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);2153 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);
2128 const versionMismatchDismissKey =2154 const versionMismatchDismissKey =
2129 versionMismatch && activeThread2155 versionMismatch && activeThread
...@@ -2403,11 +2429,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2403,11 +2429,13 @@ function ChatViewContent(props: ChatViewProps) {
2403 null2429 null
2404 );2430 );
2405 }, [activeLatestTurn?.turnId, activePlan]);2431 }, [activeLatestTurn?.turnId, activePlan]);
2406 const showPlanFollowUpPrompt =2432 const showPlanFollowUpPrompt = shouldShowPlanFollowUpPrompt({
2407 pendingUserInputs.length === 0 &&2433 pendingUserInputCount: pendingUserInputs.length,
2408 interactionMode === "plan" &&2434 interactionMode,
2409 latestTurnSettled &&2435 latestTurnSettled,
2410 hasActionableProposedPlan(activeProposedPlan);2436 hasActionableProposedPlan: hasActionableProposedPlan(activeProposedPlan),
2437 hasComposerAttachments: composerHasAttachments,
2438 });
2411 const activePendingApproval = pendingApprovals[0] ?? null;2439 const activePendingApproval = pendingApprovals[0] ?? null;
2412 const {2440 const {
2413 beginLocalDispatch,2441 beginLocalDispatch,
...@@ -2493,11 +2521,57 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2493,11 +2521,57 @@ function ChatViewContent(props: ChatViewProps) {
2493 });2521 });
2494 }, []);2522 }, []);
2495 const serverMessages = activeThread?.messages;2523 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 );
2496 const serverAttachmentIds = useMemo(() => {2568 const serverAttachmentIds = useMemo(() => {
2497 const attachmentIds = new Set<string>();2569 const attachmentIds = new Set<string>();
2498 for (const message of serverMessages ?? []) {2570 for (const message of serverMessages ?? []) {
2499 for (const attachment of message.attachments ?? []) {2571 for (const attachment of message.attachments ?? []) {
2500 attachmentIds.add(attachment.id);2572 if (isImageAttachment(attachment)) {
2573 attachmentIds.add(attachment.id);
2574 }
2501 }2575 }
2502 }2576 }
2503 return [...attachmentIds];2577 return [...attachmentIds];
...@@ -5410,6 +5484,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5410,6 +5484,7 @@ function ChatViewContent(props: ChatViewProps) {
5410 }5484 }
5411 const {5485 const {
5412 images: sendContextImages,5486 images: sendContextImages,
5487 files: composerFiles,
5413 terminalContexts: composerTerminalContexts,5488 terminalContexts: composerTerminalContexts,
5414 elementContexts: composerElementContexts,5489 elementContexts: composerElementContexts,
5415 previewAnnotations: sendContextPreviewAnnotations,5490 previewAnnotations: sendContextPreviewAnnotations,
...@@ -5420,9 +5495,17 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5420,9 +5495,17 @@ function ChatViewContent(props: ChatViewProps) {
5420 selectedPromptEffort: ctxSelectedPromptEffort,5495 selectedPromptEffort: ctxSelectedPromptEffort,
5421 selectedModelSelection: ctxSelectedModelSelection,5496 selectedModelSelection: ctxSelectedModelSelection,
5422 } = sendCtx;5497 } = 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;
5423 const composerImages =5507 const composerImages =
5424 directAnnotation?.image &&5508 directAnnotation?.image && annotationImageAppended
5425 !sendContextImages.some((image) => image.id === directAnnotation.image?.id)
5426 ? [...sendContextImages, directAnnotation.image]5509 ? [...sendContextImages, directAnnotation.image]
5427 : sendContextImages;5510 : sendContextImages;
5428 const composerPreviewAnnotations =5511 const composerPreviewAnnotations =
...@@ -5434,9 +5517,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5434,9 +5517,13 @@ function ChatViewContent(props: ChatViewProps) {
5434 ...sendContextPreviewAnnotations,5517 ...sendContextPreviewAnnotations,
5435 {5518 {
5436 ...directAnnotation.annotation,5519 ...directAnnotation.annotation,
5437 screenshot: directAnnotation.annotation.screenshot5520 // Claim an attached crop only when the screenshot really rides
5438 ? { ...directAnnotation.annotation.screenshot, dataUrl: "" }5521 // along; a cap-dropped image must not produce a lying prompt.
5439 : null,5522 screenshot:
5523 directAnnotation.annotation.screenshot &&
5524 (annotationImageAppended || annotationImageAlreadyAttached)
5525 ? { ...directAnnotation.annotation.screenshot, dataUrl: "" }
5526 : null,
5440 },5527 },
5441 ]5528 ]
5442 : sendContextPreviewAnnotations;5529 : sendContextPreviewAnnotations;
...@@ -5448,7 +5535,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5448,7 +5535,7 @@ function ChatViewContent(props: ChatViewProps) {
5448 hasSendableContent,5535 hasSendableContent,
5449 } = deriveComposerSendState({5536 } = deriveComposerSendState({
5450 prompt: promptForSend,5537 prompt: promptForSend,
5451 imageCount: composerImages.length,5538 imageCount: composerImages.length + composerFiles.length,
5452 terminalContexts: composerTerminalContexts,5539 terminalContexts: composerTerminalContexts,
5453 elementContextCount:5540 elementContextCount:
5454 composerElementContexts.length +5541 composerElementContexts.length +
...@@ -5458,6 +5545,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5458,6 +5545,7 @@ function ChatViewContent(props: ChatViewProps) {
5458 const feedbackCommand =5545 const feedbackCommand =
5459 ctxSelectedProvider === "codex" &&5546 ctxSelectedProvider === "codex" &&
5460 composerImages.length === 0 &&5547 composerImages.length === 0 &&
5548 composerFiles.length === 0 &&
5461 sendableComposerTerminalContexts.length === 0 &&5549 sendableComposerTerminalContexts.length === 0 &&
5462 composerElementContexts.length === 0 &&5550 composerElementContexts.length === 0 &&
5463 composerPreviewAnnotations.length === 0 &&5551 composerPreviewAnnotations.length === 0 &&
...@@ -5550,7 +5638,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5550,7 +5638,13 @@ function ChatViewContent(props: ChatViewProps) {
5550 );5638 );
5551 return;5639 return;
5552 }5640 }
5553 if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) {5641 if (
5642 !directAnnotation &&
5643 showPlanFollowUpPrompt &&
5644 activeProposedPlan &&
5645 composerImages.length === 0 &&
5646 composerFiles.length === 0
5647 ) {
5554 const followUp = resolvePlanFollowUpSubmission({5648 const followUp = resolvePlanFollowUpSubmission({
5555 draftText: trimmed,5649 draftText: trimmed,
5556 planMarkdown: activeProposedPlan.planMarkdown,5650 planMarkdown: activeProposedPlan.planMarkdown,
...@@ -5579,6 +5673,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5579,6 +5673,7 @@ function ChatViewContent(props: ChatViewProps) {
5579 const standaloneSlashCommand =5673 const standaloneSlashCommand =
5580 settings.planModeEnabled &&5674 settings.planModeEnabled &&
5581 composerImages.length === 0 &&5675 composerImages.length === 0 &&
5676 composerFiles.length === 0 &&
5582 sendableComposerTerminalContexts.length === 0 &&5677 sendableComposerTerminalContexts.length === 0 &&
5583 composerElementContexts.length === 0 &&5678 composerElementContexts.length === 0 &&
5584 composerPreviewAnnotations.length === 0 &&5679 composerPreviewAnnotations.length === 0 &&
...@@ -5635,6 +5730,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5635,6 +5730,8 @@ function ChatViewContent(props: ChatViewProps) {
5635 }5730 }
56365731
5637 const composerImagesSnapshot = [...composerImages];5732 const composerImagesSnapshot = [...composerImages];
5733 const composerFilesSnapshot = [...composerFiles];
5734 const composerAttachmentsSnapshot = [...composerImagesSnapshot, ...composerFilesSnapshot];
5638 const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];5735 const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];
5639 const composerElementContextsSnapshot = [...composerElementContexts];5736 const composerElementContextsSnapshot = [...composerElementContexts];
5640 const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations];5737 const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations];
...@@ -5656,21 +5753,57 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5656,21 +5753,57 @@ function ChatViewContent(props: ChatViewProps) {
5656 model: ctxSelectedModel,5753 model: ctxSelectedModel,
5657 models: ctxSelectedProviderModels,5754 models: ctxSelectedProviderModels,
5658 effort: ctxSelectedPromptEffort,5755 effort: ctxSelectedPromptEffort,
5659 text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,5756 text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
5660 });5757 });
5661 if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) {5758 if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) {
5662 return;5759 return;
5663 }5760 }
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
5665 sendInFlightRef.current = true;5778 sendInFlightRef.current = true;
5666 if (supportsAttachmentUploads && composerImagesSnapshot.length > 0) {5779 const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities();
5667 for (const image of composerImagesSnapshot) {5780 if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) {
5668 startAttachmentUpload({ environmentId, image });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 });
5669 }5796 }
5670 await awaitAttachmentUploads(composerImagesSnapshot.map((image) => image.id));5797 await awaitAttachmentUploads(composerAttachmentsSnapshot.map((attachment) => attachment.id));
5671 if (getUploadedAttachments({ environmentId, images: composerImagesSnapshot }) === null) {5798 const attachmentCapabilitiesAfterUpload = readLiveAttachmentCapabilities();
5799 if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) {
5672 sendInFlightRef.current = false;5800 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.");
5674 return;5807 return;
5675 }5808 }
5676 }5809 }
...@@ -5699,6 +5832,16 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5699,6 +5832,16 @@ function ChatViewContent(props: ChatViewProps) {
5699 void dockTransition.catch(() => resolveDockStarted?.());5832 void dockTransition.catch(() => resolveDockStarted?.());
5700 await dockStarted;5833 await dockStarted;
5701 }5834 }
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 }
5702 beginLocalDispatch({5845 beginLocalDispatch({
5703 preparingWorktree: Boolean(baseBranchForWorktree),5846 preparingWorktree: Boolean(baseBranchForWorktree),
5704 submissionIntent: resolvedSubmissionIntent,5847 submissionIntent: resolvedSubmissionIntent,
...@@ -5707,31 +5850,45 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5707,31 +5850,45 @@ function ChatViewContent(props: ChatViewProps) {
5707 const messageIdForSend = newMessageId();5850 const messageIdForSend = newMessageId();
5708 const messageCreatedAt = new Date().toISOString();5851 const messageCreatedAt = new Date().toISOString();
5709 const turnAttachmentsPromise = Promise.all(5852 const turnAttachmentsPromise = Promise.all(
5710 composerImagesSnapshot.map(async (image) => {5853 composerAttachmentsSnapshot.map(async (attachment) => {
5711 if (supportsAttachmentUploads) {5854 if (turnUsesAttachmentUploads) {
5712 const uploaded = getUploadedAttachments({ environmentId, images: [image] })?.[0];5855 const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0];
5713 if (!uploaded) {5856 if (!uploaded) {
5714 throw new Error(`Image '${image.name}' did not finish uploading.`);5857 throw new Error(`Attachment '${attachment.name}' did not finish uploading.`);
5715 }5858 }
5716 return uploaded;5859 return uploaded;
5717 }5860 }
5861 if (attachment.type !== "image") {
5862 throw new Error("This server does not support file attachments.");
5863 }
5718 return {5864 return {
5719 type: "image" as const,5865 type: "image" as const,
5720 name: image.name,5866 name: attachment.name,
5721 mimeType: image.mimeType,5867 mimeType: attachment.mimeType,
5722 sizeBytes: image.sizeBytes,5868 sizeBytes: attachment.sizeBytes,
5723 dataUrl: await readFileAsDataUrl(image.file),5869 dataUrl: await readFileAsDataUrl(attachment.file),
5724 };5870 };
5725 }),5871 }),
5726 );5872 );
5727 const optimisticAttachments = composerImagesSnapshot.map((image) => ({5873 const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) =>
5728 type: "image" as const,5874 attachment.type === "image"
5729 id: image.id,5875 ? {
5730 name: image.name,5876 type: "image" as const,
5731 mimeType: image.mimeType,5877 id: attachment.id,
5732 sizeBytes: image.sizeBytes,5878 name: attachment.name,
5733 previewUrl: image.previewUrl,5879 mimeType: attachment.mimeType,
5734 }));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 );
5735 const shouldAnchorFirstMessage =5892 const shouldAnchorFirstMessage =
5736 activeThread.latestTurn === null &&5893 activeThread.latestTurn === null &&
5737 !timelineMessages.some((message) => message.role === "user");5894 !timelineMessages.some((message) => message.role === "user");
...@@ -5793,6 +5950,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5793,6 +5950,8 @@ function ChatViewContent(props: ChatViewProps) {
5793 if (!titleSeed) {5950 if (!titleSeed) {
5794 if (firstComposerImageName) {5951 if (firstComposerImageName) {
5795 titleSeed = `Image: ${firstComposerImageName}`;5952 titleSeed = `Image: ${firstComposerImageName}`;
5953 } else if (composerFilesSnapshot[0]) {
5954 titleSeed = `File: ${composerFilesSnapshot[0].name}`;
5796 } else if (composerTerminalContextsSnapshot.length > 0) {5955 } else if (composerTerminalContextsSnapshot.length > 0) {
5797 titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!);5956 titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!);
5798 } else if (composerElementContextsSnapshot.length > 0) {5957 } else if (composerElementContextsSnapshot.length > 0) {
...@@ -5839,7 +5998,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5839,7 +5998,14 @@ function ChatViewContent(props: ChatViewProps) {
5839 }5998 }
5840 }5999 }
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 });
5843 if (failure === null && turnAttachmentsResult._tag === "Failure") {6009 if (failure === null && turnAttachmentsResult._tag === "Failure") {
5844 failure = turnAttachmentsResult;6010 failure = turnAttachmentsResult;
5845 }6011 }
...@@ -5909,8 +6075,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5909,8 +6075,8 @@ function ChatViewContent(props: ChatViewProps) {
5909 failure = startResult;6075 failure = startResult;
5910 } else {6076 } else {
5911 turnStartSucceeded = true;6077 turnStartSucceeded = true;
5912 if (supportsAttachmentUploads) {6078 if (turnUsesAttachmentUploads) {
5913 releaseAttachmentUploads(composerImagesSnapshot);6079 releaseDraftAttachments(composerAttachmentsSnapshot);
5914 }6080 }
5915 acknowledgeActiveThreadWoke();6081 acknowledgeActiveThreadWoke();
5916 if (backgroundThreadRef) {6082 if (backgroundThreadRef) {
...@@ -5967,6 +6133,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5967,6 +6133,7 @@ function ChatViewContent(props: ChatViewProps) {
5967 if (6133 if (
5968 promptRef.current.length === 0 &&6134 promptRef.current.length === 0 &&
5969 composerImagesRef.current.length === 0 &&6135 composerImagesRef.current.length === 0 &&
6136 composerFilesRef.current.length === 0 &&
5970 composerTerminalContextsRef.current.length === 0 &&6137 composerTerminalContextsRef.current.length === 0 &&
5971 composerElementContextsRef.current.length === 0 &&6138 composerElementContextsRef.current.length === 0 &&
5972 (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations6139 (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations
...@@ -5985,10 +6152,12 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5985,10 +6152,12 @@ function ChatViewContent(props: ChatViewProps) {
5985 promptRef.current = promptForSend;6152 promptRef.current = promptForSend;
5986 const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry);6153 const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry);
5987 composerImagesRef.current = retryComposerImages;6154 composerImagesRef.current = retryComposerImages;
6155 composerFilesRef.current = composerFilesSnapshot;
5988 composerTerminalContextsRef.current = composerTerminalContextsSnapshot;6156 composerTerminalContextsRef.current = composerTerminalContextsSnapshot;
5989 composerElementContextsRef.current = composerElementContextsSnapshot;6157 composerElementContextsRef.current = composerElementContextsSnapshot;
5990 setComposerDraftPrompt(composerDraftTarget, promptForSend);6158 setComposerDraftPrompt(composerDraftTarget, promptForSend);
5991 addComposerDraftImages(composerDraftTarget, retryComposerImages);6159 addComposerDraftImages(composerDraftTarget, retryComposerImages);
6160 addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot);
5992 setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot);6161 setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot);
5993 setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot);6162 setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot);
5994 setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot);6163 setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot);
...@@ -6958,6 +7127,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6958,6 +7127,7 @@ function ChatViewContent(props: ChatViewProps) {
6958 onRevertUserMessage={onRevertUserMessage}7127 onRevertUserMessage={onRevertUserMessage}
6959 isRevertingCheckpoint={isRevertingCheckpoint}7128 isRevertingCheckpoint={isRevertingCheckpoint}
6960 onImageExpand={onExpandTimelineImage}7129 onImageExpand={onExpandTimelineImage}
7130 onFileDownload={downloadFileAttachment}
6961 markdownCwd={gitCwd ?? undefined}7131 markdownCwd={gitCwd ?? undefined}
6962 resolvedTheme={resolvedTheme}7132 resolvedTheme={resolvedTheme}
6963 timestampFormat={timestampFormat}7133 timestampFormat={timestampFormat}
...@@ -7057,6 +7227,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7057,6 +7227,7 @@ function ChatViewContent(props: ChatViewProps) {
7057 environmentId={environmentId}7227 environmentId={environmentId}
7058 attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown}7228 attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown}
7059 supportsAttachmentUploads={supportsAttachmentUploads}7229 supportsAttachmentUploads={supportsAttachmentUploads}
7230 maxFileAttachmentBytes={maxFileAttachmentBytes}
7060 routeKind={routeKind}7231 routeKind={routeKind}
7061 routeThreadRef={routeThreadRef}7232 routeThreadRef={routeThreadRef}
7062 draftId={draftId}7233 draftId={draftId}
...@@ -7111,6 +7282,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7111,6 +7282,7 @@ function ChatViewContent(props: ChatViewProps) {
7111 gitCwd={gitCwd}7282 gitCwd={gitCwd}
7112 promptRef={promptRef}7283 promptRef={promptRef}
7113 composerImagesRef={composerImagesRef}7284 composerImagesRef={composerImagesRef}
7285 composerFilesRef={composerFilesRef}
7114 composerTerminalContextsRef={composerTerminalContextsRef}7286 composerTerminalContextsRef={composerTerminalContextsRef}
7115 composerElementContextsRef={composerElementContextsRef}7287 composerElementContextsRef={composerElementContextsRef}
7116 onSend={onSend}7288 onSend={onSend}
apps/web/src/components/LegacySidebar.tsx+10-2
...@@ -1471,7 +1471,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1471,7 +1471,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1471 return result;1471 return result;
1472 }1472 }
1473 const draftStore = useComposerDraftStore.getState();1473 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 );
1475 const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef);1483 const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef);
1476 if (projectDraftThread) {1484 if (projectDraftThread) {
1477 draftStore.clearDraftThread(projectDraftThread.draftId);1485 draftStore.clearDraftThread(projectDraftThread.draftId);
...@@ -1479,7 +1487,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1479,7 +1487,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1479 draftStore.clearProjectDraftThreadId(memberProjectRef);1487 draftStore.clearProjectDraftThreadId(memberProjectRef);
1480 return result;1488 return result;
1481 },1489 },
1482 [deleteProject],1490 [deleteProject, sidebarThreads],
1483 );1491 );
14841492
1485 const handleRemoveProject = useCallback(1493 const handleRemoveProject = useCallback(
apps/web/src/components/Sidebar.tsx+1
...@@ -495,6 +495,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -495,6 +495,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
495 // that only the persisted list is populated, hence max not sum.495 // that only the persisted list is populated, hence max not sum.
496 const attachmentCount =496 const attachmentCount =
497 Math.max(composer.images.length, composer.persistedAttachments.length) +497 Math.max(composer.images.length, composer.persistedAttachments.length) +
498 composer.files.length +
498 composer.terminalContexts.length +499 composer.terminalContexts.length +
499 composer.elementContexts.length +500 composer.elementContexts.length +
500 composer.previewAnnotations.length +501 composer.previewAnnotations.length +
apps/web/src/components/chat/ChatComposer.tsx+573-63
...@@ -13,7 +13,6 @@ import type {...@@ -13,7 +13,6 @@ import type {
13 TurnId,13 TurnId,
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import {15import {
16 isProviderSendTurnSupportedImageMimeType,
17 ProviderDriverKind,16 ProviderDriverKind,
18 ProviderInstanceId,17 ProviderInstanceId,
19 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,18 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
...@@ -51,9 +50,13 @@ import {...@@ -51,9 +50,13 @@ import {
51 makeComposerMentionDragHandlers,50 makeComposerMentionDragHandlers,
52} from "./composerMentionDrag";51} from "./composerMentionDrag";
53import {52import {
53 type ComposerFileAttachment,
54 type ComposerImageAttachment,54 type ComposerImageAttachment,
55 type DraftId,55 type DraftId,
56 type PersistedComposerFileAttachment,
56 type PersistedComposerImageAttachment,57 type PersistedComposerImageAttachment,
58 composerFileNeedsReattach,
59 composerTargetKey,
57 hydrateImagesFromPersisted,60 hydrateImagesFromPersisted,
58 useComposerDraftStore,61 useComposerDraftStore,
59 useComposerThreadDraft,62 useComposerThreadDraft,
...@@ -73,16 +76,28 @@ import {...@@ -73,16 +76,28 @@ import {
73 type ComposerTaskStep,76 type ComposerTaskStep,
74 type ComposerTasksProgress,77 type ComposerTasksProgress,
75} from "./ComposerTasksBadge";78} from "./ComposerTasksBadge";
79import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";
76import {80import {
77 compressImageForStash,81 fileAttachmentTooLargeMessage,
78 isHeicImageFile,82 formatAttachmentSize,
79 prepareImageForAttachment,83} from "@t3tools/client-runtime/state/attachments";
80} from "../../lib/imageCompression";
81import {84import {
85 attachmentsToReleaseOnUploadCapabilityLoss,
86 classifyComposerAttachmentFile,
87 fileAttachmentCapabilityBlockReason,
88 fileAttachmentStagingLimit,
89 normalizeComposerImageFileMimeType,
90 shouldHandleComposerAttachmentPaste,
91} from "./composerAttachmentFiles";
92import {
93 readAttachmentUpload,
82 releaseAttachmentUpload,94 releaseAttachmentUpload,
95 releaseDraftAttachment,
96 releasePersistedAttachmentUpload,
83 retryAttachmentUpload,97 retryAttachmentUpload,
84 startAttachmentUpload,98 startAttachmentUpload,
85 useAttachmentUploadStore,99 useAttachmentUploadStore,
100 verifyStashedAttachmentUpload,
86} from "../../lib/attachmentUploadQueue";101} from "../../lib/attachmentUploadQueue";
87import {102import {
88 attachmentUploadBlockReason,103 attachmentUploadBlockReason,
...@@ -241,6 +256,8 @@ import { toastManager } from "../ui/toast";...@@ -241,6 +256,8 @@ import { toastManager } from "../ui/toast";
241import {256import {
242 BotIcon,257 BotIcon,
243 CircleAlertIcon,258 CircleAlertIcon,
259 FileIcon,
260 PaperclipIcon,
244 PencilRulerIcon,261 PencilRulerIcon,
245 type LucideIcon,262 type LucideIcon,
246 LockIcon,263 LockIcon,
...@@ -532,6 +549,7 @@ export interface ChatComposerHandle {...@@ -532,6 +549,7 @@ export interface ChatComposerHandle {
532 getSendContext: () => {549 getSendContext: () => {
533 prompt: string;550 prompt: string;
534 images: ComposerImageAttachment[];551 images: ComposerImageAttachment[];
552 files: ComposerFileAttachment[];
535 terminalContexts: TerminalContextDraft[];553 terminalContexts: TerminalContextDraft[];
536 elementContexts: ElementContextDraft[];554 elementContexts: ElementContextDraft[];
537 previewAnnotations: PreviewAnnotationPayload[];555 previewAnnotations: PreviewAnnotationPayload[];
...@@ -557,6 +575,7 @@ export interface ChatComposerProps {...@@ -557,6 +575,7 @@ export interface ChatComposerProps {
557 environmentId: EnvironmentId;575 environmentId: EnvironmentId;
558 attachmentUploadsCapabilityKnown: boolean;576 attachmentUploadsCapabilityKnown: boolean;
559 supportsAttachmentUploads: boolean;577 supportsAttachmentUploads: boolean;
578 maxFileAttachmentBytes: number | null;
560 routeKind: "server" | "draft";579 routeKind: "server" | "draft";
561 routeThreadRef: ScopedThreadRef;580 routeThreadRef: ScopedThreadRef;
562 draftId: DraftId | null;581 draftId: DraftId | null;
...@@ -630,6 +649,7 @@ export interface ChatComposerProps {...@@ -630,6 +649,7 @@ export interface ChatComposerProps {
630 // Refs the parent needs kept in sync649 // Refs the parent needs kept in sync
631 promptRef: React.RefObject<string>;650 promptRef: React.RefObject<string>;
632 composerImagesRef: React.RefObject<ComposerImageAttachment[]>;651 composerImagesRef: React.RefObject<ComposerImageAttachment[]>;
652 composerFilesRef: React.RefObject<ComposerFileAttachment[]>;
633 composerTerminalContextsRef: React.RefObject<TerminalContextDraft[]>;653 composerTerminalContextsRef: React.RefObject<TerminalContextDraft[]>;
634 composerElementContextsRef: React.RefObject<ElementContextDraft[]>;654 composerElementContextsRef: React.RefObject<ElementContextDraft[]>;
635 composerRef: React.RefObject<ChatComposerHandle | null>;655 composerRef: React.RefObject<ChatComposerHandle | null>;
...@@ -675,6 +695,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -675,6 +695,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
675 environmentId,695 environmentId,
676 attachmentUploadsCapabilityKnown,696 attachmentUploadsCapabilityKnown,
677 supportsAttachmentUploads,697 supportsAttachmentUploads,
698 maxFileAttachmentBytes,
678 routeKind,699 routeKind,
679 routeThreadRef,700 routeThreadRef,
680 draftId,701 draftId,
...@@ -721,6 +742,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -721,6 +742,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
721 promptRef,742 promptRef,
722 composerRef,743 composerRef,
723 composerImagesRef,744 composerImagesRef,
745 composerFilesRef,
724 composerTerminalContextsRef,746 composerTerminalContextsRef,
725 composerElementContextsRef,747 composerElementContextsRef,
726 onSend,748 onSend,
...@@ -745,21 +767,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -745,21 +767,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
745 // Store subscriptions (prompt / images / terminal contexts)767 // Store subscriptions (prompt / images / terminal contexts)
746 // ------------------------------------------------------------------768 // ------------------------------------------------------------------
747 const composerDraft = useComposerThreadDraft(composerDraftTarget);769 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);
748 const prompt = composerDraft.prompt;774 const prompt = composerDraft.prompt;
749 const composerImages = composerDraft.images;775 const composerImages = composerDraft.images;
776 const composerFiles = composerDraft.files;
750 const composerTerminalContexts = composerDraft.terminalContexts;777 const composerTerminalContexts = composerDraft.terminalContexts;
751 const composerElementContexts = composerDraft.elementContexts;778 const composerElementContexts = composerDraft.elementContexts;
752 const composerPreviewAnnotations = composerDraft.previewAnnotations;779 const composerPreviewAnnotations = composerDraft.previewAnnotations;
753 const composerReviewComments = composerDraft.reviewComments;780 const composerReviewComments = composerDraft.reviewComments;
754 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;781 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
755 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);782 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
756 const attachmentBlockReason = supportsAttachmentUploads783 const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length;
757 ? attachmentUploadBlockReason({784 const fileStagingLimit = fileAttachmentStagingLimit({
758 imageIds: composerImages.map((image) => image.id),785 attachmentUploadsCapabilityKnown,
759 uploadsByImageId,786 supportsAttachmentUploads,
760 environmentId,787 maxFileAttachmentBytes,
761 })788 });
762 : null;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);
763 const sendDisabledReason =808 const sendDisabledReason =
764 externalSendDisabledReason ?? (activePendingProgress ? null : attachmentBlockReason);809 externalSendDisabledReason ?? (activePendingProgress ? null : attachmentBlockReason);
765 const isSendDisabled = sendDisabledReason !== null;810 const isSendDisabled = sendDisabledReason !== null;
...@@ -768,6 +813,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -768,6 +813,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
768 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);813 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
769 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);814 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
770 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);815 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);
771 const insertComposerDraftTerminalContext = useComposerDraftStore(819 const insertComposerDraftTerminalContext = useComposerDraftStore(
772 (store) => store.insertTerminalContext,820 (store) => store.insertTerminalContext,
773 );821 );
...@@ -802,15 +850,76 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -802,15 +850,76 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
802 return;850 return;
803 }851 }
804 if (!supportsAttachmentUploads) {852 if (!supportsAttachmentUploads) {
805 for (const image of composerImages) {853 // The capability can flap on reconnect or version skew. Deleting a
806 releaseAttachmentUpload(image.id);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);
807 }861 }
808 return;862 return;
809 }863 }
810 for (const image of composerImages) {864 const invalidFiles =
811 startAttachmentUpload({ environmentId, image });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 }
812 }912 }
813 }, [attachmentUploadsCapabilityKnown, composerImages, environmentId, supportsAttachmentUploads]);913 }, [
914 attachmentUploadsCapabilityKnown,
915 composerDraftTarget,
916 composerFiles,
917 environmentId,
918 maxFileAttachmentBytes,
919 setComposerDraftFileUpload,
920 supportsAttachmentUploads,
921 uploadsByImageId,
922 ]);
814923
815 // ------------------------------------------------------------------924 // ------------------------------------------------------------------
816 // Model state925 // Model state
...@@ -1068,6 +1177,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1068,6 +1177,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1068 // Refs1177 // Refs
1069 // ------------------------------------------------------------------1178 // ------------------------------------------------------------------
1070 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);1179 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
1180 const attachmentInputRef = useRef<HTMLInputElement>(null);
1071 const composerFormRef = useRef<HTMLFormElement>(null);1181 const composerFormRef = useRef<HTMLFormElement>(null);
1072 const composerSurfaceRef = useRef<HTMLDivElement>(null);1182 const composerSurfaceRef = useRef<HTMLDivElement>(null);
1073 const providerInputRejectedRef = useRef(false);1183 const providerInputRejectedRef = useRef(false);
...@@ -1102,7 +1212,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1102,7 +1212,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1102 () =>1212 () =>
1103 deriveComposerSendState({1213 deriveComposerSendState({
1104 prompt,1214 prompt,
1105 imageCount: composerImages.length,1215 imageCount: composerImages.length + composerFiles.length,
1106 terminalContexts: composerTerminalContexts,1216 terminalContexts: composerTerminalContexts,
1107 elementContextCount:1217 elementContextCount:
1108 composerElementContexts.length +1218 composerElementContexts.length +
...@@ -1111,6 +1221,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1111,6 +1221,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1111 }),1221 }),
1112 [1222 [
1113 composerElementContexts.length,1223 composerElementContexts.length,
1224 composerFiles.length,
1114 composerImages.length,1225 composerImages.length,
1115 composerPreviewAnnotations.length,1226 composerPreviewAnnotations.length,
1116 composerReviewComments.length,1227 composerReviewComments.length,
...@@ -1395,6 +1506,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1395,6 +1506,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1395 [composerDraftTarget, addComposerDraftImages],1506 [composerDraftTarget, addComposerDraftImages],
1396 );1507 );
13971508
1509 const addComposerFilesToDraft = useCallback(
1510 (files: ComposerFileAttachment[]) => {
1511 addComposerDraftFiles(composerDraftTarget, files);
1512 },
1513 [addComposerDraftFiles, composerDraftTarget],
1514 );
1515
1398 const removeComposerImageFromDraft = useCallback(1516 const removeComposerImageFromDraft = useCallback(
1399 (imageId: string) => {1517 (imageId: string) => {
1400 releaseAttachmentUpload(imageId);1518 releaseAttachmentUpload(imageId);
...@@ -1403,6 +1521,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1403,6 +1521,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1403 [composerDraftTarget, removeComposerDraftImage],1521 [composerDraftTarget, removeComposerDraftImage],
1404 );1522 );
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
1406 const removeComposerTerminalContextFromDraft = useCallback(1539 const removeComposerTerminalContextFromDraft = useCallback(
1407 (contextId: string) => {1540 (contextId: string) => {
1408 const contextIndex = composerTerminalContexts.findIndex(1541 const contextIndex = composerTerminalContexts.findIndex(
...@@ -1459,6 +1592,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1459,6 +1592,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1459 composerImagesRef.current = composerImages;1592 composerImagesRef.current = composerImages;
1460 }, [composerImages, composerImagesRef]);1593 }, [composerImages, composerImagesRef]);
14611594
1595 useEffect(() => {
1596 composerFilesRef.current = composerFiles;
1597 }, [composerFiles, composerFilesRef]);
1598
1462 useEffect(() => {1599 useEffect(() => {
1463 composerTerminalContextsRef.current = composerTerminalContexts;1600 composerTerminalContextsRef.current = composerTerminalContexts;
1464 }, [composerTerminalContexts, composerTerminalContextsRef]);1601 }, [composerTerminalContexts, composerTerminalContextsRef]);
...@@ -2127,9 +2264,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2127,9 +2264,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2127 // ------------------------------------------------------------------2264 // ------------------------------------------------------------------
2128 // Prompt stash (⌘S)2265 // Prompt stash (⌘S)
2129 // ------------------------------------------------------------------2266 // ------------------------------------------------------------------
2130 // One global queue. Stashed prompts carry only text + images so they can be2267 // Files remain tied to the environment that owns their uploaded bytes.
2131 // restored into any thread or provider — stash, switch, restore is the
2132 // whole point.
2133 const stashQueue = usePromptStashStore((state) => state.entries);2268 const stashQueue = usePromptStashStore((state) => state.entries);
2134 const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry);2269 const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry);
2135 const takeStashEntry = usePromptStashStore((state) => state.takeEntry);2270 const takeStashEntry = usePromptStashStore((state) => state.takeEntry);
...@@ -2157,10 +2292,45 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2157,10 +2292,45 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2157 }, []);2292 }, []);
21582293
2159 const restoreStashEntry = useCallback(2294 const restoreStashEntry = useCallback(
2160 (entry: PromptStashEntry) => {2295 async (menuEntry: PromptStashEntry) => {
2161 // Remove first so a double activation (click + Enter) can't restore twice.2296 const filesToVerify = menuEntry.files ?? [];
2162 const { entry: taken, durable } = takeStashEntry(entry.id);2297 if (filesToVerify.some((file) => file.environmentId !== environmentId)) {
2163 if (!taken) return;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;
2164 if (!durable) {2334 if (!durable) {
2165 toastManager.add({2335 toastManager.add({
2166 type: "warning",2336 type: "warning",
...@@ -2170,7 +2340,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2170,7 +2340,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2170 data: { hideCopyButton: true },2340 data: { hideCopyButton: true },
2171 });2341 });
2172 }2342 }
2173 setIsStashMenuOpen(false);
21742343
2175 const currentPrompt = promptRef.current;2344 const currentPrompt = promptRef.current;
2176 // An image-only stash must not append blank lines to whatever is2345 // An image-only stash must not append blank lines to whatever is
...@@ -2189,6 +2358,121 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2189,6 +2358,121 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2189 setComposerTrigger(null);2358 setComposerTrigger(null);
2190 }2359 }
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
2192 let unrestoredImageNames: string[] = [];2476 let unrestoredImageNames: string[] = [];
2193 if (entry.attachments.length > 0) {2477 if (entry.attachments.length > 0) {
2194 const existingIds = new Set(composerImagesRef.current.map((image) => image.id));2478 const existingIds = new Set(composerImagesRef.current.map((image) => image.id));
...@@ -2203,7 +2487,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2203,7 +2487,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2203 );2487 );
2204 const capacity = Math.max(2488 const capacity = Math.max(
2205 0,2489 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,
2207 );2494 );
2208 const pending = entry.attachments.filter(2495 const pending = entry.attachments.filter(
2209 (attachment) =>2496 (attachment) =>
...@@ -2242,13 +2529,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2242,13 +2529,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2242 }2529 }
2243 if (unrestoredImageNames.length > 0) {2530 if (unrestoredImageNames.length > 0) {
2244 missingImageReasons.push(2531 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.`,
2246 );2543 );
2247 }2544 }
2248 if (missingImageReasons.length > 0) {2545 if (missingImageReasons.length > 0) {
2249 toastManager.add({2546 toastManager.add({
2250 type: "warning",2547 type: "warning",
2251 title: "Some images were not restored",2548 title: "Some attachments were not restored",
2252 description: missingImageReasons.join(" "),2549 description: missingImageReasons.join(" "),
2253 });2550 });
2254 }2551 }
...@@ -2262,9 +2559,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2262,9 +2559,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2262 }2559 }
2263 },2560 },
2264 [2561 [
2562 addComposerDraftFiles,
2265 addComposerDraftImages,2563 addComposerDraftImages,
2266 composerDraftTarget,2564 composerDraftTarget,
2565 composerFilesRef,
2267 composerImagesRef,2566 composerImagesRef,
2567 environmentId,
2268 promptRef,2568 promptRef,
2269 setComposerDraftPrompt,2569 setComposerDraftPrompt,
2270 takeStashEntry,2570 takeStashEntry,
...@@ -2273,7 +2573,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2273,7 +2573,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22732573
2274 const deleteStashEntry = useCallback(2574 const deleteStashEntry = useCallback(
2275 (entry: PromptStashEntry) => {2575 (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 }
2277 if (!durable) {2586 if (!durable) {
2278 toastManager.add({2587 toastManager.add({
2279 type: "warning",2588 type: "warning",
...@@ -2292,17 +2601,45 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2292,17 +2601,45 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2292 // round-trip, so they are stripped from the stashed prompt.2601 // round-trip, so they are stripped from the stashed prompt.
2293 const prompt = promptRef.current.split(INLINE_TERMINAL_CONTEXT_PLACEHOLDER).join("").trim();2602 const prompt = promptRef.current.split(INLINE_TERMINAL_CONTEXT_PLACEHOLDER).join("").trim();
2294 const images = [...composerImagesRef.current];2603 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) {
2296 setIsStashMenuOpen((open) => !open);2606 setIsStashMenuOpen((open) => !open);
2297 return;2607 return;
2298 }2608 }
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 }
2299 // A repeat ⌘S on the *same* still-unencoded snapshot would stash it2635 // A repeat ⌘S on the *same* still-unencoded snapshot would stash it
2300 // twice. Guard on the snapshot itself rather than a bare boolean: once2636 // twice. Guard on the snapshot itself rather than a bare boolean: once
2301 // the composer has been cleared the user can type something genuinely2637 // the composer has been cleared the user can type something genuinely
2302 // new (or switch threads) while encoding continues, and that deserves its2638 // new (or switch threads) while encoding continues, and that deserves its
2303 // own entry.2639 // own entry.
2304 const snapshotKey = `${String(composerDraftTarget)}${prompt}${images2640 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}`))
2306 .join(",")}`;2643 .join(",")}`;
2307 if (stashInFlightRef.current.has(snapshotKey)) return;2644 if (stashInFlightRef.current.has(snapshotKey)) return;
2308 stashInFlightRef.current.add(snapshotKey);2645 stashInFlightRef.current.add(snapshotKey);
...@@ -2320,6 +2657,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2320,6 +2657,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2320 createdAt: new Date().toISOString(),2657 createdAt: new Date().toISOString(),
2321 prompt,2658 prompt,
2322 attachments: [],2659 attachments: [],
2660 ...(stashedFiles.length > 0 ? { files: stashedFiles } : {}),
2323 droppedImageNames: [],2661 droppedImageNames: [],
2324 unreadableImageNames: [],2662 unreadableImageNames: [],
2325 pendingImageCount: images.length,2663 pendingImageCount: images.length,
...@@ -2352,9 +2690,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2352,9 +2690,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2352 });2690 });
2353 }2691 }
23542692
2355 // Only the prompt and images are cleared — terminal/element contexts,2693 // Terminal and preview context stays behind because the stash cannot restore it.
2356 // preview annotations, and review comments are not stashable, so
2357 // destroying them here would be unrecoverable.
2358 promptRef.current = "";2694 promptRef.current = "";
2359 clearComposerDraftPromptAndImages(stashTarget);2695 clearComposerDraftPromptAndImages(stashTarget);
2360 for (const image of images) {2696 for (const image of images) {
...@@ -2365,6 +2701,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2365,6 +2701,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2365 pulseStashBadge();2701 pulseStashBadge();
23662702
2367 if (evicted) {2703 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 }
2368 toastManager.add({2711 toastManager.add({
2369 type: "warning",2712 type: "warning",
2370 title: "Oldest stashed prompt discarded",2713 title: "Oldest stashed prompt discarded",
...@@ -2440,7 +2783,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2440,7 +2783,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2440 }, [2783 }, [
2441 clearComposerDraftPromptAndImages,2784 clearComposerDraftPromptAndImages,
2442 composerDraftTarget,2785 composerDraftTarget,
2786 composerFilesRef,
2443 composerImagesRef,2787 composerImagesRef,
2788 environmentId,
2444 finalizeStashEntryImages,2789 finalizeStashEntryImages,
2445 promptRef,2790 promptRef,
2446 pulseStashBadge,2791 pulseStashBadge,
...@@ -2586,14 +2931,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2586,14 +2931,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2586 ]);2931 ]);
25872932
2588 // ------------------------------------------------------------------2933 // ------------------------------------------------------------------
2589 // Callbacks: images2934 // Callbacks: attachments
2590 // ------------------------------------------------------------------2935 // ------------------------------------------------------------------
2591 const addComposerImages = async (files: File[]) => {2936 const addComposerAttachments = async (files: File[]) => {
2592 if (!activeThreadId || files.length === 0) return;2937 if (!activeThreadId || files.length === 0) return;
2593 if (pendingUserInputs.length > 0) {2938 if (pendingUserInputs.length > 0) {
2594 toastManager.add({2939 toastManager.add({
2595 type: "error",2940 type: "error",
2596 title: "Attach images after answering plan questions.",2941 title: "Attach files after answering plan questions.",
2597 });2942 });
2598 return;2943 return;
2599 }2944 }
...@@ -2606,34 +2951,75 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2606,34 +2951,75 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2606 // accepted files reserve their attachment slots (via the pending counter)2951 // accepted files reserve their attachment slots (via the pending counter)
2607 // before the first await, keeping the total under the limit.2952 // before the first await, keeping the total under the limit.
2608 const pendingCount = pendingImageCompressionsRef.current.get(threadId) ?? 0;2953 const pendingCount = pendingImageCompressionsRef.current.get(threadId) ?? 0;
2609 let reservedCount = composerImagesRef.current.length + pendingCount;2954 let reservedCount =
2610 const acceptedFiles: File[] = [];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[] = [];
2611 let error: string | null = null;2966 let error: string | null = null;
2612 for (const file of files) {2967 for (const file of files) {
2613 const isHeicImage = isHeicImageFile(file);2968 const attachmentKind = classifyComposerAttachmentFile(file);
2614 if (!file.type.startsWith("image/") && !isHeicImage) {2969 const replacesReattachMarker =
2615 error = `Unsupported file type for '${file.name}'. Please attach image files only.`;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.
2616 continue;2978 continue;
2617 }2979 }
2618 if (!isHeicImage && !isProviderSendTurnSupportedImageMimeType(file.type)) {2980 if (attachmentKind === "unsupported-image") {
2619 error = `'${file.name}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.`;2981 error = `'${file.name}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.`;
2620 continue;2982 continue;
2621 }2983 }
2622 if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {2984 if (attachmentKind === "image") {
2623 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;2985 acceptedImages.push(normalizeComposerImageFileMimeType(file));
2624 break;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;
2625 }3010 }
2626 acceptedFiles.push(file);
2627 reservedCount += 1;
2628 }3011 }
2629 setThreadError(threadId, error);3012 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);
2633 try {3019 try {
2634 const nextImages: ComposerImageAttachment[] = [];3020 const nextImages: ComposerImageAttachment[] = [];
2635 let compressionError: string | null = null;3021 let compressionError: string | null = null;
2636 for (const file of acceptedFiles) {3022 for (const file of acceptedImages) {
2637 // Images over the wire cap are downscaled to fit rather than3023 // Images over the wire cap are downscaled to fit rather than
2638 // refused; files already within it pass through byte-for-byte.3024 // refused; files already within it pass through byte-for-byte.
2639 const compressed = await prepareImageForAttachment(3025 const compressed = await prepareImageForAttachment(
...@@ -2673,7 +3059,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2673,7 +3059,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2673 }3059 }
2674 } finally {3060 } finally {
2675 const remaining =3061 const remaining =
2676 (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedFiles.length;3062 (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedImages.length;
2677 if (remaining > 0) {3063 if (remaining > 0) {
2678 pendingImageCompressionsRef.current.set(threadId, remaining);3064 pendingImageCompressionsRef.current.set(threadId, remaining);
2679 } else {3065 } else {
...@@ -2691,13 +3077,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2691,13 +3077,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2691 // ------------------------------------------------------------------3077 // ------------------------------------------------------------------
2692 const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {3078 const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {
2693 const files = Array.from(event.clipboardData.files);3079 const files = Array.from(event.clipboardData.files);
2694 if (files.length === 0) return;3080 // Claimable pastes go through even when plan questions are pending or the
2695 const imageFiles = files.filter(3081 // composer is at its attachment limit: `addComposerAttachments` surfaces
2696 (file) => file.type.startsWith("image/") || isHeicImageFile(file),3082 // those as a toast and a thread error. An early return here would swallow
2697 );3083 // the paste with no feedback.
2698 if (imageFiles.length === 0) return;3084 if (
3085 files.length === 0 ||
3086 !activeThreadId ||
3087 !shouldHandleComposerAttachmentPaste({
3088 files,
3089 plainText: event.clipboardData.getData("text/plain"),
3090 })
3091 ) {
3092 return;
3093 }
2699 event.preventDefault();3094 event.preventDefault();
2700 void addComposerImages(imageFiles);3095 void addComposerAttachments(files);
2701 };3096 };
27023097
2703 const insertComposerTextAtEnd = (3098 const insertComposerTextAtEnd = (
...@@ -2823,7 +3218,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2823,7 +3218,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2823 composerEditorRef.current?.focusAt(cursor);3218 composerEditorRef.current?.focusAt(cursor);
2824 },3219 },
2825 addDroppedFiles: (files: File[]) => {3220 addDroppedFiles: (files: File[]) => {
2826 void addComposerImages(files);3221 void addComposerAttachments(files);
2827 focusComposer();3222 focusComposer();
2828 },3223 },
2829 insertTextAtEnd: insertComposerTextAtEnd,3224 insertTextAtEnd: insertComposerTextAtEnd,
...@@ -2894,6 +3289,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2894,6 +3289,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2894 getSendContext: () => ({3289 getSendContext: () => ({
2895 prompt: promptRef.current,3290 prompt: promptRef.current,
2896 images: composerImagesRef.current,3291 images: composerImagesRef.current,
3292 files: composerFilesRef.current,
2897 terminalContexts: composerTerminalContextsRef.current,3293 terminalContexts: composerTerminalContextsRef.current,
2898 elementContexts: composerElementContextsRef.current,3294 elementContexts: composerElementContextsRef.current,
2899 previewAnnotations: composerPreviewAnnotations,3295 previewAnnotations: composerPreviewAnnotations,
...@@ -2919,13 +3315,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2919,13 +3315,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2919 }),3315 }),
2920 [3316 [
2921 activeThread,3317 activeThread,
2922 addComposerImages,3318 addComposerAttachments,
2923 composerDraftTarget,3319 composerDraftTarget,
2924 composerCursor,3320 composerCursor,
2925 composerTerminalContexts,3321 composerTerminalContexts,
2926 insertComposerDraftTerminalContext,3322 insertComposerDraftTerminalContext,
2927 promptRef,3323 promptRef,
2928 composerImagesRef,3324 composerImagesRef,
3325 composerFilesRef,
2929 composerTerminalContextsRef,3326 composerTerminalContextsRef,
2930 composerElementContextsRef,3327 composerElementContextsRef,
2931 composerPreviewAnnotations,3328 composerPreviewAnnotations,
...@@ -3241,7 +3638,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3241,7 +3638,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3241 ? {3638 ? {
3242 uploadsByImageId,3639 uploadsByImageId,
3243 onRetryUpload: (image: ComposerImageAttachment) =>3640 onRetryUpload: (image: ComposerImageAttachment) =>
3244 retryAttachmentUpload({ environmentId, image }),3641 retryAttachmentUpload({
3642 environmentId,
3643 image,
3644 draftTarget: composerDraftTarget,
3645 }),
3245 }3646 }
3246 : {})}3647 : {})}
3247 onRemove={(annotationId) => {3648 onRemove={(annotationId) => {
...@@ -3367,7 +3768,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3367,7 +3768,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3367 size="icon-xs"3768 size="icon-xs"
3368 className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95"3769 className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95"
3369 onClick={() =>3770 onClick={() =>
3370 retryAttachmentUpload({ environmentId, image })3771 retryAttachmentUpload({
3772 environmentId,
3773 image,
3774 draftTarget: composerDraftTarget,
3775 })
3371 }3776 }
3372 aria-label={`Retry upload for ${image.name}`}3777 aria-label={`Retry upload for ${image.name}`}
3373 />3778 />
...@@ -3398,6 +3803,78 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3398,6 +3803,78 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3398 </div>3803 </div>
3399 )}3804 )}
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
3401 <div className="relative">3878 <div className="relative">
3402 <ComposerPromptEditor3879 <ComposerPromptEditor
3403 editorRef={composerEditorRef}3880 editorRef={composerEditorRef}
...@@ -3568,6 +4045,39 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3568,6 +4045,39 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3568 }4045 }
3569 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"4046 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
3570 >4047 >
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}
3571 {showMobilePendingAnswerActions ? null : inlineTasksBadge}4081 {showMobilePendingAnswerActions ? null : inlineTasksBadge}
3572 {showMobilePendingAnswerActions ? null : inlineStashBadge}4082 {showMobilePendingAnswerActions ? null : inlineStashBadge}
3573 <ComposerFooterPrimaryActions4083 <ComposerFooterPrimaryActions
apps/web/src/components/chat/ComposerStashMenu.test.tsx+42
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { renderToStaticMarkup } from "react-dom/server";1import { renderToStaticMarkup } from "react-dom/server";
2import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
34
4import { ComposerStashMenu } from "./ComposerStashMenu";5import { ComposerStashMenu } from "./ComposerStashMenu";
...@@ -89,4 +90,45 @@ describe("ComposerStashMenu", () => {...@@ -89,4 +90,45 @@ describe("ComposerStashMenu", () => {
89 expect(markup).toContain("size-3.5 stroke-2");90 expect(markup).toContain("size-3.5 stroke-2");
90 expect(markup).not.toContain("bg-background/90");91 expect(markup).not.toContain("bg-background/90");
91 });92 });
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 });
92});134});
apps/web/src/components/chat/ComposerStashMenu.tsx+14-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { XIcon } from "lucide-react";1import { FileIcon, XIcon } from "lucide-react";
2import { memo, useEffect, useRef, useState } from "react";2import { memo, useEffect, useRef, useState } from "react";
33
4import { formatRelativeTimeLabel } from "../../timestampFormat";4import { formatRelativeTimeLabel } from "../../timestampFormat";
...@@ -20,7 +20,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string {...@@ -20,7 +20,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string {
20 return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed;20 return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed;
21 }21 }
22 const imageCount = entry.attachments.length + entry.droppedImageNames.length;22 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"})`;
24}30}
2531
26/**32/**
...@@ -180,6 +186,12 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -180,6 +186,12 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
180 ))}186 ))}
181 </span>187 </span>
182 ) : null}188 ) : 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}
183 <span className="shrink-0 text-secondary-label text-xs max-sm:hidden">195 <span className="shrink-0 text-secondary-label text-xs max-sm:hidden">
184 {formatRelativeTimeLabel(entry.createdAt)}196 {formatRelativeTimeLabel(entry.createdAt)}
185 </span>197 </span>
apps/web/src/components/chat/MessagesTimeline.test.tsx+110
...@@ -558,6 +558,116 @@ describe("MessagesTimeline", () => {...@@ -558,6 +558,116 @@ describe("MessagesTimeline", () => {
558 expect(onAnchorReady).not.toHaveBeenCalled();558 expect(onAnchorReady).not.toHaveBeenCalled();
559 });559 });
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
561 it("keeps reserved end space when tool work starts while reading history", () => {671 it("keeps reserved end space when tool work starts while reading history", () => {
562 const turnId = TurnId.make("turn-with-active-tool");672 const turnId = TurnId.make("turn-with-active-tool");
563 const firstEntry = buildUserTimelineEntry("Run the command.");673 const firstEntry = buildUserTimelineEntry("Run the command.");
apps/web/src/components/chat/MessagesTimeline.tsx+67-2
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 type ChatFileAttachment,
2 type EnvironmentId,3 type EnvironmentId,
3 type MessageId,4 type MessageId,
4 type ScopedThreadRef,5 type ScopedThreadRef,
...@@ -14,6 +15,7 @@ import {...@@ -14,6 +15,7 @@ import {
1415
15const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();16const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();
16const NOOP_OPEN_AGENTS = () => {};17const NOOP_OPEN_AGENTS = () => {};
18const NOOP_DOWNLOAD_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
17import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";19import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
18import {20import {
19 createContext,21 createContext,
...@@ -38,7 +40,12 @@ import {...@@ -38,7 +40,12 @@ import {
38 workEntrySignalsSevereFailure,40 workEntrySignalsSevereFailure,
39 workLogEntryIsToolLike,41 workLogEntryIsToolLike,
40} from "../../session-logic";42} 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";
42import {49import {
43 getRenderablePatch,50 getRenderablePatch,
44 resolveDiffThemeName,51 resolveDiffThemeName,
...@@ -51,7 +58,9 @@ import {...@@ -51,7 +58,9 @@ import {
51 ChevronDownIcon,58 ChevronDownIcon,
52 ChevronRightIcon,59 ChevronRightIcon,
53 CircleAlertIcon,60 CircleAlertIcon,
61 DownloadIcon,
54 EyeIcon,62 EyeIcon,
63 FileIcon,
55 GlobeIcon,64 GlobeIcon,
56 HammerIcon,65 HammerIcon,
57 MessageCircleIcon,66 MessageCircleIcon,
...@@ -143,6 +152,7 @@ interface TimelineRowSharedState {...@@ -143,6 +152,7 @@ interface TimelineRowSharedState {
143 activeThreadEnvironmentId: EnvironmentId;152 activeThreadEnvironmentId: EnvironmentId;
144 onRevertUserMessage: (messageId: MessageId) => void;153 onRevertUserMessage: (messageId: MessageId) => void;
145 onImageExpand: (preview: ExpandedImagePreview) => void;154 onImageExpand: (preview: ExpandedImagePreview) => void;
155 onFileDownload: (attachment: ChatFileAttachment) => void;
146 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;156 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
147 onToggleTurnFold: (turnId: TurnId) => void;157 onToggleTurnFold: (turnId: TurnId) => void;
148 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;158 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
...@@ -221,6 +231,7 @@ interface MessagesTimelineProps {...@@ -221,6 +231,7 @@ interface MessagesTimelineProps {
221 onRevertUserMessage: (messageId: MessageId) => void;231 onRevertUserMessage: (messageId: MessageId) => void;
222 isRevertingCheckpoint: boolean;232 isRevertingCheckpoint: boolean;
223 onImageExpand: (preview: ExpandedImagePreview) => void;233 onImageExpand: (preview: ExpandedImagePreview) => void;
234 onFileDownload?: (attachment: ChatFileAttachment) => void;
224 activeThreadEnvironmentId: EnvironmentId;235 activeThreadEnvironmentId: EnvironmentId;
225 markdownCwd: string | undefined;236 markdownCwd: string | undefined;
226 resolvedTheme: "light" | "dark";237 resolvedTheme: "light" | "dark";
...@@ -266,6 +277,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -266,6 +277,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
266 onRevertUserMessage,277 onRevertUserMessage,
267 isRevertingCheckpoint,278 isRevertingCheckpoint,
268 onImageExpand,279 onImageExpand,
280 onFileDownload = NOOP_DOWNLOAD_ATTACHMENT,
269 activeThreadEnvironmentId,281 activeThreadEnvironmentId,
270 markdownCwd,282 markdownCwd,
271 resolvedTheme,283 resolvedTheme,
...@@ -524,6 +536,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -524,6 +536,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
524 activeThreadEnvironmentId,536 activeThreadEnvironmentId,
525 onRevertUserMessage,537 onRevertUserMessage,
526 onImageExpand,538 onImageExpand,
539 onFileDownload,
527 onOpenTurnDiff,540 onOpenTurnDiff,
528 onToggleTurnFold,541 onToggleTurnFold,
529 onToggleWorkGroup,542 onToggleWorkGroup,
...@@ -540,6 +553,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -540,6 +553,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
540 activeThreadEnvironmentId,553 activeThreadEnvironmentId,
541 onRevertUserMessage,554 onRevertUserMessage,
542 onImageExpand,555 onImageExpand,
556 onFileDownload,
543 onOpenTurnDiff,557 onOpenTurnDiff,
544 onToggleTurnFold,558 onToggleTurnFold,
545 onToggleWorkGroup,559 onToggleWorkGroup,
...@@ -986,7 +1000,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -986,7 +1000,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
9861000
987function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {1001function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
988 const ctx = use(TimelineRowCtx);1002 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.
989 const userImages = (row.message.attachments ?? []).filter(isImageAttachment);1005 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 );
990 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);1010 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);
991 const terminalContexts = displayedUserMessage.contexts;1011 const terminalContexts = displayedUserMessage.contexts;
992 const previewAnnotations: ParsedPreviewAnnotation[] = [];1012 const previewAnnotations: ParsedPreviewAnnotation[] = [];
...@@ -1011,7 +1031,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1011,7 +1031,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1011 <div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground">1031 <div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground">
1012 {regularImages.length > 0 && (1032 {regularImages.length > 0 && (
1013 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">1033 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
1014 {regularImages.map((image: ChatImageAttachment) => (1034 {regularImages.map((image) => (
1015 <div1035 <div
1016 key={image.id}1036 key={image.id}
1017 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"1037 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
...@@ -1049,6 +1069,51 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1049,6 +1069,51 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1049 image={previewImages[index] ?? null}1069 image={previewImages[index] ?? null}
1050 />1070 />
1051 ))}1071 ))}
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}
1052 {elementContexts.length > 0 ? (1117 {elementContexts.length > 0 ? (
1053 <div className="mb-2 flex flex-wrap gap-1.5">1118 <div className="mb-2 flex flex-wrap gap-1.5">
1054 {elementContexts.map((context) => (1119 {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 {...@@ -6,7 +6,7 @@ import {
6 squashAtomCommandFailure,6 squashAtomCommandFailure,
7 type AtomCommandResult,7 type AtomCommandResult,
8} from "@t3tools/client-runtime/state/runtime";8} from "@t3tools/client-runtime/state/runtime";
9import { scopeProjectRef } from "@t3tools/client-runtime/environment";9import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
10import { AsyncResult } from "effect/unstable/reactivity";10import { AsyncResult } from "effect/unstable/reactivity";
11import {11import {
12 deriveProjectGroupingOverrideKey,12 deriveProjectGroupingOverrideKey,
...@@ -733,7 +733,10 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -733,7 +733,10 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
733 return;733 return;
734 }734 }
735 const projectRef = scopeProjectRef(member.environmentId, member.id);735 const projectRef = scopeProjectRef(member.environmentId, member.id);
736 releaseProjectDraftUploads(projectRef);736 releaseProjectDraftUploads(
737 projectRef,
738 memberThreads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)),
739 );
737 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);740 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);
738 if (projectDraftThread) {741 if (projectDraftThread) {
739 draftStore.clearDraftThread(projectDraftThread.draftId);742 draftStore.clearDraftThread(projectDraftThread.draftId);
apps/web/src/composerDraftStore.test.ts+483
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 ProjectId,11 ProjectId,
12 ProviderDriverKind,12 ProviderDriverKind,
13 ProviderInstanceId,13 ProviderInstanceId,
14 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
14 ThreadId,15 ThreadId,
15 type ModelSelection,16 type ModelSelection,
16 type ProviderOptionSelection,17 type ProviderOptionSelection,
...@@ -65,7 +66,9 @@ import {...@@ -65,7 +66,9 @@ import {
65 markPromotedDraftThreadByRef,66 markPromotedDraftThreadByRef,
66 markPromotedDraftThreads,67 markPromotedDraftThreads,
67 markPromotedDraftThreadsByRef,68 markPromotedDraftThreadsByRef,
69 type ComposerFileAttachment,
68 type ComposerImageAttachment,70 type ComposerImageAttachment,
71 composerFileNeedsReattach,
69 useComposerDraftStore,72 useComposerDraftStore,
70 DraftId,73 DraftId,
71} from "./composerDraftStore";74} from "./composerDraftStore";
...@@ -104,6 +107,18 @@ function makeImage(input: {...@@ -104,6 +107,18 @@ function makeImage(input: {
104 };107 };
105}108}
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
107function makeTerminalContext(input: {122function makeTerminalContext(input: {
108 id: string;123 id: string;
109 text?: string;124 text?: string;
...@@ -289,6 +304,280 @@ describe("composerDraftStore clearComposerContent", () => {...@@ -289,6 +304,280 @@ describe("composerDraftStore clearComposerContent", () => {
289 });304 });
290});305});
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
292describe("composerDraftStore moveComposerPromptAndImages", () => {581describe("composerDraftStore moveComposerPromptAndImages", () => {
293 const sourceDraftId = DraftId.make("draft-move-source");582 const sourceDraftId = DraftId.make("draft-move-source");
294 const destinationDraftId = DraftId.make("draft-move-destination");583 const destinationDraftId = DraftId.make("draft-move-destination");
...@@ -335,6 +624,111 @@ describe("composerDraftStore moveComposerPromptAndImages", () => {...@@ -335,6 +624,111 @@ describe("composerDraftStore moveComposerPromptAndImages", () => {
335 expect(draftByKey(destinationDraftId)?.prompt).toBe(" explain this error");624 expect(draftByKey(destinationDraftId)?.prompt).toBe(" explain this error");
336 });625 });
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
338 it("is a no-op when source and destination are the same target", () => {732 it("is a no-op when source and destination are the same target", () => {
339 const store = useComposerDraftStore.getState();733 const store = useComposerDraftStore.getState();
340 store.setPrompt(sourceDraftId, "keep me");734 store.setPrompt(sourceDraftId, "keep me");
...@@ -1959,6 +2353,95 @@ describe("composerDraftStore model seed migration", () => {...@@ -1959,6 +2353,95 @@ describe("composerDraftStore model seed migration", () => {
1959 vi.useRealTimers();2353 vi.useRealTimers();
1960 }2354 }
1961 });2355 });
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 });
1962});2445});
19632446
1964describe("composerDraftStore provider-scoped option updates", () => {2447describe("composerDraftStore provider-scoped option updates", () => {
apps/web/src/composerDraftStore.ts+354-19
...@@ -2,13 +2,14 @@ import {...@@ -2,13 +2,14 @@ import {
2 DEFAULT_MODEL,2 DEFAULT_MODEL,
3 DEFAULT_MODEL_BY_PROVIDER,3 DEFAULT_MODEL_BY_PROVIDER,
4 defaultInstanceIdForDriver,4 defaultInstanceIdForDriver,
5 type EnvironmentId,5 EnvironmentId,
6 ModelSelection,6 ModelSelection,
7 ProjectId,7 ProjectId,
8 ProviderInstanceId,8 ProviderInstanceId,
9 ProviderInteractionMode,9 ProviderInteractionMode,
10 ProviderDriverKind,10 ProviderDriverKind,
11 ProviderOptionSelection,11 ProviderOptionSelection,
12 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
12 PreviewAnnotationPayloadSchema,13 PreviewAnnotationPayloadSchema,
13 type PreviewAnnotationPayload,14 type PreviewAnnotationPayload,
14 RuntimeMode,15 RuntimeMode,
...@@ -33,7 +34,12 @@ import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"...@@ -33,7 +34,12 @@ import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"
33import { useMemo } from "react";34import { useMemo } from "react";
34import { getLocalStorageItem } from "./hooks/useLocalStorage";35import { getLocalStorageItem } from "./hooks/useLocalStorage";
35import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection";36import { 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";
37import {43import {
38 type TerminalContextDraft,44 type TerminalContextDraft,
39 ensureInlineTerminalContextPlaceholders,45 ensureInlineTerminalContextPlaceholders,
...@@ -93,6 +99,49 @@ export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "prev...@@ -93,6 +99,49 @@ export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "prev
93 file: File;99 file: File;
94}100}
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
96const PersistedTerminalContextDraft = Schema.Struct({145const PersistedTerminalContextDraft = Schema.Struct({
97 id: Schema.String,146 id: Schema.String,
98 threadId: ThreadId,147 threadId: ThreadId,
...@@ -129,6 +178,7 @@ type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type;...@@ -129,6 +178,7 @@ type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type;
129const PersistedComposerThreadDraftState = Schema.Struct({178const PersistedComposerThreadDraftState = Schema.Struct({
130 prompt: Schema.String,179 prompt: Schema.String,
131 attachments: Schema.Array(PersistedComposerImageAttachment),180 attachments: Schema.Array(PersistedComposerImageAttachment),
181 files: Schema.optionalKey(Schema.Array(PersistedComposerDraftFileAttachment)),
132 terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)),182 terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)),
133 elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)),183 elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)),
134 previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)),184 previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)),
...@@ -255,6 +305,7 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({...@@ -255,6 +305,7 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({
255export interface ComposerThreadDraftState {305export interface ComposerThreadDraftState {
256 prompt: string;306 prompt: string;
257 images: ComposerImageAttachment[];307 images: ComposerImageAttachment[];
308 files: ComposerFileAttachment[];
258 nonPersistedImageIds: string[];309 nonPersistedImageIds: string[];
259 persistedAttachments: PersistedComposerImageAttachment[];310 persistedAttachments: PersistedComposerImageAttachment[];
260 terminalContexts: TerminalContextDraft[];311 terminalContexts: TerminalContextDraft[];
...@@ -305,6 +356,7 @@ export function composerDraftHasUserContent(...@@ -305,6 +356,7 @@ export function composerDraftHasUserContent(
305 return (356 return (
306 draft.prompt.trim().length > 0 ||357 draft.prompt.trim().length > 0 ||
307 draft.images.length > 0 ||358 draft.images.length > 0 ||
359 draft.files.length > 0 ||
308 draft.persistedAttachments.length > 0 ||360 draft.persistedAttachments.length > 0 ||
309 draft.terminalContexts.length > 0 ||361 draft.terminalContexts.length > 0 ||
310 draft.elementContexts.length > 0 ||362 draft.elementContexts.length > 0 ||
...@@ -351,7 +403,7 @@ interface ProjectDraftSession extends DraftSessionState {...@@ -351,7 +403,7 @@ interface ProjectDraftSession extends DraftSessionState {
351 * Raw `ThreadId` is intentionally excluded so callers cannot drop environment403 * Raw `ThreadId` is intentionally excluded so callers cannot drop environment
352 * identity for real threads.404 * identity for real threads.
353 */405 */
354type ComposerThreadTarget = ScopedThreadRef | DraftId;406export type ComposerThreadTarget = ScopedThreadRef | DraftId;
355407
356/**408/**
357 * Persisted store for composer content plus draft-session metadata.409 * Persisted store for composer content plus draft-session metadata.
...@@ -484,6 +536,20 @@ interface ComposerDraftStoreState {...@@ -484,6 +536,20 @@ interface ComposerDraftStoreState {
484 addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void;536 addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void;
485 addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void;537 addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void;
486 removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void;538 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;
487 insertTerminalContext: (553 insertTerminalContext: (
488 threadRef: ComposerThreadTarget,554 threadRef: ComposerThreadTarget,
489 prompt: string,555 prompt: string,
...@@ -534,19 +600,16 @@ interface ComposerDraftStoreState {...@@ -534,19 +600,16 @@ interface ComposerDraftStoreState {
534 ) => void;600 ) => void;
535 clearComposerContent: (threadRef: ComposerThreadTarget) => void;601 clearComposerContent: (threadRef: ComposerThreadTarget) => void;
536 /**602 /**
537 * Clears only the prompt text and image attachments, preserving terminal /603 * Clears the prompt text and attachments, preserving terminal /
538 * element contexts, preview annotations, and review comments. Used by the604 * element contexts, preview annotations, and review comments. Used by the
539 * prompt stash, which can only round-trip text + images: clearing the605 * prompt stash. Session-bound context stays in the source draft.
540 * session-bound contexts would destroy state nothing can restore.
541 */606 */
542 clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void;607 clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void;
543 /**608 /**
544 * Moves the prompt text and image attachments from one composer target to609 * Moves prompt text and transferable attachments into another composer.
545 * another. Used when a draft changes project: the new project gets its own610 * Attachments over the destination limit and uploaded files that belong to
546 * draft session and the typed content follows it. Session-bound extras611 * another environment stay in the source draft. Terminal and element
547 * (terminal / element contexts, preview annotations, review comments) stay612 * context, preview annotations, and review comments also stay in the source.
548 * on the source — they reference sessions of the source thread that the
549 * destination cannot use.
550 */613 */
551 moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void;614 moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void;
552}615}
...@@ -615,6 +678,7 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftSt...@@ -615,6 +678,7 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftSt
615});678});
616679
617const EMPTY_IMAGES: ComposerImageAttachment[] = [];680const EMPTY_IMAGES: ComposerImageAttachment[] = [];
681const EMPTY_FILES: ComposerFileAttachment[] = [];
618const EMPTY_IDS: string[] = [];682const EMPTY_IDS: string[] = [];
619const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];683const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];
620const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = [];684const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = [];
...@@ -622,6 +686,7 @@ const EMPTY_ELEMENT_CONTEXTS: ElementContextDraft[] = [];...@@ -622,6 +686,7 @@ const EMPTY_ELEMENT_CONTEXTS: ElementContextDraft[] = [];
622const EMPTY_PREVIEW_ANNOTATIONS: PreviewAnnotationPayload[] = [];686const EMPTY_PREVIEW_ANNOTATIONS: PreviewAnnotationPayload[] = [];
623const EMPTY_REVIEW_COMMENTS: ReviewCommentContext[] = [];687const EMPTY_REVIEW_COMMENTS: ReviewCommentContext[] = [];
624Object.freeze(EMPTY_IMAGES);688Object.freeze(EMPTY_IMAGES);
689Object.freeze(EMPTY_FILES);
625Object.freeze(EMPTY_IDS);690Object.freeze(EMPTY_IDS);
626Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);691Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
627Object.freeze(EMPTY_ELEMENT_CONTEXTS);692Object.freeze(EMPTY_ELEMENT_CONTEXTS);
...@@ -637,6 +702,7 @@ const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>(...@@ -637,6 +702,7 @@ const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>(
637const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({702const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({
638 prompt: "",703 prompt: "",
639 images: EMPTY_IMAGES,704 images: EMPTY_IMAGES,
705 files: EMPTY_FILES,
640 nonPersistedImageIds: EMPTY_IDS,706 nonPersistedImageIds: EMPTY_IDS,
641 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,707 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
642 terminalContexts: EMPTY_TERMINAL_CONTEXTS,708 terminalContexts: EMPTY_TERMINAL_CONTEXTS,
...@@ -659,6 +725,7 @@ export function createEmptyThreadDraft(): ComposerThreadDraftState {...@@ -659,6 +725,7 @@ export function createEmptyThreadDraft(): ComposerThreadDraftState {
659 return {725 return {
660 prompt: "",726 prompt: "",
661 images: [],727 images: [],
728 files: [],
662 nonPersistedImageIds: [],729 nonPersistedImageIds: [],
663 persistedAttachments: [],730 persistedAttachments: [],
664 terminalContexts: [],731 terminalContexts: [],
...@@ -733,6 +800,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {...@@ -733,6 +800,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
733 return (800 return (
734 draft.prompt.length === 0 &&801 draft.prompt.length === 0 &&
735 draft.images.length === 0 &&802 draft.images.length === 0 &&
803 draft.files.length === 0 &&
736 draft.persistedAttachments.length === 0 &&804 draft.persistedAttachments.length === 0 &&
737 draft.terminalContexts.length === 0 &&805 draft.terminalContexts.length === 0 &&
738 draft.elementContexts.length === 0 &&806 draft.elementContexts.length === 0 &&
...@@ -1263,7 +1331,7 @@ function logicalProjectDraftKey(logicalProjectKey: string): string {...@@ -1263,7 +1331,7 @@ function logicalProjectDraftKey(logicalProjectKey: string): string {
1263 * Draft sessions are keyed by `DraftId`. Real threads are keyed by1331 * Draft sessions are keyed by `DraftId`. Real threads are keyed by
1264 * `ScopedThreadRef` so environment identity is always preserved.1332 * `ScopedThreadRef` so environment identity is always preserved.
1265 */1333 */
1266function composerTargetKey(target: ScopedThreadRef | DraftId): string {1334export function composerTargetKey(target: ScopedThreadRef | DraftId): string {
1267 if (typeof target === "string") {1335 if (typeof target === "string") {
1268 return target.trim();1336 return target.trim();
1269 }1337 }
...@@ -1717,6 +1785,9 @@ function normalizePersistedDraftsByThreadId(...@@ -1717,6 +1785,9 @@ function normalizePersistedDraftsByThreadId(
1717 return normalized ? [normalized] : [];1785 return normalized ? [normalized] : [];
1718 })1786 })
1719 : [];1787 : [];
1788 const files = Array.isArray(draftCandidate.files)
1789 ? draftCandidate.files.filter(isPersistedComposerDraftFileAttachment)
1790 : [];
1720 const terminalContexts = Array.isArray(draftCandidate.terminalContexts)1791 const terminalContexts = Array.isArray(draftCandidate.terminalContexts)
1721 ? draftCandidate.terminalContexts.flatMap((entry) => {1792 ? draftCandidate.terminalContexts.flatMap((entry) => {
1722 const normalized = normalizePersistedTerminalContextDraft(entry);1793 const normalized = normalizePersistedTerminalContextDraft(entry);
...@@ -1796,6 +1867,7 @@ function normalizePersistedDraftsByThreadId(...@@ -1796,6 +1867,7 @@ function normalizePersistedDraftsByThreadId(
1796 if (1867 if (
1797 promptCandidate.length === 0 &&1868 promptCandidate.length === 0 &&
1798 attachments.length === 0 &&1869 attachments.length === 0 &&
1870 files.length === 0 &&
1799 terminalContexts.length === 0 &&1871 terminalContexts.length === 0 &&
1800 elementContexts.length === 0 &&1872 elementContexts.length === 0 &&
1801 reviewComments.length === 0 &&1873 reviewComments.length === 0 &&
...@@ -1820,6 +1892,7 @@ function normalizePersistedDraftsByThreadId(...@@ -1820,6 +1892,7 @@ function normalizePersistedDraftsByThreadId(
1820 nextDraftsByThreadKey[normalizedThreadKey] = {1892 nextDraftsByThreadKey[normalizedThreadKey] = {
1821 prompt,1893 prompt,
1822 attachments,1894 attachments,
1895 ...(files.length > 0 ? { files } : {}),
1823 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),1896 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),
1824 ...(elementContexts.length > 0 ? { elementContexts } : {}),1897 ...(elementContexts.length > 0 ? { elementContexts } : {}),
1825 ...(reviewComments.length > 0 ? { reviewComments } : {}),1898 ...(reviewComments.length > 0 ? { reviewComments } : {}),
...@@ -1842,6 +1915,7 @@ function persistedComposerDraftHasUserContent(draft: PersistedComposerThreadDraf...@@ -1842,6 +1915,7 @@ function persistedComposerDraftHasUserContent(draft: PersistedComposerThreadDraf
1842 return (1915 return (
1843 draft.prompt.trim().length > 0 ||1916 draft.prompt.trim().length > 0 ||
1844 draft.attachments.length > 0 ||1917 draft.attachments.length > 0 ||
1918 (draft.files?.length ?? 0) > 0 ||
1845 (draft.terminalContexts?.length ?? 0) > 0 ||1919 (draft.terminalContexts?.length ?? 0) > 0 ||
1846 (draft.elementContexts?.length ?? 0) > 0 ||1920 (draft.elementContexts?.length ?? 0) > 0 ||
1847 (draft.previewAnnotations?.length ?? 0) > 0 ||1921 (draft.previewAnnotations?.length ?? 0) > 0 ||
...@@ -1925,6 +1999,7 @@ function partializeComposerDraftStoreState(...@@ -1925,6 +1999,7 @@ function partializeComposerDraftStoreState(
1925 if (1999 if (
1926 draft.prompt.length === 0 &&2000 draft.prompt.length === 0 &&
1927 draft.persistedAttachments.length === 0 &&2001 draft.persistedAttachments.length === 0 &&
2002 draft.files.length === 0 &&
1928 draft.terminalContexts.length === 0 &&2003 draft.terminalContexts.length === 0 &&
1929 draft.elementContexts.length === 0 &&2004 draft.elementContexts.length === 0 &&
1930 draft.previewAnnotations.length === 0 &&2005 draft.previewAnnotations.length === 0 &&
...@@ -1938,6 +2013,25 @@ function partializeComposerDraftStoreState(...@@ -1938,6 +2013,25 @@ function partializeComposerDraftStoreState(
1938 const persistedDraft: DeepMutable<PersistedComposerThreadDraftState> = {2013 const persistedDraft: DeepMutable<PersistedComposerThreadDraftState> = {
1939 prompt: draft.prompt,2014 prompt: draft.prompt,
1940 attachments: draft.persistedAttachments,2015 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 : {}),
1941 ...(draft.terminalContexts.length > 02035 ...(draft.terminalContexts.length > 0
1942 ? {2036 ? {
1943 terminalContexts: draft.terminalContexts.map((context) => ({2037 terminalContexts: draft.terminalContexts.map((context) => ({
...@@ -2219,6 +2313,21 @@ function toHydratedThreadDraft(...@@ -2219,6 +2313,21 @@ function toHydratedThreadDraft(
2219 return {2313 return {
2220 prompt: persistedDraft.prompt,2314 prompt: persistedDraft.prompt,
2221 images: hydrateImagesFromPersisted(persistedDraft.attachments),2315 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 })) ?? [],
2222 nonPersistedImageIds: [],2331 nonPersistedImageIds: [],
2223 persistedAttachments: [...persistedDraft.attachments],2332 persistedAttachments: [...persistedDraft.attachments],
2224 terminalContexts:2333 terminalContexts:
...@@ -3008,6 +3117,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3008,6 +3117,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
3008 }3117 }
3009 continue;3118 continue;
3010 }3119 }
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 }
3011 dedupedIncoming.push(image);3129 dedupedIncoming.push(image);
3012 existingIds.add(image.id);3130 existingIds.add(image.id);
3013 existingDedupKeys.add(dedupKey);3131 existingDedupKeys.add(dedupKey);
...@@ -3062,6 +3180,160 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3062,6 +3180,160 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
3062 return { draftsByThreadKey: nextDraftsByThreadKey };3180 return { draftsByThreadKey: nextDraftsByThreadKey };
3063 });3181 });
3064 },3182 },
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 },
3065 insertTerminalContext: (threadRef, prompt, context, index) => {3337 insertTerminalContext: (threadRef, prompt, context, index) => {
3066 const threadKey = resolveComposerDraftKey(get(), threadRef);3338 const threadKey = resolveComposerDraftKey(get(), threadRef);
3067 const threadId = resolveComposerThreadId(get(), threadRef);3339 const threadId = resolveComposerThreadId(get(), threadRef);
...@@ -3462,6 +3734,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3462,6 +3734,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
3462 ...current,3734 ...current,
3463 prompt: "",3735 prompt: "",
3464 images: [],3736 images: [],
3737 files: [],
3465 nonPersistedImageIds: [],3738 nonPersistedImageIds: [],
3466 persistedAttachments: [],3739 persistedAttachments: [],
3467 terminalContexts: [],3740 terminalContexts: [],
...@@ -3495,6 +3768,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3495,6 +3768,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
3495 ...current,3768 ...current,
3496 prompt: ensureInlineTerminalContextPlaceholders("", current.terminalContexts.length),3769 prompt: ensureInlineTerminalContextPlaceholders("", current.terminalContexts.length),
3497 images: [],3770 images: [],
3771 files: [],
3498 nonPersistedImageIds: [],3772 nonPersistedImageIds: [],
3499 persistedAttachments: [],3773 persistedAttachments: [],
3500 };3774 };
...@@ -3519,6 +3793,59 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3519,6 +3793,59 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
3519 return state;3793 return state;
3520 }3794 }
3521 const destination = state.draftsByThreadKey[toKey] ?? createEmptyThreadDraft();3795 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));
3522 // Inline placeholders reference the source's terminal contexts,3849 // Inline placeholders reference the source's terminal contexts,
3523 // which stay behind; re-anchor the moved prompt to whatever3850 // which stay behind; re-anchor the moved prompt to whatever
3524 // contexts the destination already holds.3851 // contexts the destination already holds.
...@@ -3529,14 +3856,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3529,14 +3856,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
3529 const nextDestination: ComposerThreadDraftState = {3856 const nextDestination: ComposerThreadDraftState = {
3530 ...destination,3857 ...destination,
3531 prompt: movedPrompt,3858 prompt: movedPrompt,
3532 images: [...destination.images, ...source.images],3859 images: [...destination.images, ...movedImages],
3860 files: [...destination.files, ...movedFiles],
3533 nonPersistedImageIds: [3861 nonPersistedImageIds: [
3534 ...destination.nonPersistedImageIds,3862 ...destination.nonPersistedImageIds,
3535 ...source.nonPersistedImageIds,3863 ...source.nonPersistedImageIds.filter((imageId) => movedImageIds.has(imageId)),
3536 ],3864 ],
3537 persistedAttachments: [3865 persistedAttachments: [
3538 ...destination.persistedAttachments,3866 ...destination.persistedAttachments,
3539 ...source.persistedAttachments,3867 ...source.persistedAttachments.filter((attachment) =>
3868 movedImageIds.has(attachment.id),
3869 ),
3540 ],3870 ],
3541 };3871 };
3542 // Same clearing shape as clearComposerPromptAndImages, but the3872 // Same clearing shape as clearComposerPromptAndImages, but the
...@@ -3545,9 +3875,14 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3545,9 +3875,14 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
3545 const nextSource: ComposerThreadDraftState = {3875 const nextSource: ComposerThreadDraftState = {
3546 ...source,3876 ...source,
3547 prompt: ensureInlineTerminalContextPlaceholders("", source.terminalContexts.length),3877 prompt: ensureInlineTerminalContextPlaceholders("", source.terminalContexts.length),
3548 images: [],3878 images: retainedImages,
3549 nonPersistedImageIds: [],3879 files: retainedFiles,
3550 persistedAttachments: [],3880 nonPersistedImageIds: source.nonPersistedImageIds.filter(
3881 (imageId) => !movedImageIds.has(imageId),
3882 ),
3883 persistedAttachments: source.persistedAttachments.filter(
3884 (attachment) => !movedImageIds.has(attachment.id),
3885 ),
3551 };3886 };
3552 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };3887 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3553 if (shouldRemoveDraft(nextSource)) {3888 if (shouldRemoveDraft(nextSource)) {
apps/web/src/hooks/useHandleNewThread.ts+14-1
...@@ -33,6 +33,7 @@ import { primaryServerSettingsAtom } from "../state/server";...@@ -33,6 +33,7 @@ import { primaryServerSettingsAtom } from "../state/server";
33import { resolveThreadRouteTarget } from "../threadRoutes";33import { resolveThreadRouteTarget } from "../threadRoutes";
34import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";34import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
35import { useClientSettings } from "./useSettings";35import { useClientSettings } from "./useSettings";
36import { toastManager } from "../components/ui/toast";
3637
37interface NewThreadWorkspaceOptions {38interface NewThreadWorkspaceOptions {
38 branch?: string | null;39 branch?: string | null;
...@@ -78,7 +79,7 @@ export function useNewThreadHandler() {...@@ -78,7 +79,7 @@ export function useNewThreadHandler() {
78 startFromOrigin?: boolean;79 startFromOrigin?: boolean;
79 replace?: boolean;80 replace?: boolean;
80 /**81 /**
81 * Move the viewed draft's typed content (prompt + images) into the82 * Move the viewed draft's typed content and transferable attachments into the
82 * draft this request lands on. Set by the draft repo picker: the83 * draft this request lands on. Set by the draft repo picker: the
83 * user started writing in the wrong project and the text should84 * user started writing in the wrong project and the text should
84 * follow them. Explicit new-thread surfaces leave this unset and85 * follow them. Explicit new-thread surfaces leave this unset and
...@@ -156,6 +157,18 @@ export function useNewThreadHandler() {...@@ -156,6 +157,18 @@ export function useNewThreadHandler() {
156 composerDraftHasUserContent(getComposerDraft(carryContentSourceDraftId))157 composerDraftHasUserContent(getComposerDraft(carryContentSourceDraftId))
157 ) {158 ) {
158 moveComposerPromptAndImages(carryContentSourceDraftId, destinationDraftId);159 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 }
159 }172 }
160 };173 };
161 const project = projects.find(174 const project = projects.find(
apps/web/src/lib/attachmentUploadQueue.test.ts+660-4
...@@ -1,21 +1,36 @@...@@ -1,21 +1,36 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";3import { 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
6const mocks = vi.hoisted(() => ({13const mocks = vi.hoisted(() => ({
14 createAssetUrl: vi.fn(),
7 createUploadUrl: Symbol("create-upload-url"),15 createUploadUrl: Symbol("create-upload-url"),
16 executeAtomQuery: vi.fn(),
8 removeUpload: Symbol("remove-upload"),17 removeUpload: Symbol("remove-upload"),
9 runAtomCommand: vi.fn(),18 runAtomCommand: vi.fn(),
10 readPreparedConnection: vi.fn(),19 readPreparedConnection: vi.fn(),
11}));20}));
1221
13vi.mock("@t3tools/client-runtime/state/runtime", () => ({22vi.mock("@t3tools/client-runtime/state/runtime", () => ({
23 executeAtomQuery: mocks.executeAtomQuery,
14 runAtomCommand: mocks.runAtomCommand,24 runAtomCommand: mocks.runAtomCommand,
25 squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error,
15}));26}));
1627
17vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} }));28vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} }));
1829
30vi.mock("../state/assets", () => ({
31 assetEnvironment: { createUrl: mocks.createAssetUrl },
32}));
33
19vi.mock("../state/attachments", () => ({34vi.mock("../state/attachments", () => ({
20 attachmentEnvironment: {35 attachmentEnvironment: {
21 createUploadUrl: mocks.createUploadUrl,36 createUploadUrl: mocks.createUploadUrl,
...@@ -32,7 +47,9 @@ import {...@@ -32,7 +47,9 @@ import {
32 getUploadedAttachments,47 getUploadedAttachments,
33 readAttachmentUpload,48 readAttachmentUpload,
34 releaseAttachmentUpload,49 releaseAttachmentUpload,
35 releaseAttachmentUploads,50 releaseDraftAttachment,
51 releaseDraftAttachments,
52 releasePersistedAttachmentUpload,
36 retryAttachmentUpload,53 retryAttachmentUpload,
37 startAttachmentUpload,54 startAttachmentUpload,
38 useAttachmentUploadStore,55 useAttachmentUploadStore,
...@@ -110,9 +127,27 @@ function makeImage(id: string): ComposerImageAttachment {...@@ -110,9 +127,27 @@ function makeImage(id: string): ComposerImageAttachment {
110 };127 };
111}128}
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
113describe("attachmentUploadQueue", () => {144describe("attachmentUploadQueue", () => {
114 beforeEach(() => {145 beforeEach(() => {
115 TestXmlHttpRequest.requests = [];146 TestXmlHttpRequest.requests = [];
147 mocks.createAssetUrl.mockReset();
148 mocks.createAssetUrl.mockImplementation((target: unknown) => target);
149 mocks.executeAtomQuery.mockReset();
150 mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} });
116 mocks.runAtomCommand.mockReset();151 mocks.runAtomCommand.mockReset();
117 mocks.readPreparedConnection.mockReset();152 mocks.readPreparedConnection.mockReset();
118 mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" });153 mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" });
...@@ -176,7 +211,7 @@ describe("attachmentUploadQueue", () => {...@@ -176,7 +211,7 @@ describe("attachmentUploadQueue", () => {
176 },211 },
177 ]);212 ]);
178213
179 releaseAttachmentUploads([image]);214 releaseDraftAttachments([image]);
180 expect(readAttachmentUpload(image.id)).toBeUndefined();215 expect(readAttachmentUpload(image.id)).toBeUndefined();
181 expect(mocks.runAtomCommand).toHaveBeenCalledWith(216 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
182 expect.anything(),217 expect.anything(),
...@@ -189,6 +224,543 @@ describe("attachmentUploadQueue", () => {...@@ -189,6 +224,543 @@ describe("attachmentUploadQueue", () => {
189 );224 );
190 });225 });
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
192 it("retries rejected uploads", async () => {764 it("retries rejected uploads", async () => {
193 const image = makeImage("image-retry");765 const image = makeImage("image-retry");
194 startAttachmentUpload({ environmentId: firstEnvironment, image });766 startAttachmentUpload({ environmentId: firstEnvironment, image });
...@@ -277,6 +849,90 @@ describe("attachmentUploadQueue", () => {...@@ -277,6 +849,90 @@ describe("attachmentUploadQueue", () => {
277 });849 });
278 });850 });
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
280 it("does not let stalled uploads block another environment", async () => {936 it("does not let stalled uploads block another environment", async () => {
281 const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage);937 const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage);
282 for (const image of images) {938 for (const image of images) {
apps/web/src/lib/attachmentUploadQueue.ts+309-89
...@@ -3,12 +3,25 @@ import {...@@ -3,12 +3,25 @@ import {
3 type ChatAttachment,3 type ChatAttachment,
4 type EnvironmentId,4 type EnvironmentId,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
6import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";7import { 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";
8import { create } from "zustand";14import { 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";
11import { appAtomRegistry } from "../rpc/atomRegistry";23import { appAtomRegistry } from "../rpc/atomRegistry";
24import { assetEnvironment } from "../state/assets";
12import { attachmentEnvironment } from "../state/attachments";25import { attachmentEnvironment } from "../state/attachments";
13import { readPreparedConnection } from "../state/session";26import { readPreparedConnection } from "../state/session";
14import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState";27import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState";
...@@ -25,11 +38,23 @@ export const useAttachmentUploadStore = create<AttachmentUploadStore>(() => ({...@@ -25,11 +38,23 @@ export const useAttachmentUploadStore = create<AttachmentUploadStore>(() => ({
25}));38}));
2639
27interface UploadJob {40interface UploadJob {
28 readonly image: ComposerImageAttachment;41 readonly image: ComposerImageAttachment | ComposerFileAttachment;
29 readonly environmentId: EnvironmentId;42 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;
30 readonly previous?: ReadyAttachmentUpload;48 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;
31 readonly settled: Promise<void>;55 readonly settled: Promise<void>;
32 resolveSettled: () => void;56 resolveSettled: () => void;
57 /** Only ids this queue minted itself. Cancel and retry may delete these. */
33 attachmentId: string | null;58 attachmentId: string | null;
34 cancelled: boolean;59 cancelled: boolean;
35 abort: (() => void) | null;60 abort: (() => void) | null;
...@@ -60,25 +85,73 @@ export function readAttachmentUpload(imageId: string): AttachmentUploadState | u...@@ -60,25 +85,73 @@ export function readAttachmentUpload(imageId: string): AttachmentUploadState | u
60 return useAttachmentUploadStore.getState().uploadsByImageId[imageId];85 return useAttachmentUploadStore.getState().uploadsByImageId[imageId];
61}86}
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
63function deletePendingUpload(environmentId: EnvironmentId, attachmentId: string): void {135function deletePendingUpload(environmentId: EnvironmentId, attachmentId: string): void {
64 void runAtomCommand(136 deletePendingAttachmentUpload({
65 appAtomRegistry,137 registry: appAtomRegistry,
66 attachmentEnvironment.remove,138 remove: attachmentEnvironment.remove,
67 { environmentId, input: { attachmentId } },139 environmentId,
68 { reportFailure: false, reportDefect: false },140 attachmentId,
69 );141 });
70}142}
71143
72function uploadBytes(input: {144function uploadBytes(input: {
73 readonly url: string;145 readonly url: string;
74 readonly file: File;146 readonly file: File;
147 readonly mimeType: string;
75 readonly onProgress: (progress: number) => void;148 readonly onProgress: (progress: number) => void;
76}): { readonly done: Promise<void>; readonly abort: () => void } {149}): { readonly done: Promise<void>; readonly abort: () => void } {
77 const xhr = new XMLHttpRequest();150 const xhr = new XMLHttpRequest();
78 const done = new Promise<void>((resolve, reject) => {151 const done = new Promise<void>((resolve, reject) => {
79 xhr.open("POST", input.url, true);152 xhr.open("POST", input.url, true);
80 xhr.timeout = UPLOAD_TIMEOUT_MS;153 xhr.timeout = UPLOAD_TIMEOUT_MS;
81 xhr.setRequestHeader("Content-Type", input.file.type);154 xhr.setRequestHeader("Content-Type", input.mimeType);
82 xhr.upload.addEventListener("progress", (event) => {155 xhr.upload.addEventListener("progress", (event) => {
83 if (event.lengthComputable && event.total > 0) {156 if (event.lengthComputable && event.total > 0) {
84 input.onProgress(event.loaded / event.total);157 input.onProgress(event.loaded / event.total);
...@@ -101,109 +174,162 @@ function uploadBytes(input: {...@@ -101,109 +174,162 @@ function uploadBytes(input: {
101}174}
102175
103async function runUpload(job: UploadJob): Promise<void> {176async function runUpload(job: UploadJob): Promise<void> {
104 const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find(177 if (job.persistedAttachmentId) {
105 (supportedMimeType) => supportedMimeType === job.image.mimeType.toLowerCase(),178 const verification = await verifyPersistedAttachmentUpload({
106 );179 registry: appAtomRegistry,
107 if (!mimeType) {180 createAssetUrl: assetEnvironment.createUrl,
108 setUploadState(job.image.id, {
109 status: "failed",
110 environmentId: job.environmentId,181 environmentId: job.environmentId,
111 reason: "Unsupported image type",182 attachmentId: job.persistedAttachmentId,
112 ...(job.previous ? { previous: job.previous } : {}),
113 });183 });
114 return;184 if (job.cancelled) {
115 }185 return;
116186 }
117 const minted = await runAtomCommand(187 if (verification.status === "verified") {
118 appAtomRegistry,188 setUploadState(job.image.id, {
119 attachmentEnvironment.createUploadUrl,189 status: "ready",
120 {190 environmentId: job.environmentId,
121 environmentId: job.environmentId,191 attachmentId: job.persistedAttachmentId,
122 input: {192 });
123 name: job.image.name,193 stampDraftFileUpload(job, job.persistedAttachmentId);
124 mimeType,194 return;
125 sizeBytes: job.image.file.size,195 }
126 },196 if (verification.status === "missing" && !job.image.file && job.image.type === "file") {
127 },197 const draftTarget = resolveCurrentFileDraftTarget(job);
128 { reportFailure: false },198 if (
129 );199 draftTarget !== undefined &&
130 if (job.cancelled) {200 useComposerDraftStore
131 if (minted._tag === "Success") {201 .getState()
132 deletePendingUpload(job.environmentId, minted.value.attachmentId);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;
133 }228 }
134 return;
135 }229 }
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) {
137 setUploadState(job.image.id, {238 setUploadState(job.image.id, {
138 status: "failed",239 status: "failed",
139 environmentId: job.environmentId,240 environmentId: job.environmentId,
140 reason: "Upload could not start",241 reason: "Unsupported image type",
141 ...(job.previous ? { previous: job.previous } : {}),242 ...(job.previous ? { previous: job.previous } : {}),
142 });243 });
143 return;244 return;
144 }245 }
145 job.attachmentId = minted.value.attachmentId;246 const file = job.image.file;
146247 if (!file) {
147 const connection = readPreparedConnection(job.environmentId);
148 const url = connection ? resolveAssetUrl(connection.httpBaseUrl, minted.value.relativeUrl) : null;
149 if (!url) {
150 setUploadState(job.image.id, {248 setUploadState(job.image.id, {
151 status: "failed",249 status: "failed",
152 environmentId: job.environmentId,250 environmentId: job.environmentId,
153 reason: "Not connected",251 reason: "Original file is no longer available",
154 attachmentId: minted.value.attachmentId,
155 ...(job.previous ? { previous: job.previous } : {}),252 ...(job.previous ? { previous: job.previous } : {}),
156 });253 });
157 return;254 return;
158 }255 }
159256
160 let lastStep = -1;257 let lastStep = -1;
161 const upload = uploadBytes({258 const result = await runAttachmentUploadCycle({
162 url,259 registry: appAtomRegistry,
163 file: job.image.file,260 createUploadUrl: attachmentEnvironment.createUploadUrl,
164 onProgress: (progress) => {261 remove: attachmentEnvironment.remove,
165 const step = Math.floor(progress * 20);262 environmentId: job.environmentId,
166 if (step === lastStep || job.cancelled) {263 upload: {
167 return;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";
168 }295 }
169 lastStep = step;296 job.attachmentId = attachmentId;
170 setUploadState(job.image.id, {297 return "continue";
171 status: "uploading",298 },
172 environmentId: job.environmentId,299 onTransferStart: (abort) => {
173 progress,300 job.abort = abort;
174 ...(job.previous ? { previous: job.previous } : {}),
175 });
176 },301 },
177 });302 });
178 job.abort = upload.abort;303 job.abort = null;
179304 if (result.status === "cancelled" || job.cancelled) {
180 try {305 return;
181 await upload.done;306 }
182 if (job.cancelled) {307 if (result.status === "uploaded") {
183 return;
184 }
185 setUploadState(job.image.id, {308 setUploadState(job.image.id, {
186 status: "ready",309 status: "ready",
187 environmentId: job.environmentId,310 environmentId: job.environmentId,
188 attachmentId: minted.value.attachmentId,311 attachmentId: result.attachmentId,
189 });312 });
313 stampDraftFileUpload(job, result.attachmentId);
190 if (job.previous) {314 if (job.previous) {
191 deletePendingUpload(job.previous.environmentId, job.previous.attachmentId);315 deletePendingUpload(job.previous.environmentId, job.previous.attachmentId);
192 }316 }
193 } catch (error) {317 return;
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;
206 }318 }
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 });
207}333}
208334
209function pumpUploads(): void {335function pumpUploads(): void {
...@@ -249,7 +375,9 @@ function pumpUploads(): void {...@@ -249,7 +375,9 @@ function pumpUploads(): void {
249375
250export function startAttachmentUpload(input: {376export function startAttachmentUpload(input: {
251 readonly environmentId: EnvironmentId;377 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;
253}): void {381}): void {
254 const existingJob = jobsByImageId.get(input.image.id);382 const existingJob = jobsByImageId.get(input.image.id);
255 if (existingJob?.environmentId === input.environmentId) {383 if (existingJob?.environmentId === input.environmentId) {
...@@ -287,7 +415,13 @@ export function startAttachmentUpload(input: {...@@ -287,7 +415,13 @@ export function startAttachmentUpload(input: {
287 const job: UploadJob = {415 const job: UploadJob = {
288 image: input.image,416 image: input.image,
289 environmentId: input.environmentId,417 environmentId: input.environmentId,
418 ...(input.draftTarget !== undefined ? { draftTarget: input.draftTarget } : {}),
290 ...(previous ? { previous } : {}),419 ...(previous ? { previous } : {}),
420 ...(input.image.type === "file" &&
421 input.image.uploadEnvironmentId === input.environmentId &&
422 input.image.uploadedAttachmentId
423 ? { persistedAttachmentId: input.image.uploadedAttachmentId }
424 : {}),
291 settled,425 settled,
292 resolveSettled,426 resolveSettled,
293 attachmentId: null,427 attachmentId: null,
...@@ -306,6 +440,11 @@ export function startAttachmentUpload(input: {...@@ -306,6 +440,11 @@ export function startAttachmentUpload(input: {
306 pumpUploads();440 pumpUploads();
307}441}
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 */
309export function cancelAttachmentUpload(imageId: string): void {448export function cancelAttachmentUpload(imageId: string): void {
310 const job = jobsByImageId.get(imageId);449 const job = jobsByImageId.get(imageId);
311 if (!job) {450 if (!job) {
...@@ -340,12 +479,42 @@ export function releaseAttachmentUpload(imageId: string): void {...@@ -340,12 +479,42 @@ export function releaseAttachmentUpload(imageId: string): void {
340 clearUploadState(imageId);479 clearUploadState(imageId);
341}480}
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
343export function retryAttachmentUpload(input: {508export function retryAttachmentUpload(input: {
344 readonly environmentId: EnvironmentId;509 readonly environmentId: EnvironmentId;
345 readonly image: ComposerImageAttachment;510 readonly image: ComposerImageAttachment | ComposerFileAttachment;
511 readonly draftTarget?: ComposerThreadTarget;
346}): void {512}): void {
347 const previous = readAttachmentUpload(input.image.id);513 const previous = readAttachmentUpload(input.image.id);
348 cancelAttachmentUpload(input.image.id);514 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.
349 if (previous?.status === "failed" && previous.attachmentId) {518 if (previous?.status === "failed" && previous.attachmentId) {
350 deletePendingUpload(previous.environmentId, previous.attachmentId);519 deletePendingUpload(previous.environmentId, previous.attachmentId);
351 }520 }
...@@ -357,13 +526,30 @@ export function retryAttachmentUpload(input: {...@@ -357,13 +526,30 @@ export function retryAttachmentUpload(input: {
357 startAttachmentUpload(input);526 startAttachmentUpload(input);
358}527}
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
360export async function awaitAttachmentUploads(imageIds: ReadonlyArray<string>): Promise<void> {546export async function awaitAttachmentUploads(imageIds: ReadonlyArray<string>): Promise<void> {
361 await Promise.all(imageIds.map((imageId) => jobsByImageId.get(imageId)?.settled));547 await Promise.all(imageIds.map((imageId) => jobsByImageId.get(imageId)?.settled));
362}548}
363549
364export function getUploadedAttachments(input: {550export function getUploadedAttachments(input: {
365 readonly environmentId: EnvironmentId;551 readonly environmentId: EnvironmentId;
366 readonly images: ReadonlyArray<ComposerImageAttachment>;552 readonly images: ReadonlyArray<ComposerImageAttachment | ComposerFileAttachment>;
367}): ChatAttachment[] | null {553}): ChatAttachment[] | null {
368 const attachments: ChatAttachment[] = [];554 const attachments: ChatAttachment[] = [];
369 for (const image of input.images) {555 for (const image of input.images) {
...@@ -372,7 +558,7 @@ export function getUploadedAttachments(input: {...@@ -372,7 +558,7 @@ export function getUploadedAttachments(input: {
372 return null;558 return null;
373 }559 }
374 attachments.push({560 attachments.push({
375 type: "image",561 type: image.type,
376 id: upload.attachmentId,562 id: upload.attachmentId,
377 name: image.name,563 name: image.name,
378 mimeType: image.mimeType,564 mimeType: image.mimeType,
...@@ -382,8 +568,42 @@ export function getUploadedAttachments(input: {...@@ -382,8 +568,42 @@ export function getUploadedAttachments(input: {
382 return attachments;568 return attachments;
383}569}
384570
385export function releaseAttachmentUploads(images: ReadonlyArray<ComposerImageAttachment>): void {571/**
386 for (const image of images) {572 * The one owner for discarding a draft attachment's server-side upload. The
387 releaseAttachmentUpload(image.id);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);
388 }608 }
389}609}
apps/web/src/lib/attachmentUploadState.test.ts+3-3
...@@ -34,7 +34,7 @@ describe("attachmentUploadBlockReason", () => {...@@ -34,7 +34,7 @@ describe("attachmentUploadBlockReason", () => {
34 "image-1": { status: "uploading", environmentId, progress: 0.5 },34 "image-1": { status: "uploading", environmentId, progress: 0.5 },
35 },35 },
36 }),36 }),
37 ).toBe("Images still uploading");37 ).toBe("Attachments still uploading");
38 });38 });
3939
40 it("asks the user to retry or remove failed uploads", () => {40 it("asks the user to retry or remove failed uploads", () => {
...@@ -46,7 +46,7 @@ describe("attachmentUploadBlockReason", () => {...@@ -46,7 +46,7 @@ describe("attachmentUploadBlockReason", () => {
46 "image-1": { status: "failed", environmentId, reason: "Upload failed" },46 "image-1": { status: "failed", environmentId, reason: "Upload failed" },
47 },47 },
48 }),48 }),
49 ).toBe("Retry or remove the failed image");49 ).toBe("Retry or remove the failed attachment");
50 });50 });
5151
52 it("does not accept an upload from another environment", () => {52 it("does not accept an upload from another environment", () => {
...@@ -62,7 +62,7 @@ describe("attachmentUploadBlockReason", () => {...@@ -62,7 +62,7 @@ describe("attachmentUploadBlockReason", () => {
62 },62 },
63 },63 },
64 }),64 }),
65 ).toBe("Image still uploading");65 ).toBe("Attachment still uploading");
66 });66 });
67});67});
6868
apps/web/src/lib/attachmentUploadState.ts+9-2
...@@ -18,6 +18,11 @@ export type AttachmentUploadState =...@@ -18,6 +18,11 @@ export type AttachmentUploadState =
18 readonly status: "failed";18 readonly status: "failed";
19 readonly environmentId: EnvironmentId;19 readonly environmentId: EnvironmentId;
20 readonly reason: string;20 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 */
21 readonly attachmentId?: string;26 readonly attachmentId?: string;
22 readonly previous?: ReadyAttachmentUpload;27 readonly previous?: ReadyAttachmentUpload;
23 };28 };
...@@ -40,10 +45,12 @@ export function attachmentUploadBlockReason(input: {...@@ -40,10 +45,12 @@ export function attachmentUploadBlockReason(input: {
40 }45 }
4146
42 if (failed > 0) {47 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";
44 }51 }
45 if (pending > 0) {52 if (pending > 0) {
46 return pending === 1 ? "Image still uploading" : "Images still uploading";53 return pending === 1 ? "Attachment still uploading" : "Attachments still uploading";
47 }54 }
48 return null;55 return null;
49}56}
apps/web/src/lib/composerDraftUploads.ts+21-4
...@@ -1,23 +1,40 @@...@@ -1,23 +1,40 @@
1import type { ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts";1import type { ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts";
2import { scopedThreadKey } from "@t3tools/client-runtime/environment";
23
3import { type DraftId, useComposerDraftStore } from "../composerDraftStore";4import { type DraftId, useComposerDraftStore } from "../composerDraftStore";
4import { releaseAttachmentUploads } from "./attachmentUploadQueue";5import { releaseDraftAttachments } from "./attachmentUploadQueue";
56
6export function releaseComposerDraftUploads(target: ScopedThreadRef | DraftId): void {7export function releaseComposerDraftUploads(target: ScopedThreadRef | DraftId): void {
7 const draft = useComposerDraftStore.getState().getComposerDraft(target);8 const draft = useComposerDraftStore.getState().getComposerDraft(target);
8 if (draft) {9 if (draft) {
9 releaseAttachmentUploads(draft.images);10 releaseDraftAttachments([...draft.images, ...draft.files]);
10 }11 }
11}12}
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 {
14 const store = useComposerDraftStore.getState();24 const store = useComposerDraftStore.getState();
15 for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) {25 for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) {
16 if (26 if (
17 session.environmentId === projectRef.environmentId &&27 session.environmentId === projectRef.environmentId &&
18 session.projectId === projectRef.projectId28 session.projectId === projectRef.projectId
19 ) {29 ) {
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]);
21 }38 }
22 }39 }
23}40}
apps/web/src/promptStashStore.test.ts+57
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";1import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
2import { EnvironmentId } from "@t3tools/contracts";
23
3import { removeLocalStorageItem } from "./hooks/useLocalStorage";4import { removeLocalStorageItem } from "./hooks/useLocalStorage";
45
...@@ -162,6 +163,62 @@ describe("promptStashStore", () => {...@@ -162,6 +163,62 @@ describe("promptStashStore", () => {
162 expect(entry?.pendingImageCount).toBe(0);163 expect(entry?.pendingImageCount).toBe(0);
163 });164 });
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
165 it("finalizeEntryImages reports false when the entry was already taken", () => {222 it("finalizeEntryImages reports false when the entry was already taken", () => {
166 const store = usePromptStashStore.getState();223 const store = usePromptStashStore.getState();
167 store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 });224 store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 });
apps/web/src/promptStashStore.ts+7-5
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import { create } from "zustand";2import { create } from "zustand";
33
4import { PersistedComposerImageAttachment } from "./composerDraftStore";4import {
5 PersistedComposerFileAttachment,
6 PersistedComposerImageAttachment,
7} from "./composerDraftStore";
5import { createMemoryStorage, type StateStorage } from "./lib/storage";8import { createMemoryStorage, type StateStorage } from "./lib/storage";
69
7export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2";10export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2";
...@@ -27,16 +30,15 @@ export const MAX_STASH_ENTRIES = 20;...@@ -27,16 +30,15 @@ export const MAX_STASH_ENTRIES = 20;
27export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000;30export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000;
2831
29/**32/**
30 * A stashed prompt carries only what every provider can accept: text and33 * Stashed files keep signed-upload references instead of storing their bytes.
31 * image attachments. Deliberately no provider instance or model selection —34 * Image payloads remain subject to the localStorage budget.
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.
34 */35 */
35const StashEntrySchema = Schema.Struct({36const StashEntrySchema = Schema.Struct({
36 id: Schema.String,37 id: Schema.String,
37 createdAt: Schema.String,38 createdAt: Schema.String,
38 prompt: Schema.String,39 prompt: Schema.String,
39 attachments: Schema.Array(PersistedComposerImageAttachment),40 attachments: Schema.Array(PersistedComposerImageAttachment),
41 files: Schema.optionalKey(Schema.Array(PersistedComposerFileAttachment)),
40 /** Names of images that exceeded the attachment budget and were not saved. */42 /** Names of images that exceeded the attachment budget and were not saved. */
41 droppedImageNames: Schema.Array(Schema.String),43 droppedImageNames: Schema.Array(Schema.String),
42 /**44 /**
apps/web/src/state/attachments.ts+2-12
...@@ -1,15 +1,5 @@...@@ -1,15 +1,5 @@
1import { WS_METHODS } from "@t3tools/contracts";1import { createAttachmentEnvironmentAtoms } from "@t3tools/client-runtime/state/attachments";
2import { createEnvironmentRpcCommand } from "@t3tools/client-runtime/state/runtime";
32
4import { connectionAtomRuntime } from "../connection/runtime";3import { connectionAtomRuntime } from "../connection/runtime";
54
6export const attachmentEnvironment = {5export const attachmentEnvironment = createAttachmentEnvironmentAtoms(connectionAtomRuntime);
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};
apps/web/src/types.ts+18-7
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type {1import type {
2 ChatAttachment as ContractChatAttachment,2 ChatFileAttachment as ContractChatFileAttachment,
3 ChatImageAttachment as ContractChatImageAttachment,3 ChatImageAttachment as ContractChatImageAttachment,
4 ChatUnknownAttachment as ContractChatUnknownAttachment,
4 OrchestrationCheckpointFile,5 OrchestrationCheckpointFile,
5 OrchestrationCheckpointSummary,6 OrchestrationCheckpointSummary,
6 OrchestrationLatestTurn,7 OrchestrationLatestTurn,
...@@ -36,18 +37,28 @@ export interface ChatImageAttachment extends ContractChatImageAttachment {...@@ -36,18 +37,28 @@ export interface ChatImageAttachment extends ContractChatImageAttachment {
36 readonly previewUrl?: string;37 readonly previewUrl?: string;
37}38}
3839
39// Non-image members pass through with the contract shape. The web UI renders40export interface ChatFileAttachment extends ContractChatFileAttachment {
40// them once it grows file support; until then they only need to typecheck.41 readonly previewUrl?: string;
41export type ChatAttachment =42 readonly downloadable?: boolean;
42 | ChatImageAttachment43}
43 | Exclude<ContractChatAttachment, ContractChatImageAttachment>;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
45// The union has an open member (`type: string`), so a literal comparison does52// 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.
47export function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment {54export function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment {
48 return attachment.type === "image";55 return attachment.type === "image";
49}56}
5057
58export function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment {
59 return attachment.type === "file";
60}
61
51export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> {62export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> {
52 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;63 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
53}64}
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...@@ -4,8 +4,17 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee
4composer and shows how many characters need to be removed. Shorten the draft or split it into4composer and shows how many characters need to be removed. Shorten the draft or split it into
5multiple messages, then send again in the same thread.5multiple 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 button7You can attach images up to 10 MB. On servers that support file uploads, web and desktop can also
8becomes available after every upload finishes. Failed uploads can be retried or removed.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
10On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into19On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into
11the composer or paste them into a message.20the composer or paste them into a message.
...@@ -14,6 +23,15 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such...@@ -14,6 +23,15 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such
14GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list23GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list
15when starting a thread or changing an existing thread's model.24when 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
17## Commands and skills35## Commands and skills
1836
19Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source,37Type `/` 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 @@...@@ -55,6 +55,10 @@
55 "types": "./src/state/assets.ts",55 "types": "./src/state/assets.ts",
56 "default": "./src/state/assets.ts"56 "default": "./src/state/assets.ts"
57 },57 },
58 "./state/attachments": {
59 "types": "./src/state/attachments.ts",
60 "default": "./src/state/attachments.ts"
61 },
58 "./state/connections": {62 "./state/connections": {
59 "types": "./src/state/connections.ts",63 "types": "./src/state/connections.ts",
60 "default": "./src/state/connections.ts"64 "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>(...@@ -327,15 +327,34 @@ export async function executeAtomCommand<A, E>(
327 return result;327 return result;
328}328}
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
330export async function executeAtomQuery<A, E>(339export async function executeAtomQuery<A, E>(
331 registry: AtomRegistry.AtomRegistry,340 registry: AtomRegistry.AtomRegistry,
332 atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>,341 atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>,
333 options: AtomCommandOptions = {},342 options: AtomQueryOptions = {},
334 reporter: AtomCommandReporter = console,343 reporter: AtomCommandReporter = console,
335): Promise<AtomCommandResult<A, E>> {344): Promise<AtomCommandResult<A, E>> {
336 const query = Effect.scoped(345 const query = Effect.scoped(
337 Effect.gen(function* () {346 Effect.gen(function* () {
338 yield* AtomRegistry.mount(registry, atom);347 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 }
339 return yield* AtomRegistry.getResult(registry, atom, {358 return yield* AtomRegistry.getResult(registry, atom, {
340 suspendOnWaiting: true,359 suspendOnWaiting: true,
341 });360 });