From a041b4913f40cdd3694ab584ad3171317bc2e8d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrei=20Dr=C4=83gan=20R=C4=83dule=C8=9B?= Date: Thu, 16 Jul 2026 20:59:42 +0200 Subject: [PATCH 1/7] feat(contracts): add ChatFileAttachment variant to attachment unions Additive union member: existing image rows/drafts decode unchanged. Limits shared with images (10 MB, 8 per message). Co-Authored-By: Claude Fable 5 --- packages/contracts/src/orchestration.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 7e9c421b5df..c3e923f9797 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -173,9 +173,29 @@ const UploadChatImageAttachment = Schema.Struct({ }); export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type; -export const ChatAttachment = Schema.Union([ChatImageAttachment]); +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.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)), +}); +export type ChatFileAttachment = typeof ChatFileAttachment.Type; + +const UploadChatFileAttachment = Schema.Struct({ + type: Schema.Literal("file"), + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)), + dataUrl: TrimmedNonEmptyString.check( + Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), + ), +}); +export type UploadChatFileAttachment = typeof UploadChatFileAttachment.Type; + +export const ChatAttachment = Schema.Union([ChatImageAttachment, ChatFileAttachment]); export type ChatAttachment = typeof ChatAttachment.Type; -const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]); +const UploadChatAttachment = Schema.Union([UploadChatImageAttachment, UploadChatFileAttachment]); export type UploadChatAttachment = typeof UploadChatAttachment.Type; export const ProjectScriptIcon = Schema.Literals([ From 7ea686d14038037f5ccd9c2f1b53f32a408a696b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrei=20Dr=C4=83gan=20R=C4=83dule=C8=9B?= Date: Thu, 16 Jul 2026 20:59:44 +0200 Subject: [PATCH 2/7] feat(server): persist non-image file attachments - inferFileExtension with a conservative SAFE_FILE_EXTENSIONS allowlist (.bin fallback) so resolve-by-id keeps working and risky extensions (.html/.svg-for-files) never land on the same-origin asset route - Normalizer accepts any MIME for type:"file" uploads; image gate and size limits unchanged - attachment GC keep-set includes file attachments (previously any non-image attachment would be pruned from disk) Co-Authored-By: Claude Fable 5 --- apps/server/src/attachmentStore.ts | 18 +++++++- apps/server/src/imageMime.ts | 42 +++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 3 -- apps/server/src/orchestration/Normalizer.ts | 8 ++-- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db21..857e39d3224 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -8,9 +8,16 @@ import { normalizeAttachmentRelativePath, resolveAttachmentRelativePath, } from "./attachmentPaths.ts"; -import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; +import { + inferFileExtension, + inferImageExtension, + SAFE_FILE_EXTENSIONS, + SAFE_IMAGE_FILE_EXTENSIONS, +} from "./imageMime.ts"; -const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; +const ATTACHMENT_FILENAME_EXTENSIONS = [ + ...new Set([...SAFE_IMAGE_FILE_EXTENSIONS, ...SAFE_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}"; @@ -63,6 +70,13 @@ export function attachmentRelativePath(attachment: ChatAttachment): string { }); return `${attachment.id}${extension}`; } + case "file": { + const extension = inferFileExtension({ + mimeType: attachment.mimeType, + fileName: attachment.name, + }); + return `${attachment.id}${extension}`; + } } } diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index c8761285abe..ae9a3ef1829 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -57,6 +57,48 @@ export function parseBase64DataUrl( return { mimeType, base64 }; } +export const SAFE_FILE_EXTENSIONS = new Set([ + ".csv", + ".diff", + ".docx", + ".gz", + ".json", + ".jsonl", + ".log", + ".md", + ".patch", + ".pdf", + ".pptx", + ".tar", + ".toml", + ".tsv", + ".txt", + ".xlsx", + ".xml", + ".yaml", + ".yml", + ".zip", +]); + +export function inferFileExtension(input: { mimeType: string; fileName?: string }): string { + const fileName = input.fileName?.trim() ?? ""; + const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName); + const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : ""; + if (SAFE_FILE_EXTENSIONS.has(fileNameExtension)) { + return fileNameExtension; + } + + // Mime.getExtension returns dot-less extensions ("pdf"), the sets hold + // dotted ones (".pdf"). + const fromMimeExtension = Mime.getExtension(input.mimeType); + const dottedMimeExtension = fromMimeExtension ? `.${fromMimeExtension}` : null; + if (dottedMimeExtension && SAFE_FILE_EXTENSIONS.has(dottedMimeExtension)) { + return dottedMimeExtension; + } + + return ".bin"; +} + export function inferImageExtension(input: { mimeType: string; fileName?: string }): string { const key = input.mimeType.toLowerCase(); const fromMime = Object.hasOwn(IMAGE_EXTENSION_BY_MIME_TYPE, key) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..7b58620eb25 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -338,9 +338,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.ts b/apps/server/src/orchestration/Normalizer.ts index bed166eba45..b880f5392ed 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -74,16 +74,16 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => (attachment) => Effect.gen(function* () { const parsed = parseBase64DataUrl(attachment.dataUrl); - if (!parsed || !parsed.mimeType.startsWith("image/")) { + if (!parsed || (attachment.type === "image" && !parsed.mimeType.startsWith("image/"))) { return yield* new OrchestrationDispatchCommandError({ - message: `Invalid image attachment payload for '${attachment.name}'.`, + message: `Invalid ${attachment.type} attachment payload for '${attachment.name}'.`, }); } const bytes = Buffer.from(parsed.base64, "base64"); if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { return yield* new OrchestrationDispatchCommandError({ - message: `Image attachment '${attachment.name}' is empty or too large.`, + message: `Attachment '${attachment.name}' is empty or too large.`, }); } @@ -95,7 +95,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => } const persistedAttachment = { - type: "image" as const, + type: attachment.type, id: attachmentId, name: attachment.name, mimeType: parsed.mimeType.toLowerCase(), From 0c454e7f06ebb0dff31d17c4f03ae5cb69d0f966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrei=20Dr=C4=83gan=20R=C4=83dule=C8=9B?= Date: Thu, 16 Jul 2026 20:59:46 +0200 Subject: [PATCH 3/7] feat(server): deliver file attachments by path in the turn prompt File attachments already live on the server's disk; append an '[Attached file: ...]' line to the turn input at the single sendTurn choke point and pass only image attachments to the provider. The agent reads the file with its own tools, so Claude/Codex/Grok need no adapter changes and remote environments work by construction. Co-Authored-By: Claude Fable 5 --- apps/server/src/attachmentPrompt.ts | 55 +++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 17 +++++- 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/attachmentPrompt.ts diff --git a/apps/server/src/attachmentPrompt.ts b/apps/server/src/attachmentPrompt.ts new file mode 100644 index 00000000000..4f21a516a9a --- /dev/null +++ b/apps/server/src/attachmentPrompt.ts @@ -0,0 +1,55 @@ +import type { ChatAttachment } from "@t3tools/contracts"; + +import { resolveAttachmentPath } from "./attachmentStore.ts"; + +const SIZE_LABEL_UNITS = ["B", "KB", "MB"] as const; + +function formatSizeLabel(sizeBytes: number): string { + let value = sizeBytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < SIZE_LABEL_UNITS.length - 1) { + value /= 1024; + unitIndex += 1; + } + const rounded = unitIndex === 0 ? String(value) : value.toFixed(1); + return `${rounded} ${SIZE_LABEL_UNITS[unitIndex]}`; +} + +function sanitizePromptFileName(name: string): string { + // eslint-disable-next-line no-control-regex + return name.replace(/[\u0000-\u001f\u007f()[\]]+/gu, " ").trim(); +} + +/** + * Non-image attachments are delivered by reference: the file already lives on + * the server's disk, so the provider agent reads it with its own tools. Images + * keep their native content-block path and are not mentioned here. + */ +export function appendFileAttachmentPromptText(input: { + readonly text: string; + readonly attachmentsDir: string; + readonly attachments: ReadonlyArray; +}): string { + const lines: Array = []; + for (const attachment of input.attachments) { + if (attachment.type !== "file") { + continue; + } + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: input.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + continue; + } + const name = sanitizePromptFileName(attachment.name); + lines.push( + `[Attached file: ${attachmentPath} (${name}, ${attachment.mimeType}, ${formatSizeLabel(attachment.sizeBytes)}). Read it from disk when needed.]`, + ); + } + if (lines.length === 0) { + return input.text; + } + const joined = lines.join("\n"); + return input.text.trim().length > 0 ? `${input.text}\n\n${joined}` : joined; +} diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9c7a7c94bb1..d31cea6a9ec 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -25,7 +25,9 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { appendFileAttachmentPromptText } from "../../attachmentPrompt.ts"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; +import { ServerConfig } from "../../config.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; @@ -196,6 +198,7 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; const serverCommandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); @@ -605,8 +608,18 @@ const make = Effect.gen(function* () { if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } - const normalizedInput = toNonEmptyProviderInput(input.messageText); - const normalizedAttachments = input.attachments ?? []; + const normalizedInput = toNonEmptyProviderInput( + appendFileAttachmentPromptText({ + text: input.messageText, + attachmentsDir: serverConfig.attachmentsDir, + attachments: input.attachments ?? [], + }), + ); + // Only image attachments go to the provider as content blocks; file + // attachments are delivered by path in the prompt text above. + const normalizedAttachments = (input.attachments ?? []).filter( + (attachment) => attachment.type === "image", + ); const activeSession = yield* providerService .listSessions() .pipe( From a436ed68a1e7a592508601c286eab08bdf11fa5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrei=20Dr=C4=83gan=20R=C4=83dule=C8=9B?= Date: Thu, 16 Jul 2026 20:59:48 +0200 Subject: [PATCH 4/7] feat(server): serve non-image attachments as downloads Content-Disposition: attachment for any attachment whose extension is not an image extension, so uploaded content is never rendered inline on the same-origin asset route. Co-Authored-By: Claude Fable 5 --- apps/server/src/assets/AssetAccess.ts | 21 +++++++++++++++++---- apps/server/src/http.ts | 5 +++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b469e0e315b..a01abd2c0b2 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -36,6 +36,7 @@ import { } from "../auth/utils.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; +import { SAFE_IMAGE_FILE_EXTENSIONS } from "../imageMime.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -91,7 +92,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 { @@ -383,9 +388,17 @@ 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) - : null; + if (!Option.isSome(info) || info.value.type !== "File") { + return null; + } + // Non-image attachments are downloaded rather than rendered inline so the + // same-origin asset route never interprets uploaded content (e.g. HTML/XML). + const extension = attachmentPath.slice(attachmentPath.lastIndexOf(".")).toLowerCase(); + return { + kind: "file", + path: attachmentPath, + download: !SAFE_IMAGE_FILE_EXTENSIONS.has(extension), + } satisfies ResolvedAsset; } if (claims.kind === "project-favicon") { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index b9bb40f372d..be58bee4327 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -197,6 +197,11 @@ export const assetRouteLayer = HttpRouter.add( headers: { "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", + ...(asset.download + ? { + "Content-Disposition": `attachment; filename="${asset.path.replace(/^.*[/\\]/, "")}"`, + } + : {}), }, }).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), From 39a75de54f528cef979286974c917b8292488ef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrei=20Dr=C4=83gan=20R=C4=83dule=C8=9B?= Date: Thu, 16 Jul 2026 20:59:50 +0200 Subject: [PATCH 5/7] feat(web): accept any file in the composer, render file chips - drag/drop and paste accept non-image files (staged as name+size chips); images keep the existing preview tiles and expand dialog - timeline renders file attachments as download chips via the signed asset URL - drafts persist an optional attachment type (missing = image, so existing persisted drafts decode unchanged; no storage version bump) - limits and copy: 10 MB per attachment, 8 attachments per message Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ChatView.logic.ts | 6 +- apps/web/src/components/ChatView.tsx | 16 ++--- apps/web/src/components/chat/ChatComposer.tsx | 64 ++++++++++++------- .../chat/ComposerPreviewAnnotationCards.tsx | 4 +- .../src/components/chat/MessagesTimeline.tsx | 39 ++++++++++- apps/web/src/composerDraftStore.ts | 36 ++++++++--- apps/web/src/historyBootstrap.ts | 22 +++++-- apps/web/src/lib/attachmentSize.ts | 9 +++ apps/web/src/types.ts | 7 +- 9 files changed, 150 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/lib/attachmentSize.ts diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 705793ec77e..6f16768b74c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -10,7 +10,7 @@ import { type TurnId, } from "@t3tools/contracts"; import { type ChatMessage, type SessionPhase, type Thread } from "../types"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { type ComposerAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; @@ -193,9 +193,7 @@ export function resolveSendEnvMode(input: { return input.isGitRepo ? input.requestedEnvMode : "local"; } -export function cloneComposerImageForRetry( - image: ComposerImageAttachment, -): ComposerImageAttachment { +export function cloneComposerImageForRetry(image: ComposerAttachment): ComposerAttachment { if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { return image; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..acae9a6fad7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -160,7 +160,7 @@ import { } from "../logicalProject"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { - type ComposerImageAttachment, + type ComposerAttachment, type DraftThreadEnvMode, useComposerDraftStore, type DraftId, @@ -250,8 +250,8 @@ import { } from "../versionSkew"; import { 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 file(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; @@ -1092,7 +1092,7 @@ function ChatViewContent(props: ChatViewProps) { : null, ); const promptRef = useRef(""); - const composerImagesRef = useRef([]); + const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); @@ -4007,11 +4007,11 @@ function ChatViewContent(props: ChatViewProps) { model: ctxSelectedModel, models: ctxSelectedProviderModels, effort: ctxSelectedPromptEffort, - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, }); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ - type: "image" as const, + type: image.type, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, @@ -4019,7 +4019,7 @@ function ChatViewContent(props: ChatViewProps) { })), ); const optimisticAttachments = composerImagesSnapshot.map((image) => ({ - type: "image" as const, + type: image.type, id: image.id, name: image.name, mimeType: image.mimeType, @@ -4081,7 +4081,7 @@ function ChatViewContent(props: ChatViewProps) { let titleSeed = trimmed; if (!titleSeed) { if (firstComposerImageName) { - titleSeed = `Image: ${firstComposerImageName}`; + titleSeed = `${composerImagesSnapshot[0]?.type === "file" ? "File" : "Image"}: ${firstComposerImageName}`; } else if (composerTerminalContextsSnapshot.length > 0) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else if (composerElementContextsSnapshot.length > 0) { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f9aec837ca..4f8eb530315 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -44,7 +44,7 @@ import { } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { - type ComposerImageAttachment, + type ComposerAttachment, type DraftId, type PersistedComposerImageAttachment, useComposerDraftStore, @@ -95,6 +95,7 @@ import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, + FileTextIcon, ListTodoIcon, PencilRulerIcon, type LucideIcon, @@ -124,6 +125,7 @@ import { import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { formatAttachmentSizeLabel } from "../../lib/attachmentSize"; import type { ReviewCommentContext } from "../../reviewCommentContext"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -410,7 +412,7 @@ export interface ChatComposerHandle { /** Get the current prompt/effort/model state for use in send. */ getSendContext: () => { prompt: string; - images: ComposerImageAttachment[]; + images: ComposerAttachment[]; terminalContexts: TerminalContextDraft[]; elementContexts: ElementContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; @@ -499,7 +501,7 @@ export interface ChatComposerProps { // Refs the parent needs kept in sync promptRef: React.RefObject; - composerImagesRef: React.RefObject; + composerImagesRef: React.RefObject; composerTerminalContextsRef: React.RefObject; composerElementContextsRef: React.RefObject; composerRef: React.RefObject; @@ -1151,14 +1153,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const addComposerImage = useCallback( - (image: ComposerImageAttachment) => { + (image: ComposerAttachment) => { addComposerDraftImage(composerDraftTarget, image); }, [composerDraftTarget, addComposerDraftImage], ); const addComposerImagesToDraft = useCallback( - (images: ComposerImageAttachment[]) => { + (images: ComposerAttachment[]) => { addComposerDraftImages(composerDraftTarget, images); }, [composerDraftTarget, addComposerDraftImages], @@ -1368,6 +1370,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) try { const dataUrl = await readFileAsDataUrl(image.file); stagedAttachmentById.set(image.id, { + type: image.type, id: image.id, name: image.name, mimeType: image.mimeType, @@ -1765,32 +1768,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (pendingUserInputs.length > 0) { toastManager.add({ type: "error", - title: "Attach images after answering plan questions.", + title: "Attach files after answering plan questions.", }); return; } - const nextImages: ComposerImageAttachment[] = []; + const nextImages: ComposerAttachment[] = []; let nextImageCount = composerImagesRef.current.length; let error: string | null = null; for (const file of files) { - if (!file.type.startsWith("image/")) { - error = `Unsupported file type for '${file.name}'. Please attach image files only.`; - continue; - } if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; continue; } if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`; break; } + const isImage = file.type.startsWith("image/"); const previewUrl = URL.createObjectURL(file); nextImages.push({ - type: "image", + type: isImage ? "image" : "file", id: randomUUID(), - name: file.name || "image", - mimeType: file.type, + name: file.name || (isImage ? "image" : "file"), + mimeType: file.type || "application/octet-stream", sizeBytes: file.size, previewUrl, file, @@ -1815,10 +1815,8 @@ 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/")); - if (imageFiles.length === 0) return; event.preventDefault(); - addComposerImages(imageFiles); + addComposerImages(files); }; const onComposerDragEnter = (event: React.DragEvent) => { @@ -2269,7 +2267,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) } onExpandImage={(imageId) => { - const preview = buildExpandedImagePreview(composerImages, imageId); + const preview = buildExpandedImagePreview( + composerImages.filter((attachment) => attachment.type === "image"), + imageId, + ); if (preview) onExpandImage(preview); }} className="mb-3" @@ -2320,15 +2321,34 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) .map((image) => (
- {image.previewUrl ? ( + {image.type === "file" ? ( + <> + +
+
+ {image.name} +
+
+ {formatAttachmentSizeLabel(image.sizeBytes)} +
+
+ + ) : image.previewUrl ? (