import React, { useRef, memo } from 'react'; import { useInputStore } from '@/sync/input-store'; import type { AttachedFile } from '@/sync/session-ui-store'; import { useUIStore } from '@/stores/useUIStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { toast } from '@/components/ui'; import { cn } from '@/lib/utils'; import { openExternalUrl } from '@/lib/url'; import { isDrawioFile } from '@/lib/toolHelpers'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; import { useDeviceInfo } from '@/lib/device'; import type { ToolPopupContent } from './message/types'; const FileAttachmentButton = memo(() => { const { t } = useI18n(); const fileInputRef = useRef(null); const addAttachedFile = useInputStore((state) => state.addAttachedFile); const isMobile = useUIStore((state) => state.isMobile); const runtimeApis = useRuntimeAPIs(); const isVSCodeRuntime = runtimeApis.runtime.isVSCode; const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7'; const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]'; const attachFiles = async (files: FileList | File[]) => { for (let i = 0; i < files.length; i++) { const file = files[i]; try { await addAttachedFile(file); } catch (error) { console.error('File attach failed', error); toast.error(error instanceof Error ? error.message : t('chat.fileAttachment.toast.attachFailed')); } } }; const handleFileSelect = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files) return; await attachFiles(files); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; const handleVSCodePick = async () => { try { const data = (await runtimeApis.vscode?.pickFiles?.()) as { files?: Array<{ name: string; mimeType?: string; dataUrl?: string }>; skipped?: Array<{ name?: string; reason?: string }>; } | undefined; const picked = Array.isArray(data?.files) ? data.files : []; const skipped = Array.isArray(data?.skipped) ? data.skipped : []; if (skipped.length > 0) { const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || t('chat.fileAttachment.fileFallback')}: ${s?.reason || t('chat.fileAttachment.skippedFallback')}`).join('\n'); toast.error(t('chat.fileAttachment.toast.someFilesSkipped', { summary })); } const asFiles = picked .map((file: { name: string; mimeType?: string; dataUrl?: string }) => { if (!file?.dataUrl) return null; try { const [meta, base64] = file.dataUrl.split(','); const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream'); if (!base64) return null; const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } const blob = new Blob([bytes], { type: mime }); return new File([blob], file.name || t('chat.fileAttachment.fileFallback'), { type: mime }); } catch (err) { console.error('Failed to decode VS Code picked file', err); return null; } }) .filter(Boolean) as File[]; if (asFiles.length > 0) { await attachFiles(asFiles); } } catch (error) { console.error('VS Code file pick failed', error); toast.error(error instanceof Error ? error.message : t('chat.fileAttachment.toast.vscodePickFailed')); } }; return ( <>

{t('chat.fileAttachment.actions.attach')}

); }); FileAttachmentButton.displayName = 'FileAttachmentButton'; interface ImagePreviewProps { file: AttachedFile; onRemove: () => void; onShowPopup?: (content: ToolPopupContent) => void; gallery?: NonNullable['gallery']; index?: number; } const ImagePreview = memo(({ file, onRemove, onShowPopup, gallery, index = 0 }: ImagePreviewProps) => { const { t } = useI18n(); const { isMobile, isTablet } = useDeviceInfo(); const alwaysShowActions = isMobile || isTablet; const isLocalImagePreview = file.source !== 'server' && file.mimeType.startsWith('image/') && typeof file.dataUrl === 'string' && file.dataUrl.startsWith('data:image/'); const imageUrl = isLocalImagePreview ? file.dataUrl : (file.serverPath || ''); const extractFilename = (path: string): string => { const normalized = path.replace(/\\/g, '/'); const parts = normalized.split('/'); return parts[parts.length - 1] || path; }; const getFileExtension = (filename: string): string => { const parts = filename.split('.'); return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''; }; const displayName = extractFilename(file.filename); const extension = getFileExtension(file.filename); const handleOpenPreview = React.useCallback(() => { if (!onShowPopup || !imageUrl) return; onShowPopup({ open: true, title: displayName || 'Image', content: '', metadata: { tool: 'image-preview', filename: displayName, mime: file.mimeType, size: file.size, }, image: { url: imageUrl, mimeType: file.mimeType, filename: displayName, size: file.size, gallery, index, }, }); }, [displayName, file.mimeType, file.size, gallery, imageUrl, index, onShowPopup]); if (!imageUrl) { // Fallback to text-only for server images without preview return ( ); } return (
{ if (!onShowPopup) return; if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); handleOpenPreview(); } }} className="relative h-10 w-10 rounded-lg border border-border/40 bg-muted/10 overflow-hidden flex-shrink-0 group cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" aria-label={displayName} > {displayName}
); }); ImagePreview.displayName = 'ImagePreview'; const useFileDetails = (file: AttachedFile) => { const getFileExtension = (filename: string): string => { const parts = filename.split('.'); return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''; }; const formatFileSize = (bytes: number) => { if (!Number.isFinite(bytes) || bytes <= 0) return ''; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; }; const extractFilename = (path: string): string => { const normalized = path.replace(/\\/g, '/'); const parts = normalized.split('/'); const filename = parts[parts.length - 1]; return filename || path; }; return { displayName: extractFilename(file.filename), fileSize: formatFileSize(file.size), extension: getFileExtension(file.filename), }; }; interface FileChipProps { file: AttachedFile; onRemove: () => void; } const FileChip = memo(({ file, onRemove }: FileChipProps) => { const { t } = useI18n(); const { displayName, fileSize, extension } = useFileDetails(file); return ( ); }); FileChip.displayName = 'FileChip'; const VSCodeFileChip = memo(({ file, onRemove }: FileChipProps) => { const { t } = useI18n(); const { displayName, extension } = useFileDetails(file); // Detect selection-style attachments: ends with ":N" or ":N-M" const isSelectionAttachment = /:\d+(?:-\d+)?$/.test(displayName); return ( ); }); VSCodeFileChip.displayName = 'VSCodeFileChip'; interface AttachedFilesListProps { onShowPopup?: (content: ToolPopupContent) => void; } export const AttachedVSCodeFileChips = memo(({ onShowPopup }: AttachedFilesListProps) => { const attachedFiles = useInputStore((state) => state.attachedFiles); const removeAttachedFile = useInputStore((state) => state.removeAttachedFile); const vscodeFiles = attachedFiles.filter((file) => file.source === 'vscode'); if (vscodeFiles.length === 0) return null; const images = vscodeFiles.filter((f) => f.mimeType.startsWith('image/')); const otherFiles = vscodeFiles.filter((f) => !f.mimeType.startsWith('image/')); const imageGallery = images.map((file) => ({ url: file.dataUrl || file.serverPath || '', mimeType: file.mimeType, filename: file.filename, size: file.size, })).filter((image) => image.url); return (
{images.map((file, index) => ( removeAttachedFile(file.id)} onShowPopup={onShowPopup} gallery={imageGallery} index={index} /> ))} {otherFiles.map((file) => ( removeAttachedFile(file.id)} /> ))}
); }); AttachedVSCodeFileChips.displayName = 'AttachedVSCodeFileChips'; export const AttachedFilesList = memo(({ onShowPopup }: AttachedFilesListProps) => { const attachedFiles = useInputStore((state) => state.attachedFiles); const removeAttachedFile = useInputStore((state) => state.removeAttachedFile); const localFiles = attachedFiles.filter((file) => file.source !== 'server' && file.source !== 'vscode'); if (localFiles.length === 0) return null; const images = localFiles.filter((f) => f.mimeType.startsWith('image/')); const otherFiles = localFiles.filter((f) => !f.mimeType.startsWith('image/')); const imageGallery = images.map((file) => ({ url: file.dataUrl || file.serverPath || '', mimeType: file.mimeType, filename: file.filename, size: file.size, })).filter((image) => image.url); return (
{/* Images row - inline with previews */} {images.length > 0 && (
{images.map((file, index) => ( removeAttachedFile(file.id)} onShowPopup={onShowPopup} gallery={imageGallery} index={index} /> ))}
)} {/* Other files row - inline text-only */} {otherFiles.length > 0 && (
{otherFiles.map((file) => ( removeAttachedFile(file.id)} /> ))}
)}
); }); AttachedFilesList.displayName = 'AttachedFilesList'; export const ActiveEditorFileSuggestion = memo(() => { const { t } = useI18n(); const activeEditorFile = useInputStore((s) => s.activeEditorFile); const attachedFiles = useInputStore((s) => s.attachedFiles) const addVSCodeFileAttachment = useInputStore((s) => s.addVSCodeFileAttachment) const addVSCodeSelectionAttachment = useInputStore((s) => s.addVSCodeSelectionAttachment) const isVSCodeRuntime = useRuntimeAPIs().runtime.isVSCode; if (!isVSCodeRuntime || !activeEditorFile) return null; const { filePath, fileName, relativePath, selection, fileSize } = activeEditorFile; // Normalize to forward slashes for comparison const isFileAttached = attachedFiles.some( (f) => f.source === 'vscode' && f.vscodeSource === 'file' && (f.vscodePath || '') === filePath ) // Compute selection label using a compact range (single line shown as "N" not "N-N") let selectionRange = '' if (selection) { selectionRange = selection.startLine === selection.endLine ? `${selection.startLine}` : `${selection.startLine}-${selection.endLine}` } const selectionLabel = selection ? `${relativePath}:${selectionRange}` : '' const isSelectionAttached = !!selectionLabel && attachedFiles.some( (f) => f.source === 'vscode' && f.vscodeSource === 'selection' && f.filename === selectionLabel && f.vscodePath === filePath ) // Nothing to show — file is already attached and there's no (or already-attached) selection if (isFileAttached && (!selection || isSelectionAttached)) return null; const ext = fileName.split('.').pop() || ''; // Always show only the filename in the suggestion UI const displayName = fileName; const handleAddFile = () => { addVSCodeFileAttachment(filePath, fileName, fileSize); }; const handlePinSelection = async () => { if (!selection) return; const blob = new Blob([selection.text], { type: 'text/plain' }); const file = new File([blob], selectionLabel, { type: 'text/plain' }); await addVSCodeSelectionAttachment(filePath, file); }; // If there is a selection, prefer showing the pin-selection UI only. const showSelectionPin = !!selection && !isSelectionAttached; const showFileAdd = !showSelectionPin && !isFileAttached; if (!showSelectionPin && !showFileAdd) return null; return (
{showSelectionPin && (
{`${displayName}:${selectionRange}`}
)} {showFileAdd && (
{displayName}
)}
); }); ActiveEditorFileSuggestion.displayName = 'ActiveEditorFileSuggestion'; interface FilePart { type: string; mime?: string; url?: string; filename?: string; size?: number; source?: Record; } const FORGE_LINK_MIMES = new Set([ 'application/vnd.github.issue-link', 'application/vnd.github.pull-request-link', 'application/vnd.gitlab.issue-link', 'application/vnd.gitlab.merge-request-link', 'application/vnd.gitea.issue-link', 'application/vnd.gitea.pull-request-link', ]); const ISSUE_LINK_MIMES = new Set([ 'application/vnd.github.issue-link', 'application/vnd.gitlab.issue-link', 'application/vnd.gitea.issue-link', ]); const PR_LINK_MIMES = new Set([ 'application/vnd.github.pull-request-link', 'application/vnd.gitlab.merge-request-link', 'application/vnd.gitea.pull-request-link', ]); const LINEAR_ISSUE_LINK_MIME = 'application/vnd.openchamber.linear-issue-link'; type ForgeLinkInfo = { kind: 'issue' | 'pr'; provider: 'github' | 'gitlab' | 'gitea' } | null; const getForgeLinkInfo = (file: FilePart): ForgeLinkInfo => { const mime = file.mime; if (!mime || !FORGE_LINK_MIMES.has(mime)) return null; if (ISSUE_LINK_MIMES.has(mime)) { if (mime.includes('gitlab')) return { kind: 'issue', provider: 'gitlab' }; if (mime.includes('gitea')) return { kind: 'issue', provider: 'gitea' }; return { kind: 'issue', provider: 'github' }; } if (PR_LINK_MIMES.has(mime)) { if (mime.includes('gitlab')) return { kind: 'pr', provider: 'gitlab' }; if (mime.includes('gitea')) return { kind: 'pr', provider: 'gitea' }; return { kind: 'pr', provider: 'github' }; } return null; }; const isLinearLink = (file: FilePart): boolean => file.mime === LINEAR_ISSUE_LINK_MIME; type LinkInfo = ForgeLinkInfo | { kind: 'linear-issue' } | null; const getLinkInfo = (file: FilePart): LinkInfo => { const forge = getForgeLinkInfo(file); if (forge) return forge; if (isLinearLink(file)) return { kind: 'linear-issue' }; return null; }; const linkIconName = (info: LinkInfo): 'github' | 'gitlab' | 'git-branch' | 'git-pull-request' | 'linear' => { if (!info) return 'github'; if (info.kind === 'pr') return 'git-pull-request'; if (info.kind === 'linear-issue') return 'linear'; if (info.provider === 'gitlab') return 'gitlab'; if (info.provider === 'gitea') return 'git-branch'; return 'github'; }; interface MessageFilesDisplayProps { files: FilePart[]; onShowPopup?: (content: ToolPopupContent) => void; compact?: boolean; } export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }: MessageFilesDisplayProps) => { const { t } = useI18n(); const fileItems = files.filter(f => f.type === 'file' && (f.mime || f.url)); const extractFilename = (path?: string): string => { if (!path) return 'Unnamed file'; const normalized = path.replace(/\\/g, '/'); const parts = normalized.split('/'); const filename = parts[parts.length - 1]; return filename || path; }; const resolveDisplayName = React.useCallback((file: FilePart): string => { const isLink = getLinkInfo(file) !== null; if (isLink && typeof file.filename === 'string' && file.filename.trim().length > 0) { return file.filename.trim(); } return extractFilename(file.filename || file.url); }, []); const formatFileSize = (bytes?: number) => { if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return ''; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; const imageFiles = fileItems.filter(f => f.mime?.startsWith('image/') && f.url); const otherFiles = fileItems.filter(f => !f.mime?.startsWith('image/')); const imageGallery = React.useMemo( () => imageFiles.flatMap((file) => { if (!file.url) return []; const filename = resolveDisplayName(file) || 'Image'; return [{ url: file.url, mimeType: file.mime, filename, size: file.size, }]; }), [imageFiles, resolveDisplayName] ); const handleImageClick = React.useCallback((index: number) => { if (!onShowPopup) { return; } const file = imageGallery[index]; if (!file?.url) return; const filename = file.filename || 'Image'; onShowPopup({ open: true, title: filename, content: '', metadata: { tool: 'image-preview', filename, mime: file.mimeType, size: file.size, }, image: { url: file.url, mimeType: file.mimeType, filename, size: file.size, gallery: imageGallery, index, }, }); }, [imageGallery, onShowPopup]); if (fileItems.length === 0) return null; if (compact) { return (
{otherFiles.length > 0 && (
{otherFiles.map((file, index) => { const fileName = resolveDisplayName(file); const ext = fileName.split('.').pop() || ''; const sizeText = formatFileSize(file.size); const linkInfo = getLinkInfo(file); return ( {linkInfo && file.url ? ( ) : (
{file.mime?.includes('pdf') ? ( ) : ( )}
{fileName}
)}

{fileName}{sizeText ? ` (${sizeText})` : ''}

); })}
)} {imageFiles.length > 0 && (
{imageFiles.map((file, index) => { const filename = resolveDisplayName(file) || 'Image'; return ( {filename} ); })}
)}
); } return (
{fileItems.map((file, index) => { const fileName = resolveDisplayName(file); const isImage = file.mime?.startsWith('image/'); const sizeText = formatFileSize(file.size); const linkInfo = getLinkInfo(file); if (isImage && file.url) { return (
{fileName}

{fileName}

{sizeText &&

{sizeText}

}
); } if (linkInfo && file.url) { return (

{fileName}{sizeText ? ` (${sizeText})` : ''}

); } const source = file.source; const sourceType = typeof source?.type === 'string' ? source.type : undefined; const sourcePath = source && typeof (source as Record).path === 'string' ? (source as Record).path as string : undefined; const filePath = sourceType === 'file' && sourcePath ? sourcePath : (file.url || ''); const isDrawio = filePath && isDrawioFile(filePath); if (isDrawio) { return (

{t('chat.fileAttachment.openInDiagram')}

); } return (

{fileName}{sizeText ? ` (${sizeText})` : ''}

); })}
); }); MessageFilesDisplay.displayName = 'MessageFilesDisplay'; interface ImageGalleryProps { urls: string[]; caption?: string; onShowPopup?: (content: ToolPopupContent) => void; } const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryProps) => { if (urls.length === 0) return null; const getGridCols = () => { if (urls.length === 1) return 'grid-cols-1'; if (urls.length === 2) return 'grid-cols-2'; if (urls.length <= 4) return 'grid-cols-2'; return 'grid-cols-3'; }; return (
{urls.map((url, index) => ( ))}
{caption && (

{caption}

)}
); }); ImageGallery.displayName = 'ImageGallery';