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

407 lines
14 KiB
TypeScript
Raw Normal View History

2025-12-07 19:32:53 +02:00
import React, { useRef, memo } from 'react';
import { RiAttachment2, RiCloseLine, RiComputerLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiHardDrive3Line } from '@remixicon/react';
import { useSessionStore, type AttachedFile } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { toast } from '@/components/ui';
2025-12-07 19:32:53 +02:00
import { cn } from '@/lib/utils';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
2025-12-13 16:34:17 +02:00
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
2025-12-07 19:32:53 +02:00
import type { ToolPopupContent } from './message/types';
export const FileAttachmentButton = memo(() => {
const fileInputRef = useRef<HTMLInputElement>(null);
const { addAttachedFile } = useSessionStore();
const { isMobile } = useUIStore();
2025-12-13 16:34:17 +02:00
const isVSCodeRuntime = useIsVSCodeRuntime();
2025-12-07 19:32:53 +02:00
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
2025-12-13 16:34:17 +02:00
const attachFiles = async (files: FileList | File[]) => {
2025-12-07 19:32:53 +02:00
let attachedCount = 0;
for (let i = 0; i < files.length; i++) {
2025-12-13 16:34:17 +02:00
const file = files[i];
2025-12-07 19:32:53 +02:00
const sizeBefore = useSessionStore.getState().attachedFiles.length;
try {
2025-12-13 16:34:17 +02:00
await addAttachedFile(file);
2025-12-07 19:32:53 +02:00
const sizeAfter = useSessionStore.getState().attachedFiles.length;
if (sizeAfter > sizeBefore) {
attachedCount++;
}
} catch (error) {
console.error('File attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
}
}
if (attachedCount > 0) {
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
}
2025-12-13 16:34:17 +02:00
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
await attachFiles(files);
2025-12-07 19:32:53 +02:00
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
2025-12-13 16:34:17 +02:00
const handleVSCodePick = async () => {
try {
const response = await fetch('/api/vscode/pick-files');
const data = await response.json();
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 || 'file'}: ${s?.reason || 'skipped'}`).join('\n');
toast.error(`Some files were skipped:\n${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 || 'file', { 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 : 'Failed to pick files in VS Code');
}
};
2025-12-07 19:32:53 +02:00
return (
<>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
accept="*/*"
/>
<button
type='button'
2025-12-13 16:34:17 +02:00
onClick={() => {
if (isVSCodeRuntime) {
void handleVSCodePick();
} else {
fileInputRef.current?.click();
}
}}
2025-12-07 19:32:53 +02:00
className={cn(
buttonSizeClass,
'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0'
)}
title='Attach files'
>
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
</button>
</>
);
});
interface FileChipProps {
file: AttachedFile;
onRemove: () => void;
}
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
const getFileIcon = () => {
if (file.mimeType.startsWith('image/')) {
return <RiFileImageLine className="h-3.5 w-3.5" />;
}
if (file.mimeType.includes('text') || file.mimeType.includes('code')) {
return <RiFileLine className="h-3.5 w-3.5" />;
}
if (file.mimeType.includes('json') || file.mimeType.includes('xml')) {
return <RiFilePdfLine className="h-3.5 w-3.5" />;
}
return <RiFileLine className="h-3.5 w-3.5" />;
};
const formatFileSize = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) return '...';
2025-12-07 19:32:53 +02:00
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;
};
const displayName = extractFilename(file.filename);
const isLocalImagePreview =
file.source !== 'server' &&
file.mimeType.startsWith('image/') &&
typeof file.dataUrl === 'string' &&
file.dataUrl.startsWith('data:image/');
if (isLocalImagePreview) {
return (
<div className="relative h-12 w-12 sm:h-14 sm:w-14 overflow-hidden rounded-lg border border-border/40 bg-muted/10 flex-shrink-0">
<img
src={file.dataUrl}
alt={displayName}
className="h-full w-full object-cover"
loading="lazy"
/>
<button
onClick={onRemove}
className="absolute top-1 right-1 h-5 w-5 rounded-full bg-background/80 text-foreground hover:text-destructive flex items-center justify-center focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
title="Remove image"
aria-label={`Remove ${displayName}`}
>
<RiCloseLine className="h-3 w-3" />
</button>
</div>
);
}
2025-12-07 19:32:53 +02:00
return (
<div className="flex w-full sm:inline-flex sm:w-auto items-center gap-1.5 px-3 sm:px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta max-w-full min-w-0">
2025-12-07 19:32:53 +02:00
<div title={file.source === 'server' ? "Server file" : "Local file"}>
{file.source === 'server' ? (
<RiHardDrive3Line className="h-3 w-3 text-primary flex-shrink-0" />
2025-12-07 19:32:53 +02:00
) : (
<RiComputerLine className="h-3 w-3 text-muted-foreground flex-shrink-0" />
2025-12-07 19:32:53 +02:00
)}
</div>
{getFileIcon()}
<div className="overflow-hidden max-w-[120px] sm:max-w-[180px] flex-1 min-w-0">
<span className="truncate block" title={file.serverPath || displayName}>
{displayName}
</span>
</div>
<span className="ml-auto text-muted-foreground flex-shrink-0 text-xs">
{formatFileSize(file.size)}
2025-12-07 19:32:53 +02:00
</span>
<button
onClick={onRemove}
className="hover:text-destructive min-h-6 min-w-6 sm:min-h-0 sm:min-w-0 sm:p-0.5 flex items-center justify-center flex-shrink-0 rounded focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
2025-12-07 19:32:53 +02:00
title="Remove file"
>
<RiCloseLine className="h-4 w-4 sm:h-3 sm:w-3" />
2025-12-07 19:32:53 +02:00
</button>
</div>
);
});
export const AttachedFilesList = memo(() => {
const { attachedFiles, removeAttachedFile } = useSessionStore();
if (attachedFiles.length === 0) return null;
return (
<div className="pb-2 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center sm:flex-wrap gap-2 px-3 py-2 bg-muted/30 rounded-xl border border-border/30">
2025-12-07 19:32:53 +02:00
{attachedFiles.map((file) => (
<FileChip
key={file.id}
file={file}
onRemove={() => removeAttachedFile(file.id)}
/>
))}
</div>
</div>
);
});
interface FilePart {
type: string;
mime?: string;
url?: string;
filename?: string;
size?: number;
}
interface MessageFilesDisplayProps {
files: FilePart[];
onShowPopup?: (content: ToolPopupContent) => void;
compact?: boolean;
2025-12-07 19:32:53 +02:00
}
export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }: MessageFilesDisplayProps) => {
2025-12-07 19:32:53 +02:00
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('/');
return parts[parts.length - 1] || path;
};
const getFileIcon = (mimeType?: string) => {
if (!mimeType) return <RiFileLine className="h-3.5 w-3.5" />;
if (mimeType.startsWith('image/')) {
return <RiFileImageLine className="h-3.5 w-3.5" />;
}
if (mimeType.includes('text') || mimeType.includes('code')) {
return <RiFileLine className="h-3.5 w-3.5" />;
}
if (mimeType.includes('json') || mimeType.includes('xml')) {
return <RiFilePdfLine className="h-3.5 w-3.5" />;
}
return <RiFileLine className="h-3.5 w-3.5" />;
};
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 = extractFilename(file.filename) || 'Image';
return [{
url: file.url,
mimeType: file.mime,
filename,
size: file.size,
}];
}),
[imageFiles]
);
const handleImageClick = React.useCallback((index: number) => {
if (!onShowPopup) {
2025-12-07 19:32:53 +02:00
return;
}
const file = imageGallery[index];
if (!file?.url) return;
const filename = file.filename || 'Image';
2025-12-07 19:32:53 +02:00
const popupPayload: ToolPopupContent = {
open: true,
title: filename,
content: '',
metadata: {
tool: 'image-preview',
filename,
mime: file.mimeType,
2025-12-07 19:32:53 +02:00
size: file.size,
},
image: {
url: file.url,
mimeType: file.mimeType,
2025-12-07 19:32:53 +02:00
filename,
size: file.size,
gallery: imageGallery,
index,
2025-12-07 19:32:53 +02:00
},
};
onShowPopup(popupPayload);
}, [imageGallery, onShowPopup]);
2025-12-07 19:32:53 +02:00
if (fileItems.length === 0) return null;
return (
<div className={cn(compact ? 'space-y-1.5 mt-1.5' : 'space-y-2 mt-2')}>
2025-12-07 19:32:53 +02:00
{}
{otherFiles.length > 0 && (
<div className={cn('flex flex-wrap', compact ? 'gap-1.5' : 'gap-2')}>
2025-12-07 19:32:53 +02:00
{otherFiles.map((file, index) => (
<div
key={`file-${index}`}
className={cn(
'inline-flex items-center bg-muted/30 border border-border/30 typography-meta',
compact ? 'gap-1 px-2 py-0.5 rounded-lg' : 'gap-1.5 px-2.5 py-1 rounded-xl'
)}
2025-12-07 19:32:53 +02:00
>
{getFileIcon(file.mime)}
<div className={cn('overflow-hidden', compact ? 'max-w-[140px]' : 'max-w-[200px]')}>
<span className="truncate block" title={extractFilename(file.filename)}>
{extractFilename(file.filename)}
</span>
</div>
2025-12-07 19:32:53 +02:00
</div>
))}
</div>
)}
{}
{imageFiles.length > 0 && (
<div className={cn('overflow-x-auto -mx-1 px-1 scrollbar-thin', compact ? 'py-0.5' : 'py-1')}>
<div className={cn('flex snap-x snap-mandatory', compact ? 'gap-2' : 'gap-3')}>
2025-12-07 19:32:53 +02:00
{imageFiles.map((file, index) => {
const filename = extractFilename(file.filename) || 'Image';
return (
<Tooltip key={`img-${index}`} delayDuration={1000}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleImageClick(index)}
className={cn(
'relative flex-none border border-border/40 bg-muted/10 overflow-hidden snap-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary',
compact
? 'h-12 w-12 sm:h-14 sm:w-14 md:h-16 md:w-16 rounded-lg'
: 'aspect-square w-16 sm:w-20 md:w-24 rounded-xl'
)}
2025-12-07 19:32:53 +02:00
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">
<RiFileImageLine 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>
);
});