diff --git a/agent/context_references.py b/agent/context_references.py index dc03ccd740b..6307033d270 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -246,7 +246,14 @@ def _expand_file_reference( if not path.is_file(): return f"{ref.raw}: path is not a file", None if _is_binary_file(path): - return f"{ref.raw}: binary files are not supported", None + # A binary file can't be inlined as text, but it IS on disk (the agent's + # tools run where this resolves — the local cwd, or the staged copy in a + # remote session workspace). Returning a bare "not supported" warning + # with no content was a dead end: the model saw a failure and gave up + # (told the user the file type wasn't supported). Instead, hand it an + # actionable block — the path, type, size, and a nudge to use its tools — + # so it can read/convert/view the file itself. + return None, _binary_reference_block(ref, path) text = path.read_text(encoding="utf-8") if ref.line_start is not None: @@ -493,6 +500,30 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None: return files[:limit] +def _human_bytes(n: int) -> str: + size = float(n) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024 or unit == "GB": + return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} GB" + + +def _binary_reference_block(ref: ContextReference, path: Path) -> str: + mime, _ = mimetypes.guess_type(path.name) + mime = mime or "application/octet-stream" + try: + size = _human_bytes(path.stat().st_size) + except OSError: + size = "unknown size" + return ( + f"📎 {ref.raw} ({mime}, {size}) — binary file, not inlined as text. " + f"It is available on disk at `{path}`. Use your tools to work with it " + f"(read or convert it, extract its text, or view/render it as needed); " + f"do not tell the user the file type is unsupported." + ) + + def _file_metadata(path: Path) -> str: if _is_binary_file(path): return f"{path.stat().st_size} bytes" diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx index 0c154a8a4b1..6229c9da8bd 100644 --- a/apps/desktop/src/app/chat/composer/attachments.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.tsx @@ -3,8 +3,9 @@ import { useStore } from '@nanostores/react' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' -import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons' +import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' +import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' import { notifyError } from '@/store/notifications' import { setCurrentSessionPreviewTarget } from '@/store/preview' @@ -31,7 +32,9 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme const c = t.composer const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind] const cwd = useStore($currentCwd) - const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' + const isUploading = attachment.uploadState === 'uploading' + const hasUploadError = attachment.uploadState === 'error' + const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined async function openPreview() { @@ -59,7 +62,15 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme throw new Error(c.couldNotPreview(attachment.label)) } - setCurrentSessionPreviewTarget(preview, 'manual', target) + // We already hold the image bytes (the card thumbnail) — render those + // directly so a screenshot/clipboard image previews even when its only + // on-disk copy is a transient path the renderer can't re-read. + const withBytes = + attachment.kind === 'image' && attachment.previewUrl + ? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const } + : preview + + setCurrentSessionPreviewTarget(withBytes, 'manual', target) } catch (error) { notifyError(error, c.previewUnavailable) } @@ -69,30 +80,51 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme