import * as api from '../api'; import type { Attachment } from '../api'; export const IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; export const MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024; export const MAX_ATTACHMENTS = 5; export type PendingAttachmentStatus = 'pending' | 'uploading' | 'uploaded' | 'error'; export interface PendingAttachment { key: string; file: File; previewUrl?: string; status: PendingAttachmentStatus; uploadedBytes?: number; uploadController?: AbortController; error?: string; attachment?: Attachment; } const identity = (file: File) => `${file.name}\u0000${file.type}\u0000${file.size}`; const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp)$/i; function normalizedMime(type: string) { const value = String(type || '').split(';', 1)[0].trim().toLowerCase(); return value === 'image/jpg' || value === 'image/pjpeg' ? 'image/jpeg' : value; } // Clipboard and drag sources sometimes omit MIME metadata. Use it only to // decide whether a local thumbnail is useful; the server independently decides // which uploads are validated/native images and which remain inert files. export function isPreviewableImageFile(file: Pick) { const type = normalizedMime(file.type); return (IMAGE_MIMES as readonly string[]).includes(type) || IMAGE_EXTENSION.test(file.name || ''); } export function attachmentFileError(file: Pick) { if (file.size > MAX_ATTACHMENT_BYTES) return 'too large (100 MB max)'; if (file.size === 0) return 'empty file'; return undefined; } export function transferMayContainFile(transfer: DataTransfer) { return Array.from(transfer.items || []).some((item) => item.kind === 'file') || Array.from(transfer.files || []).length > 0; } /** * DataTransfer exposes the same file through both `files` and `items` in some * browsers. The two views are not guaranteed to return the same File object — * clipboard-created files can even get different `lastModified` values — so * merging them can turn one paste into two attachments. `files` is the * canonical list; `items` is only a fallback for browsers that leave it empty. */ export function filesFromTransfer(transfer: DataTransfer) { const listed = Array.from(transfer.files || []); if (listed.length) return listed; const files: File[] = []; const seen = new Set(); const add = (file: File | null) => { if (!file) return; const id = identity(file); if (seen.has(id)) return; seen.add(id); files.push(file); }; for (const item of Array.from(transfer.items || [])) { if (item.kind === 'file') add(item.getAsFile()); } return files; } const extensionForMime = (type: string) => ({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/svg+xml': 'svg', 'image/tiff': 'tiff', 'image/heic': 'heic', 'application/pdf': 'pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx', }[normalizedMime(type)] || 'bin'); /** One ClipboardItem can advertise the same logical file in several formats. */ export async function filesFromClipboardItems(items: ClipboardItem[]) { const files: File[] = []; for (const item of items) { const imageTypes = item.types.filter((type) => normalizedMime(type).startsWith('image/')); const fileTypes = item.types.filter((type) => !['text/plain', 'text/html'].includes(normalizedMime(type))); const hasText = item.types.some((type) => ['text/plain', 'text/html'].includes(normalizedMime(type))); const type = imageTypes.find((candidate) => (IMAGE_MIMES as readonly string[]).includes(normalizedMime(candidate))) || imageTypes[0] || (!hasText ? fileTypes[0] : undefined); if (!type) continue; const blob = await item.getType(type); const mime = blob.type || type; files.push(new File([blob], `Clipboard.${extensionForMime(mime)}`, { type: mime })); } return files; } export function pendingAttachmentsFromFiles(files: File[], currentCount = 0) { const remaining = Math.max(0, MAX_ATTACHMENTS - currentCount); const accepted = files.slice(0, remaining).map((file): PendingAttachment => { const error = attachmentFileError(file); return { key: globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`, file, previewUrl: isPreviewableImageFile(file) ? URL.createObjectURL(file) : undefined, status: error ? 'error' : 'pending', error, }; }); return { attachments: accepted, error: files.length > remaining ? `At most ${MAX_ATTACHMENTS} files can be attached.` : null, }; } export function revokePendingAttachments(attachments: PendingAttachment[]) { for (const attachment of attachments) { if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl); } } /** Abandon an unsent chip: stop its transfer and remove any stored server file. */ export function discardPendingAttachment(sessionId: string, attachment: PendingAttachment) { attachment.uploadController?.abort(); if (attachment.attachment) { void api.deleteAttachment(sessionId, attachment.attachment.id).catch(() => {}); } if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl); } export function discardPendingAttachments(sessionId: string, attachments: PendingAttachment[]) { for (const attachment of attachments) discardPendingAttachment(sessionId, attachment); } export interface PendingPrompt { /** The operator's words (or the attachment-only fallback), used to recognise * the real transcript turn when it arrives. */ text: string; /** What the optimistic exchange shows until that transcript turn arrives. */ displayText: string; } /** * The server sends the prompt and its attachment paths as one string. Mirror * that small presentation rule in the optimistic exchange so the first paint * says exactly what was sent, without making transcript catch-up depend on a * CLI retaining the path syntax verbatim. */ export function buildPendingPrompt( cli: string, text: string, attachments: Pick[], ): PendingPrompt { const onlyImages = attachments.length > 0 && attachments.every((attachment) => attachment.kind === 'image'); const prompt = String(text || '').trim() || (onlyImages ? `Please inspect the attached screenshot${attachments.length === 1 ? '' : 's'}.` : `Please inspect the attached file${attachments.length === 1 ? '' : 's'}.`); if (!attachments.length) return { text: prompt, displayText: prompt }; const paths = attachments.map((attachment) => attachment.path); const attachmentText = cli === 'gemini' ? paths.map((filePath) => `@${JSON.stringify(filePath)}`).join('\n') : `Attached files:\n${paths.map((filePath) => `- ${JSON.stringify(filePath)}`).join('\n')}`; return { text: prompt, displayText: `${prompt}\n\n${attachmentText}` }; } /** Upload in display order. Already-successful items are reused on retry. */ export async function uploadPendingAttachments( sessionId: string, attachments: PendingAttachment[], update: (key: string, patch: Partial) => void, ) { const uploaded: Attachment[] = []; let firstFailure: Error | null = null; const controllers = new Map(); for (const attachment of attachments) { if (attachment.attachment || attachmentFileError(attachment.file)) continue; const controller = new AbortController(); controllers.set(attachment.key, controller); update(attachment.key, { uploadController: controller }); } for (const attachment of attachments) { if (attachment.attachment) { uploaded.push(attachment.attachment); continue; } const invalid = attachmentFileError(attachment.file); if (invalid) { update(attachment.key, { status: 'error', error: invalid }); firstFailure ??= new Error(invalid); continue; } const controller = controllers.get(attachment.key)!; if (controller.signal.aborted) continue; update(attachment.key, { status: 'uploading', uploadedBytes: 0, error: undefined }); try { const stored = await api.uploadAttachment(sessionId, attachment.file, { signal: controller.signal, onProgress: ({ loaded }) => update(attachment.key, { uploadedBytes: loaded }), }); // An abort can race the final response: XHR may have settled while the // click that removed the chip already marked the controller aborted. // In that narrow window we learned the id, so remove the now-unsent file. if (controller.signal.aborted) { await api.deleteAttachment(sessionId, stored.id).catch(() => undefined); continue; } update(attachment.key, { status: 'uploaded', uploadedBytes: attachment.file.size, uploadController: undefined, attachment: stored, }); uploaded.push(stored); } catch (error) { if (controller.signal.aborted) { update(attachment.key, { uploadController: undefined }); continue; } const message = error instanceof Error ? error.message : 'upload failed'; update(attachment.key, { status: 'error', uploadController: undefined, error: message }); firstFailure ??= error instanceof Error ? error : new Error(message); } } if (firstFailure) throw firstFailure; return uploaded; }