From 5d78d66b00d9df9af23cd4121c5bd5d00caa5e60 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Mon, 23 Feb 2026 12:01:03 +0530 Subject: [PATCH] Refactor chat message attachment handling and improve UI for attachments in chat input and sidebar --- apps/x/apps/renderer/src/App.tsx | 27 +--- .../components/chat-input-with-mentions.tsx | 103 ++++++++++--- .../components/chat-message-attachments.tsx | 137 ++++++++++++++++++ .../renderer/src/components/chat-sidebar.tsx | 26 +--- .../src/lib/attachment-presentation.ts | 107 ++++++++++++++ .../renderer/src/lib/chat-conversation.ts | 1 + apps/x/apps/renderer/src/lib/file-utils.ts | 18 +++ 7 files changed, 359 insertions(+), 60 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/chat-message-attachments.tsx create mode 100644 apps/x/apps/renderer/src/lib/attachment-presentation.ts diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 1eda1b44..63a0d3b4 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,12 +5,12 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, Paperclip, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react'; -import { isImageMime } from '@/lib/file-utils' +import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; +import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; @@ -1659,6 +1659,7 @@ function App() { filename: attachment.filename, mediaType: attachment.mediaType, size: attachment.size, + thumbnailUrl: attachment.thumbnailUrl, })) : undefined setConversation((prev) => [...prev, { @@ -2951,24 +2952,12 @@ function App() { 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/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 4dfb174e..7ab65a45 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -1,7 +1,27 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { ArrowUp, LoaderIcon, Paperclip, Plus, Square, X } from 'lucide-react' +import { + ArrowUp, + AudioLines, + FileArchive, + FileCode2, + FileIcon, + FileSpreadsheet, + FileText, + FileVideo, + LoaderIcon, + Plus, + Square, + X, +} from 'lucide-react' import { Button } from '@/components/ui/button' +import { + type AttachmentIconKind, + getAttachmentDisplayName, + getAttachmentIconKind, + getAttachmentToneClass, + getAttachmentTypeLabel, +} from '@/lib/attachment-presentation' import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils' import { cn } from '@/lib/utils' import { @@ -25,6 +45,25 @@ export type StagedAttachment = { const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB +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 + } +} + interface ChatInputInnerProps { onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void onStop?: () => void @@ -53,6 +92,7 @@ function ChatInputInner({ const controller = usePromptInputController() const message = controller.textInput.value const [attachments, setAttachments] = useState([]) + const [focusNonce, setFocusNonce] = useState(0) const fileInputRef = useRef(null) const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing @@ -102,6 +142,7 @@ function ChatInputInner({ } if (newAttachments.length > 0) { setAttachments((prev) => [...prev, ...newAttachments]) + setFocusNonce((value) => value + 1) } }, []) @@ -157,27 +198,45 @@ function ChatInputInner({ return (
{attachments.length > 0 && ( -
- {attachments.map((attachment) => ( - - {attachment.isImage && attachment.thumbnailUrl ? ( - - ) : ( - - )} - {attachment.filename} - - - ))} + + {attachment.isImage && attachment.thumbnailUrl ? ( + + ) : ( + + )} + + + {attachmentName} + {attachmentType} + + + + ) + })}
)}
@@ -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 ( + Image attachment { + 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}`; +}