agent-ecosystem/src/renderer/components/team/dialogs/TaskAttachments.tsx
iliya 70fdc2537a feat: enhance inbox message handling and UI components
- Introduced functions to identify and filter out internal coordination noise from inbox messages, improving notification relevance.
- Updated TeamProvisioningService to automatically mark noise messages as read, reducing clutter for team leads.
- Enhanced TeamDetailView and MessagesFilterPopover to allow users to toggle visibility of noise messages in the UI.
- Refactored various components to improve the handling of task attachments and message serialization, ensuring better user experience.
- Improved error handling and state management in session detail fetching, allowing for real-time updates without UI disruptions.
2026-03-05 17:27:09 +02:00

384 lines
12 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import { File, ImagePlus, Loader2, Trash2 } from 'lucide-react';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import type { TaskAttachmentMeta } from '@shared/types';
const ACCEPTED_TYPES = new Set<string>(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB
interface TaskAttachmentsProps {
teamName: string;
taskId: string;
attachments: TaskAttachmentMeta[];
}
export const TaskAttachments = ({
teamName,
taskId,
attachments,
}: TaskAttachmentsProps): React.JSX.Element => {
const saveTaskAttachment = useStore((s) => s.saveTaskAttachment);
const deleteTaskAttachment = useStore((s) => s.deleteTaskAttachment);
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
const [uploading, setUploading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [lightboxSlides, setLightboxSlides] = useState<{ src: string; alt: string }[]>([]);
const [thumbCache, setThumbCache] = useState<Map<string, string>>(new Map());
const fileInputRef = useRef<HTMLInputElement>(null);
const imageAttachments = attachments.filter((a) => isImageMimeType(a.mimeType));
const handleThumbLoaded = useCallback((attachmentId: string, dataUrl: string) => {
setThumbCache((prev) => {
if (prev.get(attachmentId) === dataUrl) return prev;
const next = new Map(prev);
next.set(attachmentId, dataUrl);
return next;
});
}, []);
const handleFileSelect = useCallback(
async (files: FileList | null) => {
if (!files || files.length === 0) return;
setError(null);
setUploading(true);
try {
for (const file of Array.from(files)) {
if (!ACCEPTED_TYPES.has(file.type)) {
setError(`Unsupported file type: ${file.type}`);
continue;
}
if (file.size > MAX_FILE_SIZE) {
setError(`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`);
continue;
}
const base64 = await fileToBase64(file);
await saveTaskAttachment(teamName, taskId, {
name: file.name,
type: file.type,
base64,
});
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to upload');
} finally {
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
},
[teamName, taskId, saveTaskAttachment]
);
const handleDelete = useCallback(
async (attachmentId: string, mimeType: string) => {
setDeletingId(attachmentId);
try {
await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
} finally {
setDeletingId(null);
}
},
[teamName, taskId, deleteTaskAttachment]
);
const handleDownload = useCallback(
async (att: TaskAttachmentMeta) => {
setError(null);
try {
const base64 = await getTaskAttachmentData(teamName, taskId, att.id, att.mimeType);
if (!base64) {
setError('Attachment file not found');
return;
}
const mime = att.mimeType && typeof att.mimeType === 'string' ? att.mimeType : 'application/octet-stream';
const dataUrl = `data:${mime};base64,${base64}`;
const blob = await fetch(dataUrl).then((r) => r.blob());
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = att.filename || 'attachment';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to download');
}
},
[getTaskAttachmentData, teamName, taskId]
);
const handlePreview = useCallback(
(att: TaskAttachmentMeta) => {
if (!isImageMimeType(att.mimeType)) {
void handleDownload(att);
return;
}
const idx = imageAttachments.findIndex((a) => a.id === att.id);
if (idx >= 0) {
const snapshot = imageAttachments.map((a) => {
const dataUrl = thumbCache.get(a.id);
return {
src: dataUrl ?? `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>`,
alt: a.filename,
};
});
setLightboxSlides(snapshot);
setLightboxIndex(idx);
}
},
[imageAttachments, thumbCache, handleDownload]
);
// Handle paste events for quick image attachment
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: ClipboardEvent): void => {
const items = e.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
for (const item of Array.from(items)) {
if (item.kind === 'file' && ACCEPTED_TYPES.has(item.type)) {
const file = item.getAsFile();
if (file) imageFiles.push(file);
}
}
if (imageFiles.length > 0) {
e.preventDefault();
const dt = new DataTransfer();
imageFiles.forEach((f) => dt.items.add(f));
void handleFileSelect(dt.files);
}
};
const el = containerRef.current;
if (el) {
el.addEventListener('paste', handler);
return () => el.removeEventListener('paste', handler);
}
}, [handleFileSelect]);
// Handle drag-and-drop
const [dragOver, setDragOver] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
}, []);
const handleDragLeave = useCallback(() => setDragOver(false), []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
void handleFileSelect(e.dataTransfer.files);
},
[handleFileSelect]
);
return (
<div
ref={containerRef}
tabIndex={-1}
className="space-y-2"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Attachment thumbnails */}
{attachments.length > 0 ? (
<div className="flex flex-wrap gap-2">
{attachments.map((att) => (
<AttachmentThumbnail
key={att.id}
attachment={att}
teamName={teamName}
taskId={taskId}
isDeleting={deletingId === att.id}
onPreview={() => void handlePreview(att)}
onDelete={() => void handleDelete(att.id, att.mimeType)}
onDataLoaded={handleThumbLoaded}
/>
))}
</div>
) : null}
{/* Image lightbox */}
{lightboxIndex !== null && (
<ImageLightbox
open
onClose={() => {
setLightboxIndex(null);
setLightboxSlides([]);
}}
slides={lightboxSlides}
index={lightboxIndex}
/>
)}
{/* Drop zone indicator */}
{dragOver ? (
<div className="flex items-center justify-center rounded-md border-2 border-dashed border-blue-500/40 bg-blue-500/5 py-4 text-xs text-blue-400">
Drop image here
</div>
) : null}
{/* Controls */}
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
multiple
className="hidden"
onChange={(e) => void handleFileSelect(e.target.files)}
/>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs text-[var(--color-text-muted)]"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? <Loader2 size={12} className="animate-spin" /> : <ImagePlus size={12} />}
Attach image
</Button>
<span className="text-[10px] text-[var(--color-text-muted)]">or paste / drag-drop</span>
</div>
{error ? <p className="text-xs text-red-400">{error}</p> : null}
</div>
);
};
// ---------------------------------------------------------------------------
// Thumbnail sub-component
// ---------------------------------------------------------------------------
interface AttachmentThumbnailProps {
attachment: TaskAttachmentMeta;
teamName: string;
taskId: string;
isDeleting: boolean;
onPreview: () => void;
onDelete: () => void;
onDataLoaded?: (attachmentId: string, dataUrl: string) => void;
}
const AttachmentThumbnail = ({
attachment,
teamName,
taskId,
isDeleting,
onPreview,
onDelete,
onDataLoaded,
}: AttachmentThumbnailProps): React.JSX.Element => {
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
if (!isImageMimeType(attachment.mimeType)) return;
const base64 = await getTaskAttachmentData(
teamName,
taskId,
attachment.id,
attachment.mimeType
);
if (!cancelled && base64) {
const dataUrl = `data:${attachment.mimeType};base64,${base64}`;
setThumbUrl(dataUrl);
onDataLoaded?.(attachment.id, dataUrl);
}
} catch {
// ignore
}
})();
return () => {
cancelled = true;
};
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData, onDataLoaded]);
const sizeLabel =
attachment.size < 1024
? `${attachment.size} B`
: attachment.size < 1024 * 1024
? `${(attachment.size / 1024).toFixed(0)} KB`
: `${(attachment.size / (1024 * 1024)).toFixed(1)} MB`;
return (
<div
className={`group relative flex size-20 cursor-pointer items-center justify-center overflow-hidden rounded border transition-colors border-[var(--color-border)] hover:border-[var(--color-border-emphasis)] bg-[var(--color-surface)]`}
onClick={onPreview}
>
{isImageMimeType(attachment.mimeType) ? (
thumbUrl ? (
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
) : (
<Loader2 size={16} className="animate-spin text-[var(--color-text-muted)]" />
)
) : (
<div className="flex flex-col items-center gap-1 px-1 text-center">
<File size={18} className="text-[var(--color-text-muted)]" />
<div className="max-w-full truncate text-[9px] text-[var(--color-text-muted)]">
{attachment.filename}
</div>
</div>
)}
{/* Delete button overlay */}
<button
type="button"
className="absolute right-0.5 top-0.5 rounded bg-black/60 p-0.5 text-white opacity-0 transition-opacity hover:bg-red-600 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
disabled={isDeleting}
>
{isDeleting ? <Loader2 size={10} className="animate-spin" /> : <Trash2 size={10} />}
</button>
{/* Filename tooltip */}
<div className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-1 py-0.5 text-center text-[8px] text-white opacity-0 transition-opacity group-hover:opacity-100">
{attachment.filename} ({sizeLabel})
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// Strip the data URL prefix (e.g., "data:image/png;base64,")
const base64 = result.split(',')[1];
if (base64) {
resolve(base64);
} else {
reject(new Error('Failed to read file as base64'));
}
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}