diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 9a51725478e1..4d2f5bb24676 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -142,12 +142,13 @@ const sharingPlugin: NonNullable[number] = [ supportsText: true, supportsWebUrlWithMaxCount: 1, supportsImageWithMaxCount: 8, + supportsFileWithMaxCount: 8, }, }, android: { enabled: true, - singleShareMimeTypes: ["text/plain", "image/*"], - multipleShareMimeTypes: ["image/*"], + singleShareMimeTypes: ["*/*"], + multipleShareMimeTypes: ["*/*"], }, }, ]; diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 0621285c03e0..bad898797af0 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -2,12 +2,13 @@ import { SymbolView } from "../components/AppSymbol"; import { Image, Pressable, ScrollView, View } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; -import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { AppText as Text } from "./AppText"; +import type { DraftComposerAttachment } from "../lib/composerImages"; export interface ComposerAttachmentStripProps { - /** Attachment images to display. */ - readonly attachments: ReadonlyArray; - /** Called when the user taps the remove button on an image. */ + /** Attachments to display. */ + readonly attachments: ReadonlyArray; + /** Called when the user removes an attachment. */ readonly onRemove: (imageId: string) => void; /** Called when the user taps on an image thumbnail to preview it. */ readonly onPressImage?: (previewUri: string) => void; @@ -20,8 +21,7 @@ export interface ComposerAttachmentStripProps { } /** - * A horizontally-scrollable strip of image attachment thumbnails with remove - * buttons. Used by both the thread composer and the new-task draft screen. + * Attachment thumbnails used by the thread composer and the new-task draft screen. */ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { const subtleBg = useThemeColor("--color-subtle"); @@ -42,29 +42,48 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { className="grow-0" > - {props.attachments.map((image) => ( + {props.attachments.map((attachment) => ( - props.onPressImage!(image.previewUri) : undefined} - > - props.onPressImage!(attachment.previewUri) : undefined + } + > + + + ) : ( + - + > + + + {attachment.name} + + + )} props.onRemove(image.id)} + onPress={() => props.onRemove(attachment.id)} > > { +async function resolvedPayloadsForFiles(): Promise> { try { return await getResolvedSharedPayloadsAsync(); } catch (error) { @@ -84,6 +86,11 @@ async function readBase64(uri: string): Promise { return new File(uri).base64(); } +async function readFileSize(uri: string): Promise { + const { File } = await import("expo-file-system"); + return new File(uri).size ?? null; +} + async function removeOwnedFile(uri: string): Promise { if (!uri.startsWith("file:")) { return; @@ -99,21 +106,23 @@ async function removeOwnedFile(uri: string): Promise { } } -async function removeReplayedImagePayloadFiles( - payloads: ReadonlyArray, -): Promise { +async function removeReplayedPayloadFiles(payloads: ReadonlyArray): Promise { const uris = new Set(); for (const payload of payloads) { - if (payload.shareType === "image") { + if (["image", "file", "audio", "video"].includes(payload.shareType)) { uris.add(payload.value); } } if (uris.size === 0) { return; } - const resolvedPayloads = await resolvedPayloadsForImages(); + const resolvedPayloads = payloads.some((payload) => + ["file", "audio", "video"].includes(payload.shareType), + ) + ? [] + : await resolvedPayloadsForFiles(); for (const payload of resolvedPayloads) { - if (payload.shareType === "image" && payload.contentUri) { + if (["image", "file", "audio", "video"].includes(payload.shareType) && payload.contentUri) { uris.add(payload.contentUri); } } @@ -131,14 +140,29 @@ const incomingShareInbox = new IncomingShareInbox({ clearPayloads: clearSharedPayloads, buildDraft: async ({ payloads, id, createdAt }) => { const cleanupUris = new Set(); - const resolvedPayloads = payloads.some((payload) => payload.shareType === "image") - ? await resolvedPayloadsForImages() - : []; + const persistedUris = new Set(); + const hasGenericFilePayload = payloads.some((payload) => + ["file", "audio", "video"].includes(payload.shareType), + ); + const resolvedPayloads = + !hasGenericFilePayload && payloads.some((payload) => payload.shareType === "image") + ? await resolvedPayloadsForFiles() + : []; const draft = await buildIncomingShareDraft({ payloads, resolvedPayloads, fileReader: { readBase64, + persistFile: async (uri, name) => { + const persistedUri = await persistComposerAttachmentFile( + uri, + name, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + ); + persistedUris.add(persistedUri); + return persistedUri; + }, + readSize: readFileSize, removeOwnedFile: (uri) => { cleanupUris.add(uri); }, @@ -151,9 +175,12 @@ const incomingShareInbox = new IncomingShareInbox({ cleanup: async () => { await Promise.all([...cleanupUris].map(removeOwnedFile)); }, + rollback: async () => { + await Promise.all([...persistedUris].map(removeOwnedFile)); + }, }; }, - cleanupReplayedPayloads: removeReplayedImagePayloadFiles, + cleanupReplayedPayloads: removeReplayedPayloadFiles, idForPayloads: incomingShareIdForPayloads, now: () => new Date().toISOString(), onClearError: (error) => { diff --git a/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts b/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts index ff50c6a917a6..251fa3757014 100644 --- a/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts +++ b/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts @@ -136,11 +136,13 @@ describe("IncomingShareInbox", () => { it("does not acknowledge a supported payload when its durable write fails", async () => { const clearPayloads = vi.fn(); const cleanup = vi.fn(async () => undefined); + const rollback = vi.fn(async () => undefined); const { inbox } = createHarness({ clearPayloads, buildDraft: async ({ id, createdAt }) => ({ draft: draft(id, createdAt), cleanup, + rollback, }), writeDraft: async () => { throw new Error("disk full"); @@ -150,6 +152,7 @@ describe("IncomingShareInbox", () => { await expect(inbox.refresh({ ingestNative: true })).rejects.toThrow("disk full"); expect(clearPayloads).not.toHaveBeenCalled(); expect(cleanup).not.toHaveBeenCalled(); + expect(rollback).toHaveBeenCalledOnce(); }); it("durably reserves a share for one project before draft import", async () => { diff --git a/apps/mobile/src/features/sharing/incoming-share-inbox.ts b/apps/mobile/src/features/sharing/incoming-share-inbox.ts index 1f61ea710bb5..ca9d65d36ae0 100644 --- a/apps/mobile/src/features/sharing/incoming-share-inbox.ts +++ b/apps/mobile/src/features/sharing/incoming-share-inbox.ts @@ -20,6 +20,7 @@ export interface IncomingShareInboxDependencies { }) => Promise<{ readonly draft: IncomingShareDraft; readonly cleanup: () => Promise; + readonly rollback?: () => Promise; }>; readonly cleanupReplayedPayloads?: (payloads: ReadonlyArray) => Promise; readonly idForPayloads: (payloads: ReadonlyArray) => Promise; @@ -116,7 +117,14 @@ export class IncomingShareInbox { // The durable inbox write is the transaction boundary. Never clear the // native handoff first: a process termination must leave one recoverable // copy on one side of the boundary. - await this.dependencies.writeDraft(draft); + try { + await this.dependencies.writeDraft(draft); + } catch (error) { + if (built.rollback) { + await this.cleanup(built.rollback); + } + throw error; + } await this.cleanup(built.cleanup); this.clearNativePayloads(); return sortAndDedupeIncomingShares([draft, ...persisted]); diff --git a/apps/mobile/src/features/sharing/incoming-share-model.test.ts b/apps/mobile/src/features/sharing/incoming-share-model.test.ts index 07ede18b8ef8..2e1bcd4854d9 100644 --- a/apps/mobile/src/features/sharing/incoming-share-model.test.ts +++ b/apps/mobile/src/features/sharing/incoming-share-model.test.ts @@ -1,11 +1,16 @@ import { describe, expect, it, vi } from "@effect/vitest"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; -import { buildIncomingShareDraft, hasIncomingShareContent } from "./incoming-share-model"; +import { + buildIncomingShareDraft, + hasIncomingShareContent, + selectIncomingShareAttachments, +} from "./incoming-share-model"; describe("incoming native shares", () => { it("converts shared text, URLs, and images into a durable composer draft", async () => { @@ -96,6 +101,253 @@ describe("incoming native shares", () => { expect(hasIncomingShareContent(result)).toBe(false); }); + it("keeps a shared PDF on disk without converting its contents to base64", async () => { + const file: SharePayload = { + shareType: "file", + value: "file:///shared/report.pdf", + mimeType: "application/pdf", + }; + const readBase64 = vi.fn(async () => "unused"); + const persistFile = vi.fn(async () => "file:///documents/report.pdf"); + const removeOwnedFile = vi.fn(async () => undefined); + + const result = await buildIncomingShareDraft({ + id: "share-report", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [file], + resolvedPayloads: [ + { + ...file, + contentUri: file.value, + contentType: "file", + contentMimeType: "application/pdf", + contentSize: 42, + originalName: "report.pdf", + }, + ], + fileReader: { readBase64, persistFile, removeOwnedFile }, + }); + + expect(result.attachments).toEqual([ + { + id: "share-report:file:0", + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/report.pdf", + }, + ]); + expect(readBase64).not.toHaveBeenCalled(); + expect(persistFile).toHaveBeenCalledWith(file.value, "report.pdf"); + expect(removeOwnedFile).toHaveBeenCalledWith(file.value); + }); + + it("rejects shared files that exceed the generic attachment limit", async () => { + const file: SharePayload = { + shareType: "file", + value: "file:///shared/huge.zip", + mimeType: "application/zip", + }; + const persistFile = vi.fn(async () => "file:///documents/huge.zip"); + const removeOwnedFile = vi.fn(async () => undefined); + + const result = await buildIncomingShareDraft({ + id: "share-huge", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [file], + resolvedPayloads: [ + { + ...file, + contentUri: file.value, + contentType: "file", + contentMimeType: "application/zip", + contentSize: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1, + originalName: "huge.zip", + }, + ], + fileReader: { + readBase64: async () => "unused", + persistFile, + removeOwnedFile, + }, + }); + + expect(result.attachments).toEqual([]); + expect(result.warnings).toEqual(["'huge.zip' exceeds the 50 MB attachment limit."]); + expect(persistFile).not.toHaveBeenCalled(); + expect(removeOwnedFile).toHaveBeenCalledWith(file.value); + }); + + it("reports an unreadable shared file without calling it oversized", async () => { + const file: SharePayload = { + shareType: "file", + value: "file:///shared/empty.txt", + mimeType: "text/plain", + }; + + const result = await buildIncomingShareDraft({ + id: "share-empty", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [file], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "unused", + readSize: async () => 0, + removeOwnedFile: async () => undefined, + }, + }); + + expect(result.attachments).toEqual([]); + expect(result.warnings).toEqual(["'empty.txt' is empty or could not be read."]); + }); + + it("reads an Android content URI's size after copying it into app-owned storage", async () => { + const file: SharePayload = { + shareType: "file", + value: "content://shared/report", + mimeType: "application/pdf", + }; + const persistFile = vi.fn(async () => "file:///documents/report.pdf"); + const readSize = vi.fn(async (uri: string) => (uri.startsWith("content:") ? null : 42)); + + const result = await buildIncomingShareDraft({ + id: "share-android-report", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [file], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "unused", + persistFile, + readSize, + removeOwnedFile: async () => undefined, + }, + }); + + expect(result.attachments).toEqual([ + { + id: "share-android-report:file:0", + type: "file", + name: "report", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/report.pdf", + }, + ]); + expect(readSize.mock.calls).toEqual([ + ["content://shared/report"], + ["file:///documents/report.pdf"], + ]); + }); + + it("treats a zero-length Android content URI as unknown until its copy is measured", async () => { + const file: SharePayload = { + shareType: "file", + value: "content://shared/report", + mimeType: "application/pdf", + }; + + const result = await buildIncomingShareDraft({ + id: "share-zero-metadata", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [file], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "unused", + persistFile: async () => "file:///documents/report.pdf", + readSize: async (uri) => (uri.startsWith("content:") ? 0 : 42), + removeOwnedFile: async () => undefined, + }, + }); + + expect(result.attachments[0]?.sizeBytes).toBe(42); + expect(result.warnings).toEqual([]); + }); + + it("keeps the Android display name without copying the file into the Expo cache", async () => { + const file = { + shareType: "file" as const, + value: "content://shared/12345", + mimeType: "application/pdf", + originalName: "quarterly-report.pdf", + }; + + const result = await buildIncomingShareDraft({ + id: "share-named-report", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [file], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "unused", + readSize: async () => 42, + persistFile: async (_uri, name) => `file:///documents/${name}`, + removeOwnedFile: async () => undefined, + }, + }); + + expect(result.attachments).toEqual([ + { + id: "share-named-report:file:0", + type: "file", + name: "quarterly-report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/quarterly-report.pdf", + }, + ]); + }); + + it("keeps images and rejects shared files on servers without file support", () => { + const image = { + id: "image-1", + type: "image" as const, + name: "image.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "data:image/png;base64,YWJj", + }; + const file = { + id: "file-1", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/report.pdf", + }; + + expect( + selectIncomingShareAttachments({ + attachments: [image, file], + maxFileAttachmentBytes: null, + }), + ).toEqual({ + attachments: [image], + warnings: ["'report.pdf' was skipped because this server does not support files."], + }); + }); + + it("uses the destination server's attachment limit in share warnings", () => { + const file = { + id: "file-1", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6 * 1024 * 1024, + fileUri: "file:///documents/report.pdf", + }; + + expect( + selectIncomingShareAttachments({ + attachments: [file], + maxFileAttachmentBytes: 5 * 1024 * 1024, + }), + ).toEqual({ + attachments: [], + warnings: ["'report.pdf' exceeds the 5 MB attachment limit."], + }); + }); + it("releases every temporary file when a share exceeds the attachment limit", async () => { const payloads = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({ shareType: "image" as const, diff --git a/apps/mobile/src/features/sharing/incoming-share-model.ts b/apps/mobile/src/features/sharing/incoming-share-model.ts index d9985a700051..3423a1e200a2 100644 --- a/apps/mobile/src/features/sharing/incoming-share-model.ts +++ b/apps/mobile/src/features/sharing/incoming-share-model.ts @@ -1,13 +1,14 @@ import { isProviderSendTurnSupportedImageMimeType, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; -import { DraftComposerImageAttachmentSchema } from "../../lib/composer-image-schema"; -import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import { DraftComposerAttachmentSchema } from "../../lib/composer-image-schema"; +import type { DraftComposerAttachment } from "../../lib/composerImages"; import { estimateBase64ByteSize } from "../../lib/base64"; export interface IncomingShareDraft { @@ -16,7 +17,7 @@ export interface IncomingShareDraft { readonly createdAt: string; readonly destination?: IncomingShareDestination; readonly text: string; - readonly attachments: ReadonlyArray; + readonly attachments: ReadonlyArray; readonly warnings: ReadonlyArray; } @@ -36,7 +37,7 @@ export const IncomingShareDraftSchema = Schema.Struct({ createdAt: Schema.String, destination: Schema.optional(IncomingShareDestinationSchema), text: Schema.String, - attachments: Schema.Array(DraftComposerImageAttachmentSchema), + attachments: Schema.Array(DraftComposerAttachmentSchema), warnings: Schema.Array(Schema.String), }); @@ -49,6 +50,48 @@ export function decodeIncomingShareDraft(value: unknown): IncomingShareDraft { export interface IncomingShareFileReader { readonly readBase64: (uri: string) => Promise; readonly removeOwnedFile: (uri: string) => Promise | void; + readonly persistFile?: (uri: string, name: string) => Promise; + readonly readSize?: (uri: string) => Promise; +} + +function attachmentLimitLabel(maxBytes: number): string { + return `${Math.round(maxBytes / (1024 * 1024))} MB`; +} + +/** Apply the destination server's file support after the user chooses a project. */ +export function selectIncomingShareAttachments(input: { + readonly attachments: ReadonlyArray; + readonly maxFileAttachmentBytes: number | null; +}): { + readonly attachments: ReadonlyArray; + readonly warnings: ReadonlyArray; +} { + const attachments: DraftComposerAttachment[] = []; + const warnings: string[] = []; + + for (const attachment of input.attachments) { + if (attachment.type === "image") { + attachments.push(attachment); + continue; + } + if (input.maxFileAttachmentBytes === null) { + warnings.push(`'${attachment.name}' was skipped because this server does not support files.`); + continue; + } + const maxFileAttachmentBytes = Math.min( + input.maxFileAttachmentBytes, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + ); + if (attachment.sizeBytes > maxFileAttachmentBytes) { + warnings.push( + `'${attachment.name}' exceeds the ${attachmentLimitLabel(maxFileAttachmentBytes)} attachment limit.`, + ); + continue; + } + attachments.push(attachment); + } + + return { attachments, warnings }; } function sharedText(payloads: ReadonlyArray): string { @@ -130,13 +173,18 @@ export async function buildIncomingShareDraft(input: { readonly id: string; readonly createdAt: string; }): Promise { - const attachments: DraftComposerImageAttachment[] = []; + const attachments: DraftComposerAttachment[] = []; const warnings: string[] = []; const consumedResolvedPayloadIndexes = new Set(); let warnedAttachmentLimit = false; for (const [index, payload] of input.payloads.entries()) { - if (payload.shareType !== "image") { + if ( + payload.shareType !== "image" && + payload.shareType !== "file" && + payload.shareType !== "audio" && + payload.shareType !== "video" + ) { continue; } const resolved = resolvedImageFor( @@ -149,7 +197,7 @@ export async function buildIncomingShareDraft(input: { if (attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { if (!warnedAttachmentLimit) { warnings.push( - `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared images were attached.`, + `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared ${payload.shareType === "image" ? "images" : "files"} were attached.`, ); warnedAttachmentLimit = true; } @@ -157,7 +205,72 @@ export async function buildIncomingShareDraft(input: { continue; } - const mimeType = (resolved?.contentMimeType ?? payload.mimeType ?? "image/png").toLowerCase(); + const mimeType = ( + resolved?.contentMimeType ?? + payload.mimeType ?? + (payload.shareType === "image" ? "image/png" : "application/octet-stream") + ).toLowerCase(); + if (payload.shareType !== "image") { + const sharedFileName = + "originalName" in payload && typeof payload.originalName === "string" + ? payload.originalName + : undefined; + const name = resolved?.originalName ?? sharedFileName ?? fallbackName(uri, index, mimeType); + if (!uri) { + warnings.push("One shared file could not be read."); + continue; + } + let persistedFileUri: string | undefined; + try { + let sizeBytes = resolved?.contentSize ?? (await input.fileReader.readSize?.(uri)) ?? null; + if ( + (sizeBytes === null || (sizeBytes === 0 && uri.startsWith("content:"))) && + input.fileReader.persistFile + ) { + persistedFileUri = await input.fileReader.persistFile(uri, name); + sizeBytes = (await input.fileReader.readSize?.(persistedFileUri)) ?? null; + } + if (sizeBytes === null) { + warnings.push(`The size of '${name}' could not be determined.`); + if (persistedFileUri) { + await releaseOwnedFiles(input.fileReader, [persistedFileUri]); + } + continue; + } + if (sizeBytes <= 0) { + warnings.push(`'${name}' is empty or could not be read.`); + if (persistedFileUri) { + await releaseOwnedFiles(input.fileReader, [persistedFileUri]); + } + continue; + } + if (sizeBytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + warnings.push( + `'${name}' exceeds the ${attachmentLimitLabel(PROVIDER_SEND_TURN_MAX_FILE_BYTES)} attachment limit.`, + ); + if (persistedFileUri) { + await releaseOwnedFiles(input.fileReader, [persistedFileUri]); + } + continue; + } + const fileUri = + persistedFileUri ?? + (input.fileReader.persistFile ? await input.fileReader.persistFile(uri, name) : uri); + attachments.push({ + id: `${input.id}:file:${index}`, + type: "file", + name, + mimeType, + sizeBytes, + fileUri, + }); + } catch (error) { + warnings.push(error instanceof Error ? error.message : `Could not read '${name}'.`); + } finally { + await releaseOwnedFiles(input.fileReader, [uri, payload.value]); + } + continue; + } if (!uri || !mimeType.startsWith("image/")) { warnings.push("One shared item was not a supported image."); await releaseOwnedFiles(input.fileReader, [uri, payload.value]); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 8f5beb69c938..077c666dd05b 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -41,14 +41,20 @@ import { } from "./use-thread-settings-sheet-presentation"; import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; -import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; +import { + convertPastedImagesToAttachments, + pickComposerFiles, + pickComposerImages, +} from "../../lib/composerImages"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { clearComposerDraftContent, + flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContent, restoreComposerDraftSnapshot, + scheduleUnusedComposerAttachmentCleanup, type ComposerDraft, } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; @@ -66,6 +72,7 @@ import { resolveNewTaskWorkspaceLabel, } from "./new-task-context-presentation"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; +import { selectIncomingShareAttachments } from "../sharing/incoming-share-model"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; @@ -201,9 +208,16 @@ export function NewTaskDraftScreen(props: { ); const isProjectPickerReturnActive = isReturningToProjectPicker && !requestedInitialProjectAvailable; + const isIncomingShareAwaitingServerConfig = Boolean( + incomingShare?.attachments.some((attachment) => attachment.type === "file") && + selectedEnvironmentServerConfig === null, + ); const isIncomingShareTransferPending = Boolean( - incomingShare && cancelledIncomingShareId !== props.incomingShareId, + incomingShare && + cancelledIncomingShareId !== props.incomingShareId && + !isIncomingShareAwaitingServerConfig, ); + const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting; usePreventRemove( (isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport, () => undefined, @@ -423,6 +437,13 @@ export function NewTaskDraftScreen(props: { return; } + if ( + incomingShare.attachments.some((attachment) => attachment.type === "file") && + selectedEnvironmentServerConfig === null + ) { + return; + } + if (alertedUnavailableIncomingShareIdRef.current === shareId) { alertedUnavailableIncomingShareIdRef.current = null; } @@ -436,6 +457,12 @@ export function NewTaskDraftScreen(props: { activeShareImportTokenRef.current = importToken; setImportingShareKey(importKey); void (async () => { + const selectedAttachments = selectIncomingShareAttachments({ + attachments: incomingShare.attachments, + maxFileAttachmentBytes: + selectedEnvironmentServerConfig?.environment.capabilities.fileAttachments + ?.maxUploadBytes ?? null, + }); await reserveShare(shareId, { environmentId: String(destinationProject.environmentId), projectId: String(destinationProject.id), @@ -452,7 +479,7 @@ export function NewTaskDraftScreen(props: { needsDraftRestore = true; const { skippedAttachmentCount } = await mergeComposerDraftContent(draftKey, { text: incomingShare.text, - attachments: incomingShare.attachments, + attachments: selectedAttachments.attachments, sourceShareId: shareId, }); if ( @@ -469,10 +496,17 @@ export function NewTaskDraftScreen(props: { if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) { return; } - const warnings = [...incomingShare.warnings]; + const retainedAttachmentIds = new Set( + getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id), + ); + const rejectedAttachments = incomingShare.attachments.filter( + (attachment) => !retainedAttachmentIds.has(attachment.id), + ); + scheduleUnusedComposerAttachmentCleanup(rejectedAttachments); + const warnings = [...incomingShare.warnings, ...selectedAttachments.warnings]; if (skippedAttachmentCount > 0) { warnings.push( - `${skippedAttachmentCount} shared image${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`, + `${skippedAttachmentCount} shared file${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`, ); } if (warnings.length > 0) { @@ -579,6 +613,7 @@ export function NewTaskDraftScreen(props: { props.initialProjectRef?.projectId, releaseShareReservation, reserveShare, + selectedEnvironmentServerConfig, selectedProject, shareImportAttempt, ]); @@ -610,7 +645,7 @@ export function NewTaskDraftScreen(props: { const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0; async function handlePickImages(): Promise { - if (isIncomingShareTransferPending) { + if (isComposerInteractionLocked) { return; } const result = await pickComposerImages({ existingCount: flow.attachments.length }); @@ -619,6 +654,28 @@ export function NewTaskDraftScreen(props: { } } + async function handlePickFiles(): Promise { + if (isComposerInteractionLocked) { + return; + } + const maxBytes = + selectedEnvironmentServerConfig?.environment.capabilities.fileAttachments?.maxUploadBytes; + if (maxBytes === undefined) { + Alert.alert("File attachments are not available on this server."); + return; + } + const result = await pickComposerFiles({ + existingCount: flow.attachments.length, + maxBytes, + }); + if (result.files.length > 0) { + flow.appendAttachments(result.files); + } + if (result.error) { + Alert.alert("Could not attach file", result.error); + } + } + const handleNativePasteImages = useCallback( async (uris: ReadonlyArray) => { try { @@ -739,6 +796,10 @@ export function NewTaskDraftScreen(props: { interactionMode, initialMessageText, initialAttachments: draft.attachments, + onAttachmentsUploaded: async (attachments) => { + flow.replaceAttachments(attachments); + await flushComposerDrafts(); + }, ...(editingPendingTask ? { turnMetadata: { @@ -812,7 +873,7 @@ export function NewTaskDraftScreen(props: { // The context-first screen intentionally opens with the keyboard closed. // Focusing is a user action, so presenting the form sheet has one motion. autoFocus={false} - editable={!isIncomingShareTransferPending} + editable={!isComposerInteractionLocked} multiline scrollEnabled value={flow.prompt} @@ -844,7 +905,7 @@ export function NewTaskDraftScreen(props: { navigation.goBack(); }; const chooseProject = () => { - if (isIncomingShareTransferPending) { + if (isComposerInteractionLocked) { return; } promptInputRef.current?.blur(); @@ -852,7 +913,7 @@ export function NewTaskDraftScreen(props: { navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId })); }; const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => { - if (isIncomingShareTransferPending) { + if (isComposerInteractionLocked) { return; } promptInputRef.current?.blur(); @@ -872,7 +933,7 @@ export function NewTaskDraftScreen(props: { accessibilityHint="Opens the project picker" accessibilityLabel={`Change project from ${selectedProject.title}`} accessibilityRole="button" - disabled={isIncomingShareTransferPending} + disabled={isComposerInteractionLocked} onPress={chooseProject} className="min-w-0 max-w-[250px] active:opacity-65" style={{ @@ -894,7 +955,7 @@ export function NewTaskDraftScreen(props: { undefined : flow.removeAttachment} + onRemove={isComposerInteractionLocked ? () => undefined : flow.removeAttachment} /> ) : null} @@ -991,14 +1052,24 @@ export function NewTaskDraftScreen(props: { > void handlePickImages()} + onPress={() => { + if (selectedEnvironmentServerConfig?.environment.capabilities.fileAttachments) { + Alert.alert("Add attachment", undefined, [ + { text: "Photos", onPress: () => void handlePickImages() }, + { text: "Files", onPress: () => void handlePickFiles() }, + { text: "Cancel", style: "cancel" }, + ]); + return; + } + void handlePickImages(); + }} showChevron={false} /> @@ -1011,7 +1082,7 @@ export function NewTaskDraftScreen(props: { attachment.type === "image") ? "images" : "files"} you shared` : null; const screenTitle = incomingShare ? "Start a task" : "Choose project"; const projectEmptyState = deriveProjectEmptyState(catalogState); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index c771aaebcb6e..746268cf01ab 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -18,6 +18,7 @@ import type { ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { ActivityIndicator, + Alert, Image, Platform, Pressable, @@ -39,6 +40,7 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re import { scopedThreadKey } from "../../lib/scopedEntities"; import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; import { GlassSurface } from "../../components/GlassSurface"; import { @@ -54,7 +56,7 @@ import { } from "../../components/ComposerToolbar"; import { ControlPill } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; -import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import type { DraftComposerAttachment } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -91,7 +93,7 @@ export const COMPOSER_EXPANDED_CHROME = 156; export interface ThreadComposerProps { readonly draftMessage: string; - readonly draftAttachments: ReadonlyArray; + readonly draftAttachments: ReadonlyArray; readonly placeholder: string; readonly contentMaxWidth?: number; readonly bottomInset?: number; @@ -112,6 +114,7 @@ export interface ThreadComposerProps { readonly editorRef?: RefObject; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftImages: () => Promise; + readonly onPickDraftFiles: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; @@ -831,15 +834,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer {!isExpanded && props.draftAttachments.length > 0 ? ( - {props.draftAttachments.slice(0, 3).map((image) => ( - onPressImage(image.previewUri)}> - - - ))} + {props.draftAttachments.slice(0, 3).map((attachment) => + attachment.type === "image" ? ( + onPressImage(attachment.previewUri)} + > + + + ) : ( + + + + ), + )} {props.draftAttachments.length > 3 ? ( @@ -873,7 +888,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer void props.onPickDraftImages()} + onPress={() => { + if (props.serverConfig?.environment.capabilities.fileAttachments) { + Alert.alert("Add attachment", undefined, [ + { text: "Photos", onPress: () => void props.onPickDraftImages() }, + { text: "Files", onPress: () => void props.onPickDraftFiles() }, + { text: "Cancel", style: "cancel" }, + ]); + return; + } + void props.onPickDraftImages(); + }} showChevron={false} /> > | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly draftMessage: string; - readonly draftAttachments: ReadonlyArray; + readonly draftAttachments: ReadonlyArray; readonly connectionStateLabel: EnvironmentConnectionPhase; /** Message sync status for the selected thread (drives the composer status pill). */ readonly threadSyncStatus?: EnvironmentThreadStatus; @@ -113,6 +113,7 @@ export interface ThreadDetailScreenProps { readonly onOpenConnectionEditor: () => void; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftImages: () => Promise; + readonly onPickDraftFiles: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; @@ -757,6 +758,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread bottomInset={composerBottomInset} onChangeDraftMessage={props.onChangeDraftMessage} onPickDraftImages={props.onPickDraftImages} + onPickDraftFiles={props.onPickDraftFiles} onNativePasteImages={props.onNativePasteImages} onRemoveDraftImage={props.onRemoveDraftImage} onStopThread={props.onStopThread} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 60b397802ccc..cac7bcaf8794 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2,6 +2,8 @@ import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; @@ -28,6 +30,7 @@ import { } from "react-native-nitro-markdown"; import { ActivityIndicator, + Alert, Image, Platform, type LayoutChangeEvent, @@ -108,7 +111,10 @@ import { WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; -import { useAssetUrl, useAssetUrlState } from "../../state/assets"; +import { assetEnvironment, useAssetUrl, useAssetUrlState } from "../../state/assets"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { usePreparedConnection } from "../../state/session"; +import * as Option from "effect/Option"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; @@ -202,6 +208,64 @@ function MessageAttachmentImage(props: { ); } +function MessageAttachmentFile(props: { + readonly environmentId: EnvironmentId; + readonly attachmentId: string; + readonly name: string; + readonly sizeBytes: number; +}) { + const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + }); + const preparedConnection = usePreparedConnection(props.environmentId); + const sizeLabel = + props.sizeBytes >= 1024 * 1024 + ? `${(props.sizeBytes / (1024 * 1024)).toFixed(1)} MB` + : `${Math.max(1, Math.ceil(props.sizeBytes / 1024))} KB`; + + return ( + { + if (Option.isNone(preparedConnection)) return; + void (async () => { + const result = await createAssetUrl({ + environmentId: props.environmentId, + input: { resource: { _tag: "attachment", attachmentId: props.attachmentId } }, + }); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not open attachment", + error instanceof Error ? error.message : "The attachment is unavailable.", + ); + return; + } + const url = resolveAssetUrl( + preparedConnection.value.httpBaseUrl, + result.value.relativeUrl, + ); + if (url !== null) { + await tryOpenExternalUrl(url, "file-preview"); + } + })(); + }} + > + + + {props.name} + + {sizeLabel} + + ); +} + function ThreadMarkdownImageView(props: { readonly uri: string | null; readonly sourceKey: string; @@ -1088,7 +1152,7 @@ function renderFeedEntry( /> ) : null} {attachments.map((attachment) => { - return ( + return attachment.type === "image" ? ( + ) : ( + ); })} @@ -1150,7 +1222,7 @@ function renderFeedEntry( ) ) : null} {attachments.map((attachment) => { - return ( + return attachment.type === "image" ? ( + ) : ( + ); })} {showAssistantMeta ? ( diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index cad1cab8e602..6647eb3bb9a3 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -794,6 +794,7 @@ function ThreadRouteContent( onOpenConnectionEditor={handleOpenConnectionEditor} onChangeDraftMessage={composer.onChangeDraftMessage} onPickDraftImages={composer.onPickDraftImages} + onPickDraftFiles={composer.onPickDraftFiles} onNativePasteImages={composer.onNativePasteImages} onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 14f0fcc95a22..6c0aff9938c2 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -27,7 +27,7 @@ import { pipe } from "effect/Function"; import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities"; import type { TurnCommandMetadata } from "../../lib/commandMetadata"; -import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import type { DraftComposerAttachment } from "../../lib/composerImages"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { buildModelOptions, @@ -47,6 +47,7 @@ import { isComposerDraftEmpty, removeComposerDraftAttachment, replaceComposerDraftAttachments, + scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, updateComposerDraftSettings, useComposerDraft, @@ -132,7 +133,7 @@ type NewTaskFlowContextValue = { readonly draftKey: string | null; readonly editingPendingTask: QueuedThreadMessage | null; readonly prompt: string; - readonly attachments: ReadonlyArray; + readonly attachments: ReadonlyArray; readonly submitting: boolean; readonly branchQuery: string; readonly branchesLoading: boolean; @@ -171,8 +172,8 @@ type NewTaskFlowContextValue = { readonly cancelEditingPendingTask: () => void; readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null; readonly setPrompt: (value: string) => void; - readonly replaceAttachments: (attachments: ReadonlyArray) => void; - readonly appendAttachments: (attachments: ReadonlyArray) => void; + readonly replaceAttachments: (attachments: ReadonlyArray) => void; + readonly appendAttachments: (attachments: ReadonlyArray) => void; readonly removeAttachment: (imageId: string) => void; readonly clearAttachments: () => void; readonly setSubmitting: (value: boolean) => void; @@ -497,7 +498,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [selectedProjectDraftKey], ); const replaceAttachments = useCallback( - (nextAttachments: ReadonlyArray) => { + (nextAttachments: ReadonlyArray) => { if (!selectedProjectDraftKey) { return; } @@ -506,7 +507,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [selectedProjectDraftKey], ); const appendAttachments = useCallback( - (nextAttachments: ReadonlyArray) => { + (nextAttachments: ReadonlyArray) => { if (!selectedProjectDraftKey) { return; } @@ -916,6 +917,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { } clearComposerDraft(pendingTaskDraftKey(editing.messageId)); releaseEditingQueuedMessage(editing.messageId); + scheduleUnusedComposerAttachmentCleanup(editing.attachments); } setEditingPendingTask(null); }, []); @@ -979,6 +981,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { } clearComposerDraft(pendingTaskDraftKey(editing.messageId)); releaseEditingQueuedMessage(editing.messageId); + scheduleUnusedComposerAttachmentCleanup(editing.attachments); }) .catch((error) => { // Keep the drain lock and the draft: delivering the stale payload diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 9d03dde59a93..c1236a468a7a 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -14,11 +14,17 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { threadEnvironment } from "../../state/threads"; -import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import type { DraftComposerAttachment } from "../../lib/composerImages"; +import { + deletePendingMobileAttachments, + uploadMobileAttachments, + withUploadedMobileAttachmentReferences, +} from "../../lib/attachmentUpload"; import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata"; import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; import { randomHex } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; +import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts"; import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; @@ -36,7 +42,10 @@ export function useCreateProjectThread() { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly initialMessageText: string; - readonly initialAttachments: ReadonlyArray; + readonly initialAttachments: ReadonlyArray; + readonly onAttachmentsUploaded: ( + attachments: ReadonlyArray, + ) => Promise; /** Reuse identifiers from a queued pending task instead of minting new ones. */ readonly turnMetadata?: TurnCommandMetadata; }) => { @@ -56,6 +65,27 @@ export function useCreateProjectThread() { return AsyncResult.failure(Cause.fail(validationError)); } + let uploaded: Awaited>; + try { + uploaded = await uploadMobileAttachments({ + environmentId: input.project.environmentId, + attachments: input.initialAttachments, + }); + if (uploaded.pendingAttachmentIds.length > 0) { + await input.onAttachmentsUploaded( + withUploadedMobileAttachmentReferences({ + environmentId: input.project.environmentId, + attachments: input.initialAttachments, + uploadedAttachments: uploaded.attachments, + }), + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : "An attachment could not upload."; + setPendingConnectionError(message); + return AsyncResult.failure(Cause.fail(new Error(message))); + } + const result = await startTurn({ environmentId: input.project.environmentId, input: buildProjectThreadStartTurnInput({ @@ -67,6 +97,7 @@ export function useCreateProjectThread() { createdAt: metadata.createdAt, text: initialMessageText, attachments: input.initialAttachments, + uploadedAttachments: uploaded.attachments, modelSelection: input.modelSelection, runtimeMode: input.runtimeMode, interactionMode: input.interactionMode, @@ -84,7 +115,12 @@ export function useCreateProjectThread() { ); return AsyncResult.failure(result.cause); } + await deletePendingMobileAttachments( + input.project.environmentId, + uploaded.pendingAttachmentIds, + ); setPendingConnectionError(null); + scheduleUnusedComposerAttachmentCleanup(input.initialAttachments); return mapAtomCommandResult(result, () => scopeThreadRef(input.project.environmentId, threadId), diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts new file mode 100644 index 000000000000..03ce05a8b759 --- /dev/null +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -0,0 +1,296 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + createAssetUrl: vi.fn(), + createUploadUrl: Symbol("create-upload-url"), + executeAtomQuery: vi.fn(), + removeUpload: Symbol("remove-upload"), + preparedConnection: Symbol("prepared-connection"), + runAtomCommand: vi.fn(), + readAtom: vi.fn(), + upload: vi.fn(), +})); + +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + executeAtomQuery: mocks.executeAtomQuery, + runAtomCommand: mocks.runAtomCommand, + squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error, +})); + +vi.mock("../state/atom-registry", () => ({ + appAtomRegistry: { get: mocks.readAtom }, +})); + +vi.mock("../state/assets", () => ({ + assetEnvironment: { createUrl: mocks.createAssetUrl }, +})); + +vi.mock("../state/attachments", () => ({ + attachmentEnvironment: { + createUploadUrl: mocks.createUploadUrl, + remove: mocks.removeUpload, + }, +})); + +vi.mock("../state/session", () => ({ + environmentSession: { + preparedConnectionValueAtom: () => mocks.preparedConnection, + }, +})); + +vi.mock("expo-file-system", () => ({ + File: class { + constructor(readonly uri: string) {} + + upload(url: string, options: unknown) { + return mocks.upload(this.uri, url, options); + } + }, + UploadType: { BINARY_CONTENT: 0 }, +})); + +vi.mock("./uuid", () => ({ + uuidv4: () => "attachment-id", +})); + +import { + uploadMobileAttachments, + withUploadedMobileAttachmentReferences, +} from "./attachmentUpload"; +import type { DraftComposerAttachment } from "./composerImages"; + +const environmentId = EnvironmentId.make("environment-1"); + +const image = { + id: "image-1", + type: "image", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "file:///images/screenshot.png", +} as const satisfies DraftComposerAttachment; + +const file = { + id: "file-1", + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/report.pdf", +} as const satisfies DraftComposerAttachment; + +describe("uploadMobileAttachments", () => { + beforeEach(() => { + mocks.createAssetUrl.mockReset(); + mocks.createAssetUrl.mockImplementation((target: unknown) => target); + mocks.executeAtomQuery.mockReset(); + mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} }); + mocks.runAtomCommand.mockReset(); + mocks.readAtom.mockReset(); + mocks.upload.mockReset(); + mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" })); + mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) => + command === mocks.createUploadUrl + ? { + _tag: "Success", + value: { + attachmentId: "pending-00000000-0000-4000-8000-000000000001-pdf", + relativeUrl: "/api/attachments/upload/signed", + expiresAt: 1, + }, + } + : { _tag: "Success", value: undefined }, + ); + mocks.upload.mockResolvedValue({ status: 204, body: "", headers: {} }); + }); + + it("keeps existing image attachments on the legacy wire path", async () => { + await expect(uploadMobileAttachments({ environmentId, attachments: [image] })).resolves.toEqual( + { + attachments: [ + { + type: "image", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + }, + ], + pendingAttachmentIds: [], + }, + ); + expect(mocks.upload).not.toHaveBeenCalled(); + }); + + it("uploads generic file bytes directly and keeps mixed attachment order", async () => { + const result = await uploadMobileAttachments({ + environmentId, + attachments: [file, image], + }); + + expect(mocks.upload).toHaveBeenCalledWith( + "file:///documents/report.pdf", + "https://environment.example/api/attachments/upload/signed", + { + httpMethod: "POST", + uploadType: 0, + headers: { "Content-Type": "application/pdf" }, + }, + ); + expect(result.attachments[0]).toEqual({ + type: "file", + id: "pending-00000000-0000-4000-8000-000000000001-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + }); + expect(result.attachments[1]?.type).toBe("image"); + expect(result.pendingAttachmentIds).toEqual([ + "pending-00000000-0000-4000-8000-000000000001-pdf", + ]); + }); + + it("uses the current connection when an environment reconnects during URL creation", async () => { + mocks.readAtom + .mockReturnValueOnce(Option.some({ httpBaseUrl: "https://old-environment.example/" })) + .mockReturnValueOnce(Option.some({ httpBaseUrl: "https://new-environment.example/" })); + + await uploadMobileAttachments({ environmentId, attachments: [file] }); + + expect(mocks.upload).toHaveBeenCalledWith( + file.fileUri, + "https://new-environment.example/api/attachments/upload/signed", + expect.anything(), + ); + }); + + it("adds uploaded file references to durable drafts without changing images", () => { + expect( + withUploadedMobileAttachmentReferences({ + environmentId, + attachments: [file, image], + uploadedAttachments: [ + { + type: "file", + id: "pending-existing-pdf", + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + }, + { + type: "image", + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: image.dataUrl, + }, + ], + }), + ).toEqual([ + { + ...file, + uploadedAttachmentId: "pending-existing-pdf", + uploadEnvironmentId: environmentId, + }, + image, + ]); + }); + + it("reuses a pending file upload from a previous outbox attempt", async () => { + const previouslyUploaded = { + ...file, + uploadedAttachmentId: "pending-existing-pdf", + uploadEnvironmentId: environmentId, + }; + + await expect( + uploadMobileAttachments({ environmentId, attachments: [previouslyUploaded, image] }), + ).resolves.toEqual({ + attachments: [ + { + type: "file", + id: "pending-existing-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + }, + { + type: "image", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + }, + ], + pendingAttachmentIds: ["pending-existing-pdf"], + }); + expect(mocks.upload).not.toHaveBeenCalled(); + expect(mocks.runAtomCommand).not.toHaveBeenCalled(); + }); + + it("uploads a file again when its saved pending upload has expired", async () => { + mocks.executeAtomQuery.mockResolvedValueOnce({ + _tag: "Failure", + error: { _tag: "AssetAttachmentNotFoundError" }, + }); + const previouslyUploaded = { + ...file, + uploadedAttachmentId: "pending-expired-pdf", + uploadEnvironmentId: environmentId, + }; + + const result = await uploadMobileAttachments({ + environmentId, + attachments: [previouslyUploaded], + }); + + expect(mocks.upload).toHaveBeenCalledOnce(); + expect(result.pendingAttachmentIds).toEqual([ + "pending-00000000-0000-4000-8000-000000000001-pdf", + ]); + }); + + it("removes pending uploads when the native HTTP request fails", async () => { + mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} }); + + await expect(uploadMobileAttachments({ environmentId, attachments: [file] })).rejects.toThrow( + "Upload failed for 'report.pdf' (500).", + ); + expect(mocks.runAtomCommand).toHaveBeenCalledWith( + expect.anything(), + mocks.removeUpload, + { + environmentId, + input: { attachmentId: "pending-00000000-0000-4000-8000-000000000001-pdf" }, + }, + expect.anything(), + ); + }); + + it("keeps a previously persisted upload when a later attachment fails", async () => { + const previouslyUploaded = { + ...file, + id: "file-existing", + uploadedAttachmentId: "pending-existing-pdf", + uploadEnvironmentId: environmentId, + }; + mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} }); + + await expect( + uploadMobileAttachments({ environmentId, attachments: [previouslyUploaded, file] }), + ).rejects.toThrow("Upload failed for 'report.pdf' (500)."); + + expect(mocks.runAtomCommand).not.toHaveBeenCalledWith( + expect.anything(), + mocks.removeUpload, + { + environmentId, + input: { attachmentId: "pending-existing-pdf" }, + }, + expect.anything(), + ); + }); +}); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts new file mode 100644 index 000000000000..e13b73458bae --- /dev/null +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -0,0 +1,193 @@ +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + executeAtomQuery, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { + ChatFileAttachment, + EnvironmentId, + UploadChatImageAttachment, +} from "@t3tools/contracts"; +import { AssetAttachmentNotFoundError } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { appAtomRegistry } from "../state/atom-registry"; +import { assetEnvironment } from "../state/assets"; +import { attachmentEnvironment } from "../state/attachments"; +import { environmentSession } from "../state/session"; +import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; + +export type UploadedMobileAttachment = UploadChatImageAttachment | ChatFileAttachment; +const isAssetAttachmentNotFound = Schema.is(AssetAttachmentNotFoundError); + +/** Keep uploaded file ids on durable drafts so a later send can reuse their bytes. */ +export function withUploadedMobileAttachmentReferences(input: { + readonly environmentId: EnvironmentId; + readonly attachments: ReadonlyArray; + readonly uploadedAttachments: ReadonlyArray; +}): ReadonlyArray { + return input.attachments.map((attachment, index) => { + const uploaded = input.uploadedAttachments[index]; + if ( + attachment.type !== "file" || + uploaded?.type !== "file" || + (attachment.uploadedAttachmentId === uploaded.id && + attachment.uploadEnvironmentId === input.environmentId) + ) { + return attachment; + } + return { + ...attachment, + uploadedAttachmentId: uploaded.id, + uploadEnvironmentId: input.environmentId, + }; + }); +} + +export async function deletePendingMobileAttachments( + environmentId: EnvironmentId, + attachmentIds: ReadonlyArray, +): Promise { + await Promise.all( + attachmentIds.map((attachmentId) => + runAtomCommand( + appAtomRegistry, + attachmentEnvironment.remove, + { environmentId, input: { attachmentId } }, + { reportFailure: false, reportDefect: false }, + ), + ), + ); +} + +export async function uploadMobileAttachments(input: { + readonly environmentId: EnvironmentId; + readonly attachments: ReadonlyArray; +}): Promise<{ + readonly attachments: ReadonlyArray; + readonly pendingAttachmentIds: ReadonlyArray; +}> { + const files = input.attachments.filter((attachment) => attachment.type === "file"); + if (files.length === 0) { + return { + attachments: toUploadChatImageAttachments( + input.attachments.filter((attachment) => attachment.type === "image"), + ), + pendingAttachmentIds: [], + }; + } + + const connection = appAtomRegistry.get( + environmentSession.preparedConnectionValueAtom(input.environmentId), + ); + if (Option.isNone(connection)) { + throw new Error("The environment is not connected."); + } + + const { File, UploadType } = await import("expo-file-system"); + const uploadedAttachments: UploadedMobileAttachment[] = []; + const pendingAttachmentIds: string[] = []; + const createdAttachmentIds: string[] = []; + try { + for (const attachment of input.attachments) { + if (attachment.type === "image") { + uploadedAttachments.push(...toUploadChatImageAttachments([attachment])); + continue; + } + + if ( + attachment.uploadEnvironmentId === input.environmentId && + attachment.uploadedAttachmentId + ) { + const verified = await executeAtomQuery( + appAtomRegistry, + assetEnvironment.createUrl({ + environmentId: input.environmentId, + input: { + resource: { _tag: "attachment", attachmentId: attachment.uploadedAttachmentId }, + }, + }), + { reportFailure: false, reportDefect: false }, + ); + if (verified._tag === "Success") { + pendingAttachmentIds.push(attachment.uploadedAttachmentId); + uploadedAttachments.push({ + type: "file", + id: attachment.uploadedAttachmentId, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }); + continue; + } + + const error = squashAtomCommandFailure(verified); + if ( + !isAssetAttachmentNotFound(error) && + !( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "AssetAttachmentNotFoundError" + ) + ) { + throw error; + } + } + + const issued = await runAtomCommand( + appAtomRegistry, + attachmentEnvironment.createUploadUrl, + { + environmentId: input.environmentId, + input: { + type: "file", + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }, + }, + { reportFailure: false }, + ); + if (issued._tag !== "Success") { + throw Cause.squash(issued.cause); + } + pendingAttachmentIds.push(issued.value.attachmentId); + createdAttachmentIds.push(issued.value.attachmentId); + + const currentConnection = appAtomRegistry.get( + environmentSession.preparedConnectionValueAtom(input.environmentId), + ); + if (Option.isNone(currentConnection)) { + throw new Error("The environment disconnected before the attachment could upload."); + } + const url = resolveAssetUrl(currentConnection.value.httpBaseUrl, issued.value.relativeUrl); + if (!url) { + throw new Error(`Could not resolve the upload URL for '${attachment.name}'.`); + } + const result = await new File(attachment.fileUri).upload(url, { + httpMethod: "POST", + uploadType: UploadType.BINARY_CONTENT, + headers: { "Content-Type": attachment.mimeType }, + }); + if (result.status < 200 || result.status >= 300) { + throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`); + } + + uploadedAttachments.push({ + type: "file", + id: issued.value.attachmentId, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }); + } + return { attachments: uploadedAttachments, pendingAttachmentIds }; + } catch (error) { + await deletePendingMobileAttachments(input.environmentId, createdAttachmentIds); + throw error; + } +} diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts index a121b70ddb5a..401a5fd512c3 100644 --- a/apps/mobile/src/lib/composer-image-schema.ts +++ b/apps/mobile/src/lib/composer-image-schema.ts @@ -1,4 +1,5 @@ import * as Schema from "effect/Schema"; +import { EnvironmentId } from "@t3tools/contracts"; export const DraftComposerImageAttachmentSchema = Schema.Struct({ id: Schema.String, @@ -9,3 +10,19 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({ sizeBytes: Schema.Number, dataUrl: Schema.String, }); + +export const DraftComposerFileAttachmentSchema = Schema.Struct({ + id: Schema.String, + type: Schema.Literal("file"), + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + fileUri: Schema.String, + uploadedAttachmentId: Schema.optional(Schema.String), + uploadEnvironmentId: Schema.optional(EnvironmentId), +}); + +export const DraftComposerAttachmentSchema = Schema.Union([ + DraftComposerImageAttachmentSchema, + DraftComposerFileAttachmentSchema, +]); diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts new file mode 100644 index 000000000000..a1fb11d86c8b --- /dev/null +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -0,0 +1,295 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + pickFile: vi.fn(), + copy: vi.fn(), + delete: vi.fn(), + open: vi.fn(), + size: vi.fn(), +})); + +vi.mock("expo-file-system", () => { + class Directory { + readonly uri: string; + + constructor(root: string, name: string) { + this.uri = `${root}/${name}`; + } + + create(): void {} + } + + class File { + static pickFileAsync = mocks.pickFile; + + readonly uri: string; + + constructor(source: string | Directory, name?: string) { + this.uri = source instanceof Directory ? `${source.uri}/${name}` : source; + } + + get exists(): boolean { + return true; + } + + get size(): number | null { + return mocks.size(this.uri) ?? null; + } + + create(): void {} + + open(mode: string) { + return mocks.open(this.uri, mode); + } + + async copy(destination: File): Promise { + mocks.copy(this.uri, destination.uri); + } + + delete(): void { + mocks.delete(this.uri); + } + } + + return { + Directory, + File, + FileMode: { ReadOnly: "r", WriteOnly: "w" }, + Paths: { document: "file:///documents" }, + }; +}); + +vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" })); + +import { + persistComposerAttachmentFile, + pickComposerFiles, + removePersistedComposerAttachmentFile, +} from "./composerImages"; + +describe("pickComposerFiles", () => { + beforeEach(() => { + mocks.pickFile.mockReset(); + mocks.copy.mockReset(); + mocks.delete.mockReset(); + mocks.open.mockReset(); + mocks.size.mockReset(); + mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42)); + }); + + it("copies picked files into app-owned storage without loading their contents", async () => { + mocks.pickFile.mockResolvedValue({ + canceled: false, + result: [ + { + uri: "file:///downloads/report.pdf", + name: "report.pdf", + type: "application/pdf", + size: 42, + }, + ], + }); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [ + { + id: "attachment-id", + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/attachment-id-report.pdf", + }, + ], + error: null, + }); + expect(mocks.copy).toHaveBeenCalledWith( + "file:///downloads/report.pdf", + "file:///documents/t3-composer-attachments/attachment-id-report.pdf", + ); + }); + + it("rejects files that exceed the environment's advertised upload limit", async () => { + mocks.pickFile.mockResolvedValue({ + canceled: false, + result: [ + { + uri: "file:///downloads/archive.zip", + name: "archive.zip", + type: "application/zip", + size: 2 * 1024 * 1024, + }, + ], + }); + + await expect(pickComposerFiles({ existingCount: 0, maxBytes: 1024 * 1024 })).resolves.toEqual({ + files: [], + error: "'archive.zip' exceeds the 1 MB attachment limit.", + }); + expect(mocks.copy).not.toHaveBeenCalled(); + }); + + it("never accepts files above the 50 MB contract limit", async () => { + mocks.pickFile.mockResolvedValue({ + canceled: false, + result: [ + { + uri: "file:///downloads/archive.zip", + name: "archive.zip", + type: "application/zip", + size: 51 * 1024 * 1024, + }, + ], + }); + + await expect( + pickComposerFiles({ existingCount: 0, maxBytes: 80 * 1024 * 1024 }), + ).resolves.toEqual({ + files: [], + error: "'archive.zip' exceeds the 50 MB attachment limit.", + }); + }); + + it("rejects a file that grew after the picker reported its size", async () => { + mocks.pickFile.mockResolvedValue({ + canceled: false, + result: [ + { + uri: "file:///downloads/archive.zip", + name: "archive.zip", + type: "application/zip", + size: 42, + }, + ], + }); + mocks.size.mockReturnValue(2 * 1024 * 1024); + + await expect(pickComposerFiles({ existingCount: 0, maxBytes: 1024 * 1024 })).resolves.toEqual({ + files: [], + error: "'archive.zip' exceeds the 1 MB attachment limit.", + }); + expect(mocks.copy).not.toHaveBeenCalled(); + }); + + it("stops copying an unknown-size content URI when it exceeds the attachment limit", async () => { + const maxBytes = 1024 * 1024; + let remainingBytes = maxBytes + 1; + const source = { + readBytes: vi.fn((length: number) => { + const size = Math.min(length, remainingBytes); + remainingBytes -= size; + return new Uint8Array(size); + }), + close: vi.fn(), + }; + const destination = { writeBytes: vi.fn(), close: vi.fn() }; + mocks.open.mockImplementation((uri: string) => + uri.startsWith("content:") ? source : destination, + ); + + await expect( + persistComposerAttachmentFile("content://shared/large", "large.bin", maxBytes), + ).rejects.toThrow("'large.bin' exceeds the 1 MB attachment limit."); + + expect(source.close).toHaveBeenCalledOnce(); + expect(destination.close).toHaveBeenCalledOnce(); + expect(mocks.delete).toHaveBeenCalledWith( + "file:///documents/t3-composer-attachments/attachment-id-large.bin", + ); + expect(mocks.copy).not.toHaveBeenCalled(); + }); + + it("reports an empty file without calling it oversized", async () => { + mocks.size.mockReturnValue(0); + mocks.pickFile.mockResolvedValue({ + canceled: false, + result: [ + { + uri: "file:///downloads/empty.txt", + name: "empty.txt", + type: "text/plain", + size: 0, + }, + ], + }); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [], + error: "'empty.txt' is empty or could not be read.", + }); + }); + + it("copies an Android SAF file when the picker reports an unknown zero size", async () => { + const reader = { + readBytes: vi + .fn() + .mockReturnValueOnce(new Uint8Array(42)) + .mockReturnValueOnce(new Uint8Array()), + close: vi.fn(), + }; + const writer = { writeBytes: vi.fn(), close: vi.fn() }; + mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? 0 : 42)); + mocks.open.mockImplementation((uri: string) => (uri.startsWith("content:") ? reader : writer)); + mocks.pickFile.mockResolvedValue({ + canceled: false, + result: [ + { + uri: "content://shared/report", + name: "report.pdf", + type: "application/pdf", + size: 0, + }, + ], + }); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [ + { + id: "attachment-id", + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/attachment-id-report.pdf", + }, + ], + error: null, + }); + }); + + it("uses the remaining slot for the first valid file after an oversized selection", async () => { + mocks.pickFile.mockResolvedValue({ + canceled: false, + result: [ + { + uri: "file:///downloads/huge.zip", + name: "huge.zip", + type: "application/zip", + size: 2 * 1024 * 1024, + }, + { + uri: "file:///downloads/report.pdf", + name: "report.pdf", + type: "application/pdf", + size: 42, + }, + ], + }); + + const result = await pickComposerFiles({ existingCount: 7, maxBytes: 1024 * 1024 }); + + expect(result.files.map((file) => file.name)).toEqual(["report.pdf"]); + }); + + it("deletes app-owned attachments without touching user-owned files", async () => { + await removePersistedComposerAttachmentFile( + "file:///documents/t3-composer-attachments/report.pdf", + ); + await removePersistedComposerAttachmentFile("file:///downloads/report.pdf"); + + expect(mocks.delete).toHaveBeenCalledOnce(); + expect(mocks.delete).toHaveBeenCalledWith( + "file:///documents/t3-composer-attachments/report.pdf", + ); + }); +}); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 747b7afd31bc..6efc09a1b0c8 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -1,9 +1,12 @@ import { isProviderSendTurnSupportedImageMimeType, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + type EnvironmentId, type UploadChatImageAttachment, } from "@t3tools/contracts"; +import type { PickMultipleFilesResult } from "expo-file-system"; import { estimateBase64ByteSize } from "./base64"; import { beginForegroundHandoff } from "./foreground-handoff"; import { uuidv4 } from "./uuid"; @@ -13,6 +16,19 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment readonly previewUri: string; } +export interface DraftComposerFileAttachment { + readonly id: string; + readonly type: "file"; + readonly name: string; + readonly mimeType: string; + readonly sizeBytes: number; + readonly fileUri: string; + readonly uploadedAttachmentId?: string; + readonly uploadEnvironmentId?: EnvironmentId; +} + +export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment; + /** Wire shape for startTurn: pure uploads without client draft id / previewUri. */ export function toUploadChatImageAttachments( attachments: ReadonlyArray, @@ -27,6 +43,164 @@ export function toUploadChatImageAttachments( } const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; +const OWNED_ATTACHMENT_DIRECTORY = "t3-composer-attachments"; +const ATTACHMENT_COPY_CHUNK_BYTES = 64 * 1024; + +export async function persistComposerAttachmentFile( + uri: string, + name: string, + maxBytes?: number, +): Promise { + const { Directory, File, FileMode, Paths } = await import("expo-file-system"); + const directory = new Directory(Paths.document, OWNED_ATTACHMENT_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const safeName = + Array.from(name, (character) => + character === "/" || character === "\\" || character.charCodeAt(0) < 32 ? "-" : character, + ).join("") || "file"; + const destination = new File(directory, `${uuidv4()}-${safeName}`); + const source = new File(uri); + const sourceSize = source.size; + if ( + maxBytes !== undefined && + (sourceSize === null || (sourceSize === 0 && uri.startsWith("content:"))) + ) { + destination.create(); + try { + const reader = source.open(FileMode.ReadOnly); + try { + const writer = destination.open(FileMode.WriteOnly); + try { + let copiedBytes = 0; + while (true) { + const chunk = reader.readBytes( + Math.min(ATTACHMENT_COPY_CHUNK_BYTES, maxBytes - copiedBytes + 1), + ); + if (chunk.byteLength === 0) { + break; + } + copiedBytes += chunk.byteLength; + if (copiedBytes > maxBytes) { + throw new Error( + `'${name}' exceeds the ${Math.round(maxBytes / (1024 * 1024))} MB attachment limit.`, + ); + } + writer.writeBytes(chunk); + } + } finally { + writer.close(); + } + } finally { + reader.close(); + } + } catch (error) { + if (destination.exists) { + destination.delete(); + } + throw error; + } + return destination.uri; + } + + if (maxBytes !== undefined && sourceSize !== null && sourceSize > maxBytes) { + throw new Error( + `'${name}' exceeds the ${Math.round(maxBytes / (1024 * 1024))} MB attachment limit.`, + ); + } + await source.copy(destination); + return destination.uri; +} + +export async function removePersistedComposerAttachmentFile(uri: string): Promise { + try { + const path = new URL(uri).pathname; + if (!path.split("/").includes(OWNED_ATTACHMENT_DIRECTORY)) { + return; + } + const { File } = await import("expo-file-system"); + const file = new File(uri); + if (file.exists) { + file.delete(); + } + } catch (error) { + console.warn("[composer-attachments] could not remove local file", error); + } +} + +export async function pickComposerFiles(input: { + readonly existingCount: number; + readonly maxBytes?: number; +}): Promise<{ + readonly files: ReadonlyArray; + readonly error: string | null; +}> { + const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; + if (remainingSlots <= 0) { + return { + files: [], + error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + }; + } + + const { File } = await import("expo-file-system"); + const endHandoff = beginForegroundHandoff(); + let result: PickMultipleFilesResult; + try { + result = await File.pickFileAsync({ multipleFiles: true }); + } finally { + endHandoff(); + } + if (result.canceled) { + return { files: [], error: null }; + } + + const maxBytes = Math.min( + input.maxBytes ?? PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + ); + const attachments: DraftComposerFileAttachment[] = []; + let error: string | null = null; + let exceededAttachmentLimit = false; + for (const file of result.result) { + if (attachments.length >= remainingSlots) { + exceededAttachmentLimit = true; + break; + } + const sizeBytes = file.size ?? null; + if (sizeBytes !== null && sizeBytes > maxBytes) { + error = `'${file.name}' exceeds the ${Math.round(maxBytes / (1024 * 1024))} MB attachment limit.`; + continue; + } + try { + const fileUri = await persistComposerAttachmentFile(file.uri, file.name, maxBytes); + const storedSizeBytes = new File(fileUri).size ?? sizeBytes ?? 0; + if (storedSizeBytes <= 0) { + await removePersistedComposerAttachmentFile(fileUri); + error = `'${file.name}' is empty or could not be read.`; + continue; + } + if (storedSizeBytes > maxBytes) { + await removePersistedComposerAttachmentFile(fileUri); + error = `'${file.name}' exceeds the ${Math.round(maxBytes / (1024 * 1024))} MB attachment limit.`; + continue; + } + attachments.push({ + id: uuidv4(), + type: "file", + name: file.name, + mimeType: file.type || "application/octet-stream", + sizeBytes: storedSizeBytes, + fileUri, + }); + } catch (cause) { + error = cause instanceof Error ? cause.message : `Could not read '${file.name}'.`; + } + } + if (exceededAttachmentLimit) { + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`; + } + return { files: attachments, error }; +} async function loadImagePicker() { try { diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 85523175a2f5..75a84a906ee1 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -2,13 +2,15 @@ import { CommandId, MessageId, ThreadId, + type ChatFileAttachment, type ModelSelection, type ProjectId, type ProviderInteractionMode, type RuntimeMode, + type UploadChatImageAttachment, } from "@t3tools/contracts"; -import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages"; +import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; export function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -28,7 +30,8 @@ export interface ProjectThreadStartTurnSpec { readonly messageId: string; readonly createdAt: string; readonly text: string; - readonly attachments: ReadonlyArray; + readonly attachments: ReadonlyArray; + readonly uploadedAttachments?: ReadonlyArray; readonly modelSelection: ModelSelection; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; @@ -55,7 +58,11 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe messageId: MessageId.make(spec.messageId), role: "user" as const, text: spec.text, - attachments: toUploadChatImageAttachments(spec.attachments), + attachments: + spec.uploadedAttachments ?? + toUploadChatImageAttachments( + spec.attachments.filter((attachment) => attachment.type === "image"), + ), }, modelSelection: spec.modelSelection, titleSeed: title, diff --git a/apps/mobile/src/state/attachments.ts b/apps/mobile/src/state/attachments.ts new file mode 100644 index 000000000000..8ecaba8f2243 --- /dev/null +++ b/apps/mobile/src/state/attachments.ts @@ -0,0 +1,15 @@ +import { createEnvironmentRpcCommand } from "@t3tools/client-runtime/state/runtime"; +import { WS_METHODS } from "@t3tools/contracts"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const attachmentEnvironment = { + createUploadUrl: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-command:attachments:create-upload-url", + tag: WS_METHODS.attachmentsCreateUploadUrl, + }), + remove: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-command:attachments:delete", + tag: WS_METHODS.attachmentsDelete, + }), +}; diff --git a/apps/mobile/src/state/thread-outbox-manager.ts b/apps/mobile/src/state/thread-outbox-manager.ts index f6a20ccffc2a..73d87c5177ba 100644 --- a/apps/mobile/src/state/thread-outbox-manager.ts +++ b/apps/mobile/src/state/thread-outbox-manager.ts @@ -126,10 +126,15 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { // Rewrites an already-queued message. A no-op when the message has been // removed in the meantime (e.g. deleted or delivered), so a trailing editor // flush can never resurrect it. Returns whether the message was updated. - const update = (message: QueuedThreadMessage): Promise => + const update = ( + message: QueuedThreadMessage, + expectedMessage?: QueuedThreadMessage, + ): Promise => serialize(async () => { - const exists = currentMessages().some( - (candidate) => candidate.messageId === message.messageId, + const exists = currentMessages().some((candidate) => + expectedMessage === undefined + ? candidate.messageId === message.messageId + : candidate === expectedMessage, ); if (!exists) { return false; @@ -145,6 +150,12 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { cause, }); } + if ( + expectedMessage !== undefined && + !currentMessages().some((candidate) => candidate === expectedMessage) + ) { + return false; + } setMessages([ ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), message, diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index eede506976a7..e4e46fef7f9d 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -17,8 +17,8 @@ import { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; -import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; +import type { DraftComposerAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; const THREAD_OUTBOX_SCHEMA_VERSION = 3; @@ -43,7 +43,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({ messageId: MessageId, commandId: CommandId, text: Schema.String, - attachments: Schema.Array(DraftComposerImageAttachmentSchema), + attachments: Schema.Array(DraftComposerAttachmentSchema), modelSelection: Schema.optional(ModelSelection), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), @@ -72,7 +72,7 @@ export interface QueuedThreadMessage { readonly messageId: MessageId; readonly commandId: CommandId; readonly text: string; - readonly attachments: ReadonlyArray; + readonly attachments: ReadonlyArray; readonly modelSelection?: ModelSelectionType; readonly runtimeMode?: RuntimeModeType; readonly interactionMode?: ProviderInteractionModeType; diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index b12ad2dc5843..5d945f34e3ae 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -78,6 +78,29 @@ describe("thread outbox", () => { ).toThrow(); }); + it("persists generic attachment paths without embedding their contents", () => { + const message = { + ...queuedMessage({ + messageId: "message-file", + createdAt: "2026-06-08T10:00:01.000Z", + }), + attachments: [ + { + id: "file-1", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/report.pdf", + uploadedAttachmentId: "pending-report-pdf", + uploadEnvironmentId: EnvironmentId.make("environment-1"), + }, + ], + } satisfies QueuedThreadMessage; + + expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(message))).toEqual(message); + }); + it("persists the exact selector snapshot while remaining compatible with v1 messages", () => { const legacyMessage = queuedMessage({ messageId: "message-1", @@ -457,6 +480,77 @@ describe("thread outbox", () => { registry.dispose(); }); + it("rejects an attachment update when the queued message was edited first", async () => { + const registry = AtomRegistry.make(); + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [], + write: async () => undefined, + remove: async () => undefined, + }, + }); + const original = queuedMessage({ + messageId: "message-edit-race", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const edited = { ...original, text: "keep my changes" }; + + await manager.enqueue(original); + await manager.update(edited); + + await expect(manager.update({ ...original, text: "stale upload" }, original)).resolves.toBe( + false, + ); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [edited], + }); + registry.dispose(); + }); + + it("does not publish a stale attachment update after a replacement appears during its write", async () => { + const registry = AtomRegistry.make(); + let resumeWrite: () => void = () => {}; + let signalWriteStarted: () => void = () => {}; + const writeStarted = new Promise((resolve) => { + signalWriteStarted = resolve; + }); + const writeBarrier = new Promise((resolve) => { + resumeWrite = resolve; + }); + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [], + write: async (message) => { + if (message.text === "stale upload") { + signalWriteStarted(); + await writeBarrier; + } + }, + remove: async () => undefined, + }, + }); + const original = queuedMessage({ + messageId: "message-write-race", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const replacement = { ...original, text: "newer edit" }; + + await manager.enqueue(original); + const update = manager.update({ ...original, text: "stale upload" }, original); + await writeStarted; + const enqueue = manager.enqueue(replacement); + resumeWrite(); + + await expect(update).resolves.toBe(false); + await enqueue; + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [replacement], + }); + registry.dispose(); + }); + it("only removes a missing-thread message after shell synchronization is live", () => { expect( resolveThreadOutboxDeliveryAction({ diff --git a/apps/mobile/src/state/thread-outbox.ts b/apps/mobile/src/state/thread-outbox.ts index 1de1f8da655c..1fbec4e3c23b 100644 --- a/apps/mobile/src/state/thread-outbox.ts +++ b/apps/mobile/src/state/thread-outbox.ts @@ -37,8 +37,11 @@ export function confirmThreadOutboxMessageQueued(message: QueuedThreadMessage): } /** Rewrite a queued message; no-op (false) if it was removed in the meantime. */ -export function updateThreadOutboxMessage(message: QueuedThreadMessage): Promise { - return threadOutboxManager.update(message); +export function updateThreadOutboxMessage( + message: QueuedThreadMessage, + expectedMessage?: QueuedThreadMessage, +): Promise { + return threadOutboxManager.update(message, expectedMessage); } export function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise { diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 8dbddfe1fece..c65392024fbe 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; -import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentId, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { vi } from "vite-plus/test"; const composerDraftFileMocks = vi.hoisted(() => { @@ -53,13 +59,22 @@ const composerDraftFileMocks = vi.hoisted(() => { }; }); +const composerAttachmentCleanupMocks = vi.hoisted(() => ({ + remove: vi.fn(async () => undefined), +})); + vi.mock("expo-file-system", () => ({ Directory: composerDraftFileMocks.Directory, File: composerDraftFileMocks.File, Paths: { document: "/documents" }, })); +vi.mock("../lib/composerImages", () => ({ + removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove, +})); + import { appAtomRegistry } from "./atom-registry"; +import { threadOutboxManager } from "./thread-outbox"; import { clearComposerDraftContentState, ComposerDraftPersistenceError, @@ -71,6 +86,7 @@ import { flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, + releaseUnusedComposerAttachmentFiles, removeComposerDraftsForEnvironment, restoreComposerDraftSnapshotState, setComposerDraftText, @@ -83,9 +99,146 @@ const DRAFT: ComposerDraft = { afterEach(() => { appAtomRegistry.set(composerDraftsAtom, {}); + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); + composerAttachmentCleanupMocks.remove.mockClear(); }); describe("mobile composer drafts", () => { + it("hydrates generic file attachments from their saved local paths", () => { + const file = { + id: "file-1", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/report.pdf", + }; + + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": { text: "Review this file", attachments: [file] }, + }, + }), + ).toEqual({ + "environment-1:thread-1": { text: "Review this file", attachments: [file] }, + }); + }); + + it("keeps shared attachment files until every draft releases them", async () => { + const file = { + id: "file-1", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/report.pdf", + }; + appAtomRegistry.set(composerDraftsAtom, { + source: { text: "First draft", attachments: [file] }, + copied: { text: "Second draft", attachments: [file] }, + }); + + await releaseUnusedComposerAttachmentFiles([file]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + appAtomRegistry.set(composerDraftsAtom, { + copied: { text: "Second draft", attachments: [file] }, + }); + await releaseUnusedComposerAttachmentFiles([file]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + appAtomRegistry.set(composerDraftsAtom, {}); + await releaseUnusedComposerAttachmentFiles([file]); + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); + }); + + it("keeps local attachment files while an outbox message still needs them", async () => { + const file = { + id: "file-queued", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/report.pdf", + }; + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { + "environment-1:thread-1": [ + { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-1"), + commandId: CommandId.make("command-1"), + text: "Review the report", + attachments: [file], + createdAt: "2026-08-24T12:00:00.000Z", + }, + ], + }); + + await releaseUnusedComposerAttachmentFiles([file]); + + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + }); + + it("loads persisted outbox messages before deciding an attachment file is unused", async () => { + const file = { + id: "file-persisted", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/report.pdf", + }; + const load = vi.spyOn(threadOutboxManager, "load").mockImplementation(async () => { + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { + "environment-1:thread-1": [ + { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-persisted"), + commandId: CommandId.make("command-persisted"), + text: "Review the report", + attachments: [file], + createdAt: "2026-08-24T12:00:00.000Z", + }, + ], + }); + }); + + try { + await releaseUnusedComposerAttachmentFiles([file]); + + expect(load).toHaveBeenCalledOnce(); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + } finally { + load.mockRestore(); + } + }); + + it("does not delete attachment files when the draft removal cannot be saved", async () => { + const file = { + id: "file-unsaved", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/report.pdf", + }; + setComposerDraftText("environment-1:thread-1", "Unsaved draft"); + composerDraftFileMocks.setWriteError(new Error("storage unavailable")); + + try { + await expect(releaseUnusedComposerAttachmentFiles([file])).rejects.toBeInstanceOf( + ComposerDraftPersistenceError, + ); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + } finally { + composerDraftFileMocks.setWriteError(null); + } + }); + it("hydrates selector state even when the message content is empty", () => { expect( decodePersistedComposerDrafts({ diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 7dbea23596c7..d2fd5de9e138 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -14,10 +14,11 @@ import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; import { writeFileAtomically } from "../lib/atomic-file"; -import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; -import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; +import type { DraftComposerAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; +import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; @@ -40,7 +41,7 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass; + readonly attachments: ReadonlyArray; readonly importedShareIds?: ReadonlyArray; readonly modelSelection?: ModelSelection; readonly runtimeMode?: RuntimeMode; @@ -50,7 +51,7 @@ export interface ComposerDraft { export interface ComposerDraftContent { readonly text: string; - readonly attachments: ReadonlyArray; + readonly attachments: ReadonlyArray; readonly sourceShareId?: string; } @@ -75,7 +76,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ const ComposerDraftSchema = Schema.Struct({ text: Schema.String, - attachments: Schema.Array(DraftComposerImageAttachmentSchema), + attachments: Schema.Array(DraftComposerAttachmentSchema), importedShareIds: Schema.optional(Schema.Array(Schema.String)), modelSelection: Schema.optional(ModelSelectionSchema), runtimeMode: Schema.optional(RuntimeModeSchema), @@ -229,6 +230,51 @@ export async function flushComposerDrafts(): Promise { } while (persistTimer !== null); } +export async function releaseUnusedComposerAttachmentFiles( + attachments: ReadonlyArray, +): Promise { + const candidates = new Set( + attachments + .filter((attachment) => attachment.type === "file") + .map((attachment) => attachment.fileUri), + ); + if (candidates.size === 0) { + return; + } + + await flushComposerDrafts(); + await threadOutboxManager.load(); + await flushThreadOutbox(); + + const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom)); + const queuedMessages = Object.values( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ).flat(); + for (const owner of [...drafts, ...queuedMessages]) { + for (const attachment of owner.attachments) { + if (attachment.type === "file") { + candidates.delete(attachment.fileUri); + } + } + } + + if (candidates.size > 0) { + const { removePersistedComposerAttachmentFile } = await import("../lib/composerImages"); + await Promise.all([...candidates].map((uri) => removePersistedComposerAttachmentFile(uri))); + } +} + +export function scheduleUnusedComposerAttachmentCleanup( + attachments: ReadonlyArray, +): void { + if (!attachments.some((attachment) => attachment.type === "file")) { + return; + } + void releaseUnusedComposerAttachmentFiles(attachments).catch((error) => { + console.warn("[composer-attachments] could not remove unused files", error); + }); +} + function schedulePersistComposerDrafts(drafts: Record): void { if (persistTimer !== null) { clearTimeout(persistTimer); @@ -268,6 +314,14 @@ export function ensureComposerDraftsLoaded(): void { }); } +/** Wait until persisted drafts have been merged into the in-memory composer state. */ +export async function waitForComposerDraftsLoaded(): Promise { + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } +} + function updateComposerDrafts( update: (current: Record) => Record, ): void { @@ -313,7 +367,7 @@ export function appendComposerDraftText(draftKey: string, value: string): void { export function appendComposerDraftAttachments( draftKey: string, - attachments: ReadonlyArray, + attachments: ReadonlyArray, ): void { if (attachments.length === 0) { return; @@ -332,8 +386,9 @@ export function appendComposerDraftAttachments( export function replaceComposerDraftAttachments( draftKey: string, - attachments: ReadonlyArray, + attachments: ReadonlyArray, ): void { + const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; updateComposerDrafts((current) => { const draft = { ...normalizeDraft(current[draftKey]), @@ -349,9 +404,14 @@ export function replaceComposerDraftAttachments( [draftKey]: draft, }; }); + const retainedIds = new Set(attachments.map((attachment) => attachment.id)); + scheduleUnusedComposerAttachmentCleanup( + previousAttachments.filter((attachment) => !retainedIds.has(attachment.id)), + ); } export function removeComposerDraftAttachment(draftKey: string, imageId: string): void { + const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); const draft = { @@ -368,6 +428,9 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) [draftKey]: draft, }; }); + scheduleUnusedComposerAttachmentCleanup( + previousAttachments.filter((attachment) => attachment.id === imageId), + ); } export function updateComposerDraftSettings( @@ -601,10 +664,13 @@ export function clearComposerDraftContent( draftKey: string, options?: { readonly clearWorkspaceSelection?: boolean }, ): void { + const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options)); + scheduleUnusedComposerAttachmentCleanup(previousAttachments); } export function clearComposerDraft(draftKey: string): void { + const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; updateComposerDrafts((current) => { if (!current[draftKey]) { return current; @@ -613,6 +679,7 @@ export function clearComposerDraft(draftKey: string): void { delete next[draftKey]; return next; }); + scheduleUnusedComposerAttachmentCleanup(previousAttachments); } export function removeComposerDraftsForEnvironment( @@ -635,10 +702,11 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI await loadPromise; } - const next = removeComposerDraftsForEnvironment( - appAtomRegistry.get(composerDraftsAtom), - environmentId, - ); + const current = appAtomRegistry.get(composerDraftsAtom); + const next = removeComposerDraftsForEnvironment(current, environmentId); + const removedAttachments = Object.entries(current) + .filter(([draftKey]) => next[draftKey] === undefined) + .flatMap(([, draft]) => draft.attachments); if (persistTimer !== null) { clearTimeout(persistTimer); @@ -646,6 +714,7 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI } appAtomRegistry.set(composerDraftsAtom, next); await persistenceQueue.run(() => writePersistedComposerDrafts(next)); + await releaseUnusedComposerAttachmentFiles(removedAttachments); } export function useComposerDraft(draftKey: string | null): ComposerDraft { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index dd7ace60ad99..ec8cf0dd9992 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -6,6 +6,7 @@ import * as Cause from "effect/Cause"; import { CommandId, MessageId, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, type EnvironmentId, type ModelSelection, type ProviderInteractionMode, @@ -26,6 +27,7 @@ import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { convertPastedImagesToAttachments, pasteComposerClipboard, + pickComposerFiles, pickComposerImages, } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; @@ -304,6 +306,31 @@ export function useThreadComposerState() { } }, [composerDrafts, selectedThreadShell]); + const onPickDraftFiles = useCallback(async () => { + if (!selectedThreadShell) { + return; + } + const maxBytes = + selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.fileAttachments + ?.maxUploadBytes; + if (maxBytes === undefined) { + Alert.alert("Could not attach file", "This server does not support file attachments."); + return; + } + + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const result = await pickComposerFiles({ + existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + maxBytes: Math.min(maxBytes, PROVIDER_SEND_TURN_MAX_FILE_BYTES), + }); + if (result.files.length > 0) { + appendComposerDraftAttachments(threadKey, result.files); + } + if (result.error) { + Alert.alert("Could not attach file", result.error); + } + }, [composerDrafts, selectedEnvironmentRuntime?.serverConfig, selectedThreadShell]); + const onPasteIntoDraft = useCallback(async () => { if (!selectedThreadShell) { return; @@ -404,6 +431,7 @@ export function useThreadComposerState() { interactionMode, onChangeDraftMessage, onPickDraftImages, + onPickDraftFiles, onPasteIntoDraft, onNativePasteImages, onRemoveDraftImage, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 68c973ff97e3..ddd67521dd5f 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -8,6 +8,8 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, type MessageId, } from "@t3tools/contracts"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; @@ -15,16 +17,21 @@ import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { scopedThreadKey } from "../lib/scopedEntities"; +import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; -import { toUploadChatImageAttachments } from "../lib/composerImages"; +import { + deletePendingMobileAttachments, + uploadMobileAttachments, + withUploadedMobileAttachmentReferences, +} from "../lib/attachmentUpload"; import { randomHex } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; -import { useProjects, useThreadShells } from "./entities"; +import { useProjects, useServerConfigs, useThreadShells } from "./entities"; import { confirmThreadOutboxMessageQueued, ensureThreadOutboxLoaded, removeThreadOutboxMessage, + updateThreadOutboxMessage, } from "./thread-outbox"; import { isQueuedThreadCreationSendable, @@ -32,19 +39,34 @@ import { resolveThreadOutboxDeliveryAction, resolveThreadOutboxFailureAction, resolveQueuedThreadSettings, + shouldRetryThreadOutboxDelivery, threadOutboxRetryDelayMs, type QueuedThreadCreation, type QueuedThreadMessage, type ThreadOutboxCommandStage, } from "./thread-outbox-model"; import { threadEnvironment } from "./threads"; +import { + composerDraftsAtom, + flushComposerDrafts, + getComposerDraftSnapshot, + mergeComposerDraftContent, + replaceComposerDraftAttachments, + releaseUnusedComposerAttachmentFiles, + restoreComposerDraftSnapshot, + updateComposerDraftSettings, + waitForComposerDraftsLoaded, +} from "./use-composer-drafts"; import { useAtomCommand } from "./use-atom-command"; import { editingQueuedMessageIdsAtom, useThreadOutboxMessages, useThreadOutboxShellStatuses, } from "./use-thread-outbox"; -import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; +import { + setPendingConnectionError, + useRemoteConnectionStatus, +} from "./use-remote-environment-registry"; export const dispatchingQueuedMessageIdAtom = Atom.make(null).pipe( Atom.keepAlive, @@ -85,6 +107,175 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma return CommandId.make(`${message.commandId}:${setting}`); } +async function persistQueuedAttachmentUploads( + queuedMessage: QueuedThreadMessage, + uploaded: Awaited>, +): Promise { + const attachments = withUploadedMobileAttachmentReferences({ + environmentId: queuedMessage.environmentId, + attachments: queuedMessage.attachments, + uploadedAttachments: uploaded.attachments, + }); + if (attachments.every((attachment, index) => attachment === queuedMessage.attachments[index])) { + return queuedMessage; + } + + const previousAttachmentIds = new Set( + queuedMessage.attachments.flatMap((attachment) => + attachment.type === "file" && + attachment.uploadEnvironmentId === queuedMessage.environmentId && + attachment.uploadedAttachmentId + ? [attachment.uploadedAttachmentId] + : [], + ), + ); + const createdAttachmentIds = uploaded.pendingAttachmentIds.filter( + (attachmentId) => !previousAttachmentIds.has(attachmentId), + ); + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + await deletePendingMobileAttachments(queuedMessage.environmentId, createdAttachmentIds); + return null; + } + + const updatedMessage = { ...queuedMessage, attachments }; + try { + if (await updateThreadOutboxMessage(updatedMessage, queuedMessage)) { + return updatedMessage; + } + await deletePendingMobileAttachments(queuedMessage.environmentId, createdAttachmentIds); + return null; + } catch (error) { + await deletePendingMobileAttachments(queuedMessage.environmentId, createdAttachmentIds); + throw error; + } +} + +async function restoreRejectedQueuedMessage( + queuedMessage: QueuedThreadMessage, + message: string, +): Promise<"restored" | "deferred" | "blocked" | "retry"> { + const draftKey = recoveryDraftKey(queuedMessage); + try { + if ( + appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId] || + !(await confirmThreadOutboxMessageQueued(queuedMessage)) || + appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId] + ) { + return "deferred"; + } + + await waitForComposerDraftsLoaded(); + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + return "deferred"; + } + const originalDraft = getComposerDraftSnapshot(draftKey); + const existingAttachmentIds = new Set( + originalDraft.attachments.map((attachment) => attachment.id), + ); + const addedAttachmentCount = queuedMessage.attachments.filter( + (attachment) => !existingAttachmentIds.has(attachment.id), + ).length; + if (existingAttachmentIds.size + addedAttachmentCount > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + setPendingConnectionError( + `Remove attachments from the draft before restoring this message. Messages can contain at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`, + ); + return "blocked"; + } + + await mergeComposerDraftContent(draftKey, { + text: queuedMessage.text, + attachments: queuedMessage.attachments, + }); + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + await restoreComposerDraftSnapshot(draftKey, originalDraft); + return "deferred"; + } + updateComposerDraftSettings(draftKey, { + ...(queuedMessage.modelSelection ? { modelSelection: queuedMessage.modelSelection } : {}), + ...(queuedMessage.runtimeMode ? { runtimeMode: queuedMessage.runtimeMode } : {}), + ...(queuedMessage.interactionMode ? { interactionMode: queuedMessage.interactionMode } : {}), + ...(queuedMessage.creation + ? { + workspaceSelection: { + mode: queuedMessage.creation.workspaceMode, + branch: queuedMessage.creation.branch, + worktreePath: queuedMessage.creation.worktreePath, + ...(queuedMessage.creation.startFromOrigin !== undefined + ? { startFromOrigin: queuedMessage.creation.startFromOrigin } + : {}), + }, + } + : {}), + }); + await flushComposerDrafts(); + if ( + appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId] || + !(await confirmThreadOutboxMessageQueued(queuedMessage)) || + appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId] + ) { + await restoreComposerDraftSnapshot(draftKey, originalDraft); + return "deferred"; + } + await removeThreadOutboxMessage(queuedMessage); + setPendingConnectionError(message); + return "restored"; + } catch (error) { + console.warn("[thread-outbox] failed to restore an undeliverable message", error); + setPendingConnectionError( + error instanceof Error ? error.message : "The unsent message could not be restored.", + ); + return "retry"; + } +} + +function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string { + return queuedMessage.creation + ? `new-task:${scopedProjectKey(queuedMessage.environmentId, queuedMessage.creation.projectId)}` + : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId); +} + +async function preserveUploadedAttachmentsForEditor( + originalMessage: QueuedThreadMessage, + uploadedMessage: QueuedThreadMessage, +): Promise { + if (!originalMessage.creation) { + return; + } + + const draftKey = `pending-task:${originalMessage.messageId}`; + const draft = getComposerDraftSnapshot(draftKey); + const uploadedById = new Map( + uploadedMessage.attachments + .filter((attachment) => attachment.type === "file") + .map((attachment) => [attachment.id, attachment] as const), + ); + let changed = false; + const nextAttachments = draft.attachments.map((attachment) => { + if (attachment.type !== "file") { + return attachment; + } + const uploaded = uploadedById.get(attachment.id); + if ( + !uploaded?.uploadedAttachmentId || + uploaded.uploadEnvironmentId !== originalMessage.environmentId || + (attachment.uploadedAttachmentId === uploaded.uploadedAttachmentId && + attachment.uploadEnvironmentId === uploaded.uploadEnvironmentId) + ) { + return attachment; + } + changed = true; + return { + ...attachment, + uploadedAttachmentId: uploaded.uploadedAttachmentId, + uploadEnvironmentId: uploaded.uploadEnvironmentId, + }; + }); + if (changed) { + replaceComposerDraftAttachments(draftKey, nextAttachments); + await flushComposerDrafts(); + } +} + export function useThreadOutboxDrain(): void { const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -102,11 +293,75 @@ export function useThreadOutboxDrain(): void { const shellStatuses = useThreadOutboxShellStatuses(); const threads = useThreadShells(); const projects = useProjects(); + const serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); const [retryTick, setRetryTick] = useState(0); const retryAttemptRef = useRef(new Map()); const retryNotBeforeRef = useRef(new Map()); const retryTimersRef = useRef(new Map>()); + const blockedRecoverySubscriptionsRef = useRef( + new Map< + MessageId, + { readonly message: QueuedThreadMessage; readonly unsubscribe: () => void } + >(), + ); + + const scheduleQueuedMessageRetry = useCallback((messageId: MessageId) => { + const retryAttempt = (retryAttemptRef.current.get(messageId) ?? 0) + 1; + retryAttemptRef.current.set(messageId, retryAttempt); + const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt); + retryNotBeforeRef.current.set(messageId, Date.now() + retryDelayMs); + const pendingTimer = retryTimersRef.current.get(messageId); + if (pendingTimer !== undefined) { + clearTimeout(pendingTimer); + } + const retryTimer = setTimeout(() => { + retryTimersRef.current.delete(messageId); + setRetryTick((current) => current + 1); + }, retryDelayMs); + retryTimersRef.current.set(messageId, retryTimer); + }, []); + + const restoreQueuedMessage = useCallback( + async (queuedMessage: QueuedThreadMessage, message: string): Promise => { + const result = await restoreRejectedQueuedMessage(queuedMessage, message); + if (result !== "blocked") { + return result !== "retry"; + } + + if (!blockedRecoverySubscriptionsRef.current.has(queuedMessage.messageId)) { + const draftKey = recoveryDraftKey(queuedMessage); + const editorDraftKey = queuedMessage.creation + ? `pending-task:${queuedMessage.messageId}` + : null; + const currentDrafts = appAtomRegistry.get(composerDraftsAtom); + const blockedAttachments = currentDrafts[draftKey]?.attachments; + const editorAttachments = + editorDraftKey === null ? undefined : currentDrafts[editorDraftKey]?.attachments; + const unsubscribe = appAtomRegistry.subscribe(composerDraftsAtom, (drafts) => { + if ( + drafts[draftKey]?.attachments === blockedAttachments && + (editorDraftKey === null || drafts[editorDraftKey]?.attachments === editorAttachments) + ) { + return; + } + const active = blockedRecoverySubscriptionsRef.current.get(queuedMessage.messageId); + if (!active) { + return; + } + blockedRecoverySubscriptionsRef.current.delete(queuedMessage.messageId); + active.unsubscribe(); + setRetryTick((current) => current + 1); + }); + blockedRecoverySubscriptionsRef.current.set(queuedMessage.messageId, { + message: queuedMessage, + unsubscribe, + }); + } + return true; + }, + [], + ); useEffect(() => { ensureThreadOutboxLoaded(); @@ -115,6 +370,10 @@ export function useThreadOutboxDrain(): void { clearTimeout(timer); } retryTimersRef.current.clear(); + for (const blocked of blockedRecoverySubscriptionsRef.current.values()) { + blocked.unsubscribe(); + } + blockedRecoverySubscriptionsRef.current.clear(); }; }, []); @@ -151,6 +410,7 @@ export function useThreadOutboxDrain(): void { try { await removeThreadOutboxMessage(queuedMessage); + await releaseUnusedComposerAttachmentFiles(queuedMessage.attachments); return true; } catch (error) { console.warn("[thread-outbox] failed to remove delivered queued message", { @@ -217,6 +477,30 @@ export function useThreadOutboxDrain(): void { } } + let uploaded: Awaited>; + try { + uploaded = await uploadMobileAttachments({ + environmentId: queuedMessage.environmentId, + attachments: queuedMessage.attachments, + }); + const persistedMessage = await persistQueuedAttachmentUploads(queuedMessage, uploaded); + if (persistedMessage === null) { + return true; + } + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + await preserveUploadedAttachmentsForEditor(queuedMessage, persistedMessage); + return true; + } + } catch (error) { + console.warn("[thread-outbox] failed to upload attachments", error); + if (!shouldRetryThreadOutboxDelivery(error)) { + return restoreQueuedMessage( + queuedMessage, + error instanceof Error ? error.message : "An attachment could not upload.", + ); + } + return false; + } const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: { @@ -226,7 +510,7 @@ export function useThreadOutboxDrain(): void { messageId: queuedMessage.messageId, role: "user", text: queuedMessage.text, - attachments: toUploadChatImageAttachments(queuedMessage.attachments), + attachments: uploaded.attachments, }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, @@ -234,7 +518,14 @@ export function useThreadOutboxDrain(): void { createdAt: queuedMessage.createdAt, }, }); - return completeDelivery(deliveryResult); + const delivered = await completeDelivery(deliveryResult); + if (delivered) { + await deletePendingMobileAttachments( + queuedMessage.environmentId, + uploaded.pendingAttachmentIds, + ); + } + return delivered; }, [ makeDeliveryHelpers, @@ -242,6 +533,7 @@ export function useThreadOutboxDrain(): void { setThreadRuntimeMode, startTurn, updateThreadMetadata, + restoreQueuedMessage, ], ); @@ -256,6 +548,30 @@ export function useThreadOutboxDrain(): void { return false; } const { completeDelivery } = makeDeliveryHelpers(queuedMessage); + let uploaded: Awaited>; + try { + uploaded = await uploadMobileAttachments({ + environmentId: queuedMessage.environmentId, + attachments: queuedMessage.attachments, + }); + const persistedMessage = await persistQueuedAttachmentUploads(queuedMessage, uploaded); + if (persistedMessage === null) { + return true; + } + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + await preserveUploadedAttachmentsForEditor(queuedMessage, persistedMessage); + return true; + } + } catch (error) { + console.warn("[thread-outbox] failed to upload attachments", error); + if (!shouldRetryThreadOutboxDelivery(error)) { + return restoreQueuedMessage( + queuedMessage, + error instanceof Error ? error.message : "An attachment could not upload.", + ); + } + return false; + } const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: buildProjectThreadStartTurnInput({ @@ -267,6 +583,7 @@ export function useThreadOutboxDrain(): void { createdAt: queuedMessage.createdAt, text: queuedMessage.text.trim(), attachments: queuedMessage.attachments, + uploadedAttachments: uploaded.attachments, modelSelection, runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE, interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, @@ -277,9 +594,16 @@ export function useThreadOutboxDrain(): void { worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), }), }); - return completeDelivery(deliveryResult); + const delivered = await completeDelivery(deliveryResult); + if (delivered) { + await deletePendingMobileAttachments( + queuedMessage.environmentId, + uploaded.pendingAttachmentIds, + ); + } + return delivered; }, - [makeDeliveryHelpers, startTurn], + [makeDeliveryHelpers, restoreQueuedMessage, startTurn], ); useEffect(() => { @@ -295,10 +619,63 @@ export function useThreadOutboxDrain(): void { if (editingQueuedMessageIds[nextQueuedMessage.messageId]) { continue; } + const blockedRecovery = blockedRecoverySubscriptionsRef.current.get( + nextQueuedMessage.messageId, + ); + if (blockedRecovery) { + if (blockedRecovery.message === nextQueuedMessage) { + continue; + } + blockedRecoverySubscriptionsRef.current.delete(nextQueuedMessage.messageId); + blockedRecovery.unsubscribe(); + } if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { continue; } + const fileAttachments = nextQueuedMessage.attachments.filter( + (attachment) => attachment.type === "file", + ); + let attachmentError: string | null = null; + if (fileAttachments.length > 0) { + const serverConfig = serverConfigs.get(nextQueuedMessage.environmentId); + if (!serverConfig) { + continue; + } + const maxBytes = serverConfig.environment.capabilities.fileAttachments?.maxUploadBytes; + if (maxBytes === undefined) { + attachmentError = "This server does not support file attachments."; + } else { + const effectiveMaxBytes = Math.min(maxBytes, PROVIDER_SEND_TURN_MAX_FILE_BYTES); + const oversized = fileAttachments.find( + (attachment) => attachment.sizeBytes > effectiveMaxBytes, + ); + if (oversized) { + attachmentError = `'${oversized.name}' exceeds the ${Math.round(effectiveMaxBytes / (1024 * 1024))} MB attachment limit.`; + } + } + } + if (attachmentError !== null) { + beginDispatchingQueuedMessage(nextQueuedMessage.messageId); + void confirmThreadOutboxMessageQueued(nextQueuedMessage) + .then((queued) => { + if ( + !queued || + appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId] + ) { + return true; + } + return restoreQueuedMessage(nextQueuedMessage, attachmentError); + }) + .then((restored) => { + if (!restored) { + scheduleQueuedMessageRetry(nextQueuedMessage.messageId); + } + }) + .finally(() => finishDispatchingQueuedMessage(nextQueuedMessage.messageId)); + return; + } + const thread = findThread(threads, nextQueuedMessage); if (thread && scopedThreadKey(thread.environmentId, thread.id) !== threadKey) { continue; @@ -391,19 +768,7 @@ export function useThreadOutboxDrain(): void { return; } - const retryAttempt = (retryAttemptRef.current.get(nextQueuedMessage.messageId) ?? 0) + 1; - retryAttemptRef.current.set(nextQueuedMessage.messageId, retryAttempt); - const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt); - retryNotBeforeRef.current.set(nextQueuedMessage.messageId, Date.now() + retryDelayMs); - const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); - if (pendingTimer !== undefined) { - clearTimeout(pendingTimer); - } - const retryTimer = setTimeout(() => { - retryTimersRef.current.delete(nextQueuedMessage.messageId); - setRetryTick((current) => current + 1); - }, retryDelayMs); - retryTimersRef.current.set(nextQueuedMessage.messageId, retryTimer); + scheduleQueuedMessageRetry(nextQueuedMessage.messageId); }) .finally(() => { finishDispatchingQueuedMessage(nextQueuedMessage.messageId); @@ -417,8 +782,11 @@ export function useThreadOutboxDrain(): void { projects, queuedMessagesByThreadKey, retryTick, + restoreQueuedMessage, + scheduleQueuedMessageRetry, sendQueuedCreation, sendQueuedMessage, + serverConfigs, shellStatuses, threads, ]); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 232a41e5a9c8..6183deb03504 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -37,7 +37,7 @@ import { timingSafeEqualBase64Url, } from "../auth/utils.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { resolveAttachmentPathById } from "../attachmentStore.ts"; +import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -101,7 +101,11 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); -export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; +export type ResolvedAsset = { + readonly kind: "file"; + readonly path: string; + readonly download?: boolean; +}; function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -464,7 +468,11 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( Effect.orElseSucceed(() => Option.none()), ); return Option.isSome(info) && info.value.type === "File" - ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset) + ? ({ + kind: "file", + path: attachmentPath, + ...(parseAttachmentFileExtension(claims.attachmentId) ? { download: true } : {}), + } satisfies ResolvedAsset) : null; } diff --git a/apps/server/src/assets/AttachmentUpload.test.ts b/apps/server/src/assets/AttachmentUpload.test.ts index cb08d5e4b2f1..c00f7b5f6c94 100644 --- a/apps/server/src/assets/AttachmentUpload.test.ts +++ b/apps/server/src/assets/AttachmentUpload.test.ts @@ -6,9 +6,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { base64UrlEncode, signPayload } from "../auth/utils.ts"; import * as ServerConfig from "../config.ts"; import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts"; import { @@ -30,6 +33,19 @@ const uploadInput = { sizeBytes: 6, } as const; +const LegacyAttachmentUploadClaims = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("attachment-upload"), + attachmentId: Schema.String, + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + expiresAt: Schema.Number, +}); +const encodeLegacyAttachmentUploadClaims = Schema.encodeEffect( + Schema.fromJsonString(LegacyAttachmentUploadClaims), +); + describe("AttachmentUpload", () => { it.effect("signs the attachment metadata and validates the upload token", () => Effect.gen(function* () { @@ -59,6 +75,31 @@ describe("AttachmentUpload", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("accepts unexpired image upload tokens issued before file support", () => + Effect.gen(function* () { + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const secret = yield* secretStore.getOrCreateRandom("asset-access-signing-key", 32); + const encodedPayload = base64UrlEncode( + yield* encodeLegacyAttachmentUploadClaims({ + version: 1, + kind: "attachment-upload", + attachmentId: issued.attachmentId, + name: uploadInput.name, + mimeType: uploadInput.mimeType, + sizeBytes: uploadInput.sizeBytes, + expiresAt: issued.expiresAt, + }), + ); + const legacyToken = `${encodedPayload}.${signPayload(encodedPayload, secret)}`; + + expect(yield* validateAttachmentUploadToken(legacyToken)).toMatchObject({ + type: "image", + attachmentId: issued.attachmentId, + }); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("rejects expired upload tokens", () => Effect.gen(function* () { const issued = yield* issueAttachmentUploadUrl(uploadInput); @@ -108,6 +149,55 @@ describe("AttachmentUpload", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("streams generic files to a path with their original extension", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const issued = yield* issueAttachmentUploadUrl({ + type: "file", + name: "report.PDF", + mimeType: "application/pdf", + sizeBytes: 6, + }); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + expect( + yield* storeAttachmentUpload( + claims, + Stream.make(new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])), + ), + ).toEqual({ ok: true }); + expect(issued.attachmentId).toMatch(/-pdf$/); + expect( + NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.pdf`)), + ).toEqual(Buffer.from([1, 2, 3, 4, 5, 6])); + + yield* deletePendingAttachment(issued.attachmentId); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes partial streamed uploads that exceed their signed size", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + expect(yield* storeAttachmentUpload(claims, Stream.make(new Uint8Array(7)))).toMatchObject({ + ok: false, + status: 400, + }); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("deletes pending uploads without deleting thread-owned copies", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/assets/AttachmentUpload.ts b/apps/server/src/assets/AttachmentUpload.ts index 6142b69d7342..3fb5c92329f0 100644 --- a/apps/server/src/assets/AttachmentUpload.ts +++ b/apps/server/src/assets/AttachmentUpload.ts @@ -12,8 +12,11 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { + attachmentFileExtension, createPendingAttachmentId, parseThreadSegmentFromAttachmentId, PENDING_ATTACHMENT_THREAD_SEGMENT, @@ -41,6 +44,9 @@ const lastPendingSweepByDirectory = new Map(); const AttachmentUploadClaims = Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("attachment-upload"), + type: Schema.Literals(["image", "file"]).pipe( + Schema.withDecodingDefault(Effect.succeed("image" as const)), + ), attachmentId: Schema.String, name: Schema.String, mimeType: Schema.String, @@ -89,12 +95,16 @@ export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(f } } - const attachmentId = createPendingAttachmentId(); + const attachmentType = input.type ?? "image"; + const attachmentId = createPendingAttachmentId( + attachmentType === "file" ? attachmentFileExtension(input.name) : undefined, + ); const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS; const encodedPayload = base64UrlEncode( encodeAttachmentUploadClaims({ version: 1, kind: "attachment-upload", + type: attachmentType, attachmentId, name: input.name, mimeType: input.mimeType, @@ -141,18 +151,21 @@ export type StoreAttachmentUploadResult = export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* ( claims: AttachmentUploadClaims, - bytes: Uint8Array, + body: Uint8Array | HttpServerRequest.HttpServerRequest["stream"], ) { - if (bytes.byteLength !== claims.sizeBytes) { + if (body instanceof Uint8Array && body.byteLength !== claims.sizeBytes) { return { ok: false, status: 400, - detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`, + detail: `Body was ${body.byteLength} bytes, expected ${claims.sizeBytes}.`, } satisfies StoreAttachmentUploadResult; } const config = yield* ServerConfig.ServerConfig; - const extension = inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name }); + const extension = + claims.type === "file" + ? attachmentFileExtension(claims.name) + : inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name }); const relativePath = `${claims.attachmentId}${extension}`; const finalPath = resolveAttachmentRelativePath({ attachmentsDir: config.attachmentsDir, @@ -168,9 +181,27 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + let receivedBytes = 0; + const bodyStream = body instanceof Uint8Array ? Stream.make(body) : body; return yield* Effect.gen(function* () { yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true }); - yield* fileSystem.writeFile(partPath, bytes); + yield* Stream.run( + bodyStream.pipe( + Stream.takeWhile((chunk) => { + receivedBytes += chunk.byteLength; + return receivedBytes <= claims.sizeBytes; + }), + ), + fileSystem.sink(partPath), + ); + if (receivedBytes !== claims.sizeBytes) { + yield* fileSystem.remove(partPath, { force: true }); + return { + ok: false, + status: 400, + detail: `Body was ${receivedBytes} bytes, expected ${claims.sizeBytes}.`, + } satisfies StoreAttachmentUploadResult; + } yield* fileSystem.rename(partPath, finalPath); return { ok: true } satisfies StoreAttachmentUploadResult; }).pipe( diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 5e782e55407f..2976511c55c7 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -6,9 +6,11 @@ import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { + attachmentFileExtension, createAttachmentId, createPendingAttachmentId, parseAttachmentUuid, + parseAttachmentFileExtension, planAttachmentClaim, parseThreadSegmentFromAttachmentId, resolveAttachmentPathById, @@ -58,6 +60,18 @@ describe("attachmentStore", () => { ); }); + it("preserves safe file extensions in attachment ids and paths", () => { + const attachmentId = createPendingAttachmentId(".PDF"); + + expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("pending"); + expect(parseAttachmentUuid(attachmentId)).toMatch(/^[a-f0-9-]{36}$/); + expect(parseAttachmentFileExtension(attachmentId)).toBe("pdf"); + expect(attachmentFileExtension("report.PDF")).toBe(".pdf"); + expect(attachmentFileExtension("report")).toBe(".bin"); + expect(attachmentFileExtension("report.extensiontoolong")).toBe(".bin"); + expect(createAttachmentId("x".repeat(80), ".abcdefghij")?.length).toBeLessThanOrEqual(128); + }); + it("resolves attachment path by id using the extension that exists on disk", () => { const attachmentsDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), @@ -92,6 +106,21 @@ describe("attachmentStore", () => { } }); + it("resolves generic attachments without scanning the attachment directory", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-file-attachment-"), + ); + try { + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-zip"; + const archivePath = NodePath.join(attachmentsDir, `${attachmentId}.zip`); + NodeFS.writeFileSync(archivePath, Buffer.from("archive")); + + expect(resolveAttachmentPathById({ attachmentsDir, attachmentId })).toBe(archivePath); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + it("plans pending attachment claims with direct filename lookups", () => { const attachmentsDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"), @@ -147,15 +176,17 @@ describe("attachmentStore", () => { const oldTimeSeconds = (now - 2 * 24 * 60 * 60 * 1000) / 1000; const uuid = "00000000-0000-4000-8000-000000000002"; const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`); + const pendingFilePath = NodePath.join(attachmentsDir, `pending-${uuid}-pdf.pdf`); const threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`); const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`); - for (const filePath of [pendingPath, threadPath, partialPath]) { + for (const filePath of [pendingPath, pendingFilePath, threadPath, partialPath]) { NodeFS.writeFileSync(filePath, Buffer.from("pixels")); NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds); } - expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 2 }); + expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 3 }); expect(NodeFS.existsSync(pendingPath)).toBe(false); + expect(NodeFS.existsSync(pendingFilePath)).toBe(false); expect(NodeFS.existsSync(partialPath)).toBe(false); expect(NodeFS.existsSync(threadPath)).toBe(true); } finally { diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index d0334bce09f3..a03794f2527d 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -15,8 +15,9 @@ const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +const ATTACHMENT_ID_FILE_EXTENSION_PATTERN = "[a-z0-9]{1,10}"; const ATTACHMENT_ID_PATTERN = new RegExp( - `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`, + `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})(?:-(${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}))?$`, "i", ); @@ -39,8 +40,23 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null { return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment; } -export function createPendingAttachmentId(): string { - return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}`; +export function attachmentFileExtension(fileName: string): string { + const extension = NodePath.extname(fileName).toLowerCase(); + return /^\.[a-z0-9]{1,10}$/.test(extension) ? extension : ".bin"; +} + +function attachmentIdExtensionSuffix(extension: string | undefined): string { + if (!extension) { + return ""; + } + const normalized = extension.replace(/^\./, "").toLowerCase(); + return new RegExp(`^${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}$`).test(normalized) + ? `-${normalized}` + : "-bin"; +} + +export function createPendingAttachmentId(extension?: string): string { + return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`; } export function parseAttachmentUuid(attachmentId: string): string | null { @@ -51,12 +67,20 @@ export function parseAttachmentUuid(attachmentId: string): string | null { return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null; } -export function createAttachmentId(threadId: string): string | null { +export function parseAttachmentFileExtension(attachmentId: string): string | null { + const normalizedId = normalizeAttachmentRelativePath(attachmentId); + if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { + return null; + } + return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[3]?.toLowerCase() ?? null; +} + +export function createAttachmentId(threadId: string, extension?: string): string | null { const threadSegment = toSafeThreadAttachmentSegment(threadId); if (!threadSegment) { return null; } - return `${threadSegment}-${NodeCrypto.randomUUID()}`; + return `${threadSegment}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`; } export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { @@ -80,6 +104,8 @@ export function attachmentRelativePath(attachment: ChatAttachment): string { }); return `${attachment.id}${extension}`; } + case "file": + return `${attachment.id}${attachmentFileExtension(attachment.name)}`; } } @@ -101,6 +127,14 @@ export function resolveAttachmentPathById(input: { if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { return null; } + const fileExtension = parseAttachmentFileExtension(normalizedId); + if (fileExtension) { + const filePath = resolveAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + relativePath: `${normalizedId}.${fileExtension.toLowerCase()}`, + }); + return filePath && NodeFS.existsSync(filePath) ? filePath : null; + } for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) { const maybePath = resolveAttachmentRelativePath({ attachmentsDir: input.attachmentsDir, @@ -147,7 +181,8 @@ export function planAttachmentClaim(input: { if (!currentPath) { return { ok: false, reason: "attachment not found (removed or expired)" }; } - const finalId = createAttachmentId(input.threadId); + const fileExtension = parseAttachmentFileExtension(input.attachmentId) ?? undefined; + const finalId = createAttachmentId(input.threadId, fileExtension); if (!finalId) { return { ok: false, reason: "failed to create attachment id" }; } diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 531d061219f4..9dc1a8eb5b0c 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -91,6 +91,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.attachmentUploads).toBe(true); + expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.threadPullRequestLinking).toBe(true); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 907a5d64bdfc..4e631dc19d11 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -1,4 +1,8 @@ -import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { + EnvironmentId, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + type ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -147,6 +151,7 @@ export const make = Effect.gen(function* () { repositoryIdentity: true, connectionProbe: true, attachmentUploads: true, + fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, threadSettlement: true, threadSnooze: true, diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index f85de08d40b4..0e2a0f0f61eb 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -55,4 +55,12 @@ describe("assetResponseHeaders", () => { "text/html; charset=utf-8", ); }); + + it("downloads uploaded documents without executing their content", () => { + expect(assetResponseHeaders("/attachments/upload.html", { download: true })).toMatchObject({ + "Content-Disposition": "attachment", + "Content-Security-Policy": "default-src 'none'; sandbox", + "Content-Type": "application/octet-stream", + }); + }); }); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index c3104e7bc420..957031841d86 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -50,15 +50,24 @@ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; -export function assetResponseHeaders(filePath: string): Record { +export function assetResponseHeaders( + filePath: string, + options?: { readonly download?: boolean }, +): Record { const lowerPath = filePath.toLowerCase(); return { "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", - ...(lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") - ? { "Content-Type": "text/html; charset=utf-8" } - : {}), - ...(lowerPath.endsWith(".svg") + ...(options?.download + ? { + "Content-Disposition": "attachment", + "Content-Security-Policy": "default-src 'none'; sandbox", + "Content-Type": "application/octet-stream", + } + : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") + ? { "Content-Type": "text/html; charset=utf-8" } + : {}), + ...(!options?.download && lowerPath.endsWith(".svg") ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } : {}), }; @@ -228,7 +237,7 @@ export const assetRouteLayer = HttpRouter.add( } return yield* HttpServerResponse.file(asset.path, { status: 200, - headers: assetResponseHeaders(asset.path), + headers: assetResponseHeaders(asset.path, asset.download ? { download: true } : undefined), }).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); @@ -265,15 +274,7 @@ export const attachmentUploadRouteLayer = HttpRouter.add( }); } - const body = yield* request.arrayBuffer.pipe( - Effect.provideService(HttpServerRequest.MaxBodySize, FileSystem.Size(claims.sizeBytes)), - Effect.orElseSucceed(() => null), - ); - if (body === null) { - return HttpServerResponse.text("Failed to read the upload body.", { status: 400 }); - } - - const stored = yield* storeAttachmentUpload(claims, new Uint8Array(body)); + const stored = yield* storeAttachmentUpload(claims, request.stream); return stored.ok ? HttpServerResponse.empty({ status: 204 }) : HttpServerResponse.text(stored.detail, { status: stored.status }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index cd95293aa635..358cbcadf762 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -870,6 +870,7 @@ it.layer( const now = "2026-01-01T00:00:00.000Z"; const threadId = ThreadId.make("Thread Revert.Files"); const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001"; + const keepFileAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000004-pdf"; const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002"; const otherThreadAttachmentId = "thread-revert-files-extra-00000000-0000-4000-8000-000000000003"; @@ -971,6 +972,13 @@ it.layer( mimeType: "image/png", sizeBytes: 5, }, + { + type: "file", + id: keepFileAttachmentId, + name: "keep.pdf", + mimeType: "application/pdf", + sizeBytes: 5, + }, ], turnId: TurnId.make("turn-keep"), streaming: false, @@ -1033,9 +1041,11 @@ it.layer( }); const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`); + const keepFilePath = path.join(attachmentsDir, `${keepFileAttachmentId}.pdf`); const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`); yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); yield* fileSystem.writeFileString(keepPath, "keep"); + yield* fileSystem.writeFileString(keepFilePath, "keep"); yield* fileSystem.writeFileString(removePath, "remove"); const otherThreadPath = path.join(attachmentsDir, `${otherThreadAttachmentId}.png`); yield* fileSystem.writeFileString(otherThreadPath, "other"); @@ -1060,6 +1070,7 @@ it.layer( }); assert.isTrue(yield* exists(keepPath)); + assert.isTrue(yield* exists(keepFilePath)); assert.isFalse(yield* exists(removePath)); assert.isTrue(yield* exists(otherThreadPath)); }), @@ -1079,6 +1090,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta const now = "2026-01-01T00:00:00.000Z"; const threadId = ThreadId.make("Thread Delete.Files"); const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001"; + const fileAttachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000003-pdf"; const otherThreadAttachmentId = "thread-delete-files-extra-00000000-0000-4000-8000-000000000002"; @@ -1157,6 +1169,13 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta mimeType: "image/png", sizeBytes: 5, }, + { + type: "file", + id: fileAttachmentId, + name: "delete.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + }, ], turnId: null, streaming: false, @@ -1166,14 +1185,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta }); const threadAttachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); + const threadFileAttachmentPath = path.join(attachmentsDir, `${fileAttachmentId}.pdf`); const otherThreadAttachmentPath = path.join( attachmentsDir, `${otherThreadAttachmentId}.png`, ); yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); yield* fileSystem.writeFileString(threadAttachmentPath, "delete"); + yield* fileSystem.writeFileString(threadFileAttachmentPath, "delete"); yield* fileSystem.writeFileString(otherThreadAttachmentPath, "other-thread"); assert.isTrue(yield* exists(threadAttachmentPath)); + assert.isTrue(yield* exists(threadFileAttachmentPath)); assert.isTrue(yield* exists(otherThreadAttachmentPath)); yield* appendAndProject({ @@ -1193,6 +1215,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta }); assert.isFalse(yield* exists(threadAttachmentPath)); + assert.isFalse(yield* exists(threadFileAttachmentPath)); assert.isTrue(yield* exists(otherThreadAttachmentPath)); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 8eb8cdb561b8..c4c81d5a0a39 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -361,9 +361,6 @@ function collectThreadAttachmentRelativePaths( const relativePaths = new Set(); for (const message of messages) { for (const attachment of message.attachments ?? []) { - if (attachment.type !== "image") { - continue; - } const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id); if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { continue; diff --git a/apps/server/src/orchestration/Normalizer.attachments.test.ts b/apps/server/src/orchestration/Normalizer.attachments.test.ts index 27a35977ffca..21b7452a8753 100644 --- a/apps/server/src/orchestration/Normalizer.attachments.test.ts +++ b/apps/server/src/orchestration/Normalizer.attachments.test.ts @@ -96,6 +96,9 @@ describe("normalizeDispatchCommand attachments", () => { expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${attachmentId}.png`))).toBe( true, ); + expect(NodeFS.statSync(pendingPath).ino).toBe( + NodeFS.statSync(NodePath.join(config.attachmentsDir, `${attachmentId}.png`)).ino, + ); }).pipe(Effect.provide(testLayer)), ); @@ -124,6 +127,45 @@ describe("normalizeDispatchCommand attachments", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("claims uploaded documents without changing their original extension", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingId = `pending-${attachmentUuid}-pdf`; + const pendingPath = NodePath.join(config.attachmentsDir, `${pendingId}.pdf`); + NodeFS.writeFileSync(pendingPath, Buffer.from("report")); + + const imageCommand = turnStartCommand({ attachments: [] }); + if (imageCommand.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + const normalized = yield* normalizeDispatchCommand({ + ...imageCommand, + message: { + ...imageCommand.message, + attachments: [ + { + type: "file", + id: pendingId, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + }, + ], + }, + }); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + const attachment = normalized.message.attachments[0]!; + expect(attachment.type).toBe("file"); + expect(attachment.id).toMatch(/^thread-1-.*-pdf$/); + const claimedPath = NodePath.join(config.attachmentsDir, `${attachment.id}.pdf`); + expect(NodeFS.readFileSync(claimedPath)).toEqual(Buffer.from("report")); + expect(NodeFS.statSync(claimedPath).ino).toBe(NodeFS.statSync(pendingPath).ino); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("retries a failed bootstrap with a fresh thread id", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -312,7 +354,7 @@ describe("normalizeDispatchCommand attachments", () => { })), }, }).pipe(Effect.flip); - expect(mismatchedType.message).toContain("image type"); + expect(mismatchedType.message).toContain("attachment type"); }).pipe(Effect.provide(testLayer)), ); }); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index bd6a8f242b87..81bed24b6743 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -176,13 +176,13 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }); if (expectedPath !== claim.finalPath) { return yield* new OrchestrationDispatchCommandError({ - message: `Attachment '${attachment.name}' cannot be sent: image type does not match the upload.`, + message: `Attachment '${attachment.name}' cannot be sent: attachment type does not match the upload.`, }); } - // Keep the pending copy until the turn succeeds. A failed thread - // bootstrap can then retry with a fresh thread id. - yield* fileSystem.copyFile(claim.currentPath, claim.finalPath).pipe( + // A hard link keeps the pending upload retryable without copying its bytes. + yield* fileSystem.link(claim.currentPath, claim.finalPath).pipe( + Effect.catch(() => fileSystem.copyFile(claim.currentPath, claim.finalPath)), Effect.mapError( (cause) => new OrchestrationDispatchCommandError({ diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index bd89dc4f8812..26d1d376ba35 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -14,7 +14,6 @@ import type { } from "@t3tools/contracts"; import { ApprovalRequestId, - EnvironmentId, EventId, ProviderDriverKind, ProviderInstanceId, @@ -1145,6 +1144,31 @@ routing.layer("ProviderServiceLive routing", (it) => { const imageOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; assert.equal(imageOnlyInput.input?.startsWith('[Attached image "screenshot.png"'), true); + const fileAttachment = { + type: "file" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 456, + }; + + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "summarize the report", + attachments: [attachment, fileAttachment], + }); + const mixedInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.include(mixedInput.input ?? "", '[Attached file "report.pdf" is saved at: '); + assert.include(mixedInput.input ?? "", `${fileAttachment.id}.pdf]`); + assert.deepEqual(mixedInput.attachments, [attachment]); + + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ threadId: session.threadId, attachments: [fileAttachment] }); + const fileOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.include(fileOnlyInput.input ?? "", '[Attached file "report.pdf" is saved at: '); + assert.deepEqual(fileOnlyInput.attachments, []); + yield* provider.stopSession({ threadId: session.threadId }); }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index b8cd0df539ac..39ce3b88897d 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -729,13 +729,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } - // Adapters inline attachment pixels into the model prompt, but the model's - // tools cannot dereference pixels. Appending the on-disk path is what lets - // a turn like "include this screenshot in the PR" copy the actual file. - // This runs after schema decode, so the appended lines are exempt from the - // PROVIDER_SEND_TURN_MAX_INPUT_CHARS check; attachment count is capped, so - // the overhead is bounded. Unresolvable ids are skipped here and surface - // as adapter errors when the file is read for inlining. + // Images are passed to provider adapters and every attachment gets an + // on-disk path in the prompt. Generic files reach the agent only through + // that path. Missing images can still fail while an adapter reads them; + // file references were already checked when the turn was normalized. const attachmentPathLines = attachments.flatMap((attachment) => { const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, @@ -757,13 +754,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ...(inputTextWithAttachmentPaths !== undefined ? { input: inputTextWithAttachmentPaths } : {}), - attachments, + attachments: attachments.filter((attachment) => attachment.type === "image"), }; yield* Effect.annotateCurrentSpan({ "provider.operation": "send-turn", "provider.thread_id": input.threadId, "provider.interaction_mode": input.interactionMode, - "provider.attachment_count": input.attachments.length, + "provider.attachment_count": attachments.length, }); let metricProvider = "unknown"; let metricModel = input.modelSelection?.model; @@ -807,7 +804,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // often, since every toggle restarts the session. Recording it per turn // gives a usage-weighted view and lets it cross with interactionMode. runtimeMode: routed.runtimeMode, - attachmentCount: input.attachments.length, + attachmentCount: attachments.length, hasInput: typeof input.input === "string" && input.input.trim().length > 0, }); return turn; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c1674361d904..d9f3b2e73bca 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4529,6 +4529,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); assert.equal(streamedResponse.status, 204); yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: streamed.attachmentId }); + + const uploadedFile = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + }); + const fileResponse = yield* HttpClient.post(uploadedFile.relativeUrl, { + body: HttpBody.stream( + Stream.make(new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])), + "application/pdf", + ), + }); + assert.equal(fileResponse.status, 204); + const uploadedFilePath = path.join( + config.attachmentsDir, + `${uploadedFile.attachmentId}.pdf`, + ); + assert.isTrue(yield* fileSystem.exists(uploadedFilePath)); + + const download = yield* client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "attachment", attachmentId: uploadedFile.attachmentId }, + }); + const downloadResponse = yield* HttpClient.get(download.relativeUrl); + assert.equal(downloadResponse.status, 200); + assert.equal(downloadResponse.headers["content-disposition"], "attachment"); + assert.equal(downloadResponse.headers["content-type"], "application/octet-stream"); + + yield* client[WS_METHODS.attachmentsDelete]({ + attachmentId: uploadedFile.attachmentId, + }); + assert.isFalse(yield* fileSystem.exists(uploadedFilePath)); }), ), ); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 1fcf9bc4c73a..04dda5955fe9 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -18,6 +18,7 @@ const runtimeMock = { state: { startCalls: [] as string[], promptUrls: [] as string[], + promptParts: [] as ReadonlyArray[], authHeaders: [] as Array, closeCalls: [] as string[], sessionCreateError: undefined as unknown, @@ -30,6 +31,7 @@ const runtimeMock = { reset() { this.state.startCalls.length = 0; this.state.promptUrls.length = 0; + this.state.promptParts.length = 0; this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; this.state.sessionCreateError = undefined; @@ -73,8 +75,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { } return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; }, - prompt: async () => { + prompt: async (input: { readonly parts: ReadonlyArray }) => { runtimeMock.state.promptUrls.push(baseUrl); + runtimeMock.state.promptParts.push(input.parts); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); @@ -187,6 +190,45 @@ const advanceIdleClock = Effect.gen(function* () { }); it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { + it.effect("excludes generic files from thread title generation", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [{ type: "text", text: '{"title":"Review uploaded report"}' }], + }, + }; + + yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Review these attachments.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + attachments: [ + { + type: "image", + id: "thread-image-attachment", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + }, + { + type: "file", + id: "thread-report-attachment-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + }, + ], + }); + + expect(runtimeMock.state.promptParts[0]).toEqual([ + expect.objectContaining({ type: "text" }), + expect.objectContaining({ type: "file", filename: "screenshot.png" }), + ]); + }), + ), + ); + it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index e09c3db2cffc..d49ca1bdc614 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -375,7 +375,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" } const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ - attachments: input.attachments, + attachments: input.attachments?.filter((attachment) => attachment.type === "image"), resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0188af478c0..0b9ebb2e3f0b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { type ApprovalRequestId, + type ChatFileAttachment, DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, @@ -17,6 +18,7 @@ import { type TurnId, type KeybindingCommand, OrchestrationThreadActivity, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, ProviderInteractionMode, ProviderDriverKind, RuntimeMode, @@ -230,6 +232,7 @@ import { beginBackgroundDraftSubmissionByRef, clearBackgroundDraftSubmissionByRef, composerDraftHasUserContent, + type ComposerFileAttachment, type ComposerImageAttachment, type DraftThreadEnvMode, finalizePromotedDraftThreadByRef, @@ -376,7 +379,10 @@ import { import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; +import { assetEnvironment } from "../state/assets"; +import { readPreparedConnection } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { Button } from "./ui/button"; import { AlertDialog, @@ -397,10 +403,10 @@ import { resolveServerSelfUpdateCapability, serverUpdateGuidance, } from "../versionSkew"; -import { useAssetUrls } from "../assets/assetUrls"; +import { resolveAssetUrl, useAssetUrls } from "../assets/assetUrls"; -const IMAGE_ONLY_BOOTSTRAP_PROMPT = - "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; +const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = + "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; @@ -1298,6 +1304,9 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + }); const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); @@ -1385,6 +1394,7 @@ function ChatViewContent(props: ChatViewProps) { ); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); + const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); @@ -1411,6 +1421,7 @@ function ChatViewContent(props: ChatViewProps) { ); const promptRef = useRef(""); const composerImagesRef = useRef([]); + const composerFilesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); @@ -2123,6 +2134,12 @@ function ChatViewContent(props: ChatViewProps) { const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; const supportsAttachmentUploads = attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; + const advertisedFileAttachmentBytes = + attachmentEnvironmentConfig?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null; + const maxFileAttachmentBytes = + advertisedFileAttachmentBytes === null + ? null + : Math.min(advertisedFileAttachmentBytes, PROVIDER_SEND_TURN_MAX_FILE_BYTES); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2492,11 +2509,47 @@ function ChatViewContent(props: ChatViewProps) { }); }, []); const serverMessages = activeThread?.messages; + const downloadFileAttachment = useCallback( + async (attachment: ChatFileAttachment) => { + const connection = readPreparedConnection(environmentId); + if (!connection) { + toastManager.add({ type: "error", title: "The environment is not connected." }); + return; + } + + const result = await createAttachmentAssetUrl({ + environmentId, + input: { resource: { _tag: "attachment", attachmentId: attachment.id } }, + }); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: `Could not download ${attachment.name}`, + description: error instanceof Error ? error.message : "The attachment is unavailable.", + }); + return; + } + + const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); + if (!url) { + toastManager.add({ type: "error", title: `Could not download ${attachment.name}` }); + return; + } + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = attachment.name; + anchor.click(); + }, + [createAttachmentAssetUrl, environmentId], + ); const serverAttachmentIds = useMemo(() => { const attachmentIds = new Set(); for (const message of serverMessages ?? []) { for (const attachment of message.attachments ?? []) { - attachmentIds.add(attachment.id); + if (attachment.type === "image") { + attachmentIds.add(attachment.id); + } } } return [...attachmentIds]; @@ -5382,6 +5435,7 @@ function ChatViewContent(props: ChatViewProps) { } const { images: sendContextImages, + files: composerFiles, terminalContexts: composerTerminalContexts, elementContexts: composerElementContexts, previewAnnotations: sendContextPreviewAnnotations, @@ -5420,7 +5474,7 @@ function ChatViewContent(props: ChatViewProps) { hasSendableContent, } = deriveComposerSendState({ prompt: promptForSend, - imageCount: composerImages.length, + imageCount: composerImages.length + composerFiles.length, terminalContexts: composerTerminalContexts, elementContextCount: composerElementContexts.length + @@ -5430,6 +5484,7 @@ function ChatViewContent(props: ChatViewProps) { const feedbackCommand = ctxSelectedProvider === "codex" && composerImages.length === 0 && + composerFiles.length === 0 && sendableComposerTerminalContexts.length === 0 && composerElementContexts.length === 0 && composerPreviewAnnotations.length === 0 && @@ -5522,7 +5577,13 @@ function ChatViewContent(props: ChatViewProps) { ); return; } - if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) { + if ( + !directAnnotation && + showPlanFollowUpPrompt && + activeProposedPlan && + composerImages.length === 0 && + composerFiles.length === 0 + ) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, @@ -5551,6 +5612,7 @@ function ChatViewContent(props: ChatViewProps) { const standaloneSlashCommand = settings.planModeEnabled && composerImages.length === 0 && + composerFiles.length === 0 && sendableComposerTerminalContexts.length === 0 && composerElementContexts.length === 0 && composerPreviewAnnotations.length === 0 && @@ -5607,6 +5669,8 @@ function ChatViewContent(props: ChatViewProps) { } const composerImagesSnapshot = [...composerImages]; + const composerFilesSnapshot = [...composerFiles]; + const composerAttachmentsSnapshot = [...composerImagesSnapshot, ...composerFilesSnapshot]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; @@ -5628,21 +5692,21 @@ function ChatViewContent(props: ChatViewProps) { model: ctxSelectedModel, models: ctxSelectedProviderModels, effort: ctxSelectedPromptEffort, - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, }); if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { return; } sendInFlightRef.current = true; - if (supportsAttachmentUploads && composerImagesSnapshot.length > 0) { - for (const image of composerImagesSnapshot) { - startAttachmentUpload({ environmentId, image }); + if (supportsAttachmentUploads && composerAttachmentsSnapshot.length > 0) { + for (const attachment of composerAttachmentsSnapshot) { + startAttachmentUpload({ environmentId, image: attachment }); } - await awaitAttachmentUploads(composerImagesSnapshot.map((image) => image.id)); - if (getUploadedAttachments({ environmentId, images: composerImagesSnapshot }) === null) { + await awaitAttachmentUploads(composerAttachmentsSnapshot.map((attachment) => attachment.id)); + if (getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null) { sendInFlightRef.current = false; - setThreadError(threadIdForSend, "Retry or remove failed image uploads before sending."); + setThreadError(threadIdForSend, "Retry or remove failed uploads before sending."); return; } } @@ -5679,31 +5743,45 @@ function ChatViewContent(props: ChatViewProps) { const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( - composerImagesSnapshot.map(async (image) => { + composerAttachmentsSnapshot.map(async (attachment) => { if (supportsAttachmentUploads) { - const uploaded = getUploadedAttachments({ environmentId, images: [image] })?.[0]; + const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0]; if (!uploaded) { - throw new Error(`Image '${image.name}' did not finish uploading.`); + throw new Error(`Attachment '${attachment.name}' did not finish uploading.`); } return uploaded; } + if (attachment.type !== "image") { + throw new Error("This server does not support file attachments."); + } return { type: "image" as const, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: await readFileAsDataUrl(attachment.file), }; }), ); - const optimisticAttachments = composerImagesSnapshot.map((image) => ({ - type: "image" as const, - id: image.id, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - previewUrl: image.previewUrl, - })); + const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) => + attachment.type === "image" + ? { + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: attachment.previewUrl, + } + : { + type: "file" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + downloadable: false, + }, + ); const shouldAnchorFirstMessage = activeThread.latestTurn === null && !timelineMessages.some((message) => message.role === "user"); @@ -5765,6 +5843,8 @@ function ChatViewContent(props: ChatViewProps) { if (!titleSeed) { if (firstComposerImageName) { titleSeed = `Image: ${firstComposerImageName}`; + } else if (composerFilesSnapshot[0]) { + titleSeed = `File: ${composerFilesSnapshot[0].name}`; } else if (composerTerminalContextsSnapshot.length > 0) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else if (composerElementContextsSnapshot.length > 0) { @@ -5882,7 +5962,7 @@ function ChatViewContent(props: ChatViewProps) { } else { turnStartSucceeded = true; if (supportsAttachmentUploads) { - releaseAttachmentUploads(composerImagesSnapshot); + releaseAttachmentUploads(composerAttachmentsSnapshot); } acknowledgeActiveThreadWoke(); if (backgroundThreadRef) { @@ -5939,6 +6019,7 @@ function ChatViewContent(props: ChatViewProps) { if ( promptRef.current.length === 0 && composerImagesRef.current.length === 0 && + composerFilesRef.current.length === 0 && composerTerminalContextsRef.current.length === 0 && composerElementContextsRef.current.length === 0 && (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations @@ -5957,10 +6038,12 @@ function ChatViewContent(props: ChatViewProps) { promptRef.current = promptForSend; const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); composerImagesRef.current = retryComposerImages; + composerFilesRef.current = composerFilesSnapshot; composerTerminalContextsRef.current = composerTerminalContextsSnapshot; composerElementContextsRef.current = composerElementContextsSnapshot; setComposerDraftPrompt(composerDraftTarget, promptForSend); addComposerDraftImages(composerDraftTarget, retryComposerImages); + addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot); setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); @@ -6929,6 +7012,7 @@ function ChatViewContent(props: ChatViewProps) { onRevertUserMessage={onRevertUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} + onFileDownload={downloadFileAttachment} markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} @@ -7028,6 +7112,7 @@ function ChatViewContent(props: ChatViewProps) { environmentId={environmentId} attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} supportsAttachmentUploads={supportsAttachmentUploads} + maxFileAttachmentBytes={maxFileAttachmentBytes} routeKind={routeKind} routeThreadRef={routeThreadRef} draftId={draftId} @@ -7082,6 +7167,7 @@ function ChatViewContent(props: ChatViewProps) { gitCwd={gitCwd} promptRef={promptRef} composerImagesRef={composerImagesRef} + composerFilesRef={composerFilesRef} composerTerminalContextsRef={composerTerminalContextsRef} composerElementContextsRef={composerElementContextsRef} onSend={onSend} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 63681a54813a..bdd014ebf555 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -483,6 +483,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { // that only the persisted list is populated, hence max not sum. const attachmentCount = Math.max(composer.images.length, composer.persistedAttachments.length) + + composer.files.length + composer.terminalContexts.length + composer.elementContexts.length + composer.previewAnnotations.length + diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6d0ca8a765ba..c665e8af4e25 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -13,7 +13,6 @@ import type { TurnId, } from "@t3tools/contracts"; import { - isProviderSendTurnSupportedImageMimeType, ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -51,8 +50,10 @@ import { makeComposerMentionDragHandlers, } from "./composerMentionDrag"; import { + type ComposerFileAttachment, type ComposerImageAttachment, type DraftId, + type PersistedComposerFileAttachment, type PersistedComposerImageAttachment, hydrateImagesFromPersisted, useComposerDraftStore, @@ -73,13 +74,15 @@ import { type ComposerTaskStep, type ComposerTasksProgress, } from "./ComposerTasksBadge"; +import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression"; import { - compressImageForStash, - isHeicImageFile, - prepareImageForAttachment, -} from "../../lib/imageCompression"; + classifyComposerAttachmentFile, + shouldHandleComposerAttachmentPaste, +} from "./composerAttachmentFiles"; import { + readAttachmentUpload, releaseAttachmentUpload, + releasePersistedAttachmentUpload, retryAttachmentUpload, startAttachmentUpload, useAttachmentUploadStore, @@ -241,6 +244,8 @@ import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, + FileIcon, + PaperclipIcon, PencilRulerIcon, type LucideIcon, LockIcon, @@ -532,6 +537,7 @@ export interface ChatComposerHandle { getSendContext: () => { prompt: string; images: ComposerImageAttachment[]; + files: ComposerFileAttachment[]; terminalContexts: TerminalContextDraft[]; elementContexts: ElementContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; @@ -557,6 +563,7 @@ export interface ChatComposerProps { environmentId: EnvironmentId; attachmentUploadsCapabilityKnown: boolean; supportsAttachmentUploads: boolean; + maxFileAttachmentBytes: number | null; routeKind: "server" | "draft"; routeThreadRef: ScopedThreadRef; draftId: DraftId | null; @@ -630,6 +637,7 @@ export interface ChatComposerProps { // Refs the parent needs kept in sync promptRef: React.RefObject; composerImagesRef: React.RefObject; + composerFilesRef: React.RefObject; composerTerminalContextsRef: React.RefObject; composerElementContextsRef: React.RefObject; composerRef: React.RefObject; @@ -675,6 +683,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, attachmentUploadsCapabilityKnown, supportsAttachmentUploads, + maxFileAttachmentBytes, routeKind, routeThreadRef, draftId, @@ -721,6 +730,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptRef, composerRef, composerImagesRef, + composerFilesRef, composerTerminalContextsRef, composerElementContextsRef, onSend, @@ -747,6 +757,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerDraft = useComposerThreadDraft(composerDraftTarget); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; + const composerFiles = composerDraft.files; const composerTerminalContexts = composerDraft.terminalContexts; const composerElementContexts = composerDraft.elementContexts; const composerPreviewAnnotations = composerDraft.previewAnnotations; @@ -755,7 +766,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId); const attachmentBlockReason = supportsAttachmentUploads ? attachmentUploadBlockReason({ - imageIds: composerImages.map((image) => image.id), + imageIds: [...composerImages, ...composerFiles].map((attachment) => attachment.id), uploadsByImageId, environmentId, }) @@ -768,6 +779,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); + const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); + const removeComposerDraftFile = useComposerDraftStore((store) => store.removeFile); + const setComposerDraftFileUpload = useComposerDraftStore((store) => store.setFileUpload); const insertComposerDraftTerminalContext = useComposerDraftStore( (store) => store.insertTerminalContext, ); @@ -802,15 +816,41 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } if (!supportsAttachmentUploads) { - for (const image of composerImages) { - releaseAttachmentUpload(image.id); + for (const attachment of [...composerImages, ...composerFiles]) { + releaseAttachmentUpload(attachment.id); } return; } - for (const image of composerImages) { - startAttachmentUpload({ environmentId, image }); + for (const attachment of [...composerImages, ...composerFiles]) { + startAttachmentUpload({ environmentId, image: attachment }); + } + }, [ + attachmentUploadsCapabilityKnown, + composerFiles, + composerImages, + environmentId, + supportsAttachmentUploads, + ]); + + useEffect(() => { + for (const file of composerFiles) { + const upload = uploadsByImageId[file.id]; + if (upload?.status === "ready" && upload.environmentId === environmentId) { + setComposerDraftFileUpload( + composerDraftTarget, + file.id, + environmentId, + upload.attachmentId, + ); + } } - }, [attachmentUploadsCapabilityKnown, composerImages, environmentId, supportsAttachmentUploads]); + }, [ + composerDraftTarget, + composerFiles, + environmentId, + setComposerDraftFileUpload, + uploadsByImageId, + ]); // ------------------------------------------------------------------ // Model state @@ -1060,6 +1100,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Refs // ------------------------------------------------------------------ const composerEditorRef = useRef(null); + const attachmentInputRef = useRef(null); const composerFormRef = useRef(null); const composerSurfaceRef = useRef(null); const providerInputRejectedRef = useRef(false); @@ -1094,7 +1135,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => deriveComposerSendState({ prompt, - imageCount: composerImages.length, + imageCount: composerImages.length + composerFiles.length, terminalContexts: composerTerminalContexts, elementContextCount: composerElementContexts.length + @@ -1103,6 +1144,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), [ composerElementContexts.length, + composerFiles.length, composerImages.length, composerPreviewAnnotations.length, composerReviewComments.length, @@ -1387,6 +1429,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [composerDraftTarget, addComposerDraftImages], ); + const addComposerFilesToDraft = useCallback( + (files: ComposerFileAttachment[]) => { + addComposerDraftFiles(composerDraftTarget, files); + }, + [addComposerDraftFiles, composerDraftTarget], + ); + const removeComposerImageFromDraft = useCallback( (imageId: string) => { releaseAttachmentUpload(imageId); @@ -1395,6 +1444,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [composerDraftTarget, removeComposerDraftImage], ); + const removeComposerFileFromDraft = useCallback( + (fileId: string) => { + releaseAttachmentUpload(fileId); + removeComposerDraftFile(composerDraftTarget, fileId); + }, + [composerDraftTarget, removeComposerDraftFile], + ); + const removeComposerTerminalContextFromDraft = useCallback( (contextId: string) => { const contextIndex = composerTerminalContexts.findIndex( @@ -1451,6 +1508,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerImagesRef.current = composerImages; }, [composerImages, composerImagesRef]); + useEffect(() => { + composerFilesRef.current = composerFiles; + }, [composerFiles, composerFilesRef]); + useEffect(() => { composerTerminalContextsRef.current = composerTerminalContexts; }, [composerTerminalContexts, composerTerminalContextsRef]); @@ -2119,9 +2180,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Prompt stash (⌘S) // ------------------------------------------------------------------ - // One global queue. Stashed prompts carry only text + images so they can be - // restored into any thread or provider — stash, switch, restore is the - // whole point. + // Files remain tied to the environment that owns their uploaded bytes. const stashQueue = usePromptStashStore((state) => state.entries); const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); const takeStashEntry = usePromptStashStore((state) => state.takeEntry); @@ -2150,6 +2209,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const restoreStashEntry = useCallback( (entry: PromptStashEntry) => { + const stashedFiles = entry.files ?? []; + if (stashedFiles.some((file) => file.environmentId !== environmentId)) { + toastManager.add({ + type: "error", + title: "Stashed files belong to another environment", + description: "Restore this prompt in the environment that received its files.", + }); + return; + } // Remove first so a double activation (click + Enter) can't restore twice. const { entry: taken, durable } = takeStashEntry(entry.id); if (!taken) return; @@ -2181,6 +2249,65 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerTrigger(null); } + let unrestoredFileNames: string[] = []; + let restoredFileCount = 0; + if (stashedFiles.length > 0) { + const existingFileIds = new Set(composerFilesRef.current.map((file) => file.id)); + const retainedUploadIds = new Set( + composerFilesRef.current.flatMap((file) => + file.uploadedAttachmentId ? [file.uploadedAttachmentId] : [], + ), + ); + const existingFileKeys = new Set( + composerFilesRef.current.map( + (file) => `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`, + ), + ); + const duplicateFiles: PersistedComposerFileAttachment[] = []; + const filesToRestore = stashedFiles.filter((file) => { + const key = `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`; + if (existingFileIds.has(file.id) || existingFileKeys.has(key)) { + if (!retainedUploadIds.has(file.attachmentId)) { + duplicateFiles.push(file); + } + return false; + } + existingFileIds.add(file.id); + existingFileKeys.add(key); + retainedUploadIds.add(file.attachmentId); + return true; + }); + const capacity = Math.max( + 0, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS - + composerImagesRef.current.length - + composerFilesRef.current.length, + ); + const restoredFiles = filesToRestore.slice(0, capacity).map((file) => ({ + type: "file" as const, + id: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + file: null, + uploadedAttachmentId: file.attachmentId, + uploadEnvironmentId: environmentId, + })); + const skippedFiles = filesToRestore.slice(capacity); + unrestoredFileNames = skippedFiles.map((file) => file.name); + for (const file of [...duplicateFiles, ...skippedFiles]) { + releasePersistedAttachmentUpload({ + id: file.id, + environmentId, + attachmentId: file.attachmentId, + }); + } + if (restoredFiles.length > 0) { + addComposerDraftFiles(composerDraftTarget, restoredFiles); + restoredFileCount = restoredFiles.length; + } + } + let unrestoredImageNames: string[] = []; if (entry.attachments.length > 0) { const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); @@ -2195,7 +2322,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const capacity = Math.max( 0, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS - composerImagesRef.current.length, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS - + composerImagesRef.current.length - + composerFilesRef.current.length - + restoredFileCount, ); const pending = entry.attachments.filter( (attachment) => @@ -2234,13 +2364,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } if (unrestoredImageNames.length > 0) { missingImageReasons.push( - `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-image limit.`, + `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`, + ); + } + if (unrestoredFileNames.length > 0) { + missingImageReasons.push( + `${unrestoredFileNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`, ); } if (missingImageReasons.length > 0) { toastManager.add({ type: "warning", - title: "Some images were not restored", + title: "Some attachments were not restored", description: missingImageReasons.join(" "), }); } @@ -2254,9 +2389,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } }, [ + addComposerDraftFiles, addComposerDraftImages, composerDraftTarget, + composerFilesRef, composerImagesRef, + environmentId, promptRef, setComposerDraftPrompt, takeStashEntry, @@ -2265,7 +2403,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const deleteStashEntry = useCallback( (entry: PromptStashEntry) => { - const { durable } = takeStashEntry(entry.id); + const { entry: removed, durable } = takeStashEntry(entry.id); + if (durable && removed) { + for (const file of removed.files ?? []) { + releasePersistedAttachmentUpload({ + id: file.id, + environmentId: file.environmentId, + attachmentId: file.attachmentId, + }); + } + } if (!durable) { toastManager.add({ type: "warning", @@ -2284,17 +2431,38 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // round-trip, so they are stripped from the stashed prompt. const prompt = promptRef.current.split(INLINE_TERMINAL_CONTEXT_PLACEHOLDER).join("").trim(); const images = [...composerImagesRef.current]; - if (prompt.length === 0 && images.length === 0) { + const files = [...composerFilesRef.current]; + if (prompt.length === 0 && images.length === 0 && files.length === 0) { setIsStashMenuOpen((open) => !open); return; } + const stashedFiles: PersistedComposerFileAttachment[] = []; + for (const file of files) { + const upload = readAttachmentUpload(file.id); + if (upload?.status !== "ready" || upload.environmentId !== environmentId) { + toastManager.add({ + type: "error", + title: "Wait for file uploads before stashing this prompt", + }); + return; + } + stashedFiles.push({ + id: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + attachmentId: upload.attachmentId, + environmentId, + }); + } // A repeat ⌘S on the *same* still-unencoded snapshot would stash it // twice. Guard on the snapshot itself rather than a bare boolean: once // the composer has been cleared the user can type something genuinely // new (or switch threads) while encoding continues, and that deserves its // own entry. const snapshotKey = `${String(composerDraftTarget)}${prompt}${images - .map((image) => image.id) + .map((image) => `image:${image.id}`) + .concat(files.map((file) => `file:${file.id}`)) .join(",")}`; if (stashInFlightRef.current.has(snapshotKey)) return; stashInFlightRef.current.add(snapshotKey); @@ -2312,6 +2480,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) createdAt: new Date().toISOString(), prompt, attachments: [], + ...(stashedFiles.length > 0 ? { files: stashedFiles } : {}), droppedImageNames: [], unreadableImageNames: [], pendingImageCount: images.length, @@ -2344,9 +2513,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); } - // Only the prompt and images are cleared — terminal/element contexts, - // preview annotations, and review comments are not stashable, so - // destroying them here would be unrecoverable. + // Terminal and preview context stays behind because the stash cannot restore it. promptRef.current = ""; clearComposerDraftPromptAndImages(stashTarget); for (const image of images) { @@ -2357,6 +2524,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pulseStashBadge(); if (evicted) { + for (const file of evicted.files ?? []) { + releasePersistedAttachmentUpload({ + id: file.id, + environmentId: file.environmentId, + attachmentId: file.attachmentId, + }); + } toastManager.add({ type: "warning", title: "Oldest stashed prompt discarded", @@ -2432,7 +2606,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [ clearComposerDraftPromptAndImages, composerDraftTarget, + composerFilesRef, composerImagesRef, + environmentId, finalizeStashEntryImages, promptRef, pulseStashBadge, @@ -2578,14 +2754,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); // ------------------------------------------------------------------ - // Callbacks: images + // Callbacks: attachments // ------------------------------------------------------------------ - const addComposerImages = async (files: File[]) => { + const addComposerAttachments = async (files: File[]) => { if (!activeThreadId || files.length === 0) return; if (pendingUserInputs.length > 0) { toastManager.add({ type: "error", - title: "Attach images after answering plan questions.", + title: "Attach files after answering plan questions.", }); return; } @@ -2598,34 +2774,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // accepted files reserve their attachment slots (via the pending counter) // before the first await, keeping the total under the limit. const pendingCount = pendingImageCompressionsRef.current.get(threadId) ?? 0; - let reservedCount = composerImagesRef.current.length + pendingCount; - const acceptedFiles: File[] = []; + let reservedCount = + composerImagesRef.current.length + composerFilesRef.current.length + pendingCount; + const acceptedImages: File[] = []; + const acceptedFiles: ComposerFileAttachment[] = []; let error: string | null = null; for (const file of files) { - const isHeicImage = isHeicImageFile(file); - if (!file.type.startsWith("image/") && !isHeicImage) { - error = `Unsupported file type for '${file.name}'. Please attach image files only.`; - continue; + if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`; + break; } - if (!isHeicImage && !isProviderSendTurnSupportedImageMimeType(file.type)) { + const attachmentKind = classifyComposerAttachmentFile(file); + if (attachmentKind === "unsupported-image") { error = `'${file.name}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.`; continue; } - if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; - break; + if (attachmentKind === "image") { + acceptedImages.push(file); + } else { + if (maxFileAttachmentBytes === null) { + error = "This server does not support file attachments."; + continue; + } + if (file.size <= 0) { + error = `'${file.name}' is empty or could not be read.`; + continue; + } + if (file.size > maxFileAttachmentBytes) { + error = `'${file.name}' exceeds the ${Math.round(maxFileAttachmentBytes / (1024 * 1024))} MB attachment limit.`; + continue; + } + acceptedFiles.push({ + type: "file", + id: randomUUID(), + name: file.name || "file", + mimeType: file.type || "application/octet-stream", + sizeBytes: file.size, + file, + }); } - acceptedFiles.push(file); reservedCount += 1; } setThreadError(threadId, error); - if (acceptedFiles.length === 0) return; + if (acceptedFiles.length > 0) { + addComposerFilesToDraft(acceptedFiles); + } + if (acceptedImages.length === 0) return; - pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedFiles.length); + pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedImages.length); try { const nextImages: ComposerImageAttachment[] = []; let compressionError: string | null = null; - for (const file of acceptedFiles) { + for (const file of acceptedImages) { // Images over the wire cap are downscaled to fit rather than // refused; files already within it pass through byte-for-byte. const compressed = await prepareImageForAttachment( @@ -2665,7 +2865,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } } finally { const remaining = - (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedFiles.length; + (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedImages.length; if (remaining > 0) { pendingImageCompressionsRef.current.set(threadId, remaining); } else { @@ -2683,13 +2883,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); - if (files.length === 0) return; - const imageFiles = files.filter( - (file) => file.type.startsWith("image/") || isHeicImageFile(file), - ); - if (imageFiles.length === 0) return; + if ( + files.length === 0 || + !activeThreadId || + pendingUserInputs.length > 0 || + !shouldHandleComposerAttachmentPaste({ + files, + plainText: event.clipboardData.getData("text/plain"), + maxFileAttachmentBytes, + remainingAttachmentSlots: + PROVIDER_SEND_TURN_MAX_ATTACHMENTS - + composerImagesRef.current.length - + composerFilesRef.current.length - + (pendingImageCompressionsRef.current.get(activeThreadId) ?? 0), + }) + ) { + return; + } event.preventDefault(); - void addComposerImages(imageFiles); + void addComposerAttachments(files); }; const insertComposerTextAtEnd = ( @@ -2815,7 +3027,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerEditorRef.current?.focusAt(cursor); }, addDroppedFiles: (files: File[]) => { - void addComposerImages(files); + void addComposerAttachments(files); focusComposer(); }, insertTextAtEnd: insertComposerTextAtEnd, @@ -2886,6 +3098,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) getSendContext: () => ({ prompt: promptRef.current, images: composerImagesRef.current, + files: composerFilesRef.current, terminalContexts: composerTerminalContextsRef.current, elementContexts: composerElementContextsRef.current, previewAnnotations: composerPreviewAnnotations, @@ -2911,13 +3124,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), [ activeThread, - addComposerImages, + addComposerAttachments, composerDraftTarget, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext, promptRef, composerImagesRef, + composerFilesRef, composerTerminalContextsRef, composerElementContextsRef, composerPreviewAnnotations, @@ -3383,6 +3597,67 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerFiles.length > 0 && ( +
+ {composerFiles.map((file) => { + const upload = uploadsByImageId[file.id]; + const sizeLabel = + file.sizeBytes >= 1024 * 1024 + ? `${(file.sizeBytes / (1024 * 1024)).toFixed(1)} MB` + : `${Math.max(1, Math.ceil(file.sizeBytes / 1024))} KB`; + return ( +
+ + {file.name} + + {upload?.status === "uploading" + ? formatAttachmentUploadProgress(upload.progress) + : sizeLabel} + + {upload?.status === "failed" ? ( + + + retryAttachmentUpload({ environmentId, image: file }) + } + aria-label={`Retry upload for ${file.name}`} + /> + } + > + + + + {upload.reason} + + + ) : null} + +
+ ); + })} +
+ )} +
+ {maxFileAttachmentBytes !== null && pendingUserInputs.length === 0 ? ( + <> + { + const files = Array.from(event.currentTarget.files ?? []); + event.currentTarget.value = ""; + void addComposerAttachments(files); + }} + /> + + attachmentInputRef.current?.click()} + aria-label="Attach files" + /> + } + > + + + Attach files + + + ) : null} {showMobilePendingAnswerActions ? null : inlineTasksBadge} {showMobilePendingAnswerActions ? null : inlineStashBadge} { expect(markup).toContain("size-3.5 stroke-2"); expect(markup).not.toContain("bg-background/90"); }); + + it("labels mixed file and image stashes without treating images as files", () => { + const markup = renderToStaticMarkup( + {}} + onDelete={() => {}} + onClose={() => {}} + />, + ); + + expect(markup).toContain("(2 attachments)"); + expect(markup).toContain("size-3.5 text-secondary-label"); + expect(markup).not.toContain("(2 files)"); + }); }); diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index fc12831025b7..fc97c89d0145 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -1,4 +1,4 @@ -import { XIcon } from "lucide-react"; +import { FileIcon, XIcon } from "lucide-react"; import { memo, useEffect, useRef, useState } from "react"; import { formatRelativeTimeLabel } from "../../timestampFormat"; @@ -20,7 +20,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string { return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed; } const imageCount = entry.attachments.length + entry.droppedImageNames.length; - return imageCount > 0 ? `(${imageCount} image${imageCount === 1 ? "" : "s"})` : "(empty)"; + const fileCount = entry.files?.length ?? 0; + const attachmentCount = imageCount + fileCount; + if (attachmentCount === 0) { + return "(empty)"; + } + const label = imageCount > 0 && fileCount > 0 ? "attachment" : fileCount > 0 ? "file" : "image"; + return `(${attachmentCount} ${label}${attachmentCount === 1 ? "" : "s"})`; } /** @@ -176,6 +182,12 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { ))} ) : null} + {(entry.files?.length ?? 0) > 0 ? ( + + + {entry.files!.length} + + ) : null} {formatRelativeTimeLabel(entry.createdAt)} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 7ee4514c3709..fcd596d2c7a4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -558,6 +558,85 @@ describe("MessagesTimeline", () => { expect(onAnchorReady).not.toHaveBeenCalled(); }); + it("renders generic attachments as download links instead of image previews", () => { + const entry = { + ...buildUserTimelineEntry("Read the report."), + message: { + ...buildUserTimelineEntry("Read the report.").message, + attachments: [ + { + type: "file" as const, + id: "attachment-report-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + previewUrl: "https://environment.test/api/assets/report.pdf", + }, + ], + }, + }; + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('href="https://environment.test/api/assets/report.pdf"'); + expect(markup).toContain('download="report.pdf"'); + expect(markup).not.toContain('alt="report.pdf"'); + }); + + it("renders a file download button without creating its URL in advance", () => { + const entry = { + ...buildUserTimelineEntry("Read the report."), + message: { + ...buildUserTimelineEntry("Read the report.").message, + attachments: [ + { + type: "file" as const, + id: "attachment-report-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + }, + ], + }, + }; + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-label="Download report.pdf"'); + expect(markup).toContain("cursor-pointer"); + expect(markup).not.toContain("href="); + }); + + it("does not download an optimistic file before the server supplies its attachment ID", () => { + const entry = { + ...buildUserTimelineEntry("Read the report."), + message: { + ...buildUserTimelineEntry("Read the report.").message, + attachments: [ + { + type: "file" as const, + id: "composer-local-report", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + downloadable: false, + }, + ], + }, + }; + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("report.pdf"); + expect(markup).not.toContain('aria-label="Download report.pdf"'); + }); + it("keeps reserved end space when tool work starts while reading history", () => { const turnId = TurnId.make("turn-with-active-tool"); const firstEntry = buildUserTimelineEntry("Run the command."); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index af920c0d6156..8ccef34eec6d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,4 +1,5 @@ import { + type ChatFileAttachment, type EnvironmentId, type MessageId, type ScopedThreadRef, @@ -14,6 +15,7 @@ import { const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); const NOOP_OPEN_AGENTS = () => {}; +const NOOP_DOWNLOAD_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { createContext, @@ -50,7 +52,9 @@ import { ChevronDownIcon, ChevronRightIcon, CircleAlertIcon, + DownloadIcon, EyeIcon, + FileIcon, GlobeIcon, HammerIcon, MessageCircleIcon, @@ -142,6 +146,7 @@ interface TimelineRowSharedState { activeThreadEnvironmentId: EnvironmentId; onRevertUserMessage: (messageId: MessageId) => void; onImageExpand: (preview: ExpandedImagePreview) => void; + onFileDownload: (attachment: ChatFileAttachment) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; @@ -220,6 +225,7 @@ interface MessagesTimelineProps { onRevertUserMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; + onFileDownload?: (attachment: ChatFileAttachment) => void; activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; @@ -265,6 +271,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, isRevertingCheckpoint, onImageExpand, + onFileDownload = NOOP_DOWNLOAD_ATTACHMENT, activeThreadEnvironmentId, markdownCwd, resolvedTheme, @@ -523,6 +530,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, + onFileDownload, onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, @@ -539,6 +547,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, + onFileDownload, onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, @@ -987,7 +996,12 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const userImages = row.message.attachments ?? []; + const userImages = (row.message.attachments ?? []).filter( + (attachment) => attachment.type === "image", + ); + const userFiles = (row.message.attachments ?? []).filter( + (attachment) => attachment.type === "file", + ); const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); const terminalContexts = displayedUserMessage.contexts; const previewAnnotations: ParsedPreviewAnnotation[] = []; @@ -1012,7 +1026,7 @@ function UserTimelineRow({ row }: { row: Extract {regularImages.length > 0 && (
- {regularImages.map((image: NonNullable[number]) => ( + {regularImages.map((image) => (
))} + {userFiles.length > 0 ? ( +
+ {userFiles.map((file) => { + const content = ( + <> + + {file.name} + {file.downloadable === false ? null : ( + + )} + + ); + return file.previewUrl ? ( + + {content} + + ) : file.downloadable === false ? ( +
+ {content} +
+ ) : ( + + ); + })} +
+ ) : null} {elementContexts.length > 0 ? (
{elementContexts.map((context) => ( diff --git a/apps/web/src/components/chat/composerAttachmentFiles.test.ts b/apps/web/src/components/chat/composerAttachmentFiles.test.ts new file mode 100644 index 000000000000..203950a96773 --- /dev/null +++ b/apps/web/src/components/chat/composerAttachmentFiles.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + classifyComposerAttachmentFile, + shouldHandleComposerAttachmentPaste, +} from "./composerAttachmentFiles"; + +describe("composer attachment files", () => { + it("keeps supported images and HEIC photos on the image path", () => { + expect(classifyComposerAttachmentFile({ name: "photo.png", type: "image/png" })).toBe("image"); + expect(classifyComposerAttachmentFile({ name: "photo.heic", type: "" })).toBe("image"); + }); + + it("rejects unsupported image types instead of attaching them as generic files", () => { + expect(classifyComposerAttachmentFile({ name: "diagram.svg", type: "image/svg+xml" })).toBe( + "unsupported-image", + ); + expect(classifyComposerAttachmentFile({ name: "photo.tiff", type: "image/tiff" })).toBe( + "unsupported-image", + ); + expect(classifyComposerAttachmentFile({ name: "report.pdf", type: "application/pdf" })).toBe( + "file", + ); + }); + + it("preserves text paste when an application adds a synthetic generic file", () => { + const file = new File(["clipboard"], "clipboard.rtf", { type: "application/rtf" }); + + expect( + shouldHandleComposerAttachmentPaste({ + files: [file], + plainText: "Copied text", + maxFileAttachmentBytes: 50 * 1024 * 1024, + remainingAttachmentSlots: 1, + }), + ).toBe(false); + }); + + it("only claims generic file pastes accepted by the current server", () => { + const file = new File(["report"], "report.pdf", { type: "application/pdf" }); + const input = { + files: [file], + plainText: "", + remainingAttachmentSlots: 1, + }; + + expect(shouldHandleComposerAttachmentPaste({ ...input, maxFileAttachmentBytes: null })).toBe( + false, + ); + expect(shouldHandleComposerAttachmentPaste({ ...input, maxFileAttachmentBytes: 1 })).toBe( + false, + ); + expect(shouldHandleComposerAttachmentPaste({ ...input, maxFileAttachmentBytes: 10 })).toBe( + true, + ); + }); + + it("claims image pastes even when clipboard text is present", () => { + const image = new File(["image"], "photo.heic", { type: "image/heic" }); + + expect( + shouldHandleComposerAttachmentPaste({ + files: [image], + plainText: "Image caption", + maxFileAttachmentBytes: null, + remainingAttachmentSlots: 1, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/composerAttachmentFiles.ts b/apps/web/src/components/chat/composerAttachmentFiles.ts new file mode 100644 index 000000000000..d600d5370299 --- /dev/null +++ b/apps/web/src/components/chat/composerAttachmentFiles.ts @@ -0,0 +1,44 @@ +import { isProviderSendTurnSupportedImageMimeType } from "@t3tools/contracts"; + +import { isHeicImageFile } from "../../lib/imageCompression"; + +type ComposerAttachmentFileKind = "image" | "file" | "unsupported-image"; + +export function classifyComposerAttachmentFile( + file: Pick, +): ComposerAttachmentFileKind { + if (isHeicImageFile(file)) { + return "image"; + } + if (!file.type.toLowerCase().startsWith("image/")) { + return "file"; + } + return isProviderSendTurnSupportedImageMimeType(file.type) ? "image" : "unsupported-image"; +} + +export function shouldHandleComposerAttachmentPaste(input: { + readonly files: ReadonlyArray; + readonly plainText: string; + readonly maxFileAttachmentBytes: number | null; + readonly remainingAttachmentSlots: number; +}): boolean { + if (input.remainingAttachmentSlots <= 0) { + return false; + } + + if (input.files.some((file) => classifyComposerAttachmentFile(file) === "image")) { + return true; + } + + const maxFileAttachmentBytes = input.maxFileAttachmentBytes; + if (input.plainText.length > 0 || maxFileAttachmentBytes === null) { + return false; + } + + return input.files.some( + (file) => + classifyComposerAttachmentFile(file) === "file" && + file.size > 0 && + file.size <= maxFileAttachmentBytes, + ); +} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 20c6603f773b..94073af16d89 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -11,6 +11,7 @@ import { ProjectId, ProviderDriverKind, ProviderInstanceId, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ThreadId, type ModelSelection, type ProviderOptionSelection, @@ -65,6 +66,7 @@ import { markPromotedDraftThreadByRef, markPromotedDraftThreads, markPromotedDraftThreadsByRef, + type ComposerFileAttachment, type ComposerImageAttachment, useComposerDraftStore, DraftId, @@ -104,6 +106,18 @@ function makeImage(input: { }; } +function makeFile(id: string): ComposerFileAttachment { + const file = new File(["report"], "report.pdf", { type: "application/pdf" }); + return { + type: "file", + id, + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + file, + }; +} + function makeTerminalContext(input: { id: string; text?: string; @@ -289,6 +303,128 @@ describe("composerDraftStore clearComposerContent", () => { }); }); +describe("composerDraftStore file attachments", () => { + const threadId = ThreadId.make("thread-files"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + + beforeEach(() => { + resetComposerDraftStore(); + }); + + it("persists uploaded file references without including file contents", () => { + const store = useComposerDraftStore.getState(); + store.addFiles(threadRef, [makeFile("file-1")]); + store.setFileUpload(threadRef, "file-1", TEST_ENVIRONMENT_ID, "pending-report-pdf"); + + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + partialize: (state: ReturnType) => unknown; + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + const options = persistApi.getOptions(); + const persisted = options.partialize(useComposerDraftStore.getState()) as { + draftsByThreadKey: Record> }>; + }; + + expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual( + [ + { + id: "file-1", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + attachmentId: "pending-report-pdf", + environmentId: TEST_ENVIRONMENT_ID, + }, + ], + ); + + const hydrated = options.merge(persisted, useComposerDraftStore.getState()); + expect(hydrated.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual([ + { + type: "file", + id: "file-1", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + file: null, + uploadedAttachmentId: "pending-report-pdf", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }, + ]); + }); + + it("removes generic files when the composer is cleared", () => { + const store = useComposerDraftStore.getState(); + store.addFiles(threadRef, [makeFile("file-clear")]); + + store.clearComposerContent(threadRef); + + expect(store.getComposerDraft(threadRef)).toBeNull(); + }); + + it("removes generic files when a prompt is moved into the stash", () => { + const store = useComposerDraftStore.getState(); + store.setPrompt(threadRef, "Review the report"); + store.addFiles(threadRef, [makeFile("file-stash")]); + + store.clearComposerPromptAndImages(threadRef); + + expect(store.getComposerDraft(threadRef)).toBeNull(); + }); + + it("enforces the combined file and image limit across separate updates", () => { + const store = useComposerDraftStore.getState(); + const images = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1 }, (_, index) => + makeImage({ + id: `image-${index}`, + name: `image-${index}.png`, + previewUrl: `blob:image-${index}`, + }), + ); + store.addImages(threadRef, images); + store.addFiles(threadRef, [ + makeFile("file-accepted"), + { ...makeFile("file-overflow"), name: "other.pdf" }, + ]); + store.addImages(threadRef, [ + makeImage({ id: "image-overflow", name: "overflow.png", previewUrl: "blob:overflow" }), + ]); + + const draft = store.getComposerDraft(threadRef); + expect(draft?.images).toHaveLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1); + expect(draft?.files.map((file) => file.id)).toEqual(["file-accepted"]); + }); + + it("keeps the remaining file slot available after a duplicate is skipped", () => { + const store = useComposerDraftStore.getState(); + store.addImages( + threadRef, + Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 2 }, (_, index) => + makeImage({ + id: `image-${index}`, + name: `image-${index}.png`, + previewUrl: `blob:image-${index}`, + }), + ), + ); + store.addFiles(threadRef, [makeFile("file-original")]); + store.addFiles(threadRef, [ + makeFile("file-duplicate"), + { ...makeFile("file-unique"), name: "unique.pdf" }, + ]); + + expect(store.getComposerDraft(threadRef)?.files.map((file) => file.id)).toEqual([ + "file-original", + "file-unique", + ]); + }); +}); + describe("composerDraftStore moveComposerPromptAndImages", () => { const sourceDraftId = DraftId.make("draft-move-source"); const destinationDraftId = DraftId.make("draft-move-destination"); @@ -335,6 +471,81 @@ describe("composerDraftStore moveComposerPromptAndImages", () => { expect(draftByKey(destinationDraftId)?.prompt).toBe(" explain this error"); }); + it("keeps hydrated file references on their original environment", () => { + const sourceRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-file-source")); + const destinationRef = scopeThreadRef( + OTHER_TEST_ENVIRONMENT_ID, + ThreadId.make("thread-file-destination"), + ); + const store = useComposerDraftStore.getState(); + store.setPrompt(sourceRef, "review the report"); + store.addFiles(sourceRef, [ + { + ...makeFile("file-hydrated"), + file: null, + uploadedAttachmentId: "pending-report-pdf", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }, + ]); + + store.moveComposerPromptAndImages(sourceRef, destinationRef); + + expect(store.getComposerDraft(sourceRef)?.files.map((file) => file.id)).toEqual([ + "file-hydrated", + ]); + expect(store.getComposerDraft(destinationRef)?.files).toEqual([]); + expect(store.getComposerDraft(destinationRef)?.prompt).toBe("review the report"); + }); + + it("moves files across environments when the original browser file remains available", () => { + const sourceRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-file-source")); + const destinationRef = scopeThreadRef( + OTHER_TEST_ENVIRONMENT_ID, + ThreadId.make("thread-file-destination"), + ); + const store = useComposerDraftStore.getState(); + store.addFiles(sourceRef, [makeFile("file-local")]); + + store.moveComposerPromptAndImages(sourceRef, destinationRef); + + expect(store.getComposerDraft(sourceRef)).toBeNull(); + expect(store.getComposerDraft(destinationRef)?.files.map((file) => file.id)).toEqual([ + "file-local", + ]); + }); + + it("keeps overflow attachments on the source when the destination is nearly full", () => { + const store = useComposerDraftStore.getState(); + store.addImages( + destinationDraftId, + Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1 }, (_, index) => + makeImage({ + id: `destination-${index}`, + name: `destination-${index}.png`, + previewUrl: `blob:destination-${index}`, + }), + ), + ); + store.addImages(sourceDraftId, [ + makeImage({ id: "source-first", name: "first.png", previewUrl: "blob:first" }), + makeImage({ id: "source-second", name: "second.png", previewUrl: "blob:second" }), + ]); + store.addFiles(sourceDraftId, [makeFile("source-file")]); + + store.moveComposerPromptAndImages(sourceDraftId, destinationDraftId); + + expect(store.getComposerDraft(destinationDraftId)?.images).toHaveLength( + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + ); + expect(store.getComposerDraft(destinationDraftId)?.files).toEqual([]); + expect(store.getComposerDraft(sourceDraftId)?.images.map((image) => image.id)).toEqual([ + "source-second", + ]); + expect(store.getComposerDraft(sourceDraftId)?.files.map((file) => file.id)).toEqual([ + "source-file", + ]); + }); + it("is a no-op when source and destination are the same target", () => { const store = useComposerDraftStore.getState(); store.setPrompt(sourceDraftId, "keep me"); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f20385ee04f4..9a9660302eb3 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2,13 +2,14 @@ import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, - type EnvironmentId, + EnvironmentId, ModelSelection, ProjectId, ProviderInstanceId, ProviderInteractionMode, ProviderDriverKind, ProviderOptionSelection, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PreviewAnnotationPayloadSchema, type PreviewAnnotationPayload, RuntimeMode, @@ -33,7 +34,12 @@ import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model" import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; -import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; +import { + DEFAULT_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type ChatFileAttachment, + type ChatImageAttachment, +} from "./types"; import { type TerminalContextDraft, ensureInlineTerminalContextPlaceholders, @@ -93,6 +99,23 @@ export interface ComposerImageAttachment extends Omit { + file: File | null; + uploadedAttachmentId?: string; + uploadEnvironmentId?: EnvironmentId; +} + +export const PersistedComposerFileAttachment = Schema.Struct({ + id: Schema.String, + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + attachmentId: Schema.String, + environmentId: EnvironmentId, +}); +export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type; +const isPersistedComposerFileAttachment = Schema.is(PersistedComposerFileAttachment); + const PersistedTerminalContextDraft = Schema.Struct({ id: Schema.String, threadId: ThreadId, @@ -129,6 +152,7 @@ type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type; const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), + files: Schema.optionalKey(Schema.Array(PersistedComposerFileAttachment)), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)), previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)), @@ -251,6 +275,7 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ export interface ComposerThreadDraftState { prompt: string; images: ComposerImageAttachment[]; + files: ComposerFileAttachment[]; nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; @@ -295,6 +320,7 @@ export function composerDraftHasUserContent( return ( draft.prompt.trim().length > 0 || draft.images.length > 0 || + draft.files.length > 0 || draft.persistedAttachments.length > 0 || draft.terminalContexts.length > 0 || draft.elementContexts.length > 0 || @@ -473,6 +499,14 @@ interface ComposerDraftStoreState { addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void; addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void; removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void; + addFiles: (threadRef: ComposerThreadTarget, files: ComposerFileAttachment[]) => void; + removeFile: (threadRef: ComposerThreadTarget, fileId: string) => void; + setFileUpload: ( + threadRef: ComposerThreadTarget, + fileId: string, + environmentId: EnvironmentId, + attachmentId: string, + ) => void; insertTerminalContext: ( threadRef: ComposerThreadTarget, prompt: string, @@ -523,19 +557,16 @@ interface ComposerDraftStoreState { ) => void; clearComposerContent: (threadRef: ComposerThreadTarget) => void; /** - * Clears only the prompt text and image attachments, preserving terminal / + * Clears the prompt text and attachments, preserving terminal / * element contexts, preview annotations, and review comments. Used by the - * prompt stash, which can only round-trip text + images: clearing the - * session-bound contexts would destroy state nothing can restore. + * prompt stash. Session-bound context stays in the source draft. */ clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; /** - * Moves the prompt text and image attachments from one composer target to - * another. Used when a draft changes project: the new project gets its own - * draft session and the typed content follows it. Session-bound extras - * (terminal / element contexts, preview annotations, review comments) stay - * on the source — they reference sessions of the source thread that the - * destination cannot use. + * Moves prompt text and transferable attachments into another composer. + * Attachments over the destination limit and uploaded files that belong to + * another environment stay in the source draft. Terminal and element + * context, preview annotations, and review comments also stay in the source. */ moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void; } @@ -604,6 +635,7 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze( const EMPTY_THREAD_DRAFT = Object.freeze({ prompt: "", images: EMPTY_IMAGES, + files: EMPTY_FILES, nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, @@ -648,6 +682,7 @@ export function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", images: [], + files: [], nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], @@ -722,6 +757,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { return ( draft.prompt.length === 0 && draft.images.length === 0 && + draft.files.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && draft.elementContexts.length === 0 && @@ -1694,6 +1730,9 @@ function normalizePersistedDraftsByThreadId( return normalized ? [normalized] : []; }) : []; + const files = Array.isArray(draftCandidate.files) + ? draftCandidate.files.filter(isPersistedComposerFileAttachment) + : []; const terminalContexts = Array.isArray(draftCandidate.terminalContexts) ? draftCandidate.terminalContexts.flatMap((entry) => { const normalized = normalizePersistedTerminalContextDraft(entry); @@ -1771,6 +1810,7 @@ function normalizePersistedDraftsByThreadId( if ( promptCandidate.length === 0 && attachments.length === 0 && + files.length === 0 && terminalContexts.length === 0 && elementContexts.length === 0 && reviewComments.length === 0 && @@ -1795,6 +1835,7 @@ function normalizePersistedDraftsByThreadId( nextDraftsByThreadKey[normalizedThreadKey] = { prompt, attachments, + ...(files.length > 0 ? { files } : {}), ...(terminalContexts.length > 0 ? { terminalContexts } : {}), ...(elementContexts.length > 0 ? { elementContexts } : {}), ...(reviewComments.length > 0 ? { reviewComments } : {}), @@ -1902,6 +1943,7 @@ function partializeComposerDraftStoreState( if ( draft.prompt.length === 0 && draft.persistedAttachments.length === 0 && + draft.files.length === 0 && draft.terminalContexts.length === 0 && draft.elementContexts.length === 0 && draft.previewAnnotations.length === 0 && @@ -1915,6 +1957,24 @@ function partializeComposerDraftStoreState( const persistedDraft: DeepMutable = { prompt: draft.prompt, attachments: draft.persistedAttachments, + ...(draft.files.length > 0 + ? { + files: draft.files.flatMap((file) => + file.uploadedAttachmentId && file.uploadEnvironmentId + ? [ + { + id: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + attachmentId: file.uploadedAttachmentId, + environmentId: file.uploadEnvironmentId, + }, + ] + : [], + ), + } + : {}), ...(draft.terminalContexts.length > 0 ? { terminalContexts: draft.terminalContexts.map((context) => ({ @@ -2195,6 +2255,17 @@ function toHydratedThreadDraft( return { prompt: persistedDraft.prompt, images: hydrateImagesFromPersisted(persistedDraft.attachments), + files: + persistedDraft.files?.map((file) => ({ + type: "file" as const, + id: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + file: null, + uploadedAttachmentId: file.attachmentId, + uploadEnvironmentId: file.environmentId, + })) ?? [], nonPersistedImageIds: [], persistedAttachments: [...persistedDraft.attachments], terminalContexts: @@ -2987,6 +3058,15 @@ const composerDraftStore = create()( } continue; } + if ( + existing.images.length + existing.files.length + dedupedIncoming.length >= + PROVIDER_SEND_TURN_MAX_ATTACHMENTS + ) { + if (!acceptedPreviewUrls.has(image.previewUrl)) { + revokeObjectPreviewUrl(image.previewUrl); + } + continue; + } dedupedIncoming.push(image); existingIds.add(image.id); existingDedupKeys.add(dedupKey); @@ -3041,6 +3121,104 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + addFiles: (threadRef, files) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0 || files.length === 0) { + return; + } + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const knownIds = new Set(existing.files.map((file) => file.id)); + const knownFiles = new Set( + existing.files.map( + (file) => `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`, + ), + ); + const accepted: ComposerFileAttachment[] = []; + for (const file of files) { + const key = `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`; + if (knownIds.has(file.id) || knownFiles.has(key)) { + continue; + } + if ( + existing.images.length + existing.files.length + accepted.length >= + PROVIDER_SEND_TURN_MAX_ATTACHMENTS + ) { + break; + } + accepted.push(file); + knownIds.add(file.id); + knownFiles.add(key); + } + if (accepted.length === 0) { + return state; + } + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { ...existing, files: [...existing.files, ...accepted] }, + }, + }; + }); + }, + removeFile: (threadRef, fileId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current?.files.some((file) => file.id === fileId)) { + return state; + } + const nextDraft = { + ...current, + files: current.files.filter((file) => file.id !== fileId), + } satisfies ComposerThreadDraftState; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + setFileUpload: (threadRef, fileId, environmentId, attachmentId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + const file = current?.files.find((entry) => entry.id === fileId); + if ( + !current || + !file || + (file.uploadEnvironmentId === environmentId && + file.uploadedAttachmentId === attachmentId) + ) { + return state; + } + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...current, + files: current.files.map((entry) => + entry.id === fileId + ? { + ...entry, + uploadedAttachmentId: attachmentId, + uploadEnvironmentId: environmentId, + } + : entry, + ), + }, + }, + }; + }); + }, insertTerminalContext: (threadRef, prompt, context, index) => { const threadKey = resolveComposerDraftKey(get(), threadRef); const threadId = resolveComposerThreadId(get(), threadRef); @@ -3441,6 +3619,7 @@ const composerDraftStore = create()( ...current, prompt: "", images: [], + files: [], nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], @@ -3474,6 +3653,7 @@ const composerDraftStore = create()( ...current, prompt: ensureInlineTerminalContextPlaceholders("", current.terminalContexts.length), images: [], + files: [], nonPersistedImageIds: [], persistedAttachments: [], }; @@ -3498,6 +3678,29 @@ const composerDraftStore = create()( return state; } const destination = state.draftsByThreadKey[toKey] ?? createEmptyThreadDraft(); + const destinationEnvironmentId = + typeof to === "string" + ? (state.draftThreadsByThreadKey[toKey]?.environmentId ?? + parseScopedThreadKey(toKey)?.environmentId ?? + null) + : to.environmentId; + const transferableFiles = source.files.filter( + (file) => file.file !== null || file.uploadEnvironmentId === destinationEnvironmentId, + ); + const remainingAttachmentSlots = Math.max( + 0, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS - + destination.images.length - + destination.files.length, + ); + const movedImages = source.images.slice(0, remainingAttachmentSlots); + const movedImageIds = new Set(movedImages.map((image) => image.id)); + const retainedImages = source.images.filter((image) => !movedImageIds.has(image.id)); + const movedFiles = transferableFiles.slice( + 0, + remainingAttachmentSlots - movedImages.length, + ); + const retainedFiles = source.files.filter((file) => !movedFiles.includes(file)); // Inline placeholders reference the source's terminal contexts, // which stay behind; re-anchor the moved prompt to whatever // contexts the destination already holds. @@ -3508,14 +3711,17 @@ const composerDraftStore = create()( const nextDestination: ComposerThreadDraftState = { ...destination, prompt: movedPrompt, - images: [...destination.images, ...source.images], + images: [...destination.images, ...movedImages], + files: [...destination.files, ...movedFiles], nonPersistedImageIds: [ ...destination.nonPersistedImageIds, - ...source.nonPersistedImageIds, + ...source.nonPersistedImageIds.filter((imageId) => movedImageIds.has(imageId)), ], persistedAttachments: [ ...destination.persistedAttachments, - ...source.persistedAttachments, + ...source.persistedAttachments.filter((attachment) => + movedImageIds.has(attachment.id), + ), ], }; // Same clearing shape as clearComposerPromptAndImages, but the @@ -3524,9 +3730,14 @@ const composerDraftStore = create()( const nextSource: ComposerThreadDraftState = { ...source, prompt: ensureInlineTerminalContextPlaceholders("", source.terminalContexts.length), - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], + images: retainedImages, + files: retainedFiles, + nonPersistedImageIds: source.nonPersistedImageIds.filter( + (imageId) => !movedImageIds.has(imageId), + ), + persistedAttachments: source.persistedAttachments.filter( + (attachment) => !movedImageIds.has(attachment.id), + ), }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextSource)) { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index ed88a2033296..c3297956e665 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -30,6 +30,7 @@ import { primaryServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; +import { toastManager } from "../components/ui/toast"; interface NewThreadWorkspaceOptions { branch?: string | null; @@ -75,7 +76,7 @@ export function useNewThreadHandler() { startFromOrigin?: boolean; replace?: boolean; /** - * Move the viewed draft's typed content (prompt + images) into the + * Move the viewed draft's typed content and transferable attachments into the * draft this request lands on. Set by the draft repo picker: the * user started writing in the wrong project and the text should * follow them. Explicit new-thread surfaces leave this unset and @@ -154,6 +155,14 @@ export function useNewThreadHandler() { composerDraftHasUserContent(getComposerDraft(carryContentSourceDraftId)) ) { moveComposerPromptAndImages(carryContentSourceDraftId, destinationDraftId); + const remainingFiles = getComposerDraft(carryContentSourceDraftId)?.files ?? []; + if (remainingFiles.length > 0) { + toastManager.add({ + type: "warning", + title: `${remainingFiles.length} file${remainingFiles.length === 1 ? " stayed" : "s stayed"} in the original draft`, + description: "Return to the original draft or attach the files again.", + }); + } } }; const project = projects.find( diff --git a/apps/web/src/lib/attachmentUploadQueue.test.ts b/apps/web/src/lib/attachmentUploadQueue.test.ts index 2b2b94431c80..f3abf6020ca1 100644 --- a/apps/web/src/lib/attachmentUploadQueue.test.ts +++ b/apps/web/src/lib/attachmentUploadQueue.test.ts @@ -1,21 +1,29 @@ import { EnvironmentId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { ComposerImageAttachment } from "../composerDraftStore"; +import type { ComposerFileAttachment, ComposerImageAttachment } from "../composerDraftStore"; const mocks = vi.hoisted(() => ({ + createAssetUrl: vi.fn(), createUploadUrl: Symbol("create-upload-url"), + executeAtomQuery: vi.fn(), removeUpload: Symbol("remove-upload"), runAtomCommand: vi.fn(), readPreparedConnection: vi.fn(), })); vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + executeAtomQuery: mocks.executeAtomQuery, runAtomCommand: mocks.runAtomCommand, + squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error, })); vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("../state/assets", () => ({ + assetEnvironment: { createUrl: mocks.createAssetUrl }, +})); + vi.mock("../state/attachments", () => ({ attachmentEnvironment: { createUploadUrl: mocks.createUploadUrl, @@ -32,6 +40,7 @@ import { getUploadedAttachments, readAttachmentUpload, releaseAttachmentUpload, + releasePersistedAttachmentUpload, releaseAttachmentUploads, retryAttachmentUpload, startAttachmentUpload, @@ -110,9 +119,27 @@ function makeImage(id: string): ComposerImageAttachment { }; } +function makeFile(id: string): ComposerFileAttachment { + const file = new File([new Uint8Array([1, 2, 3])], `${id}.pdf`, { + type: "application/pdf", + }); + return { + type: "file", + id, + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + file, + }; +} + describe("attachmentUploadQueue", () => { beforeEach(() => { TestXmlHttpRequest.requests = []; + mocks.createAssetUrl.mockReset(); + mocks.createAssetUrl.mockImplementation((target: unknown) => target); + mocks.executeAtomQuery.mockReset(); + mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} }); mocks.runAtomCommand.mockReset(); mocks.readPreparedConnection.mockReset(); mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" }); @@ -189,6 +216,199 @@ describe("attachmentUploadQueue", () => { ); }); + it("uploads generic files and sends file attachment references", async () => { + const file = makeFile("report"); + startAttachmentUpload({ environmentId: firstEnvironment, image: file }); + await Promise.resolve(); + + expect(mocks.runAtomCommand).toHaveBeenCalledWith( + expect.anything(), + mocks.createUploadUrl, + { + environmentId: firstEnvironment, + input: { + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 3, + }, + }, + expect.anything(), + ); + + const settled = awaitAttachmentUploads([file.id]); + TestXmlHttpRequest.requests[0]!.complete(); + await settled; + + expect(getUploadedAttachments({ environmentId: firstEnvironment, images: [file] })).toEqual([ + { + type: "file", + id: "pending-environment-1-report.pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 3, + }, + ]); + }); + + it("verifies an uploaded file reference before restoring it", async () => { + const file: ComposerFileAttachment = { + ...makeFile("restored"), + file: null, + uploadedAttachmentId: "pending-restored-pdf", + uploadEnvironmentId: firstEnvironment, + }; + + startAttachmentUpload({ environmentId: firstEnvironment, image: file }); + await awaitAttachmentUploads([file.id]); + + expect(readAttachmentUpload(file.id)).toEqual({ + status: "ready", + environmentId: firstEnvironment, + attachmentId: "pending-restored-pdf", + }); + expect(mocks.createAssetUrl).toHaveBeenCalledWith({ + environmentId: firstEnvironment, + input: { resource: { _tag: "attachment", attachmentId: "pending-restored-pdf" } }, + }); + expect(TestXmlHttpRequest.requests).toHaveLength(0); + }); + + it("marks an expired restored file as failed when its original bytes are unavailable", async () => { + const file: ComposerFileAttachment = { + ...makeFile("expired"), + file: null, + uploadedAttachmentId: "pending-expired-pdf", + uploadEnvironmentId: firstEnvironment, + }; + mocks.executeAtomQuery.mockResolvedValueOnce({ + _tag: "Failure", + error: { _tag: "AssetAttachmentNotFoundError" }, + }); + + startAttachmentUpload({ environmentId: firstEnvironment, image: file }); + await awaitAttachmentUploads([file.id]); + + expect(readAttachmentUpload(file.id)).toMatchObject({ + status: "failed", + reason: "Uploaded file expired. Remove it and attach it again.", + }); + expect(TestXmlHttpRequest.requests).toHaveLength(0); + }); + + it("uploads the original file again when its persisted server upload expired", async () => { + const file: ComposerFileAttachment = { + ...makeFile("recoverable"), + uploadedAttachmentId: "pending-expired-pdf", + uploadEnvironmentId: firstEnvironment, + }; + mocks.executeAtomQuery.mockResolvedValueOnce({ + _tag: "Failure", + error: { _tag: "AssetAttachmentNotFoundError" }, + }); + + startAttachmentUpload({ environmentId: firstEnvironment, image: file }); + await Promise.resolve(); + await Promise.resolve(); + + const settled = awaitAttachmentUploads([file.id]); + TestXmlHttpRequest.requests[0]!.complete(); + await settled; + + expect(readAttachmentUpload(file.id)).toMatchObject({ + status: "ready", + attachmentId: "pending-environment-1-recoverable.pdf", + }); + }); + + it("removes a persisted upload when its draft is discarded during verification", async () => { + const file: ComposerFileAttachment = { + ...makeFile("checking"), + file: null, + uploadedAttachmentId: "pending-checking-pdf", + uploadEnvironmentId: firstEnvironment, + }; + let resolveVerification: (result: { + readonly _tag: "Success"; + readonly value: object; + }) => void = () => {}; + mocks.executeAtomQuery.mockReturnValueOnce( + new Promise((resolve) => { + resolveVerification = resolve; + }), + ); + + startAttachmentUpload({ environmentId: firstEnvironment, image: file }); + releaseAttachmentUpload(file.id); + resolveVerification({ _tag: "Success", value: {} }); + + expect(mocks.runAtomCommand).toHaveBeenCalledWith( + expect.anything(), + mocks.removeUpload, + { + environmentId: firstEnvironment, + input: { attachmentId: "pending-checking-pdf" }, + }, + expect.anything(), + ); + }); + + it("cancels persisted-upload verification when a stash discards its file", async () => { + const file: ComposerFileAttachment = { + ...makeFile("stashed-checking"), + file: null, + uploadedAttachmentId: "pending-stashed-checking-pdf", + uploadEnvironmentId: firstEnvironment, + }; + let resolveVerification: (result: { + readonly _tag: "Success"; + readonly value: object; + }) => void = () => {}; + mocks.executeAtomQuery.mockReturnValueOnce( + new Promise((resolve) => { + resolveVerification = resolve; + }), + ); + + startAttachmentUpload({ environmentId: firstEnvironment, image: file }); + releasePersistedAttachmentUpload({ + id: file.id, + environmentId: firstEnvironment, + attachmentId: "pending-stashed-checking-pdf", + }); + resolveVerification({ _tag: "Success", value: {} }); + await Promise.resolve(); + + expect(readAttachmentUpload(file.id)).toBeUndefined(); + expect(mocks.runAtomCommand).toHaveBeenCalledWith( + expect.anything(), + mocks.removeUpload, + { + environmentId: firstEnvironment, + input: { attachmentId: "pending-stashed-checking-pdf" }, + }, + expect.anything(), + ); + }); + + it("deletes a persisted server upload even when browser upload state is gone", () => { + releasePersistedAttachmentUpload({ + id: "stashed-report", + environmentId: firstEnvironment, + attachmentId: "pending-00000000-0000-4000-8000-000000000001-pdf", + }); + + expect(mocks.runAtomCommand).toHaveBeenCalledWith( + expect.anything(), + mocks.removeUpload, + { + environmentId: firstEnvironment, + input: { attachmentId: "pending-00000000-0000-4000-8000-000000000001-pdf" }, + }, + expect.anything(), + ); + }); + it("retries rejected uploads", async () => { const image = makeImage("image-retry"); startAttachmentUpload({ environmentId: firstEnvironment, image }); diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index 37eb924ca256..b04b39c35079 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -1,20 +1,28 @@ import { + AssetAttachmentNotFoundError, PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, type ChatAttachment, type EnvironmentId, } from "@t3tools/contracts"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { + executeAtomQuery, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import * as Schema from "effect/Schema"; import { create } from "zustand"; -import type { ComposerImageAttachment } from "../composerDraftStore"; +import type { ComposerFileAttachment, ComposerImageAttachment } from "../composerDraftStore"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { assetEnvironment } from "../state/assets"; import { attachmentEnvironment } from "../state/attachments"; import { readPreparedConnection } from "../state/session"; import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState"; const MAX_UPLOADS_PER_ENVIRONMENT = 3; const UPLOAD_TIMEOUT_MS = 5 * 60_000; +const isAssetAttachmentNotFound = Schema.is(AssetAttachmentNotFoundError); interface AttachmentUploadStore { readonly uploadsByImageId: Readonly>; @@ -25,9 +33,10 @@ export const useAttachmentUploadStore = create(() => ({ })); interface UploadJob { - readonly image: ComposerImageAttachment; + readonly image: ComposerImageAttachment | ComposerFileAttachment; readonly environmentId: EnvironmentId; readonly previous?: ReadyAttachmentUpload; + readonly persistedAttachmentId?: string; readonly settled: Promise; resolveSettled: () => void; attachmentId: string | null; @@ -101,9 +110,54 @@ function uploadBytes(input: { } async function runUpload(job: UploadJob): Promise { - const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find( - (supportedMimeType) => supportedMimeType === job.image.mimeType.toLowerCase(), - ); + if (job.persistedAttachmentId) { + const result = await executeAtomQuery( + appAtomRegistry, + assetEnvironment.createUrl({ + environmentId: job.environmentId, + input: { resource: { _tag: "attachment", attachmentId: job.persistedAttachmentId } }, + }), + { reportFailure: false, reportDefect: false }, + ); + if (job.cancelled) { + return; + } + if (result._tag === "Success") { + setUploadState(job.image.id, { + status: "ready", + environmentId: job.environmentId, + attachmentId: job.persistedAttachmentId, + }); + return; + } + + const error = squashAtomCommandFailure(result); + const missing = + isAssetAttachmentNotFound(error) || + (typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "AssetAttachmentNotFoundError"); + if (!missing || !job.image.file) { + setUploadState(job.image.id, { + status: "failed", + environmentId: job.environmentId, + attachmentId: job.persistedAttachmentId, + reason: missing + ? "Uploaded file expired. Remove it and attach it again." + : "Uploaded file could not be verified. Retry when the server reconnects.", + ...(job.previous ? { previous: job.previous } : {}), + }); + return; + } + } + + const mimeType = + job.image.type === "file" + ? job.image.mimeType.toLowerCase() + : PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find( + (supportedMimeType) => supportedMimeType === job.image.mimeType.toLowerCase(), + ); if (!mimeType) { setUploadState(job.image.id, { status: "failed", @@ -113,6 +167,15 @@ async function runUpload(job: UploadJob): Promise { }); return; } + if (!job.image.file) { + setUploadState(job.image.id, { + status: "failed", + environmentId: job.environmentId, + reason: "Original file is no longer available", + ...(job.previous ? { previous: job.previous } : {}), + }); + return; + } const minted = await runAtomCommand( appAtomRegistry, @@ -120,6 +183,7 @@ async function runUpload(job: UploadJob): Promise { { environmentId: job.environmentId, input: { + ...(job.image.type === "file" ? { type: "file" as const } : {}), name: job.image.name, mimeType, sizeBytes: job.image.file.size, @@ -249,7 +313,7 @@ function pumpUploads(): void { export function startAttachmentUpload(input: { readonly environmentId: EnvironmentId; - readonly image: ComposerImageAttachment; + readonly image: ComposerImageAttachment | ComposerFileAttachment; }): void { const existingJob = jobsByImageId.get(input.image.id); if (existingJob?.environmentId === input.environmentId) { @@ -288,9 +352,17 @@ export function startAttachmentUpload(input: { image: input.image, environmentId: input.environmentId, ...(previous ? { previous } : {}), + ...(input.image.type === "file" && + input.image.uploadEnvironmentId === input.environmentId && + input.image.uploadedAttachmentId + ? { persistedAttachmentId: input.image.uploadedAttachmentId } + : {}), settled, resolveSettled, - attachmentId: null, + attachmentId: + input.image.type === "file" && input.image.uploadEnvironmentId === input.environmentId + ? (input.image.uploadedAttachmentId ?? null) + : null, cancelled: false, abort: null, }; @@ -340,9 +412,34 @@ export function releaseAttachmentUpload(imageId: string): void { clearUploadState(imageId); } +export function releasePersistedAttachmentUpload(input: { + readonly id: string; + readonly environmentId: EnvironmentId; + readonly attachmentId: string; +}): void { + const job = jobsByImageId.get(input.id); + if ( + job?.environmentId === input.environmentId && + job.persistedAttachmentId === input.attachmentId + ) { + releaseAttachmentUpload(input.id); + return; + } + const upload = readAttachmentUpload(input.id); + if ( + upload?.status === "ready" && + upload.environmentId === input.environmentId && + upload.attachmentId === input.attachmentId + ) { + releaseAttachmentUpload(input.id); + return; + } + deletePendingUpload(input.environmentId, input.attachmentId); +} + export function retryAttachmentUpload(input: { readonly environmentId: EnvironmentId; - readonly image: ComposerImageAttachment; + readonly image: ComposerImageAttachment | ComposerFileAttachment; }): void { const previous = readAttachmentUpload(input.image.id); cancelAttachmentUpload(input.image.id); @@ -363,7 +460,7 @@ export async function awaitAttachmentUploads(imageIds: ReadonlyArray): P export function getUploadedAttachments(input: { readonly environmentId: EnvironmentId; - readonly images: ReadonlyArray; + readonly images: ReadonlyArray; }): ChatAttachment[] | null { const attachments: ChatAttachment[] = []; for (const image of input.images) { @@ -372,7 +469,7 @@ export function getUploadedAttachments(input: { return null; } attachments.push({ - type: "image", + type: image.type, id: upload.attachmentId, name: image.name, mimeType: image.mimeType, @@ -382,7 +479,9 @@ export function getUploadedAttachments(input: { return attachments; } -export function releaseAttachmentUploads(images: ReadonlyArray): void { +export function releaseAttachmentUploads( + images: ReadonlyArray, +): void { for (const image of images) { releaseAttachmentUpload(image.id); } diff --git a/apps/web/src/lib/attachmentUploadState.test.ts b/apps/web/src/lib/attachmentUploadState.test.ts index 3156d7200778..1fd44c04dd01 100644 --- a/apps/web/src/lib/attachmentUploadState.test.ts +++ b/apps/web/src/lib/attachmentUploadState.test.ts @@ -34,7 +34,7 @@ describe("attachmentUploadBlockReason", () => { "image-1": { status: "uploading", environmentId, progress: 0.5 }, }, }), - ).toBe("Images still uploading"); + ).toBe("Attachments still uploading"); }); it("asks the user to retry or remove failed uploads", () => { @@ -46,7 +46,7 @@ describe("attachmentUploadBlockReason", () => { "image-1": { status: "failed", environmentId, reason: "Upload failed" }, }, }), - ).toBe("Retry or remove the failed image"); + ).toBe("Retry or remove the failed attachment"); }); it("does not accept an upload from another environment", () => { @@ -62,7 +62,7 @@ describe("attachmentUploadBlockReason", () => { }, }, }), - ).toBe("Image still uploading"); + ).toBe("Attachment still uploading"); }); }); diff --git a/apps/web/src/lib/attachmentUploadState.ts b/apps/web/src/lib/attachmentUploadState.ts index 6ca2d2bc155c..61191d94c150 100644 --- a/apps/web/src/lib/attachmentUploadState.ts +++ b/apps/web/src/lib/attachmentUploadState.ts @@ -40,10 +40,12 @@ export function attachmentUploadBlockReason(input: { } if (failed > 0) { - return failed === 1 ? "Retry or remove the failed image" : "Retry or remove the failed images"; + return failed === 1 + ? "Retry or remove the failed attachment" + : "Retry or remove the failed attachments"; } if (pending > 0) { - return pending === 1 ? "Image still uploading" : "Images still uploading"; + return pending === 1 ? "Attachment still uploading" : "Attachments still uploading"; } return null; } diff --git a/apps/web/src/lib/composerDraftUploads.ts b/apps/web/src/lib/composerDraftUploads.ts index a9b8a357725e..70c73d2d597b 100644 --- a/apps/web/src/lib/composerDraftUploads.ts +++ b/apps/web/src/lib/composerDraftUploads.ts @@ -6,7 +6,7 @@ import { releaseAttachmentUploads } from "./attachmentUploadQueue"; export function releaseComposerDraftUploads(target: ScopedThreadRef | DraftId): void { const draft = useComposerDraftStore.getState().getComposerDraft(target); if (draft) { - releaseAttachmentUploads(draft.images); + releaseAttachmentUploads([...draft.images, ...draft.files]); } } @@ -17,7 +17,8 @@ export function releaseProjectDraftUploads(projectRef: ScopedProjectRef): void { session.environmentId === projectRef.environmentId && session.projectId === projectRef.projectId ) { - releaseAttachmentUploads(store.draftsByThreadKey[draftKey]?.images ?? []); + const draft = store.draftsByThreadKey[draftKey]; + releaseAttachmentUploads(draft ? [...draft.images, ...draft.files] : []); } } } diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 20894713d1d9..1d056d72090d 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; import { removeLocalStorageItem } from "./hooks/useLocalStorage"; @@ -162,6 +163,27 @@ describe("promptStashStore", () => { expect(entry?.pendingImageCount).toBe(0); }); + it("preserves uploaded file references without storing file contents", () => { + const store = usePromptStashStore.getState(); + const file = { + id: "file-1", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + attachmentId: "pending-report-pdf", + environmentId: EnvironmentId.make("environment-1"), + }; + + store.stashEntry({ ...makeEntry({ id: "with-file" }), files: [file] }); + store.finalizeEntryImages("with-file", { + attachments: [], + droppedImageNames: [], + unreadableImageNames: [], + }); + + expect(usePromptStashStore.getState().entries[0]?.files).toEqual([file]); + }); + it("finalizeEntryImages reports false when the entry was already taken", () => { const store = usePromptStashStore.getState(); store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 }); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index d7c541e7a947..c6bd44e1df12 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -1,7 +1,10 @@ import * as Schema from "effect/Schema"; import { create } from "zustand"; -import { PersistedComposerImageAttachment } from "./composerDraftStore"; +import { + PersistedComposerFileAttachment, + PersistedComposerImageAttachment, +} from "./composerDraftStore"; import { createMemoryStorage, type StateStorage } from "./lib/storage"; export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2"; @@ -27,16 +30,15 @@ export const MAX_STASH_ENTRIES = 20; export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000; /** - * A stashed prompt carries only what every provider can accept: text and - * image attachments. Deliberately no provider instance or model selection — - * the point of stashing is to move a prompt into a different thread or - * provider, so restoring must never drag the old model choice along. + * Stashed files keep signed-upload references instead of storing their bytes. + * Image payloads remain subject to the localStorage budget. */ const StashEntrySchema = Schema.Struct({ id: Schema.String, createdAt: Schema.String, prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), + files: Schema.optionalKey(Schema.Array(PersistedComposerFileAttachment)), /** Names of images that exceeded the attachment budget and were not saved. */ droppedImageNames: Schema.Array(Schema.String), /** diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 45a8539a1517..9c4a222f5a3d 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,4 +1,5 @@ import type { + ChatFileAttachment as ContractChatFileAttachment, ChatImageAttachment as ContractChatImageAttachment, OrchestrationCheckpointFile, OrchestrationCheckpointSummary, @@ -35,7 +36,12 @@ export interface ChatImageAttachment extends ContractChatImageAttachment { readonly previewUrl?: string; } -export type ChatAttachment = ChatImageAttachment; +export interface ChatFileAttachment extends ContractChatFileAttachment { + readonly previewUrl?: string; + readonly downloadable?: boolean; +} + +export type ChatAttachment = ChatImageAttachment | ChatFileAttachment; export interface ChatMessage extends Omit { readonly attachments?: ReadonlyArray | undefined; diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..a782ab956497 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -39,6 +39,20 @@ directory to route session and turn operations for a thread, so callers name a t Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No orchestration, contract, or client change is required for the common case. +## Attachment access + +The server stores uploaded attachments in its attachment directory, outside the project workspace. +`ProviderService` adds the absolute path of each attachment to the turn text. Images also go to the +provider adapter as image inputs. Generic files reach the agent only as file paths. + +Claude receives the attachment directory as an allowed additional directory. Codex keeps its +configured sandbox policy, so access depends on that policy and the selected runtime mode. OpenCode +allows all paths in full-access mode and requests approval for directories outside the workspace in +restricted modes. Cursor and Grok use their own provider permission rules. + +The server does not copy attachments into a project or bypass provider approval rules. If an agent +cannot read an attachment, the user must approve the access or select a runtime mode that permits it. + ## How provider work is requested Clients never call a provider directly. They dispatch orchestration commands over the RPC method diff --git a/docs/user/composer.md b/docs/user/composer.md index 35d634556d88..1194d79337eb 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -4,8 +4,14 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee composer and shows how many characters need to be removed. Shorten the draft or split it into multiple messages, then send again in the same thread. -On servers that support direct uploads, images upload as soon as you add them. The send button -becomes available after every upload finishes. Failed uploads can be retried or removed. +On servers that support direct uploads, you can attach images, text files, PDFs, ZIP archives, and +other files. Each file can be up to 50 MB, and each message can contain up to eight attachments. +Images keep their existing 10 MB limit. Files upload directly to the environment, where your agent +can read, copy, or edit them by their file path. + +On web and desktop, attachments upload as soon as you add them. The send button becomes available +after every upload finishes. Failed uploads can be retried or removed. On mobile, use the attachment +button or share a file with T3 Code from another app. On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into the composer or paste them into a message. diff --git a/packages/contracts/src/assets.test.ts b/packages/contracts/src/assets.test.ts index ce4214d300da..c47c53b2a84e 100644 --- a/packages/contracts/src/assets.test.ts +++ b/packages/contracts/src/assets.test.ts @@ -2,7 +2,10 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { AttachmentCreateUploadUrlInput } from "./assets.ts"; -import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "./orchestration.ts"; +import { + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, +} from "./orchestration.ts"; const isUploadInput = Schema.is(AttachmentCreateUploadUrlInput); @@ -21,10 +24,37 @@ describe("AttachmentCreateUploadUrlInput", () => { expect(isUploadInput({ ...uploadInput, mimeType: "image/svg+xml" })).toBe(false); }); + it("accepts generic files without treating them as provider images", () => { + expect( + isUploadInput({ + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1, + }), + ).toBe(true); + expect( + isUploadInput({ + type: "file", + name: "diagram.svg", + mimeType: "image/svg+xml", + sizeBytes: 3, + }), + ).toBe(true); + }); + it("rejects empty and oversized uploads", () => { expect(isUploadInput({ ...uploadInput, sizeBytes: 0 })).toBe(false); expect( isUploadInput({ ...uploadInput, sizeBytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1 }), ).toBe(false); + expect( + isUploadInput({ + type: "file", + name: "archive.zip", + mimeType: "application/zip", + sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1, + }), + ).toBe(false); }); }); diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index bfc2c9472aaa..7b8d06fcd65c 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { + PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, ProjectFaviconPath, @@ -42,7 +43,8 @@ export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; export const ATTACHMENT_UPLOAD_URL_TTL_MS = 10 * 60_000; -export const AttachmentCreateUploadUrlInput = Schema.Struct({ +const ImageAttachmentCreateUploadUrlInput = Schema.Struct({ + type: Schema.optionalKey(Schema.Literal("image")), name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), mimeType: Schema.Literals(PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES), sizeBytes: NonNegativeInt.check( @@ -50,6 +52,21 @@ export const AttachmentCreateUploadUrlInput = Schema.Struct({ Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES), ), }); + +const FileAttachmentCreateUploadUrlInput = Schema.Struct({ + type: Schema.Literal("file"), + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + sizeBytes: NonNegativeInt.check( + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES), + ), +}); + +export const AttachmentCreateUploadUrlInput = Schema.Union([ + ImageAttachmentCreateUploadUrlInput, + FileAttachmentCreateUploadUrlInput, +]); export type AttachmentCreateUploadUrlInput = typeof AttachmentCreateUploadUrlInput.Type; export const AttachmentCreateUploadUrlResult = Schema.Struct({ diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 455cc58f47d1..55633835bba1 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -39,4 +39,16 @@ describe("ExecutionEnvironmentDescriptor", () => { }).capabilities.attachmentUploads, ).toBe(true); }); + + it("preserves the server's generic attachment upload limit", () => { + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { + ...descriptor.capabilities, + fileAttachments: { maxUploadBytes: 50 * 1024 * 1024 }, + }, + }).capabilities.fileAttachments, + ).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); + }); }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 939313e8c83e..6c3d404674a5 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -50,6 +50,12 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ connectionProbe: Schema.optionalKey(Schema.Boolean), /** Missing on older servers, which still accept inline image attachments. */ attachmentUploads: Schema.optionalKey(Schema.Boolean), + /** Missing on servers that only accept image attachments. */ + fileAttachments: Schema.optionalKey( + Schema.Struct({ + maxUploadBytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), + }), + ), /** Server exposes the pull-request list, detail, activity, diff, and mutation APIs. Absent on servers from before the pull-request workspace shipped, so clients must not probe them. */ pullRequests: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 52b893f39c3b..f210c6540e9d 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -243,7 +243,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", () }), ); -it.effect("accepts both inline and uploaded image attachments from clients", () => +it.effect("accepts inline images, uploaded images, and uploaded files from clients", () => Effect.gen(function* () { const command = yield* decodeClientOrchestrationCommand({ type: "thread.turn.start", @@ -268,6 +268,13 @@ it.effect("accepts both inline and uploaded image attachments from clients", () mimeType: "image/png", sizeBytes: 3, }, + { + type: "file", + id: "pending-00000000-0000-4000-8000-000000000002-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 3, + }, ], }, runtimeMode: "full-access", @@ -278,9 +285,10 @@ it.effect("accepts both inline and uploaded image attachments from clients", () if (command.type !== "thread.turn.start") { assert.fail(`Expected thread.turn.start, received ${command.type}.`); } - assert.strictEqual(command.message.attachments.length, 2); + assert.strictEqual(command.message.attachments.length, 3); assert.strictEqual("dataUrl" in command.message.attachments[0]!, true); assert.strictEqual("id" in command.message.attachments[1]!, true); + assert.strictEqual(command.message.attachments[2]!.type, "file"); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index e0634cea1152..5150ac8b03f4 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -156,6 +156,7 @@ export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; +export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024; export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [ "image/gif", "image/jpeg", @@ -191,6 +192,18 @@ export const ChatImageAttachment = Schema.Struct({ }); export type ChatImageAttachment = typeof ChatImageAttachment.Type; +export const ChatFileAttachment = Schema.Struct({ + type: Schema.Literal("file"), + id: ChatAttachmentId, + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + sizeBytes: NonNegativeInt.check( + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES), + ), +}); +export type ChatFileAttachment = typeof ChatFileAttachment.Type; + const UploadChatImageAttachment = Schema.Struct({ type: Schema.Literal("image"), name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), @@ -202,7 +215,7 @@ const UploadChatImageAttachment = Schema.Struct({ }); export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type; -export const ChatAttachment = Schema.Union([ChatImageAttachment]); +export const ChatAttachment = Schema.Union([ChatImageAttachment, ChatFileAttachment]); export type ChatAttachment = typeof ChatAttachment.Type; const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]); export type UploadChatAttachment = typeof UploadChatAttachment.Type; diff --git a/patches/expo-sharing@56.0.18.patch b/patches/expo-sharing@56.0.18.patch new file mode 100644 index 000000000000..61a110db81ac --- /dev/null +++ b/patches/expo-sharing@56.0.18.patch @@ -0,0 +1,98 @@ +diff --git a/android/src/main/java/expo/modules/sharing/SharingRecords.kt b/android/src/main/java/expo/modules/sharing/SharingRecords.kt +index 5ce42a0..fb609d2 100644 +--- a/android/src/main/java/expo/modules/sharing/SharingRecords.kt ++++ b/android/src/main/java/expo/modules/sharing/SharingRecords.kt +@@ -52,5 +52,6 @@ +-internal data class SharePayload( +- @Field var value: String = "", +- @Field var shareType: ShareType = ShareType.Text, +- @Field var mimeType: String = "text/plain" +-) : Record ++internal data class SharePayload( ++ @Field var value: String = "", ++ @Field var shareType: ShareType = ShareType.Text, ++ @Field var mimeType: String = "text/plain", ++ @Field var originalName: String? = null ++) : Record +diff --git a/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt b/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt +index 4f17a41..b471de3 100644 +--- a/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt ++++ b/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt +@@ -3,3 +3,4 @@ +-import android.content.Context +-import android.content.Intent +-import android.net.Uri ++import android.content.Context ++import android.content.Intent ++import android.net.Uri ++import android.provider.OpenableColumns +@@ -19,17 +20,22 @@ internal class SimpleShareIntentDataParser { +- private fun handleSendAction(context: Context, intent: Intent, type: String): List { +- return if (type == "text/plain") { +- val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return emptyList() +- val isUrl = android.util.Patterns.WEB_URL.matcher(text).matches() +- +- listOf( +- SharePayload().apply { +- value = text +- shareType = if (isUrl) ShareType.Url else ShareType.Text +- mimeType = "text/plain" +- } +- ) +- } else { +- val uri = intent.getParcelableExtraCompat(Intent.EXTRA_STREAM) +- listOfNotNull(uri?.let { createUriPayload(context, it, type) }) +- } +- } ++ private fun handleSendAction(context: Context, intent: Intent, type: String): List { ++ val stream = intent.getParcelableExtraCompat(Intent.EXTRA_STREAM) ++ val text = intent.getStringExtra(Intent.EXTRA_TEXT) ++ if (stream == null) { ++ if (!type.startsWith("text/") || text == null) return emptyList() ++ return listOf( ++ SharePayload().apply { ++ value = text ++ shareType = if (android.util.Patterns.WEB_URL.matcher(text).matches()) ShareType.Url else ShareType.Text ++ mimeType = type ++ } ++ ) ++ } ++ val filePayload = createUriPayload(context, stream, type) ++ if (text.isNullOrBlank()) return listOf(filePayload) ++ val textPayload = SharePayload().apply { ++ value = text ++ shareType = if (android.util.Patterns.WEB_URL.matcher(text).matches()) ShareType.Url else ShareType.Text ++ mimeType = "text/plain" ++ } ++ return listOf(textPayload, filePayload) ++ } +@@ -42,8 +48,21 @@ internal class SimpleShareIntentDataParser { +- private fun createUriPayload(context: Context, uri: Uri, defaultType: String): SharePayload { +- val specificType = context.contentResolver.getType(uri) ?: defaultType +- return SharePayload().apply { +- value = uri.toString() +- shareType = ShareType.fromMimeType(specificType) +- mimeType = specificType +- } +- } ++ private fun createUriPayload(context: Context, uri: Uri, defaultType: String): SharePayload { ++ val specificType = context.contentResolver.getType(uri) ?: defaultType ++ val displayName = runCatching { ++ context.contentResolver.query( ++ uri, ++ arrayOf(OpenableColumns.DISPLAY_NAME), ++ null, ++ null, ++ null ++ )?.use { cursor -> ++ val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) ++ if (column >= 0 && cursor.moveToFirst()) cursor.getString(column) else null ++ } ++ }.getOrNull() ++ return SharePayload().apply { ++ value = uri.toString() ++ shareType = if (specificType.startsWith("text/")) ShareType.File else ShareType.fromMimeType(specificType) ++ mimeType = specificType ++ originalName = displayName ++ } ++ } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7995dec1a2cf..8a309811b6d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,7 @@ overrides: '@types/node': 24.12.4 effect: 4.0.0-beta.103 expo-modules-jsi: 56.0.10 + expo-sharing: 56.0.18 expo-sharing>@expo/config-plugins: 56.0.9 expo-sharing>@expo/config-types: 56.0.6 vite: npm:@voidzero-dev/vite-plus-core@0.2.2 @@ -91,6 +92,7 @@ patchedDependencies: '@react-navigation/native-stack@7.17.6': 0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f + expo-sharing@56.0.18: 113081f88bd816f2219cf89f33206c78c591004342cc364e3f098664dd950031 react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 @@ -358,8 +360,8 @@ importers: specifier: ~56.0.4 version: 56.0.4(expo@56.0.12) expo-sharing: - specifier: ~56.0.18 - version: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + specifier: 56.0.18 + version: 56.0.18(patch_hash=113081f88bd816f2219cf89f33206c78c591004342cc364e3f098664dd950031)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-splash-screen: specifier: ~56.0.10 version: 56.0.10(expo@56.0.12)(typescript@6.0.3) @@ -17167,7 +17169,7 @@ snapshots: expo-server@56.0.5: {} - expo-sharing@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-sharing@56.0.18(patch_hash=113081f88bd816f2219cf89f33206c78c591004342cc364e3f098664dd950031)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/config-plugins': 56.0.9(typescript@6.0.3) '@expo/config-types': 56.0.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0a4d3cc7cb50..c8fee507f095 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -113,6 +113,7 @@ overrides: "@types/node": "catalog:" effect: "catalog:" expo-modules-jsi: 56.0.10 + expo-sharing: 56.0.18 "expo-sharing>@expo/config-plugins": 56.0.9 "expo-sharing>@expo/config-types": 56.0.6 vite: "catalog:" @@ -144,6 +145,7 @@ patchedDependencies: "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch effect@4.0.0-beta.103: patches/effect@4.0.0-beta.103.patch expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch + expo-sharing@56.0.18: patches/expo-sharing@56.0.18.patch react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch