@@ -210,7 +269,7 @@ function ChatInputInner({
placeholder="Type your message..."
onKeyDown={handleKeyDown}
autoFocus={isActive}
- focusTrigger={isActive ? runId : undefined}
+ focusTrigger={isActive ? `${runId ?? 'new'}:${focusNonce}` : undefined}
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
/>
{isProcessing ? (
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..40747045
--- /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.mediaType || 'image/*'
+ setSrc(`data:${mimeType};base64,${result.data}`)
+ } catch {
+ // Keep current src; fallback rendering (broken image icon) is better than crashing.
+ }
+ },
+ [attachment.mediaType, 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.mediaType))
+ const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mediaType))
+
+ 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 a6216deb..f020cdae 100644
--- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx
+++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
@@ -1,10 +1,9 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { Maximize2, Minimize2, Paperclip, SquarePen } from 'lucide-react'
+import { Maximize2, Minimize2, SquarePen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
-import { isImageMime } from '@/lib/file-utils'
import {
Conversation,
ConversationContent,
@@ -27,6 +26,7 @@ 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, type StagedAttachment } from '@/components/chat-input-with-mentions'
+import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { wikiLabel } from '@/lib/wiki-links'
import {
type ChatTabViewState,
@@ -260,24 +260,12 @@ export function ChatSidebar({
if (item.attachments && item.attachments.length > 0) {
return (
-
-
- {item.attachments.map((attachment, index) => (
-
- {isImageMime(attachment.mediaType) ? (
-
- ) : (
-
- )}
- {attachment.filename}
-
- ))}
-
- {item.content}
+
+
+ {item.content && (
+ {item.content}
+ )}
)
}
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..7948cf1a
--- /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
+ mediaType: 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.mediaType.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.mediaType.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 c9623cd6..5d52fcd7 100644
--- a/apps/x/apps/renderer/src/lib/chat-conversation.ts
+++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts
@@ -8,6 +8,7 @@ export interface MessageAttachment {
filename: string
mediaType: string
size?: number
+ thumbnailUrl?: string
}
export interface ChatMessage {
diff --git a/apps/x/apps/renderer/src/lib/file-utils.ts b/apps/x/apps/renderer/src/lib/file-utils.ts
index bbebbb02..3ac3431a 100644
--- a/apps/x/apps/renderer/src/lib/file-utils.ts
+++ b/apps/x/apps/renderer/src/lib/file-utils.ts
@@ -41,3 +41,21 @@ export function getExtension(filePath: string): string {
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}`;
+}