Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/plans/2026-09-27-image-paste-unsupported-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Pasting an image into an agent that cannot take one says so (#1250 row 7)

Short plan: a bug with a known root cause. The row comes from `temp/quality-loop/hunt-c3.md` (row 7, P3). #1250 is a batch issue, so this PR is `Refs #1250`.

## Outcome
Only Claude takes pasted images today (`supportsImageAttachments`). In a Codex, OpenCode, Grok or Pi composer, pasting a screenshot does nothing at all. The user now sees "<Provider> can't take pasted images." when the clipboard holds an image file and no text.

## Root cause (verified in source, origin/main)
- `useClaudeImagePaste.handlePaste` returns `{ handledImages: false }` at once for a provider without image support. The textarea's default paste of an image-only clipboard inserts nothing, and `usePasteToFocus` appends only text. Nothing is said.

## Design (contract)
- In that early return, read `clipboardData` synchronously. If it has an `image/*` ITEM and no `text/plain`, show a toast (through the hook's `showToast`, the pane toast): "<shortLabel> can't take pasted images."
- **Only an image FILE item with no text counts.** Copying from a web page often carries `<img>` in `text/html` alongside text, and that text paste must stay silent. A mixed image+text paste pastes the text as before, with no toast.
- It still returns `handledImages: false`, so the text routing is unchanged.

## Tests
New `useClaudeImagePaste.renderer.test.tsx` renders the real hook, with a real PNG from `testing/fixtures/image-reads` placed in a `DataTransfer`.
- Codex, image only: the toast shows. Red on main.
- Codex, image plus text: no toast (the text pastes).
- Codex, text only: no toast.
- Claude, image only: no such toast, and the image is taken (`handledImages: true`, draft images set).

## Out of scope
- #1250's other rows.
- Image support for other providers.

## Review round 1 (a, b: FIX-BEFORE-MERGE)
- **b1: an image FILE plus text dropped the image silently** ("see attached" with no attachment). The text still pastes, and the toast says "…only the text was pasted." A file item is strong evidence; only an HTML `<img>` beside text (a web-page copy) stays silent.
- **a2 / b2: an image arriving only as a data-URL `<img>` in text/html, with no text, was silent.** It now counts.
- **a3: an image only the async clipboard API shows was silent.** With nothing else in the event (no text, no HTML, no items), `readImagesFromClipboard` is probed. Paste-to-focus's documented miss of this shape is unchanged (it never calls the hook for it).
- **b3: the wording blamed the provider.** Grok's own TUI attaches clipboard images. The sentence is now about this composer: "Pasted images can't be sent to <Provider> from this composer."
- **a1 (terminal view): declined.** There the paste goes to the provider's own TUI, which owns image handling (Grok's attaches images); the app's capability flag is about its composer.
- **a4: every provider's wording is tested** (Codex, OpenCode, Grok, Pi).
- `parseImagesFromHtml` uses `querySelectorAll('img')` instead of `doc.images`. It is identical in a browser, and the happy-dom test DOM lacks `images` on a parsed document.
- **c (MERGE-READY, reviewing the round-0 head; test gaps pinned):** a non-image file, a type-less file, a string item typed `image/*`, and a null `clipboardData` stay silent. The Claude case asserts the image is taken. The provider label is covered by the per-provider cases.
- **Verification a (Major): an async-only image via paste-to-focus stayed silent.** That path decided "no image" synchronously and appended empty text. An EMPTY event (no text, no HTML, no items) now goes to the shared handler, which probes it; this also lets Claude attach such an image. Ordinary text never takes the branch, so it is never delayed. Test red on the previous head.
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ export function parseImagesFromHtml(html: string): ClaudeDraftImage[] {
// the HTML is never injected into the live DOM.
const doc = new DOMParser().parseFromString(html, 'text/html')
const images: ClaudeDraftImage[] = []
for (const img of Array.from(doc.images)) {
// querySelectorAll, not `doc.images`: identical in a browser, and the
// happy-dom test DOM has no `images` collection on a parsed document.
for (const img of Array.from(doc.querySelectorAll('img'))) {
const src = img.getAttribute('src')?.trim() ?? ''
if (!src.startsWith('data:image/')) continue
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { readFileSync } from 'node:fs'

import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { useClaudeImagePaste } from './useClaudeImagePaste'

// #1250 row 7: only Claude's composer takes pasted images. A screenshot
// pasted into a Codex, OpenCode, Grok or Pi composer was dropped with nothing
// said.
//
// The REAL hook with the real provider capabilities. The image bytes are a
// RECORDED user attachment (testing/fixtures/image-reads/claude-user-attachment.json);
// the paste event's clipboardData, and navigator.clipboard.read for the async
// case, are the stubbed edges, shaped as the browser gives them.

const recorded = JSON.parse(readFileSync('testing/fixtures/image-reads/claude-user-attachment.json', 'utf8')) as {
entry: { message: { content: Array<{ source?: { data: string; media_type: string } }> } }
}
const source = recorded.entry.message.content[1]!.source!
const png = new File([Buffer.from(source.data, 'base64')], 'screenshot.png', { type: source.media_type })
const dataUrlImg = `<img src="data:${source.media_type};base64,${source.data}">`

const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
afterEach(() => {
if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard)
else Reflect.deleteProperty(navigator, 'clipboard')
})

function paste(options: { image?: boolean; text?: string; html?: string; items?: Array<{ kind: string; type: string }> }) {
const items = options.items ?? (options.image ? [{ kind: 'file', type: png.type, getAsFile: () => png }] : [])
return {
clipboardData: {
items,
getData: (type: string) => (type === 'text/plain' ? options.text ?? '' : type === 'text/html' ? options.html ?? '' : ''),
} as unknown as DataTransfer,
preventDefault: vi.fn(),
}
}

function hook(provider: 'codex' | 'opencode' | 'grok' | 'pi' | 'claude') {
const showToast = vi.fn()
const setDraftImages = vi.fn()
const { result } = renderHook(() => useClaudeImagePaste({ provider, sessionId: 's1' as never, setDraftImages, showToast }))
return { handlePaste: result.current.handlePaste, showToast, setDraftImages }
}

describe('pasting an image into a composer that cannot take one', () => {
// Each provider is named: the sentence is built from its label (#1426 review a).
it.each([
['codex', 'Codex'],
['opencode', 'OpenCode'],
['grok', 'Grok'],
['pi', 'Pi'],
] as const)('says so for an image-only paste into %s', async (provider, label) => {
const { handlePaste, showToast } = hook(provider)
let answer: unknown
await act(async () => { answer = await handlePaste(paste({ image: true })) })
expect(showToast).toHaveBeenCalledWith(`Pasted images can't be sent to ${label} from this composer.`)
expect(answer).toEqual({ handledImages: false })
})

// #1426 review b: "see attached" with the attachment silently dropped.
it('says the image was left out of an image-plus-text paste', async () => {
const { handlePaste, showToast } = hook('codex')
await act(async () => { await handlePaste(paste({ image: true, text: 'see attached' })) })
expect(showToast).toHaveBeenCalledWith("Pasted images can't be sent to Codex from this composer; only the text was pasted.")
})

// #1426 review a, b: a browser image copy that arrives only as a data-URL
// <img> in text/html.
it('says so for an image that arrives only as HTML', async () => {
const { handlePaste, showToast } = hook('codex')
await act(async () => { await handlePaste(paste({ html: dataUrlImg })) })
expect(showToast).toHaveBeenCalledWith("Pasted images can't be sent to Codex from this composer.")
})

// #1426 review a: an image only the async clipboard API shows.
it('says so for an image only the async clipboard shows', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { read: vi.fn(async () => [{ types: [png.type], getType: async () => png }]) },
})
const { handlePaste, showToast } = hook('codex')
await act(async () => { await handlePaste(paste({})) })
expect(showToast).toHaveBeenCalledWith("Pasted images can't be sent to Codex from this composer.")
})

it('stays silent for plain text, and for a web-page copy (an <img> beside its text)', async () => {
const { handlePaste, showToast } = hook('codex')
await act(async () => { await handlePaste(paste({ text: 'hello' })) })
await act(async () => { await handlePaste(paste({ text: 'an article', html: `<p>an article</p>${dataUrlImg}` })) })
expect(showToast).not.toHaveBeenCalled()
})

it('does not say it for Claude, which takes the image', async () => {
const { handlePaste, showToast, setDraftImages } = hook('claude')
let answer: unknown
await act(async () => { answer = await handlePaste(paste({ image: true })) })
expect(showToast).not.toHaveBeenCalledWith(expect.stringContaining("can't be sent"))
// Taken, not just unmentioned (#1426 review c).
expect(answer).toEqual({ handledImages: true })
expect(setDraftImages).toHaveBeenCalled()
})

// #1426 review c: only an IMAGE FILE counts. A PDF, a type-less Finder file,
// or a string item that happens to say image/* is not an image paste, and a
// missing clipboardData says nothing.
it('stays silent for a non-image file, a string item, and no clipboard data', async () => {
const { handlePaste, showToast } = hook('codex')
await act(async () => { await handlePaste(paste({ items: [{ kind: 'file', type: 'application/pdf' }] })) })
await act(async () => { await handlePaste(paste({ items: [{ kind: 'file', type: '' }] })) })
await act(async () => { await handlePaste(paste({ items: [{ kind: 'string', type: 'image/png' }] })) })
await act(async () => { await handlePaste({ clipboardData: null, preventDefault: vi.fn() }) })
expect(showToast).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,43 @@ export function useClaudeImagePaste({

const handlePaste = useCallback(
async (e: ClipboardLike): Promise<ImagePasteResult> => {
// Codex has no inline-image content — fall through so the
// caller routes the clipboard's text instead.
if (!getRendererProviderCapabilities(provider).supportsImageAttachments) return { handledImages: false }
// Providers without inline-image content fall through, so the caller
// routes the clipboard's text instead.
const capabilities = getRendererProviderCapabilities(provider)
if (!capabilities.supportsImageAttachments) {
// WHY say so (#1250 row 7): an image pasted into a Codex, OpenCode,
// Grok or Pi composer was dropped with nothing said. The wording is
// about THIS composer, not the provider (#1426 review b): Grok's own
// TUI attaches clipboard images in Terminal view, where a paste goes
// to the TUI and this hook never runs.
//
// What counts as an image (#1426 review a, b):
// - an image FILE item, with or without text. A mixed paste pastes
// its text and says the image was left out ("see attached" with
// no attachment is the silent loss);
// - a data-URL <img> in text/html with NO text. A web-page copy
// carries <img> beside its text, and that text paste stays silent;
// - with nothing else at all, an image only the async clipboard API
// shows (an Electron/macOS shape claudeImages.ts documents).
// Everything is read synchronously first; the event's data is only
// reliable during dispatch.
const data = e.clipboardData
if (!data) return { handledImages: false }
const text = data.getData('text/plain')
const hasImageFile = Array.from(data.items).some(item => item.kind === 'file' && item.type.startsWith('image/'))
const html = data.getData('text/html')
const hasHtmlImage = parseImagesFromHtml(html).length > 0
const where = `${capabilities.shortLabel} from this composer`
if (hasImageFile && text) {
showToast(`Pasted images can't be sent to ${where}; only the text was pasted.`)
} else if (hasImageFile || (hasHtmlImage && !text)) {
showToast(`Pasted images can't be sent to ${where}.`)
} else if (!text && !html && data.items.length === 0) {
const asyncImages = await readImagesFromClipboard().catch(() => [])
if (asyncImages.length > 0) showToast(`Pasted images can't be sent to ${where}.`)
}
return { handledImages: false }
}
const clipboardData = e.clipboardData
if (!clipboardData) return { handledImages: false }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { fireEvent, render } from '@testing-library/react'
import { useRef } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { usePasteToFocus } from './usePasteToFocus'

// #1426 verification a: a paste landing outside the composer (paste-to-focus)
// whose image only the async clipboard API shows arrives as an EMPTY event:
// no text, no HTML, no items. It never reached the shared image handler, so
// nothing was said (and Claude never attached it). It now does; ordinary text
// still appends synchronously and never calls the handler.

function Harness({ handlePaste }: { handlePaste: Parameters<typeof usePasteToFocus>[0]['handlePaste'] }) {
const inputRef = useRef<HTMLTextAreaElement>(null)
usePasteToFocus({ focused: true, sessionId: 's1' as never, inputRef, setDraftInput: vi.fn(), handlePaste })
return <textarea aria-label="Composer" ref={inputRef} readOnly />
}

afterEach(() => { document.body.replaceChildren() })

describe('paste-to-focus and an async-only image', () => {
it('hands an empty paste event to the image handler', () => {
const handlePaste = vi.fn(async () => ({ handledImages: false }))
render(<Harness handlePaste={handlePaste} />)
fireEvent.paste(document.body, { clipboardData: new DataTransfer() })
expect(handlePaste).toHaveBeenCalledTimes(1)
})

it('never hands ordinary text to it', () => {
const handlePaste = vi.fn(async () => ({ handledImages: false }))
render(<Harness handlePaste={handlePaste} />)
const clipboard = new DataTransfer()
clipboard.setData('text/plain', 'hello')
fireEvent.paste(document.body, { clipboardData: clipboard })
expect(handlePaste).not.toHaveBeenCalled()
})
})
10 changes: 10 additions & 0 deletions src/renderer/src/workspace/tile-tree/TileLeaf/usePasteToFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ export function usePasteToFocus({
// synchronously: no await, so no delay, no reorder race, and no
// chance of an async failure swallowing the text.
if (!clipboardHasImageCandidate(e.clipboardData)) {
// An EMPTY event (no text, no HTML, no items) is the one shape whose
// image only the async clipboard API can show (claudeImages.ts). It
// goes to the shared handler, which probes it (Claude attaches it;
// other composers say it cannot be sent, #1426 verification a).
// Ordinary text never takes this branch, so it is never delayed.
const data = e.clipboardData
if (!text && data && !data.getData('text/html') && data.items.length === 0) {
void handlePaste(e)
return
}
appendText(text)
return
}
Expand Down
Loading