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 taskAttachmentStore = new TeamTaskAttachmentStore();
const teamMetaStore = new TeamMetaStore(); 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 const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
/** /**

View file

@ -3601,7 +3601,7 @@ export class TeamProvisioningService {
async sendMessageToTeam( async sendMessageToTeam(
teamName: string, teamName: string,
message: string, message: string,
attachments?: { data: string; mimeType: string }[] attachments?: { data: string; mimeType: string; filename?: string }[]
): Promise<void> { ): Promise<void> {
const runId = this.getAliveRunId(teamName); const runId = this.getAliveRunId(teamName);
if (!runId) { if (!runId) {
@ -3615,14 +3615,38 @@ export class TeamProvisioningService {
const contentBlocks: Record<string, unknown>[] = [{ type: 'text', text: message }]; const contentBlocks: Record<string, unknown>[] = [{ type: 'text', text: message }];
if (attachments?.length) { if (attachments?.length) {
for (const att of attachments) { for (const att of attachments) {
contentBlocks.push({ if (att.mimeType === 'application/pdf') {
type: 'image', // PDF → document block with base64 source
source: { contentBlocks.push({
type: 'base64', type: 'document',
media_type: att.mimeType, source: {
data: att.data, 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 { Loader2 } from 'lucide-react';
import { isImageMime } from '@renderer/utils/attachmentUtils';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { AttachmentThumbnail } from './AttachmentThumbnail'; import { AttachmentThumbnail } from './AttachmentThumbnail';
import { ImageLightbox } from './ImageLightbox'; import { ImageLightbox } from './ImageLightbox';
@ -66,30 +70,61 @@ export const AttachmentDisplay = ({
.map((meta) => { .map((meta) => {
const data = dataById.get(meta.id); const data = dataById.get(meta.id);
if (!data) return null; 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; 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 ( return (
<> <>
<div className="mt-1.5 flex flex-wrap gap-2"> <div className="mt-1.5 flex flex-wrap gap-2">
{items.map((item, i) => ( {items.map((item, i) =>
<AttachmentThumbnail item.isImage && item.dataUrl ? (
key={item.meta.id} <AttachmentThumbnail
src={item.dataUrl} key={item.meta.id}
alt={item.meta.filename} src={item.dataUrl}
size="md" alt={item.meta.filename}
onClick={() => setLightboxIndex(i)} 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> </div>
{lightboxIndex !== null && items[lightboxIndex] ? ( {lightboxIndex !== null && imageSlides[lightboxIndex] ? (
<ImageLightbox <ImageLightbox
open open
onClose={() => setLightboxIndex(null)} onClose={() => setLightboxIndex(null)}
slides={items.map((item) => ({ src: item.dataUrl, alt: item.meta.filename }))} slides={imageSlides}
index={lightboxIndex} index={lightboxIndex}
/> />
) : null} ) : 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 { Ban, X } from 'lucide-react';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { AttachmentThumbnail } from './AttachmentThumbnail'; import { AttachmentThumbnail } from './AttachmentThumbnail';
import type { AttachmentPayload } from '@shared/types'; import type { AttachmentPayload } from '@shared/types';
@ -18,7 +20,8 @@ export const AttachmentPreviewItem = ({
onPreview, onPreview,
disabled, disabled,
}: AttachmentPreviewItemProps): React.JSX.Element => { }: 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 ( 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"> <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" /> <Ban size={18} className="text-red-400" />
</div> </div>
) : null} ) : 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"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]"> <span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">
{attachment.filename} {attachment.filename}

View file

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

View file

@ -1,8 +1,8 @@
import { Ban, ImagePlus } from 'lucide-react'; import { Ban, Paperclip } from 'lucide-react';
interface DropZoneOverlayProps { interface DropZoneOverlayProps {
active: boolean; 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; rejected?: boolean;
} }
@ -23,7 +23,7 @@ export const DropZoneOverlay = ({
> >
<div className="flex flex-col items-center gap-1.5 text-red-400"> <div className="flex flex-col items-center gap-1.5 text-red-400">
<Ban size={24} /> <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>
</div> </div>
); );
@ -41,8 +41,8 @@ export const DropZoneOverlay = ({
className="flex flex-col items-center gap-1.5" className="flex flex-col items-center gap-1.5"
style={{ color: 'var(--color-accent, #6366f1)' }} style={{ color: 'var(--color-accent, #6366f1)' }}
> >
<ImagePlus size={24} /> <Paperclip size={24} />
<span className="text-xs font-medium">Drop images here</span> <span className="text-xs font-medium">Drop files here</span>
</div> </div>
</div> </div>
); );

View file

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

View file

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

View file

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

View file

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

View file

@ -17,6 +17,7 @@ import {
type ComposerDraftSnapshot, type ComposerDraftSnapshot,
composerDraftStorage, composerDraftStorage,
} from '@renderer/services/composerDraftStorage'; } from '@renderer/services/composerDraftStorage';
import { categorizeFile } from '@shared/constants/attachments';
import { import {
fileToAttachmentPayload, fileToAttachmentPayload,
MAX_FILES, MAX_FILES,
@ -325,8 +326,30 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
const fileArray = Array.from(files); const fileArray = Array.from(files);
if (fileArray.length === 0) return; 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; let batchSize = 0;
for (const file of fileArray) { for (const file of supported) {
const validation = validateAttachment(file); const validation = validateAttachment(file);
if (!validation.valid) { if (!validation.valid) {
setAttachmentError(validation.error); setAttachmentError(validation.error);
@ -336,7 +359,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
} }
const newPayloads: AttachmentPayload[] = []; const newPayloads: AttachmentPayload[] = [];
for (const file of fileArray) { for (const file of supported) {
try { try {
const payload = await fileToAttachmentPayload(file); const payload = await fileToAttachmentPayload(file);
newPayloads.push(payload); newPayloads.push(payload);
@ -363,7 +386,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
setIsSaved(false); setIsSaved(false);
scheduleSave(); scheduleSave();
}, },
[scheduleSave] [scheduleSave, setText]
); );
const removeAttachment = useCallback( const removeAttachment = useCallback(
@ -397,17 +420,19 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
const items = event.clipboardData?.items; const items = event.clipboardData?.items;
if (!items) return; if (!items) return;
const imageFiles: File[] = []; const supportedFiles: File[] = [];
for (const item of Array.from(items)) { for (const item of Array.from(items)) {
if (item.kind === 'file' && item.type.startsWith('image/')) { if (item.kind === 'file') {
const file = item.getAsFile(); 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(); event.preventDefault();
void addFiles(imageFiles); void addFiles(supportedFiles);
} }
}, },
[addFiles] [addFiles]
@ -418,14 +443,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
event.preventDefault(); event.preventDefault();
const files = event.dataTransfer?.files; const files = event.dataTransfer?.files;
if (!files?.length) return; if (!files?.length) return;
void addFiles(Array.from(files));
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');
}
}, },
[addFiles] [addFiles]
); );

View file

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