feat(attachments): share renderer image intake
This commit is contained in:
parent
1960c5d29e
commit
6d7df59250
4 changed files with 106 additions and 72 deletions
|
|
@ -33,6 +33,10 @@ 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 {
|
||||||
|
inferTeamProviderIdFromModel,
|
||||||
|
normalizeOptionalTeamProviderId,
|
||||||
|
} from '@shared/utils/teamProvider';
|
||||||
import { AlertCircle, Paperclip, Send, X } from 'lucide-react';
|
import { AlertCircle, Paperclip, Send, X } from 'lucide-react';
|
||||||
|
|
||||||
import { MemberBadge } from '../MemberBadge';
|
import { MemberBadge } from '../MemberBadge';
|
||||||
|
|
@ -138,15 +142,23 @@ export const SendMessageDialog = ({
|
||||||
|
|
||||||
const selectedMember = members.find((m) => m.name === member);
|
const selectedMember = members.find((m) => m.name === member);
|
||||||
const isLeadRecipient = selectedMember ? isLeadMember(selectedMember) : false;
|
const isLeadRecipient = selectedMember ? isLeadMember(selectedMember) : false;
|
||||||
|
const selectedProviderId =
|
||||||
|
normalizeOptionalTeamProviderId(selectedMember?.providerId) ??
|
||||||
|
inferTeamProviderIdFromModel(selectedMember?.model);
|
||||||
|
const isOpenCodeRecipient = selectedProviderId === 'opencode';
|
||||||
const hasTeammates = members.length > 1;
|
const hasTeammates = members.length > 1;
|
||||||
const canDelegate = hasTeammates && isLeadRecipient;
|
const canDelegate = hasTeammates && isLeadRecipient;
|
||||||
const shouldAutoDelegate = canDelegate;
|
const shouldAutoDelegate = canDelegate;
|
||||||
const supportsAttachments = isLeadRecipient && !!isTeamAlive;
|
const supportsAttachments = !!isTeamAlive && (isLeadRecipient || isOpenCodeRecipient);
|
||||||
const canAttach = supportsAttachments && canAddMore;
|
const canAttach = supportsAttachments && canAddMore;
|
||||||
const attachmentRestrictionReason = !supportsAttachments
|
const attachmentRestrictionReason = !supportsAttachments
|
||||||
? !isLeadRecipient
|
? !isTeamAlive
|
||||||
? 'Files can only be sent to the team lead'
|
? 'Team must be online to attach files'
|
||||||
: '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;
|
: undefined;
|
||||||
|
|
||||||
// Auto-switch to delegate when lead recipient is selected, but don't
|
// 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(() => {
|
const showFileRestrictionError = useCallback(() => {
|
||||||
setFileRestrictionError(
|
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);
|
window.clearTimeout(fileRestrictionTimerRef.current);
|
||||||
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
||||||
|
|
@ -322,6 +323,22 @@ export const SendMessageDialog = ({
|
||||||
}, 4000);
|
}, 4000);
|
||||||
}, [attachmentRestrictionReason]);
|
}, [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
|
// Cleanup restriction error timer on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ref = fileRestrictionTimerRef;
|
const ref = fileRestrictionTimerRef;
|
||||||
|
|
@ -441,11 +458,9 @@ export const SendMessageDialog = ({
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
{!isTeamAlive
|
{canAttach
|
||||||
? 'Team must be online to attach files'
|
? 'Attach files (paste or drag & drop)'
|
||||||
: !canAddMore
|
: (attachmentRestrictionReason ?? 'Attachments are unavailable')}
|
||||||
? 'Maximum attachments reached'
|
|
||||||
: 'Attach files (paste or drag & drop)'}
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
|
|
@ -458,7 +473,7 @@ export const SendMessageDialog = ({
|
||||||
error={attachmentError ?? fileRestrictionError}
|
error={attachmentError ?? fileRestrictionError}
|
||||||
onDismissError={clearAttachmentError}
|
onDismissError={clearAttachmentError}
|
||||||
disabled={attachmentsBlocked}
|
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'}>
|
<div className={quote ? 'flex flex-col' : 'contents'}>
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { draftStorage } from '@renderer/services/draftStorage';
|
import { draftStorage } from '@renderer/services/draftStorage';
|
||||||
import {
|
import {
|
||||||
fileToAttachmentPayload,
|
fileToAgentAttachmentPayload,
|
||||||
MAX_FILES,
|
MAX_FILES,
|
||||||
MAX_TOTAL_SIZE,
|
MAX_TOTAL_SIZE,
|
||||||
validateAttachment,
|
validateAttachment,
|
||||||
|
validateOptimizedImageTotal,
|
||||||
} from '@renderer/utils/attachmentUtils';
|
} from '@renderer/utils/attachmentUtils';
|
||||||
import { categorizeFile } from '@shared/constants/attachments';
|
import { categorizeFile } from '@shared/constants/attachments';
|
||||||
|
|
||||||
|
|
@ -189,7 +190,6 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
|
|
||||||
if (supported.length === 0) return;
|
if (supported.length === 0) return;
|
||||||
|
|
||||||
let batchSize = 0;
|
|
||||||
let valid = true;
|
let valid = true;
|
||||||
for (const file of supported) {
|
for (const file of supported) {
|
||||||
const validation = validateAttachment(file);
|
const validation = validateAttachment(file);
|
||||||
|
|
@ -198,17 +198,16 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
valid = false;
|
valid = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
batchSize += file.size;
|
|
||||||
}
|
}
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
const newPayloads: AttachmentPayload[] = [];
|
const newPayloads: AttachmentPayload[] = [];
|
||||||
for (const file of supported) {
|
for (const file of supported) {
|
||||||
try {
|
try {
|
||||||
const payload = await fileToAttachmentPayload(file);
|
const payload = await fileToAgentAttachmentPayload(file);
|
||||||
newPayloads.push(payload);
|
newPayloads.push(payload);
|
||||||
} catch {
|
} catch (error) {
|
||||||
setError(`Failed to read file: ${file.name}`);
|
setError(error instanceof Error ? error.message : `Failed to read file: ${file.name}`);
|
||||||
valid = false;
|
valid = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -221,10 +220,16 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const currentTotal = prev.reduce((sum, a) => sum + a.size, 0);
|
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) {
|
if (currentTotal + batchSize > MAX_TOTAL_SIZE) {
|
||||||
setError('Total attachment size exceeds 20MB limit');
|
setError('Total attachment size exceeds 20MB limit');
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
const optimizedImageTotal = validateOptimizedImageTotal([...prev, ...newPayloads]);
|
||||||
|
if (!optimizedImageTotal.valid) {
|
||||||
|
setError(optimizedImageTotal.error);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
const next = [...prev, ...newPayloads];
|
const next = [...prev, ...newPayloads];
|
||||||
attachmentsRef.current = next;
|
attachmentsRef.current = next;
|
||||||
schedulePersist(next);
|
schedulePersist(next);
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,11 @@ import {
|
||||||
composerDraftStorage,
|
composerDraftStorage,
|
||||||
} from '@renderer/services/composerDraftStorage';
|
} from '@renderer/services/composerDraftStorage';
|
||||||
import {
|
import {
|
||||||
DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET,
|
fileToAgentAttachmentPayload,
|
||||||
optimizeImageForAgent,
|
|
||||||
} from '@features/agent-attachments/renderer';
|
|
||||||
import {
|
|
||||||
fileToAttachmentPayload,
|
|
||||||
MAX_FILES,
|
MAX_FILES,
|
||||||
MAX_TOTAL_SIZE,
|
MAX_TOTAL_SIZE,
|
||||||
validateAttachment,
|
validateAttachment,
|
||||||
|
validateOptimizedImageTotal,
|
||||||
} from '@renderer/utils/attachmentUtils';
|
} from '@renderer/utils/attachmentUtils';
|
||||||
import { categorizeFile } from '@shared/constants/attachments';
|
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
|
// Hook
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -473,11 +436,9 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
|
||||||
setAttachmentError('Total attachment size exceeds 20MB limit');
|
setAttachmentError('Total attachment size exceeds 20MB limit');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const optimizedImageBytes = [...prev, ...newPayloads]
|
const optimizedImageTotal = validateOptimizedImageTotal([...prev, ...newPayloads]);
|
||||||
.filter((attachment) => attachment.mimeType.startsWith('image/'))
|
if (!optimizedImageTotal.valid) {
|
||||||
.reduce((sum, attachment) => sum + attachment.size, 0);
|
setAttachmentError(optimizedImageTotal.error);
|
||||||
if (optimizedImageBytes > DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET.maxOutputBytesTotal) {
|
|
||||||
setAttachmentError('Optimized image attachments exceed the safe runtime size limit');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { categorizeFile, getEffectiveMimeType, isImageMime } from '@shared/constants/attachments';
|
||||||
|
|
||||||
import type { AttachmentPayload, ImageMimeType } from '@shared/types';
|
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 { categorizeFile, isImageMime };
|
||||||
|
|
||||||
export function formatFileSize(bytes: number): string {
|
export function formatFileSize(bytes: number): string {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue