Files
openchamber/packages/ui/src/components/chat/FileAttachment.tsx
T

1009 lines
37 KiB
TypeScript

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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={isVSCodeRuntime ? handleVSCodePick : () => fileInputRef.current?.click()}
className={cn(
'flex items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
'hover:bg-muted text-muted-foreground',
buttonSizeClass
)}
aria-label={t('chat.fileAttachment.actions.attachAria')}
>
<Icon name="attachment-2" className={iconSizeClass} />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{t('chat.fileAttachment.actions.attach')}</p>
</TooltipContent>
</Tooltip>
</>
);
});
FileAttachmentButton.displayName = 'FileAttachmentButton';
interface ImagePreviewProps {
file: AttachedFile;
onRemove: () => void;
onShowPopup?: (content: ToolPopupContent) => void;
gallery?: NonNullable<ToolPopupContent['image']>['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 (
<button
type="button"
className="flex items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5"
>
<FileTypeIcon filePath={file.filename} extension={extension} className="h-4 w-4" />
<span className="text-foreground truncate max-w-[200px]">
{displayName}
</span>
<span
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
>
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
</span>
</button>
);
}
return (
<div
role={onShowPopup ? 'button' : undefined}
tabIndex={onShowPopup ? 0 : undefined}
onClick={handleOpenPreview}
onKeyDown={(event) => {
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}
>
<img
src={imageUrl}
alt={displayName}
className="h-full w-full object-cover"
loading="lazy"
/>
<button
onClick={(event) => {
event.stopPropagation();
onRemove();
}}
className={cn(
"absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-background/80 text-foreground hover:text-destructive flex items-center justify-center transition-opacity focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
alwaysShowActions ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
title={t('chat.fileAttachment.actions.removeImage')}
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
>
<Icon name="close" className="h-2.5 w-2.5" />
</button>
</div>
);
});
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 (
<button
type="button"
onClick={(e) => {
// Prevent click from bubbling if clicking the remove button
if ((e.target as HTMLElement).closest('[data-remove-button]')) {
return;
}
}}
className="flex items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5"
>
<FileTypeIcon filePath={file.filename} extension={extension} className="h-4 w-4" />
<span className="text-foreground truncate max-w-[200px]">
{displayName}
{fileSize && <span className="text-muted-foreground ml-1">({fileSize})</span>}
</span>
<span
data-remove-button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
>
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
</span>
</button>
);
});
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 (
<button
type="button"
onClick={(e) => {
// Prevent click from bubbling if clicking the remove button
if ((e.target as HTMLElement).closest('[data-remove-button]')) {
return;
}
}}
className="inline-flex items-center gap-1 text-xs pr-1 rounded-sm border border-solid bg-transparent text-foreground not-italic hover:opacity-90 transition-colors text-left"
style={{ borderColor: 'var(--syntax-punctuation)' }}
title={file.vscodePath}
>
<span
data-remove-button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label={t('chat.fileAttachment.activeEditor.remove')}
title={t('chat.fileAttachment.activeEditor.remove')}
>
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
</span>
<FileTypeIcon filePath={file.filename} extension={extension} className="h-4 w-4" />
<span className={cn('text-foreground', isSelectionAttachment ? 'whitespace-nowrap' : 'truncate max-w-[200px]')}>
{displayName}
</span>
</button>
);
});
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 (
<div className="flex items-center gap-2 flex-wrap">
{images.map((file, index) => (
<ImagePreview key={file.id} file={file} onRemove={() => removeAttachedFile(file.id)} onShowPopup={onShowPopup} gallery={imageGallery} index={index} />
))}
{otherFiles.map((file) => (
<VSCodeFileChip key={file.id} file={file} onRemove={() => removeAttachedFile(file.id)} />
))}
</div>
);
});
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 (
<div className="pb-4 w-full px-1 space-y-3">
{/* Images row - inline with previews */}
{images.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{images.map((file, index) => (
<ImagePreview
key={file.id}
file={file}
onRemove={() => removeAttachedFile(file.id)}
onShowPopup={onShowPopup}
gallery={imageGallery}
index={index}
/>
))}
</div>
)}
{/* Other files row - inline text-only */}
{otherFiles.length > 0 && (
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap">
{otherFiles.map((file) => (
<FileChip
key={file.id}
file={file}
onRemove={() => removeAttachedFile(file.id)}
/>
))}
</div>
)}
</div>
);
});
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 (
<div className="inline-flex items-center">
{showSelectionPin && (
<div
className="inline-flex items-center gap-1 text-xs pr-1 rounded-sm italic text-muted-foreground border border-dashed bg-transparent"
style={{ borderColor: 'var(--syntax-punctuation)' }}
title={relativePath}
>
<button
type="button"
title={t('chat.fileAttachment.activeEditor.pinSelection')}
aria-label={t('chat.fileAttachment.activeEditor.pinSelection')}
onClick={() => { void handlePinSelection(); }}
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
>
<Icon name="pushpin-2" className="h-4 w-4" />
</button>
<FileTypeIcon filePath={fileName} extension={ext} className="h-4 w-4 flex-shrink-0" />
<span className="text-xs whitespace-nowrap">{`${displayName}:${selectionRange}`}</span>
</div>
)}
{showFileAdd && (
<div
className="inline-flex items-center gap-1 text-xs pr-1 rounded-sm italic text-muted-foreground border border-dashed bg-transparent"
style={{ borderColor: 'var(--syntax-punctuation)' }}
title={relativePath}
>
<button
type="button"
title={t('chat.fileAttachment.activeEditor.addFile', { name: displayName })}
aria-label={t('chat.fileAttachment.activeEditor.addFile', { name: displayName })}
onClick={handleAddFile}
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
>
<Icon name="add" className="h-4 w-4" />
</button>
<FileTypeIcon filePath={fileName} extension={ext} className="h-4 w-4 flex-shrink-0" />
<span className="text-xs truncate max-w-[220px]">{displayName}</span>
</div>
)}
</div>
);
});
ActiveEditorFileSuggestion.displayName = 'ActiveEditorFileSuggestion';
interface FilePart {
type: string;
mime?: string;
url?: string;
filename?: string;
size?: number;
source?: Record<string, unknown>;
}
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 (
<div className="space-y-1.5 mt-1.5">
{otherFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{otherFiles.map((file, index) => {
const fileName = resolveDisplayName(file);
const ext = fileName.split('.').pop() || '';
const sizeText = formatFileSize(file.size);
const linkInfo = getLinkInfo(file);
return (
<Tooltip key={`file-${file.url || file.filename || index}`}>
<TooltipTrigger asChild>
{linkInfo && file.url ? (
<button
type="button"
onClick={() => {
void openExternalUrl(file.url || '');
}}
className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg text-foreground hover:text-primary transition-colors"
>
<Icon name={linkIconName(linkInfo)} className="text-muted-foreground h-3.5 w-3.5" />
<div className="overflow-hidden max-w-[220px]">
<span className="truncate block" title={fileName}>{fileName}</span>
</div>
</button>
) : (
<div className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg">
{file.mime?.includes('pdf') ? (
<Icon name="file-pdf" className="text-muted-foreground h-3.5 w-3.5" />
) : (
<FileTypeIcon filePath={fileName} extension={ext} className="text-muted-foreground h-3.5 w-3.5" />
)}
<div className="overflow-hidden max-w-[140px]">
<span className="truncate block" title={fileName}>{fileName}</span>
</div>
</div>
)}
</TooltipTrigger>
<TooltipContent>
<p>{fileName}{sizeText ? ` (${sizeText})` : ''}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
)}
{imageFiles.length > 0 && (
<div className="overflow-x-auto -mx-1 px-1 py-0.5 scrollbar-thin">
<div className="flex snap-x snap-mandatory gap-2">
{imageFiles.map((file, index) => {
const filename = resolveDisplayName(file) || 'Image';
return (
<Tooltip key={`img-${file.url || file.filename || index}`}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleImageClick(index)}
className="relative flex-none border border-border/40 bg-muted/10 overflow-hidden snap-start h-12 w-12 sm:h-14 sm:w-14 md:h-16 md:w-16 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary"
aria-label={filename}
>
{file.url ? (
<img
src={file.url}
alt={filename}
className="h-full w-full object-cover"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.visibility = 'hidden';
}}
/>
) : (
<div className="h-full w-full flex items-center justify-center bg-muted/30 text-muted-foreground">
<Icon name="file-image" className="h-6 w-6" />
</div>
)}
<span className="sr-only">{filename}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="typography-meta px-2 py-1">
{filename}
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
)}
</div>
);
}
return (
<div className={cn(
"grid gap-2",
compact ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2"
)}>
{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 (
<div
key={file.url || `${fileName}-${index}`}
className="relative aspect-video rounded-lg border border-border/40 bg-muted/10 overflow-hidden group"
>
<img
src={file.url}
alt={fileName}
className="h-full w-full object-cover"
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="absolute bottom-0 left-0 right-0 p-2 text-white opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-xs font-medium truncate">{fileName}</p>
{sizeText && <p className="text-xs opacity-80">{sizeText}</p>}
</div>
</div>
);
}
if (linkInfo && file.url) {
return (
<Tooltip key={file.url || `${fileName}-${index}`}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
void openExternalUrl(file.url || '');
}}
className={cn(
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left",
compact ? "text-xs" : "text-sm"
)}
>
<div className="flex-shrink-0">
<Icon name={linkIconName(linkInfo)} className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{fileName}</p>
{sizeText && <p className="text-xs text-muted-foreground">{sizeText}</p>}
</div>
</button>
</TooltipTrigger>
<TooltipContent>
<p>{fileName}{sizeText ? ` (${sizeText})` : ''}</p>
</TooltipContent>
</Tooltip>
);
}
const source = file.source;
const sourceType = typeof source?.type === 'string' ? source.type : undefined;
const sourcePath = source && typeof (source as Record<string, unknown>).path === 'string' ? (source as Record<string, unknown>).path as string : undefined;
const filePath = sourceType === 'file' && sourcePath ? sourcePath : (file.url || '');
const isDrawio = filePath && isDrawioFile(filePath);
if (isDrawio) {
return (
<Tooltip key={file.url || `${fileName}-${index}`}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
const directory = useDirectoryStore.getState().currentDirectory;
if (directory) {
useUIStore.getState().openContextFile(directory, filePath);
}
}}
className={cn(
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left cursor-pointer",
compact ? "text-xs" : "text-sm"
)}
>
<Icon name="file" className={cn("text-muted-foreground shrink-0", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{fileName}</p>
<p className="text-xs text-status-info">{t('chat.fileAttachment.openInDiagram')}</p>
</div>
<Icon name="external-link" className={cn("text-muted-foreground shrink-0", compact ? "h-3 w-3" : "h-3.5 w-3.5")} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('chat.fileAttachment.openInDiagram')}</p>
</TooltipContent>
</Tooltip>
);
}
return (
<Tooltip key={file.url || `${fileName}-${index}`}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
if (onShowPopup && file.url) {
onShowPopup({
open: true,
title: fileName,
content: '',
image: {
url: file.url,
mimeType: file.mime,
filename: fileName,
},
});
}
}}
className={cn(
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left",
compact ? "text-xs" : "text-sm"
)}
>
<div className="flex-shrink-0">
{file.mime?.startsWith('image/') ? (
<Icon name="file-image" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
) : file.mime?.includes('pdf') ? (
<Icon name="file-pdf" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
) : (
<Icon name="file" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{fileName}</p>
{sizeText && <p className="text-xs text-muted-foreground">{sizeText}</p>}
</div>
</button>
</TooltipTrigger>
<TooltipContent>
<p>{fileName}{sizeText ? ` (${sizeText})` : ''}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
);
});
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 (
<div className="space-y-2">
<div className={cn("grid gap-2", getGridCols())}>
{urls.map((url, index) => (
<button
key={url}
type="button"
onClick={() => onShowPopup?.({
open: true,
title: caption || `Image ${index + 1} of ${urls.length}`,
content: '',
image: {
url,
gallery: urls.map(u => ({ url: u })),
index,
},
})}
className="relative aspect-square rounded-lg border border-border/40 bg-muted/10 overflow-hidden group"
>
<img
src={url}
alt={caption || `Image ${index + 1}`}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
</button>
))}
</div>
{caption && (
<p className="text-sm text-muted-foreground italic">{caption}</p>
)}
</div>
);
});
ImageGallery.displayName = 'ImageGallery';