From 6d7df59250d6a54559cfee488fb87b4bc8e4eb22 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 9 May 2026 01:49:19 +0300 Subject: [PATCH] feat(attachments): share renderer image intake --- .../team/dialogs/SendMessageDialog.tsx | 59 ++++++++++++------- src/renderer/hooks/useAttachments.ts | 17 ++++-- src/renderer/hooks/useComposerDraft.ts | 49 ++------------- src/renderer/utils/attachmentUtils.ts | 53 +++++++++++++++++ 4 files changed, 106 insertions(+), 72 deletions(-) diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index f241e23d..57e5712a 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -33,6 +33,10 @@ import { } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { + inferTeamProviderIdFromModel, + normalizeOptionalTeamProviderId, +} from '@shared/utils/teamProvider'; import { AlertCircle, Paperclip, Send, X } from 'lucide-react'; import { MemberBadge } from '../MemberBadge'; @@ -138,15 +142,23 @@ export const SendMessageDialog = ({ const selectedMember = members.find((m) => m.name === member); const isLeadRecipient = selectedMember ? isLeadMember(selectedMember) : false; + const selectedProviderId = + normalizeOptionalTeamProviderId(selectedMember?.providerId) ?? + inferTeamProviderIdFromModel(selectedMember?.model); + const isOpenCodeRecipient = selectedProviderId === 'opencode'; const hasTeammates = members.length > 1; const canDelegate = hasTeammates && isLeadRecipient; const shouldAutoDelegate = canDelegate; - const supportsAttachments = isLeadRecipient && !!isTeamAlive; + const supportsAttachments = !!isTeamAlive && (isLeadRecipient || isOpenCodeRecipient); const canAttach = supportsAttachments && canAddMore; const attachmentRestrictionReason = !supportsAttachments - ? !isLeadRecipient - ? 'Files can only be sent to the team lead' - : 'Team must be online to attach files' + ? !isTeamAlive + ? 'Team must be online to attach files' + : !isLeadRecipient && !isOpenCodeRecipient + ? 'Files can be sent to the team lead or OpenCode teammates' + : isOpenCodeRecipient + ? 'Team must be online to attach files for OpenCode teammates' + : 'Team must be online to attach files' : undefined; // Auto-switch to delegate when lead recipient is selected, but don't @@ -301,20 +313,9 @@ export const SendMessageDialog = ({ } }; - const handleFileInputChange = useCallback( - (e: React.ChangeEvent) => { - const input = e.target; - if (input.files?.length) { - void addFiles(input.files); - } - input.value = ''; - }, - [addFiles] - ); - const showFileRestrictionError = useCallback(() => { setFileRestrictionError( - attachmentRestrictionReason ?? 'Files can only be sent to the team lead' + attachmentRestrictionReason ?? 'Files can be sent to the team lead or OpenCode teammates' ); window.clearTimeout(fileRestrictionTimerRef.current); fileRestrictionTimerRef.current = window.setTimeout(() => { @@ -322,6 +323,22 @@ export const SendMessageDialog = ({ }, 4000); }, [attachmentRestrictionReason]); + const handleFileInputChange = useCallback( + (e: React.ChangeEvent) => { + const input = e.target; + if (input.files?.length) { + if (!canAttach) { + showFileRestrictionError(); + input.value = ''; + return; + } + void addFiles(input.files); + } + input.value = ''; + }, + [addFiles, canAttach, showFileRestrictionError] + ); + // Cleanup restriction error timer on unmount useEffect(() => { const ref = fileRestrictionTimerRef; @@ -441,11 +458,9 @@ export const SendMessageDialog = ({ - {!isTeamAlive - ? 'Team must be online to attach files' - : !canAddMore - ? 'Maximum attachments reached' - : 'Attach files (paste or drag & drop)'} + {canAttach + ? 'Attach files (paste or drag & drop)' + : (attachmentRestrictionReason ?? 'Attachments are unavailable')} @@ -458,7 +473,7 @@ export const SendMessageDialog = ({ error={attachmentError ?? fileRestrictionError} onDismissError={clearAttachmentError} disabled={attachmentsBlocked} - disabledHint="File attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient." + disabledHint="File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient." />
diff --git a/src/renderer/hooks/useAttachments.ts b/src/renderer/hooks/useAttachments.ts index 25a83497..b34777d2 100644 --- a/src/renderer/hooks/useAttachments.ts +++ b/src/renderer/hooks/useAttachments.ts @@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { draftStorage } from '@renderer/services/draftStorage'; import { - fileToAttachmentPayload, + fileToAgentAttachmentPayload, MAX_FILES, MAX_TOTAL_SIZE, validateAttachment, + validateOptimizedImageTotal, } from '@renderer/utils/attachmentUtils'; import { categorizeFile } from '@shared/constants/attachments'; @@ -189,7 +190,6 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR if (supported.length === 0) return; - let batchSize = 0; let valid = true; for (const file of supported) { const validation = validateAttachment(file); @@ -198,17 +198,16 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR valid = false; break; } - batchSize += file.size; } if (!valid) return; const newPayloads: AttachmentPayload[] = []; for (const file of supported) { try { - const payload = await fileToAttachmentPayload(file); + const payload = await fileToAgentAttachmentPayload(file); newPayloads.push(payload); - } catch { - setError(`Failed to read file: ${file.name}`); + } catch (error) { + setError(error instanceof Error ? error.message : `Failed to read file: ${file.name}`); valid = false; break; } @@ -221,10 +220,16 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR return prev; } const currentTotal = prev.reduce((sum, a) => sum + a.size, 0); + const batchSize = newPayloads.reduce((sum, a) => sum + a.size, 0); if (currentTotal + batchSize > MAX_TOTAL_SIZE) { setError('Total attachment size exceeds 20MB limit'); return prev; } + const optimizedImageTotal = validateOptimizedImageTotal([...prev, ...newPayloads]); + if (!optimizedImageTotal.valid) { + setError(optimizedImageTotal.error); + return prev; + } const next = [...prev, ...newPayloads]; attachmentsRef.current = next; schedulePersist(next); diff --git a/src/renderer/hooks/useComposerDraft.ts b/src/renderer/hooks/useComposerDraft.ts index f5a10e3d..7ac94876 100644 --- a/src/renderer/hooks/useComposerDraft.ts +++ b/src/renderer/hooks/useComposerDraft.ts @@ -18,14 +18,11 @@ import { composerDraftStorage, } from '@renderer/services/composerDraftStorage'; import { - DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET, - optimizeImageForAgent, -} from '@features/agent-attachments/renderer'; -import { - fileToAttachmentPayload, + fileToAgentAttachmentPayload, MAX_FILES, MAX_TOTAL_SIZE, validateAttachment, + validateOptimizedImageTotal, } from '@renderer/utils/attachmentUtils'; import { categorizeFile } from '@shared/constants/attachments'; @@ -109,40 +106,6 @@ function snapshotMatchesContent( ); } -function imageOutputFilename(filename: string, mimeType: 'image/png' | 'image/jpeg'): string { - const trimmed = filename.trim() || 'image'; - const withoutExtension = trimmed.replace(/\.[^.\\/]+$/, '') || 'image'; - return `${withoutExtension}.${mimeType === 'image/png' ? 'png' : 'jpg'}`; -} - -function blobToBase64(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const dataUrl = typeof reader.result === 'string' ? reader.result : ''; - resolve(dataUrl.split(',')[1] ?? ''); - }; - reader.onerror = () => reject(new Error('Failed to read optimized image')); - reader.readAsDataURL(blob); - }); -} - -async function fileToAgentAttachmentPayload(file: File): Promise { - const category = categorizeFile(file); - if (category !== 'image' || file.type === 'image/gif') { - return fileToAttachmentPayload(file); - } - - const optimized = await optimizeImageForAgent({ file }); - return { - id: crypto.randomUUID(), - filename: imageOutputFilename(file.name, optimized.optimized.mimeType), - mimeType: optimized.optimized.mimeType, - size: optimized.optimized.sizeBytes, - data: await blobToBase64(optimized.optimized.blob), - }; -} - // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- @@ -473,11 +436,9 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult { setAttachmentError('Total attachment size exceeds 20MB limit'); return; } - const optimizedImageBytes = [...prev, ...newPayloads] - .filter((attachment) => attachment.mimeType.startsWith('image/')) - .reduce((sum, attachment) => sum + attachment.size, 0); - if (optimizedImageBytes > DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET.maxOutputBytesTotal) { - setAttachmentError('Optimized image attachments exceed the safe runtime size limit'); + const optimizedImageTotal = validateOptimizedImageTotal([...prev, ...newPayloads]); + if (!optimizedImageTotal.valid) { + setAttachmentError(optimizedImageTotal.error); return; } diff --git a/src/renderer/utils/attachmentUtils.ts b/src/renderer/utils/attachmentUtils.ts index 9b23efcf..a0f78918 100644 --- a/src/renderer/utils/attachmentUtils.ts +++ b/src/renderer/utils/attachmentUtils.ts @@ -1,3 +1,7 @@ +import { + DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET, + optimizeImageForAgent, +} from '@features/agent-attachments/renderer'; import { categorizeFile, getEffectiveMimeType, isImageMime } from '@shared/constants/attachments'; import type { AttachmentPayload, ImageMimeType } from '@shared/types'; @@ -51,6 +55,55 @@ export async function fileToAttachmentPayload(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = typeof reader.result === 'string' ? reader.result : ''; + resolve(dataUrl.split(',')[1] ?? ''); + }; + reader.onerror = () => reject(new Error('Failed to read optimized image')); + reader.readAsDataURL(blob); + }); +} + +export async function fileToAgentAttachmentPayload(file: File): Promise { + const category = categorizeFile(file); + if (category !== 'image' || file.type === 'image/gif') { + return fileToAttachmentPayload(file); + } + + const optimized = await optimizeImageForAgent({ file }); + return { + id: crypto.randomUUID(), + filename: imageOutputFilename(file.name, optimized.optimized.mimeType), + mimeType: optimized.optimized.mimeType, + size: optimized.optimized.sizeBytes, + data: await blobToBase64(optimized.optimized.blob), + }; +} + +export function validateOptimizedImageTotal( + attachments: AttachmentPayload[] +): { valid: true } | { valid: false; error: string } { + const optimizedImageBytes = attachments + .filter((attachment) => attachment.mimeType.startsWith('image/')) + .reduce((sum, attachment) => sum + attachment.size, 0); + if (optimizedImageBytes <= DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET.maxOutputBytesTotal) { + return { valid: true }; + } + return { + valid: false, + error: 'Optimized image attachments exceed the safe runtime size limit', + }; +} + export { categorizeFile, isImageMime }; export function formatFileSize(bytes: number): string {