feat(attachments): share renderer image intake

This commit is contained in:
777genius 2026-05-09 01:49:19 +03:00
parent 1960c5d29e
commit 6d7df59250
4 changed files with 106 additions and 72 deletions

View file

@ -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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 = ({
</button>
</TooltipTrigger>
<TooltipContent side="top">
{!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')}
</TooltipContent>
</Tooltip>
</>
@ -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."
/>
<div className={quote ? 'flex flex-col' : 'contents'}>

View file

@ -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);

View file

@ -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<string> {
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<AttachmentPayload> {
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;
}

View file

@ -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<AttachmentPay
});
}
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<string> {
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<AttachmentPayload> {
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 {