feat: add image attachment citations
Names pasted images predictably when needed Inserts bracket citations at the cursor Highlights citations only for pending attachments
This commit is contained in:
@@ -79,6 +79,11 @@ import {
|
||||
type MentionRange,
|
||||
} from './composerHighlight';
|
||||
import { highlightFencedCode } from './composerCodeHighlight';
|
||||
import {
|
||||
assignImageAttachmentFilenames,
|
||||
buildAttachmentCitationText,
|
||||
findAttachmentCitationRanges,
|
||||
} from './attachmentCitations';
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||
@@ -99,6 +104,42 @@ const VS_CODE_DROP_DATA_TYPES = [
|
||||
'text/plain',
|
||||
];
|
||||
|
||||
const renameFileForAttachmentCitation = (file: File, filename: string): File => {
|
||||
if (file.name === filename) {
|
||||
return file;
|
||||
}
|
||||
|
||||
return new File([file], filename, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
});
|
||||
};
|
||||
|
||||
const buildImagePasteInsertion = (pastedText: string, citationText: string): string => {
|
||||
const text = pastedText;
|
||||
if (!text) {
|
||||
return citationText;
|
||||
}
|
||||
return `${text}${/\s$/.test(text) ? '' : ' '}${citationText}`;
|
||||
};
|
||||
|
||||
const withInlineInsertionBoundaries = (content: string, before: string, after: string): string => {
|
||||
if (!content) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const needsLeadingSpace = before.length > 0
|
||||
&& !/\s$/.test(before)
|
||||
&& !/^\s/.test(content)
|
||||
&& !/[([{]$/.test(before);
|
||||
const needsTrailingSpace = after.length > 0
|
||||
&& !/\s$/.test(content)
|
||||
&& !/^\s/.test(after)
|
||||
&& !/^[\])}.,;:!?]/.test(after);
|
||||
|
||||
return `${needsLeadingSpace ? ' ' : ''}${content}${needsTrailingSpace ? ' ' : ''}`;
|
||||
};
|
||||
|
||||
const collectInlineSkillMentions = (text: string, skillNames: Set<string>): string[] => {
|
||||
const mentions: string[] = [];
|
||||
INLINE_SKILL_TOKEN_PATTERN.lastIndex = 0;
|
||||
@@ -949,6 +990,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const skipNextDraftPersistRef = React.useRef(false);
|
||||
const lastPersistedDraftRef = React.useRef<Map<string, string>>(new Map());
|
||||
const currentSessionIdForDraftRef = React.useRef<string | null>(null);
|
||||
const pendingPastedAttachmentFilenamesRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
// TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -1159,6 +1201,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
return ranges;
|
||||
}, [inputMode, message, knownAgentNames]);
|
||||
|
||||
const attachmentCitationRanges = React.useMemo<HighlightRange[]>(() => {
|
||||
if (!message || !message.includes('[') || inputMode === 'shell' || sendableAttachedFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return findAttachmentCitationRanges(
|
||||
message,
|
||||
sendableAttachedFiles.map((file) => file.filename),
|
||||
).map((range) => ({
|
||||
...range,
|
||||
style: 'mentionFile' as const,
|
||||
}));
|
||||
}, [inputMode, message, sendableAttachedFiles]);
|
||||
|
||||
// Combined source-mode highlight: markdown syntax + @mentions. Returns null
|
||||
// when there's nothing to highlight so the overlay stays off for plain text.
|
||||
const highlightedComposerContent = React.useMemo(() => {
|
||||
@@ -1171,9 +1227,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
...mentionRangesToHighlightRanges(composerMentionRanges),
|
||||
...composerCommandRanges,
|
||||
...composerSnippetRanges,
|
||||
...attachmentCitationRanges,
|
||||
];
|
||||
return buildHighlightParts(message, ranges);
|
||||
}, [composerCommandRanges, composerSnippetRanges, composerMentionRanges, inputMode, message]);
|
||||
}, [attachmentCitationRanges, composerCommandRanges, composerSnippetRanges, composerMentionRanges, inputMode, message]);
|
||||
|
||||
const sanitizeAttachmentsForSend = React.useCallback(
|
||||
(files: AttachedFile[] | undefined): AttachedFile[] => (files ?? [])
|
||||
@@ -2787,19 +2844,39 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
e.preventDefault();
|
||||
|
||||
const pastedText = e.clipboardData.getData('text');
|
||||
if (pastedText) {
|
||||
insertTextAtSelection(pastedText);
|
||||
}
|
||||
const assignedFilenames = assignImageAttachmentFilenames(
|
||||
imageFiles,
|
||||
[
|
||||
...attachedFiles.map((file) => file.filename),
|
||||
...pendingPastedAttachmentFilenamesRef.current,
|
||||
],
|
||||
);
|
||||
const citationText = buildAttachmentCitationText(assignedFilenames);
|
||||
const textarea = textareaRef.current;
|
||||
const selectionStart = textarea?.selectionStart ?? message.length;
|
||||
const selectionEnd = textarea?.selectionEnd ?? message.length;
|
||||
const insertionText = withInlineInsertionBoundaries(
|
||||
buildImagePasteInsertion(pastedText, citationText),
|
||||
message.slice(0, selectionStart),
|
||||
message.slice(selectionEnd),
|
||||
);
|
||||
|
||||
for (const file of imageFiles) {
|
||||
insertTextAtSelection(insertionText);
|
||||
|
||||
for (let index = 0; index < imageFiles.length; index += 1) {
|
||||
const filename = assignedFilenames[index];
|
||||
const file = renameFileForAttachmentCitation(imageFiles[index], filename);
|
||||
pendingPastedAttachmentFilenamesRef.current.add(filename);
|
||||
try {
|
||||
await addAttachedFile(file);
|
||||
} catch (error) {
|
||||
console.error('Clipboard image attach failed', error);
|
||||
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.clipboardAttachFailed'));
|
||||
} finally {
|
||||
pendingPastedAttachmentFilenamesRef.current.delete(filename);
|
||||
}
|
||||
}
|
||||
}, [addAttachedFile, adjustTextareaHeight, currentSessionId, inputMode, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]);
|
||||
}, [addAttachedFile, attachedFiles, adjustTextareaHeight, currentSessionId, inputMode, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]);
|
||||
|
||||
const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => {
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
assignImageAttachmentFilenames,
|
||||
buildAttachmentCitationText,
|
||||
findAttachmentCitationRanges,
|
||||
isGenericImageFilename,
|
||||
} from '../attachmentCitations';
|
||||
|
||||
describe('attachment citations', () => {
|
||||
test('keeps meaningful image names', () => {
|
||||
expect(assignImageAttachmentFilenames([
|
||||
{ name: 'desktop_without_icons.jpg', type: 'image/jpeg' },
|
||||
], [])).toEqual(['desktop_without_icons.jpg']);
|
||||
});
|
||||
|
||||
test('renames generic clipboard image names', () => {
|
||||
expect(assignImageAttachmentFilenames([
|
||||
{ name: 'image.png', type: 'image/png' },
|
||||
{ name: 'Screenshot.png', type: 'image/png' },
|
||||
], [])).toEqual(['image-1.png', 'image-2.png']);
|
||||
});
|
||||
|
||||
test('deduplicates meaningful names inside pending attachments', () => {
|
||||
expect(assignImageAttachmentFilenames([
|
||||
{ name: 'desktop.jpg', type: 'image/jpeg' },
|
||||
{ name: 'desktop.jpg', type: 'image/jpeg' },
|
||||
], ['desktop-2.jpg'])).toEqual(['desktop.jpg', 'desktop-3.jpg']);
|
||||
});
|
||||
|
||||
test('continues generated image indexes from existing pending attachments', () => {
|
||||
expect(assignImageAttachmentFilenames([
|
||||
{ name: 'image.png', type: 'image/png' },
|
||||
{ name: '', type: 'image/webp' },
|
||||
], ['image-1.png'])).toEqual(['image-2.png', 'image-3.webp']);
|
||||
});
|
||||
|
||||
test('detects generic names narrowly', () => {
|
||||
expect(isGenericImageFilename('image.png')).toBe(true);
|
||||
expect(isGenericImageFilename('Screenshot (1).png')).toBe(true);
|
||||
expect(isGenericImageFilename('Screen Shot.png')).toBe(true);
|
||||
expect(isGenericImageFilename('Screenshot 2026-05-24.png')).toBe(false);
|
||||
expect(isGenericImageFilename('desktop_without_icons.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
test('builds bracket citations', () => {
|
||||
expect(buildAttachmentCitationText(['desktop.jpg', 'icon.png'])).toBe('[desktop.jpg] [icon.png]');
|
||||
});
|
||||
|
||||
test('finds active attachment citation ranges', () => {
|
||||
expect(findAttachmentCitationRanges(
|
||||
'desktop [desktop.jpg] link [desktop.jpg](https://example.com) missing [other.jpg]',
|
||||
['desktop.jpg'],
|
||||
)).toEqual([{ start: 8, end: 21 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
export interface ImageAttachmentCandidate {
|
||||
name: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface CitationRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const GENERIC_IMAGE_BASENAMES = new Set([
|
||||
'image',
|
||||
'screenshot',
|
||||
'screen-shot',
|
||||
'clipboard',
|
||||
'pasted-image',
|
||||
'pastedimage',
|
||||
'untitled',
|
||||
'unknown',
|
||||
'file',
|
||||
'blob',
|
||||
]);
|
||||
|
||||
const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
|
||||
'image/avif': 'avif',
|
||||
'image/bmp': 'bmp',
|
||||
'image/gif': 'gif',
|
||||
'image/heic': 'heic',
|
||||
'image/heif': 'heif',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/svg+xml': 'svg',
|
||||
'image/tiff': 'tiff',
|
||||
'image/webp': 'webp',
|
||||
};
|
||||
|
||||
const normalizeFilenameKey = (filename: string): string => filename.trim().toLowerCase();
|
||||
|
||||
const isUnsafeFilenameChar = (char: string): boolean => (
|
||||
char.charCodeAt(0) < 32 || '<>:"/\\|?*[]'.includes(char)
|
||||
);
|
||||
|
||||
const sanitizeFilename = (name: string): string => {
|
||||
const basename = name.replace(/\\/g, '/').split('/').pop() ?? '';
|
||||
return Array.from(basename)
|
||||
.map((char) => (isUnsafeFilenameChar(char) ? '-' : char))
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const getMimeExtension = (mimeType?: string): string => {
|
||||
const normalized = mimeType?.trim().toLowerCase() ?? '';
|
||||
return IMAGE_MIME_EXTENSIONS[normalized] ?? 'png';
|
||||
};
|
||||
|
||||
const splitImageFilename = (candidate: ImageAttachmentCandidate): { base: string; ext: string } => {
|
||||
const clean = sanitizeFilename(candidate.name);
|
||||
const fallbackExt = getMimeExtension(candidate.type);
|
||||
const lastDot = clean.lastIndexOf('.');
|
||||
|
||||
if (lastDot > 0 && lastDot < clean.length - 1) {
|
||||
const rawExt = clean.slice(lastDot + 1).toLowerCase();
|
||||
if (/^[a-z0-9]{1,10}$/.test(rawExt)) {
|
||||
return {
|
||||
base: clean.slice(0, lastDot).trim() || 'image',
|
||||
ext: rawExt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
base: clean.trim() || 'image',
|
||||
ext: fallbackExt,
|
||||
};
|
||||
};
|
||||
|
||||
export const isGenericImageFilename = (filename: string): boolean => {
|
||||
const { base } = splitImageFilename({ name: filename });
|
||||
const normalized = base
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
if (GENERIC_IMAGE_BASENAMES.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const withoutCopyCounter = normalized.replace(/-\(\d+\)$/g, '');
|
||||
if (withoutCopyCounter !== normalized && GENERIC_IMAGE_BASENAMES.has(withoutCopyCounter)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /^(image|file|unknown|untitled|blob)-\d+$/.test(normalized);
|
||||
};
|
||||
|
||||
const withExtension = (base: string, ext: string): string => `${base}.${ext}`;
|
||||
|
||||
const nextUniqueFilename = (base: string, ext: string, used: Set<string>): string => {
|
||||
const first = withExtension(base, ext);
|
||||
if (!used.has(normalizeFilenameKey(first))) {
|
||||
return first;
|
||||
}
|
||||
|
||||
for (let index = 2; index < Number.MAX_SAFE_INTEGER; index += 1) {
|
||||
const candidate = withExtension(`${base}-${index}`, ext);
|
||||
if (!used.has(normalizeFilenameKey(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return withExtension(`${base}-${Date.now()}`, ext);
|
||||
};
|
||||
|
||||
const nextGeneratedImageFilename = (ext: string, used: Set<string>): string => {
|
||||
for (let index = 1; index < Number.MAX_SAFE_INTEGER; index += 1) {
|
||||
const candidate = withExtension(`image-${index}`, ext);
|
||||
const generatedBaseTaken = Array.from(used).some((filename) => filename.startsWith(`image-${index}.`));
|
||||
if (!generatedBaseTaken && !used.has(normalizeFilenameKey(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return withExtension(`image-${Date.now()}`, ext);
|
||||
};
|
||||
|
||||
export const assignImageAttachmentFilenames = (
|
||||
files: ImageAttachmentCandidate[],
|
||||
existingFilenames: string[],
|
||||
): string[] => {
|
||||
const used = new Set(existingFilenames.map(normalizeFilenameKey));
|
||||
|
||||
return files.map((file) => {
|
||||
const { base, ext } = splitImageFilename(file);
|
||||
const filename = isGenericImageFilename(withExtension(base, ext))
|
||||
? nextGeneratedImageFilename(ext, used)
|
||||
: nextUniqueFilename(base, ext, used);
|
||||
used.add(normalizeFilenameKey(filename));
|
||||
return filename;
|
||||
});
|
||||
};
|
||||
|
||||
export const buildAttachmentCitationText = (filenames: string[]): string => (
|
||||
filenames.map((filename) => `[${filename}]`).join(' ')
|
||||
);
|
||||
|
||||
export const findAttachmentCitationRanges = (text: string, filenames: string[]): CitationRange[] => {
|
||||
if (!text || !text.includes('[') || filenames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const known = new Set(filenames.map(normalizeFilenameKey));
|
||||
const ranges: CitationRange[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < text.length) {
|
||||
const start = text.indexOf('[', cursor);
|
||||
if (start === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const end = text.indexOf(']', start + 1);
|
||||
if (end === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Markdown links keep their normal link highlighting; attachment citations
|
||||
// are plain bracket references like [desktop.png].
|
||||
if (text[end + 1] !== '(') {
|
||||
const name = text.slice(start + 1, end).trim();
|
||||
if (known.has(normalizeFilenameKey(name))) {
|
||||
ranges.push({ start, end: end + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
cursor = end + 1;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
};
|
||||
Reference in New Issue
Block a user