diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 4f632af4..1c95b763 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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[] => { const mentions: string[] = []; INLINE_SKILL_TOKEN_PATTERN.lastIndex = 0; @@ -949,6 +990,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const skipNextDraftPersistRef = React.useRef(false); const lastPersistedDraftRef = React.useRef>(new Map()); const currentSessionIdForDraftRef = React.useRef(null); + const pendingPastedAttachmentFilenamesRef = React.useRef>(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 = ({ onOpenSettings, scrollTo return ranges; }, [inputMode, message, knownAgentNames]); + const attachmentCitationRanges = React.useMemo(() => { + 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 = ({ 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 = ({ 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 }) => { diff --git a/packages/ui/src/components/chat/__tests__/attachmentCitations.test.ts b/packages/ui/src/components/chat/__tests__/attachmentCitations.test.ts new file mode 100644 index 00000000..96221b61 --- /dev/null +++ b/packages/ui/src/components/chat/__tests__/attachmentCitations.test.ts @@ -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 }]); + }); +}); diff --git a/packages/ui/src/components/chat/attachmentCitations.ts b/packages/ui/src/components/chat/attachmentCitations.ts new file mode 100644 index 00000000..1faf6925 --- /dev/null +++ b/packages/ui/src/components/chat/attachmentCitations.ts @@ -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 = { + '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 => { + 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 => { + 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; +};