diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 8239724ca..69bdcc473 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; import type { ChatAttachment } from "@okcode/contracts"; @@ -7,9 +7,7 @@ import { normalizeAttachmentRelativePath, resolveAttachmentRelativePath, } from "./attachmentPaths.ts"; -import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; - -const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; +import { inferAttachmentExtension } from "./imageMime.ts"; 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}"; @@ -55,8 +53,9 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string export function attachmentRelativePath(attachment: ChatAttachment): string { switch (attachment.type) { - case "image": { - const extension = inferImageExtension({ + case "image": + case "file": { + const extension = inferAttachmentExtension({ mimeType: attachment.mimeType, fileName: attachment.name, }); @@ -83,10 +82,20 @@ export function resolveAttachmentPathById(input: { if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { return null; } - for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) { + let entries: string[]; + try { + entries = readdirSync(input.attachmentsDir); + } catch { + return null; + } + for (const entry of entries) { + const entryId = parseAttachmentIdFromRelativePath(entry); + if (entryId !== normalizedId) { + continue; + } const maybePath = resolveAttachmentRelativePath({ attachmentsDir: input.attachmentsDir, - relativePath: `${normalizedId}${extension}`, + relativePath: entry, }); if (maybePath && existsSync(maybePath)) { return maybePath; diff --git a/apps/server/src/attachmentText.ts b/apps/server/src/attachmentText.ts new file mode 100644 index 000000000..8be8d7033 --- /dev/null +++ b/apps/server/src/attachmentText.ts @@ -0,0 +1,213 @@ +import { + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type ChatFileAttachment, +} from "@okcode/contracts"; + +const MAX_FILE_CONTEXT_TOTAL_CHARS = 80_000; +const MAX_FILE_CONTEXT_CHARS_PER_FILE = 24_000; +const TEXT_DECODER = new TextDecoder("utf-8", { fatal: false }); +const TEXTUAL_MIME_SUBSTRINGS = [ + "json", + "xml", + "yaml", + "toml", + "javascript", + "typescript", + "markdown", + "csv", + "graphql", + "sql", + "x-sh", + "x-shellscript", +]; +const TEXTUAL_FILE_EXTENSIONS = new Set([ + "c", + "cc", + "cfg", + "conf", + "cpp", + "cs", + "css", + "csv", + "env", + "go", + "graphql", + "h", + "hpp", + "html", + "ini", + "java", + "js", + "json", + "jsx", + "kt", + "log", + "lua", + "md", + "mjs", + "php", + "pl", + "py", + "rb", + "rs", + "scss", + "sh", + "sql", + "svg", + "swift", + "toml", + "ts", + "tsx", + "txt", + "vue", + "xml", + "yaml", + "yml", + "zsh", +]); + +function attachmentExtension(fileName: string): string { + const match = /\.([a-z0-9]{1,12})$/i.exec(fileName.trim()); + return match?.[1]?.toLowerCase() ?? ""; +} + +function looksTextLikeMimeType(mimeType: string): boolean { + const normalized = mimeType.trim().toLowerCase(); + if (normalized.startsWith("text/")) { + return true; + } + return TEXTUAL_MIME_SUBSTRINGS.some((part) => normalized.includes(part)); +} + +function looksTextLikeFileName(fileName: string): boolean { + return TEXTUAL_FILE_EXTENSIONS.has(attachmentExtension(fileName)); +} + +function hasSuspiciousControlBytes(text: string): boolean { + let suspiciousCount = 0; + let visibleCount = 0; + for (let index = 0; index < text.length; index += 1) { + const codePoint = text.charCodeAt(index); + if (codePoint === 0) { + return true; + } + if (codePoint < 32 && codePoint !== 9 && codePoint !== 10 && codePoint !== 13) { + suspiciousCount += 1; + continue; + } + visibleCount += 1; + } + if (visibleCount === 0) { + return suspiciousCount > 0; + } + return suspiciousCount / Math.max(visibleCount, 1) > 0.02; +} + +export function extractTextAttachmentContents(input: { + readonly mimeType: string; + readonly fileName: string; + readonly bytes: Uint8Array; +}): string | null { + if (input.bytes.byteLength === 0) { + return ""; + } + const decoded = TEXT_DECODER.decode(input.bytes); + if (hasSuspiciousControlBytes(decoded)) { + return null; + } + const replacementCount = decoded.split("\uFFFD").length - 1; + const replacementRatio = replacementCount / Math.max(decoded.length, 1); + const expectedText = + looksTextLikeMimeType(input.mimeType) || looksTextLikeFileName(input.fileName); + if (replacementRatio > (expectedText ? 0.02 : 0.005)) { + return null; + } + if (!expectedText && decoded.trim().length === 0) { + return null; + } + return decoded.replace(/\r\n?/g, "\n"); +} + +export function buildFileAttachmentContextText(input: { + readonly baseText: string; + readonly attachments: ReadonlyArray<{ + readonly attachment: ChatFileAttachment; + readonly text: string; + }>; + readonly maxChars?: number; +}): string { + if (input.attachments.length === 0) { + return input.baseText; + } + + const maxChars = Math.max( + 1, + Math.floor(input.maxChars ?? PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + ); + let result = input.baseText; + let usedFileContextChars = 0; + let omittedCount = 0; + + const append = (chunk: string): boolean => { + if (chunk.length === 0) { + return true; + } + if (result.length + chunk.length > maxChars) { + return false; + } + result += chunk; + return true; + }; + + const header = `${result.length > 0 ? "\n\n" : ""}Attached file context:`; + if (!append(header)) { + return result; + } + + for (const [index, entry] of input.attachments.entries()) { + const openBlock = + "\n\n\n" + + `name: ${entry.attachment.name}\n` + + `mime_type: ${entry.attachment.mimeType}\n` + + `size_bytes: ${entry.attachment.sizeBytes}\n` + + "content:\n"; + const closeBlock = "\n"; + const remainingContextBudget = + MAX_FILE_CONTEXT_TOTAL_CHARS - usedFileContextChars - openBlock.length - closeBlock.length; + const remainingTotalBudget = maxChars - result.length - openBlock.length - closeBlock.length; + const maxContentChars = Math.min( + MAX_FILE_CONTEXT_CHARS_PER_FILE, + remainingContextBudget, + remainingTotalBudget, + ); + + if (maxContentChars <= 0) { + omittedCount = input.attachments.length - index; + break; + } + + const truncationNote = "\n[content truncated to fit input limits]"; + const needsTruncation = entry.text.length > maxContentChars; + const availableContentChars = needsTruncation + ? Math.max(0, maxContentChars - truncationNote.length) + : maxContentChars; + if (availableContentChars <= 0) { + omittedCount = input.attachments.length - index; + break; + } + + const blockBody = entry.text.slice(0, availableContentChars); + const block = `${openBlock}${blockBody}${needsTruncation ? truncationNote : ""}${closeBlock}`; + if (!append(block)) { + omittedCount = input.attachments.length - index; + break; + } + usedFileContextChars += block.length; + } + + if (omittedCount > 0) { + append(`\n\n[${omittedCount} attached file(s) omitted due to input size limits.]`); + } + + return result; +} diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index 814abbb32..70789754f 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -1,5 +1,7 @@ import Mime from "@effect/platform-node/Mime"; +const SAFE_ATTACHMENT_FILE_EXTENSION_PATTERN = /^[a-z0-9]{1,12}$/i; + export const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { "image/avif": ".avif", "image/bmp": ".bmp", @@ -29,6 +31,10 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ ".webp", ]); +export function isImageMimeType(mimeType: string): boolean { + return mimeType.trim().toLowerCase().startsWith("image/"); +} + export function parseBase64DataUrl( dataUrl: string, ): { readonly mimeType: string; readonly base64: string } | null { @@ -77,3 +83,25 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string return ".bin"; } + +export function inferAttachmentExtension(input: { mimeType: string; fileName?: string }): string { + if (isImageMimeType(input.mimeType)) { + return inferImageExtension(input); + } + + const mimeExtension = Mime.getExtension(input.mimeType); + if ( + typeof mimeExtension === "string" && + SAFE_ATTACHMENT_FILE_EXTENSION_PATTERN.test(mimeExtension.replace(/^\./, "")) + ) { + return mimeExtension.startsWith(".") ? mimeExtension : `.${mimeExtension}`; + } + + const fileName = input.fileName?.trim() ?? ""; + const extensionMatch = /\.([a-z0-9]{1,12})$/i.exec(fileName); + if (extensionMatch?.[1]) { + return `.${extensionMatch[1].toLowerCase()}`; + } + + return ".bin"; +} diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 36313b8c9..8fc9c2132 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -221,9 +221,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/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 06ab6f01e..503b459ef 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -65,6 +65,10 @@ import { } from "effect"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { + buildFileAttachmentContextText, + extractTextAttachmentContents, +} from "../../attachmentText.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderAdapterProcessError, @@ -563,18 +567,72 @@ function buildUserMessageEffect( }, ): Effect.Effect { return Effect.gen(function* () { - const text = buildPromptText(input); + const imageAttachments: Array[number], { type: "image" }>> = []; + const fileAttachments: Array<{ + readonly attachment: Extract< + NonNullable[number], + { type: "file" } + >; + readonly text: string; + }> = []; + + for (const attachment of input.attachments ?? []) { + if (attachment.type === "image") { + imageAttachments.push(attachment); + continue; + } + + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: dependencies.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + + const bytes = yield* dependencies.fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: toMessage(cause, "Failed to read attachment file."), + cause, + }), + ), + ); + + const text = extractTextAttachmentContents({ + mimeType: attachment.mimeType, + fileName: attachment.name, + bytes, + }); + if (text === null) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Unsupported file attachment '${attachment.name}'. Attach UTF-8 text files or images.`, + }); + } + + fileAttachments.push({ attachment, text }); + } + + const text = buildFileAttachmentContextText({ + baseText: buildPromptText(input), + attachments: fileAttachments, + }); const sdkContent: Array> = []; if (text.length > 0) { sdkContent.push({ type: "text", text }); } - for (const attachment of input.attachments ?? []) { - if (attachment.type !== "image") { - continue; - } - + for (const attachment of imageAttachments) { if (!SUPPORTED_CLAUDE_IMAGE_MIME_TYPES.has(attachment.mimeType)) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index da341bb70..bfaaea5ad 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -38,6 +38,10 @@ import { type CodexAppServerStartSessionInput, } from "../../codexAppServerManager.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { + buildFileAttachmentContextText, + extractTextAttachmentContents, +} from "../../attachmentText.ts"; import { ServerConfig } from "../../config.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -1381,10 +1385,58 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => const sendTurn: CodexAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { + const textFileAttachments: Array<{ + readonly attachment: Extract< + NonNullable[number]>, + { type: "file" } + >; + readonly text: string; + }> = []; const codexAttachments = yield* Effect.forEach( input.attachments ?? [], (attachment) => Effect.gen(function* () { + if (attachment.type === "file") { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* toRequestError( + input.threadId, + "turn/start", + new Error(`Invalid attachment id '${attachment.id}'.`), + ); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: toMessage(cause, "Failed to read attachment file."), + cause, + }), + ), + ); + const text = extractTextAttachmentContents({ + mimeType: attachment.mimeType, + fileName: attachment.name, + bytes, + }); + if (text === null) { + return yield* toRequestError( + input.threadId, + "turn/start", + new Error( + `Unsupported file attachment '${attachment.name}'. Attach UTF-8 text files or images.`, + ), + ); + } + textFileAttachments.push({ attachment, text }); + return null; + } + const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment, @@ -1413,13 +1465,18 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => }; }), { concurrency: 1 }, - ); + ).pipe(Effect.map((attachments) => attachments.filter((attachment) => attachment !== null))); + + const turnInputText = buildFileAttachmentContextText({ + baseText: input.input?.trim() ?? "", + attachments: textFileAttachments, + }); return yield* Effect.tryPromise({ try: () => { const managerInput = { threadId: input.threadId, - ...(input.input !== undefined ? { input: input.input } : {}), + ...(turnInputText.length > 0 ? { input: turnInputText } : {}), ...(input.model !== undefined ? { model: input.model } : {}), ...(input.modelOptions?.codex?.reasoningEffort !== undefined ? { effort: input.modelOptions.codex.reasoningEffort } diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 801c64d8f..455351c34 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -12,11 +12,13 @@ import type { Duplex } from "node:stream"; import Mime from "@effect/platform-node/Mime"; import { CommandId, + DEFAULT_CHAT_FILE_MIME_TYPE, DEFAULT_PROVIDER_INTERACTION_MODE, type ClientOrchestrationCommand, type OrchestrationCommand, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, ProjectId, ThreadId, @@ -75,6 +77,7 @@ import { resolveAttachmentPathById, } from "./attachmentStore.ts"; import { parseBase64DataUrl } from "./imageMime.ts"; +import { extractTextAttachmentContents } from "./attachmentText.ts"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; @@ -391,17 +394,43 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< (attachment) => Effect.gen(function* () { const parsed = parseBase64DataUrl(attachment.dataUrl); - if (!parsed || !parsed.mimeType.startsWith("image/")) { + if (!parsed) { return yield* new RouteRequestError({ - message: `Invalid image attachment payload for '${attachment.name}'.`, + message: `Invalid 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 RouteRequestError({ - message: `Image attachment '${attachment.name}' is empty or too large.`, + const normalizedMimeType = + parsed.mimeType.trim().toLowerCase() || DEFAULT_CHAT_FILE_MIME_TYPE; + + if (attachment.type === "image") { + if (!normalizedMimeType.startsWith("image/")) { + return yield* new RouteRequestError({ + message: `Invalid image attachment payload for '${attachment.name}'.`, + }); + } + if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + return yield* new RouteRequestError({ + message: `Image attachment '${attachment.name}' is empty or too large.`, + }); + } + } else { + if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + return yield* new RouteRequestError({ + message: `File attachment '${attachment.name}' is empty or too large.`, + }); + } + const extractedText = extractTextAttachmentContents({ + mimeType: normalizedMimeType, + fileName: attachment.name, + bytes, }); + if (extractedText === null) { + return yield* new RouteRequestError({ + message: `Unsupported file attachment '${attachment.name}'. Attach UTF-8 text files or images.`, + }); + } } const attachmentId = createAttachmentId(turnStartCommand.threadId); @@ -411,13 +440,22 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); } - const persistedAttachment = { - type: "image" as const, - id: attachmentId, - name: attachment.name, - mimeType: parsed.mimeType.toLowerCase(), - sizeBytes: bytes.byteLength, - }; + const persistedAttachment = + attachment.type === "image" + ? { + type: "image" as const, + id: attachmentId, + name: attachment.name, + mimeType: normalizedMimeType, + sizeBytes: bytes.byteLength, + } + : { + type: "file" as const, + id: attachmentId, + name: attachment.name, + mimeType: normalizedMimeType, + sizeBytes: bytes.byteLength, + }; const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 7933bbf81..d9348df2b 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -11,7 +11,7 @@ describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ prompt: "\uFFFC", - imageCount: 0, + attachmentCount: 0, terminalContexts: [ { id: "ctx-expired", @@ -35,7 +35,7 @@ describe("deriveComposerSendState", () => { it("keeps text sendable while excluding expired terminal pills", () => { const state = deriveComposerSendState({ prompt: `yoo \uFFFC waddup`, - imageCount: 0, + attachmentCount: 0, terminalContexts: [ { id: "ctx-expired", @@ -54,6 +54,16 @@ describe("deriveComposerSendState", () => { expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(true); }); + + it("treats file attachments as sendable content", () => { + const state = deriveComposerSendState({ + prompt: "", + attachmentCount: 1, + terminalContexts: [], + }); + + expect(state.hasSendableContent).toBe(true); + }); }); describe("buildExpiredTerminalContextToastCopy", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 17963561a..c3f5088a9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,7 +1,7 @@ import { type MessageId, ProjectId, type ThreadId } from "@okcode/contracts"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { type ComposerAttachment, type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; import { filterTerminalContextsWithText, @@ -81,7 +81,7 @@ export type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; export interface QueuedMessage { id: MessageId; text: string; - images: ComposerImageAttachment[]; + attachments: ComposerAttachment[]; terminalContexts: TerminalContextDraft[]; createdAt: string; } @@ -114,25 +114,29 @@ export function buildTemporaryWorktreeBranchName(): string { return `${WORKTREE_BRANCH_PREFIX}/${token}`; } -export function cloneComposerImageForRetry( - image: ComposerImageAttachment, -): ComposerImageAttachment { - if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { - return image; +export function cloneComposerAttachmentForRetry( + attachment: ComposerAttachment, +): ComposerAttachment { + if ( + attachment.type !== "image" || + typeof URL === "undefined" || + !attachment.previewUrl.startsWith("blob:") + ) { + return attachment; } try { return { - ...image, - previewUrl: URL.createObjectURL(image.file), + ...attachment, + previewUrl: URL.createObjectURL(attachment.file), }; } catch { - return image; + return attachment; } } export function deriveComposerSendState(options: { prompt: string; - imageCount: number; + attachmentCount: number; terminalContexts: ReadonlyArray; }): { trimmedPrompt: string; @@ -149,7 +153,9 @@ export function deriveComposerSendState(options: { sendableTerminalContexts, expiredTerminalContextCount, hasSendableContent: - trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0, + trimmedPrompt.length > 0 || + options.attachmentCount > 0 || + sendableTerminalContexts.length > 0, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f274ed113..70029e0cb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { type ApprovalRequestId, + DEFAULT_CHAT_FILE_MIME_TYPE, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, type MessageId, @@ -10,6 +11,7 @@ import { type ProjectId, type ProviderApprovalDecision, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, type ServerProviderStatus, @@ -148,9 +150,9 @@ import { } from "../appSettings"; import { isTerminalFocused } from "../lib/terminalFocus"; import { - type ComposerImageAttachment, + type ComposerAttachment, type DraftThreadEnvMode, - type PersistedComposerImageAttachment, + type PersistedComposerAttachment, useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; @@ -195,7 +197,7 @@ import { buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildTemporaryWorktreeBranchName, - cloneComposerImageForRetry, + cloneComposerAttachmentForRetry, collectUserMessageBlobPreviewUrls, deriveComposerSendState, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -215,6 +217,7 @@ import { useTransportState } from "~/hooks/useTransportState"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; +const FILE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_FILE_BYTES / (1024 * 1024))}MB`; 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 EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; @@ -296,6 +299,10 @@ const PREVIEW_SPLIT_MIN_SIZE_PX = 220; const PREVIEW_SPLIT_DEFAULT_SIZE_PX = 384; const PREVIEW_CHAT_MIN_SIZE_PX = 360; +function composerAttachmentMimeType(file: File): string { + return file.type.trim() || DEFAULT_CHAT_FILE_MIME_TYPE; +} + const extendReplacementRangeForTrailingSpace = ( text: string, rangeEnd: number, @@ -379,18 +386,26 @@ export default function ChatView({ threadId }: ChatViewProps) { const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; - const composerImages = composerDraft.images; + const composerAttachments = composerDraft.attachments; + const composerImageAttachments = useMemo( + () => composerAttachments.filter((attachment) => attachment.type === "image"), + [composerAttachments], + ); + const composerFileAttachments = useMemo( + () => composerAttachments.filter((attachment) => attachment.type === "file"), + [composerAttachments], + ); const composerTerminalContexts = composerDraft.terminalContexts; const composerSendState = useMemo( () => deriveComposerSendState({ prompt, - imageCount: composerImages.length, + attachmentCount: composerAttachments.length, terminalContexts: composerTerminalContexts, }), - [composerImages.length, composerTerminalContexts, prompt], + [composerAttachments.length, composerTerminalContexts, prompt], ); - const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; + const nonPersistedComposerAttachmentIds = composerDraft.nonPersistedAttachmentIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); @@ -398,9 +413,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, ); - const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); - const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); - const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); + const addComposerDraftAttachment = useComposerDraftStore((store) => store.addAttachment); + const addComposerDraftAttachments = useComposerDraftStore((store) => store.addAttachments); + const removeComposerDraftAttachment = useComposerDraftStore((store) => store.removeAttachment); const insertComposerDraftTerminalContext = useComposerDraftStore( (store) => store.insertTerminalContext, ); @@ -510,7 +525,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerFormHeightRef = useRef(0); - const composerImagesRef = useRef([]); + const composerAttachmentsRef = useRef([]); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); @@ -542,17 +557,17 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [setComposerDraftPrompt, threadId], ); - const addComposerImage = useCallback( - (image: ComposerImageAttachment) => { - addComposerDraftImage(threadId, image); + const addComposerAttachment = useCallback( + (attachment: ComposerAttachment) => { + addComposerDraftAttachment(threadId, attachment); }, - [addComposerDraftImage, threadId], + [addComposerDraftAttachment, threadId], ); - const addComposerImagesToDraft = useCallback( - (images: ComposerImageAttachment[]) => { - addComposerDraftImages(threadId, images); + const addComposerAttachmentsToDraft = useCallback( + (attachments: ComposerAttachment[]) => { + addComposerDraftAttachments(threadId, attachments); }, - [addComposerDraftImages, threadId], + [addComposerDraftAttachments, threadId], ); const addComposerTerminalContextsToDraft = useCallback( (contexts: TerminalContextDraft[]) => { @@ -560,11 +575,11 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [addComposerDraftTerminalContexts, threadId], ); - const removeComposerImageFromDraft = useCallback( - (imageId: string) => { - removeComposerDraftImage(threadId, imageId); + const removeComposerAttachmentFromDraft = useCallback( + (attachmentId: string) => { + removeComposerDraftAttachment(threadId, attachmentId); }, - [removeComposerDraftImage, threadId], + [removeComposerDraftAttachment, threadId], ); const removeComposerTerminalContextFromDraft = useCallback( (contextId: string) => { @@ -1320,9 +1335,9 @@ export default function ChatView({ threadId }: ChatViewProps) { composerMenuOpenRef.current = composerMenuOpen; composerMenuItemsRef.current = composerMenuItems; activeComposerMenuItemRef.current = activeComposerMenuItem; - const nonPersistedComposerImageIdSet = useMemo( - () => new Set(nonPersistedComposerImageIds), - [nonPersistedComposerImageIds], + const nonPersistedComposerAttachmentIdSet = useMemo( + () => new Set(nonPersistedComposerAttachmentIds), + [nonPersistedComposerAttachmentIds], ); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; @@ -2236,8 +2251,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThread?.id, focusComposer, terminalState.terminalOpen]); useEffect(() => { - composerImagesRef.current = composerImages; - }, [composerImages]); + composerAttachmentsRef.current = composerAttachments; + }, [composerAttachments]); useEffect(() => { composerTerminalContextsRef.current = composerTerminalContexts; @@ -2297,7 +2312,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { let cancelled = false; void (async () => { - if (composerImages.length === 0) { + if (composerAttachments.length === 0) { clearComposerDraftPersistedAttachments(threadId); return; } @@ -2308,22 +2323,23 @@ export default function ChatView({ threadId }: ChatViewProps) { const existingPersistedById = new Map( currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), ); - const stagedAttachmentById = new Map(); + const stagedAttachmentById = new Map(); await Promise.all( - composerImages.map(async (image) => { + composerAttachments.map(async (attachment) => { try { - const dataUrl = await readFileAsDataUrl(image.file); - stagedAttachmentById.set(image.id, { - id: image.id, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, + const dataUrl = await readFileAsDataUrl(attachment.file); + stagedAttachmentById.set(attachment.id, { + type: attachment.type, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, dataUrl, }); } catch { - const existingPersisted = existingPersistedById.get(image.id); + const existingPersisted = existingPersistedById.get(attachment.id); if (existingPersisted) { - stagedAttachmentById.set(image.id, existingPersisted); + stagedAttachmentById.set(attachment.id, existingPersisted); } } }), @@ -2335,11 +2351,11 @@ export default function ChatView({ threadId }: ChatViewProps) { // Stage attachments in persisted draft state first so persist middleware can write them. syncComposerDraftPersistedAttachments(threadId, serialized); } catch { - const currentImageIds = new Set(composerImages.map((image) => image.id)); + const currentAttachmentIds = new Set(composerAttachments.map((attachment) => attachment.id)); const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); const fallbackPersistedIds = fallbackPersistedAttachments .map((attachment) => attachment.id) - .filter((id) => currentImageIds.has(id)); + .filter((id) => currentAttachmentIds.has(id)); const fallbackPersistedIdSet = new Set(fallbackPersistedIds); const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => fallbackPersistedIdSet.has(attachment.id), @@ -2355,7 +2371,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }, [ clearComposerDraftPersistedAttachments, - composerImages, + composerAttachments, syncComposerDraftPersistedAttachments, threadId, ]); @@ -2484,10 +2500,13 @@ export default function ChatView({ threadId }: ChatViewProps) { nextQueued.text, composerTerminalContextsSnapshot, ); + const fallbackOutgoingText = nextQueued.attachments.some((attachment) => attachment.type === "image") + ? IMAGE_ONLY_BOOTSTRAP_PROMPT + : ""; const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, effort: selectedPromptEffort, - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: messageTextForSend || fallbackOutgoingText, }); sendInFlightRef.current = true; @@ -2502,12 +2521,12 @@ export default function ChatView({ threadId }: ChatViewProps) { interactionMode, }); const turnAttachments = await Promise.all( - nextQueued.images.map(async (image) => ({ - type: "image" as const, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), + nextQueued.attachments.map(async (attachment) => ({ + type: attachment.type, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: await readFileAsDataUrl(attachment.file), })), ); await api.orchestration.dispatchCommand({ @@ -2649,57 +2668,71 @@ export default function ChatView({ threadId }: ChatViewProps) { toggleTerminalVisibility, ]); - const addComposerImages = (files: File[]) => { + const addComposerAttachments = (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; } - const nextImages: ComposerImageAttachment[] = []; - let nextImageCount = composerImagesRef.current.length; + const nextAttachments: ComposerAttachment[] = []; + let nextAttachmentCount = composerAttachmentsRef.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 (nextAttachmentCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`; + break; } - if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { - error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; + + const mimeType = composerAttachmentMimeType(file); + if (mimeType.startsWith("image/")) { + if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; + continue; + } + const previewUrl = URL.createObjectURL(file); + nextAttachments.push({ + type: "image", + id: randomUUID(), + name: file.name || "image", + mimeType, + sizeBytes: file.size, + previewUrl, + file, + }); + nextAttachmentCount += 1; continue; } - if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; - break; - } - const previewUrl = URL.createObjectURL(file); - nextImages.push({ - type: "image", + if (file.size > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + error = `'${file.name}' exceeds the ${FILE_SIZE_LIMIT_LABEL} attachment limit.`; + continue; + } + nextAttachments.push({ + type: "file", id: randomUUID(), - name: file.name || "image", - mimeType: file.type, + name: file.name || "file", + mimeType, sizeBytes: file.size, - previewUrl, file, }); - nextImageCount += 1; + nextAttachmentCount += 1; } - if (nextImages.length === 1 && nextImages[0]) { - addComposerImage(nextImages[0]); - } else if (nextImages.length > 1) { - addComposerImagesToDraft(nextImages); + if (nextAttachments.length === 1 && nextAttachments[0]) { + addComposerAttachment(nextAttachments[0]); + } else if (nextAttachments.length > 1) { + addComposerAttachmentsToDraft(nextAttachments); } setThreadError(activeThreadId, error); }; - const removeComposerImage = (imageId: string) => { - removeComposerImageFromDraft(imageId); + const removeComposerAttachment = (attachmentId: string) => { + removeComposerAttachmentFromDraft(attachmentId); }; const onComposerPaste = (event: React.ClipboardEvent) => { @@ -2707,12 +2740,8 @@ export default function ChatView({ threadId }: ChatViewProps) { if (files.length === 0) { return; } - const imageFiles = files.filter((file) => file.type.startsWith("image/")); - if (imageFiles.length === 0) { - return; - } event.preventDefault(); - addComposerImages(imageFiles); + addComposerAttachments(files); }; const onComposerDragEnter = (event: React.DragEvent) => { @@ -2770,16 +2799,16 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } - // Handle image file drops + // Handle file drops const files = Array.from(event.dataTransfer.files); - addComposerImages(files); + addComposerAttachments(files); focusComposer(); }; const onFileInputChange = (event: React.ChangeEvent) => { const files = Array.from(event.target.files ?? []); if (files.length > 0) { - addComposerImages(files); + addComposerAttachments(files); } // Reset so the same file can be selected again event.target.value = ""; @@ -2835,15 +2864,15 @@ export default function ChatView({ threadId }: ChatViewProps) { ? useComposerDraftStore.getState().draftsByThreadId[activeThread.id] : null; const nextPrompt = latestDraft?.prompt ?? promptRef.current; - const nextImages = latestDraft?.images ?? composerImagesRef.current; + const nextAttachments = latestDraft?.attachments ?? composerAttachmentsRef.current; const nextTerminalContexts = latestDraft?.terminalContexts ?? composerTerminalContextsRef.current; promptRef.current = nextPrompt; - composerImagesRef.current = nextImages; + composerAttachmentsRef.current = nextAttachments; composerTerminalContextsRef.current = nextTerminalContexts; return { prompt: nextPrompt, - images: nextImages, + attachments: nextAttachments, terminalContexts: nextTerminalContexts, }; }, [activeThread]); @@ -2858,7 +2887,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } const liveComposerDraft = readLiveComposerDraftSnapshot(); const promptForSend = liveComposerDraft.prompt; - const composerImagesForSend = liveComposerDraft.images; + const composerAttachmentsForSend = liveComposerDraft.attachments; const composerTerminalContextsForSend = liveComposerDraft.terminalContexts; const { trimmedPrompt: trimmed, @@ -2867,7 +2896,7 @@ export default function ChatView({ threadId }: ChatViewProps) { hasSendableContent, } = deriveComposerSendState({ prompt: promptForSend, - imageCount: composerImagesForSend.length, + attachmentCount: composerAttachmentsForSend.length, terminalContexts: composerTerminalContextsForSend, }); if (showPlanFollowUpPrompt && activeProposedPlan) { @@ -2887,7 +2916,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } const standaloneSlashCommand = - composerImagesForSend.length === 0 && sendableComposerTerminalContexts.length === 0 + composerAttachmentsForSend.length === 0 && sendableComposerTerminalContexts.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { @@ -2918,32 +2947,47 @@ export default function ChatView({ threadId }: ChatViewProps) { // ── Queue message if a turn is already running ──────────────────── if (phase === "running") { - const composerImagesSnapshot = [...composerImagesForSend]; + const composerAttachmentsSnapshot = [...composerAttachmentsForSend]; const messageTextForSend = appendTerminalContextsToPrompt( promptForSend, sendableComposerTerminalContexts, ); const messageCreatedAt = new Date().toISOString(); + const fallbackOutgoingText = composerAttachmentsSnapshot.some( + (attachment) => attachment.type === "image", + ) + ? IMAGE_ONLY_BOOTSTRAP_PROMPT + : ""; const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, effort: selectedPromptEffort, - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: messageTextForSend || fallbackOutgoingText, }); - 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, + }, + ); const queuedId = newMessageId(); setQueuedMessages((existing) => [ ...existing, { id: queuedId, text: promptForSend, - images: composerImagesSnapshot, + attachments: composerAttachmentsSnapshot, terminalContexts: [...sendableComposerTerminalContexts], createdAt: messageCreatedAt, }, @@ -3004,7 +3048,7 @@ export default function ChatView({ threadId }: ChatViewProps) { sendInFlightRef.current = true; beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); - const composerImagesSnapshot = [...composerImagesForSend]; + const composerAttachmentsSnapshot = [...composerAttachmentsForSend]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const messageTextForSend = appendTerminalContextsToPrompt( promptForSend, @@ -3012,28 +3056,43 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); + const fallbackOutgoingText = composerAttachmentsSnapshot.some( + (attachment) => attachment.type === "image", + ) + ? IMAGE_ONLY_BOOTSTRAP_PROMPT + : ""; const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, effort: selectedPromptEffort, - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: messageTextForSend || fallbackOutgoingText, }); const turnAttachmentsPromise = Promise.all( - composerImagesSnapshot.map(async (image) => ({ - type: "image" as const, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), + composerAttachmentsSnapshot.map(async (attachment) => ({ + type: attachment.type, + 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, + }, + ); setOptimisticUserMessages((existing) => [ ...existing, { @@ -3109,17 +3168,11 @@ export default function ChatView({ threadId }: ChatViewProps) { } } - let firstComposerImageName: string | null = null; - if (composerImagesSnapshot.length > 0) { - const firstComposerImage = composerImagesSnapshot[0]; - if (firstComposerImage) { - firstComposerImageName = firstComposerImage.name; - } - } + const firstComposerAttachment = composerAttachmentsSnapshot[0] ?? null; let titleSeed = trimmed; if (!titleSeed) { - if (firstComposerImageName) { - titleSeed = `Image: ${firstComposerImageName}`; + if (firstComposerAttachment) { + titleSeed = `${firstComposerAttachment.type === "image" ? "Image" : "File"}: ${firstComposerAttachment.name}`; } else if (composerTerminalContextsSnapshot.length > 0) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else { @@ -3229,7 +3282,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if ( !turnStartSucceeded && promptRef.current.length === 0 && - composerImagesRef.current.length === 0 && + composerAttachmentsRef.current.length === 0 && composerTerminalContextsRef.current.length === 0 ) { setOptimisticUserMessages((existing) => { @@ -3243,7 +3296,9 @@ export default function ChatView({ threadId }: ChatViewProps) { promptRef.current = promptForSend; setPrompt(promptForSend); setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length)); - addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); + addComposerAttachmentsToDraft( + composerAttachmentsSnapshot.map(cloneComposerAttachmentForRetry), + ); addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length)); } @@ -4407,7 +4462,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ) : ( <> - - Drop images to attach + + Drop files to attach )} @@ -4495,71 +4549,126 @@ export default function ChatView({ threadId }: ChatViewProps) { {!isComposerApprovalState && pendingUserInputs.length === 0 && - composerImages.length > 0 && ( -
- {composerImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} + {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerAttachmentIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be + lost on navigation. + + + )} +
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - + )} + {composerFileAttachments.length > 0 && ( +
+ {composerFileAttachments.map((attachment) => ( +
+ +
+
+ {attachment.name} +
+
+ {attachment.mimeType} +
+
+ {nonPersistedComposerAttachmentIdSet.has(attachment.id) && ( + + + + + } + /> + - - - } - /> - + + )} + + + +
+ ))}
- ))} + )}
)} { - const userImages = row.message.attachments ?? []; + const userAttachments = row.message.attachments ?? []; + const userImages = userAttachments.filter((attachment) => attachment.type === "image"); + const userFiles = userAttachments.filter((attachment) => attachment.type === "file"); const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); const terminalContexts = displayedUserMessage.contexts; const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); @@ -401,7 +404,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {userImages.length > 0 && (
{userImages.map( - (image: NonNullable[number]) => ( + (image) => (
)} + {userFiles.length > 0 && ( +
+ {userFiles.map((attachment) => { + const content = ( + <> + + {attachment.name} + + ); + return attachment.url ? ( + + {content} + + ) : ( +
+ {content} +
+ ); + })} +
+ )} {(displayedUserMessage.visibleText.trim().length > 0 || terminalContexts.length > 0) && ( ; + attachments?: ReadonlyArray<{ id: string; type?: "image" | "file" }>; } interface TimelineHeightEstimateLayout { @@ -89,9 +90,15 @@ export function estimateTimelineMessageHeight( .join(" ") : displayedUserMessage.visibleText; const estimatedLines = estimateWrappedLineCount(renderedText, charsPerLine); - const attachmentCount = message.attachments?.length ?? 0; - const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); - const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; + const imageAttachmentCount = + message.attachments?.filter((attachment) => attachment.type !== "file").length ?? 0; + const fileAttachmentCount = + message.attachments?.filter((attachment) => attachment.type === "file").length ?? 0; + const imageAttachmentRows = Math.ceil(imageAttachmentCount / ATTACHMENTS_PER_ROW); + const fileAttachmentRows = Math.ceil(fileAttachmentCount / ATTACHMENTS_PER_ROW); + const attachmentHeight = + imageAttachmentRows * USER_IMAGE_ATTACHMENT_ROW_HEIGHT_PX + + fileAttachmentRows * USER_FILE_ATTACHMENT_ROW_HEIGHT_PX; return USER_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX + attachmentHeight; } diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 72deb4a7b..0ea593d09 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -15,7 +15,12 @@ import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; import { normalizeModelSlug } from "@okcode/shared/model"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; -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, @@ -54,20 +59,27 @@ if (typeof window !== "undefined") { }); } -export const PersistedComposerImageAttachment = Schema.Struct({ +export const PersistedComposerAttachment = Schema.Struct({ + type: Schema.Literals(["image", "file"]), id: Schema.String, name: Schema.String, mimeType: Schema.String, sizeBytes: Schema.Number, dataUrl: Schema.String, }); -export type PersistedComposerImageAttachment = typeof PersistedComposerImageAttachment.Type; +export type PersistedComposerAttachment = typeof PersistedComposerAttachment.Type; export interface ComposerImageAttachment extends Omit { previewUrl: string; file: File; } +export interface ComposerFileAttachment extends Omit { + file: File; +} + +export type ComposerAttachment = ComposerImageAttachment | ComposerFileAttachment; + const PersistedTerminalContextDraft = Schema.Struct({ id: Schema.String, threadId: ThreadId, @@ -81,7 +93,7 @@ type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type; const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, - attachments: Schema.Array(PersistedComposerImageAttachment), + attachments: Schema.Array(PersistedComposerAttachment), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), provider: Schema.optionalKey(ProviderKind), model: Schema.optionalKey(Schema.String), @@ -127,9 +139,9 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ interface ComposerThreadDraftState { prompt: string; - images: ComposerImageAttachment[]; - nonPersistedImageIds: string[]; - persistedAttachments: PersistedComposerImageAttachment[]; + attachments: ComposerAttachment[]; + nonPersistedAttachmentIds: string[]; + persistedAttachments: PersistedComposerAttachment[]; terminalContexts: TerminalContextDraft[]; provider: ProviderKind | null; model: string | null; @@ -211,9 +223,9 @@ interface ComposerDraftStoreState { threadId: ThreadId, interactionMode: ProviderInteractionMode | null | undefined, ) => void; - addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; - addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; - removeImage: (threadId: ThreadId, imageId: string) => void; + addAttachment: (threadId: ThreadId, attachment: ComposerAttachment) => void; + addAttachments: (threadId: ThreadId, attachments: ComposerAttachment[]) => void; + removeAttachment: (threadId: ThreadId, attachmentId: string) => void; insertTerminalContext: ( threadId: ThreadId, prompt: string, @@ -227,7 +239,7 @@ interface ComposerDraftStoreState { clearPersistedAttachments: (threadId: ThreadId) => void; syncPersistedAttachments: ( threadId: ThreadId, - attachments: PersistedComposerImageAttachment[], + attachments: PersistedComposerAttachment[], ) => void; clearComposerContent: (threadId: ThreadId) => void; } @@ -242,17 +254,17 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze({ prompt: "", - images: EMPTY_IMAGES, - nonPersistedImageIds: EMPTY_IDS, + attachments: EMPTY_ATTACHMENTS, + nonPersistedAttachmentIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, provider: null, @@ -265,8 +277,8 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", - images: [], - nonPersistedImageIds: [], + attachments: [], + nonPersistedAttachmentIds: [], persistedAttachments: [], terminalContexts: [], provider: null, @@ -277,10 +289,10 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { }; } -function composerImageDedupKey(image: ComposerImageAttachment): string { +function composerAttachmentDedupKey(attachment: ComposerAttachment): string { // Keep this independent from File.lastModified so dedupe is stable for hydrated // images reconstructed from localStorage (which get a fresh lastModified value). - return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`; + return `${attachment.type}\u0000${attachment.mimeType}\u0000${attachment.sizeBytes}\u0000${attachment.name}`; } function terminalContextDedupKey(context: TerminalContextDraft): string { @@ -337,7 +349,7 @@ function normalizeTerminalContextsForThread( function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { return ( draft.prompt.length === 0 && - draft.images.length === 0 && + draft.attachments.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && draft.provider === null && @@ -454,17 +466,19 @@ function revokeObjectPreviewUrl(previewUrl: string): void { URL.revokeObjectURL(previewUrl); } -function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null { +function normalizePersistedAttachment(value: unknown): PersistedComposerAttachment | null { if (!value || typeof value !== "object") { return null; } const candidate = value as Record; + const type = candidate.type; const id = candidate.id; const name = candidate.name; const mimeType = candidate.mimeType; const sizeBytes = candidate.sizeBytes; const dataUrl = candidate.dataUrl; if ( + (type !== "image" && type !== "file") || typeof id !== "string" || typeof name !== "string" || typeof mimeType !== "string" || @@ -477,6 +491,7 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt return null; } return { + type, id, name, mimeType, @@ -853,7 +868,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { function verifyPersistedAttachments( threadId: ThreadId, - attachments: PersistedComposerImageAttachment[], + attachments: PersistedComposerAttachment[], set: ( partial: | ComposerDraftStoreState @@ -876,17 +891,17 @@ function verifyPersistedAttachments( if (!current) { return state; } - const imageIdSet = new Set(current.images.map((image) => image.id)); + const attachmentIdSet = new Set(current.attachments.map((attachment) => attachment.id)); const persistedAttachments = attachments.filter( - (attachment) => imageIdSet.has(attachment.id) && persistedIdSet.has(attachment.id), + (attachment) => attachmentIdSet.has(attachment.id) && persistedIdSet.has(attachment.id), ); - const nonPersistedImageIds = current.images - .map((image) => image.id) - .filter((imageId) => !persistedIdSet.has(imageId)); + const nonPersistedAttachmentIds = current.attachments + .map((attachment) => attachment.id) + .filter((attachmentId) => !persistedIdSet.has(attachmentId)); const nextDraft: ComposerThreadDraftState = { ...current, persistedAttachments, - nonPersistedImageIds, + nonPersistedAttachmentIds, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -898,8 +913,8 @@ function verifyPersistedAttachments( }); } -function hydreatePersistedComposerImageAttachment( - attachment: PersistedComposerImageAttachment, +function hydratePersistedComposerAttachmentFile( + attachment: PersistedComposerAttachment, ): File | null { const commaIndex = attachment.dataUrl.indexOf(","); const header = commaIndex === -1 ? attachment.dataUrl : attachment.dataUrl.slice(0, commaIndex); @@ -930,23 +945,36 @@ function hydreatePersistedComposerImageAttachment( } } -function hydrateImagesFromPersisted( - attachments: ReadonlyArray, -): ComposerImageAttachment[] { +function hydrateAttachmentsFromPersisted( + attachments: ReadonlyArray, +): ComposerAttachment[] { return attachments.flatMap((attachment) => { - const file = hydreatePersistedComposerImageAttachment(attachment); + const file = hydratePersistedComposerAttachmentFile(attachment); if (!file) return []; + if (attachment.type === "image") { + return [ + { + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: attachment.dataUrl, + file, + } satisfies ComposerImageAttachment, + ]; + } + return [ { - type: "image" as const, + type: "file" as const, id: attachment.id, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, - previewUrl: attachment.dataUrl, file, - } satisfies ComposerImageAttachment, + } satisfies ComposerFileAttachment, ]; }); } @@ -956,8 +984,8 @@ function toHydratedThreadDraft( ): ComposerThreadDraftState { return { prompt: persistedDraft.prompt, - images: hydrateImagesFromPersisted(persistedDraft.attachments), - nonPersistedImageIds: [], + attachments: hydrateAttachmentsFromPersisted(persistedDraft.attachments), + nonPersistedAttachmentIds: [], persistedAttachments: [...persistedDraft.attachments], terminalContexts: persistedDraft.terminalContexts?.map((context) => ({ @@ -1196,8 +1224,10 @@ export const useComposerDraftStore = create()( } const existing = get().draftsByThreadId[threadId]; if (existing) { - for (const image of existing.images) { - revokeObjectPreviewUrl(image.previewUrl); + for (const attachment of existing.attachments) { + if (attachment.type === "image") { + revokeObjectPreviewUrl(attachment.previewUrl); + } } } set((state) => { @@ -1478,37 +1508,46 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, - addImage: (threadId, image) => { + addAttachment: (threadId, attachment) => { if (threadId.length === 0) { return; } - get().addImages(threadId, [image]); + get().addAttachments(threadId, [attachment]); }, - addImages: (threadId, images) => { - if (threadId.length === 0 || images.length === 0) { + addAttachments: (threadId, attachments) => { + if (threadId.length === 0 || attachments.length === 0) { return; } set((state) => { const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); - const existingIds = new Set(existing.images.map((image) => image.id)); + const existingIds = new Set(existing.attachments.map((attachment) => attachment.id)); const existingDedupKeys = new Set( - existing.images.map((image) => composerImageDedupKey(image)), + existing.attachments.map((attachment) => composerAttachmentDedupKey(attachment)), + ); + const acceptedPreviewUrls = new Set( + existing.attachments.flatMap((attachment) => + attachment.type === "image" ? [attachment.previewUrl] : [], + ), ); - const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl)); - const dedupedIncoming: ComposerImageAttachment[] = []; - for (const image of images) { - const dedupKey = composerImageDedupKey(image); - if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) { + const dedupedIncoming: ComposerAttachment[] = []; + for (const attachment of attachments) { + const dedupKey = composerAttachmentDedupKey(attachment); + if (existingIds.has(attachment.id) || existingDedupKeys.has(dedupKey)) { // Avoid revoking a blob URL that's still referenced by an accepted image. - if (!acceptedPreviewUrls.has(image.previewUrl)) { - revokeObjectPreviewUrl(image.previewUrl); + if ( + attachment.type === "image" && + !acceptedPreviewUrls.has(attachment.previewUrl) + ) { + revokeObjectPreviewUrl(attachment.previewUrl); } continue; } - dedupedIncoming.push(image); - existingIds.add(image.id); + dedupedIncoming.push(attachment); + existingIds.add(attachment.id); existingDedupKeys.add(dedupKey); - acceptedPreviewUrls.add(image.previewUrl); + if (attachment.type === "image") { + acceptedPreviewUrls.add(attachment.previewUrl); + } } if (dedupedIncoming.length === 0) { return state; @@ -1518,13 +1557,13 @@ export const useComposerDraftStore = create()( ...state.draftsByThreadId, [threadId]: { ...existing, - images: [...existing.images, ...dedupedIncoming], + attachments: [...existing.attachments, ...dedupedIncoming], }, }, }; }); }, - removeImage: (threadId, imageId) => { + removeAttachment: (threadId, attachmentId) => { if (threadId.length === 0) { return; } @@ -1532,9 +1571,11 @@ export const useComposerDraftStore = create()( if (!existing) { return; } - const removedImage = existing.images.find((image) => image.id === imageId); - if (removedImage) { - revokeObjectPreviewUrl(removedImage.previewUrl); + const removedAttachment = existing.attachments.find( + (attachment) => attachment.id === attachmentId, + ); + if (removedAttachment?.type === "image") { + revokeObjectPreviewUrl(removedAttachment.previewUrl); } set((state) => { const current = state.draftsByThreadId[threadId]; @@ -1543,10 +1584,14 @@ export const useComposerDraftStore = create()( } const nextDraft: ComposerThreadDraftState = { ...current, - images: current.images.filter((image) => image.id !== imageId), - nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId), + attachments: current.attachments.filter( + (attachment) => attachment.id !== attachmentId, + ), + nonPersistedAttachmentIds: current.nonPersistedAttachmentIds.filter( + (id) => id !== attachmentId, + ), persistedAttachments: current.persistedAttachments.filter( - (attachment) => attachment.id !== imageId, + (attachment) => attachment.id !== attachmentId, ), }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; @@ -1688,7 +1733,7 @@ export const useComposerDraftStore = create()( const nextDraft: ComposerThreadDraftState = { ...current, persistedAttachments: [], - nonPersistedImageIds: [], + nonPersistedAttachmentIds: [], }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1713,7 +1758,7 @@ export const useComposerDraftStore = create()( ...current, // Stage attempted attachments so persist middleware can try writing them. persistedAttachments: attachments, - nonPersistedImageIds: current.nonPersistedImageIds.filter( + nonPersistedAttachmentIds: current.nonPersistedAttachmentIds.filter( (id) => !attachmentIdSet.has(id), ), }; @@ -1741,8 +1786,8 @@ export const useComposerDraftStore = create()( const nextDraft: ComposerThreadDraftState = { ...current, prompt: "", - images: [], - nonPersistedImageIds: [], + attachments: [], + nonPersistedAttachmentIds: [], persistedAttachments: [], terminalContexts: [], }; diff --git a/apps/web/src/historyBootstrap.ts b/apps/web/src/historyBootstrap.ts index b6a2570f0..e825fcfb2 100644 --- a/apps/web/src/historyBootstrap.ts +++ b/apps/web/src/historyBootstrap.ts @@ -19,17 +19,31 @@ function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" { } function attachmentSummary(message: ChatMessage): string | null { - const imageAttachments = message.attachments?.filter((attachment) => attachment.type === "image"); - const count = imageAttachments?.length ?? 0; - if (count === 0) { + const attachments = message.attachments ?? []; + if (attachments.length === 0) { return null; } - const names = imageAttachments?.slice(0, 3).map((image) => image.name) ?? []; - const namesSummary = names.join(", "); - const extraCount = count - names.length; - const extraSummary = extraCount > 0 ? ` (+${extraCount} more)` : ""; - return `[Attached image${count === 1 ? "" : "s"}: ${namesSummary}${extraSummary}]`; + const imageAttachments = attachments.filter((attachment) => attachment.type === "image"); + const fileAttachments = attachments.filter((attachment) => attachment.type === "file"); + const summaries: string[] = []; + + if (imageAttachments.length > 0) { + const names = imageAttachments.slice(0, 3).map((image) => image.name); + const extraCount = imageAttachments.length - names.length; + summaries.push( + `image${imageAttachments.length === 1 ? "" : "s"}: ${names.join(", ")}${extraCount > 0 ? ` (+${extraCount} more)` : ""}`, + ); + } + if (fileAttachments.length > 0) { + const names = fileAttachments.slice(0, 3).map((file) => file.name); + const extraCount = fileAttachments.length - names.length; + summaries.push( + `file${fileAttachments.length === 1 ? "" : "s"}: ${names.join(", ")}${extraCount > 0 ? ` (+${extraCount} more)` : ""}`, + ); + } + + return summaries.length > 0 ? `[Attached ${summaries.join("; ")}]` : null; } function buildMessageBlock(message: ChatMessage): string { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 4b4edf28c..381925529 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -261,12 +261,24 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea : null, messages: thread.messages.map((message) => { const attachments = message.attachments?.map((attachment) => ({ - type: "image" as const, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), + ...(attachment.type === "image" + ? { + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + url: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), + previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), + } + : { + type: "file" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + url: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), + }), })); const normalizedMessage: ChatMessage = { id: message.id, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index fb546fc22..2923232b4 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -28,16 +28,24 @@ export interface ThreadTerminalGroup { terminalIds: string[]; } -export interface ChatImageAttachment { - type: "image"; +interface ChatAttachmentBase { id: string; name: string; mimeType: string; sizeBytes: number; + url?: string; +} + +export interface ChatImageAttachment extends ChatAttachmentBase { + type: "image"; previewUrl?: string; } -export type ChatAttachment = ChatImageAttachment; +export interface ChatFileAttachment extends ChatAttachmentBase { + type: "file"; +} + +export type ChatAttachment = ChatImageAttachment | ChatFileAttachment; export interface ChatMessage { id: MessageId; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b3787be96..acd99905a 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -108,7 +108,10 @@ export const MAX_THREADS_PER_PROJECT = 100; 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 = 1 * 1024 * 1024; +export const DEFAULT_CHAT_FILE_MIME_TYPE = "application/octet-stream"; const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; +const PROVIDER_SEND_TURN_MAX_FILE_DATA_URL_CHARS = 1_600_000; const CHAT_ATTACHMENT_ID_MAX_CHARS = 128; // Correlation id is command id by design in this model. export const CorrelationId = CommandId; @@ -129,6 +132,15 @@ 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(255)), + sizeBytes: NonNegativeInt.check(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)), @@ -140,9 +152,20 @@ const UploadChatImageAttachment = Schema.Struct({ }); export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type; -export const ChatAttachment = Schema.Union([ChatImageAttachment]); +const UploadChatFileAttachment = Schema.Struct({ + type: Schema.Literal("file"), + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES)), + dataUrl: TrimmedNonEmptyString.check( + Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_FILE_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([