Unified · Split
| @@ -36,6 +36,7 @@ import { · 35 unmodified lines | ||
| 36 | 36 | shouldDockDraftHeroForSubmission, |
| 37 | 37 | shouldReleaseTimelineAnchorForToolActivity, |
| 38 | 38 | shouldShowBranchMismatchBanner, |
| 39 | + shouldShowPlanFollowUpPrompt, | |
| 39 | 40 | shouldWriteThreadErrorToCurrentServerThread, |
| 40 | 41 | } from "./ChatView.logic"; |
| 41 | 42 | |
| @@ -591,6 +592,31 @@ describe("shouldShowBranchMismatchBanner", () => { · 549 unmodified lines | ||
| 591 | 592 | }); |
| 592 | 593 | }); |
| 593 | 594 | |
| 595 | +describe("shouldShowPlanFollowUpPrompt", () => { | |
| 596 | + const base = { | |
| 597 | + pendingUserInputCount: 0, | |
| 598 | + interactionMode: "plan" as const, | |
| 599 | + latestTurnSettled: true, | |
| 600 | + hasActionableProposedPlan: true, | |
| 601 | + hasComposerAttachments: false, | |
| 602 | + }; | |
| 603 | + | |
| 604 | + it("shows plan actions for a settled actionable plan without attachments", () => { | |
| 605 | + expect(shouldShowPlanFollowUpPrompt(base)).toBe(true); | |
| 606 | + }); | |
| 607 | + | |
| 608 | + it("hides plan actions while the composer has staged attachments", () => { | |
| 609 | + expect(shouldShowPlanFollowUpPrompt({ ...base, hasComposerAttachments: true })).toBe(false); | |
| 610 | + }); | |
| 611 | + | |
| 612 | + it("preserves the existing plan follow-up gates", () => { | |
| 613 | + expect(shouldShowPlanFollowUpPrompt({ ...base, pendingUserInputCount: 1 })).toBe(false); | |
| 614 | + expect(shouldShowPlanFollowUpPrompt({ ...base, interactionMode: "default" })).toBe(false); | |
| 615 | + expect(shouldShowPlanFollowUpPrompt({ ...base, latestTurnSettled: false })).toBe(false); | |
| 616 | + expect(shouldShowPlanFollowUpPrompt({ ...base, hasActionableProposedPlan: false })).toBe(false); | |
| 617 | + }); | |
| 618 | +}); | |
| 619 | + | |
| 594 | 620 | describe("session branch mismatch dismissal", () => { |
| 595 | 621 | it("tracks dismissed keys and treats other keys as active", () => { |
| 596 | 622 | expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false); |
| @@ -4,6 +4,7 @@ import { · 3 unmodified lines | ||
| 4 | 4 | ProjectId, |
| 5 | 5 | type MessageId, |
| 6 | 6 | type ModelSelection, |
| 7 | + type ProviderInteractionMode, | |
| 7 | 8 | type ProviderDriverKind, |
| 8 | 9 | type ServerProvider, |
| 9 | 10 | type ScopedProjectRef, |
| @@ -445,6 +446,22 @@ export function shouldShowBranchMismatchBanner(input: { · 435 unmodified lines | ||
| 445 | 446 | return input.composerHasContent || input.wasShownForCurrentMismatch; |
| 446 | 447 | } |
| 447 | 448 | |
| 449 | +export function shouldShowPlanFollowUpPrompt(input: { | |
| 450 | + pendingUserInputCount: number; | |
| 451 | + interactionMode: ProviderInteractionMode; | |
| 452 | + latestTurnSettled: boolean; | |
| 453 | + hasActionableProposedPlan: boolean; | |
| 454 | + hasComposerAttachments: boolean; | |
| 455 | +}): boolean { | |
| 456 | + return ( | |
| 457 | + input.pendingUserInputCount === 0 && | |
| 458 | + input.interactionMode === "plan" && | |
| 459 | + input.latestTurnSettled && | |
| 460 | + input.hasActionableProposedPlan && | |
| 461 | + !input.hasComposerAttachments | |
| 462 | + ); | |
| 463 | +} | |
| 464 | + | |
| 448 | 465 | // Session-scoped (module-level so it survives ChatView remounts, e.g. route |
| 449 | 466 | // changes). Durable cross-device dismissal is planned as a server-side ack. |
| 450 | 467 | const sessionDismissedBranchMismatchKeys = new Set<string>(); |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | type ApprovalRequestId, |
| 3 | + type ChatFileAttachment, | |
| 3 | 4 | DEFAULT_MODEL, |
| 4 | 5 | defaultInstanceIdForDriver, |
| 5 | 6 | type EnvironmentId, |
| @@ -17,6 +18,7 @@ import { · 11 unmodified lines | ||
| 17 | 18 | type TurnId, |
| 18 | 19 | type KeybindingCommand, |
| 19 | 20 | OrchestrationThreadActivity, |
| 21 | + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 20 | 22 | ProviderInteractionMode, |
| 21 | 23 | ProviderDriverKind, |
| 22 | 24 | RuntimeMode, |
| @@ -231,6 +233,7 @@ import { · 208 unmodified lines | ||
| 231 | 233 | beginBackgroundDraftSubmissionByRef, |
| 232 | 234 | clearBackgroundDraftSubmissionByRef, |
| 233 | 235 | composerDraftHasUserContent, |
| 236 | + type ComposerFileAttachment, | |
| 234 | 237 | type ComposerImageAttachment, |
| 235 | 238 | type DraftThreadEnvMode, |
| 236 | 239 | finalizePromotedDraftThreadByRef, |
| @@ -257,6 +260,7 @@ import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/ · 20 unmodified lines | ||
| 257 | 260 | import { projectEnvironment } from "../state/projects"; |
| 258 | 261 | import { useEnvironmentQuery } from "../state/query"; |
| 259 | 262 | import { |
| 263 | + environmentServerConfigsAtom, | |
| 260 | 264 | primaryServerAvailableEditorsAtom, |
| 261 | 265 | primaryServerKeybindingsAtom, |
| 262 | 266 | primaryServerSettingsAtom, |
| @@ -346,6 +350,7 @@ import { · 83 unmodified lines | ||
| 346 | 350 | shouldDockDraftHeroForSubmission, |
| 347 | 351 | shouldReleaseTimelineAnchorForToolActivity, |
| 348 | 352 | shouldShowBranchMismatchBanner, |
| 353 | + shouldShowPlanFollowUpPrompt, | |
| 349 | 354 | getStartedThreadModelChangeBlockReason, |
| 350 | 355 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| 351 | 356 | LastInvokedScriptByProjectSchema, |
| @@ -371,13 +376,19 @@ import { useComposerHandleContext } from "../composerHandleContext"; · 19 unmodified lines | ||
| 371 | 376 | import { |
| 372 | 377 | awaitAttachmentUploads, |
| 373 | 378 | getUploadedAttachments, |
| 374 | − releaseAttachmentUploads, | |
| 379 | + releaseDraftAttachments, | |
| 375 | 380 | startAttachmentUpload, |
| 376 | 381 | } from "../lib/attachmentUploadQueue"; |
| 377 | 382 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; |
| 378 | 383 | import { RightPanelSheet } from "./RightPanelSheet"; |
| 379 | 384 | import { previewEnvironment } from "../state/preview"; |
| 385 | +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; | |
| 386 | +import { appAtomRegistry } from "../rpc/atomRegistry"; | |
| 387 | +import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; | |
| 388 | +import { assetEnvironment } from "../state/assets"; | |
| 389 | +import { readPreparedConnection } from "../state/session"; | |
| 380 | 390 | import { useAtomCommand } from "../state/use-atom-command"; |
| 391 | +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | |
| 381 | 392 | import { Button } from "./ui/button"; |
| 382 | 393 | import { |
| 383 | 394 | AlertDialog, |
| @@ -398,10 +409,10 @@ import { · 14 unmodified lines | ||
| 398 | 409 | resolveServerSelfUpdateCapability, |
| 399 | 410 | serverUpdateGuidance, |
| 400 | 411 | } from "../versionSkew"; |
| 401 | −import { useAssetUrls } from "../assets/assetUrls"; | |
| 412 | +import { resolveAssetUrl, useAssetUrls } from "../assets/assetUrls"; | |
| 402 | 413 | |
| 403 | −const IMAGE_ONLY_BOOTSTRAP_PROMPT = | |
| 404 | − "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; | |
| 414 | +const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = | |
| 415 | + "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; | |
| 405 | 416 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 406 | 417 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 407 | 418 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; |
| @@ -1299,6 +1310,9 @@ function ChatViewContent(props: ChatViewProps) { · 891 unmodified lines | ||
| 1299 | 1310 | reportFailure: false, |
| 1300 | 1311 | }); |
| 1301 | 1312 | const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); |
| 1313 | + const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { | |
| 1314 | + reportFailure: false, | |
| 1315 | + }); | |
| 1302 | 1316 | const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { |
| 1303 | 1317 | reportFailure: false, |
| 1304 | 1318 | }); |
| @@ -1384,8 +1398,13 @@ function ChatViewContent(props: ChatViewProps) { · 79 unmodified lines | ||
| 1384 | 1398 | const composerHasUnsentContent = useComposerDraftStore((store) => |
| 1385 | 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 | 1405 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); |
| 1388 | 1406 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); |
| 1407 | + const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); | |
| 1389 | 1408 | const setComposerDraftTerminalContexts = useComposerDraftStore( |
| 1390 | 1409 | (store) => store.setTerminalContexts, |
| 1391 | 1410 | ); |
| @@ -1412,6 +1431,7 @@ function ChatViewContent(props: ChatViewProps) { · 20 unmodified lines | ||
| 1412 | 1431 | ); |
| 1413 | 1432 | const promptRef = useRef(""); |
| 1414 | 1433 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); |
| 1434 | + const composerFilesRef = useRef<ComposerFileAttachment[]>([]); | |
| 1415 | 1435 | const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]); |
| 1416 | 1436 | const composerElementContextsRef = useRef<ElementContextDraft[]>([]); |
| 1417 | 1437 | const localComposerRef = useRef<ChatComposerHandle | null>(null); |
| @@ -2124,6 +2144,12 @@ function ChatViewContent(props: ChatViewProps) { · 706 unmodified lines | ||
| 2124 | 2144 | const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; |
| 2125 | 2145 | const supportsAttachmentUploads = |
| 2126 | 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 | 2153 | const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); |
| 2128 | 2154 | const versionMismatchDismissKey = |
| 2129 | 2155 | versionMismatch && activeThread |
| @@ -2403,11 +2429,13 @@ function ChatViewContent(props: ChatViewProps) { · 273 unmodified lines | ||
| 2403 | 2429 | null |
| 2404 | 2430 | ); |
| 2405 | 2431 | }, [activeLatestTurn?.turnId, activePlan]); |
| 2406 | − const showPlanFollowUpPrompt = | |
| 2407 | − pendingUserInputs.length === 0 && | |
| 2408 | − interactionMode === "plan" && | |
| 2409 | − latestTurnSettled && | |
| 2410 | − hasActionableProposedPlan(activeProposedPlan); | |
| 2432 | + const showPlanFollowUpPrompt = shouldShowPlanFollowUpPrompt({ | |
| 2433 | + pendingUserInputCount: pendingUserInputs.length, | |
| 2434 | + interactionMode, | |
| 2435 | + latestTurnSettled, | |
| 2436 | + hasActionableProposedPlan: hasActionableProposedPlan(activeProposedPlan), | |
| 2437 | + hasComposerAttachments: composerHasAttachments, | |
| 2438 | + }); | |
| 2411 | 2439 | const activePendingApproval = pendingApprovals[0] ?? null; |
| 2412 | 2440 | const { |
| 2413 | 2441 | beginLocalDispatch, |
| @@ -2493,11 +2521,57 @@ function ChatViewContent(props: ChatViewProps) { · 79 unmodified lines | ||
| 2493 | 2521 | }); |
| 2494 | 2522 | }, []); |
| 2495 | 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 | 2568 | const serverAttachmentIds = useMemo(() => { |
| 2497 | 2569 | const attachmentIds = new Set<string>(); |
| 2498 | 2570 | for (const message of serverMessages ?? []) { |
| 2499 | 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 | 2577 | return [...attachmentIds]; |
| @@ -5410,6 +5484,7 @@ function ChatViewContent(props: ChatViewProps) { · 2906 unmodified lines | ||
| 5410 | 5484 | } |
| 5411 | 5485 | const { |
| 5412 | 5486 | images: sendContextImages, |
| 5487 | + files: composerFiles, | |
| 5413 | 5488 | terminalContexts: composerTerminalContexts, |
| 5414 | 5489 | elementContexts: composerElementContexts, |
| 5415 | 5490 | previewAnnotations: sendContextPreviewAnnotations, |
| @@ -5420,9 +5495,17 @@ function ChatViewContent(props: ChatViewProps) { · 4 unmodified lines | ||
| 5420 | 5495 | selectedPromptEffort: ctxSelectedPromptEffort, |
| 5421 | 5496 | selectedModelSelection: ctxSelectedModelSelection, |
| 5422 | 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 | 5507 | const composerImages = |
| 5424 | − directAnnotation?.image && | |
| 5425 | − !sendContextImages.some((image) => image.id === directAnnotation.image?.id) | |
| 5508 | + directAnnotation?.image && annotationImageAppended | |
| 5426 | 5509 | ? [...sendContextImages, directAnnotation.image] |
| 5427 | 5510 | : sendContextImages; |
| 5428 | 5511 | const composerPreviewAnnotations = |
| @@ -5434,9 +5517,13 @@ function ChatViewContent(props: ChatViewProps) { · 5 unmodified lines | ||
| 5434 | 5517 | ...sendContextPreviewAnnotations, |
| 5435 | 5518 | { |
| 5436 | 5519 | ...directAnnotation.annotation, |
| 5437 | − screenshot: directAnnotation.annotation.screenshot | |
| 5438 | − ? { ...directAnnotation.annotation.screenshot, dataUrl: "" } | |
| 5439 | − : null, | |
| 5520 | + // Claim an attached crop only when the screenshot really rides | |
| 5521 | + // along; a cap-dropped image must not produce a lying prompt. | |
| 5522 | + screenshot: | |
| 5523 | + directAnnotation.annotation.screenshot && | |
| 5524 | + (annotationImageAppended || annotationImageAlreadyAttached) | |
| 5525 | + ? { ...directAnnotation.annotation.screenshot, dataUrl: "" } | |
| 5526 | + : null, | |
| 5440 | 5527 | }, |
| 5441 | 5528 | ] |
| 5442 | 5529 | : sendContextPreviewAnnotations; |
| @@ -5448,7 +5535,7 @@ function ChatViewContent(props: ChatViewProps) { · 5 unmodified lines | ||
| 5448 | 5535 | hasSendableContent, |
| 5449 | 5536 | } = deriveComposerSendState({ |
| 5450 | 5537 | prompt: promptForSend, |
| 5451 | − imageCount: composerImages.length, | |
| 5538 | + imageCount: composerImages.length + composerFiles.length, | |
| 5452 | 5539 | terminalContexts: composerTerminalContexts, |
| 5453 | 5540 | elementContextCount: |
| 5454 | 5541 | composerElementContexts.length + |
| @@ -5458,6 +5545,7 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines | ||
| 5458 | 5545 | const feedbackCommand = |
| 5459 | 5546 | ctxSelectedProvider === "codex" && |
| 5460 | 5547 | composerImages.length === 0 && |
| 5548 | + composerFiles.length === 0 && | |
| 5461 | 5549 | sendableComposerTerminalContexts.length === 0 && |
| 5462 | 5550 | composerElementContexts.length === 0 && |
| 5463 | 5551 | composerPreviewAnnotations.length === 0 && |
| @@ -5550,7 +5638,13 @@ function ChatViewContent(props: ChatViewProps) { · 86 unmodified lines | ||
| 5550 | 5638 | ); |
| 5551 | 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 | 5648 | const followUp = resolvePlanFollowUpSubmission({ |
| 5555 | 5649 | draftText: trimmed, |
| 5556 | 5650 | planMarkdown: activeProposedPlan.planMarkdown, |
| @@ -5579,6 +5673,7 @@ function ChatViewContent(props: ChatViewProps) { · 22 unmodified lines | ||
| 5579 | 5673 | const standaloneSlashCommand = |
| 5580 | 5674 | settings.planModeEnabled && |
| 5581 | 5675 | composerImages.length === 0 && |
| 5676 | + composerFiles.length === 0 && | |
| 5582 | 5677 | sendableComposerTerminalContexts.length === 0 && |
| 5583 | 5678 | composerElementContexts.length === 0 && |
| 5584 | 5679 | composerPreviewAnnotations.length === 0 && |
| @@ -5635,6 +5730,8 @@ function ChatViewContent(props: ChatViewProps) { · 50 unmodified lines | ||
| 5635 | 5730 | } |
| 5636 | 5731 | |
| 5637 | 5732 | const composerImagesSnapshot = [...composerImages]; |
| 5733 | + const composerFilesSnapshot = [...composerFiles]; | |
| 5734 | + const composerAttachmentsSnapshot = [...composerImagesSnapshot, ...composerFilesSnapshot]; | |
| 5638 | 5735 | const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; |
| 5639 | 5736 | const composerElementContextsSnapshot = [...composerElementContexts]; |
| 5640 | 5737 | const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; |
| @@ -5656,21 +5753,57 @@ function ChatViewContent(props: ChatViewProps) { · 15 unmodified lines | ||
| 5656 | 5753 | model: ctxSelectedModel, |
| 5657 | 5754 | models: ctxSelectedProviderModels, |
| 5658 | 5755 | effort: ctxSelectedPromptEffort, |
| 5659 | − text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, | |
| 5756 | + text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, | |
| 5660 | 5757 | }); |
| 5661 | 5758 | if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { |
| 5662 | 5759 | return; |
| 5663 | 5760 | } |
| 5664 | 5761 | |
| 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 | 5778 | sendInFlightRef.current = true; |
| 5666 | − if (supportsAttachmentUploads && composerImagesSnapshot.length > 0) { | |
| 5667 | − for (const image of composerImagesSnapshot) { | |
| 5668 | − startAttachmentUpload({ environmentId, image }); | |
| 5779 | + const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities(); | |
| 5780 | + if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) { | |
| 5781 | + sendInFlightRef.current = false; | |
| 5782 | + setThreadError(threadIdForSend, attachmentCapabilitiesBeforeUpload.fileBlockReason); | |
| 5783 | + return; | |
| 5784 | + } | |
| 5785 | + const turnUsesAttachmentUploads = | |
| 5786 | + composerFilesSnapshot.length > 0 | |
| 5787 | + ? attachmentCapabilitiesBeforeUpload.supportsAttachmentUploads | |
| 5788 | + : supportsAttachmentUploads; | |
| 5789 | + if (turnUsesAttachmentUploads && composerAttachmentsSnapshot.length > 0) { | |
| 5790 | + for (const attachment of composerAttachmentsSnapshot) { | |
| 5791 | + startAttachmentUpload({ | |
| 5792 | + environmentId, | |
| 5793 | + image: attachment, | |
| 5794 | + draftTarget: composerDraftTarget, | |
| 5795 | + }); | |
| 5669 | 5796 | } |
| 5670 | − await awaitAttachmentUploads(composerImagesSnapshot.map((image) => image.id)); | |
| 5671 | − if (getUploadedAttachments({ environmentId, images: composerImagesSnapshot }) === null) { | |
| 5797 | + await awaitAttachmentUploads(composerAttachmentsSnapshot.map((attachment) => attachment.id)); | |
| 5798 | + const attachmentCapabilitiesAfterUpload = readLiveAttachmentCapabilities(); | |
| 5799 | + if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) { | |
| 5672 | 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 | 5807 | return; |
| 5675 | 5808 | } |
| 5676 | 5809 | } |
| @@ -5699,6 +5832,16 @@ function ChatViewContent(props: ChatViewProps) { · 22 unmodified lines | ||
| 5699 | 5832 | void dockTransition.catch(() => resolveDockStarted?.()); |
| 5700 | 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 | 5845 | beginLocalDispatch({ |
| 5703 | 5846 | preparingWorktree: Boolean(baseBranchForWorktree), |
| 5704 | 5847 | submissionIntent: resolvedSubmissionIntent, |
| @@ -5707,31 +5850,45 @@ function ChatViewContent(props: ChatViewProps) { · 2 unmodified lines | ||
| 5707 | 5850 | const messageIdForSend = newMessageId(); |
| 5708 | 5851 | const messageCreatedAt = new Date().toISOString(); |
| 5709 | 5852 | const turnAttachmentsPromise = Promise.all( |
| 5710 | − composerImagesSnapshot.map(async (image) => { | |
| 5711 | − if (supportsAttachmentUploads) { | |
| 5712 | − const uploaded = getUploadedAttachments({ environmentId, images: [image] })?.[0]; | |
| 5853 | + composerAttachmentsSnapshot.map(async (attachment) => { | |
| 5854 | + if (turnUsesAttachmentUploads) { | |
| 5855 | + const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0]; | |
| 5713 | 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 | 5859 | return uploaded; |
| 5717 | 5860 | } |
| 5861 | + if (attachment.type !== "image") { | |
| 5862 | + throw new Error("This server does not support file attachments."); | |
| 5863 | + } | |
| 5718 | 5864 | return { |
| 5719 | 5865 | type: "image" as const, |
| 5720 | − name: image.name, | |
| 5721 | − mimeType: image.mimeType, | |
| 5722 | − sizeBytes: image.sizeBytes, | |
| 5723 | − dataUrl: await readFileAsDataUrl(image.file), | |
| 5866 | + name: attachment.name, | |
| 5867 | + mimeType: attachment.mimeType, | |
| 5868 | + sizeBytes: attachment.sizeBytes, | |
| 5869 | + dataUrl: await readFileAsDataUrl(attachment.file), | |
| 5724 | 5870 | }; |
| 5725 | 5871 | }), |
| 5726 | 5872 | ); |
| 5727 | − const optimisticAttachments = composerImagesSnapshot.map((image) => ({ | |
| 5728 | − type: "image" as const, | |
| 5729 | − id: image.id, | |
| 5730 | − name: image.name, | |
| 5731 | − mimeType: image.mimeType, | |
| 5732 | − sizeBytes: image.sizeBytes, | |
| 5733 | − previewUrl: image.previewUrl, | |
| 5734 | − })); | |
| 5873 | + const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) => | |
| 5874 | + attachment.type === "image" | |
| 5875 | + ? { | |
| 5876 | + type: "image" as const, | |
| 5877 | + id: attachment.id, | |
| 5878 | + name: attachment.name, | |
| 5879 | + mimeType: attachment.mimeType, | |
| 5880 | + sizeBytes: attachment.sizeBytes, | |
| 5881 | + previewUrl: attachment.previewUrl, | |
| 5882 | + } | |
| 5883 | + : { | |
| 5884 | + type: "file" as const, | |
| 5885 | + id: attachment.id, | |
| 5886 | + name: attachment.name, | |
| 5887 | + mimeType: attachment.mimeType, | |
| 5888 | + sizeBytes: attachment.sizeBytes, | |
| 5889 | + downloadable: false, | |
| 5890 | + }, | |
| 5891 | + ); | |
| 5735 | 5892 | const shouldAnchorFirstMessage = |
| 5736 | 5893 | activeThread.latestTurn === null && |
| 5737 | 5894 | !timelineMessages.some((message) => message.role === "user"); |
| @@ -5793,6 +5950,8 @@ function ChatViewContent(props: ChatViewProps) { · 55 unmodified lines | ||
| 5793 | 5950 | if (!titleSeed) { |
| 5794 | 5951 | if (firstComposerImageName) { |
| 5795 | 5952 | titleSeed = `Image: ${firstComposerImageName}`; |
| 5953 | + } else if (composerFilesSnapshot[0]) { | |
| 5954 | + titleSeed = `File: ${composerFilesSnapshot[0].name}`; | |
| 5796 | 5955 | } else if (composerTerminalContextsSnapshot.length > 0) { |
| 5797 | 5956 | titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); |
| 5798 | 5957 | } else if (composerElementContextsSnapshot.length > 0) { |
| @@ -5839,7 +5998,14 @@ function ChatViewContent(props: ChatViewProps) { · 40 unmodified lines | ||
| 5839 | 5998 | } |
| 5840 | 5999 | } |
| 5841 | 6000 | |
| 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 | 6009 | if (failure === null && turnAttachmentsResult._tag === "Failure") { |
| 5844 | 6010 | failure = turnAttachmentsResult; |
| 5845 | 6011 | } |
| @@ -5909,8 +6075,8 @@ function ChatViewContent(props: ChatViewProps) { · 63 unmodified lines | ||
| 5909 | 6075 | failure = startResult; |
| 5910 | 6076 | } else { |
| 5911 | 6077 | turnStartSucceeded = true; |
| 5912 | − if (supportsAttachmentUploads) { | |
| 5913 | − releaseAttachmentUploads(composerImagesSnapshot); | |
| 6078 | + if (turnUsesAttachmentUploads) { | |
| 6079 | + releaseDraftAttachments(composerAttachmentsSnapshot); | |
| 5914 | 6080 | } |
| 5915 | 6081 | acknowledgeActiveThreadWoke(); |
| 5916 | 6082 | if (backgroundThreadRef) { |
| @@ -5967,6 +6133,7 @@ function ChatViewContent(props: ChatViewProps) { · 50 unmodified lines | ||
| 5967 | 6133 | if ( |
| 5968 | 6134 | promptRef.current.length === 0 && |
| 5969 | 6135 | composerImagesRef.current.length === 0 && |
| 6136 | + composerFilesRef.current.length === 0 && | |
| 5970 | 6137 | composerTerminalContextsRef.current.length === 0 && |
| 5971 | 6138 | composerElementContextsRef.current.length === 0 && |
| 5972 | 6139 | (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations |
| @@ -5985,10 +6152,12 @@ function ChatViewContent(props: ChatViewProps) { · 12 unmodified lines | ||
| 5985 | 6152 | promptRef.current = promptForSend; |
| 5986 | 6153 | const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); |
| 5987 | 6154 | composerImagesRef.current = retryComposerImages; |
| 6155 | + composerFilesRef.current = composerFilesSnapshot; | |
| 5988 | 6156 | composerTerminalContextsRef.current = composerTerminalContextsSnapshot; |
| 5989 | 6157 | composerElementContextsRef.current = composerElementContextsSnapshot; |
| 5990 | 6158 | setComposerDraftPrompt(composerDraftTarget, promptForSend); |
| 5991 | 6159 | addComposerDraftImages(composerDraftTarget, retryComposerImages); |
| 6160 | + addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot); | |
| 5992 | 6161 | setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); |
| 5993 | 6162 | setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); |
| 5994 | 6163 | setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); |
| @@ -6958,6 +7127,7 @@ function ChatViewContent(props: ChatViewProps) { · 963 unmodified lines | ||
| 6958 | 7127 | onRevertUserMessage={onRevertUserMessage} |
| 6959 | 7128 | isRevertingCheckpoint={isRevertingCheckpoint} |
| 6960 | 7129 | onImageExpand={onExpandTimelineImage} |
| 7130 | + onFileDownload={downloadFileAttachment} | |
| 6961 | 7131 | markdownCwd={gitCwd ?? undefined} |
| 6962 | 7132 | resolvedTheme={resolvedTheme} |
| 6963 | 7133 | timestampFormat={timestampFormat} |
| @@ -7057,6 +7227,7 @@ function ChatViewContent(props: ChatViewProps) { · 93 unmodified lines | ||
| 7057 | 7227 | environmentId={environmentId} |
| 7058 | 7228 | attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} |
| 7059 | 7229 | supportsAttachmentUploads={supportsAttachmentUploads} |
| 7230 | + maxFileAttachmentBytes={maxFileAttachmentBytes} | |
| 7060 | 7231 | routeKind={routeKind} |
| 7061 | 7232 | routeThreadRef={routeThreadRef} |
| 7062 | 7233 | draftId={draftId} |
| @@ -7111,6 +7282,7 @@ function ChatViewContent(props: ChatViewProps) { · 48 unmodified lines | ||
| 7111 | 7282 | gitCwd={gitCwd} |
| 7112 | 7283 | promptRef={promptRef} |
| 7113 | 7284 | composerImagesRef={composerImagesRef} |
| 7285 | + composerFilesRef={composerFilesRef} | |
| 7114 | 7286 | composerTerminalContextsRef={composerTerminalContextsRef} |
| 7115 | 7287 | composerElementContextsRef={composerElementContextsRef} |
| 7116 | 7288 | onSend={onSend} |
| @@ -1471,7 +1471,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 1470 unmodified lines | ||
| 1471 | 1471 | return result; |
| 1472 | 1472 | } |
| 1473 | 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 | 1483 | const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); |
| 1476 | 1484 | if (projectDraftThread) { |
| 1477 | 1485 | draftStore.clearDraftThread(projectDraftThread.draftId); |
| @@ -1479,7 +1487,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 1 unmodified line | ||
| 1479 | 1487 | draftStore.clearProjectDraftThreadId(memberProjectRef); |
| 1480 | 1488 | return result; |
| 1481 | 1489 | }, |
| 1482 | − [deleteProject], | |
| 1490 | + [deleteProject, sidebarThreads], | |
| 1483 | 1491 | ); |
| 1484 | 1492 | |
| 1485 | 1493 | const handleRemoveProject = useCallback( |
| @@ -495,6 +495,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { · 494 unmodified lines | ||
| 495 | 495 | // that only the persisted list is populated, hence max not sum. |
| 496 | 496 | const attachmentCount = |
| 497 | 497 | Math.max(composer.images.length, composer.persistedAttachments.length) + |
| 498 | + composer.files.length + | |
| 498 | 499 | composer.terminalContexts.length + |
| 499 | 500 | composer.elementContexts.length + |
| 500 | 501 | composer.previewAnnotations.length + |
| @@ -13,7 +13,6 @@ import type { · 12 unmodified lines | ||
| 13 | 13 | TurnId, |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | 15 | import { |
| 16 | − isProviderSendTurnSupportedImageMimeType, | |
| 17 | 16 | ProviderDriverKind, |
| 18 | 17 | ProviderInstanceId, |
| 19 | 18 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| @@ -51,9 +50,13 @@ import { · 31 unmodified lines | ||
| 51 | 50 | makeComposerMentionDragHandlers, |
| 52 | 51 | } from "./composerMentionDrag"; |
| 53 | 52 | import { |
| 53 | + type ComposerFileAttachment, | |
| 54 | 54 | type ComposerImageAttachment, |
| 55 | 55 | type DraftId, |
| 56 | + type PersistedComposerFileAttachment, | |
| 56 | 57 | type PersistedComposerImageAttachment, |
| 58 | + composerFileNeedsReattach, | |
| 59 | + composerTargetKey, | |
| 57 | 60 | hydrateImagesFromPersisted, |
| 58 | 61 | useComposerDraftStore, |
| 59 | 62 | useComposerThreadDraft, |
| @@ -73,16 +76,28 @@ import { · 13 unmodified lines | ||
| 73 | 76 | type ComposerTaskStep, |
| 74 | 77 | type ComposerTasksProgress, |
| 75 | 78 | } from "./ComposerTasksBadge"; |
| 79 | +import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression"; | |
| 76 | 80 | import { |
| 77 | − compressImageForStash, | |
| 78 | − isHeicImageFile, | |
| 79 | − prepareImageForAttachment, | |
| 80 | −} from "../../lib/imageCompression"; | |
| 81 | + fileAttachmentTooLargeMessage, | |
| 82 | + formatAttachmentSize, | |
| 83 | +} from "@t3tools/client-runtime/state/attachments"; | |
| 81 | 84 | import { |
| 85 | + attachmentsToReleaseOnUploadCapabilityLoss, | |
| 86 | + classifyComposerAttachmentFile, | |
| 87 | + fileAttachmentCapabilityBlockReason, | |
| 88 | + fileAttachmentStagingLimit, | |
| 89 | + normalizeComposerImageFileMimeType, | |
| 90 | + shouldHandleComposerAttachmentPaste, | |
| 91 | +} from "./composerAttachmentFiles"; | |
| 92 | +import { | |
| 93 | + readAttachmentUpload, | |
| 82 | 94 | releaseAttachmentUpload, |
| 95 | + releaseDraftAttachment, | |
| 96 | + releasePersistedAttachmentUpload, | |
| 83 | 97 | retryAttachmentUpload, |
| 84 | 98 | startAttachmentUpload, |
| 85 | 99 | useAttachmentUploadStore, |
| 100 | + verifyStashedAttachmentUpload, | |
| 86 | 101 | } from "../../lib/attachmentUploadQueue"; |
| 87 | 102 | import { |
| 88 | 103 | attachmentUploadBlockReason, |
| @@ -241,6 +256,8 @@ import { toastManager } from "../ui/toast"; · 152 unmodified lines | ||
| 241 | 256 | import { |
| 242 | 257 | BotIcon, |
| 243 | 258 | CircleAlertIcon, |
| 259 | + FileIcon, | |
| 260 | + PaperclipIcon, | |
| 244 | 261 | PencilRulerIcon, |
| 245 | 262 | type LucideIcon, |
| 246 | 263 | LockIcon, |
| @@ -532,6 +549,7 @@ export interface ChatComposerHandle { · 285 unmodified lines | ||
| 532 | 549 | getSendContext: () => { |
| 533 | 550 | prompt: string; |
| 534 | 551 | images: ComposerImageAttachment[]; |
| 552 | + files: ComposerFileAttachment[]; | |
| 535 | 553 | terminalContexts: TerminalContextDraft[]; |
| 536 | 554 | elementContexts: ElementContextDraft[]; |
| 537 | 555 | previewAnnotations: PreviewAnnotationPayload[]; |
| @@ -557,6 +575,7 @@ export interface ChatComposerProps { · 19 unmodified lines | ||
| 557 | 575 | environmentId: EnvironmentId; |
| 558 | 576 | attachmentUploadsCapabilityKnown: boolean; |
| 559 | 577 | supportsAttachmentUploads: boolean; |
| 578 | + maxFileAttachmentBytes: number | null; | |
| 560 | 579 | routeKind: "server" | "draft"; |
| 561 | 580 | routeThreadRef: ScopedThreadRef; |
| 562 | 581 | draftId: DraftId | null; |
| @@ -630,6 +649,7 @@ export interface ChatComposerProps { · 67 unmodified lines | ||
| 630 | 649 | // Refs the parent needs kept in sync |
| 631 | 650 | promptRef: React.RefObject<string>; |
| 632 | 651 | composerImagesRef: React.RefObject<ComposerImageAttachment[]>; |
| 652 | + composerFilesRef: React.RefObject<ComposerFileAttachment[]>; | |
| 633 | 653 | composerTerminalContextsRef: React.RefObject<TerminalContextDraft[]>; |
| 634 | 654 | composerElementContextsRef: React.RefObject<ElementContextDraft[]>; |
| 635 | 655 | composerRef: React.RefObject<ChatComposerHandle | null>; |
| @@ -675,6 +695,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 39 unmodified lines | ||
| 675 | 695 | environmentId, |
| 676 | 696 | attachmentUploadsCapabilityKnown, |
| 677 | 697 | supportsAttachmentUploads, |
| 698 | + maxFileAttachmentBytes, | |
| 678 | 699 | routeKind, |
| 679 | 700 | routeThreadRef, |
| 680 | 701 | draftId, |
| @@ -721,6 +742,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 40 unmodified lines | ||
| 721 | 742 | promptRef, |
| 722 | 743 | composerRef, |
| 723 | 744 | composerImagesRef, |
| 745 | + composerFilesRef, | |
| 724 | 746 | composerTerminalContextsRef, |
| 725 | 747 | composerElementContextsRef, |
| 726 | 748 | onSend, |
| @@ -745,21 +767,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 18 unmodified lines | ||
| 745 | 767 | // Store subscriptions (prompt / images / terminal contexts) |
| 746 | 768 | // ------------------------------------------------------------------ |
| 747 | 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 | 774 | const prompt = composerDraft.prompt; |
| 749 | 775 | const composerImages = composerDraft.images; |
| 776 | + const composerFiles = composerDraft.files; | |
| 750 | 777 | const composerTerminalContexts = composerDraft.terminalContexts; |
| 751 | 778 | const composerElementContexts = composerDraft.elementContexts; |
| 752 | 779 | const composerPreviewAnnotations = composerDraft.previewAnnotations; |
| 753 | 780 | const composerReviewComments = composerDraft.reviewComments; |
| 754 | 781 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; |
| 755 | 782 | const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId); |
| 756 | − const attachmentBlockReason = supportsAttachmentUploads | |
| 757 | − ? attachmentUploadBlockReason({ | |
| 758 | − imageIds: composerImages.map((image) => image.id), | |
| 759 | − uploadsByImageId, | |
| 760 | − environmentId, | |
| 761 | − }) | |
| 762 | − : null; | |
| 783 | + const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length; | |
| 784 | + const fileStagingLimit = fileAttachmentStagingLimit({ | |
| 785 | + attachmentUploadsCapabilityKnown, | |
| 786 | + supportsAttachmentUploads, | |
| 787 | + maxFileAttachmentBytes, | |
| 788 | + }); | |
| 789 | + const fileCapabilityBlockReason = fileAttachmentCapabilityBlockReason({ | |
| 790 | + files: composerFiles, | |
| 791 | + attachmentUploadsCapabilityKnown, | |
| 792 | + supportsAttachmentUploads, | |
| 793 | + maxFileAttachmentBytes, | |
| 794 | + }); | |
| 795 | + const attachmentBlockReason = | |
| 796 | + fileCapabilityBlockReason ?? | |
| 797 | + (supportsAttachmentUploads | |
| 798 | + ? needsReattachFileCount > 0 | |
| 799 | + ? needsReattachFileCount === 1 | |
| 800 | + ? "Attach the interrupted file again or remove it" | |
| 801 | + : "Attach the interrupted files again or remove them" | |
| 802 | + : attachmentUploadBlockReason({ | |
| 803 | + imageIds: [...composerImages, ...composerFiles].map((attachment) => attachment.id), | |
| 804 | + uploadsByImageId, | |
| 805 | + environmentId, | |
| 806 | + }) | |
| 807 | + : null); | |
| 763 | 808 | const sendDisabledReason = |
| 764 | 809 | externalSendDisabledReason ?? (activePendingProgress ? null : attachmentBlockReason); |
| 765 | 810 | const isSendDisabled = sendDisabledReason !== null; |
| @@ -768,6 +813,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2 unmodified lines | ||
| 768 | 813 | const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); |
| 769 | 814 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); |
| 770 | 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 | 819 | const insertComposerDraftTerminalContext = useComposerDraftStore( |
| 772 | 820 | (store) => store.insertTerminalContext, |
| 773 | 821 | ); |
| @@ -802,15 +850,76 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 28 unmodified lines | ||
| 802 | 850 | return; |
| 803 | 851 | } |
| 804 | 852 | if (!supportsAttachmentUploads) { |
| 805 | − for (const image of composerImages) { | |
| 806 | − releaseAttachmentUpload(image.id); | |
| 853 | + // The capability can flap on reconnect or version skew. Deleting a | |
| 854 | + // persisted hydrated upload here would make the next send fail | |
| 855 | + // verification while the file still sits in the draft. | |
| 856 | + for (const attachment of attachmentsToReleaseOnUploadCapabilityLoss([ | |
| 857 | + ...composerImages, | |
| 858 | + ...composerFiles, | |
| 859 | + ])) { | |
| 860 | + releaseAttachmentUpload(attachment.id); | |
| 807 | 861 | } |
| 808 | 862 | return; |
| 809 | 863 | } |
| 810 | − for (const image of composerImages) { | |
| 811 | − startAttachmentUpload({ environmentId, image }); | |
| 864 | + const invalidFiles = | |
| 865 | + maxFileAttachmentBytes === null | |
| 866 | + ? composerFiles | |
| 867 | + : composerFiles.filter((file) => file.sizeBytes > maxFileAttachmentBytes); | |
| 868 | + for (const attachment of attachmentsToReleaseOnUploadCapabilityLoss(invalidFiles)) { | |
| 869 | + releaseAttachmentUpload(attachment.id); | |
| 870 | + } | |
| 871 | + const uploadableFiles = | |
| 872 | + maxFileAttachmentBytes === null | |
| 873 | + ? [] | |
| 874 | + : composerFiles.filter((file) => file.sizeBytes <= maxFileAttachmentBytes); | |
| 875 | + const uploadableAttachments = [...composerImages, ...uploadableFiles]; | |
| 876 | + for (const attachment of uploadableAttachments) { | |
| 877 | + // A needs-reattach file has no bytes to upload and no upload to verify. | |
| 878 | + if (attachment.type === "file" && composerFileNeedsReattach(attachment)) { | |
| 879 | + continue; | |
| 880 | + } | |
| 881 | + startAttachmentUpload({ environmentId, image: attachment, draftTarget: composerDraftTarget }); | |
| 882 | + } | |
| 883 | + }, [ | |
| 884 | + attachmentUploadsCapabilityKnown, | |
| 885 | + composerDraftTarget, | |
| 886 | + composerFiles, | |
| 887 | + composerImages, | |
| 888 | + environmentId, | |
| 889 | + maxFileAttachmentBytes, | |
| 890 | + supportsAttachmentUploads, | |
| 891 | + ]); | |
| 892 | + | |
| 893 | + useEffect(() => { | |
| 894 | + for (const file of composerFiles) { | |
| 895 | + if ( | |
| 896 | + !attachmentUploadsCapabilityKnown || | |
| 897 | + !supportsAttachmentUploads || | |
| 898 | + maxFileAttachmentBytes === null || | |
| 899 | + file.sizeBytes > maxFileAttachmentBytes | |
| 900 | + ) { | |
| 901 | + continue; | |
| 902 | + } | |
| 903 | + const upload = uploadsByImageId[file.id]; | |
| 904 | + if (upload?.status === "ready" && upload.environmentId === environmentId) { | |
| 905 | + setComposerDraftFileUpload( | |
| 906 | + composerDraftTarget, | |
| 907 | + file.id, | |
| 908 | + environmentId, | |
| 909 | + upload.attachmentId, | |
| 910 | + ); | |
| 911 | + } | |
| 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 | + ]); | |
| 814 | 923 | |
| 815 | 924 | // ------------------------------------------------------------------ |
| 816 | 925 | // Model state |
| @@ -1068,6 +1177,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 251 unmodified lines | ||
| 1068 | 1177 | // Refs |
| 1069 | 1178 | // ------------------------------------------------------------------ |
| 1070 | 1179 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); |
| 1180 | + const attachmentInputRef = useRef<HTMLInputElement>(null); | |
| 1071 | 1181 | const composerFormRef = useRef<HTMLFormElement>(null); |
| 1072 | 1182 | const composerSurfaceRef = useRef<HTMLDivElement>(null); |
| 1073 | 1183 | const providerInputRejectedRef = useRef(false); |
| @@ -1102,7 +1212,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 28 unmodified lines | ||
| 1102 | 1212 | () => |
| 1103 | 1213 | deriveComposerSendState({ |
| 1104 | 1214 | prompt, |
| 1105 | − imageCount: composerImages.length, | |
| 1215 | + imageCount: composerImages.length + composerFiles.length, | |
| 1106 | 1216 | terminalContexts: composerTerminalContexts, |
| 1107 | 1217 | elementContextCount: |
| 1108 | 1218 | composerElementContexts.length + |
| @@ -1111,6 +1221,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2 unmodified lines | ||
| 1111 | 1221 | }), |
| 1112 | 1222 | [ |
| 1113 | 1223 | composerElementContexts.length, |
| 1224 | + composerFiles.length, | |
| 1114 | 1225 | composerImages.length, |
| 1115 | 1226 | composerPreviewAnnotations.length, |
| 1116 | 1227 | composerReviewComments.length, |
| @@ -1395,6 +1506,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 278 unmodified lines | ||
| 1395 | 1506 | [composerDraftTarget, addComposerDraftImages], |
| 1396 | 1507 | ); |
| 1397 | 1508 | |
| 1509 | + const addComposerFilesToDraft = useCallback( | |
| 1510 | + (files: ComposerFileAttachment[]) => { | |
| 1511 | + addComposerDraftFiles(composerDraftTarget, files); | |
| 1512 | + }, | |
| 1513 | + [addComposerDraftFiles, composerDraftTarget], | |
| 1514 | + ); | |
| 1515 | + | |
| 1398 | 1516 | const removeComposerImageFromDraft = useCallback( |
| 1399 | 1517 | (imageId: string) => { |
| 1400 | 1518 | releaseAttachmentUpload(imageId); |
| @@ -1403,6 +1521,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2 unmodified lines | ||
| 1403 | 1521 | [composerDraftTarget, removeComposerDraftImage], |
| 1404 | 1522 | ); |
| 1405 | 1523 | |
| 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 | 1539 | const removeComposerTerminalContextFromDraft = useCallback( |
| 1407 | 1540 | (contextId: string) => { |
| 1408 | 1541 | const contextIndex = composerTerminalContexts.findIndex( |
| @@ -1459,6 +1592,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 50 unmodified lines | ||
| 1459 | 1592 | composerImagesRef.current = composerImages; |
| 1460 | 1593 | }, [composerImages, composerImagesRef]); |
| 1461 | 1594 | |
| 1595 | + useEffect(() => { | |
| 1596 | + composerFilesRef.current = composerFiles; | |
| 1597 | + }, [composerFiles, composerFilesRef]); | |
| 1598 | + | |
| 1462 | 1599 | useEffect(() => { |
| 1463 | 1600 | composerTerminalContextsRef.current = composerTerminalContexts; |
| 1464 | 1601 | }, [composerTerminalContexts, composerTerminalContextsRef]); |
| @@ -2127,9 +2264,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 662 unmodified lines | ||
| 2127 | 2264 | // ------------------------------------------------------------------ |
| 2128 | 2265 | // Prompt stash (⌘S) |
| 2129 | 2266 | // ------------------------------------------------------------------ |
| 2130 | − // One global queue. Stashed prompts carry only text + images so they can be | |
| 2131 | − // restored into any thread or provider — stash, switch, restore is the | |
| 2132 | − // whole point. | |
| 2267 | + // Files remain tied to the environment that owns their uploaded bytes. | |
| 2133 | 2268 | const stashQueue = usePromptStashStore((state) => state.entries); |
| 2134 | 2269 | const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); |
| 2135 | 2270 | const takeStashEntry = usePromptStashStore((state) => state.takeEntry); |
| @@ -2157,10 +2292,45 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 21 unmodified lines | ||
| 2157 | 2292 | }, []); |
| 2158 | 2293 | |
| 2159 | 2294 | const restoreStashEntry = useCallback( |
| 2160 | − (entry: PromptStashEntry) => { | |
| 2161 | − // Remove first so a double activation (click + Enter) can't restore twice. | |
| 2162 | − const { entry: taken, durable } = takeStashEntry(entry.id); | |
| 2163 | − if (!taken) return; | |
| 2295 | + async (menuEntry: PromptStashEntry) => { | |
| 2296 | + const filesToVerify = menuEntry.files ?? []; | |
| 2297 | + if (filesToVerify.some((file) => file.environmentId !== environmentId)) { | |
| 2298 | + toastManager.add({ | |
| 2299 | + type: "error", | |
| 2300 | + title: "Stashed files belong to another environment", | |
| 2301 | + description: "Restore this prompt in the environment that received its files.", | |
| 2302 | + }); | |
| 2303 | + return; | |
| 2304 | + } | |
| 2305 | + setIsStashMenuOpen(false); | |
| 2306 | + | |
| 2307 | + // The server sweeps pending uploads after 24 hours, so ask before | |
| 2308 | + // reattaching. An expired upload restores as a needs-reattach row | |
| 2309 | + // instead of a reference the next send would fail to verify. Verify | |
| 2310 | + // BEFORE taking: the take removes the entry from durable storage, and a | |
| 2311 | + // tab closed during this await must still find it there after reload. | |
| 2312 | + const verifications = await Promise.all( | |
| 2313 | + filesToVerify.map((file) => | |
| 2314 | + verifyStashedAttachmentUpload({ environmentId, attachmentId: file.attachmentId }), | |
| 2315 | + ), | |
| 2316 | + ); | |
| 2317 | + const expiredAttachmentIds = new Set( | |
| 2318 | + filesToVerify | |
| 2319 | + .filter((_, index) => verifications[index]?.status === "missing") | |
| 2320 | + .map((file) => file.attachmentId), | |
| 2321 | + ); | |
| 2322 | + | |
| 2323 | + // A thread switch during the verify await would mix the new thread's | |
| 2324 | + // prompt with this invocation's captured target. Nothing was taken yet, | |
| 2325 | + // so abort and leave the entry restorable where the user now is. | |
| 2326 | + if (composerTargetKey(composerDraftTarget) !== composerDraftTargetKeyRef.current) { | |
| 2327 | + return; | |
| 2328 | + } | |
| 2329 | + | |
| 2330 | + // The take is also the double-activation guard (click + Enter): the | |
| 2331 | + // second caller finds the entry gone and stops here. | |
| 2332 | + const { entry, durable } = takeStashEntry(menuEntry.id); | |
| 2333 | + if (!entry) return; | |
| 2164 | 2334 | if (!durable) { |
| 2165 | 2335 | toastManager.add({ |
| 2166 | 2336 | type: "warning", |
| @@ -2170,7 +2340,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 3 unmodified lines | ||
| 2170 | 2340 | data: { hideCopyButton: true }, |
| 2171 | 2341 | }); |
| 2172 | 2342 | } |
| 2173 | − setIsStashMenuOpen(false); | |
| 2174 | 2343 | |
| 2175 | 2344 | const currentPrompt = promptRef.current; |
| 2176 | 2345 | // An image-only stash must not append blank lines to whatever is |
| @@ -2189,6 +2358,121 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 12 unmodified lines | ||
| 2189 | 2358 | setComposerTrigger(null); |
| 2190 | 2359 | } |
| 2191 | 2360 | |
| 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 | 2476 | let unrestoredImageNames: string[] = []; |
| 2193 | 2477 | if (entry.attachments.length > 0) { |
| 2194 | 2478 | const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); |
| @@ -2203,7 +2487,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 8 unmodified lines | ||
| 2203 | 2487 | ); |
| 2204 | 2488 | const capacity = Math.max( |
| 2205 | 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 | 2495 | const pending = entry.attachments.filter( |
| 2209 | 2496 | (attachment) => |
| @@ -2242,13 +2529,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 32 unmodified lines | ||
| 2242 | 2529 | } |
| 2243 | 2530 | if (unrestoredImageNames.length > 0) { |
| 2244 | 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 | 2545 | if (missingImageReasons.length > 0) { |
| 2249 | 2546 | toastManager.add({ |
| 2250 | 2547 | type: "warning", |
| 2251 | − title: "Some images were not restored", | |
| 2548 | + title: "Some attachments were not restored", | |
| 2252 | 2549 | description: missingImageReasons.join(" "), |
| 2253 | 2550 | }); |
| 2254 | 2551 | } |
| @@ -2262,9 +2559,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 7 unmodified lines | ||
| 2262 | 2559 | } |
| 2263 | 2560 | }, |
| 2264 | 2561 | [ |
| 2562 | + addComposerDraftFiles, | |
| 2265 | 2563 | addComposerDraftImages, |
| 2266 | 2564 | composerDraftTarget, |
| 2565 | + composerFilesRef, | |
| 2267 | 2566 | composerImagesRef, |
| 2567 | + environmentId, | |
| 2268 | 2568 | promptRef, |
| 2269 | 2569 | setComposerDraftPrompt, |
| 2270 | 2570 | takeStashEntry, |
| @@ -2273,7 +2573,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2 unmodified lines | ||
| 2273 | 2573 | |
| 2274 | 2574 | const deleteStashEntry = useCallback( |
| 2275 | 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 | 2586 | if (!durable) { |
| 2278 | 2587 | toastManager.add({ |
| 2279 | 2588 | type: "warning", |
| @@ -2292,17 +2601,45 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 12 unmodified lines | ||
| 2292 | 2601 | // round-trip, so they are stripped from the stashed prompt. |
| 2293 | 2602 | const prompt = promptRef.current.split(INLINE_TERMINAL_CONTEXT_PLACEHOLDER).join("").trim(); |
| 2294 | 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 | 2606 | setIsStashMenuOpen((open) => !open); |
| 2297 | 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 | 2635 | // A repeat ⌘S on the *same* still-unencoded snapshot would stash it |
| 2300 | 2636 | // twice. Guard on the snapshot itself rather than a bare boolean: once |
| 2301 | 2637 | // the composer has been cleared the user can type something genuinely |
| 2302 | 2638 | // new (or switch threads) while encoding continues, and that deserves its |
| 2303 | 2639 | // own entry. |
| 2304 | 2640 | 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 | 2643 | .join(",")}`; |
| 2307 | 2644 | if (stashInFlightRef.current.has(snapshotKey)) return; |
| 2308 | 2645 | stashInFlightRef.current.add(snapshotKey); |
| @@ -2320,6 +2657,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 11 unmodified lines | ||
| 2320 | 2657 | createdAt: new Date().toISOString(), |
| 2321 | 2658 | prompt, |
| 2322 | 2659 | attachments: [], |
| 2660 | + ...(stashedFiles.length > 0 ? { files: stashedFiles } : {}), | |
| 2323 | 2661 | droppedImageNames: [], |
| 2324 | 2662 | unreadableImageNames: [], |
| 2325 | 2663 | pendingImageCount: images.length, |
| @@ -2352,9 +2690,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 26 unmodified lines | ||
| 2352 | 2690 | }); |
| 2353 | 2691 | } |
| 2354 | 2692 | |
| 2355 | − // Only the prompt and images are cleared — terminal/element contexts, | |
| 2356 | − // preview annotations, and review comments are not stashable, so | |
| 2357 | − // destroying them here would be unrecoverable. | |
| 2693 | + // Terminal and preview context stays behind because the stash cannot restore it. | |
| 2358 | 2694 | promptRef.current = ""; |
| 2359 | 2695 | clearComposerDraftPromptAndImages(stashTarget); |
| 2360 | 2696 | for (const image of images) { |
| @@ -2365,6 +2701,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 4 unmodified lines | ||
| 2365 | 2701 | pulseStashBadge(); |
| 2366 | 2702 | |
| 2367 | 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 | 2711 | toastManager.add({ |
| 2369 | 2712 | type: "warning", |
| 2370 | 2713 | title: "Oldest stashed prompt discarded", |
| @@ -2440,7 +2783,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 69 unmodified lines | ||
| 2440 | 2783 | }, [ |
| 2441 | 2784 | clearComposerDraftPromptAndImages, |
| 2442 | 2785 | composerDraftTarget, |
| 2786 | + composerFilesRef, | |
| 2443 | 2787 | composerImagesRef, |
| 2788 | + environmentId, | |
| 2444 | 2789 | finalizeStashEntryImages, |
| 2445 | 2790 | promptRef, |
| 2446 | 2791 | pulseStashBadge, |
| @@ -2586,14 +2931,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 139 unmodified lines | ||
| 2586 | 2931 | ]); |
| 2587 | 2932 | |
| 2588 | 2933 | // ------------------------------------------------------------------ |
| 2589 | − // Callbacks: images | |
| 2934 | + // Callbacks: attachments | |
| 2590 | 2935 | // ------------------------------------------------------------------ |
| 2591 | − const addComposerImages = async (files: File[]) => { | |
| 2936 | + const addComposerAttachments = async (files: File[]) => { | |
| 2592 | 2937 | if (!activeThreadId || files.length === 0) return; |
| 2593 | 2938 | if (pendingUserInputs.length > 0) { |
| 2594 | 2939 | toastManager.add({ |
| 2595 | 2940 | type: "error", |
| 2596 | − title: "Attach images after answering plan questions.", | |
| 2941 | + title: "Attach files after answering plan questions.", | |
| 2597 | 2942 | }); |
| 2598 | 2943 | return; |
| 2599 | 2944 | } |
| @@ -2606,34 +2951,75 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 6 unmodified lines | ||
| 2606 | 2951 | // accepted files reserve their attachment slots (via the pending counter) |
| 2607 | 2952 | // before the first await, keeping the total under the limit. |
| 2608 | 2953 | const pendingCount = pendingImageCompressionsRef.current.get(threadId) ?? 0; |
| 2609 | − let reservedCount = composerImagesRef.current.length + pendingCount; | |
| 2610 | − const acceptedFiles: File[] = []; | |
| 2954 | + let reservedCount = | |
| 2955 | + composerImagesRef.current.length + composerFilesRef.current.length + pendingCount; | |
| 2956 | + // A pick that matches a needs-reattach marker replaces it in the draft, so | |
| 2957 | + // it must not consume a slot; a draft full of markers would otherwise hit | |
| 2958 | + // the capacity error before the replacement path could run. | |
| 2959 | + const reattachKeys = new Set( | |
| 2960 | + composerFilesRef.current | |
| 2961 | + .filter(composerFileNeedsReattach) | |
| 2962 | + .map((file) => `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`), | |
| 2963 | + ); | |
| 2964 | + const acceptedImages: File[] = []; | |
| 2965 | + const acceptedFiles: ComposerFileAttachment[] = []; | |
| 2611 | 2966 | let error: string | null = null; |
| 2612 | 2967 | for (const file of files) { |
| 2613 | − const isHeicImage = isHeicImageFile(file); | |
| 2614 | − if (!file.type.startsWith("image/") && !isHeicImage) { | |
| 2615 | − error = `Unsupported file type for '${file.name}'. Please attach image files only.`; | |
| 2968 | + const attachmentKind = classifyComposerAttachmentFile(file); | |
| 2969 | + const replacesReattachMarker = | |
| 2970 | + attachmentKind === "file" && | |
| 2971 | + reattachKeys.delete( | |
| 2972 | + `${file.type || "application/octet-stream"}\u0000${file.size}\u0000${file.name || "file"}`, | |
| 2973 | + ); | |
| 2974 | + if (!replacesReattachMarker && reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | |
| 2975 | + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`; | |
| 2976 | + // Keep scanning: a later file in this batch can still replace a | |
| 2977 | + // needs-reattach marker without needing a free slot. | |
| 2616 | 2978 | continue; |
| 2617 | 2979 | } |
| 2618 | − if (!isHeicImage && !isProviderSendTurnSupportedImageMimeType(file.type)) { | |
| 2980 | + if (attachmentKind === "unsupported-image") { | |
| 2619 | 2981 | error = `'${file.name}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.`; |
| 2620 | 2982 | continue; |
| 2621 | 2983 | } |
| 2622 | − if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | |
| 2623 | − error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; | |
| 2624 | − break; | |
| 2984 | + if (attachmentKind === "image") { | |
| 2985 | + acceptedImages.push(normalizeComposerImageFileMimeType(file)); | |
| 2986 | + } else { | |
| 2987 | + if (fileStagingLimit === null) { | |
| 2988 | + error = "This server does not support file attachments."; | |
| 2989 | + continue; | |
| 2990 | + } | |
| 2991 | + if (file.size <= 0) { | |
| 2992 | + error = `'${file.name}' is empty or could not be read.`; | |
| 2993 | + continue; | |
| 2994 | + } | |
| 2995 | + if (file.size > fileStagingLimit) { | |
| 2996 | + error = fileAttachmentTooLargeMessage(file.name, fileStagingLimit); | |
| 2997 | + continue; | |
| 2998 | + } | |
| 2999 | + acceptedFiles.push({ | |
| 3000 | + type: "file", | |
| 3001 | + id: randomUUID(), | |
| 3002 | + name: file.name || "file", | |
| 3003 | + mimeType: file.type || "application/octet-stream", | |
| 3004 | + sizeBytes: file.size, | |
| 3005 | + file, | |
| 3006 | + }); | |
| 3007 | + } | |
| 3008 | + if (!replacesReattachMarker) { | |
| 3009 | + reservedCount += 1; | |
| 2625 | 3010 | } |
| 2626 | − acceptedFiles.push(file); | |
| 2627 | − reservedCount += 1; | |
| 2628 | 3011 | } |
| 2629 | 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; | |
| 2631 | 3017 | |
| 2632 | − pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedFiles.length); | |
| 3018 | + pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedImages.length); | |
| 2633 | 3019 | try { |
| 2634 | 3020 | const nextImages: ComposerImageAttachment[] = []; |
| 2635 | 3021 | let compressionError: string | null = null; |
| 2636 | − for (const file of acceptedFiles) { | |
| 3022 | + for (const file of acceptedImages) { | |
| 2637 | 3023 | // Images over the wire cap are downscaled to fit rather than |
| 2638 | 3024 | // refused; files already within it pass through byte-for-byte. |
| 2639 | 3025 | const compressed = await prepareImageForAttachment( |
| @@ -2673,7 +3059,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 33 unmodified lines | ||
| 2673 | 3059 | } |
| 2674 | 3060 | } finally { |
| 2675 | 3061 | const remaining = |
| 2676 | − (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedFiles.length; | |
| 3062 | + (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedImages.length; | |
| 2677 | 3063 | if (remaining > 0) { |
| 2678 | 3064 | pendingImageCompressionsRef.current.set(threadId, remaining); |
| 2679 | 3065 | } else { |
| @@ -2691,13 +3077,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 11 unmodified lines | ||
| 2691 | 3077 | // ------------------------------------------------------------------ |
| 2692 | 3078 | const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => { |
| 2693 | 3079 | const files = Array.from(event.clipboardData.files); |
| 2694 | − if (files.length === 0) return; | |
| 2695 | − const imageFiles = files.filter( | |
| 2696 | − (file) => file.type.startsWith("image/") || isHeicImageFile(file), | |
| 2697 | − ); | |
| 2698 | − if (imageFiles.length === 0) return; | |
| 3080 | + // Claimable pastes go through even when plan questions are pending or the | |
| 3081 | + // composer is at its attachment limit: `addComposerAttachments` surfaces | |
| 3082 | + // those as a toast and a thread error. An early return here would swallow | |
| 3083 | + // the paste with no feedback. | |
| 3084 | + if ( | |
| 3085 | + files.length === 0 || | |
| 3086 | + !activeThreadId || | |
| 3087 | + !shouldHandleComposerAttachmentPaste({ | |
| 3088 | + files, | |
| 3089 | + plainText: event.clipboardData.getData("text/plain"), | |
| 3090 | + }) | |
| 3091 | + ) { | |
| 3092 | + return; | |
| 3093 | + } | |
| 2699 | 3094 | event.preventDefault(); |
| 2700 | − void addComposerImages(imageFiles); | |
| 3095 | + void addComposerAttachments(files); | |
| 2701 | 3096 | }; |
| 2702 | 3097 | |
| 2703 | 3098 | const insertComposerTextAtEnd = ( |
| @@ -2823,7 +3218,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 119 unmodified lines | ||
| 2823 | 3218 | composerEditorRef.current?.focusAt(cursor); |
| 2824 | 3219 | }, |
| 2825 | 3220 | addDroppedFiles: (files: File[]) => { |
| 2826 | − void addComposerImages(files); | |
| 3221 | + void addComposerAttachments(files); | |
| 2827 | 3222 | focusComposer(); |
| 2828 | 3223 | }, |
| 2829 | 3224 | insertTextAtEnd: insertComposerTextAtEnd, |
| @@ -2894,6 +3289,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 64 unmodified lines | ||
| 2894 | 3289 | getSendContext: () => ({ |
| 2895 | 3290 | prompt: promptRef.current, |
| 2896 | 3291 | images: composerImagesRef.current, |
| 3292 | + files: composerFilesRef.current, | |
| 2897 | 3293 | terminalContexts: composerTerminalContextsRef.current, |
| 2898 | 3294 | elementContexts: composerElementContextsRef.current, |
| 2899 | 3295 | previewAnnotations: composerPreviewAnnotations, |
| @@ -2919,13 +3315,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 19 unmodified lines | ||
| 2919 | 3315 | }), |
| 2920 | 3316 | [ |
| 2921 | 3317 | activeThread, |
| 2922 | − addComposerImages, | |
| 3318 | + addComposerAttachments, | |
| 2923 | 3319 | composerDraftTarget, |
| 2924 | 3320 | composerCursor, |
| 2925 | 3321 | composerTerminalContexts, |
| 2926 | 3322 | insertComposerDraftTerminalContext, |
| 2927 | 3323 | promptRef, |
| 2928 | 3324 | composerImagesRef, |
| 3325 | + composerFilesRef, | |
| 2929 | 3326 | composerTerminalContextsRef, |
| 2930 | 3327 | composerElementContextsRef, |
| 2931 | 3328 | composerPreviewAnnotations, |
| @@ -3241,7 +3638,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 309 unmodified lines | ||
| 3241 | 3638 | ? { |
| 3242 | 3639 | uploadsByImageId, |
| 3243 | 3640 | onRetryUpload: (image: ComposerImageAttachment) => |
| 3244 | − retryAttachmentUpload({ environmentId, image }), | |
| 3641 | + retryAttachmentUpload({ | |
| 3642 | + environmentId, | |
| 3643 | + image, | |
| 3644 | + draftTarget: composerDraftTarget, | |
| 3645 | + }), | |
| 3245 | 3646 | } |
| 3246 | 3647 | : {})} |
| 3247 | 3648 | onRemove={(annotationId) => { |
| @@ -3367,7 +3768,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 119 unmodified lines | ||
| 3367 | 3768 | size="icon-xs" |
| 3368 | 3769 | className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95" |
| 3369 | 3770 | onClick={() => |
| 3370 | − retryAttachmentUpload({ environmentId, image }) | |
| 3771 | + retryAttachmentUpload({ | |
| 3772 | + environmentId, | |
| 3773 | + image, | |
| 3774 | + draftTarget: composerDraftTarget, | |
| 3775 | + }) | |
| 3371 | 3776 | } |
| 3372 | 3777 | aria-label={`Retry upload for ${image.name}`} |
| 3373 | 3778 | /> |
| @@ -3398,6 +3803,78 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 24 unmodified lines | ||
| 3398 | 3803 | </div> |
| 3399 | 3804 | )} |
| 3400 | 3805 | |
| 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 | 3878 | <div className="relative"> |
| 3402 | 3879 | <ComposerPromptEditor |
| 3403 | 3880 | editorRef={composerEditorRef} |
| @@ -3568,6 +4045,39 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 164 unmodified lines | ||
| 3568 | 4045 | } |
| 3569 | 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 | 4081 | {showMobilePendingAnswerActions ? null : inlineTasksBadge} |
| 3572 | 4082 | {showMobilePendingAnswerActions ? null : inlineStashBadge} |
| 3573 | 4083 | <ComposerFooterPrimaryActions |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { renderToStaticMarkup } from "react-dom/server"; |
| 2 | +import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 4 | |
| 4 | 5 | import { ComposerStashMenu } from "./ComposerStashMenu"; |
| @@ -89,4 +90,45 @@ describe("ComposerStashMenu", () => { · 84 unmodified lines | ||
| 89 | 90 | expect(markup).toContain("size-3.5 stroke-2"); |
| 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 | }); |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { XIcon } from "lucide-react"; | |
| 1 | +import { FileIcon, XIcon } from "lucide-react"; | |
| 2 | 2 | import { memo, useEffect, useRef, useState } from "react"; |
| 3 | 3 | |
| 4 | 4 | import { formatRelativeTimeLabel } from "../../timestampFormat"; |
| @@ -20,7 +20,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string { · 15 unmodified lines | ||
| 20 | 20 | return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed; |
| 21 | 21 | } |
| 22 | 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 | } |
| 25 | 31 | |
| 26 | 32 | /** |
| @@ -180,6 +186,12 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { · 153 unmodified lines | ||
| 180 | 186 | ))} |
| 181 | 187 | </span> |
| 182 | 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 | 195 | <span className="shrink-0 text-secondary-label text-xs max-sm:hidden"> |
| 184 | 196 | {formatRelativeTimeLabel(entry.createdAt)} |
| 185 | 197 | </span> |
| @@ -558,6 +558,116 @@ describe("MessagesTimeline", () => { · 557 unmodified lines | ||
| 558 | 558 | expect(onAnchorReady).not.toHaveBeenCalled(); |
| 559 | 559 | }); |
| 560 | 560 | |
| 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 | 671 | it("keeps reserved end space when tool work starts while reading history", () => { |
| 562 | 672 | const turnId = TurnId.make("turn-with-active-tool"); |
| 563 | 673 | const firstEntry = buildUserTimelineEntry("Run the command."); |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | + type ChatFileAttachment, | |
| 2 | 3 | type EnvironmentId, |
| 3 | 4 | type MessageId, |
| 4 | 5 | type ScopedThreadRef, |
| @@ -14,6 +15,7 @@ import { · 9 unmodified lines | ||
| 14 | 15 | |
| 15 | 16 | const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); |
| 16 | 17 | const NOOP_OPEN_AGENTS = () => {}; |
| 18 | +const NOOP_DOWNLOAD_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; | |
| 17 | 19 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 18 | 20 | import { |
| 19 | 21 | createContext, |
| @@ -38,7 +40,12 @@ import { · 18 unmodified lines | ||
| 38 | 40 | workEntrySignalsSevereFailure, |
| 39 | 41 | workLogEntryIsToolLike, |
| 40 | 42 | } from "../../session-logic"; |
| 41 | −import { type ChatImageAttachment, isImageAttachment, type TurnDiffSummary } from "../../types"; | |
| 43 | +import { | |
| 44 | + type ChatImageAttachment, | |
| 45 | + isFileAttachment, | |
| 46 | + isImageAttachment, | |
| 47 | + type TurnDiffSummary, | |
| 48 | +} from "../../types"; | |
| 42 | 49 | import { |
| 43 | 50 | getRenderablePatch, |
| 44 | 51 | resolveDiffThemeName, |
| @@ -51,7 +58,9 @@ import { · 6 unmodified lines | ||
| 51 | 58 | ChevronDownIcon, |
| 52 | 59 | ChevronRightIcon, |
| 53 | 60 | CircleAlertIcon, |
| 61 | + DownloadIcon, | |
| 54 | 62 | EyeIcon, |
| 63 | + FileIcon, | |
| 55 | 64 | GlobeIcon, |
| 56 | 65 | HammerIcon, |
| 57 | 66 | MessageCircleIcon, |
| @@ -143,6 +152,7 @@ interface TimelineRowSharedState { · 85 unmodified lines | ||
| 143 | 152 | activeThreadEnvironmentId: EnvironmentId; |
| 144 | 153 | onRevertUserMessage: (messageId: MessageId) => void; |
| 145 | 154 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 155 | + onFileDownload: (attachment: ChatFileAttachment) => void; | |
| 146 | 156 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 147 | 157 | onToggleTurnFold: (turnId: TurnId) => void; |
| 148 | 158 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; |
| @@ -221,6 +231,7 @@ interface MessagesTimelineProps { · 72 unmodified lines | ||
| 221 | 231 | onRevertUserMessage: (messageId: MessageId) => void; |
| 222 | 232 | isRevertingCheckpoint: boolean; |
| 223 | 233 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 234 | + onFileDownload?: (attachment: ChatFileAttachment) => void; | |
| 224 | 235 | activeThreadEnvironmentId: EnvironmentId; |
| 225 | 236 | markdownCwd: string | undefined; |
| 226 | 237 | resolvedTheme: "light" | "dark"; |
| @@ -266,6 +277,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 39 unmodified lines | ||
| 266 | 277 | onRevertUserMessage, |
| 267 | 278 | isRevertingCheckpoint, |
| 268 | 279 | onImageExpand, |
| 280 | + onFileDownload = NOOP_DOWNLOAD_ATTACHMENT, | |
| 269 | 281 | activeThreadEnvironmentId, |
| 270 | 282 | markdownCwd, |
| 271 | 283 | resolvedTheme, |
| @@ -524,6 +536,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 252 unmodified lines | ||
| 524 | 536 | activeThreadEnvironmentId, |
| 525 | 537 | onRevertUserMessage, |
| 526 | 538 | onImageExpand, |
| 539 | + onFileDownload, | |
| 527 | 540 | onOpenTurnDiff, |
| 528 | 541 | onToggleTurnFold, |
| 529 | 542 | onToggleWorkGroup, |
| @@ -540,6 +553,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 10 unmodified lines | ||
| 540 | 553 | activeThreadEnvironmentId, |
| 541 | 554 | onRevertUserMessage, |
| 542 | 555 | onImageExpand, |
| 556 | + onFileDownload, | |
| 543 | 557 | onOpenTurnDiff, |
| 544 | 558 | onToggleTurnFold, |
| 545 | 559 | onToggleWorkGroup, |
| @@ -986,7 +1000,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 440 unmodified lines | ||
| 986 | 1000 | |
| 987 | 1001 | function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { |
| 988 | 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 | 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 | 1010 | const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); |
| 991 | 1011 | const terminalContexts = displayedUserMessage.contexts; |
| 992 | 1012 | const previewAnnotations: ParsedPreviewAnnotation[] = []; |
| @@ -1011,7 +1031,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" · 18 unmodified lines | ||
| 1011 | 1031 | <div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground"> |
| 1012 | 1032 | {regularImages.length > 0 && ( |
| 1013 | 1033 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> |
| 1014 | − {regularImages.map((image: ChatImageAttachment) => ( | |
| 1034 | + {regularImages.map((image) => ( | |
| 1015 | 1035 | <div |
| 1016 | 1036 | key={image.id} |
| 1017 | 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" · 31 unmodified lines | ||
| 1049 | 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 | 1117 | {elementContexts.length > 0 ? ( |
| 1053 | 1118 | <div className="mb-2 flex flex-wrap gap-1.5"> |
| 1054 | 1119 | {elementContexts.map((context) => ( |
| @@ -0,0 +1,280 @@ | ||
| 1 | +import { EnvironmentId, PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contracts"; | |
| 2 | +import { describe, expect, it } from "vite-plus/test"; | |
| 3 | + | |
| 4 | +import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore"; | |
| 5 | +import { | |
| 6 | + attachmentsToReleaseOnUploadCapabilityLoss, | |
| 7 | + classifyComposerAttachmentFile, | |
| 8 | + fileAttachmentCapabilityBlockReason, | |
| 9 | + fileAttachmentStagingLimit, | |
| 10 | + inferImageMimeTypeFromName, | |
| 11 | + normalizeComposerImageFileMimeType, | |
| 12 | + shouldHandleComposerAttachmentPaste, | |
| 13 | +} from "./composerAttachmentFiles"; | |
| 14 | + | |
| 15 | +describe("composer attachment files", () => { | |
| 16 | + it("keeps supported images and HEIC photos on the image path", () => { | |
| 17 | + expect(classifyComposerAttachmentFile({ name: "photo.png", type: "image/png" })).toBe("image"); | |
| 18 | + expect(classifyComposerAttachmentFile({ name: "photo.heic", type: "" })).toBe("image"); | |
| 19 | + }); | |
| 20 | + | |
| 21 | + it("rejects unsupported image types instead of attaching them as generic files", () => { | |
| 22 | + expect(classifyComposerAttachmentFile({ name: "diagram.svg", type: "image/svg+xml" })).toBe( | |
| 23 | + "unsupported-image", | |
| 24 | + ); | |
| 25 | + expect(classifyComposerAttachmentFile({ name: "photo.tiff", type: "image/tiff" })).toBe( | |
| 26 | + "unsupported-image", | |
| 27 | + ); | |
| 28 | + expect(classifyComposerAttachmentFile({ name: "report.pdf", type: "application/pdf" })).toBe( | |
| 29 | + "file", | |
| 30 | + ); | |
| 31 | + }); | |
| 32 | + | |
| 33 | + it("preserves text paste when an application adds a synthetic generic file", () => { | |
| 34 | + const file = new File(["clipboard"], "clipboard.rtf", { type: "application/rtf" }); | |
| 35 | + | |
| 36 | + expect( | |
| 37 | + shouldHandleComposerAttachmentPaste({ | |
| 38 | + files: [file], | |
| 39 | + plainText: "Copied text", | |
| 40 | + }), | |
| 41 | + ).toBe(false); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + it("claims unsupported image pastes so the composer can report them", () => { | |
| 45 | + const images = [ | |
| 46 | + new File(["svg"], "diagram.svg", { type: "image/svg+xml" }), | |
| 47 | + new File(["tiff"], "photo.tiff", { type: "image/tiff" }), | |
| 48 | + ]; | |
| 49 | + | |
| 50 | + for (const image of images) { | |
| 51 | + expect( | |
| 52 | + shouldHandleComposerAttachmentPaste({ | |
| 53 | + files: [image], | |
| 54 | + plainText: "Image caption", | |
| 55 | + }), | |
| 56 | + ).toBe(true); | |
| 57 | + } | |
| 58 | + }); | |
| 59 | + | |
| 60 | + it("claims generic file-only pastes so the composer can report validation errors", () => { | |
| 61 | + const file = new File(["report"], "report.pdf", { type: "application/pdf" }); | |
| 62 | + | |
| 63 | + expect(shouldHandleComposerAttachmentPaste({ files: [file], plainText: "" })).toBe(true); | |
| 64 | + }); | |
| 65 | + | |
| 66 | + it("routes empty and oversized generic files to composer feedback", () => { | |
| 67 | + const empty = new File([], "empty.txt", { type: "text/plain" }); | |
| 68 | + const oversized = new File([new Uint8Array(1024)], "large.zip", { | |
| 69 | + type: "application/zip", | |
| 70 | + }); | |
| 71 | + | |
| 72 | + expect(shouldHandleComposerAttachmentPaste({ files: [empty], plainText: "" })).toBe(true); | |
| 73 | + expect(shouldHandleComposerAttachmentPaste({ files: [oversized], plainText: "" })).toBe(true); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + it("ignores an empty clipboard", () => { | |
| 77 | + expect(shouldHandleComposerAttachmentPaste({ files: [], plainText: "" })).toBe(false); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + it("falls back to the extension when an image arrives without a MIME type", () => { | |
| 81 | + expect(classifyComposerAttachmentFile({ name: "photo.jpg", type: "" })).toBe("image"); | |
| 82 | + expect(classifyComposerAttachmentFile({ name: "shot.PNG", type: "" })).toBe("image"); | |
| 83 | + expect(classifyComposerAttachmentFile({ name: "archive.zip", type: "" })).toBe("file"); | |
| 84 | + expect(classifyComposerAttachmentFile({ name: "no-extension", type: "" })).toBe("file"); | |
| 85 | + expect(inferImageMimeTypeFromName("photo.jpg")).toBe("image/jpeg"); | |
| 86 | + expect(inferImageMimeTypeFromName("archive.zip")).toBeNull(); | |
| 87 | + }); | |
| 88 | + | |
| 89 | + it("infers supported image types from octet-stream files", () => { | |
| 90 | + const jpeg = new File(["jpeg"], "photo.jpg", { type: "application/octet-stream" }); | |
| 91 | + const png = new File(["png"], "shot.PNG", { type: "application/octet-stream" }); | |
| 92 | + | |
| 93 | + expect(classifyComposerAttachmentFile(jpeg)).toBe("image"); | |
| 94 | + expect(classifyComposerAttachmentFile(png)).toBe("image"); | |
| 95 | + expect(normalizeComposerImageFileMimeType(jpeg).type).toBe("image/jpeg"); | |
| 96 | + expect(normalizeComposerImageFileMimeType(png).type).toBe("image/png"); | |
| 97 | + }); | |
| 98 | + | |
| 99 | + it("does not infer images for unknown extensions or specific conflicting MIME types", () => { | |
| 100 | + const binary = new File(["binary"], "archive.bin", { type: "application/octet-stream" }); | |
| 101 | + const unknownDocument = new File(["pdf"], "report.pdf", { | |
| 102 | + type: "application/octet-stream", | |
| 103 | + }); | |
| 104 | + const document = new File(["pdf"], "photo.jpg", { type: "application/pdf" }); | |
| 105 | + const explicitImage = new File(["png"], "photo.jpg", { type: "image/png" }); | |
| 106 | + | |
| 107 | + expect(classifyComposerAttachmentFile(binary)).toBe("file"); | |
| 108 | + expect(classifyComposerAttachmentFile(unknownDocument)).toBe("file"); | |
| 109 | + expect(classifyComposerAttachmentFile(document)).toBe("file"); | |
| 110 | + expect(classifyComposerAttachmentFile(explicitImage)).toBe("image"); | |
| 111 | + expect(normalizeComposerImageFileMimeType(binary)).toBe(binary); | |
| 112 | + expect(normalizeComposerImageFileMimeType(document)).toBe(document); | |
| 113 | + expect(normalizeComposerImageFileMimeType(explicitImage)).toBe(explicitImage); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + it("uses the hard local limit while server config is unknown", () => { | |
| 117 | + expect( | |
| 118 | + fileAttachmentStagingLimit({ | |
| 119 | + attachmentUploadsCapabilityKnown: false, | |
| 120 | + supportsAttachmentUploads: false, | |
| 121 | + maxFileAttachmentBytes: null, | |
| 122 | + }), | |
| 123 | + ).toBe(PROVIDER_SEND_TURN_MAX_FILE_BYTES); | |
| 124 | + expect( | |
| 125 | + fileAttachmentCapabilityBlockReason({ | |
| 126 | + files: [{ name: "pending.zip", sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }], | |
| 127 | + attachmentUploadsCapabilityKnown: false, | |
| 128 | + supportsAttachmentUploads: false, | |
| 129 | + maxFileAttachmentBytes: null, | |
| 130 | + }), | |
| 131 | + ).toBe("Waiting for the server before file attachments can send"); | |
| 132 | + }); | |
| 133 | + | |
| 134 | + it("rejects local staging and send when known config has no file support", () => { | |
| 135 | + const unsupportedReason = | |
| 136 | + "This server does not accept file attachments right now. Remove the files to send."; | |
| 137 | + expect( | |
| 138 | + fileAttachmentStagingLimit({ | |
| 139 | + attachmentUploadsCapabilityKnown: true, | |
| 140 | + supportsAttachmentUploads: true, | |
| 141 | + maxFileAttachmentBytes: null, | |
| 142 | + }), | |
| 143 | + ).toBeNull(); | |
| 144 | + expect( | |
| 145 | + fileAttachmentStagingLimit({ | |
| 146 | + attachmentUploadsCapabilityKnown: true, | |
| 147 | + supportsAttachmentUploads: false, | |
| 148 | + maxFileAttachmentBytes: 50 * 1024 * 1024, | |
| 149 | + }), | |
| 150 | + ).toBeNull(); | |
| 151 | + expect( | |
| 152 | + fileAttachmentCapabilityBlockReason({ | |
| 153 | + files: [{ name: "report.pdf", sizeBytes: 1024 }], | |
| 154 | + attachmentUploadsCapabilityKnown: true, | |
| 155 | + supportsAttachmentUploads: true, | |
| 156 | + maxFileAttachmentBytes: null, | |
| 157 | + }), | |
| 158 | + ).toBe(unsupportedReason); | |
| 159 | + expect( | |
| 160 | + fileAttachmentCapabilityBlockReason({ | |
| 161 | + files: [{ name: "report.pdf", sizeBytes: 1024 }], | |
| 162 | + attachmentUploadsCapabilityKnown: true, | |
| 163 | + supportsAttachmentUploads: false, | |
| 164 | + maxFileAttachmentBytes: 50 * 1024 * 1024, | |
| 165 | + }), | |
| 166 | + ).toBe(unsupportedReason); | |
| 167 | + }); | |
| 168 | + | |
| 169 | + it("blocks retained files that exceed a newly lower server limit", () => { | |
| 170 | + expect( | |
| 171 | + fileAttachmentCapabilityBlockReason({ | |
| 172 | + files: [{ name: "large.zip", sizeBytes: 2 * 1024 * 1024 }], | |
| 173 | + attachmentUploadsCapabilityKnown: true, | |
| 174 | + supportsAttachmentUploads: true, | |
| 175 | + maxFileAttachmentBytes: 1024 * 1024, | |
| 176 | + }), | |
| 177 | + ).toBe("'large.zip' exceeds the 1 MB attachment limit."); | |
| 178 | + }); | |
| 179 | + | |
| 180 | + it("uses the confirmed server limit without exceeding the hard cap", () => { | |
| 181 | + expect( | |
| 182 | + fileAttachmentStagingLimit({ | |
| 183 | + attachmentUploadsCapabilityKnown: true, | |
| 184 | + supportsAttachmentUploads: true, | |
| 185 | + maxFileAttachmentBytes: 1024 * 1024, | |
| 186 | + }), | |
| 187 | + ).toBe(1024 * 1024); | |
| 188 | + expect( | |
| 189 | + fileAttachmentStagingLimit({ | |
| 190 | + attachmentUploadsCapabilityKnown: true, | |
| 191 | + supportsAttachmentUploads: true, | |
| 192 | + maxFileAttachmentBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES * 2, | |
| 193 | + }), | |
| 194 | + ).toBe(PROVIDER_SEND_TURN_MAX_FILE_BYTES); | |
| 195 | + expect( | |
| 196 | + fileAttachmentCapabilityBlockReason({ | |
| 197 | + files: [{ name: "report.pdf", sizeBytes: 1024 }], | |
| 198 | + attachmentUploadsCapabilityKnown: true, | |
| 199 | + supportsAttachmentUploads: true, | |
| 200 | + maxFileAttachmentBytes: 50 * 1024 * 1024, | |
| 201 | + }), | |
| 202 | + ).toBeNull(); | |
| 203 | + }); | |
| 204 | + | |
| 205 | + it("does not block empty or image-only composers on legacy servers", () => { | |
| 206 | + expect( | |
| 207 | + fileAttachmentCapabilityBlockReason({ | |
| 208 | + files: [], | |
| 209 | + attachmentUploadsCapabilityKnown: true, | |
| 210 | + supportsAttachmentUploads: false, | |
| 211 | + maxFileAttachmentBytes: null, | |
| 212 | + }), | |
| 213 | + ).toBeNull(); | |
| 214 | + expect( | |
| 215 | + fileAttachmentCapabilityBlockReason({ | |
| 216 | + files: [], | |
| 217 | + attachmentUploadsCapabilityKnown: false, | |
| 218 | + supportsAttachmentUploads: false, | |
| 219 | + maxFileAttachmentBytes: null, | |
| 220 | + }), | |
| 221 | + ).toBeNull(); | |
| 222 | + }); | |
| 223 | + | |
| 224 | + it("keeps draft-persisted file uploads when the upload capability flips off", () => { | |
| 225 | + const environmentId = EnvironmentId.make("environment-1"); | |
| 226 | + const image: ComposerImageAttachment = { | |
| 227 | + type: "image", | |
| 228 | + id: "image-1", | |
| 229 | + name: "photo.png", | |
| 230 | + mimeType: "image/png", | |
| 231 | + sizeBytes: 3, | |
| 232 | + previewUrl: "blob:photo", | |
| 233 | + file: new File([new Uint8Array([1, 2, 3])], "photo.png", { type: "image/png" }), | |
| 234 | + }; | |
| 235 | + const uploadingFile: ComposerFileAttachment = { | |
| 236 | + type: "file", | |
| 237 | + id: "file-uploading", | |
| 238 | + name: "fresh.pdf", | |
| 239 | + mimeType: "application/pdf", | |
| 240 | + sizeBytes: 3, | |
| 241 | + file: new File([new Uint8Array([1, 2, 3])], "fresh.pdf", { type: "application/pdf" }), | |
| 242 | + }; | |
| 243 | + const hydratedFile: ComposerFileAttachment = { | |
| 244 | + type: "file", | |
| 245 | + id: "file-hydrated", | |
| 246 | + name: "report.pdf", | |
| 247 | + mimeType: "application/pdf", | |
| 248 | + sizeBytes: 3, | |
| 249 | + file: null, | |
| 250 | + uploadedAttachmentId: "pending-report-pdf", | |
| 251 | + uploadEnvironmentId: environmentId, | |
| 252 | + }; | |
| 253 | + const uploadedLocalFile: ComposerFileAttachment = { | |
| 254 | + ...uploadingFile, | |
| 255 | + id: "file-uploaded-local", | |
| 256 | + uploadedAttachmentId: "pending-fresh-pdf", | |
| 257 | + uploadEnvironmentId: environmentId, | |
| 258 | + }; | |
| 259 | + | |
| 260 | + const released = attachmentsToReleaseOnUploadCapabilityLoss([ | |
| 261 | + image, | |
| 262 | + uploadingFile, | |
| 263 | + hydratedFile, | |
| 264 | + uploadedLocalFile, | |
| 265 | + ]); | |
| 266 | + | |
| 267 | + expect(released.map((attachment) => attachment.id)).toEqual(["image-1", "file-uploading"]); | |
| 268 | + }); | |
| 269 | + | |
| 270 | + it("claims image pastes even when clipboard text is present", () => { | |
| 271 | + const image = new File(["image"], "photo.heic", { type: "image/heic" }); | |
| 272 | + | |
| 273 | + expect( | |
| 274 | + shouldHandleComposerAttachmentPaste({ | |
| 275 | + files: [image], | |
| 276 | + plainText: "Image caption", | |
| 277 | + }), | |
| 278 | + ).toBe(true); | |
| 279 | + }); | |
| 280 | +}); | |
| @@ -0,0 +1,153 @@ | ||
| 1 | +import { | |
| 2 | + isProviderSendTurnSupportedImageMimeType, | |
| 3 | + PROVIDER_SEND_TURN_MAX_FILE_BYTES, | |
| 4 | +} from "@t3tools/contracts"; | |
| 5 | +import { | |
| 6 | + clampFileAttachmentUploadBytes, | |
| 7 | + fileAttachmentTooLargeMessage, | |
| 8 | +} from "@t3tools/client-runtime/state/attachments"; | |
| 9 | + | |
| 10 | +import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore"; | |
| 11 | +import { isHeicImageFile } from "../../lib/imageCompression"; | |
| 12 | + | |
| 13 | +type ComposerAttachmentFileKind = "image" | "file" | "unsupported-image"; | |
| 14 | + | |
| 15 | +interface FileAttachmentCapabilityState { | |
| 16 | + readonly attachmentUploadsCapabilityKnown: boolean; | |
| 17 | + readonly supportsAttachmentUploads: boolean; | |
| 18 | + readonly maxFileAttachmentBytes: number | null; | |
| 19 | +} | |
| 20 | + | |
| 21 | +const IMAGE_MIME_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = { | |
| 22 | + gif: "image/gif", | |
| 23 | + jpeg: "image/jpeg", | |
| 24 | + jpg: "image/jpeg", | |
| 25 | + png: "image/png", | |
| 26 | + webp: "image/webp", | |
| 27 | +}; | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * Some sources (drags from other apps, files piped through a shell) hand over | |
| 31 | + * a `File` with an empty or generic MIME type. Maps the extension to a | |
| 32 | + * provider-supported image type so a plain `photo.jpg` still lands on the | |
| 33 | + * image path; anything unrecognized stays a generic file. | |
| 34 | + */ | |
| 35 | +export function inferImageMimeTypeFromName(name: string): string | null { | |
| 36 | + const dotIndex = name.lastIndexOf("."); | |
| 37 | + if (dotIndex <= 0) { | |
| 38 | + return null; | |
| 39 | + } | |
| 40 | + return IMAGE_MIME_TYPE_BY_EXTENSION[name.slice(dotIndex + 1).toLowerCase()] ?? null; | |
| 41 | +} | |
| 42 | + | |
| 43 | +function inferImageMimeTypeForUnknownFile(file: Pick<File, "name" | "type">): string | null { | |
| 44 | + const mimeType = file.type.toLowerCase(); | |
| 45 | + if (mimeType !== "" && mimeType !== "application/octet-stream") { | |
| 46 | + return null; | |
| 47 | + } | |
| 48 | + return inferImageMimeTypeFromName(file.name); | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** Give extension-recognized images a concrete type before compression. */ | |
| 52 | +export function normalizeComposerImageFileMimeType(file: File): File { | |
| 53 | + const inferredMimeType = inferImageMimeTypeForUnknownFile(file); | |
| 54 | + if (!inferredMimeType) { | |
| 55 | + return file; | |
| 56 | + } | |
| 57 | + return new File([file], file.name, { | |
| 58 | + type: inferredMimeType, | |
| 59 | + lastModified: file.lastModified, | |
| 60 | + }); | |
| 61 | +} | |
| 62 | + | |
| 63 | +export function classifyComposerAttachmentFile( | |
| 64 | + file: Pick<File, "name" | "type">, | |
| 65 | +): ComposerAttachmentFileKind { | |
| 66 | + if (isHeicImageFile(file)) { | |
| 67 | + return "image"; | |
| 68 | + } | |
| 69 | + if (inferImageMimeTypeForUnknownFile(file)) { | |
| 70 | + return "image"; | |
| 71 | + } | |
| 72 | + if (!file.type.toLowerCase().startsWith("image/")) { | |
| 73 | + return "file"; | |
| 74 | + } | |
| 75 | + return isProviderSendTurnSupportedImageMimeType(file.type) ? "image" : "unsupported-image"; | |
| 76 | +} | |
| 77 | + | |
| 78 | +/** Byte limit for adding a generic file to the local composer draft. */ | |
| 79 | +export function fileAttachmentStagingLimit(input: FileAttachmentCapabilityState): number | null { | |
| 80 | + if (!input.attachmentUploadsCapabilityKnown) { | |
| 81 | + return PROVIDER_SEND_TURN_MAX_FILE_BYTES; | |
| 82 | + } | |
| 83 | + if (!input.supportsAttachmentUploads || input.maxFileAttachmentBytes === null) { | |
| 84 | + return null; | |
| 85 | + } | |
| 86 | + return clampFileAttachmentUploadBytes(input.maxFileAttachmentBytes); | |
| 87 | +} | |
| 88 | + | |
| 89 | +/** Why retained generic files cannot send with the current server config. */ | |
| 90 | +export function fileAttachmentCapabilityBlockReason( | |
| 91 | + input: FileAttachmentCapabilityState & { | |
| 92 | + readonly files: ReadonlyArray<{ readonly name: string; readonly sizeBytes: number }>; | |
| 93 | + }, | |
| 94 | +): string | null { | |
| 95 | + if (input.files.length === 0) { | |
| 96 | + return null; | |
| 97 | + } | |
| 98 | + if (!input.attachmentUploadsCapabilityKnown) { | |
| 99 | + return "Waiting for the server before file attachments can send"; | |
| 100 | + } | |
| 101 | + const maxFileAttachmentBytes = fileAttachmentStagingLimit(input); | |
| 102 | + if (maxFileAttachmentBytes === null) { | |
| 103 | + return "This server does not accept file attachments right now. Remove the files to send."; | |
| 104 | + } | |
| 105 | + const oversizedFile = input.files.find((file) => file.sizeBytes > maxFileAttachmentBytes); | |
| 106 | + if (oversizedFile) { | |
| 107 | + return fileAttachmentTooLargeMessage(oversizedFile.name, maxFileAttachmentBytes); | |
| 108 | + } | |
| 109 | + return null; | |
| 110 | +} | |
| 111 | + | |
| 112 | +/** | |
| 113 | + * When `capabilities.attachmentUploads` flips off (reconnect, version skew), | |
| 114 | + * tear down only uploads that have not been persisted onto a draft file. | |
| 115 | + * Once `uploadedAttachmentId` is stamped, the draft references that server | |
| 116 | + * copy after reload even if its local `File` is still available in memory. | |
| 117 | + * Explicit attachment removal releases persisted uploads through | |
| 118 | + * `releaseDraftAttachment`. | |
| 119 | + */ | |
| 120 | +export function attachmentsToReleaseOnUploadCapabilityLoss( | |
| 121 | + attachments: ReadonlyArray<ComposerImageAttachment | ComposerFileAttachment>, | |
| 122 | +): Array<ComposerImageAttachment | ComposerFileAttachment> { | |
| 123 | + return attachments.filter( | |
| 124 | + (attachment) => !(attachment.type === "file" && attachment.uploadedAttachmentId !== undefined), | |
| 125 | + ); | |
| 126 | +} | |
| 127 | + | |
| 128 | +/** | |
| 129 | + * Whether a paste's files should be claimed as composer attachments instead of | |
| 130 | + * falling through to the default text paste. Deliberately no capacity or | |
| 131 | + * pending-plan-question gate here: `addComposerAttachments` owns those limits | |
| 132 | + * and reports them, while a gate at this layer would swallow the paste with no | |
| 133 | + * feedback. | |
| 134 | + */ | |
| 135 | +export function shouldHandleComposerAttachmentPaste(input: { | |
| 136 | + readonly files: ReadonlyArray<File>; | |
| 137 | + readonly plainText: string; | |
| 138 | +}): boolean { | |
| 139 | + if ( | |
| 140 | + input.files.some((file) => { | |
| 141 | + const classification = classifyComposerAttachmentFile(file); | |
| 142 | + return classification === "image" || classification === "unsupported-image"; | |
| 143 | + }) | |
| 144 | + ) { | |
| 145 | + return true; | |
| 146 | + } | |
| 147 | + | |
| 148 | + if (input.plainText.length > 0) { | |
| 149 | + return false; | |
| 150 | + } | |
| 151 | + | |
| 152 | + return input.files.some((file) => classifyComposerAttachmentFile(file) === "file"); | |
| 153 | +} | |
| @@ -6,7 +6,7 @@ import { · 5 unmodified lines | ||
| 6 | 6 | squashAtomCommandFailure, |
| 7 | 7 | type AtomCommandResult, |
| 8 | 8 | } from "@t3tools/client-runtime/state/runtime"; |
| 9 | −import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | |
| 9 | +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 10 | 10 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 11 | 11 | import { |
| 12 | 12 | deriveProjectGroupingOverrideKey, |
| @@ -733,7 +733,10 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { · 720 unmodified lines | ||
| 733 | 733 | return; |
| 734 | 734 | } |
| 735 | 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 | 740 | const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); |
| 738 | 741 | if (projectDraftThread) { |
| 739 | 742 | draftStore.clearDraftThread(projectDraftThread.draftId); |
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | ||
| 11 | 11 | ProjectId, |
| 12 | 12 | ProviderDriverKind, |
| 13 | 13 | ProviderInstanceId, |
| 14 | + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 14 | 15 | ThreadId, |
| 15 | 16 | type ModelSelection, |
| 16 | 17 | type ProviderOptionSelection, |
| @@ -65,7 +66,9 @@ import { · 48 unmodified lines | ||
| 65 | 66 | markPromotedDraftThreadByRef, |
| 66 | 67 | markPromotedDraftThreads, |
| 67 | 68 | markPromotedDraftThreadsByRef, |
| 69 | + type ComposerFileAttachment, | |
| 68 | 70 | type ComposerImageAttachment, |
| 71 | + composerFileNeedsReattach, | |
| 69 | 72 | useComposerDraftStore, |
| 70 | 73 | DraftId, |
| 71 | 74 | } from "./composerDraftStore"; |
| @@ -104,6 +107,18 @@ function makeImage(input: { · 32 unmodified lines | ||
| 104 | 107 | }; |
| 105 | 108 | } |
| 106 | 109 | |
| 110 | +function makeFile(id: string): ComposerFileAttachment { | |
| 111 | + const file = new File(["report"], "report.pdf", { type: "application/pdf" }); | |
| 112 | + return { | |
| 113 | + type: "file", | |
| 114 | + id, | |
| 115 | + name: file.name, | |
| 116 | + mimeType: file.type, | |
| 117 | + sizeBytes: file.size, | |
| 118 | + file, | |
| 119 | + }; | |
| 120 | +} | |
| 121 | + | |
| 107 | 122 | function makeTerminalContext(input: { |
| 108 | 123 | id: string; |
| 109 | 124 | text?: string; |
| @@ -289,6 +304,280 @@ describe("composerDraftStore clearComposerContent", () => { · 179 unmodified lines | ||
| 289 | 304 | }); |
| 290 | 305 | }); |
| 291 | 306 | |
| 307 | +describe("composerDraftStore file attachments", () => { | |
| 308 | + const threadId = ThreadId.make("thread-files"); | |
| 309 | + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | |
| 310 | + | |
| 311 | + beforeEach(() => { | |
| 312 | + resetComposerDraftStore(); | |
| 313 | + }); | |
| 314 | + | |
| 315 | + it("persists uploaded file references without including file contents", () => { | |
| 316 | + const store = useComposerDraftStore.getState(); | |
| 317 | + store.addFiles(threadRef, [makeFile("file-1")]); | |
| 318 | + store.setFileUpload(threadRef, "file-1", TEST_ENVIRONMENT_ID, "pending-report-pdf"); | |
| 319 | + | |
| 320 | + const persistApi = useComposerDraftStore.persist as unknown as { | |
| 321 | + getOptions: () => { | |
| 322 | + partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown; | |
| 323 | + merge: ( | |
| 324 | + persistedState: unknown, | |
| 325 | + currentState: ReturnType<typeof useComposerDraftStore.getState>, | |
| 326 | + ) => ReturnType<typeof useComposerDraftStore.getState>; | |
| 327 | + }; | |
| 328 | + }; | |
| 329 | + const options = persistApi.getOptions(); | |
| 330 | + const persisted = options.partialize(useComposerDraftStore.getState()) as { | |
| 331 | + draftsByThreadKey: Record<string, { files?: Array<Record<string, unknown>> }>; | |
| 332 | + }; | |
| 333 | + | |
| 334 | + expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual( | |
| 335 | + [ | |
| 336 | + { | |
| 337 | + id: "file-1", | |
| 338 | + name: "report.pdf", | |
| 339 | + mimeType: "application/pdf", | |
| 340 | + sizeBytes: 6, | |
| 341 | + attachmentId: "pending-report-pdf", | |
| 342 | + environmentId: TEST_ENVIRONMENT_ID, | |
| 343 | + }, | |
| 344 | + ], | |
| 345 | + ); | |
| 346 | + | |
| 347 | + const hydrated = options.merge(persisted, useComposerDraftStore.getState()); | |
| 348 | + expect(hydrated.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual([ | |
| 349 | + { | |
| 350 | + type: "file", | |
| 351 | + id: "file-1", | |
| 352 | + name: "report.pdf", | |
| 353 | + mimeType: "application/pdf", | |
| 354 | + sizeBytes: 6, | |
| 355 | + file: null, | |
| 356 | + uploadedAttachmentId: "pending-report-pdf", | |
| 357 | + uploadEnvironmentId: TEST_ENVIRONMENT_ID, | |
| 358 | + }, | |
| 359 | + ]); | |
| 360 | + }); | |
| 361 | + | |
| 362 | + it("persists a pending file as a needs-reattach marker instead of dropping it", () => { | |
| 363 | + const store = useComposerDraftStore.getState(); | |
| 364 | + // No setFileUpload: the upload never finished, so there is no attachment | |
| 365 | + // id and the File handle cannot serialize. | |
| 366 | + store.addFiles(threadRef, [makeFile("file-pending")]); | |
| 367 | + | |
| 368 | + const persistApi = useComposerDraftStore.persist as unknown as { | |
| 369 | + getOptions: () => { | |
| 370 | + partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown; | |
| 371 | + merge: ( | |
| 372 | + persistedState: unknown, | |
| 373 | + currentState: ReturnType<typeof useComposerDraftStore.getState>, | |
| 374 | + ) => ReturnType<typeof useComposerDraftStore.getState>; | |
| 375 | + }; | |
| 376 | + }; | |
| 377 | + const options = persistApi.getOptions(); | |
| 378 | + const persisted = options.partialize(useComposerDraftStore.getState()) as { | |
| 379 | + draftsByThreadKey: Record<string, { files?: Array<Record<string, unknown>> }>; | |
| 380 | + }; | |
| 381 | + | |
| 382 | + expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual( | |
| 383 | + [ | |
| 384 | + { | |
| 385 | + id: "file-pending", | |
| 386 | + name: "report.pdf", | |
| 387 | + mimeType: "application/pdf", | |
| 388 | + sizeBytes: 6, | |
| 389 | + }, | |
| 390 | + ], | |
| 391 | + ); | |
| 392 | + | |
| 393 | + const hydrated = options.merge(persisted, useComposerDraftStore.getState()); | |
| 394 | + const hydratedFiles = | |
| 395 | + hydrated.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files; | |
| 396 | + expect(hydratedFiles).toEqual([ | |
| 397 | + { | |
| 398 | + type: "file", | |
| 399 | + id: "file-pending", | |
| 400 | + name: "report.pdf", | |
| 401 | + mimeType: "application/pdf", | |
| 402 | + sizeBytes: 6, | |
| 403 | + file: null, | |
| 404 | + }, | |
| 405 | + ]); | |
| 406 | + expect(hydratedFiles?.every(composerFileNeedsReattach)).toBe(true); | |
| 407 | + }); | |
| 408 | + | |
| 409 | + it("marks only the matching byte-less upload as missing", () => { | |
| 410 | + const store = useComposerDraftStore.getState(); | |
| 411 | + const hydrated: ComposerFileAttachment = { | |
| 412 | + ...makeFile("file-hydrated"), | |
| 413 | + file: null, | |
| 414 | + uploadedAttachmentId: "pending-old", | |
| 415 | + uploadEnvironmentId: TEST_ENVIRONMENT_ID, | |
| 416 | + }; | |
| 417 | + const local: ComposerFileAttachment = { | |
| 418 | + ...makeFile("file-local"), | |
| 419 | + name: "local.txt", | |
| 420 | + mimeType: "text/plain", | |
| 421 | + }; | |
| 422 | + store.addFiles(threadRef, [hydrated, local]); | |
| 423 | + store.setFileUpload(threadRef, hydrated.id, TEST_ENVIRONMENT_ID, "pending-new"); | |
| 424 | + store.setFileUpload(threadRef, local.id, TEST_ENVIRONMENT_ID, "pending-local"); | |
| 425 | + | |
| 426 | + expect( | |
| 427 | + store.markFileUploadMissing(threadRef, hydrated.id, OTHER_TEST_ENVIRONMENT_ID, "pending-new"), | |
| 428 | + ).toBe(false); | |
| 429 | + expect( | |
| 430 | + store.markFileUploadMissing(threadRef, hydrated.id, TEST_ENVIRONMENT_ID, "pending-old"), | |
| 431 | + ).toBe(false); | |
| 432 | + expect( | |
| 433 | + store.markFileUploadMissing(threadRef, local.id, TEST_ENVIRONMENT_ID, "pending-local"), | |
| 434 | + ).toBe(false); | |
| 435 | + | |
| 436 | + expect(store.getComposerDraft(threadRef)?.files).toMatchObject([ | |
| 437 | + { | |
| 438 | + id: hydrated.id, | |
| 439 | + uploadedAttachmentId: "pending-new", | |
| 440 | + uploadEnvironmentId: TEST_ENVIRONMENT_ID, | |
| 441 | + }, | |
| 442 | + { | |
| 443 | + id: local.id, | |
| 444 | + file: local.file, | |
| 445 | + uploadedAttachmentId: "pending-local", | |
| 446 | + uploadEnvironmentId: TEST_ENVIRONMENT_ID, | |
| 447 | + }, | |
| 448 | + ]); | |
| 449 | + | |
| 450 | + expect( | |
| 451 | + store.markFileUploadMissing(threadRef, hydrated.id, TEST_ENVIRONMENT_ID, "pending-new"), | |
| 452 | + ).toBe(true); | |
| 453 | + const marker = store.getComposerDraft(threadRef)?.files[0]; | |
| 454 | + expect(marker && composerFileNeedsReattach(marker)).toBe(true); | |
| 455 | + expect(marker?.uploadedAttachmentId).toBeUndefined(); | |
| 456 | + expect(marker?.uploadEnvironmentId).toBeUndefined(); | |
| 457 | + }); | |
| 458 | + | |
| 459 | + it("removes generic files when the composer is cleared", () => { | |
| 460 | + const store = useComposerDraftStore.getState(); | |
| 461 | + store.addFiles(threadRef, [makeFile("file-clear")]); | |
| 462 | + | |
| 463 | + store.clearComposerContent(threadRef); | |
| 464 | + | |
| 465 | + expect(store.getComposerDraft(threadRef)).toBeNull(); | |
| 466 | + }); | |
| 467 | + | |
| 468 | + it("removes generic files when a prompt is moved into the stash", () => { | |
| 469 | + const store = useComposerDraftStore.getState(); | |
| 470 | + store.setPrompt(threadRef, "Review the report"); | |
| 471 | + store.addFiles(threadRef, [makeFile("file-stash")]); | |
| 472 | + | |
| 473 | + store.clearComposerPromptAndImages(threadRef); | |
| 474 | + | |
| 475 | + expect(store.getComposerDraft(threadRef)).toBeNull(); | |
| 476 | + }); | |
| 477 | + | |
| 478 | + it("enforces the combined file and image limit across separate updates", () => { | |
| 479 | + const store = useComposerDraftStore.getState(); | |
| 480 | + const images = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1 }, (_, index) => | |
| 481 | + makeImage({ | |
| 482 | + id: `image-${index}`, | |
| 483 | + name: `image-${index}.png`, | |
| 484 | + previewUrl: `blob:image-${index}`, | |
| 485 | + }), | |
| 486 | + ); | |
| 487 | + store.addImages(threadRef, images); | |
| 488 | + store.addFiles(threadRef, [ | |
| 489 | + makeFile("file-accepted"), | |
| 490 | + { ...makeFile("file-overflow"), name: "other.pdf" }, | |
| 491 | + ]); | |
| 492 | + store.addImages(threadRef, [ | |
| 493 | + makeImage({ id: "image-overflow", name: "overflow.png", previewUrl: "blob:overflow" }), | |
| 494 | + ]); | |
| 495 | + | |
| 496 | + const draft = store.getComposerDraft(threadRef); | |
| 497 | + expect(draft?.images).toHaveLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1); | |
| 498 | + expect(draft?.files.map((file) => file.id)).toEqual(["file-accepted"]); | |
| 499 | + }); | |
| 500 | + | |
| 501 | + it("replaces a needs-reattach marker when the same file is picked again", () => { | |
| 502 | + const store = useComposerDraftStore.getState(); | |
| 503 | + // A hydrated marker: same metadata as the original pick, no bytes and no | |
| 504 | + // server-side upload. | |
| 505 | + const marker: ComposerFileAttachment = { ...makeFile("file-marker"), file: null }; | |
| 506 | + store.addFiles(threadRef, [marker]); | |
| 507 | + expect(store.getComposerDraft(threadRef)?.files.every(composerFileNeedsReattach)).toBe(true); | |
| 508 | + | |
| 509 | + // Following the "Attach again" instruction produces a fresh id with the | |
| 510 | + // exact metadata the dedup key hashes. | |
| 511 | + const repicked = makeFile("file-repicked"); | |
| 512 | + store.addFiles(threadRef, [repicked]); | |
| 513 | + | |
| 514 | + const files = store.getComposerDraft(threadRef)?.files; | |
| 515 | + expect(files?.map((file) => file.id)).toEqual(["file-repicked"]); | |
| 516 | + expect(files?.[0]?.file).not.toBeNull(); | |
| 517 | + expect(files?.some(composerFileNeedsReattach)).toBe(false); | |
| 518 | + }); | |
| 519 | + | |
| 520 | + it("replaces a needs-reattach marker with a stash-restored uploaded file", () => { | |
| 521 | + const store = useComposerDraftStore.getState(); | |
| 522 | + const marker: ComposerFileAttachment = { ...makeFile("file-marker"), file: null }; | |
| 523 | + store.addFiles(threadRef, [marker]); | |
| 524 | + expect(store.getComposerDraft(threadRef)?.files.every(composerFileNeedsReattach)).toBe(true); | |
| 525 | + | |
| 526 | + // A stash restore carries a finished server-side upload instead of bytes. | |
| 527 | + // Matching metadata must replace the marker, not be dropped as a | |
| 528 | + // duplicate: the marker cannot send, and the restored ids are the only | |
| 529 | + // valid copy. | |
| 530 | + const restored: ComposerFileAttachment = { | |
| 531 | + ...makeFile("file-restored"), | |
| 532 | + file: null, | |
| 533 | + uploadedAttachmentId: "pending-stash-pdf", | |
| 534 | + uploadEnvironmentId: TEST_ENVIRONMENT_ID, | |
| 535 | + }; | |
| 536 | + store.addFiles(threadRef, [restored]); | |
| 537 | + | |
| 538 | + const files = store.getComposerDraft(threadRef)?.files; | |
| 539 | + expect(files?.map((file) => file.id)).toEqual(["file-restored"]); | |
| 540 | + expect(files?.[0]?.uploadedAttachmentId).toBe("pending-stash-pdf"); | |
| 541 | + expect(files?.[0]?.uploadEnvironmentId).toBe(TEST_ENVIRONMENT_ID); | |
| 542 | + expect(files?.some(composerFileNeedsReattach)).toBe(false); | |
| 543 | + }); | |
| 544 | + | |
| 545 | + it("still dedupes a re-pick against a file that does not need reattaching", () => { | |
| 546 | + const store = useComposerDraftStore.getState(); | |
| 547 | + store.addFiles(threadRef, [makeFile("file-original")]); | |
| 548 | + | |
| 549 | + store.addFiles(threadRef, [makeFile("file-duplicate")]); | |
| 550 | + | |
| 551 | + expect(store.getComposerDraft(threadRef)?.files.map((file) => file.id)).toEqual([ | |
| 552 | + "file-original", | |
| 553 | + ]); | |
| 554 | + }); | |
| 555 | + | |
| 556 | + it("keeps the remaining file slot available after a duplicate is skipped", () => { | |
| 557 | + const store = useComposerDraftStore.getState(); | |
| 558 | + store.addImages( | |
| 559 | + threadRef, | |
| 560 | + Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 2 }, (_, index) => | |
| 561 | + makeImage({ | |
| 562 | + id: `image-${index}`, | |
| 563 | + name: `image-${index}.png`, | |
| 564 | + previewUrl: `blob:image-${index}`, | |
| 565 | + }), | |
| 566 | + ), | |
| 567 | + ); | |
| 568 | + store.addFiles(threadRef, [makeFile("file-original")]); | |
| 569 | + store.addFiles(threadRef, [ | |
| 570 | + makeFile("file-duplicate"), | |
| 571 | + { ...makeFile("file-unique"), name: "unique.pdf" }, | |
| 572 | + ]); | |
| 573 | + | |
| 574 | + expect(store.getComposerDraft(threadRef)?.files.map((file) => file.id)).toEqual([ | |
| 575 | + "file-original", | |
| 576 | + "file-unique", | |
| 577 | + ]); | |
| 578 | + }); | |
| 579 | +}); | |
| 580 | + | |
| 292 | 581 | describe("composerDraftStore moveComposerPromptAndImages", () => { |
| 293 | 582 | const sourceDraftId = DraftId.make("draft-move-source"); |
| 294 | 583 | const destinationDraftId = DraftId.make("draft-move-destination"); |
| @@ -335,6 +624,111 @@ describe("composerDraftStore moveComposerPromptAndImages", () => { · 40 unmodified lines | ||
| 335 | 624 | expect(draftByKey(destinationDraftId)?.prompt).toBe(" explain this error"); |
| 336 | 625 | }); |
| 337 | 626 | |
| 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 | 732 | it("is a no-op when source and destination are the same target", () => { |
| 339 | 733 | const store = useComposerDraftStore.getState(); |
| 340 | 734 | store.setPrompt(sourceDraftId, "keep me"); |
| @@ -1959,6 +2353,95 @@ describe("composerDraftStore model seed migration", () => { · 1618 unmodified lines | ||
| 1959 | 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 | }); |
| 1963 | 2446 | |
| 1964 | 2447 | describe("composerDraftStore provider-scoped option updates", () => { |
| @@ -2,13 +2,14 @@ import { · 1 unmodified line | ||
| 2 | 2 | DEFAULT_MODEL, |
| 3 | 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 4 | 4 | defaultInstanceIdForDriver, |
| 5 | − type EnvironmentId, | |
| 5 | + EnvironmentId, | |
| 6 | 6 | ModelSelection, |
| 7 | 7 | ProjectId, |
| 8 | 8 | ProviderInstanceId, |
| 9 | 9 | ProviderInteractionMode, |
| 10 | 10 | ProviderDriverKind, |
| 11 | 11 | ProviderOptionSelection, |
| 12 | + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 12 | 13 | PreviewAnnotationPayloadSchema, |
| 13 | 14 | type PreviewAnnotationPayload, |
| 14 | 15 | RuntimeMode, |
| @@ -33,7 +34,12 @@ import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model" · 18 unmodified lines | ||
| 33 | 34 | import { useMemo } from "react"; |
| 34 | 35 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; |
| 35 | 36 | import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; |
| 36 | −import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; | |
| 37 | +import { | |
| 38 | + DEFAULT_INTERACTION_MODE, | |
| 39 | + DEFAULT_RUNTIME_MODE, | |
| 40 | + type ChatFileAttachment, | |
| 41 | + type ChatImageAttachment, | |
| 42 | +} from "./types"; | |
| 37 | 43 | import { |
| 38 | 44 | type TerminalContextDraft, |
| 39 | 45 | ensureInlineTerminalContextPlaceholders, |
| @@ -93,6 +99,49 @@ export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "prev · 53 unmodified lines | ||
| 93 | 99 | file: File; |
| 94 | 100 | } |
| 95 | 101 | |
| 102 | +export interface ComposerFileAttachment extends Omit<ChatFileAttachment, "previewUrl"> { | |
| 103 | + file: File | null; | |
| 104 | + uploadedAttachmentId?: string; | |
| 105 | + uploadEnvironmentId?: EnvironmentId; | |
| 106 | +} | |
| 107 | + | |
| 108 | +/** | |
| 109 | + * A hydrated draft file whose upload never finished before the reload has | |
| 110 | + * neither bytes (`file` is only ever null after hydration) nor a server-side | |
| 111 | + * upload. The composer renders it as a needs-reattach row: the user must | |
| 112 | + * attach the file again or remove it before sending. | |
| 113 | + */ | |
| 114 | +export function composerFileNeedsReattach(file: ComposerFileAttachment): boolean { | |
| 115 | + return file.file === null && file.uploadedAttachmentId === undefined; | |
| 116 | +} | |
| 117 | + | |
| 118 | +export const PersistedComposerFileAttachment = Schema.Struct({ | |
| 119 | + id: Schema.String, | |
| 120 | + name: Schema.String, | |
| 121 | + mimeType: Schema.String, | |
| 122 | + sizeBytes: Schema.Number, | |
| 123 | + attachmentId: Schema.String, | |
| 124 | + environmentId: EnvironmentId, | |
| 125 | +}); | |
| 126 | +export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type; | |
| 127 | + | |
| 128 | +/** | |
| 129 | + * Draft-persisted file. Unlike a stash entry (which requires a finished | |
| 130 | + * upload), a draft may hold a file whose upload never completed. Its `File` | |
| 131 | + * handle cannot serialize, so it persists as a metadata-only marker (no | |
| 132 | + * `attachmentId`) and hydrates as a needs-reattach row instead of vanishing. | |
| 133 | + */ | |
| 134 | +export const PersistedComposerDraftFileAttachment = Schema.Struct({ | |
| 135 | + id: Schema.String, | |
| 136 | + name: Schema.String, | |
| 137 | + mimeType: Schema.String, | |
| 138 | + sizeBytes: Schema.Number, | |
| 139 | + attachmentId: Schema.optionalKey(Schema.String), | |
| 140 | + environmentId: Schema.optionalKey(EnvironmentId), | |
| 141 | +}); | |
| 142 | +export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type; | |
| 143 | +const isPersistedComposerDraftFileAttachment = Schema.is(PersistedComposerDraftFileAttachment); | |
| 144 | + | |
| 96 | 145 | const PersistedTerminalContextDraft = Schema.Struct({ |
| 97 | 146 | id: Schema.String, |
| 98 | 147 | threadId: ThreadId, |
| @@ -129,6 +178,7 @@ type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type; · 30 unmodified lines | ||
| 129 | 178 | const PersistedComposerThreadDraftState = Schema.Struct({ |
| 130 | 179 | prompt: Schema.String, |
| 131 | 180 | attachments: Schema.Array(PersistedComposerImageAttachment), |
| 181 | + files: Schema.optionalKey(Schema.Array(PersistedComposerDraftFileAttachment)), | |
| 132 | 182 | terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), |
| 133 | 183 | elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)), |
| 134 | 184 | previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)), |
| @@ -255,6 +305,7 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ · 120 unmodified lines | ||
| 255 | 305 | export interface ComposerThreadDraftState { |
| 256 | 306 | prompt: string; |
| 257 | 307 | images: ComposerImageAttachment[]; |
| 308 | + files: ComposerFileAttachment[]; | |
| 258 | 309 | nonPersistedImageIds: string[]; |
| 259 | 310 | persistedAttachments: PersistedComposerImageAttachment[]; |
| 260 | 311 | terminalContexts: TerminalContextDraft[]; |
| @@ -305,6 +356,7 @@ export function composerDraftHasUserContent( · 44 unmodified lines | ||
| 305 | 356 | return ( |
| 306 | 357 | draft.prompt.trim().length > 0 || |
| 307 | 358 | draft.images.length > 0 || |
| 359 | + draft.files.length > 0 || | |
| 308 | 360 | draft.persistedAttachments.length > 0 || |
| 309 | 361 | draft.terminalContexts.length > 0 || |
| 310 | 362 | draft.elementContexts.length > 0 || |
| @@ -351,7 +403,7 @@ interface ProjectDraftSession extends DraftSessionState { · 40 unmodified lines | ||
| 351 | 403 | * Raw `ThreadId` is intentionally excluded so callers cannot drop environment |
| 352 | 404 | * identity for real threads. |
| 353 | 405 | */ |
| 354 | −type ComposerThreadTarget = ScopedThreadRef | DraftId; | |
| 406 | +export type ComposerThreadTarget = ScopedThreadRef | DraftId; | |
| 355 | 407 | |
| 356 | 408 | /** |
| 357 | 409 | * Persisted store for composer content plus draft-session metadata. |
| @@ -484,6 +536,20 @@ interface ComposerDraftStoreState { · 126 unmodified lines | ||
| 484 | 536 | addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void; |
| 485 | 537 | addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void; |
| 486 | 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 | 553 | insertTerminalContext: ( |
| 488 | 554 | threadRef: ComposerThreadTarget, |
| 489 | 555 | prompt: string, |
| @@ -534,19 +600,16 @@ interface ComposerDraftStoreState { · 44 unmodified lines | ||
| 534 | 600 | ) => void; |
| 535 | 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 | 604 | * element contexts, preview annotations, and review comments. Used by the |
| 539 | − * prompt stash, which can only round-trip text + images: clearing the | |
| 540 | − * session-bound contexts would destroy state nothing can restore. | |
| 605 | + * prompt stash. Session-bound context stays in the source draft. | |
| 541 | 606 | */ |
| 542 | 607 | clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; |
| 543 | 608 | /** |
| 544 | − * Moves the prompt text and image attachments from one composer target to | |
| 545 | − * another. Used when a draft changes project: the new project gets its own | |
| 546 | − * draft session and the typed content follows it. Session-bound extras | |
| 547 | − * (terminal / element contexts, preview annotations, review comments) stay | |
| 548 | − * on the source — they reference sessions of the source thread that the | |
| 549 | − * destination cannot use. | |
| 609 | + * Moves prompt text and transferable attachments into another composer. | |
| 610 | + * Attachments over the destination limit and uploaded files that belong to | |
| 611 | + * another environment stay in the source draft. Terminal and element | |
| 612 | + * context, preview annotations, and review comments also stay in the source. | |
| 550 | 613 | */ |
| 551 | 614 | moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void; |
| 552 | 615 | } |
| @@ -615,6 +678,7 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftSt · 62 unmodified lines | ||
| 615 | 678 | }); |
| 616 | 679 | |
| 617 | 680 | const EMPTY_IMAGES: ComposerImageAttachment[] = []; |
| 681 | +const EMPTY_FILES: ComposerFileAttachment[] = []; | |
| 618 | 682 | const EMPTY_IDS: string[] = []; |
| 619 | 683 | const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; |
| 620 | 684 | const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; |
| @@ -622,6 +686,7 @@ const EMPTY_ELEMENT_CONTEXTS: ElementContextDraft[] = []; · 1 unmodified line | ||
| 622 | 686 | const EMPTY_PREVIEW_ANNOTATIONS: PreviewAnnotationPayload[] = []; |
| 623 | 687 | const EMPTY_REVIEW_COMMENTS: ReviewCommentContext[] = []; |
| 624 | 688 | Object.freeze(EMPTY_IMAGES); |
| 689 | +Object.freeze(EMPTY_FILES); | |
| 625 | 690 | Object.freeze(EMPTY_IDS); |
| 626 | 691 | Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); |
| 627 | 692 | Object.freeze(EMPTY_ELEMENT_CONTEXTS); |
| @@ -637,6 +702,7 @@ const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>( · 9 unmodified lines | ||
| 637 | 702 | const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({ |
| 638 | 703 | prompt: "", |
| 639 | 704 | images: EMPTY_IMAGES, |
| 705 | + files: EMPTY_FILES, | |
| 640 | 706 | nonPersistedImageIds: EMPTY_IDS, |
| 641 | 707 | persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, |
| 642 | 708 | terminalContexts: EMPTY_TERMINAL_CONTEXTS, |
| @@ -659,6 +725,7 @@ export function createEmptyThreadDraft(): ComposerThreadDraftState { · 16 unmodified lines | ||
| 659 | 725 | return { |
| 660 | 726 | prompt: "", |
| 661 | 727 | images: [], |
| 728 | + files: [], | |
| 662 | 729 | nonPersistedImageIds: [], |
| 663 | 730 | persistedAttachments: [], |
| 664 | 731 | terminalContexts: [], |
| @@ -733,6 +800,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { · 68 unmodified lines | ||
| 733 | 800 | return ( |
| 734 | 801 | draft.prompt.length === 0 && |
| 735 | 802 | draft.images.length === 0 && |
| 803 | + draft.files.length === 0 && | |
| 736 | 804 | draft.persistedAttachments.length === 0 && |
| 737 | 805 | draft.terminalContexts.length === 0 && |
| 738 | 806 | draft.elementContexts.length === 0 && |
| @@ -1263,7 +1331,7 @@ function logicalProjectDraftKey(logicalProjectKey: string): string { · 524 unmodified lines | ||
| 1263 | 1331 | * Draft sessions are keyed by `DraftId`. Real threads are keyed by |
| 1264 | 1332 | * `ScopedThreadRef` so environment identity is always preserved. |
| 1265 | 1333 | */ |
| 1266 | −function composerTargetKey(target: ScopedThreadRef | DraftId): string { | |
| 1334 | +export function composerTargetKey(target: ScopedThreadRef | DraftId): string { | |
| 1267 | 1335 | if (typeof target === "string") { |
| 1268 | 1336 | return target.trim(); |
| 1269 | 1337 | } |
| @@ -1717,6 +1785,9 @@ function normalizePersistedDraftsByThreadId( · 447 unmodified lines | ||
| 1717 | 1785 | return normalized ? [normalized] : []; |
| 1718 | 1786 | }) |
| 1719 | 1787 | : []; |
| 1788 | + const files = Array.isArray(draftCandidate.files) | |
| 1789 | + ? draftCandidate.files.filter(isPersistedComposerDraftFileAttachment) | |
| 1790 | + : []; | |
| 1720 | 1791 | const terminalContexts = Array.isArray(draftCandidate.terminalContexts) |
| 1721 | 1792 | ? draftCandidate.terminalContexts.flatMap((entry) => { |
| 1722 | 1793 | const normalized = normalizePersistedTerminalContextDraft(entry); |
| @@ -1796,6 +1867,7 @@ function normalizePersistedDraftsByThreadId( · 73 unmodified lines | ||
| 1796 | 1867 | if ( |
| 1797 | 1868 | promptCandidate.length === 0 && |
| 1798 | 1869 | attachments.length === 0 && |
| 1870 | + files.length === 0 && | |
| 1799 | 1871 | terminalContexts.length === 0 && |
| 1800 | 1872 | elementContexts.length === 0 && |
| 1801 | 1873 | reviewComments.length === 0 && |
| @@ -1820,6 +1892,7 @@ function normalizePersistedDraftsByThreadId( · 18 unmodified lines | ||
| 1820 | 1892 | nextDraftsByThreadKey[normalizedThreadKey] = { |
| 1821 | 1893 | prompt, |
| 1822 | 1894 | attachments, |
| 1895 | + ...(files.length > 0 ? { files } : {}), | |
| 1823 | 1896 | ...(terminalContexts.length > 0 ? { terminalContexts } : {}), |
| 1824 | 1897 | ...(elementContexts.length > 0 ? { elementContexts } : {}), |
| 1825 | 1898 | ...(reviewComments.length > 0 ? { reviewComments } : {}), |
| @@ -1842,6 +1915,7 @@ function persistedComposerDraftHasUserContent(draft: PersistedComposerThreadDraf · 16 unmodified lines | ||
| 1842 | 1915 | return ( |
| 1843 | 1916 | draft.prompt.trim().length > 0 || |
| 1844 | 1917 | draft.attachments.length > 0 || |
| 1918 | + (draft.files?.length ?? 0) > 0 || | |
| 1845 | 1919 | (draft.terminalContexts?.length ?? 0) > 0 || |
| 1846 | 1920 | (draft.elementContexts?.length ?? 0) > 0 || |
| 1847 | 1921 | (draft.previewAnnotations?.length ?? 0) > 0 || |
| @@ -1925,6 +1999,7 @@ function partializeComposerDraftStoreState( · 77 unmodified lines | ||
| 1925 | 1999 | if ( |
| 1926 | 2000 | draft.prompt.length === 0 && |
| 1927 | 2001 | draft.persistedAttachments.length === 0 && |
| 2002 | + draft.files.length === 0 && | |
| 1928 | 2003 | draft.terminalContexts.length === 0 && |
| 1929 | 2004 | draft.elementContexts.length === 0 && |
| 1930 | 2005 | draft.previewAnnotations.length === 0 && |
| @@ -1938,6 +2013,25 @@ function partializeComposerDraftStoreState( · 7 unmodified lines | ||
| 1938 | 2013 | const persistedDraft: DeepMutable<PersistedComposerThreadDraftState> = { |
| 1939 | 2014 | prompt: draft.prompt, |
| 1940 | 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 | 2035 | ...(draft.terminalContexts.length > 0 |
| 1942 | 2036 | ? { |
| 1943 | 2037 | terminalContexts: draft.terminalContexts.map((context) => ({ |
| @@ -2219,6 +2313,21 @@ function toHydratedThreadDraft( · 275 unmodified lines | ||
| 2219 | 2313 | return { |
| 2220 | 2314 | prompt: persistedDraft.prompt, |
| 2221 | 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 | 2331 | nonPersistedImageIds: [], |
| 2223 | 2332 | persistedAttachments: [...persistedDraft.attachments], |
| 2224 | 2333 | terminalContexts: |
| @@ -3008,6 +3117,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 783 unmodified lines | ||
| 3008 | 3117 | } |
| 3009 | 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 | 3129 | dedupedIncoming.push(image); |
| 3012 | 3130 | existingIds.add(image.id); |
| 3013 | 3131 | existingDedupKeys.add(dedupKey); |
| @@ -3062,6 +3180,160 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 48 unmodified lines | ||
| 3062 | 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 | 3337 | insertTerminalContext: (threadRef, prompt, context, index) => { |
| 3066 | 3338 | const threadKey = resolveComposerDraftKey(get(), threadRef); |
| 3067 | 3339 | const threadId = resolveComposerThreadId(get(), threadRef); |
| @@ -3462,6 +3734,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 394 unmodified lines | ||
| 3462 | 3734 | ...current, |
| 3463 | 3735 | prompt: "", |
| 3464 | 3736 | images: [], |
| 3737 | + files: [], | |
| 3465 | 3738 | nonPersistedImageIds: [], |
| 3466 | 3739 | persistedAttachments: [], |
| 3467 | 3740 | terminalContexts: [], |
| @@ -3495,6 +3768,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 27 unmodified lines | ||
| 3495 | 3768 | ...current, |
| 3496 | 3769 | prompt: ensureInlineTerminalContextPlaceholders("", current.terminalContexts.length), |
| 3497 | 3770 | images: [], |
| 3771 | + files: [], | |
| 3498 | 3772 | nonPersistedImageIds: [], |
| 3499 | 3773 | persistedAttachments: [], |
| 3500 | 3774 | }; |
| @@ -3519,6 +3793,59 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 18 unmodified lines | ||
| 3519 | 3793 | return state; |
| 3520 | 3794 | } |
| 3521 | 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 | 3849 | // Inline placeholders reference the source's terminal contexts, |
| 3523 | 3850 | // which stay behind; re-anchor the moved prompt to whatever |
| 3524 | 3851 | // contexts the destination already holds. |
| @@ -3529,14 +3856,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 4 unmodified lines | ||
| 3529 | 3856 | const nextDestination: ComposerThreadDraftState = { |
| 3530 | 3857 | ...destination, |
| 3531 | 3858 | prompt: movedPrompt, |
| 3532 | − images: [...destination.images, ...source.images], | |
| 3859 | + images: [...destination.images, ...movedImages], | |
| 3860 | + files: [...destination.files, ...movedFiles], | |
| 3533 | 3861 | nonPersistedImageIds: [ |
| 3534 | 3862 | ...destination.nonPersistedImageIds, |
| 3535 | − ...source.nonPersistedImageIds, | |
| 3863 | + ...source.nonPersistedImageIds.filter((imageId) => movedImageIds.has(imageId)), | |
| 3536 | 3864 | ], |
| 3537 | 3865 | persistedAttachments: [ |
| 3538 | 3866 | ...destination.persistedAttachments, |
| 3539 | − ...source.persistedAttachments, | |
| 3867 | + ...source.persistedAttachments.filter((attachment) => | |
| 3868 | + movedImageIds.has(attachment.id), | |
| 3869 | + ), | |
| 3540 | 3870 | ], |
| 3541 | 3871 | }; |
| 3542 | 3872 | // Same clearing shape as clearComposerPromptAndImages, but the |
| @@ -3545,9 +3875,14 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 2 unmodified lines | ||
| 3545 | 3875 | const nextSource: ComposerThreadDraftState = { |
| 3546 | 3876 | ...source, |
| 3547 | 3877 | prompt: ensureInlineTerminalContextPlaceholders("", source.terminalContexts.length), |
| 3548 | − images: [], | |
| 3549 | − nonPersistedImageIds: [], | |
| 3550 | − persistedAttachments: [], | |
| 3878 | + images: retainedImages, | |
| 3879 | + files: retainedFiles, | |
| 3880 | + nonPersistedImageIds: source.nonPersistedImageIds.filter( | |
| 3881 | + (imageId) => !movedImageIds.has(imageId), | |
| 3882 | + ), | |
| 3883 | + persistedAttachments: source.persistedAttachments.filter( | |
| 3884 | + (attachment) => !movedImageIds.has(attachment.id), | |
| 3885 | + ), | |
| 3551 | 3886 | }; |
| 3552 | 3887 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; |
| 3553 | 3888 | if (shouldRemoveDraft(nextSource)) { |
| @@ -33,6 +33,7 @@ import { primaryServerSettingsAtom } from "../state/server"; · 32 unmodified lines | ||
| 33 | 33 | import { resolveThreadRouteTarget } from "../threadRoutes"; |
| 34 | 34 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| 35 | 35 | import { useClientSettings } from "./useSettings"; |
| 36 | +import { toastManager } from "../components/ui/toast"; | |
| 36 | 37 | |
| 37 | 38 | interface NewThreadWorkspaceOptions { |
| 38 | 39 | branch?: string | null; |
| @@ -78,7 +79,7 @@ export function useNewThreadHandler() { · 39 unmodified lines | ||
| 78 | 79 | startFromOrigin?: boolean; |
| 79 | 80 | replace?: boolean; |
| 80 | 81 | /** |
| 81 | − * Move the viewed draft's typed content (prompt + images) into the | |
| 82 | + * Move the viewed draft's typed content and transferable attachments into the | |
| 82 | 83 | * draft this request lands on. Set by the draft repo picker: the |
| 83 | 84 | * user started writing in the wrong project and the text should |
| 84 | 85 | * follow them. Explicit new-thread surfaces leave this unset and |
| @@ -156,6 +157,18 @@ export function useNewThreadHandler() { · 71 unmodified lines | ||
| 156 | 157 | composerDraftHasUserContent(getComposerDraft(carryContentSourceDraftId)) |
| 157 | 158 | ) { |
| 158 | 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 | 174 | const project = projects.find( |
| @@ -1,21 +1,36 @@ | ||
| 1 | −import { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 2 | +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 2 | 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 4 | |
| 4 | −import type { ComposerImageAttachment } from "../composerDraftStore"; | |
| 5 | +import { | |
| 6 | + composerFileNeedsReattach, | |
| 7 | + DraftId, | |
| 8 | + useComposerDraftStore, | |
| 9 | + type ComposerFileAttachment, | |
| 10 | + type ComposerImageAttachment, | |
| 11 | +} from "../composerDraftStore"; | |
| 5 | 12 | |
| 6 | 13 | const mocks = vi.hoisted(() => ({ |
| 14 | + createAssetUrl: vi.fn(), | |
| 7 | 15 | createUploadUrl: Symbol("create-upload-url"), |
| 16 | + executeAtomQuery: vi.fn(), | |
| 8 | 17 | removeUpload: Symbol("remove-upload"), |
| 9 | 18 | runAtomCommand: vi.fn(), |
| 10 | 19 | readPreparedConnection: vi.fn(), |
| 11 | 20 | })); |
| 12 | 21 | |
| 13 | 22 | vi.mock("@t3tools/client-runtime/state/runtime", () => ({ |
| 23 | + executeAtomQuery: mocks.executeAtomQuery, | |
| 14 | 24 | runAtomCommand: mocks.runAtomCommand, |
| 25 | + squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error, | |
| 15 | 26 | })); |
| 16 | 27 | |
| 17 | 28 | vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); |
| 18 | 29 | |
| 30 | +vi.mock("../state/assets", () => ({ | |
| 31 | + assetEnvironment: { createUrl: mocks.createAssetUrl }, | |
| 32 | +})); | |
| 33 | + | |
| 19 | 34 | vi.mock("../state/attachments", () => ({ |
| 20 | 35 | attachmentEnvironment: { |
| 21 | 36 | createUploadUrl: mocks.createUploadUrl, |
| @@ -32,7 +47,9 @@ import { · 10 unmodified lines | ||
| 32 | 47 | getUploadedAttachments, |
| 33 | 48 | readAttachmentUpload, |
| 34 | 49 | releaseAttachmentUpload, |
| 35 | − releaseAttachmentUploads, | |
| 50 | + releaseDraftAttachment, | |
| 51 | + releaseDraftAttachments, | |
| 52 | + releasePersistedAttachmentUpload, | |
| 36 | 53 | retryAttachmentUpload, |
| 37 | 54 | startAttachmentUpload, |
| 38 | 55 | useAttachmentUploadStore, |
| @@ -110,9 +127,27 @@ function makeImage(id: string): ComposerImageAttachment { · 71 unmodified lines | ||
| 110 | 127 | }; |
| 111 | 128 | } |
| 112 | 129 | |
| 130 | +function makeFile(id: string): ComposerFileAttachment { | |
| 131 | + const file = new File([new Uint8Array([1, 2, 3])], `${id}.pdf`, { | |
| 132 | + type: "application/pdf", | |
| 133 | + }); | |
| 134 | + return { | |
| 135 | + type: "file", | |
| 136 | + id, | |
| 137 | + name: file.name, | |
| 138 | + mimeType: file.type, | |
| 139 | + sizeBytes: file.size, | |
| 140 | + file, | |
| 141 | + }; | |
| 142 | +} | |
| 143 | + | |
| 113 | 144 | describe("attachmentUploadQueue", () => { |
| 114 | 145 | beforeEach(() => { |
| 115 | 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 | 151 | mocks.runAtomCommand.mockReset(); |
| 117 | 152 | mocks.readPreparedConnection.mockReset(); |
| 118 | 153 | mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" }); |
| @@ -176,7 +211,7 @@ describe("attachmentUploadQueue", () => { · 57 unmodified lines | ||
| 176 | 211 | }, |
| 177 | 212 | ]); |
| 178 | 213 | |
| 179 | − releaseAttachmentUploads([image]); | |
| 214 | + releaseDraftAttachments([image]); | |
| 180 | 215 | expect(readAttachmentUpload(image.id)).toBeUndefined(); |
| 181 | 216 | expect(mocks.runAtomCommand).toHaveBeenCalledWith( |
| 182 | 217 | expect.anything(), |
| @@ -189,6 +224,543 @@ describe("attachmentUploadQueue", () => { · 6 unmodified lines | ||
| 189 | 224 | ); |
| 190 | 225 | }); |
| 191 | 226 | |
| 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 | 764 | it("retries rejected uploads", async () => { |
| 193 | 765 | const image = makeImage("image-retry"); |
| 194 | 766 | startAttachmentUpload({ environmentId: firstEnvironment, image }); |
| @@ -277,6 +849,90 @@ describe("attachmentUploadQueue", () => { · 82 unmodified lines | ||
| 277 | 849 | }); |
| 278 | 850 | }); |
| 279 | 851 | |
| 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 | 936 | it("does not let stalled uploads block another environment", async () => { |
| 281 | 937 | const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage); |
| 282 | 938 | for (const image of images) { |
| @@ -3,12 +3,25 @@ import { · 2 unmodified lines | ||
| 3 | 3 | type ChatAttachment, |
| 4 | 4 | type EnvironmentId, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 6 | 7 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 7 | −import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; | |
| 8 | +import { | |
| 9 | + deletePendingAttachmentUpload, | |
| 10 | + runAttachmentUploadCycle, | |
| 11 | + verifyPersistedAttachmentUpload, | |
| 12 | + type PersistedAttachmentVerification, | |
| 13 | +} from "@t3tools/client-runtime/state/attachments"; | |
| 8 | 14 | import { create } from "zustand"; |
| 9 | 15 | |
| 10 | −import type { ComposerImageAttachment } from "../composerDraftStore"; | |
| 16 | +import { | |
| 17 | + DraftId, | |
| 18 | + useComposerDraftStore, | |
| 19 | + type ComposerFileAttachment, | |
| 20 | + type ComposerImageAttachment, | |
| 21 | + type ComposerThreadTarget, | |
| 22 | +} from "../composerDraftStore"; | |
| 11 | 23 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 24 | +import { assetEnvironment } from "../state/assets"; | |
| 12 | 25 | import { attachmentEnvironment } from "../state/attachments"; |
| 13 | 26 | import { readPreparedConnection } from "../state/session"; |
| 14 | 27 | import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState"; |
| @@ -25,11 +38,23 @@ export const useAttachmentUploadStore = create<AttachmentUploadStore>(() => ({ · 10 unmodified lines | ||
| 25 | 38 | })); |
| 26 | 39 | |
| 27 | 40 | interface UploadJob { |
| 28 | − readonly image: ComposerImageAttachment; | |
| 41 | + readonly image: ComposerImageAttachment | ComposerFileAttachment; | |
| 29 | 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 | 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 | 55 | readonly settled: Promise<void>; |
| 32 | 56 | resolveSettled: () => void; |
| 57 | + /** Only ids this queue minted itself. Cancel and retry may delete these. */ | |
| 33 | 58 | attachmentId: string | null; |
| 34 | 59 | cancelled: boolean; |
| 35 | 60 | abort: (() => void) | null; |
| @@ -60,25 +85,73 @@ export function readAttachmentUpload(imageId: string): AttachmentUploadState | u · 24 unmodified lines | ||
| 60 | 85 | return useAttachmentUploadStore.getState().uploadsByImageId[imageId]; |
| 61 | 86 | } |
| 62 | 87 | |
| 88 | +/** Finds the file's current same-environment draft after any in-flight move. */ | |
| 89 | +function resolveCurrentFileDraftTarget(job: UploadJob): ComposerThreadTarget | undefined { | |
| 90 | + if (job.draftTarget === undefined || job.image.type !== "file") { | |
| 91 | + return undefined; | |
| 92 | + } | |
| 93 | + const store = useComposerDraftStore.getState(); | |
| 94 | + for (const [key, draft] of Object.entries(store.draftsByThreadKey)) { | |
| 95 | + if (!draft.files.some((file) => file.id === job.image.id)) { | |
| 96 | + continue; | |
| 97 | + } | |
| 98 | + const draftSession = store.draftThreadsByThreadKey[key]; | |
| 99 | + if (draftSession !== undefined) { | |
| 100 | + if (draftSession.environmentId === job.environmentId) { | |
| 101 | + return DraftId.make(key); | |
| 102 | + } | |
| 103 | + continue; | |
| 104 | + } | |
| 105 | + // Tests and legacy callers can use a DraftId without session metadata. | |
| 106 | + // Only its original job supplies enough environment identity to trust it. | |
| 107 | + if (typeof job.draftTarget === "string" && job.draftTarget === key) { | |
| 108 | + return DraftId.make(key); | |
| 109 | + } | |
| 110 | + const threadRef = parseScopedThreadKey(key); | |
| 111 | + if (threadRef?.environmentId === job.environmentId) { | |
| 112 | + return threadRef; | |
| 113 | + } | |
| 114 | + } | |
| 115 | + return undefined; | |
| 116 | +} | |
| 117 | + | |
| 118 | +/** | |
| 119 | + * Persists a finished upload's ids onto the draft that owns the file. The | |
| 120 | + * mounted composer effect performs the same write for live UI updates, but a | |
| 121 | + * background completion (user navigated away, upload finished, reload) must | |
| 122 | + * not depend on a mounted composer to survive. `setFileUpload` no-ops when | |
| 123 | + * the draft row is gone or already carries these ids. | |
| 124 | + */ | |
| 125 | +function stampDraftFileUpload(job: UploadJob, attachmentId: string): void { | |
| 126 | + const draftTarget = resolveCurrentFileDraftTarget(job); | |
| 127 | + if (draftTarget === undefined) { | |
| 128 | + return; | |
| 129 | + } | |
| 130 | + useComposerDraftStore | |
| 131 | + .getState() | |
| 132 | + .setFileUpload(draftTarget, job.image.id, job.environmentId, attachmentId); | |
| 133 | +} | |
| 134 | + | |
| 63 | 135 | function deletePendingUpload(environmentId: EnvironmentId, attachmentId: string): void { |
| 64 | − void runAtomCommand( | |
| 65 | − appAtomRegistry, | |
| 66 | − attachmentEnvironment.remove, | |
| 67 | − { environmentId, input: { attachmentId } }, | |
| 68 | − { reportFailure: false, reportDefect: false }, | |
| 69 | − ); | |
| 136 | + deletePendingAttachmentUpload({ | |
| 137 | + registry: appAtomRegistry, | |
| 138 | + remove: attachmentEnvironment.remove, | |
| 139 | + environmentId, | |
| 140 | + attachmentId, | |
| 141 | + }); | |
| 70 | 142 | } |
| 71 | 143 | |
| 72 | 144 | function uploadBytes(input: { |
| 73 | 145 | readonly url: string; |
| 74 | 146 | readonly file: File; |
| 147 | + readonly mimeType: string; | |
| 75 | 148 | readonly onProgress: (progress: number) => void; |
| 76 | 149 | }): { readonly done: Promise<void>; readonly abort: () => void } { |
| 77 | 150 | const xhr = new XMLHttpRequest(); |
| 78 | 151 | const done = new Promise<void>((resolve, reject) => { |
| 79 | 152 | xhr.open("POST", input.url, true); |
| 80 | 153 | xhr.timeout = UPLOAD_TIMEOUT_MS; |
| 81 | − xhr.setRequestHeader("Content-Type", input.file.type); | |
| 154 | + xhr.setRequestHeader("Content-Type", input.mimeType); | |
| 82 | 155 | xhr.upload.addEventListener("progress", (event) => { |
| 83 | 156 | if (event.lengthComputable && event.total > 0) { |
| 84 | 157 | input.onProgress(event.loaded / event.total); |
| @@ -101,109 +174,162 @@ function uploadBytes(input: { · 16 unmodified lines | ||
| 101 | 174 | } |
| 102 | 175 | |
| 103 | 176 | async function runUpload(job: UploadJob): Promise<void> { |
| 104 | − const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find( | |
| 105 | − (supportedMimeType) => supportedMimeType === job.image.mimeType.toLowerCase(), | |
| 106 | − ); | |
| 107 | − if (!mimeType) { | |
| 108 | − setUploadState(job.image.id, { | |
| 109 | − status: "failed", | |
| 177 | + if (job.persistedAttachmentId) { | |
| 178 | + const verification = await verifyPersistedAttachmentUpload({ | |
| 179 | + registry: appAtomRegistry, | |
| 180 | + createAssetUrl: assetEnvironment.createUrl, | |
| 110 | 181 | environmentId: job.environmentId, |
| 111 | − reason: "Unsupported image type", | |
| 112 | − ...(job.previous ? { previous: job.previous } : {}), | |
| 182 | + attachmentId: job.persistedAttachmentId, | |
| 113 | 183 | }); |
| 114 | − return; | |
| 115 | − } | |
| 116 | − | |
| 117 | − const minted = await runAtomCommand( | |
| 118 | − appAtomRegistry, | |
| 119 | − attachmentEnvironment.createUploadUrl, | |
| 120 | − { | |
| 121 | − environmentId: job.environmentId, | |
| 122 | − input: { | |
| 123 | − name: job.image.name, | |
| 124 | − mimeType, | |
| 125 | − sizeBytes: job.image.file.size, | |
| 126 | − }, | |
| 127 | − }, | |
| 128 | − { reportFailure: false }, | |
| 129 | − ); | |
| 130 | − if (job.cancelled) { | |
| 131 | − if (minted._tag === "Success") { | |
| 132 | − deletePendingUpload(job.environmentId, minted.value.attachmentId); | |
| 184 | + if (job.cancelled) { | |
| 185 | + return; | |
| 186 | + } | |
| 187 | + if (verification.status === "verified") { | |
| 188 | + setUploadState(job.image.id, { | |
| 189 | + status: "ready", | |
| 190 | + environmentId: job.environmentId, | |
| 191 | + attachmentId: job.persistedAttachmentId, | |
| 192 | + }); | |
| 193 | + stampDraftFileUpload(job, job.persistedAttachmentId); | |
| 194 | + return; | |
| 195 | + } | |
| 196 | + if (verification.status === "missing" && !job.image.file && job.image.type === "file") { | |
| 197 | + const draftTarget = resolveCurrentFileDraftTarget(job); | |
| 198 | + if ( | |
| 199 | + draftTarget !== undefined && | |
| 200 | + useComposerDraftStore | |
| 201 | + .getState() | |
| 202 | + .markFileUploadMissing( | |
| 203 | + draftTarget, | |
| 204 | + job.image.id, | |
| 205 | + job.environmentId, | |
| 206 | + job.persistedAttachmentId, | |
| 207 | + ) | |
| 208 | + ) { | |
| 209 | + clearUploadState(job.image.id); | |
| 210 | + return; | |
| 211 | + } | |
| 212 | + } | |
| 213 | + if (verification.status === "failed" || !job.image.file) { | |
| 214 | + // No `attachmentId` here: a failed state's id marks a pending upload | |
| 215 | + // this queue minted, which retry and release then delete. The persisted | |
| 216 | + // id is the only server copy of a hydrated file, so a transient | |
| 217 | + // verification failure must leave it in place for the next retry. | |
| 218 | + setUploadState(job.image.id, { | |
| 219 | + status: "failed", | |
| 220 | + environmentId: job.environmentId, | |
| 221 | + reason: | |
| 222 | + verification.status === "missing" | |
| 223 | + ? "Uploaded file expired. Remove it and attach it again." | |
| 224 | + : "Uploaded file could not be verified. Retry when the server reconnects.", | |
| 225 | + ...(job.previous ? { previous: job.previous } : {}), | |
| 226 | + }); | |
| 227 | + return; | |
| 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 | 238 | setUploadState(job.image.id, { |
| 138 | 239 | status: "failed", |
| 139 | 240 | environmentId: job.environmentId, |
| 140 | − reason: "Upload could not start", | |
| 241 | + reason: "Unsupported image type", | |
| 141 | 242 | ...(job.previous ? { previous: job.previous } : {}), |
| 142 | 243 | }); |
| 143 | 244 | return; |
| 144 | 245 | } |
| 145 | − job.attachmentId = minted.value.attachmentId; | |
| 146 | − | |
| 147 | − const connection = readPreparedConnection(job.environmentId); | |
| 148 | − const url = connection ? resolveAssetUrl(connection.httpBaseUrl, minted.value.relativeUrl) : null; | |
| 149 | − if (!url) { | |
| 246 | + const file = job.image.file; | |
| 247 | + if (!file) { | |
| 150 | 248 | setUploadState(job.image.id, { |
| 151 | 249 | status: "failed", |
| 152 | 250 | environmentId: job.environmentId, |
| 153 | − reason: "Not connected", | |
| 154 | − attachmentId: minted.value.attachmentId, | |
| 251 | + reason: "Original file is no longer available", | |
| 155 | 252 | ...(job.previous ? { previous: job.previous } : {}), |
| 156 | 253 | }); |
| 157 | 254 | return; |
| 158 | 255 | } |
| 159 | 256 | |
| 160 | 257 | let lastStep = -1; |
| 161 | − const upload = uploadBytes({ | |
| 162 | − url, | |
| 163 | − file: job.image.file, | |
| 164 | − onProgress: (progress) => { | |
| 165 | − const step = Math.floor(progress * 20); | |
| 166 | − if (step === lastStep || job.cancelled) { | |
| 167 | − return; | |
| 258 | + const result = await runAttachmentUploadCycle({ | |
| 259 | + registry: appAtomRegistry, | |
| 260 | + createUploadUrl: attachmentEnvironment.createUploadUrl, | |
| 261 | + remove: attachmentEnvironment.remove, | |
| 262 | + environmentId: job.environmentId, | |
| 263 | + upload: { | |
| 264 | + ...(job.image.type === "file" ? { type: "file" as const } : {}), | |
| 265 | + name: job.image.name, | |
| 266 | + mimeType, | |
| 267 | + sizeBytes: file.size, | |
| 268 | + }, | |
| 269 | + resolveUploadUrl: (relativeUrl) => { | |
| 270 | + const connection = readPreparedConnection(job.environmentId); | |
| 271 | + return connection ? resolveAssetUrl(connection.httpBaseUrl, relativeUrl) : null; | |
| 272 | + }, | |
| 273 | + transport: (url) => | |
| 274 | + uploadBytes({ | |
| 275 | + url, | |
| 276 | + file, | |
| 277 | + mimeType, | |
| 278 | + onProgress: (progress) => { | |
| 279 | + const step = Math.floor(progress * 20); | |
| 280 | + if (step === lastStep || job.cancelled) { | |
| 281 | + return; | |
| 282 | + } | |
| 283 | + lastStep = step; | |
| 284 | + setUploadState(job.image.id, { | |
| 285 | + status: "uploading", | |
| 286 | + environmentId: job.environmentId, | |
| 287 | + progress, | |
| 288 | + ...(job.previous ? { previous: job.previous } : {}), | |
| 289 | + }); | |
| 290 | + }, | |
| 291 | + }), | |
| 292 | + onMinted: (attachmentId) => { | |
| 293 | + if (job.cancelled) { | |
| 294 | + return "cancel"; | |
| 168 | 295 | } |
| 169 | − lastStep = step; | |
| 170 | − setUploadState(job.image.id, { | |
| 171 | − status: "uploading", | |
| 172 | − environmentId: job.environmentId, | |
| 173 | − progress, | |
| 174 | − ...(job.previous ? { previous: job.previous } : {}), | |
| 175 | − }); | |
| 296 | + job.attachmentId = attachmentId; | |
| 297 | + return "continue"; | |
| 298 | + }, | |
| 299 | + onTransferStart: (abort) => { | |
| 300 | + job.abort = abort; | |
| 176 | 301 | }, |
| 177 | 302 | }); |
| 178 | − job.abort = upload.abort; | |
| 179 | − | |
| 180 | − try { | |
| 181 | − await upload.done; | |
| 182 | − if (job.cancelled) { | |
| 183 | − return; | |
| 184 | − } | |
| 303 | + job.abort = null; | |
| 304 | + if (result.status === "cancelled" || job.cancelled) { | |
| 305 | + return; | |
| 306 | + } | |
| 307 | + if (result.status === "uploaded") { | |
| 185 | 308 | setUploadState(job.image.id, { |
| 186 | 309 | status: "ready", |
| 187 | 310 | environmentId: job.environmentId, |
| 188 | − attachmentId: minted.value.attachmentId, | |
| 311 | + attachmentId: result.attachmentId, | |
| 189 | 312 | }); |
| 313 | + stampDraftFileUpload(job, result.attachmentId); | |
| 190 | 314 | if (job.previous) { |
| 191 | 315 | deletePendingUpload(job.previous.environmentId, job.previous.attachmentId); |
| 192 | 316 | } |
| 193 | − } catch (error) { | |
| 194 | − if (job.cancelled) { | |
| 195 | − return; | |
| 196 | − } | |
| 197 | − setUploadState(job.image.id, { | |
| 198 | − status: "failed", | |
| 199 | − environmentId: job.environmentId, | |
| 200 | − reason: error instanceof Error ? error.message : "Upload failed", | |
| 201 | − attachmentId: minted.value.attachmentId, | |
| 202 | − ...(job.previous ? { previous: job.previous } : {}), | |
| 203 | − }); | |
| 204 | − } finally { | |
| 205 | − job.abort = null; | |
| 317 | + return; | |
| 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 | } |
| 208 | 334 | |
| 209 | 335 | function pumpUploads(): void { |
| @@ -249,7 +375,9 @@ function pumpUploads(): void { · 39 unmodified lines | ||
| 249 | 375 | |
| 250 | 376 | export function startAttachmentUpload(input: { |
| 251 | 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 | 381 | }): void { |
| 254 | 382 | const existingJob = jobsByImageId.get(input.image.id); |
| 255 | 383 | if (existingJob?.environmentId === input.environmentId) { |
| @@ -287,7 +415,13 @@ export function startAttachmentUpload(input: { · 31 unmodified lines | ||
| 287 | 415 | const job: UploadJob = { |
| 288 | 416 | image: input.image, |
| 289 | 417 | environmentId: input.environmentId, |
| 418 | + ...(input.draftTarget !== undefined ? { draftTarget: input.draftTarget } : {}), | |
| 290 | 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 | 425 | settled, |
| 292 | 426 | resolveSettled, |
| 293 | 427 | attachmentId: null, |
| @@ -306,6 +440,11 @@ export function startAttachmentUpload(input: { · 12 unmodified lines | ||
| 306 | 440 | pumpUploads(); |
| 307 | 441 | } |
| 308 | 442 | |
| 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 | + */ | |
| 309 | 448 | export function cancelAttachmentUpload(imageId: string): void { |
| 310 | 449 | const job = jobsByImageId.get(imageId); |
| 311 | 450 | if (!job) { |
| @@ -340,12 +479,42 @@ export function releaseAttachmentUpload(imageId: string): void { · 28 unmodified lines | ||
| 340 | 479 | clearUploadState(imageId); |
| 341 | 480 | } |
| 342 | 481 | |
| 482 | +export function releasePersistedAttachmentUpload(input: { | |
| 483 | + readonly id: string; | |
| 484 | + readonly environmentId: EnvironmentId; | |
| 485 | + readonly attachmentId: string; | |
| 486 | +}): void { | |
| 487 | + const upload = readAttachmentUpload(input.id); | |
| 488 | + if ( | |
| 489 | + upload?.status === "ready" && | |
| 490 | + upload.environmentId === input.environmentId && | |
| 491 | + upload.attachmentId === input.attachmentId | |
| 492 | + ) { | |
| 493 | + releaseAttachmentUpload(input.id); | |
| 494 | + return; | |
| 495 | + } | |
| 496 | + const job = jobsByImageId.get(input.id); | |
| 497 | + if ( | |
| 498 | + job?.environmentId === input.environmentId && | |
| 499 | + job.persistedAttachmentId === input.attachmentId | |
| 500 | + ) { | |
| 501 | + // Tears down the in-flight verification or re-upload. The queue only | |
| 502 | + // deletes ids it minted, so the persisted id still needs the delete below. | |
| 503 | + releaseAttachmentUpload(input.id); | |
| 504 | + } | |
| 505 | + deletePendingUpload(input.environmentId, input.attachmentId); | |
| 506 | +} | |
| 507 | + | |
| 343 | 508 | export function retryAttachmentUpload(input: { |
| 344 | 509 | readonly environmentId: EnvironmentId; |
| 345 | − readonly image: ComposerImageAttachment; | |
| 510 | + readonly image: ComposerImageAttachment | ComposerFileAttachment; | |
| 511 | + readonly draftTarget?: ComposerThreadTarget; | |
| 346 | 512 | }): void { |
| 347 | 513 | const previous = readAttachmentUpload(input.image.id); |
| 348 | 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 | 518 | if (previous?.status === "failed" && previous.attachmentId) { |
| 350 | 519 | deletePendingUpload(previous.environmentId, previous.attachmentId); |
| 351 | 520 | } |
| @@ -357,13 +526,30 @@ export function retryAttachmentUpload(input: { · 5 unmodified lines | ||
| 357 | 526 | startAttachmentUpload(input); |
| 358 | 527 | } |
| 359 | 528 | |
| 529 | +/** | |
| 530 | + * Checks that a stashed upload still exists on the server. Pending uploads | |
| 531 | + * are swept after 24 hours, so a stash restore asks first instead of handing | |
| 532 | + * the composer a dead reference. | |
| 533 | + */ | |
| 534 | +export function verifyStashedAttachmentUpload(input: { | |
| 535 | + readonly environmentId: EnvironmentId; | |
| 536 | + readonly attachmentId: string; | |
| 537 | +}): Promise<PersistedAttachmentVerification> { | |
| 538 | + return verifyPersistedAttachmentUpload({ | |
| 539 | + registry: appAtomRegistry, | |
| 540 | + createAssetUrl: assetEnvironment.createUrl, | |
| 541 | + environmentId: input.environmentId, | |
| 542 | + attachmentId: input.attachmentId, | |
| 543 | + }); | |
| 544 | +} | |
| 545 | + | |
| 360 | 546 | export async function awaitAttachmentUploads(imageIds: ReadonlyArray<string>): Promise<void> { |
| 361 | 547 | await Promise.all(imageIds.map((imageId) => jobsByImageId.get(imageId)?.settled)); |
| 362 | 548 | } |
| 363 | 549 | |
| 364 | 550 | export function getUploadedAttachments(input: { |
| 365 | 551 | readonly environmentId: EnvironmentId; |
| 366 | − readonly images: ReadonlyArray<ComposerImageAttachment>; | |
| 552 | + readonly images: ReadonlyArray<ComposerImageAttachment | ComposerFileAttachment>; | |
| 367 | 553 | }): ChatAttachment[] | null { |
| 368 | 554 | const attachments: ChatAttachment[] = []; |
| 369 | 555 | for (const image of input.images) { |
| @@ -372,7 +558,7 @@ export function getUploadedAttachments(input: { · 2 unmodified lines | ||
| 372 | 558 | return null; |
| 373 | 559 | } |
| 374 | 560 | attachments.push({ |
| 375 | − type: "image", | |
| 561 | + type: image.type, | |
| 376 | 562 | id: upload.attachmentId, |
| 377 | 563 | name: image.name, |
| 378 | 564 | mimeType: image.mimeType, |
| @@ -382,8 +568,42 @@ export function getUploadedAttachments(input: { · 3 unmodified lines | ||
| 382 | 568 | return attachments; |
| 383 | 569 | } |
| 384 | 570 | |
| 385 | −export function releaseAttachmentUploads(images: ReadonlyArray<ComposerImageAttachment>): void { | |
| 386 | − for (const image of images) { | |
| 387 | − releaseAttachmentUpload(image.id); | |
| 571 | +/** | |
| 572 | + * The one owner for discarding a draft attachment's server-side upload. The | |
| 573 | + * queue-keyed release only sees in-memory state, so after a reload it finds | |
| 574 | + * nothing and the pending upload leaks. When the draft carries a persisted | |
| 575 | + * `uploadedAttachmentId` (which survives reloads), route through the persisted | |
| 576 | + * release; it still prefers the queue path when the queue owns that same | |
| 577 | + * attachment. Every draft discard path must funnel through here. | |
| 578 | + */ | |
| 579 | +export function releaseDraftAttachment( | |
| 580 | + attachment: ComposerImageAttachment | ComposerFileAttachment, | |
| 581 | +): void { | |
| 582 | + if ( | |
| 583 | + attachment.type === "file" && | |
| 584 | + attachment.uploadedAttachmentId !== undefined && | |
| 585 | + attachment.uploadEnvironmentId !== undefined | |
| 586 | + ) { | |
| 587 | + releasePersistedAttachmentUpload({ | |
| 588 | + id: attachment.id, | |
| 589 | + environmentId: attachment.uploadEnvironmentId, | |
| 590 | + attachmentId: attachment.uploadedAttachmentId, | |
| 591 | + }); | |
| 592 | + // A failed re-upload after verification can hold a newer minted | |
| 593 | + // attachment under the queue key. Release whatever is left so neither | |
| 594 | + // copy stays behind. (The pending delete is idempotent server-side.) | |
| 595 | + if (jobsByImageId.has(attachment.id) || readAttachmentUpload(attachment.id)) { | |
| 596 | + releaseAttachmentUpload(attachment.id); | |
| 597 | + } | |
| 598 | + return; | |
| 599 | + } | |
| 600 | + releaseAttachmentUpload(attachment.id); | |
| 601 | +} | |
| 602 | + | |
| 603 | +export function releaseDraftAttachments( | |
| 604 | + attachments: ReadonlyArray<ComposerImageAttachment | ComposerFileAttachment>, | |
| 605 | +): void { | |
| 606 | + for (const attachment of attachments) { | |
| 607 | + releaseDraftAttachment(attachment); | |
| 388 | 608 | } |
| 389 | 609 | } |
| @@ -34,7 +34,7 @@ describe("attachmentUploadBlockReason", () => { · 33 unmodified lines | ||
| 34 | 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 | }); |
| 39 | 39 | |
| 40 | 40 | it("asks the user to retry or remove failed uploads", () => { |
| @@ -46,7 +46,7 @@ describe("attachmentUploadBlockReason", () => { · 5 unmodified lines | ||
| 46 | 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 | }); |
| 51 | 51 | |
| 52 | 52 | it("does not accept an upload from another environment", () => { |
| @@ -62,7 +62,7 @@ describe("attachmentUploadBlockReason", () => { · 9 unmodified lines | ||
| 62 | 62 | }, |
| 63 | 63 | }, |
| 64 | 64 | }), |
| 65 | − ).toBe("Image still uploading"); | |
| 65 | + ).toBe("Attachment still uploading"); | |
| 66 | 66 | }); |
| 67 | 67 | }); |
| 68 | 68 | |
| @@ -18,6 +18,11 @@ export type AttachmentUploadState = · 17 unmodified lines | ||
| 18 | 18 | readonly status: "failed"; |
| 19 | 19 | readonly environmentId: EnvironmentId; |
| 20 | 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 | 26 | readonly attachmentId?: string; |
| 22 | 27 | readonly previous?: ReadyAttachmentUpload; |
| 23 | 28 | }; |
| @@ -40,10 +45,12 @@ export function attachmentUploadBlockReason(input: { · 16 unmodified lines | ||
| 40 | 45 | } |
| 41 | 46 | |
| 42 | 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 | 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 | 55 | return null; |
| 49 | 56 | } |
| @@ -1,23 +1,40 @@ | ||
| 1 | 1 | import type { ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 2 | 3 | |
| 3 | 4 | import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; |
| 4 | −import { releaseAttachmentUploads } from "./attachmentUploadQueue"; | |
| 5 | +import { releaseDraftAttachments } from "./attachmentUploadQueue"; | |
| 5 | 6 | |
| 6 | 7 | export function releaseComposerDraftUploads(target: ScopedThreadRef | DraftId): void { |
| 7 | 8 | const draft = useComposerDraftStore.getState().getComposerDraft(target); |
| 8 | 9 | if (draft) { |
| 9 | − releaseAttachmentUploads(draft.images); | |
| 10 | + releaseDraftAttachments([...draft.images, ...draft.files]); | |
| 10 | 11 | } |
| 11 | 12 | } |
| 12 | 13 | |
| 13 | −export function releaseProjectDraftUploads(projectRef: ScopedProjectRef): void { | |
| 14 | +/** | |
| 15 | + * Releases every upload a deleted project's drafts still hold. Draft-thread | |
| 16 | + * sessions carry their project ref, but drafts on the project's real threads | |
| 17 | + * live in `draftsByThreadKey` under scoped thread keys with no project in the | |
| 18 | + * key, so the caller passes the project's thread refs alongside. | |
| 19 | + */ | |
| 20 | +export function releaseProjectDraftUploads( | |
| 21 | + projectRef: ScopedProjectRef, | |
| 22 | + projectThreadRefs: ReadonlyArray<ScopedThreadRef> = [], | |
| 23 | +): void { | |
| 14 | 24 | const store = useComposerDraftStore.getState(); |
| 15 | 25 | for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) { |
| 16 | 26 | if ( |
| 17 | 27 | session.environmentId === projectRef.environmentId && |
| 18 | 28 | 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 | } |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; |
| 2 | +import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 3 | |
| 3 | 4 | import { removeLocalStorageItem } from "./hooks/useLocalStorage"; |
| 4 | 5 | |
| @@ -162,6 +163,62 @@ describe("promptStashStore", () => { · 157 unmodified lines | ||
| 162 | 163 | expect(entry?.pendingImageCount).toBe(0); |
| 163 | 164 | }); |
| 164 | 165 | |
| 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 | 222 | it("finalizeEntryImages reports false when the entry was already taken", () => { |
| 166 | 223 | const store = usePromptStashStore.getState(); |
| 167 | 224 | store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 }); |
| @@ -1,7 +1,10 @@ | ||
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | import { create } from "zustand"; |
| 3 | 3 | |
| 4 | −import { PersistedComposerImageAttachment } from "./composerDraftStore"; | |
| 4 | +import { | |
| 5 | + PersistedComposerFileAttachment, | |
| 6 | + PersistedComposerImageAttachment, | |
| 7 | +} from "./composerDraftStore"; | |
| 5 | 8 | import { createMemoryStorage, type StateStorage } from "./lib/storage"; |
| 6 | 9 | |
| 7 | 10 | export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2"; |
| @@ -27,16 +30,15 @@ export const MAX_STASH_ENTRIES = 20; · 19 unmodified lines | ||
| 27 | 30 | export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000; |
| 28 | 31 | |
| 29 | 32 | /** |
| 30 | − * A stashed prompt carries only what every provider can accept: text and | |
| 31 | − * image attachments. Deliberately no provider instance or model selection — | |
| 32 | − * the point of stashing is to move a prompt into a different thread or | |
| 33 | − * provider, so restoring must never drag the old model choice along. | |
| 33 | + * Stashed files keep signed-upload references instead of storing their bytes. | |
| 34 | + * Image payloads remain subject to the localStorage budget. | |
| 34 | 35 | */ |
| 35 | 36 | const StashEntrySchema = Schema.Struct({ |
| 36 | 37 | id: Schema.String, |
| 37 | 38 | createdAt: Schema.String, |
| 38 | 39 | prompt: Schema.String, |
| 39 | 40 | attachments: Schema.Array(PersistedComposerImageAttachment), |
| 41 | + files: Schema.optionalKey(Schema.Array(PersistedComposerFileAttachment)), | |
| 40 | 42 | /** Names of images that exceeded the attachment budget and were not saved. */ |
| 41 | 43 | droppedImageNames: Schema.Array(Schema.String), |
| 42 | 44 | /** |
| @@ -1,15 +1,5 @@ | ||
| 1 | −import { WS_METHODS } from "@t3tools/contracts"; | |
| 2 | −import { createEnvironmentRpcCommand } from "@t3tools/client-runtime/state/runtime"; | |
| 1 | +import { createAttachmentEnvironmentAtoms } from "@t3tools/client-runtime/state/attachments"; | |
| 3 | 2 | |
| 4 | 3 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 5 | 4 | |
| 6 | −export const attachmentEnvironment = { | |
| 7 | − createUploadUrl: createEnvironmentRpcCommand(connectionAtomRuntime, { | |
| 8 | − label: "environment-command:attachments:create-upload-url", | |
| 9 | − tag: WS_METHODS.attachmentsCreateUploadUrl, | |
| 10 | − }), | |
| 11 | − remove: createEnvironmentRpcCommand(connectionAtomRuntime, { | |
| 12 | − label: "environment-command:attachments:delete", | |
| 13 | − tag: WS_METHODS.attachmentsDelete, | |
| 14 | − }), | |
| 15 | −}; | |
| 5 | +export const attachmentEnvironment = createAttachmentEnvironmentAtoms(connectionAtomRuntime); | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { |
| 2 | − ChatAttachment as ContractChatAttachment, | |
| 2 | + ChatFileAttachment as ContractChatFileAttachment, | |
| 3 | 3 | ChatImageAttachment as ContractChatImageAttachment, |
| 4 | + ChatUnknownAttachment as ContractChatUnknownAttachment, | |
| 4 | 5 | OrchestrationCheckpointFile, |
| 5 | 6 | OrchestrationCheckpointSummary, |
| 6 | 7 | OrchestrationLatestTurn, |
| @@ -36,18 +37,28 @@ export interface ChatImageAttachment extends ContractChatImageAttachment { · 29 unmodified lines | ||
| 36 | 37 | readonly previewUrl?: string; |
| 37 | 38 | } |
| 38 | 39 | |
| 39 | −// Non-image members pass through with the contract shape. The web UI renders | |
| 40 | −// them once it grows file support; until then they only need to typecheck. | |
| 41 | −export type ChatAttachment = | |
| 42 | − | ChatImageAttachment | |
| 43 | − | Exclude<ContractChatAttachment, ContractChatImageAttachment>; | |
| 40 | +export interface ChatFileAttachment extends ContractChatFileAttachment { | |
| 41 | + readonly previewUrl?: string; | |
| 42 | + readonly downloadable?: boolean; | |
| 43 | +} | |
| 44 | + | |
| 45 | +// Attachment types this build does not know pass through with the contract | |
| 46 | +// shape. The UI renders them as inert rows so a newer server cannot crash an | |
| 47 | +// older client. | |
| 48 | +export type ChatUnknownAttachment = ContractChatUnknownAttachment; | |
| 49 | + | |
| 50 | +export type ChatAttachment = ChatImageAttachment | ChatFileAttachment | ChatUnknownAttachment; | |
| 44 | 51 | |
| 45 | 52 | // 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. | |
| 47 | 54 | export function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment { |
| 48 | 55 | return attachment.type === "image"; |
| 49 | 56 | } |
| 50 | 57 | |
| 58 | +export function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment { | |
| 59 | + return attachment.type === "file"; | |
| 60 | +} | |
| 61 | + | |
| 51 | 62 | export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> { |
| 52 | 63 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; |
| 53 | 64 | } |
| @@ -4,8 +4,17 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee · 3 unmodified lines | ||
| 4 | 4 | composer and shows how many characters need to be removed. Shorten the draft or split it into |
| 5 | 5 | multiple messages, then send again in the same thread. |
| 6 | 6 | |
| 7 | −On servers that support direct uploads, images upload as soon as you add them. The send button | |
| 8 | −becomes available after every upload finishes. Failed uploads can be retried or removed. | |
| 7 | +You can attach images up to 10 MB. On servers that support file uploads, web and desktop can also | |
| 8 | +attach text files, PDFs, ZIP archives, and other files. Each file can be up to the limit advertised | |
| 9 | +by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files | |
| 10 | +upload directly to the environment, where your agent can read, copy, or edit them by their file path. | |
| 11 | + | |
| 12 | +On web and desktop, attachments upload as soon as you add them. The send button becomes available | |
| 13 | +after every upload finishes. Failed uploads can be retried or removed. On mobile, attachments are | |
| 14 | +currently limited to images. | |
| 15 | + | |
| 16 | +If you reload before a file finishes uploading, the draft keeps the file's name and shows **Attach | |
| 17 | +again** next to it. Attach the file again or remove it, then send. | |
| 9 | 18 | |
| 10 | 19 | On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into |
| 11 | 20 | the composer or paste them into a message. |
| @@ -14,6 +23,15 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such · 2 unmodified lines | ||
| 14 | 23 | GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list |
| 15 | 24 | when starting a thread or changing an existing thread's model. |
| 16 | 25 | |
| 26 | +## Prompt stash | |
| 27 | + | |
| 28 | +Use the default shortcut, `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux, to stash the current | |
| 29 | +prompt and its attachments after all file uploads finish. Restore the entry later from the stash | |
| 30 | +menu. Stashes that contain files must be restored in the environment where those files were | |
| 31 | +uploaded. Stashed files stay uploaded on the server for 24 hours. If you restore an entry after | |
| 32 | +that, the file comes back with **Attach again** next to it. Attach the file again or remove it, then | |
| 33 | +send. | |
| 34 | + | |
| 17 | 35 | ## Commands and skills |
| 18 | 36 | |
| 19 | 37 | Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source, |
| @@ -55,6 +55,10 @@ · 54 unmodified lines | ||
| 55 | 55 | "types": "./src/state/assets.ts", |
| 56 | 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 | 62 | "./state/connections": { |
| 59 | 63 | "types": "./src/state/connections.ts", |
| 60 | 64 | "default": "./src/state/connections.ts" |
| @@ -0,0 +1,202 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { | |
| 3 | + EnvironmentId, | |
| 4 | + PROVIDER_SEND_TURN_MAX_FILE_BYTES, | |
| 5 | + type AttachmentCreateUploadUrlInput, | |
| 6 | + type AttachmentCreateUploadUrlResult, | |
| 7 | + type AttachmentDeleteInput, | |
| 8 | +} from "@t3tools/contracts"; | |
| 9 | +import * as Effect from "effect/Effect"; | |
| 10 | +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | |
| 11 | + | |
| 12 | +import type { AtomCommand } from "./runtime.ts"; | |
| 13 | +import { | |
| 14 | + clampFileAttachmentUploadBytes, | |
| 15 | + fileAttachmentTooLargeMessage, | |
| 16 | + formatAttachmentSize, | |
| 17 | + runAttachmentUploadCycle, | |
| 18 | + verifyPersistedAttachmentUpload, | |
| 19 | +} from "./attachments.ts"; | |
| 20 | + | |
| 21 | +const environmentId = EnvironmentId.make("environment-1"); | |
| 22 | +// The cycle threads the registry through to the commands untouched, so the | |
| 23 | +// fakes below can ignore it. | |
| 24 | +const registry = {} as AtomRegistry.AtomRegistry; | |
| 25 | + | |
| 26 | +type CreateUploadUrlCommand = AtomCommand< | |
| 27 | + { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput }, | |
| 28 | + AttachmentCreateUploadUrlResult, | |
| 29 | + never | |
| 30 | +>; | |
| 31 | + | |
| 32 | +type RemoveCommand = AtomCommand< | |
| 33 | + { readonly environmentId: EnvironmentId; readonly input: AttachmentDeleteInput }, | |
| 34 | + unknown, | |
| 35 | + never | |
| 36 | +>; | |
| 37 | + | |
| 38 | +function makeCreateUploadUrl(attachmentId: string): CreateUploadUrlCommand { | |
| 39 | + return { | |
| 40 | + label: "test:create-upload-url", | |
| 41 | + run: async () => | |
| 42 | + AsyncResult.success<AttachmentCreateUploadUrlResult, never>({ | |
| 43 | + attachmentId, | |
| 44 | + relativeUrl: `/api/attachments/upload/${attachmentId}`, | |
| 45 | + expiresAt: 1, | |
| 46 | + }), | |
| 47 | + }; | |
| 48 | +} | |
| 49 | + | |
| 50 | +const removeCalls: string[] = []; | |
| 51 | +const remove: RemoveCommand = { | |
| 52 | + label: "test:remove", | |
| 53 | + run: async (_registry, input) => { | |
| 54 | + removeCalls.push(input.input.attachmentId); | |
| 55 | + return AsyncResult.success(undefined); | |
| 56 | + }, | |
| 57 | +}; | |
| 58 | + | |
| 59 | +const uploadInput: AttachmentCreateUploadUrlInput = { | |
| 60 | + type: "file", | |
| 61 | + name: "report.pdf", | |
| 62 | + mimeType: "application/pdf", | |
| 63 | + sizeBytes: 3, | |
| 64 | +}; | |
| 65 | + | |
| 66 | +describe("runAttachmentUploadCycle", () => { | |
| 67 | + it("mints, transfers, and reports the attachment id", async () => { | |
| 68 | + const transferred: string[] = []; | |
| 69 | + const result = await runAttachmentUploadCycle({ | |
| 70 | + registry, | |
| 71 | + createUploadUrl: makeCreateUploadUrl("pending-1"), | |
| 72 | + remove, | |
| 73 | + environmentId, | |
| 74 | + upload: uploadInput, | |
| 75 | + resolveUploadUrl: (relativeUrl) => `https://environment.test${relativeUrl}`, | |
| 76 | + transport: (url) => { | |
| 77 | + transferred.push(url); | |
| 78 | + return { done: Promise.resolve(), abort: () => {} }; | |
| 79 | + }, | |
| 80 | + }); | |
| 81 | + | |
| 82 | + expect(result).toEqual({ status: "uploaded", attachmentId: "pending-1" }); | |
| 83 | + expect(transferred).toEqual(["https://environment.test/api/attachments/upload/pending-1"]); | |
| 84 | + }); | |
| 85 | + | |
| 86 | + it("deletes the fresh mint when the caller cancels at onMinted", async () => { | |
| 87 | + removeCalls.length = 0; | |
| 88 | + const result = await runAttachmentUploadCycle({ | |
| 89 | + registry, | |
| 90 | + createUploadUrl: makeCreateUploadUrl("pending-cancelled"), | |
| 91 | + remove, | |
| 92 | + environmentId, | |
| 93 | + upload: uploadInput, | |
| 94 | + resolveUploadUrl: () => "https://environment.test/upload", | |
| 95 | + transport: () => { | |
| 96 | + throw new Error("transport must not run after cancel"); | |
| 97 | + }, | |
| 98 | + onMinted: () => "cancel", | |
| 99 | + }); | |
| 100 | + | |
| 101 | + expect(result).toEqual({ status: "cancelled", attachmentId: "pending-cancelled" }); | |
| 102 | + expect(removeCalls).toEqual(["pending-cancelled"]); | |
| 103 | + }); | |
| 104 | + | |
| 105 | + it("keeps the minted id on transfer failure so the caller can retry or release", async () => { | |
| 106 | + removeCalls.length = 0; | |
| 107 | + const result = await runAttachmentUploadCycle({ | |
| 108 | + registry, | |
| 109 | + createUploadUrl: makeCreateUploadUrl("pending-failed"), | |
| 110 | + remove, | |
| 111 | + environmentId, | |
| 112 | + upload: uploadInput, | |
| 113 | + resolveUploadUrl: () => "https://environment.test/upload", | |
| 114 | + transport: () => ({ | |
| 115 | + done: Promise.reject(new Error("Upload rejected (413)")), | |
| 116 | + abort: () => {}, | |
| 117 | + }), | |
| 118 | + }); | |
| 119 | + | |
| 120 | + expect(result).toMatchObject({ | |
| 121 | + status: "failed", | |
| 122 | + step: "transfer", | |
| 123 | + attachmentId: "pending-failed", | |
| 124 | + }); | |
| 125 | + expect(removeCalls).toEqual([]); | |
| 126 | + }); | |
| 127 | +}); | |
| 128 | + | |
| 129 | +describe("verifyPersistedAttachmentUpload", () => { | |
| 130 | + it("hits the server on every verification instead of reusing a cached failure", async () => { | |
| 131 | + // Mirrors the app's asset URL query atom: SWR-cached with a long stale | |
| 132 | + // window and kept alive across calls. Without a forced refresh, the | |
| 133 | + // second verification would read the cached failure and never retry. | |
| 134 | + let lookups = 0; | |
| 135 | + const assetUrlAtom = Atom.make( | |
| 136 | + // Async like the real RPC, so the first read is still in flight when | |
| 137 | + // the query decides whether a refresh is needed. | |
| 138 | + Effect.promise(() => Promise.resolve()).pipe( | |
| 139 | + Effect.flatMap(() => { | |
| 140 | + lookups += 1; | |
| 141 | + return lookups === 1 | |
| 142 | + ? Effect.fail({ _tag: "TransportError" } as const) | |
| 143 | + : Effect.succeed({ url: "/api/assets/pending-1" }); | |
| 144 | + }), | |
| 145 | + ), | |
| 146 | + ).pipe(Atom.swr({ staleTime: 60_000 }), Atom.keepAlive); | |
| 147 | + const liveRegistry = AtomRegistry.make(); | |
| 148 | + | |
| 149 | + const verify = () => | |
| 150 | + verifyPersistedAttachmentUpload({ | |
| 151 | + registry: liveRegistry, | |
| 152 | + createAssetUrl: () => assetUrlAtom, | |
| 153 | + environmentId, | |
| 154 | + attachmentId: "pending-1", | |
| 155 | + }); | |
| 156 | + | |
| 157 | + const first = await verify(); | |
| 158 | + expect(first).toMatchObject({ status: "failed" }); | |
| 159 | + | |
| 160 | + const second = await verify(); | |
| 161 | + expect(second).toEqual({ status: "verified" }); | |
| 162 | + expect(lookups).toBe(2); | |
| 163 | + }); | |
| 164 | +}); | |
| 165 | + | |
| 166 | +describe("file attachment limits", () => { | |
| 167 | + it("clamps the advertised limit to the turn contract cap", () => { | |
| 168 | + expect(clampFileAttachmentUploadBytes(1024)).toBe(1024); | |
| 169 | + expect(clampFileAttachmentUploadBytes(PROVIDER_SEND_TURN_MAX_FILE_BYTES * 2)).toBe( | |
| 170 | + PROVIDER_SEND_TURN_MAX_FILE_BYTES, | |
| 171 | + ); | |
| 172 | + }); | |
| 173 | + | |
| 174 | + it("formats attachment row sizes", () => { | |
| 175 | + expect(formatAttachmentSize(3 * 1024 * 1024)).toBe("3.0 MB"); | |
| 176 | + expect(formatAttachmentSize(1)).toBe("1 KB"); | |
| 177 | + }); | |
| 178 | + | |
| 179 | + it("formats small upload limits without rounding them to zero MB", () => { | |
| 180 | + expect(fileAttachmentTooLargeMessage("tiny.txt", 1)).toBe( | |
| 181 | + "'tiny.txt' exceeds the 1 byte attachment limit.", | |
| 182 | + ); | |
| 183 | + expect(fileAttachmentTooLargeMessage("small.txt", 1024)).toBe( | |
| 184 | + "'small.txt' exceeds the 1 KB attachment limit.", | |
| 185 | + ); | |
| 186 | + expect(fileAttachmentTooLargeMessage("exact.txt", 1025)).toBe( | |
| 187 | + "'exact.txt' exceeds the 1025 bytes attachment limit.", | |
| 188 | + ); | |
| 189 | + expect(fileAttachmentTooLargeMessage("medium.zip", 512 * 1024)).toBe( | |
| 190 | + "'medium.zip' exceeds the 512 KB attachment limit.", | |
| 191 | + ); | |
| 192 | + }); | |
| 193 | + | |
| 194 | + it("keeps whole-MB upload limits for standard server caps", () => { | |
| 195 | + expect(fileAttachmentTooLargeMessage("one.bin", 1024 * 1024)).toBe( | |
| 196 | + "'one.bin' exceeds the 1 MB attachment limit.", | |
| 197 | + ); | |
| 198 | + expect(fileAttachmentTooLargeMessage("big.zip", 50 * 1024 * 1024)).toBe( | |
| 199 | + "'big.zip' exceeds the 50 MB attachment limit.", | |
| 200 | + ); | |
| 201 | + }); | |
| 202 | +}); | |
| @@ -0,0 +1,236 @@ | ||
| 1 | +import { | |
| 2 | + PROVIDER_SEND_TURN_MAX_FILE_BYTES, | |
| 3 | + WS_METHODS, | |
| 4 | + type AttachmentCreateUploadUrlInput, | |
| 5 | + type AttachmentCreateUploadUrlResult, | |
| 6 | + type AttachmentDeleteInput, | |
| 7 | + type EnvironmentId, | |
| 8 | +} from "@t3tools/contracts"; | |
| 9 | +import type { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | |
| 10 | + | |
| 11 | +import type { EnvironmentRegistry } from "../connection/registry.ts"; | |
| 12 | +import { | |
| 13 | + createEnvironmentRpcCommand, | |
| 14 | + executeAtomQuery, | |
| 15 | + runAtomCommand, | |
| 16 | + squashAtomCommandFailure, | |
| 17 | + type AtomCommand, | |
| 18 | +} from "./runtime.ts"; | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * RPC commands for pending chat attachment uploads. Mirrors | |
| 22 | + * `createAssetEnvironmentAtoms`: each client instantiates it with its own | |
| 23 | + * connection runtime (`attachmentEnvironment` in web and mobile). | |
| 24 | + */ | |
| 25 | +export function createAttachmentEnvironmentAtoms<R, E>( | |
| 26 | + runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | |
| 27 | +) { | |
| 28 | + return { | |
| 29 | + createUploadUrl: createEnvironmentRpcCommand(runtime, { | |
| 30 | + label: "environment-command:attachments:create-upload-url", | |
| 31 | + tag: WS_METHODS.attachmentsCreateUploadUrl, | |
| 32 | + }), | |
| 33 | + remove: createEnvironmentRpcCommand(runtime, { | |
| 34 | + label: "environment-command:attachments:delete", | |
| 35 | + tag: WS_METHODS.attachmentsDelete, | |
| 36 | + }), | |
| 37 | + }; | |
| 38 | +} | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * Whether a failed asset lookup means the attachment no longer exists on the | |
| 42 | + * server, as opposed to a transient transport failure. Pending uploads expire, | |
| 43 | + * so this is the signal to upload the bytes again rather than retry the lookup. | |
| 44 | + * | |
| 45 | + * A structural `_tag` check rather than a schema check: the squashed cause of | |
| 46 | + * a failed RPC is not guaranteed to be a decoded error class instance, only a | |
| 47 | + * tagged value. | |
| 48 | + */ | |
| 49 | +export function isAssetAttachmentNotFoundFailure(error: unknown): boolean { | |
| 50 | + return ( | |
| 51 | + typeof error === "object" && | |
| 52 | + error !== null && | |
| 53 | + "_tag" in error && | |
| 54 | + error._tag === "AssetAttachmentNotFoundError" | |
| 55 | + ); | |
| 56 | +} | |
| 57 | + | |
| 58 | +export type PersistedAttachmentVerification = | |
| 59 | + | { readonly status: "verified" } | |
| 60 | + | { readonly status: "missing" } | |
| 61 | + | { readonly status: "failed"; readonly error: unknown }; | |
| 62 | + | |
| 63 | +/** | |
| 64 | + * Checks that a previously uploaded pending attachment still exists on the | |
| 65 | + * server by minting an asset URL for it. `verified` means the send can reuse | |
| 66 | + * the stored bytes, `missing` means the upload expired and the bytes must be | |
| 67 | + * uploaded again, `failed` means the server could not be asked. | |
| 68 | + */ | |
| 69 | +export async function verifyPersistedAttachmentUpload<A, E>(input: { | |
| 70 | + readonly registry: AtomRegistry.AtomRegistry; | |
| 71 | + readonly createAssetUrl: (query: { | |
| 72 | + readonly environmentId: EnvironmentId; | |
| 73 | + readonly input: { | |
| 74 | + readonly resource: { readonly _tag: "attachment"; readonly attachmentId: string }; | |
| 75 | + }; | |
| 76 | + }) => Atom.Atom<AsyncResult.AsyncResult<A, E>>; | |
| 77 | + readonly environmentId: EnvironmentId; | |
| 78 | + readonly attachmentId: string; | |
| 79 | +}): Promise<PersistedAttachmentVerification> { | |
| 80 | + const result = await executeAtomQuery( | |
| 81 | + input.registry, | |
| 82 | + input.createAssetUrl({ | |
| 83 | + environmentId: input.environmentId, | |
| 84 | + input: { resource: { _tag: "attachment", attachmentId: input.attachmentId } }, | |
| 85 | + }), | |
| 86 | + // `refresh` forces a server round trip: the asset URL query atom caches | |
| 87 | + // results (SWR), so a retry right after a transient failure would | |
| 88 | + // otherwise re-observe the cached failure and never ask the server. | |
| 89 | + { reportFailure: false, reportDefect: false, refresh: true }, | |
| 90 | + ); | |
| 91 | + if (result._tag === "Success") { | |
| 92 | + return { status: "verified" }; | |
| 93 | + } | |
| 94 | + const error = squashAtomCommandFailure(result); | |
| 95 | + return isAssetAttachmentNotFoundFailure(error) | |
| 96 | + ? { status: "missing" } | |
| 97 | + : { status: "failed", error }; | |
| 98 | +} | |
| 99 | + | |
| 100 | +type AttachmentCreateUploadUrlCommand<E> = AtomCommand< | |
| 101 | + { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput }, | |
| 102 | + AttachmentCreateUploadUrlResult, | |
| 103 | + E | |
| 104 | +>; | |
| 105 | + | |
| 106 | +type AttachmentRemoveCommand<E> = AtomCommand< | |
| 107 | + { readonly environmentId: EnvironmentId; readonly input: AttachmentDeleteInput }, | |
| 108 | + unknown, | |
| 109 | + E | |
| 110 | +>; | |
| 111 | + | |
| 112 | +/** Fire-and-forget delete of a pending upload the client no longer references. */ | |
| 113 | +export function deletePendingAttachmentUpload<E>(input: { | |
| 114 | + readonly registry: AtomRegistry.AtomRegistry; | |
| 115 | + readonly remove: AttachmentRemoveCommand<E>; | |
| 116 | + readonly environmentId: EnvironmentId; | |
| 117 | + readonly attachmentId: string; | |
| 118 | +}): void { | |
| 119 | + void runAtomCommand( | |
| 120 | + input.registry, | |
| 121 | + input.remove, | |
| 122 | + { environmentId: input.environmentId, input: { attachmentId: input.attachmentId } }, | |
| 123 | + { reportFailure: false, reportDefect: false }, | |
| 124 | + ); | |
| 125 | +} | |
| 126 | + | |
| 127 | +/** A running byte transfer: resolves when the server accepted the bytes. */ | |
| 128 | +export interface AttachmentByteUpload { | |
| 129 | + readonly done: Promise<void>; | |
| 130 | + readonly abort: () => void; | |
| 131 | +} | |
| 132 | + | |
| 133 | +export type AttachmentUploadCycleResult = | |
| 134 | + | { readonly status: "uploaded"; readonly attachmentId: string } | |
| 135 | + | { readonly status: "cancelled"; readonly attachmentId: string | null } | |
| 136 | + | { | |
| 137 | + readonly status: "failed"; | |
| 138 | + readonly step: "mint" | "resolve-url" | "transfer"; | |
| 139 | + readonly attachmentId: string | null; | |
| 140 | + readonly error: unknown; | |
| 141 | + }; | |
| 142 | + | |
| 143 | +/** | |
| 144 | + * The platform-neutral upload cycle: mint a signed upload URL, resolve it | |
| 145 | + * against the environment's HTTP base, and hand the bytes to a | |
| 146 | + * platform-specific transport (XHR on web, `expo-file-system` on mobile). | |
| 147 | + * | |
| 148 | + * The cycle never deletes the minted pending upload on failure: callers keep | |
| 149 | + * the returned `attachmentId` and decide between retry and release. The one | |
| 150 | + * exception is `onMinted` returning `"cancel"`, where the caller already | |
| 151 | + * abandoned the upload and the fresh mint is deleted before returning. | |
| 152 | + */ | |
| 153 | +export async function runAttachmentUploadCycle<E, RE>(input: { | |
| 154 | + readonly registry: AtomRegistry.AtomRegistry; | |
| 155 | + readonly createUploadUrl: AttachmentCreateUploadUrlCommand<E>; | |
| 156 | + readonly remove: AttachmentRemoveCommand<RE>; | |
| 157 | + readonly environmentId: EnvironmentId; | |
| 158 | + readonly upload: AttachmentCreateUploadUrlInput; | |
| 159 | + readonly resolveUploadUrl: (relativeUrl: string) => string | null; | |
| 160 | + readonly transport: (url: string) => AttachmentByteUpload; | |
| 161 | + /** Observe the minted id (for cancellation bookkeeping) before bytes move. */ | |
| 162 | + readonly onMinted?: (attachmentId: string) => "continue" | "cancel"; | |
| 163 | + readonly onTransferStart?: (abort: () => void) => void; | |
| 164 | +}): Promise<AttachmentUploadCycleResult> { | |
| 165 | + const minted = await runAtomCommand( | |
| 166 | + input.registry, | |
| 167 | + input.createUploadUrl, | |
| 168 | + { environmentId: input.environmentId, input: input.upload }, | |
| 169 | + { reportFailure: false }, | |
| 170 | + ); | |
| 171 | + if (minted._tag !== "Success") { | |
| 172 | + return { | |
| 173 | + status: "failed", | |
| 174 | + step: "mint", | |
| 175 | + attachmentId: null, | |
| 176 | + error: squashAtomCommandFailure(minted), | |
| 177 | + }; | |
| 178 | + } | |
| 179 | + const attachmentId = minted.value.attachmentId; | |
| 180 | + if (input.onMinted?.(attachmentId) === "cancel") { | |
| 181 | + deletePendingAttachmentUpload({ | |
| 182 | + registry: input.registry, | |
| 183 | + remove: input.remove, | |
| 184 | + environmentId: input.environmentId, | |
| 185 | + attachmentId, | |
| 186 | + }); | |
| 187 | + return { status: "cancelled", attachmentId }; | |
| 188 | + } | |
| 189 | + | |
| 190 | + const url = input.resolveUploadUrl(minted.value.relativeUrl); | |
| 191 | + if (!url) { | |
| 192 | + return { | |
| 193 | + status: "failed", | |
| 194 | + step: "resolve-url", | |
| 195 | + attachmentId, | |
| 196 | + error: new Error("The environment is not connected."), | |
| 197 | + }; | |
| 198 | + } | |
| 199 | + | |
| 200 | + const transfer = input.transport(url); | |
| 201 | + input.onTransferStart?.(transfer.abort); | |
| 202 | + try { | |
| 203 | + await transfer.done; | |
| 204 | + } catch (error) { | |
| 205 | + return { status: "failed", step: "transfer", attachmentId, error }; | |
| 206 | + } | |
| 207 | + return { status: "uploaded", attachmentId }; | |
| 208 | +} | |
| 209 | + | |
| 210 | +/** | |
| 211 | + * The effective per-file byte limit for a server that advertises | |
| 212 | + * `capabilities.fileAttachments.maxUploadBytes`. The contract caps what a | |
| 213 | + * turn may reference, so a larger advertised value must not admit files the | |
| 214 | + * send would then refuse. | |
| 215 | + */ | |
| 216 | +export function clampFileAttachmentUploadBytes(advertisedMaxUploadBytes: number): number { | |
| 217 | + return Math.min(advertisedMaxUploadBytes, PROVIDER_SEND_TURN_MAX_FILE_BYTES); | |
| 218 | +} | |
| 219 | + | |
| 220 | +/** "3.2 MB" / "48 KB" label for attachment rows. Never shows "0 KB". */ | |
| 221 | +export function formatAttachmentSize(sizeBytes: number): string { | |
| 222 | + return sizeBytes >= 1024 * 1024 | |
| 223 | + ? `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB` | |
| 224 | + : `${Math.max(1, Math.ceil(sizeBytes / 1024))} KB`; | |
| 225 | +} | |
| 226 | + | |
| 227 | +/** User-facing rejection for a file over the effective upload limit. */ | |
| 228 | +export function fileAttachmentTooLargeMessage(name: string, maxUploadBytes: number): string { | |
| 229 | + const maxUploadSize = | |
| 230 | + maxUploadBytes >= 1024 * 1024 && maxUploadBytes % (1024 * 1024) === 0 | |
| 231 | + ? `${maxUploadBytes / (1024 * 1024)} MB` | |
| 232 | + : maxUploadBytes >= 1024 && maxUploadBytes % 1024 === 0 | |
| 233 | + ? `${maxUploadBytes / 1024} KB` | |
| 234 | + : `${maxUploadBytes} ${maxUploadBytes === 1 ? "byte" : "bytes"}`; | |
| 235 | + return `'${name}' exceeds the ${maxUploadSize} attachment limit.`; | |
| 236 | +} | |
| @@ -327,15 +327,34 @@ export async function executeAtomCommand<A, E>( · 326 unmodified lines | ||
| 327 | 327 | return result; |
| 328 | 328 | } |
| 329 | 329 | |
| 330 | +export interface AtomQueryOptions extends AtomCommandOptions { | |
| 331 | + /** | |
| 332 | + * Force a fresh execution instead of accepting a value the atom already | |
| 333 | + * holds (e.g. an SWR-cached result within its stale window). Used by | |
| 334 | + * verification flows where a cached failure must not satisfy a retry. | |
| 335 | + */ | |
| 336 | + readonly refresh?: boolean; | |
| 337 | +} | |
| 338 | + | |
| 330 | 339 | export async function executeAtomQuery<A, E>( |
| 331 | 340 | registry: AtomRegistry.AtomRegistry, |
| 332 | 341 | atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>, |
| 333 | − options: AtomCommandOptions = {}, | |
| 342 | + options: AtomQueryOptions = {}, | |
| 334 | 343 | reporter: AtomCommandReporter = console, |
| 335 | 344 | ): Promise<AtomCommandResult<A, E>> { |
| 336 | 345 | const query = Effect.scoped( |
| 337 | 346 | Effect.gen(function* () { |
| 338 | 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 | 358 | return yield* AtomRegistry.getResult(registry, atom, { |
| 340 | 359 | suspendOnWaiting: true, |
| 341 | 360 | }); |