feat(attachments): expand file support and enhance attachment handling

- Added support for additional attachment types: PDF and plain text.
- Updated the TeamProvisioningService to handle new attachment types with appropriate content blocks.
- Enhanced AttachmentDisplay and AttachmentPreview components to differentiate between image and non-image files.
- Modified DropZoneOverlay and SendMessageDialog to reflect changes in file handling and messaging.
- Improved user experience by allowing file previews for non-image attachments and updating error messages accordingly.
- Refactored attachment validation logic to categorize unsupported files and handle them gracefully.
This commit is contained in:
iliya 2026-03-23 17:24:48 +02:00
parent 32e310a5ff
commit b20b69066e
14 changed files with 488 additions and 174 deletions

View file

@ -270,7 +270,14 @@ const attachmentStore = new TeamAttachmentStore();
const taskAttachmentStore = new TeamTaskAttachmentStore();
const teamMetaStore = new TeamMetaStore();
const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
const ALLOWED_ATTACHMENT_TYPES = new Set([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
]);
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
/**

View file

@ -3601,7 +3601,7 @@ export class TeamProvisioningService {
async sendMessageToTeam(
teamName: string,
message: string,
attachments?: { data: string; mimeType: string }[]
attachments?: { data: string; mimeType: string; filename?: string }[]
): Promise<void> {
const runId = this.getAliveRunId(teamName);
if (!runId) {
@ -3615,14 +3615,38 @@ export class TeamProvisioningService {
const contentBlocks: Record<string, unknown>[] = [{ type: 'text', text: message }];
if (attachments?.length) {
for (const att of attachments) {
contentBlocks.push({
type: 'image',
source: {
type: 'base64',
media_type: att.mimeType,
data: att.data,
},
});
if (att.mimeType === 'application/pdf') {
// PDF → document block with base64 source
contentBlocks.push({
type: 'document',
source: {
type: 'base64',
media_type: 'application/pdf',
data: att.data,
},
});
} else if (att.mimeType === 'text/plain') {
// Text file → document block with text source (decode base64 → UTF-8)
contentBlocks.push({
type: 'document',
source: {
type: 'text',
media_type: 'text/plain',
data: Buffer.from(att.data, 'base64').toString('utf-8'),
},
title: att.filename,
});
} else {
// Image (default) → image block
contentBlocks.push({
type: 'image',
source: {
type: 'base64',
media_type: att.mimeType,
data: att.data,
},
});
}
}
}

View file

@ -2,6 +2,10 @@ import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { isImageMime } from '@renderer/utils/attachmentUtils';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { AttachmentThumbnail } from './AttachmentThumbnail';
import { ImageLightbox } from './ImageLightbox';
@ -66,30 +70,61 @@ export const AttachmentDisplay = ({
.map((meta) => {
const data = dataById.get(meta.id);
if (!data) return null;
return { meta, dataUrl: `data:${data.mimeType};base64,${data.data}` };
const isImage = isImageMime(data.mimeType);
return {
meta,
dataUrl: isImage ? `data:${data.mimeType};base64,${data.data}` : undefined,
isImage,
};
})
.filter(Boolean) as { meta: AttachmentMeta; dataUrl: string }[];
.filter(Boolean) as { meta: AttachmentMeta; dataUrl: string | undefined; isImage: boolean }[];
if (items.length === 0) return null;
// Build lightbox slides for images only, with visual→lightbox index mapping
const imageSlides: { src: string; alt: string }[] = [];
const visualToLightbox = new Map<number, number>();
items.forEach((item, i) => {
if (item.isImage && item.dataUrl) {
visualToLightbox.set(i, imageSlides.length);
imageSlides.push({ src: item.dataUrl, alt: item.meta.filename });
}
});
return (
<>
<div className="mt-1.5 flex flex-wrap gap-2">
{items.map((item, i) => (
<AttachmentThumbnail
key={item.meta.id}
src={item.dataUrl}
alt={item.meta.filename}
size="md"
onClick={() => setLightboxIndex(i)}
/>
))}
{items.map((item, i) =>
item.isImage && item.dataUrl ? (
<AttachmentThumbnail
key={item.meta.id}
src={item.dataUrl}
alt={item.meta.filename}
size="md"
onClick={
visualToLightbox.has(i)
? () => setLightboxIndex(visualToLightbox.get(i)!)
: undefined
}
/>
) : (
<div
key={item.meta.id}
className="flex size-20 flex-col items-center justify-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)]"
>
<FileIcon fileName={item.meta.filename} className="size-5" />
<span className="max-w-[72px] truncate text-[9px] text-[var(--color-text-muted)]">
{item.meta.filename}
</span>
</div>
)
)}
</div>
{lightboxIndex !== null && items[lightboxIndex] ? (
{lightboxIndex !== null && imageSlides[lightboxIndex] ? (
<ImageLightbox
open
onClose={() => setLightboxIndex(null)}
slides={items.map((item) => ({ src: item.dataUrl, alt: item.meta.filename }))}
slides={imageSlides}
index={lightboxIndex}
/>
) : null}

View file

@ -1,6 +1,8 @@
import { formatFileSize } from '@renderer/utils/attachmentUtils';
import { formatFileSize, isImageMime } from '@renderer/utils/attachmentUtils';
import { Ban, X } from 'lucide-react';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { AttachmentThumbnail } from './AttachmentThumbnail';
import type { AttachmentPayload } from '@shared/types';
@ -18,7 +20,8 @@ export const AttachmentPreviewItem = ({
onPreview,
disabled,
}: AttachmentPreviewItemProps): React.JSX.Element => {
const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`;
const isImage = isImageMime(attachment.mimeType);
const dataUrl = isImage ? `data:${attachment.mimeType};base64,${attachment.data}` : undefined;
return (
<div className="group/att relative flex shrink-0 items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5">
@ -27,7 +30,18 @@ export const AttachmentPreviewItem = ({
<Ban size={18} className="text-red-400" />
</div>
) : null}
<AttachmentThumbnail src={dataUrl} alt={attachment.filename} size="sm" onClick={onPreview} />
{isImage && dataUrl ? (
<AttachmentThumbnail
src={dataUrl}
alt={attachment.filename}
size="sm"
onClick={onPreview}
/>
) : (
<div className="flex size-12 items-center justify-center rounded bg-[var(--color-surface-raised)]">
<FileIcon fileName={attachment.filename} className="size-5" />
</div>
)}
<div className="flex min-w-0 flex-col gap-0.5">
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">
{attachment.filename}

View file

@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { AlertCircle, X } from 'lucide-react';
import { isImageMime } from '@renderer/utils/attachmentUtils';
import { AttachmentPreviewItem } from './AttachmentPreviewItem';
import { ImageLightbox } from './ImageLightbox';
@ -107,10 +109,18 @@ export const AttachmentPreviewList = ({
if (visibleAttachments.length === 0 && exitingIds.size === 0 && !error) return null;
const lightboxSlides = visibleAttachments.map((att) => ({
src: `data:${att.mimeType};base64,${att.data}`,
alt: att.filename,
}));
// Build lightbox slides for images only, with visual→lightbox index mapping
const imageSlides: { src: string; alt: string }[] = [];
const visualToLightbox = new Map<number, number>();
visibleAttachments.forEach((att, i) => {
if (isImageMime(att.mimeType)) {
visualToLightbox.set(i, imageSlides.length);
imageSlides.push({
src: `data:${att.mimeType};base64,${att.data}`,
alt: att.filename,
});
}
});
return (
<div className="space-y-1.5 px-1">
@ -135,7 +145,11 @@ export const AttachmentPreviewList = ({
<AttachmentPreviewItem
attachment={att}
onRemove={handleRemove}
onPreview={() => setLightboxIndex(i)}
onPreview={
visualToLightbox.has(i)
? () => setLightboxIndex(visualToLightbox.get(i)!)
: undefined
}
disabled={disabled}
/>
</div>
@ -167,11 +181,11 @@ export const AttachmentPreviewList = ({
) : null}
</div>
) : null}
{lightboxIndex !== null && lightboxSlides[lightboxIndex] ? (
{lightboxIndex !== null && imageSlides[lightboxIndex] ? (
<ImageLightbox
open
onClose={() => setLightboxIndex(null)}
slides={lightboxSlides}
slides={imageSlides}
index={lightboxIndex}
/>
) : null}

View file

@ -1,8 +1,8 @@
import { Ban, ImagePlus } from 'lucide-react';
import { Ban, Paperclip } from 'lucide-react';
interface DropZoneOverlayProps {
active: boolean;
/** Show a "rejected" variant when images can't be sent to this recipient. */
/** Show a "rejected" variant when files can't be sent to this recipient. */
rejected?: boolean;
}
@ -23,7 +23,7 @@ export const DropZoneOverlay = ({
>
<div className="flex flex-col items-center gap-1.5 text-red-400">
<Ban size={24} />
<span className="text-xs font-medium">Images can only be sent to the team lead</span>
<span className="text-xs font-medium">Files can only be sent to the team lead</span>
</div>
</div>
);
@ -41,8 +41,8 @@ export const DropZoneOverlay = ({
className="flex flex-col items-center gap-1.5"
style={{ color: 'var(--color-accent, #6366f1)' }}
>
<ImagePlus size={24} />
<span className="text-xs font-medium">Drop images here</span>
<Paperclip size={24} />
<span className="text-xs font-medium">Drop files here</span>
</div>
</div>
);

View file

@ -32,7 +32,7 @@ import {
} from '@renderer/utils/taskReferenceUtils';
import { MAX_TEXT_LENGTH } from '@shared/constants';
import { isLeadMember } from '@shared/utils/leadDetection';
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
import { AlertCircle, Paperclip, Send, X } from 'lucide-react';
import { MemberBadge } from '../MemberBadge';
@ -107,8 +107,8 @@ export const SendMessageDialog = ({
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
const imageRestrictionTimerRef = useRef(0);
const [fileRestrictionError, setFileRestrictionError] = useState<string | null>(null);
const fileRestrictionTimerRef = useRef(0);
const [actionMode, setActionModeState] = useState<ActionMode>(stickyActionMode);
const actionModeRef = useRef<ActionMode>(stickyActionMode);
const setActionMode = useCallback((mode: ActionMode) => {
@ -279,17 +279,17 @@ export const SendMessageDialog = ({
[addFiles]
);
const showImageRestrictionError = useCallback(() => {
setImageRestrictionError('Images can only be sent to the team lead');
window.clearTimeout(imageRestrictionTimerRef.current);
imageRestrictionTimerRef.current = window.setTimeout(() => {
setImageRestrictionError(null);
const showFileRestrictionError = useCallback(() => {
setFileRestrictionError('Files can only be sent to the team lead');
window.clearTimeout(fileRestrictionTimerRef.current);
fileRestrictionTimerRef.current = window.setTimeout(() => {
setFileRestrictionError(null);
}, 4000);
}, []);
// Cleanup restriction error timer on unmount
useEffect(() => {
const ref = imageRestrictionTimerRef;
const ref = fileRestrictionTimerRef;
return () => window.clearTimeout(ref.current);
}, []);
@ -320,33 +320,28 @@ export const SendMessageDialog = ({
if (!isLeadRecipient) {
const files = e.dataTransfer?.files;
if (files?.length) {
const hasImages = Array.from(files).some((f) => f.type.startsWith('image/'));
if (hasImages) {
showImageRestrictionError();
}
showFileRestrictionError();
}
return;
}
if (canAttach) handleDrop(e);
},
[isLeadRecipient, canAttach, handleDrop, showImageRestrictionError]
[isLeadRecipient, canAttach, handleDrop, showFileRestrictionError]
);
const handlePasteWrapper = useCallback(
(e: React.ClipboardEvent) => {
if (!isLeadRecipient) {
const hasImages = Array.from(e.clipboardData.items).some((item) =>
item.type.startsWith('image/')
);
if (hasImages) {
const hasFiles = Array.from(e.clipboardData.items).some((item) => item.kind === 'file');
if (hasFiles) {
e.preventDefault();
showImageRestrictionError();
showFileRestrictionError();
}
return;
}
if (canAttach) handlePaste(e);
},
[isLeadRecipient, canAttach, handlePaste, showImageRestrictionError]
[isLeadRecipient, canAttach, handlePaste, showFileRestrictionError]
);
return (
@ -386,7 +381,7 @@ export const SendMessageDialog = ({
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
accept="*/*"
multiple
className="hidden"
onChange={handleFileInputChange}
@ -403,15 +398,15 @@ export const SendMessageDialog = ({
disabled={!canAttach}
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus size={14} />
<Paperclip size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top">
{!isTeamAlive
? 'Team must be online to attach images'
? 'Team must be online to attach files'
: !canAddMore
? 'Maximum attachments reached'
: 'Attach images (paste or drag & drop)'}
: 'Attach files (paste or drag & drop)'}
</TooltipContent>
</Tooltip>
</>
@ -421,9 +416,9 @@ export const SendMessageDialog = ({
<AttachmentPreviewList
attachments={attachments}
onRemove={removeAttachment}
error={attachmentError ?? imageRestrictionError}
error={attachmentError ?? fileRestrictionError}
disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
disabledHint="File attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
/>
<div className={quote ? 'flex flex-col' : 'contents'}>

View file

@ -19,14 +19,14 @@ import {
stripEncodedTaskReferenceMetadata,
} from '@renderer/utils/taskReferenceUtils';
import { MAX_TEXT_LENGTH } from '@shared/constants';
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
import { categorizeFile, getEffectiveMimeType } from '@shared/constants/attachments';
import { Mic, Paperclip, Send, Trash2, X } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
const MAX_ATTACHMENTS = 5;
const MAX_FILE_SIZE = 20 * 1024 * 1024;
const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
const LONG_QUOTE_THRESHOLD = 200;
interface TaskCommentInputProps {
@ -86,45 +86,55 @@ export const TaskCommentInput = ({
trimmed.length <= MAX_TEXT_LENGTH &&
!addingComment;
const addFiles = useCallback((files: FileList | File[]) => {
setAttachError(null);
const fileArray = Array.from(files);
for (const file of fileArray) {
if (!ACCEPTED_TYPES.has(file.type)) {
setAttachError(`Unsupported type: ${file.type}`);
continue;
}
if (file.size > MAX_FILE_SIZE) {
setAttachError(`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`);
continue;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(',')[1];
if (!base64) return;
const id = crypto.randomUUID();
setPendingAttachments((prev) => {
if (prev.length >= MAX_ATTACHMENTS) {
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
return prev;
const addFiles = useCallback(
(files: FileList | File[]) => {
setAttachError(null);
const fileArray = Array.from(files);
for (const file of fileArray) {
if (categorizeFile(file) === 'unsupported') {
// Insert absolute file path into comment text for unsupported types
const filePath = (file as { path?: string }).path;
if (filePath) {
const current = draft.value;
draft.setValue(current ? filePath + '\n' + current : filePath + '\n');
}
return [
...prev,
{
id,
filename: file.name,
mimeType: file.type,
base64Data: base64,
previewUrl: result,
size: file.size,
},
];
});
};
reader.readAsDataURL(file);
}
}, []);
continue;
}
if (file.size > MAX_FILE_SIZE) {
setAttachError(
`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`
);
continue;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(',')[1];
if (!base64) return;
const id = crypto.randomUUID();
setPendingAttachments((prev) => {
if (prev.length >= MAX_ATTACHMENTS) {
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
return prev;
}
return [
...prev,
{
id,
filename: file.name,
mimeType: getEffectiveMimeType(file),
base64Data: base64,
previewUrl: result,
size: file.size,
},
];
});
};
reader.readAsDataURL(file);
}
},
[draft]
);
const removeAttachment = useCallback((id: string) => {
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
@ -179,16 +189,16 @@ export const TaskCommentInput = ({
(e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
const pastedFiles: File[] = [];
for (const item of Array.from(items)) {
if (item.kind === 'file' && ACCEPTED_TYPES.has(item.type)) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) imageFiles.push(file);
if (file && categorizeFile(file) !== 'unsupported') pastedFiles.push(file);
}
}
if (imageFiles.length > 0) {
if (pastedFiles.length > 0) {
e.preventDefault();
addFiles(imageFiles);
addFiles(pastedFiles);
}
},
[addFiles]
@ -286,7 +296,7 @@ export const TaskCommentInput = ({
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
accept="*/*"
multiple
className="hidden"
onChange={(e) => {
@ -323,10 +333,10 @@ export const TaskCommentInput = ({
disabled={addingComment || pendingAttachments.length >= MAX_ATTACHMENTS}
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus size={14} />
<Paperclip size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Attach image (or paste)</TooltipContent>
<TooltipContent side="top">Attach file (or paste)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>

View file

@ -25,7 +25,7 @@ import {
} from '@renderer/utils/taskReferenceUtils';
import { MAX_TEXT_LENGTH } from '@shared/constants';
import { isLeadMember } from '@shared/utils/leadDetection';
import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react';
import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector';
import type { MentionSuggestion } from '@renderer/types/mention';
@ -95,8 +95,8 @@ export const MessageComposer = ({
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
const imageRestrictionTimerRef = useRef(0);
const [fileRestrictionError, setFileRestrictionError] = useState<string | null>(null);
const fileRestrictionTimerRef = useRef(0);
const dismissMentionsRef = useRef<(() => void) | null>(null);
// Cross-team state
@ -331,17 +331,17 @@ export const MessageComposer = ({
[draftAddFiles]
);
const showImageRestrictionError = useCallback(() => {
setImageRestrictionError('Images can only be sent to the team lead');
window.clearTimeout(imageRestrictionTimerRef.current);
imageRestrictionTimerRef.current = window.setTimeout(() => {
setImageRestrictionError(null);
const showFileRestrictionError = useCallback(() => {
setFileRestrictionError('Files can only be sent to the team lead');
window.clearTimeout(fileRestrictionTimerRef.current);
fileRestrictionTimerRef.current = window.setTimeout(() => {
setFileRestrictionError(null);
}, 4000);
}, []);
// Cleanup restriction error timer on unmount
useEffect(() => {
const ref = imageRestrictionTimerRef;
const ref = fileRestrictionTimerRef;
return () => window.clearTimeout(ref.current);
}, []);
@ -373,39 +373,34 @@ export const MessageComposer = ({
if (!isLeadRecipient) {
const files = e.dataTransfer?.files;
if (files?.length) {
const hasImages = Array.from(files).some((f) => f.type.startsWith('image/'));
if (hasImages) {
showImageRestrictionError();
}
showFileRestrictionError();
}
return;
}
if (canAttach) draftHandleDrop(e);
},
[isLeadRecipient, canAttach, draftHandleDrop, showImageRestrictionError]
[isLeadRecipient, canAttach, draftHandleDrop, showFileRestrictionError]
);
const { handlePaste: draftHandlePaste } = draft;
const handlePasteWrapper = useCallback(
(e: React.ClipboardEvent) => {
if (!isLeadRecipient) {
const hasImages = Array.from(e.clipboardData.items).some((item) =>
item.type.startsWith('image/')
);
if (hasImages) {
const hasFiles = Array.from(e.clipboardData.items).some((item) => item.kind === 'file');
if (hasFiles) {
e.preventDefault();
showImageRestrictionError();
showFileRestrictionError();
}
return;
}
if (canAttach) draftHandlePaste(e);
},
[isLeadRecipient, canAttach, draftHandlePaste, showImageRestrictionError]
[isLeadRecipient, canAttach, draftHandlePaste, showFileRestrictionError]
);
const remaining = MAX_TEXT_LENGTH - trimmed.length;
const hasAttachmentPreviewContent =
draft.attachments.length > 0 || Boolean(draft.attachmentError ?? imageRestrictionError);
draft.attachments.length > 0 || Boolean(draft.attachmentError ?? fileRestrictionError);
return (
<div
@ -426,7 +421,7 @@ export const MessageComposer = ({
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
accept="*/*"
multiple
className="hidden"
onChange={handleFileInputChange}
@ -444,15 +439,15 @@ export const MessageComposer = ({
disabled={!canAttach}
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus size={14} />
<Paperclip size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top">
{!isTeamAlive
? 'Team must be online to attach images'
? 'Team must be online to attach files'
: !draft.canAddMore
? 'Maximum attachments reached'
: 'Attach images (paste or drag & drop)'}
: 'Attach files (paste or drag & drop)'}
</TooltipContent>
</Tooltip>
</>
@ -839,10 +834,10 @@ export const MessageComposer = ({
<AttachmentPreviewList
attachments={draft.attachments}
onRemove={draft.removeAttachment}
error={draft.attachmentError ?? imageRestrictionError}
error={draft.attachmentError ?? fileRestrictionError}
onDismissError={draft.clearAttachmentError}
disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
disabledHint="File attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
/>
) : null}
</div>

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { draftStorage } from '@renderer/services/draftStorage';
import { categorizeFile } from '@shared/constants/attachments';
import {
fileToAttachmentPayload,
MAX_FILES,
@ -13,6 +14,8 @@ import type { AttachmentPayload } from '@shared/types';
interface UseAttachmentsOptions {
/** When provided, attachments are persisted to IndexedDB under this key. */
persistenceKey?: string;
/** Called with unsupported files so the consumer can handle them (e.g. insert paths into text). */
onUnsupportedFiles?: (files: File[]) => void;
}
interface UseAttachmentsReturn {
@ -57,6 +60,8 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
const keyRef = useRef(persistenceKey);
// eslint-disable-next-line react-hooks/refs -- synchronous ref sync during render is intentional to avoid stale key in callbacks
keyRef.current = persistenceKey;
const onUnsupportedRef = useRef(options?.onUnsupportedFiles);
onUnsupportedRef.current = options?.onUnsupportedFiles;
// Sync ref with state
const updateAttachments = useCallback((next: AttachmentPayload[]) => {
@ -162,9 +167,30 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
const fileArray = Array.from(files);
if (fileArray.length === 0) return;
// Split: supported → attachments, unsupported → callback or error
const supported: File[] = [];
const unsupported: File[] = [];
for (const f of fileArray) {
if (categorizeFile(f) === 'unsupported') {
unsupported.push(f);
} else {
supported.push(f);
}
}
if (unsupported.length > 0) {
if (onUnsupportedRef.current) {
onUnsupportedRef.current(unsupported);
} else {
setError(`Unsupported file type: ${unsupported[0].name}`);
}
}
if (supported.length === 0) return;
let batchSize = 0;
let valid = true;
for (const file of fileArray) {
for (const file of supported) {
const validation = validateAttachment(file);
if (!validation.valid) {
setError(validation.error);
@ -176,7 +202,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
if (!valid) return;
const newPayloads: AttachmentPayload[] = [];
for (const file of fileArray) {
for (const file of supported) {
try {
const payload = await fileToAttachmentPayload(file);
newPayloads.push(payload);
@ -244,17 +270,19 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
const items = event.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
const supportedFiles: File[] = [];
for (const item of Array.from(items)) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) imageFiles.push(file);
if (file && categorizeFile(file) !== 'unsupported') {
supportedFiles.push(file);
}
}
}
if (imageFiles.length > 0) {
if (supportedFiles.length > 0) {
event.preventDefault();
void addFiles(imageFiles);
void addFiles(supportedFiles);
}
},
[addFiles]
@ -265,14 +293,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
event.preventDefault();
const files = event.dataTransfer?.files;
if (!files?.length) return;
const allFiles = Array.from(files);
const imageFiles = allFiles.filter((f) => f.type.startsWith('image/'));
if (imageFiles.length > 0) {
void addFiles(imageFiles);
} else if (allFiles.length > 0) {
setError('Only image files are supported');
}
void addFiles(Array.from(files));
},
[addFiles]
);

View file

@ -17,6 +17,7 @@ import {
type ComposerDraftSnapshot,
composerDraftStorage,
} from '@renderer/services/composerDraftStorage';
import { categorizeFile } from '@shared/constants/attachments';
import {
fileToAttachmentPayload,
MAX_FILES,
@ -325,8 +326,30 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
const fileArray = Array.from(files);
if (fileArray.length === 0) return;
// Split: supported → attachments, unsupported → path prepended to text
const supported: File[] = [];
const unsupportedPaths: string[] = [];
for (const f of fileArray) {
if (categorizeFile(f) === 'unsupported') {
const p = (f as { path?: string }).path;
if (p) unsupportedPaths.push(p);
else setAttachmentError(`Unsupported file: ${f.name}`);
} else {
supported.push(f);
}
}
// Prepend unsupported file paths to text (independent of attachment validation)
if (unsupportedPaths.length > 0) {
const prefix = unsupportedPaths.join('\n') + '\n';
const current = textRef.current;
setText(current ? prefix + current : prefix);
}
if (supported.length === 0) return;
let batchSize = 0;
for (const file of fileArray) {
for (const file of supported) {
const validation = validateAttachment(file);
if (!validation.valid) {
setAttachmentError(validation.error);
@ -336,7 +359,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
}
const newPayloads: AttachmentPayload[] = [];
for (const file of fileArray) {
for (const file of supported) {
try {
const payload = await fileToAttachmentPayload(file);
newPayloads.push(payload);
@ -363,7 +386,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
setIsSaved(false);
scheduleSave();
},
[scheduleSave]
[scheduleSave, setText]
);
const removeAttachment = useCallback(
@ -397,17 +420,19 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
const items = event.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
const supportedFiles: File[] = [];
for (const item of Array.from(items)) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) imageFiles.push(file);
if (file && categorizeFile(file) !== 'unsupported') {
supportedFiles.push(file);
}
}
}
if (imageFiles.length > 0) {
if (supportedFiles.length > 0) {
event.preventDefault();
void addFiles(imageFiles);
void addFiles(supportedFiles);
}
},
[addFiles]
@ -418,14 +443,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
event.preventDefault();
const files = event.dataTransfer?.files;
if (!files?.length) return;
const allFiles = Array.from(files);
const imageFiles = allFiles.filter((f) => f.type.startsWith('image/'));
if (imageFiles.length > 0) {
void addFiles(imageFiles);
} else if (allFiles.length > 0) {
setAttachmentError('Only image files are supported');
}
void addFiles(Array.from(files));
},
[addFiles]
);

View file

@ -1,3 +1,5 @@
import { categorizeFile, getEffectiveMimeType, isImageMime } from '@shared/constants/attachments';
import type { AttachmentPayload, ImageMimeType } from '@shared/types';
export const ALLOWED_MIME_TYPES = new Set<ImageMimeType>([
@ -16,8 +18,9 @@ export function isImageMimeType(type: string): type is ImageMimeType {
}
export function validateAttachment(file: File): { valid: true } | { valid: false; error: string } {
if (!isImageMimeType(file.type)) {
return { valid: false, error: `Unsupported file type: ${file.type}` };
const cat = categorizeFile(file);
if (cat === 'unsupported') {
return { valid: false, error: `Unsupported file type: ${file.name}` };
}
if (file.size > MAX_FILE_SIZE) {
return { valid: false, error: `File "${file.name}" exceeds 10MB limit` };
@ -30,12 +33,12 @@ export async function fileToAttachmentPayload(file: File): Promise<AttachmentPay
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
// Strip "data:image/png;base64," prefix to get raw base64
// Strip "data:<mime>;base64," prefix to get raw base64
const base64 = dataUrl.split(',')[1] ?? '';
resolve({
id: crypto.randomUUID(),
filename: file.name,
mimeType: file.type,
mimeType: getEffectiveMimeType(file),
size: file.size,
data: base64,
});
@ -45,6 +48,8 @@ export async function fileToAttachmentPayload(file: File): Promise<AttachmentPay
});
}
export { categorizeFile, isImageMime };
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;

View file

@ -0,0 +1,175 @@
/**
* Attachment file categorization and MIME type helpers.
*
* Browser MIME types are unreliable:
* .ts "video/mp2t", .json "application/json", .go/.rs/.yaml ""
* So categorization is ALWAYS by file extension (primary), with browser MIME
* used only as a fallback for images.
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
/** Extensions recognized as image files (fallback when browser MIME is empty). */
const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']);
/** Extensions recognized as text-based files → sent as `document` block with `text/plain`. */
export const TEXT_FILE_EXTENSIONS = new Set([
// Data
'json',
'jsonl',
'txt',
'md',
'mdx',
'csv',
'tsv',
// JavaScript / TypeScript
'ts',
'tsx',
'js',
'jsx',
'mjs',
'cjs',
// Other languages
'py',
'go',
'rs',
'java',
'kt',
'rb',
'c',
'h',
'cpp',
'hpp',
'cs',
'swift',
'dart',
'php',
'lua',
'scala',
'ex',
'exs',
// Web
'html',
'css',
'scss',
'less',
'vue',
'svelte',
// Config / markup
'xml',
'yaml',
'yml',
'toml',
'ini',
'cfg',
'conf',
// Shell
'sh',
'bash',
'zsh',
'fish',
// Query / schema
'sql',
'graphql',
'gql',
'proto',
// Misc text
'env',
'log',
'rst',
'diff',
'patch',
// Known filenames that happen to equal their "extension" when split on '.'
'dockerfile',
'makefile',
'gitignore',
'dockerignore',
'editorconfig',
]);
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type FileCategory = 'image' | 'pdf' | 'text' | 'unsupported';
// ---------------------------------------------------------------------------
// Categorization
// ---------------------------------------------------------------------------
/**
* Categorize a `File` by its **extension** (primary) browser MIME is
* unreliable for anything other than images.
*/
export function categorizeFile(file: File): FileCategory {
// 1. Browser MIME is reliable for images
if (IMAGE_MIME_TYPES.has(file.type)) return 'image';
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
// 2. Extension-based checks
if (IMAGE_EXTENSIONS.has(ext)) return 'image'; // fallback for empty MIME
if (ext === 'pdf') return 'pdf';
if (TEXT_FILE_EXTENSIONS.has(ext)) return 'text';
// 3. Special filenames / patterns
const baseName = file.name.toLowerCase();
if (baseName.startsWith('.env')) return 'text'; // .env.local, .env.production, etc.
return 'unsupported';
}
// ---------------------------------------------------------------------------
// MIME helpers
// ---------------------------------------------------------------------------
const IMAGE_EXT_TO_MIME: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
};
/**
* Return the MIME type that should be stored in `AttachmentPayload.mimeType`
* (used by the backend to choose the correct content block type).
*/
export function getEffectiveMimeType(file: File): string {
const cat = categorizeFile(file);
if (cat === 'image') {
if (file.type && IMAGE_MIME_TYPES.has(file.type)) return file.type;
// Fallback when browser returns empty MIME for an image extension
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
return IMAGE_EXT_TO_MIME[ext] ?? 'image/png';
}
if (cat === 'pdf') return 'application/pdf';
if (cat === 'text') return 'text/plain';
return file.type || 'application/octet-stream';
}
// ---------------------------------------------------------------------------
// MIME type guards (used by backend routing & preview components)
// ---------------------------------------------------------------------------
export function isImageMime(mime: string): boolean {
return IMAGE_MIME_TYPES.has(mime);
}
export function isPdfMime(mime: string): boolean {
return mime === 'application/pdf';
}
export function isTextDocMime(mime: string): boolean {
return mime === 'text/plain';
}
export function isNativeAttachmentMime(mime: string): boolean {
return isImageMime(mime) || isPdfMime(mime) || isTextDocMime(mime);
}

View file

@ -3,6 +3,7 @@
*/
export * from './agentBlocks';
export * from './attachments';
export * from './cache';
export * from './cli';
export * from './crossTeam';