Add plus button to prompt input for file and image attachments

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
tusharmagar 2026-02-17 13:42:53 +05:30 committed by Arjun
parent 9aa3a3f82b
commit c49a47e6bc
11 changed files with 507 additions and 115 deletions

View file

@ -5,11 +5,12 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
import type { LanguageModelUsage, ToolUIPart } from 'ai'; import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css' import './App.css'
import z from 'zod'; import z from 'zod';
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react'; import { CheckIcon, LoaderIcon, Paperclip, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react';
import { isImageMime } from '@/lib/file-utils'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { MarkdownEditor } from './components/markdown-editor'; import { MarkdownEditor } from './components/markdown-editor';
import { ChatSidebar } from './components/chat-sidebar'; import { ChatSidebar } from './components/chat-sidebar';
import { ChatInputWithMentions } from './components/chat-input-with-mentions'; import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions';
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
import { useDebounce } from './hooks/use-debounce'; import { useDebounce } from './hooks/use-debounce';
import { SidebarContentPanel } from '@/components/sidebar-content'; import { SidebarContentPanel } from '@/components/sidebar-content';
@ -52,6 +53,7 @@ import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar' import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar'
import { import {
type ChatMessage,
type ChatTabViewState, type ChatTabViewState,
type ConversationItem, type ConversationItem,
type ToolCall, type ToolCall,
@ -1171,19 +1173,35 @@ function App() {
if (msg.role === 'user' || msg.role === 'assistant') { if (msg.role === 'user' || msg.role === 'assistant') {
// Extract text content from message // Extract text content from message
let textContent = '' let textContent = ''
let msgAttachments: ChatMessage['attachments'] = undefined
if (typeof msg.content === 'string') { if (typeof msg.content === 'string') {
textContent = msg.content textContent = msg.content
} else if (Array.isArray(msg.content)) { } else if (Array.isArray(msg.content)) {
// Extract text parts const contentParts = msg.content as Array<{
textContent = msg.content type: string
.filter((part: { type: string }) => part.type === 'text') text?: string
.map((part: { type: string; text?: string }) => part.text || '') attachment?: NonNullable<ChatMessage['attachments']>[number]
toolCallId?: string
toolName?: string
arguments?: ToolUIPart['input']
}>
textContent = contentParts
.filter((part) => part.type === 'text')
.map((part) => part.text || '')
.join('') .join('')
const attachmentParts = contentParts.filter((part) => part.type === 'attachment' && part.attachment)
if (attachmentParts.length > 0) {
msgAttachments = attachmentParts
.map((part) => part.attachment)
.filter((attachment): attachment is NonNullable<ChatMessage['attachments']>[number] => Boolean(attachment))
}
// Also extract tool-call parts from assistant messages // Also extract tool-call parts from assistant messages
if (msg.role === 'assistant') { if (msg.role === 'assistant') {
for (const part of msg.content) { for (const part of contentParts) {
if (part.type === 'tool-call') { if (part.type === 'tool-call' && part.toolCallId && part.toolName) {
const toolCall: ToolCall = { const toolCall: ToolCall = {
id: part.toolCallId, id: part.toolCallId,
name: part.toolName, name: part.toolName,
@ -1197,11 +1215,12 @@ function App() {
} }
} }
} }
if (textContent) { if (textContent || msgAttachments) {
items.push({ items.push({
id: event.messageId, id: event.messageId,
role: msg.role, role: msg.role,
content: textContent, content: textContent,
attachments: msgAttachments,
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
}) })
} }
@ -1618,20 +1637,35 @@ function App() {
return cleanup return cleanup
}, [handleRunEvent]) }, [handleRunEvent])
const handlePromptSubmit = async (message: PromptInputMessage, mentions?: FileMention[]) => { const handlePromptSubmit = async (
message: PromptInputMessage,
mentions?: FileMention[],
stagedAttachments: StagedAttachment[] = []
) => {
if (isProcessing) return if (isProcessing) return
const { text } = message; const { text } = message
const userMessage = text.trim() const userMessage = text.trim()
if (!userMessage) return const hasAttachments = stagedAttachments.length > 0
if (!userMessage && !hasAttachments) return
setMessage('') setMessage('')
const userMessageId = `user-${Date.now()}` const userMessageId = `user-${Date.now()}`
setConversation(prev => [...prev, { const displayAttachments: ChatMessage['attachments'] = hasAttachments
? stagedAttachments.map((attachment) => ({
type: attachment.isImage ? 'image' : 'file',
path: attachment.path,
filename: attachment.filename,
mediaType: attachment.mediaType,
size: attachment.size,
}))
: undefined
setConversation((prev) => [...prev, {
id: userMessageId, id: userMessageId,
role: 'user', role: 'user',
content: userMessage, content: userMessage,
attachments: displayAttachments,
timestamp: Date.now(), timestamp: Date.now(),
}]) }])
@ -1647,42 +1681,107 @@ function App() {
newRunCreatedAt = run.createdAt newRunCreatedAt = run.createdAt
setRunId(currentRunId) setRunId(currentRunId)
// Update active chat tab's runId to the new run // Update active chat tab's runId to the new run
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: currentRunId } : t)) setChatTabs((prev) => prev.map((tab) => (
tab.id === activeChatTabId
? { ...tab, runId: currentRunId }
: tab
)))
isNewRun = true isNewRun = true
} }
// Read mentioned file contents and format message with XML context let titleSource = userMessage
let formattedMessage = userMessage
if (mentions && mentions.length > 0) {
const attachedFiles = await Promise.all(
mentions.map(async (m) => {
try {
const result = await window.ipc.invoke('workspace:readFile', { path: m.path })
return { path: m.path, content: result.data as string }
} catch (err) {
console.error('Failed to read mentioned file:', m.path, err)
return { path: m.path, content: `[Error reading file: ${m.path}]` }
}
})
)
if (attachedFiles.length > 0) { if (hasAttachments) {
const filesXml = attachedFiles type ContentPart =
.map(f => `<file path="${f.path}">\n${f.content}\n</file>`) | { type: 'text'; text: string }
.join('\n') | {
formattedMessage = `<attached-files>\n${filesXml}\n</attached-files>\n\n${userMessage}` type: 'attachment'
attachment: {
type: 'file' | 'image'
path: string
filename: string
mediaType: string
size?: number
}
}
const contentParts: ContentPart[] = []
if (mentions && mentions.length > 0) {
for (const mention of mentions) {
contentParts.push({
type: 'attachment',
attachment: {
type: 'file',
path: mention.path,
filename: mention.displayName || mention.path.split('/').pop() || mention.path,
mediaType: 'text/markdown',
},
})
}
} }
for (const attachment of stagedAttachments) {
contentParts.push({
type: 'attachment',
attachment: {
type: attachment.isImage ? 'image' : 'file',
path: attachment.path,
filename: attachment.filename,
mediaType: attachment.mediaType,
size: attachment.size,
},
})
}
if (userMessage) {
contentParts.push({ type: 'text', text: userMessage })
} else {
titleSource = stagedAttachments[0]?.filename ?? ''
}
// Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema.
const attachmentPayload = contentParts as unknown as string
await window.ipc.invoke('runs:createMessage', {
runId: currentRunId,
message: attachmentPayload,
})
} else {
// Legacy path: plain string with optional XML-formatted @mentions.
let formattedMessage = userMessage
if (mentions && mentions.length > 0) {
const attachedFiles = await Promise.all(
mentions.map(async (mention) => {
try {
const result = await window.ipc.invoke('workspace:readFile', { path: mention.path })
return { path: mention.path, content: result.data as string }
} catch (err) {
console.error('Failed to read mentioned file:', mention.path, err)
return { path: mention.path, content: `[Error reading file: ${mention.path}]` }
}
})
)
if (attachedFiles.length > 0) {
const filesXml = attachedFiles
.map((file) => `<file path="${file.path}">\n${file.content}\n</file>`)
.join('\n')
formattedMessage = `<attached-files>\n${filesXml}\n</attached-files>\n\n${userMessage}`
}
}
await window.ipc.invoke('runs:createMessage', {
runId: currentRunId,
message: formattedMessage,
})
titleSource = formattedMessage
} }
await window.ipc.invoke('runs:createMessage', {
runId: currentRunId,
message: formattedMessage,
})
if (isNewRun) { if (isNewRun) {
const inferredTitle = inferRunTitleFromMessage(formattedMessage) const inferredTitle = inferRunTitleFromMessage(titleSource)
setRuns(prev => { setRuns((prev) => {
const withoutCurrent = prev.filter(run => run.id !== currentRunId) const withoutCurrent = prev.filter((run) => run.id !== currentRunId)
return [{ return [{
id: currentRunId!, id: currentRunId!,
title: inferredTitle, title: inferredTitle,
@ -2849,6 +2948,30 @@ function App() {
const renderConversationItem = (item: ConversationItem, tabId: string) => { const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) { if (isChatMessage(item)) {
if (item.role === 'user') { if (item.role === 'user') {
if (item.attachments && item.attachments.length > 0) {
return (
<Message key={item.id} from={item.role}>
<MessageContent>
<div className="flex flex-wrap gap-1.5 mb-2">
{item.attachments.map((attachment, index) => (
<span
key={index}
className="inline-flex items-center gap-1.5 text-xs bg-muted text-muted-foreground px-2 py-1 rounded-md"
>
{isImageMime(attachment.mediaType) ? (
<span className="size-3 rounded bg-primary/20" />
) : (
<Paperclip className="size-3" />
)}
{attachment.filename}
</span>
))}
</div>
{item.content}
</MessageContent>
</Message>
)
}
const { message, files } = parseAttachedFiles(item.content) const { message, files } = parseAttachedFiles(item.content)
return ( return (
<Message key={item.id} from={item.role}> <Message key={item.id} from={item.role}>

View file

@ -1,7 +1,8 @@
import { useCallback, useEffect } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { ArrowUp, LoaderIcon, Square } from 'lucide-react' import { ArrowUp, LoaderIcon, Paperclip, Plus, Square, X } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
type FileMention, type FileMention,
@ -10,9 +11,22 @@ import {
PromptInputTextarea, PromptInputTextarea,
usePromptInputController, usePromptInputController,
} from '@/components/ai-elements/prompt-input' } from '@/components/ai-elements/prompt-input'
import { toast } from 'sonner'
export type StagedAttachment = {
id: string
path: string
filename: string
mediaType: string
isImage: boolean
size: number
thumbnailUrl?: string
}
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
interface ChatInputInnerProps { interface ChatInputInnerProps {
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
onStop?: () => void onStop?: () => void
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean
@ -38,7 +52,9 @@ function ChatInputInner({
}: ChatInputInnerProps) { }: ChatInputInnerProps) {
const controller = usePromptInputController() const controller = usePromptInputController()
const message = controller.textInput.value const message = controller.textInput.value
const canSubmit = Boolean(message.trim()) && !isProcessing const [attachments, setAttachments] = useState<StagedAttachment[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
// Restore the tab draft when this input mounts. // Restore the tab draft when this input mounts.
useEffect(() => { useEffect(() => {
@ -59,12 +75,47 @@ function ChatInputInner({
} }
}, [presetMessage, controller.textInput, onPresetMessageConsumed]) }, [presetMessage, controller.textInput, onPresetMessageConsumed])
const addFiles = useCallback(async (paths: string[]) => {
const newAttachments: StagedAttachment[] = []
for (const filePath of paths) {
try {
const result = await window.ipc.invoke('shell:readFileBase64', { path: filePath })
if (result.size > MAX_ATTACHMENT_SIZE) {
toast.error(`File too large: ${getFileDisplayName(filePath)} (max 10MB)`)
continue
}
const mime = result.mimeType || getMimeFromExtension(getExtension(filePath))
const image = isImageMime(mime)
newAttachments.push({
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
path: filePath,
filename: getFileDisplayName(filePath),
mediaType: mime,
isImage: image,
size: result.size,
thumbnailUrl: image ? `data:${mime};base64,${result.data}` : undefined,
})
} catch (err) {
console.error('Failed to read file:', filePath, err)
toast.error(`Failed to read: ${getFileDisplayName(filePath)}`)
}
}
if (newAttachments.length > 0) {
setAttachments((prev) => [...prev, ...newAttachments])
}
}, [])
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
}, [])
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
if (!canSubmit) return if (!canSubmit) return
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions) onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments)
controller.textInput.clear() controller.textInput.clear()
controller.mentions.clearMentions() controller.mentions.clearMentions()
}, [canSubmit, message, onSubmit, controller]) setAttachments([])
}, [attachments, canSubmit, controller, message, onSubmit])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@ -88,11 +139,9 @@ function ChatInputInner({
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
const paths = Array.from(e.dataTransfer.files) const paths = Array.from(e.dataTransfer.files)
.map((file) => window.electronUtils?.getPathForFile(file)) .map((file) => window.electronUtils?.getPathForFile(file))
.filter(Boolean) .filter(Boolean) as string[]
if (paths.length > 0) { if (paths.length > 0) {
const currentText = controller.textInput.value void addFiles(paths)
const pathText = paths.join(' ')
controller.textInput.setInput(currentText ? `${currentText} ${pathText}` : pathText)
} }
} }
} }
@ -103,50 +152,101 @@ function ChatInputInner({
document.removeEventListener('dragover', onDragOver) document.removeEventListener('dragover', onDragOver)
document.removeEventListener('drop', onDrop) document.removeEventListener('drop', onDrop)
} }
}, [controller, isActive]) }, [addFiles, isActive])
return ( return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-4 shadow-none"> <div className="rounded-lg border border-border bg-background shadow-none">
<PromptInputTextarea {attachments.length > 0 && (
placeholder="Type your message..." <div className="flex flex-wrap gap-1.5 px-4 pb-1 pt-3">
onKeyDown={handleKeyDown} {attachments.map((attachment) => (
autoFocus={isActive} <span
focusTrigger={isActive ? runId : undefined} key={attachment.id}
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0" className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground"
/> >
{isProcessing ? ( {attachment.isImage && attachment.thumbnailUrl ? (
<Button <img src={attachment.thumbnailUrl} alt="" className="size-4 rounded object-cover" />
size="icon" ) : (
onClick={onStop} <Paperclip className="size-3" />
title={isStopping ? 'Click again to force stop' : 'Stop generation'} )}
className={cn( <span className="max-w-[120px] truncate">{attachment.filename}</span>
'h-7 w-7 shrink-0 rounded-full transition-all', <button
isStopping type="button"
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' onClick={() => removeAttachment(attachment.id)}
: 'bg-primary text-primary-foreground hover:bg-primary/90' className="transition-colors hover:text-foreground"
)} >
> <X className="size-3" />
{isStopping ? ( </button>
<LoaderIcon className="h-4 w-4 animate-spin" /> </span>
) : ( ))}
<Square className="h-3 w-3 fill-current" /> </div>
)}
</Button>
) : (
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
canSubmit
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-muted text-muted-foreground'
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
)} )}
<div className="flex items-center gap-2 px-4 py-4">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
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 = ''
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Attach files"
>
<Plus className="h-4 w-4" />
</button>
<PromptInputTextarea
placeholder="Type your message..."
onKeyDown={handleKeyDown}
autoFocus={isActive}
focusTrigger={isActive ? runId : undefined}
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
/>
{isProcessing ? (
<Button
size="icon"
onClick={onStop}
title={isStopping ? 'Click again to force stop' : 'Stop generation'}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
isStopping
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
)}
>
{isStopping ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<Square className="h-3 w-3 fill-current" />
)}
</Button>
) : (
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
canSubmit
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-muted text-muted-foreground'
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
)}
</div>
</div> </div>
) )
} }
@ -155,7 +255,7 @@ export interface ChatInputWithMentionsProps {
knowledgeFiles: string[] knowledgeFiles: string[]
recentFiles: string[] recentFiles: string[]
visibleFiles: string[] visibleFiles: string[]
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
onStop?: () => void onStop?: () => void
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean

View file

@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Maximize2, Minimize2, SquarePen } from 'lucide-react' import { Maximize2, Minimize2, Paperclip, SquarePen } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { isImageMime } from '@/lib/file-utils'
import { import {
Conversation, Conversation,
ConversationContent, ConversationContent,
@ -25,7 +26,7 @@ import { type PromptInputMessage, type FileMention } from '@/components/ai-eleme
import { FileCardProvider } from '@/contexts/file-card-context' import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
import { TabBar, type ChatTab } from '@/components/tab-bar' 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 { wikiLabel } from '@/lib/wiki-links' import { wikiLabel } from '@/lib/wiki-links'
import { import {
type ChatTabViewState, type ChatTabViewState,
@ -89,7 +90,7 @@ interface ChatSidebarProps {
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean
onStop?: () => void onStop?: () => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
knowledgeFiles?: string[] knowledgeFiles?: string[]
recentFiles?: string[] recentFiles?: string[]
visibleFiles?: string[] visibleFiles?: string[]
@ -256,6 +257,30 @@ export function ChatSidebar({
const renderConversationItem = (item: ConversationItem, tabId: string) => { const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) { if (isChatMessage(item)) {
if (item.role === 'user') { if (item.role === 'user') {
if (item.attachments && item.attachments.length > 0) {
return (
<Message key={item.id} from={item.role}>
<MessageContent>
<div className="mb-2 flex flex-wrap gap-1.5">
{item.attachments.map((attachment, index) => (
<span
key={index}
className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground"
>
{isImageMime(attachment.mediaType) ? (
<span className="size-3 rounded bg-primary/20" />
) : (
<Paperclip className="size-3" />
)}
{attachment.filename}
</span>
))}
</div>
{item.content}
</MessageContent>
</Message>
)
}
const { message, files } = parseAttachedFiles(item.content) const { message, files } = parseAttachedFiles(item.content)
return ( return (
<Message key={item.id} from={item.role}> <Message key={item.id} from={item.role}>

View file

@ -2,10 +2,19 @@ import type { ToolUIPart } from 'ai'
import z from 'zod' import z from 'zod'
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
export interface MessageAttachment {
type: 'file' | 'image'
path: string
filename: string
mediaType: string
size?: number
}
export interface ChatMessage { export interface ChatMessage {
id: string id: string
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
attachments?: MessageAttachment[]
timestamp: number timestamp: number
} }

View file

@ -0,0 +1,43 @@
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<string, string> = {
// 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() : '';
}

View file

@ -357,6 +357,12 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
return await repo.fetch(id); 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<typeof Message>[]): ModelMessage[] { export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
const result: ModelMessage[] = []; const result: ModelMessage[] = [];
for (const msg of messages) { for (const msg of messages) {
@ -400,11 +406,41 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
}); });
break; break;
case "user": case "user":
result.push({ if (typeof msg.content === 'string') {
role: "user", // Legacy string — pass through unchanged
content: msg.content, result.push({
providerOptions, role: "user",
}); content: msg.content,
providerOptions,
});
} else {
// New content parts array — collapse to text for LLM
const textSegments: string[] = [];
// Collect attachments into a header block
const attachmentParts = msg.content.filter((p: { type: string }) => p.type === "attachment");
if (attachmentParts.length > 0) {
textSegments.push("User has attached the following files:");
for (const part of attachmentParts) {
const att = (part as { type: string; attachment: { filename: string; mediaType: string; size?: number; path: string } }).attachment;
const sizeStr = att.size ? `, ${formatBytes(att.size)}` : '';
textSegments.push(`- ${att.filename} (${att.mediaType}${sizeStr}) at ${att.path}`);
}
textSegments.push(""); // blank line separator
}
// Collect text parts
const textParts = msg.content.filter((p: { type: string }) => p.type === "text");
for (const part of textParts) {
textSegments.push((part as { type: string; text: string }).text);
}
result.push({
role: "user",
content: textSegments.join("\n"),
providerOptions,
});
}
break; break;
case "tool": case "tool":
result.push({ result.push({

View file

@ -1,12 +1,16 @@
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js"; import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
import { UserMessageContent } from "@x/shared/dist/message.js";
import z from "zod";
export type UserMessageContentType = z.infer<typeof UserMessageContent>;
type EnqueuedMessage = { type EnqueuedMessage = {
messageId: string; messageId: string;
message: string; message: UserMessageContentType;
}; };
export interface IMessageQueue { export interface IMessageQueue {
enqueue(runId: string, message: string): Promise<string>; enqueue(runId: string, message: UserMessageContentType): Promise<string>;
dequeue(runId: string): Promise<EnqueuedMessage | null>; dequeue(runId: string): Promise<EnqueuedMessage | null>;
} }
@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator; this.idGenerator = idGenerator;
} }
async enqueue(runId: string, message: string): Promise<string> { async enqueue(runId: string, message: UserMessageContentType): Promise<string> {
if (!this.store[runId]) { if (!this.store[runId]) {
this.store[runId] = []; this.store[runId] = [];
} }

View file

@ -46,10 +46,18 @@ export class FSRunsRepo implements IRunsRepo {
const messageEvent = event as z.infer<typeof MessageEvent>; const messageEvent = event as z.infer<typeof MessageEvent>;
if (messageEvent.message.role === 'user') { if (messageEvent.message.role === 'user') {
const content = messageEvent.message.content; const content = messageEvent.message.content;
if (typeof content === 'string' && content.trim()) { let textContent: string | undefined;
// Clean attached-files XML and @mentions, then truncate to 100 chars if (typeof content === 'string') {
const cleaned = cleanContentForTitle(content); textContent = content;
if (!cleaned) continue; // Skip if only attached files/mentions } else if (Array.isArray(content)) {
textContent = content
.filter((p: { type: string }) => p.type === 'text')
.map((p: { type: string; text?: string }) => p.text || '')
.join('');
}
if (textContent && textContent.trim()) {
const cleaned = cleanContentForTitle(textContent);
if (!cleaned) continue;
return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned; return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
} }
} }
@ -90,9 +98,17 @@ export class FSRunsRepo implements IRunsRepo {
if (msg.role === 'user') { if (msg.role === 'user') {
// Found first user message - use as title // Found first user message - use as title
const content = msg.content; const content = msg.content;
if (typeof content === 'string' && content.trim()) { let textContent: string | undefined;
// Clean attached-files XML and @mentions, then truncate if (typeof content === 'string') {
const cleaned = cleanContentForTitle(content); textContent = content;
} else if (Array.isArray(content)) {
textContent = content
.filter((p: { type: string }) => p.type === 'text')
.map((p: { type: string; text?: string }) => p.text || '')
.join('');
}
if (textContent && textContent.trim()) {
const cleaned = cleanContentForTitle(textContent);
if (cleaned) { if (cleaned) {
title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned; title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
} }
@ -241,5 +257,13 @@ export class FSRunsRepo implements IRunsRepo {
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
const filePath = path.join(WorkDir, 'runs', `${id}.jsonl`); const filePath = path.join(WorkDir, 'runs', `${id}.jsonl`);
await fsp.unlink(filePath); await fsp.unlink(filePath);
// Clean up attachment sidecar directory if it exists
const attachmentsDir = path.join(WorkDir, 'runs', 'attachments', id);
try {
await fsp.rm(attachmentsDir, { recursive: true });
} catch (err: unknown) {
const e = err as { code?: string };
if (e.code !== 'ENOENT') throw err;
}
} }
} }

View file

@ -1,6 +1,6 @@
import z from "zod"; import z from "zod";
import container from "../di/container.js"; 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 { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
import { IRunsRepo } from "./repo.js"; import { IRunsRepo } from "./repo.js";
import { IAgentRuntime } from "../agents/runtime.js"; import { IAgentRuntime } from "../agents/runtime.js";
@ -19,7 +19,7 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
return run; return run;
} }
export async function createMessage(runId: string, message: string): Promise<string> { export async function createMessage(runId: string, message: UserMessageContentType): Promise<string> {
const queue = container.resolve<IMessageQueue>('messageQueue'); const queue = container.resolve<IMessageQueue>('messageQueue');
const id = await queue.enqueue(runId, message); const id = await queue.enqueue(runId, message);
const runtime = container.resolve<IAgentRuntime>('agentRuntime'); const runtime = container.resolve<IAgentRuntime>('agentRuntime');

View file

@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js'; import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js'; import { ServiceEvent } from './service-events.js';
import { UserMessageContent } from './message.js';
// ============================================================================ // ============================================================================
// Runtime Validation Schemas (Single Source of Truth) // Runtime Validation Schemas (Single Source of Truth)
@ -128,7 +129,7 @@ const ipcSchemas = {
'runs:createMessage': { 'runs:createMessage': {
req: z.object({ req: z.object({
runId: z.string(), runId: z.string(),
message: z.string(), message: UserMessageContent,
}), }),
res: z.object({ res: z.object({
messageId: z.string(), messageId: z.string(),

View file

@ -28,9 +28,36 @@ export const AssistantContentPart = z.union([
ToolCallPart, ToolCallPart,
]); ]);
// Metadata about an attached file or image
export const Attachment = z.object({
type: z.enum(["file", "image"]), // extensible — could add "url", "audio" later
path: z.string(), // absolute file path
filename: z.string(), // display name ("photo.png")
mediaType: z.string(), // MIME type ("image/png", "text/plain")
size: z.number().optional(), // bytes
});
// 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"),
attachment: Attachment,
});
// 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({ export const UserMessage = z.object({
role: z.literal("user"), role: z.literal("user"),
content: z.string(), content: UserMessageContent,
providerOptions: ProviderOptions.optional(), providerOptions: ProviderOptions.optional(),
}); });