diff --git a/app/src/chat/chatSendError.ts b/app/src/chat/chatSendError.ts
index 1c1b5bec8..96b1bd390 100644
--- a/app/src/chat/chatSendError.ts
+++ b/app/src/chat/chatSendError.ts
@@ -15,7 +15,8 @@ export type ChatSendErrorCode =
| 'safety_timeout'
| 'usage_limit_reached'
| 'prompt_blocked'
- | 'prompt_review';
+ | 'prompt_review'
+ | 'attachment_invalid';
export interface ChatSendError {
code: ChatSendErrorCode;
diff --git a/app/src/components/chat/AttachmentPreview.tsx b/app/src/components/chat/AttachmentPreview.tsx
new file mode 100644
index 000000000..969bffff6
--- /dev/null
+++ b/app/src/components/chat/AttachmentPreview.tsx
@@ -0,0 +1,55 @@
+import { type Attachment, formatFileSize } from '../../lib/attachments';
+import { useT } from '../../lib/i18n/I18nContext';
+
+interface AttachmentPreviewProps {
+ attachments: Attachment[];
+ onRemove: (id: string) => void;
+ disabled?: boolean;
+}
+
+export default function AttachmentPreview({
+ attachments,
+ onRemove,
+ disabled,
+}: AttachmentPreviewProps) {
+ const { t } = useT();
+
+ if (attachments.length === 0) return null;
+
+ return (
+
+ {attachments.map(attachment => (
+
+

+
+ {attachment.file.name}
+
+ {formatFileSize(attachment.file.size)}
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/app/src/components/chat/__tests__/AttachmentPreview.test.tsx b/app/src/components/chat/__tests__/AttachmentPreview.test.tsx
new file mode 100644
index 000000000..61c3fc100
--- /dev/null
+++ b/app/src/components/chat/__tests__/AttachmentPreview.test.tsx
@@ -0,0 +1,63 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { Attachment } from '../../../lib/attachments';
+import AttachmentPreview from '../AttachmentPreview';
+
+function makeAttachment(overrides: Partial = {}): Attachment {
+ const blob = new Blob([new Uint8Array(512)], { type: 'image/png' });
+ return {
+ id: 'att-1',
+ file: new File([blob], 'test.png', { type: 'image/png' }),
+ dataUri: 'data:image/png;base64,abc',
+ mimeType: 'image/png',
+ ...overrides,
+ };
+}
+
+describe('AttachmentPreview', () => {
+ it('renders nothing when attachments list is empty', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders a chip with filename and file size for each attachment', () => {
+ const att = makeAttachment();
+ render();
+ expect(screen.getByText('test.png')).toBeInTheDocument();
+ expect(screen.getByText('512 B')).toBeInTheDocument();
+ });
+
+ it('renders a thumbnail image with the dataUri as src', () => {
+ const att = makeAttachment({ dataUri: 'data:image/png;base64,xyz' });
+ render();
+ const img = screen.getByAltText('test.png') as HTMLImageElement;
+ expect(img.src).toBe('data:image/png;base64,xyz');
+ });
+
+ it('calls onRemove with the attachment id when × is clicked', () => {
+ const onRemove = vi.fn();
+ const att = makeAttachment({ id: 'att-42' });
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /remove test\.png/i }));
+ expect(onRemove).toHaveBeenCalledWith('att-42');
+ });
+
+ it('disables the remove button when disabled prop is true', () => {
+ const att = makeAttachment();
+ render();
+ expect(screen.getByRole('button', { name: /remove test\.png/i })).toBeDisabled();
+ });
+
+ it('renders multiple chips', () => {
+ const a1 = makeAttachment({ id: '1', file: new File([], 'a.png', { type: 'image/png' }) });
+ const a2 = makeAttachment({
+ id: '2',
+ file: new File([], 'b.jpg', { type: 'image/jpeg' }),
+ mimeType: 'image/jpeg',
+ });
+ render();
+ expect(screen.getByText('a.png')).toBeInTheDocument();
+ expect(screen.getByText('b.jpg')).toBeInTheDocument();
+ });
+});
diff --git a/app/src/lib/attachments.test.ts b/app/src/lib/attachments.test.ts
new file mode 100644
index 000000000..c16870722
--- /dev/null
+++ b/app/src/lib/attachments.test.ts
@@ -0,0 +1,188 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ type Attachment,
+ ATTACHMENT_MAX_IMAGES,
+ ATTACHMENT_MAX_SIZE_BYTES,
+ buildMessageWithAttachments,
+ fileToDataUri,
+ formatFileSize,
+ isAllowedMimeType,
+ parseMessageImages,
+ validateAndReadFile,
+} from './attachments';
+
+function makeFile(name: string, type: string, size = 1024): File {
+ const blob = new Blob([new Uint8Array(size)], { type });
+ return new File([blob], name, { type });
+}
+
+function makeAttachment(overrides: Partial = {}): Attachment {
+ return {
+ id: 'test-id',
+ file: makeFile('test.png', 'image/png'),
+ dataUri: 'data:image/png;base64,abc',
+ mimeType: 'image/png',
+ ...overrides,
+ };
+}
+
+describe('isAllowedMimeType', () => {
+ it('allows supported image types', () => {
+ expect(isAllowedMimeType('image/png')).toBe(true);
+ expect(isAllowedMimeType('image/jpeg')).toBe(true);
+ expect(isAllowedMimeType('image/webp')).toBe(true);
+ expect(isAllowedMimeType('image/gif')).toBe(true);
+ expect(isAllowedMimeType('image/bmp')).toBe(true);
+ });
+
+ it('rejects unsupported types', () => {
+ expect(isAllowedMimeType('application/pdf')).toBe(false);
+ expect(isAllowedMimeType('text/plain')).toBe(false);
+ expect(isAllowedMimeType('image/svg+xml')).toBe(false);
+ expect(isAllowedMimeType('')).toBe(false);
+ });
+});
+
+describe('fileToDataUri', () => {
+ it('reads a file as a data URI', async () => {
+ const file = makeFile('photo.png', 'image/png', 4);
+ const uri = await fileToDataUri(file);
+ expect(uri).toMatch(/^data:image\/png;base64,/);
+ });
+});
+
+describe('validateAndReadFile', () => {
+ it('rejects when at max image count', async () => {
+ const file = makeFile('x.png', 'image/png');
+ const result = await validateAndReadFile(file, ATTACHMENT_MAX_IMAGES);
+ expect('error' in result).toBe(true);
+ if ('error' in result) {
+ expect(result.error.code).toBe('too_many');
+ }
+ });
+
+ it('rejects unsupported MIME types', async () => {
+ const file = makeFile('doc.pdf', 'application/pdf');
+ const result = await validateAndReadFile(file, 0);
+ expect('error' in result).toBe(true);
+ if ('error' in result) {
+ expect(result.error.code).toBe('unsupported_type');
+ }
+ });
+
+ it('rejects files that exceed the size limit', async () => {
+ const oversizedFile = makeFile('big.png', 'image/png', ATTACHMENT_MAX_SIZE_BYTES + 1);
+ const result = await validateAndReadFile(oversizedFile, 0);
+ expect('error' in result).toBe(true);
+ if ('error' in result) {
+ expect(result.error.code).toBe('too_large');
+ }
+ });
+
+ it('returns an attachment for a valid image', async () => {
+ const file = makeFile('ok.png', 'image/png', 512);
+ const result = await validateAndReadFile(file, 0);
+ expect('attachment' in result).toBe(true);
+ if ('attachment' in result) {
+ expect(result.attachment.mimeType).toBe('image/png');
+ expect(result.attachment.dataUri).toMatch(/^data:image\/png;base64,/);
+ expect(result.attachment.file).toBe(file);
+ }
+ });
+
+ it('returns read_failed when FileReader errors', async () => {
+ const file = makeFile('bad.png', 'image/png', 1);
+ const origFileReader = globalThis.FileReader;
+ class FailingReader {
+ onload: (() => void) | null = null;
+ onerror: ((e: unknown) => void) | null = null;
+ readAsDataURL() {
+ setTimeout(() => this.onerror?.(new Error('read error')), 0);
+ }
+ }
+ globalThis.FileReader = FailingReader as unknown as typeof FileReader;
+ try {
+ const result = await validateAndReadFile(file, 0);
+ expect('error' in result).toBe(true);
+ if ('error' in result) {
+ expect(result.error.code).toBe('read_failed');
+ }
+ } finally {
+ globalThis.FileReader = origFileReader;
+ }
+ });
+});
+
+describe('buildMessageWithAttachments', () => {
+ it('returns text unchanged when no attachments', () => {
+ expect(buildMessageWithAttachments('hello', [])).toBe('hello');
+ });
+
+ it('appends IMAGE markers after the text', () => {
+ const a = makeAttachment({ dataUri: 'data:image/png;base64,abc' });
+ const result = buildMessageWithAttachments('describe this', [a]);
+ expect(result).toBe('describe this [IMAGE:data:image/png;base64,abc]');
+ });
+
+ it('emits only markers when text is empty', () => {
+ const a = makeAttachment({ dataUri: 'data:image/png;base64,abc' });
+ const result = buildMessageWithAttachments('', [a]);
+ expect(result).toBe('[IMAGE:data:image/png;base64,abc]');
+ });
+
+ it('handles multiple attachments', () => {
+ const a1 = makeAttachment({ id: '1', dataUri: 'data:image/png;base64,a' });
+ const a2 = makeAttachment({ id: '2', dataUri: 'data:image/jpeg;base64,b' });
+ const result = buildMessageWithAttachments('look', [a1, a2]);
+ expect(result).toBe('look [IMAGE:data:image/png;base64,a] [IMAGE:data:image/jpeg;base64,b]');
+ });
+
+ it('trims leading/trailing whitespace from text', () => {
+ const a = makeAttachment({ dataUri: 'data:image/png;base64,x' });
+ const result = buildMessageWithAttachments(' hi ', [a]);
+ expect(result).toBe('hi [IMAGE:data:image/png;base64,x]');
+ });
+});
+
+describe('parseMessageImages', () => {
+ it('returns empty dataUris and original text when no markers', () => {
+ const result = parseMessageImages('hello world');
+ expect(result.text).toBe('hello world');
+ expect(result.dataUris).toEqual([]);
+ });
+
+ it('extracts a single image marker and strips it from text', () => {
+ const result = parseMessageImages('describe this [IMAGE:data:image/png;base64,abc]');
+ expect(result.text).toBe('describe this');
+ expect(result.dataUris).toEqual(['data:image/png;base64,abc']);
+ });
+
+ it('extracts multiple image markers', () => {
+ const result = parseMessageImages(
+ '[IMAGE:data:image/png;base64,a] [IMAGE:data:image/jpeg;base64,b]'
+ );
+ expect(result.text).toBe('');
+ expect(result.dataUris).toEqual(['data:image/png;base64,a', 'data:image/jpeg;base64,b']);
+ });
+
+ it('returns empty text when message is marker-only', () => {
+ const result = parseMessageImages('[IMAGE:data:image/png;base64,xyz]');
+ expect(result.text).toBe('');
+ expect(result.dataUris).toHaveLength(1);
+ });
+});
+
+describe('formatFileSize', () => {
+ it('formats bytes', () => {
+ expect(formatFileSize(500)).toBe('500 B');
+ });
+
+ it('formats kilobytes', () => {
+ expect(formatFileSize(1536)).toBe('1.5 KB');
+ });
+
+ it('formats megabytes', () => {
+ expect(formatFileSize(2 * 1024 * 1024)).toBe('2.0 MB');
+ });
+});
diff --git a/app/src/lib/attachments.ts b/app/src/lib/attachments.ts
new file mode 100644
index 000000000..d8ace8d55
--- /dev/null
+++ b/app/src/lib/attachments.ts
@@ -0,0 +1,115 @@
+/**
+ * Utilities for multimodal chat attachments.
+ *
+ * Images are embedded in the message text as `[IMAGE:]` markers,
+ * which the Rust agent harness (`agent/multimodal.rs`) parses and forwards to
+ * the inference provider. The backend supports up to 4 images per message at
+ * 8 MB each by default (governed by `MultimodalConfig`).
+ */
+
+export const ATTACHMENT_MAX_IMAGES = 4;
+export const ATTACHMENT_MAX_SIZE_BYTES = 8 * 1024 * 1024; // 8 MB
+
+export const ALLOWED_IMAGE_MIME_TYPES = [
+ 'image/png',
+ 'image/jpeg',
+ 'image/webp',
+ 'image/gif',
+ 'image/bmp',
+] as const;
+
+export type AllowedImageMimeType = (typeof ALLOWED_IMAGE_MIME_TYPES)[number];
+
+export interface Attachment {
+ id: string;
+ file: File;
+ dataUri: string;
+ mimeType: AllowedImageMimeType;
+}
+
+export type AttachmentError =
+ | { code: 'unsupported_type'; mimeType: string }
+ | { code: 'too_large'; sizeBytes: number; maxBytes: number }
+ | { code: 'too_many'; max: number }
+ | { code: 'read_failed'; reason: string };
+
+export function isAllowedMimeType(mime: string): mime is AllowedImageMimeType {
+ return (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(mime);
+}
+
+export function fileToDataUri(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`));
+ reader.readAsDataURL(file);
+ });
+}
+
+export async function validateAndReadFile(
+ file: File,
+ existingCount: number
+): Promise<{ attachment: Attachment } | { error: AttachmentError }> {
+ if (existingCount >= ATTACHMENT_MAX_IMAGES) {
+ return { error: { code: 'too_many', max: ATTACHMENT_MAX_IMAGES } };
+ }
+
+ if (!isAllowedMimeType(file.type)) {
+ return { error: { code: 'unsupported_type', mimeType: file.type || 'unknown' } };
+ }
+
+ if (file.size > ATTACHMENT_MAX_SIZE_BYTES) {
+ return {
+ error: { code: 'too_large', sizeBytes: file.size, maxBytes: ATTACHMENT_MAX_SIZE_BYTES },
+ };
+ }
+
+ try {
+ const dataUri = await fileToDataUri(file);
+ return {
+ attachment: {
+ id: globalThis.crypto.randomUUID(),
+ file,
+ dataUri,
+ mimeType: file.type as AllowedImageMimeType,
+ },
+ };
+ } catch (err) {
+ return {
+ error: { code: 'read_failed', reason: err instanceof Error ? err.message : String(err) },
+ };
+ }
+}
+
+/**
+ * Compose the final message string by appending `[IMAGE:]` markers
+ * for each attachment after the user's text. The Rust backend parses these
+ * markers in `parse_image_markers` and strips them from the visible message
+ * before forwarding clean text + image payload to the inference provider.
+ */
+export function buildMessageWithAttachments(text: string, attachments: Attachment[]): string {
+ if (attachments.length === 0) return text;
+ const markers = attachments.map(a => `[IMAGE:${a.dataUri}]`).join(' ');
+ return text.trim() ? `${text.trim()} ${markers}` : markers;
+}
+
+/**
+ * Parse `[IMAGE:]` markers out of a stored message string.
+ * Returns the clean text (markers removed) and the list of data URIs found.
+ */
+export function parseMessageImages(content: string): { text: string; dataUris: string[] } {
+ const dataUris: string[] = [];
+ const text = content
+ .replace(/\[IMAGE:([^\]]+)\]/g, (_match, uri: string) => {
+ dataUris.push(uri);
+ return '';
+ })
+ .trim();
+ return { text, dataUris };
+}
+
+export function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
diff --git a/app/src/lib/i18n/chunks/ar-2.ts b/app/src/lib/i18n/chunks/ar-2.ts
index 1a9093407..cf8abe4bb 100644
--- a/app/src/lib/i18n/chunks/ar-2.ts
+++ b/app/src/lib/i18n/chunks/ar-2.ts
@@ -303,6 +303,12 @@ const ar2: TranslationMap = {
'chat.turn': 'دورة',
'chat.turns': 'دورات',
'chat.openWorkerThread': 'فتح محادثة العامل',
+ 'chat.attachment.attach': 'Attach image',
+ 'chat.attachment.remove': 'Remove {name}',
+ 'chat.attachment.tooMany': 'Maximum {max} images per message',
+ 'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
+ 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.readFailed': 'Could not read file',
'memory.searchAria': 'البحث في الذاكرة',
'memory.searchPlaceholder': 'البحث في إدخالات الذاكرة...',
'memory.sourceFilter.all': 'جميع المصادر',
diff --git a/app/src/lib/i18n/chunks/bn-2.ts b/app/src/lib/i18n/chunks/bn-2.ts
index cff1106f8..faf4d12d1 100644
--- a/app/src/lib/i18n/chunks/bn-2.ts
+++ b/app/src/lib/i18n/chunks/bn-2.ts
@@ -313,6 +313,12 @@ const bn2: TranslationMap = {
'chat.turn': 'টার্ন',
'chat.turns': 'টার্ন',
'chat.openWorkerThread': 'ওয়ার্কার থ্রেড খুলুন',
+ 'chat.attachment.attach': 'Attach image',
+ 'chat.attachment.remove': 'Remove {name}',
+ 'chat.attachment.tooMany': 'Maximum {max} images per message',
+ 'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
+ 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.readFailed': 'Could not read file',
'memory.searchAria': 'মেমোরি খুঁজুন',
'memory.searchPlaceholder': 'মেমোরি এন্ট্রি খুঁজুন...',
'memory.sourceFilter.all': 'সব উৎস',
diff --git a/app/src/lib/i18n/chunks/de-2.ts b/app/src/lib/i18n/chunks/de-2.ts
index b56e70c15..7571f1283 100644
--- a/app/src/lib/i18n/chunks/de-2.ts
+++ b/app/src/lib/i18n/chunks/de-2.ts
@@ -319,6 +319,12 @@ const de2: TranslationMap = {
'chat.turn': 'drehen',
'chat.turns': 'dreht sich',
'chat.openWorkerThread': 'Arbeitsthread öffnen',
+ 'chat.attachment.attach': 'Attach image',
+ 'chat.attachment.remove': 'Remove {name}',
+ 'chat.attachment.tooMany': 'Maximum {max} images per message',
+ 'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
+ 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.readFailed': 'Could not read file',
'memory.searchAria': 'Speicher durchsuchen',
'memory.searchPlaceholder': 'Speichereinträge durchsuchen...',
'memory.sourceFilter.all': 'Alle Quellen',
diff --git a/app/src/lib/i18n/chunks/en-2.ts b/app/src/lib/i18n/chunks/en-2.ts
index 7339fd849..934997367 100644
--- a/app/src/lib/i18n/chunks/en-2.ts
+++ b/app/src/lib/i18n/chunks/en-2.ts
@@ -309,6 +309,12 @@ const en2: TranslationMap = {
'chat.turn': 'turn',
'chat.turns': 'turns',
'chat.openWorkerThread': 'Open worker thread',
+ 'chat.attachment.attach': 'Attach image',
+ 'chat.attachment.remove': 'Remove {name}',
+ 'chat.attachment.tooMany': 'Maximum {max} images per message',
+ 'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
+ 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.readFailed': 'Could not read file',
'memory.searchAria': 'Search memory',
'memory.searchPlaceholder': 'Search memory entries...',
'memory.sourceFilter.all': 'All sources',
diff --git a/app/src/lib/i18n/chunks/es-2.ts b/app/src/lib/i18n/chunks/es-2.ts
index 8f8f1eff9..0458b62ce 100644
--- a/app/src/lib/i18n/chunks/es-2.ts
+++ b/app/src/lib/i18n/chunks/es-2.ts
@@ -316,6 +316,12 @@ const es2: TranslationMap = {
'chat.turn': 'turno',
'chat.turns': 'turnos',
'chat.openWorkerThread': 'Abrir hilo de worker',
+ 'chat.attachment.attach': 'Attach image',
+ 'chat.attachment.remove': 'Remove {name}',
+ 'chat.attachment.tooMany': 'Maximum {max} images per message',
+ 'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
+ 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.readFailed': 'Could not read file',
'memory.searchAria': 'Buscar en memoria',
'memory.searchPlaceholder': 'Buscar entradas de memoria...',
'memory.sourceFilter.all': 'Todas las fuentes',
diff --git a/app/src/lib/i18n/chunks/fr-2.ts b/app/src/lib/i18n/chunks/fr-2.ts
index 2b35ff7b9..e5d525f85 100644
--- a/app/src/lib/i18n/chunks/fr-2.ts
+++ b/app/src/lib/i18n/chunks/fr-2.ts
@@ -318,6 +318,12 @@ const fr2: TranslationMap = {
'chat.turn': 'tour',
'chat.turns': 'tours',
'chat.openWorkerThread': 'Ouvrir le fil worker',
+ 'chat.attachment.attach': 'Attach image',
+ 'chat.attachment.remove': 'Remove {name}',
+ 'chat.attachment.tooMany': 'Maximum {max} images per message',
+ 'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
+ 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.readFailed': 'Could not read file',
'memory.searchAria': 'Rechercher dans la mémoire',
'memory.searchPlaceholder': 'Rechercher des entrées de mémoire…',
'memory.sourceFilter.all': 'Toutes les sources',
diff --git a/app/src/lib/i18n/chunks/hi-2.ts b/app/src/lib/i18n/chunks/hi-2.ts
index 7f1d800f9..a87a9c101 100644
--- a/app/src/lib/i18n/chunks/hi-2.ts
+++ b/app/src/lib/i18n/chunks/hi-2.ts
@@ -310,6 +310,12 @@ const hi2: TranslationMap = {
'chat.turn': 'टर्न',
'chat.turns': 'टर्न्स',
'chat.openWorkerThread': 'वर्कर थ्रेड खोलें',
+ 'chat.attachment.attach': 'Attach image',
+ 'chat.attachment.remove': 'Remove {name}',
+ 'chat.attachment.tooMany': 'Maximum {max} images per message',
+ 'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
+ 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.readFailed': 'Could not read file',
'memory.searchAria': 'मेमोरी सर्च करें',
'memory.searchPlaceholder': 'मेमोरी एंट्रीज़ सर्च करें...',
'memory.sourceFilter.all': 'सभी सोर्स',
diff --git a/app/src/lib/i18n/chunks/id-2.ts b/app/src/lib/i18n/chunks/id-2.ts
index fc96dc229..5324d2fe8 100644
--- a/app/src/lib/i18n/chunks/id-2.ts
+++ b/app/src/lib/i18n/chunks/id-2.ts
@@ -310,6 +310,12 @@ const id2: TranslationMap = {
'chat.turn': 'giliran',
'chat.turns': 'giliran',
'chat.openWorkerThread': 'Buka thread worker',
+ 'chat.attachment.attach': 'Attach image',
+ 'chat.attachment.remove': 'Remove {name}',
+ 'chat.attachment.tooMany': 'Maximum {max} images per message',
+ 'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
+ 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.readFailed': 'Could not read file',
'memory.searchAria': 'Cari memori',
'memory.searchPlaceholder': 'Cari entri memori...',
'memory.sourceFilter.all': 'Semua sumber',
diff --git a/app/src/lib/i18n/chunks/it-2.ts b/app/src/lib/i18n/chunks/it-2.ts
index dae8dcd8e..fff3dedb7 100644
--- a/app/src/lib/i18n/chunks/it-2.ts
+++ b/app/src/lib/i18n/chunks/it-2.ts
@@ -313,6 +313,12 @@ const it2: TranslationMap = {
'chat.turn': 'turno',
'chat.turns': 'turni',
'chat.openWorkerThread': 'Apri thread worker',
+ 'chat.attachment.attach': 'Attach image',
+ 'chat.attachment.remove': 'Remove {name}',
+ 'chat.attachment.tooMany': 'Maximum {max} images per message',
+ 'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
+ 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.readFailed': 'Could not read file',
'memory.searchAria': 'Cerca memoria',
'memory.searchPlaceholder': 'Cerca voci di memoria...',
'memory.sourceFilter.all': 'Tutte le origini',
diff --git a/app/src/lib/i18n/chunks/ko-2.ts b/app/src/lib/i18n/chunks/ko-2.ts
index a56c0e13b..317f3ec10 100644
--- a/app/src/lib/i18n/chunks/ko-2.ts
+++ b/app/src/lib/i18n/chunks/ko-2.ts
@@ -294,6 +294,12 @@ const ko2: TranslationMap = {
'chat.turn': '턴',
'chat.turns': '턴',
'chat.openWorkerThread': '워커 스레드 열기',
+ 'chat.attachment.attach': 'Attach image',
+ 'chat.attachment.remove': 'Remove {name}',
+ 'chat.attachment.tooMany': 'Maximum {max} images per message',
+ 'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
+ 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.readFailed': 'Could not read file',
'memory.searchAria': '메모리 검색',
'memory.searchPlaceholder': '메모리 항목 검색...',
'memory.sourceFilter.all': '모든 소스',
diff --git a/app/src/lib/i18n/chunks/pt-2.ts b/app/src/lib/i18n/chunks/pt-2.ts
index 5681471c0..8646f2cb9 100644
--- a/app/src/lib/i18n/chunks/pt-2.ts
+++ b/app/src/lib/i18n/chunks/pt-2.ts
@@ -316,6 +316,12 @@ const pt2: TranslationMap = {
'chat.turn': 'turno',
'chat.turns': 'turnos',
'chat.openWorkerThread': 'Abrir thread de worker',
+ 'chat.attachment.attach': 'Attach image',
+ 'chat.attachment.remove': 'Remove {name}',
+ 'chat.attachment.tooMany': 'Maximum {max} images per message',
+ 'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
+ 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.readFailed': 'Could not read file',
'memory.searchAria': 'Pesquisar memória',
'memory.searchPlaceholder': 'Pesquisar entradas de memória...',
'memory.sourceFilter.all': 'Todas as fontes',
diff --git a/app/src/lib/i18n/chunks/ru-2.ts b/app/src/lib/i18n/chunks/ru-2.ts
index 575b2a15d..a38e02c4b 100644
--- a/app/src/lib/i18n/chunks/ru-2.ts
+++ b/app/src/lib/i18n/chunks/ru-2.ts
@@ -312,6 +312,12 @@ const ru2: TranslationMap = {
'chat.turn': 'ход',
'chat.turns': 'ходов',
'chat.openWorkerThread': 'Открыть чат воркера',
+ 'chat.attachment.attach': 'Attach image',
+ 'chat.attachment.remove': 'Remove {name}',
+ 'chat.attachment.tooMany': 'Maximum {max} images per message',
+ 'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
+ 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.readFailed': 'Could not read file',
'memory.searchAria': 'Поиск в памяти',
'memory.searchPlaceholder': 'Поиск записей памяти...',
'memory.sourceFilter.all': 'Все источники',
diff --git a/app/src/lib/i18n/chunks/zh-CN-2.ts b/app/src/lib/i18n/chunks/zh-CN-2.ts
index 3b7938aa9..41cde91da 100644
--- a/app/src/lib/i18n/chunks/zh-CN-2.ts
+++ b/app/src/lib/i18n/chunks/zh-CN-2.ts
@@ -293,6 +293,12 @@ const zhCN2: TranslationMap = {
'chat.turn': '轮',
'chat.turns': '轮',
'chat.openWorkerThread': '打开工作线程',
+ 'chat.attachment.attach': 'Attach image',
+ 'chat.attachment.remove': 'Remove {name}',
+ 'chat.attachment.tooMany': 'Maximum {max} images per message',
+ 'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
+ 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.readFailed': 'Could not read file',
'memory.searchAria': '搜索记忆',
'memory.searchPlaceholder': '搜索记忆条目...',
'memory.sourceFilter.all': '所有来源',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index f9448e073..a37792c68 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -1605,6 +1605,12 @@ const en: TranslationMap = {
'chat.turn': 'turn',
'chat.turns': 'turns',
'chat.openWorkerThread': 'Open worker thread',
+ 'chat.attachment.attach': 'Attach image',
+ 'chat.attachment.remove': 'Remove {name}',
+ 'chat.attachment.tooMany': 'Maximum {max} images per message',
+ 'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
+ 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.readFailed': 'Could not read file',
// Memory (additional)
'memory.searchAria': 'Search memory',
diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx
index 4b7dfed9c..7ff61b9c2 100644
--- a/app/src/pages/Conversations.tsx
+++ b/app/src/pages/Conversations.tsx
@@ -6,6 +6,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { type ChatSendError, chatSendError } from '../chat/chatSendError';
import { checkPromptInjection, promptGuardMessage } from '../chat/promptInjectionGuard';
import ApprovalRequestCard from '../components/chat/ApprovalRequestCard';
+import AttachmentPreview from '../components/chat/AttachmentPreview';
import TokenUsagePill from '../components/chat/TokenUsagePill';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import PillTabBar from '../components/PillTabBar';
@@ -14,6 +15,15 @@ import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDism
import MicComposer from '../features/human/MicComposer';
import { useStickToBottom } from '../hooks/useStickToBottom';
import { useUsageState } from '../hooks/useUsageState';
+import {
+ ALLOWED_IMAGE_MIME_TYPES,
+ type Attachment,
+ ATTACHMENT_MAX_IMAGES,
+ ATTACHMENT_MAX_SIZE_BYTES,
+ buildMessageWithAttachments,
+ parseMessageImages,
+ validateAndReadFile,
+} from '../lib/attachments';
import { useT } from '../lib/i18n/I18nContext';
import { trackEvent } from '../services/analytics';
import { threadApi } from '../services/api/threadApi';
@@ -186,6 +196,8 @@ const Conversations = ({
const [showSidebar, setShowSidebar] = useState(false);
const [inputValue, setInputValue] = useState('');
+ const [attachments, setAttachments] = useState([]);
+ const fileInputRef = useRef(null);
const [copiedMessageId, setCopiedMessageId] = useState(null);
const [inputMode, setInputMode] = useState('text');
const [replyMode, setReplyMode] = useState('text');
@@ -196,6 +208,7 @@ const Conversations = ({
const [selectedLabel, setSelectedLabel] = useState('all');
const [inlineSuggestionValue, setInlineSuggestionValue] = useState('');
const [sendError, setSendError] = useState(null);
+ const [attachError, setAttachError] = useState(null);
const [sendAdvisory, setSendAdvisory] = useState(null);
const [pendingSendingThreadId, setPendingSendingThreadId] = useState(null);
const [profileDraftOpen, setProfileDraftOpen] = useState(false);
@@ -604,6 +617,40 @@ const Conversations = ({
return true;
};
+ const handleAttachFiles = async (files: FileList | null) => {
+ if (!files) return;
+ let acceptedCount = attachments.length;
+ for (const file of Array.from(files)) {
+ const result = await validateAndReadFile(file, acceptedCount);
+ if ('error' in result) {
+ const { error } = result;
+ if (error.code === 'too_many') {
+ setAttachError(
+ chatSendError(
+ 'attachment_invalid',
+ t('chat.attachment.tooMany').replace('{max}', String(ATTACHMENT_MAX_IMAGES))
+ )
+ );
+ } else if (error.code === 'too_large') {
+ const maxMb = (ATTACHMENT_MAX_SIZE_BYTES / (1024 * 1024)).toFixed(0);
+ setAttachError(
+ chatSendError(
+ 'attachment_invalid',
+ t('chat.attachment.tooLarge').replace('{max}', `${maxMb} MB`)
+ )
+ );
+ } else if (error.code === 'unsupported_type') {
+ setAttachError(chatSendError('attachment_invalid', t('chat.attachment.unsupportedType')));
+ } else {
+ setAttachError(chatSendError('attachment_invalid', t('chat.attachment.readFailed')));
+ }
+ return;
+ }
+ acceptedCount++;
+ setAttachments(prev => [...prev, result.attachment]);
+ }
+ };
+
const handleSendMessage = async (text?: string) => {
if (pendingSendRef.current) return;
@@ -622,7 +669,7 @@ const Conversations = ({
const trimmed = sendDecision.trimmedText;
if (
- sendDecision.blockReason === 'empty_input' ||
+ (sendDecision.blockReason === 'empty_input' && attachments.length === 0) ||
sendDecision.blockReason === 'missing_thread' ||
sendDecision.blockReason === 'composer_blocked'
) {
@@ -636,7 +683,10 @@ const Conversations = ({
setSendAdvisory(null);
}
- if (!sendDecision.shouldSend) {
+ if (
+ !sendDecision.shouldSend &&
+ !(sendDecision.blockReason === 'empty_input' && attachments.length > 0)
+ ) {
const blockedFeedback = getComposerBlockedSendFeedback(sendDecision.blockReason);
if (blockedFeedback) {
setSendError(chatSendError(blockedFeedback.error.code, blockedFeedback.error.message));
@@ -648,11 +698,20 @@ const Conversations = ({
if (!sendingThreadId) return;
pendingSendRef.current = sendingThreadId;
setPendingSendingThreadId(sendingThreadId);
+ const pendingAttachments = attachments.slice();
+ const messageText = buildMessageWithAttachments(trimmed, pendingAttachments);
const userMessage: ThreadMessage = {
id: `msg_${globalThis.crypto.randomUUID()}`,
content: trimmed,
type: 'text',
- extraMetadata: {},
+ extraMetadata:
+ pendingAttachments.length > 0
+ ? {
+ attachmentCount: pendingAttachments.length,
+ attachmentNames: pendingAttachments.map(a => a.file.name),
+ attachmentDataUris: pendingAttachments.map(a => a.dataUri),
+ }
+ : {},
sender: 'user',
createdAt: new Date().toISOString(),
};
@@ -677,7 +736,9 @@ const Conversations = ({
return;
}
setInputValue('');
+ setAttachments([]);
setSendError(null);
+ setAttachError(null);
// Silence timer: fires only if 600s pass without ANY inference progress
// (tool call, tool result, iteration start, subagent event, text delta).
// The effect below rearms this timer whenever `inferenceStatusByThread`
@@ -696,7 +757,7 @@ const Conversations = ({
try {
await chatSend({
threadId: sendingThreadId,
- message: trimmed,
+ message: messageText,
model: CHAT_MODEL_ID,
profileId: selectedAgentProfileId,
locale: uiLocale,
@@ -1548,13 +1609,43 @@ const Conversations = ({
)}
) : (
-
-
- {latestVisibleMessage?.id === msg.id && (
-
- {formatRelativeTime(msg.createdAt)}
-
- )}
+
+ {(() => {
+ const dataUris = Array.isArray(msg.extraMetadata?.attachmentDataUris)
+ ? (msg.extraMetadata.attachmentDataUris as string[])
+ : parseMessageImages(msg.content ?? '').dataUris;
+ const hasImages = dataUris.length > 0;
+ const showTime = latestVisibleMessage?.id === msg.id;
+ return (
+ <>
+ {hasImages && (
+
+ {dataUris.map((uri, i) => (
+

+ ))}
+
+ )}
+ {(msg.content || showTime) && (
+
+ {msg.content && (
+
+ )}
+ {showTime && (
+
+ {formatRelativeTime(msg.createdAt)}
+
+ )}
+
+ )}
+ >
+ );
+ })()}
)}