-
- {isProcessing ? (
-
- ) : (
-
+
+ {attachments.length > 0 && (
+
+ {attachments.map((attachment) => {
+ const attachmentType = getAttachmentTypeLabel(attachment)
+ const attachmentName = getAttachmentDisplayName(attachment)
+ const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
+
+ return (
+
+
+ {attachment.isImage && attachment.thumbnailUrl ? (
+
+ ) : (
+
+ )}
+
+
+ {attachmentName}
+ {attachmentType}
+
+
+
+ )
+ })}
+
)}
+
+
{
+ const files = e.target.files
+ if (!files || files.length === 0) return
+ const paths = Array.from(files)
+ .map((file) => window.electronUtils?.getPathForFile(file))
+ .filter(Boolean) as string[]
+ if (paths.length > 0) {
+ void addFiles(paths)
+ }
+ e.target.value = ''
+ }}
+ />
+
+
+ {isProcessing ? (
+
+ ) : (
+
+ )}
+
)
}
@@ -155,7 +314,7 @@ export interface ChatInputWithMentionsProps {
knowledgeFiles: string[]
recentFiles: string[]
visibleFiles: string[]
- onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
+ onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
diff --git a/apps/x/apps/renderer/src/components/chat-message-attachments.tsx b/apps/x/apps/renderer/src/components/chat-message-attachments.tsx
new file mode 100644
index 00000000..298e5f03
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/chat-message-attachments.tsx
@@ -0,0 +1,137 @@
+import {
+ AudioLines,
+ FileArchive,
+ FileCode2,
+ FileIcon,
+ FileSpreadsheet,
+ FileText,
+ FileVideo,
+} from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+
+import type { MessageAttachment } from '@/lib/chat-conversation'
+import {
+ type AttachmentIconKind,
+ getAttachmentDisplayName,
+ getAttachmentIconKind,
+ getAttachmentToneClass,
+ getAttachmentTypeLabel,
+} from '@/lib/attachment-presentation'
+import { isImageMime, toFileUrl } from '@/lib/file-utils'
+import { cn } from '@/lib/utils'
+
+function getAttachmentIcon(kind: AttachmentIconKind) {
+ switch (kind) {
+ case 'audio':
+ return AudioLines
+ case 'video':
+ return FileVideo
+ case 'spreadsheet':
+ return FileSpreadsheet
+ case 'archive':
+ return FileArchive
+ case 'code':
+ return FileCode2
+ case 'text':
+ return FileText
+ default:
+ return FileIcon
+ }
+}
+
+function ImageAttachmentPreview({ attachment }: { attachment: MessageAttachment }) {
+ const fallbackFileUrl = useMemo(() => toFileUrl(attachment.path), [attachment.path])
+ const [src, setSrc] = useState(attachment.thumbnailUrl || fallbackFileUrl)
+ const [triedBase64, setTriedBase64] = useState(Boolean(attachment.thumbnailUrl))
+
+ useEffect(() => {
+ const nextSrc = attachment.thumbnailUrl || fallbackFileUrl
+ setSrc(nextSrc)
+ setTriedBase64(Boolean(attachment.thumbnailUrl))
+ }, [attachment.thumbnailUrl, fallbackFileUrl])
+
+ const loadBase64 = useMemo(
+ () => async () => {
+ try {
+ const result = await window.ipc.invoke('shell:readFileBase64', { path: attachment.path })
+ const mimeType = result.mimeType || attachment.mimeType || 'image/*'
+ setSrc(`data:${mimeType};base64,${result.data}`)
+ } catch {
+ // Keep current src; fallback rendering (broken image icon) is better than crashing.
+ }
+ },
+ [attachment.mimeType, attachment.path]
+ )
+
+ useEffect(() => {
+ if (attachment.thumbnailUrl || triedBase64) return
+ setTriedBase64(true)
+ void loadBase64()
+ }, [attachment.thumbnailUrl, loadBase64, triedBase64])
+
+ return (
+

{
+ if (triedBase64) return
+ setTriedBase64(true)
+ void loadBase64()
+ }}
+ />
+ )
+}
+
+interface ChatMessageAttachmentsProps {
+ attachments: MessageAttachment[]
+ className?: string
+}
+
+export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) {
+ if (attachments.length === 0) return null
+
+ const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mimeType))
+ const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mimeType))
+
+ return (
+
+ {imageAttachments.length > 0 && (
+
+ {imageAttachments.map((attachment, index) => (
+
+ ))}
+
+ )}
+ {fileAttachments.length > 0 && (
+
+ {fileAttachments.map((attachment, index) => {
+ const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
+ const attachmentName = getAttachmentDisplayName(attachment)
+ const attachmentType = getAttachmentTypeLabel(attachment)
+ return (
+
+
+
+
+
+ {attachmentName}
+ {attachmentType}
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
index 170e8869..f020cdae 100644
--- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx
+++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
@@ -25,7 +25,8 @@ import { type PromptInputMessage, type FileMention } from '@/components/ai-eleme
import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
import { TabBar, type ChatTab } from '@/components/tab-bar'
-import { ChatInputWithMentions } from '@/components/chat-input-with-mentions'
+import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions'
+import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { wikiLabel } from '@/lib/wiki-links'
import {
type ChatTabViewState,
@@ -89,7 +90,7 @@ interface ChatSidebarProps {
isProcessing: boolean
isStopping?: boolean
onStop?: () => void
- onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
+ onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
knowledgeFiles?: string[]
recentFiles?: string[]
visibleFiles?: string[]
@@ -256,6 +257,18 @@ export function ChatSidebar({
const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) {
if (item.role === 'user') {
+ if (item.attachments && item.attachments.length > 0) {
+ return (
+
+
+
+
+ {item.content && (
+ {item.content}
+ )}
+
+ )
+ }
const { message, files } = parseAttachedFiles(item.content)
return (
diff --git a/apps/x/apps/renderer/src/lib/attachment-presentation.ts b/apps/x/apps/renderer/src/lib/attachment-presentation.ts
new file mode 100644
index 00000000..7ddedd30
--- /dev/null
+++ b/apps/x/apps/renderer/src/lib/attachment-presentation.ts
@@ -0,0 +1,107 @@
+import { getExtension } from '@/lib/file-utils'
+
+export type AttachmentLike = {
+ filename?: string
+ path: string
+ mimeType: string
+}
+
+export type AttachmentIconKind =
+ | 'audio'
+ | 'video'
+ | 'spreadsheet'
+ | 'archive'
+ | 'code'
+ | 'text'
+ | 'file'
+
+const ARCHIVE_EXTENSIONS = new Set([
+ 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
+])
+
+const SPREADSHEET_EXTENSIONS = new Set([
+ 'csv', 'tsv', 'xls', 'xlsx',
+])
+
+const CODE_EXTENSIONS = new Set([
+ 'js', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'toml', 'xml',
+ 'py', 'rb', 'go', 'rs', 'java', 'kt', 'c', 'cpp', 'h', 'hpp',
+ 'cs', 'php', 'swift', 'sh', 'sql', 'html', 'css', 'scss', 'md',
+])
+
+export function getAttachmentDisplayName(attachment: AttachmentLike): string {
+ if (attachment.filename) return attachment.filename
+ const fromPath = attachment.path.split(/[\\/]/).pop()
+ return fromPath || attachment.path
+}
+
+export function getAttachmentTypeLabel(attachment: AttachmentLike): string {
+ const ext = getExtension(getAttachmentDisplayName(attachment))
+ if (ext) return ext.toUpperCase()
+
+ const mediaType = attachment.mimeType.toLowerCase()
+ if (mediaType.startsWith('audio/')) return 'AUDIO'
+ if (mediaType.startsWith('video/')) return 'VIDEO'
+ if (mediaType.startsWith('text/')) return 'TEXT'
+ if (mediaType.startsWith('image/')) return 'IMAGE'
+
+ const [, subtypeRaw = 'file'] = mediaType.split('/')
+ const subtype = subtypeRaw.split(';')[0].split('+').pop() || 'file'
+ const cleaned = subtype.replace(/[^a-z0-9]/gi, '')
+ return cleaned ? cleaned.toUpperCase() : 'FILE'
+}
+
+export function getAttachmentIconKind(attachment: AttachmentLike): AttachmentIconKind {
+ const mediaType = attachment.mimeType.toLowerCase()
+ const ext = getExtension(attachment.filename || attachment.path)
+
+ if (mediaType.startsWith('audio/')) return 'audio'
+ if (mediaType.startsWith('video/')) return 'video'
+ if (mediaType.includes('spreadsheet') || SPREADSHEET_EXTENSIONS.has(ext)) return 'spreadsheet'
+ if (mediaType.includes('zip') || mediaType.includes('compressed') || ARCHIVE_EXTENSIONS.has(ext)) return 'archive'
+ if (
+ mediaType.includes('json')
+ || mediaType.includes('javascript')
+ || mediaType.includes('typescript')
+ || mediaType.includes('xml')
+ || CODE_EXTENSIONS.has(ext)
+ ) {
+ return 'code'
+ }
+ if (mediaType.startsWith('text/') || mediaType.includes('pdf') || mediaType.includes('document')) {
+ return 'text'
+ }
+
+ return 'file'
+}
+
+export function getAttachmentToneClass(typeLabel: string): string {
+ switch (typeLabel) {
+ case 'PDF':
+ return 'bg-red-500 text-white'
+ case 'CSV':
+ case 'XLS':
+ case 'XLSX':
+ case 'TSV':
+ return 'bg-emerald-500 text-white'
+ case 'ZIP':
+ case 'RAR':
+ case '7Z':
+ case 'TAR':
+ case 'GZ':
+ return 'bg-amber-500 text-white'
+ case 'MP3':
+ case 'WAV':
+ case 'M4A':
+ case 'FLAC':
+ case 'AAC':
+ return 'bg-fuchsia-500 text-white'
+ case 'MP4':
+ case 'MOV':
+ case 'AVI':
+ case 'WEBM':
+ return 'bg-violet-500 text-white'
+ default:
+ return 'bg-primary/85 text-primary-foreground'
+ }
+}
diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts
index 4dcd4c8c..830e250b 100644
--- a/apps/x/apps/renderer/src/lib/chat-conversation.ts
+++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts
@@ -2,10 +2,19 @@ import type { ToolUIPart } from 'ai'
import z from 'zod'
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
+export interface MessageAttachment {
+ path: string
+ filename: string
+ mimeType: string
+ size?: number
+ thumbnailUrl?: string
+}
+
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
+ attachments?: MessageAttachment[]
timestamp: number
}
diff --git a/apps/x/apps/renderer/src/lib/file-utils.ts b/apps/x/apps/renderer/src/lib/file-utils.ts
new file mode 100644
index 00000000..3ac3431a
--- /dev/null
+++ b/apps/x/apps/renderer/src/lib/file-utils.ts
@@ -0,0 +1,61 @@
+const IMAGE_MIMES = new Set([
+ 'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
+ 'image/svg+xml', 'image/bmp', 'image/tiff', 'image/ico', 'image/avif',
+]);
+
+const EXTENSION_TO_MIME: Record = {
+ // Images
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
+ webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/ico',
+ avif: 'image/avif', tiff: 'image/tiff',
+ // Text / code
+ txt: 'text/plain', md: 'text/markdown', html: 'text/html', css: 'text/css',
+ csv: 'text/csv', xml: 'text/xml',
+ js: 'text/javascript', ts: 'text/typescript', jsx: 'text/javascript',
+ tsx: 'text/typescript', json: 'application/json', yaml: 'text/yaml',
+ yml: 'text/yaml', toml: 'text/toml',
+ py: 'text/x-python', rb: 'text/x-ruby', rs: 'text/x-rust',
+ go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++',
+ h: 'text/x-c', hpp: 'text/x-c++', sh: 'text/x-shellscript',
+ // Documents
+ pdf: 'application/pdf',
+ // Archives
+ zip: 'application/zip',
+};
+
+export function isImageMime(mimeType: string): boolean {
+ return IMAGE_MIMES.has(mimeType) || mimeType.startsWith('image/');
+}
+
+export function getMimeFromExtension(ext: string): string {
+ const normalized = ext.toLowerCase().replace(/^\./, '');
+ return EXTENSION_TO_MIME[normalized] || 'application/octet-stream';
+}
+
+export function getFileDisplayName(filePath: string): string {
+ return filePath.split('/').pop() || filePath;
+}
+
+export function getExtension(filePath: string): string {
+ const name = filePath.split('/').pop() || '';
+ const dotIndex = name.lastIndexOf('.');
+ return dotIndex > 0 ? name.slice(dotIndex + 1).toLowerCase() : '';
+}
+
+export function toFileUrl(filePath: string): string {
+ if (!filePath) return filePath;
+ if (
+ filePath.startsWith('data:') ||
+ filePath.startsWith('file://') ||
+ filePath.startsWith('http://') ||
+ filePath.startsWith('https://')
+ ) {
+ return filePath;
+ }
+ const normalized = filePath.replace(/\\/g, '/');
+ const encoded = encodeURI(normalized);
+ if (/^[A-Za-z]:\//.test(normalized)) {
+ return `file:///${encoded}`;
+ }
+ return `file://${encoded}`;
+}
diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts
index abeeb53a..e1924523 100644
--- a/apps/x/packages/core/src/agents/runtime.ts
+++ b/apps/x/packages/core/src/agents/runtime.ts
@@ -357,6 +357,12 @@ export async function loadAgent(id: string): Promise> {
return await repo.fetch(id);
}
+function formatBytes(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`;
+}
+
export function convertFromMessages(messages: z.infer[]): ModelMessage[] {
const result: ModelMessage[] = [];
for (const msg of messages) {
@@ -400,11 +406,37 @@ export function convertFromMessages(messages: z.infer[]): ModelM
});
break;
case "user":
- result.push({
- role: "user",
- content: msg.content,
- providerOptions,
- });
+ if (typeof msg.content === 'string') {
+ // Legacy string — pass through unchanged
+ result.push({
+ role: "user",
+ content: msg.content,
+ providerOptions,
+ });
+ } else {
+ // New content parts array — collapse to text for LLM
+ const textSegments: string[] = [];
+ const attachmentLines: string[] = [];
+
+ for (const part of msg.content) {
+ if (part.type === "attachment") {
+ const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
+ attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`);
+ } else {
+ textSegments.push(part.text);
+ }
+ }
+
+ if (attachmentLines.length > 0) {
+ textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
+ }
+
+ result.push({
+ role: "user",
+ content: textSegments.join("\n"),
+ providerOptions,
+ });
+ }
break;
case "tool":
result.push({
diff --git a/apps/x/packages/core/src/application/lib/message-queue.ts b/apps/x/packages/core/src/application/lib/message-queue.ts
index c60ecd1f..2b864840 100644
--- a/apps/x/packages/core/src/application/lib/message-queue.ts
+++ b/apps/x/packages/core/src/application/lib/message-queue.ts
@@ -1,12 +1,16 @@
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
+import { UserMessageContent } from "@x/shared/dist/message.js";
+import z from "zod";
+
+export type UserMessageContentType = z.infer;
type EnqueuedMessage = {
messageId: string;
- message: string;
+ message: UserMessageContentType;
};
export interface IMessageQueue {
- enqueue(runId: string, message: string): Promise;
+ enqueue(runId: string, message: UserMessageContentType): Promise;
dequeue(runId: string): Promise;
}
@@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator;
}
- async enqueue(runId: string, message: string): Promise {
+ async enqueue(runId: string, message: UserMessageContentType): Promise {
if (!this.store[runId]) {
this.store[runId] = [];
}
diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts
index 15873c49..5d563f1f 100644
--- a/apps/x/packages/core/src/runs/repo.ts
+++ b/apps/x/packages/core/src/runs/repo.ts
@@ -46,10 +46,18 @@ export class FSRunsRepo implements IRunsRepo {
const messageEvent = event as z.infer;
if (messageEvent.message.role === 'user') {
const content = messageEvent.message.content;
- if (typeof content === 'string' && content.trim()) {
- // Clean attached-files XML and @mentions, then truncate to 100 chars
- const cleaned = cleanContentForTitle(content);
- if (!cleaned) continue; // Skip if only attached files/mentions
+ let textContent: string | undefined;
+ if (typeof content === 'string') {
+ textContent = content;
+ } else {
+ textContent = content
+ .filter(p => p.type === 'text')
+ .map(p => p.text)
+ .join('');
+ }
+ if (textContent && textContent.trim()) {
+ const cleaned = cleanContentForTitle(textContent);
+ if (!cleaned) continue;
return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
}
}
@@ -90,9 +98,17 @@ export class FSRunsRepo implements IRunsRepo {
if (msg.role === 'user') {
// Found first user message - use as title
const content = msg.content;
- if (typeof content === 'string' && content.trim()) {
- // Clean attached-files XML and @mentions, then truncate
- const cleaned = cleanContentForTitle(content);
+ let textContent: string | undefined;
+ if (typeof content === 'string') {
+ textContent = content;
+ } else {
+ textContent = content
+ .filter(p => p.type === 'text')
+ .map(p => p.text)
+ .join('');
+ }
+ if (textContent && textContent.trim()) {
+ const cleaned = cleanContentForTitle(textContent);
if (cleaned) {
title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
}
diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts
index 75f71d5f..0f123497 100644
--- a/apps/x/packages/core/src/runs/runs.ts
+++ b/apps/x/packages/core/src/runs/runs.ts
@@ -1,6 +1,6 @@
import z from "zod";
import container from "../di/container.js";
-import { IMessageQueue } from "../application/lib/message-queue.js";
+import { IMessageQueue, UserMessageContentType } from "../application/lib/message-queue.js";
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
import { IRunsRepo } from "./repo.js";
import { IAgentRuntime } from "../agents/runtime.js";
@@ -19,7 +19,7 @@ export async function createRun(opts: z.infer): Promise
return run;
}
-export async function createMessage(runId: string, message: string): Promise {
+export async function createMessage(runId: string, message: UserMessageContentType): Promise {
const queue = container.resolve('messageQueue');
const id = await queue.enqueue(runId, message);
const runtime = container.resolve('agentRuntime');
diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts
index 71491f8f..c0276d9a 100644
--- a/apps/x/packages/shared/src/ipc.ts
+++ b/apps/x/packages/shared/src/ipc.ts
@@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js';
+import { UserMessageContent } from './message.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
@@ -128,7 +129,7 @@ const ipcSchemas = {
'runs:createMessage': {
req: z.object({
runId: z.string(),
- message: z.string(),
+ message: UserMessageContent,
}),
res: z.object({
messageId: z.string(),
diff --git a/apps/x/packages/shared/src/message.ts b/apps/x/packages/shared/src/message.ts
index 702b103a..be761853 100644
--- a/apps/x/packages/shared/src/message.ts
+++ b/apps/x/packages/shared/src/message.ts
@@ -28,9 +28,30 @@ export const AssistantContentPart = z.union([
ToolCallPart,
]);
+// A piece of user-typed text within a content array
+export const UserTextPart = z.object({
+ type: z.literal("text"),
+ text: z.string(),
+});
+
+// An attachment within a content array
+export const UserAttachmentPart = z.object({
+ type: z.literal("attachment"),
+ path: z.string(), // absolute file path
+ filename: z.string(), // display name ("photo.png")
+ mimeType: z.string(), // MIME type ("image/png", "text/plain")
+ size: z.number().optional(), // bytes
+});
+
+// Any single part of a user message (text or attachment)
+export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]);
+
+// Named type for user message content — used everywhere instead of repeating the union
+export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]);
+
export const UserMessage = z.object({
role: z.literal("user"),
- content: z.string(),
+ content: UserMessageContent,
providerOptions: ProviderOptions.optional(),
});