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