gate the composer's image affordances on the model that will receive them

Reopened B4. Flipping `images: false` for OpenCode models made the metadata honest but changed nothing
on screen, because no code read the capability: the drop zone, the paste handler and the attach menu all
accepted images on every harness. The lie B4 described — drop a screenshot, watch it render in your own
bubble, have it discarded before the model sees it — was still there.

The flag is now load-bearing. Three entry points gated on `supportsImages`:

  - the drop zone does not claim the drag at all (no highlight, no preventDefault), so the browser keeps
    it rather than the composer swallowing a file it will drop on the floor
  - an image paste falls through to the default
  - the attach menu's Image entry is absent

`selectedModel || model` mirrors ModelSelector's `displayModel`, so the gate and the model name on screen
can never disagree. An unknown model allows images: a missing capability should not remove a working
control, and the flag is only false where we know it is false. Nothing to undo when images are plumbed
through OpenCodeRunParams later — the gate stops firing once the capability is true.

docs/opencode-phase0-review.md, item 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 02:49:34 +00:00
co-authored by Claude Opus 5
parent 8ac36ed8b8
commit 7774a25ad9
2 changed files with 36 additions and 10 deletions
@@ -9,7 +9,11 @@ import {
} from '@/components/ui/dropdown-menu';
type AttachButtonProps = {
onAttachImage: (file: File) => void;
/**
* Omit when the selected model takes no images: the menu entry disappears rather than offering an
* attachment that would be discarded before the model saw it. See `supportsImages` in InputArea.
*/
onAttachImage?: (file: File) => void;
onAttachWebpage: () => void;
/** Text files are inlined into the composer rather than sent as an attachment — see below. */
onAttachText: (text: string) => void;
@@ -71,10 +75,12 @@ export function AttachButton({ onAttachImage, onAttachWebpage, onAttachText, siz
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="z-[800]">
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
<Image className="mr-2 h-4 w-4" />
Image
</DropdownMenuItem>
{onAttachImage && (
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
<Image className="mr-2 h-4 w-4" />
Image
</DropdownMenuItem>
)}
{/* "PDF" used to sit beside this one. Both were inert — no onSelect at all — so the menu offered
four things and did two. There is no PDF text extraction anywhere in the platform, client or
server, so that entry could not be made honest without building one first. */}
@@ -95,7 +101,7 @@ export function AttachButton({ onAttachImage, onAttachWebpage, onAttachText, siz
className="hidden"
onChange={(ev) => {
const file = ev.target.files?.[0];
if (file) onAttachImage(file);
if (file) onAttachImage?.(file);
ev.target.value = '';
}}
/>
@@ -45,7 +45,21 @@ export const InputArea = ({ manager }: InputAreaProps) => {
const { recording, transcribing, toggleRecording } = useAudioRecording(appendToInput);
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
const drop = useImageDrop(attachImage);
// Does the model actually accept images?
//
// `list-models.ts` has always published this per model and NOTHING read it, so every image affordance
// was offered on every harness. On OpenCode the result was a lie you could watch happen: drop a
// screenshot, see it render in your own bubble, and have it discarded before the model ever saw it —
// `handleOpenCodeChat`'s message type has no `images` field.
//
// `selectedModel || model` mirrors ModelSelector's `displayModel`, so the gate and the visible model
// name can never disagree. Unknown model → allow: a missing capability should not silently remove a
// control that works, and the flag is only false where we know it is false.
const effectiveModelId = selectedModel || model;
const supportsImages = availableModels.find((m) => m.id === effectiveModelId)?.images !== false;
const drop = useImageDrop(attachImage, supportsImages);
return (
<div
@@ -72,7 +86,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
<div className="flex items-end gap-1 md:gap-2">
<AttachButton
onAttachImage={attachImage}
onAttachImage={supportsImages ? attachImage : undefined}
onAttachWebpage={() => setUrlDialogOpen(true)}
onAttachText={appendToInput}
/>
@@ -84,6 +98,9 @@ export const InputArea = ({ manager }: InputAreaProps) => {
onChange={(ev) => setInput(ev.target.value)}
onKeyDown={handleKeyDown}
onPaste={(ev) => {
// Let an image paste fall through to the browser's default (nothing) rather than attaching
// something this model will discard.
if (!supportsImages) return;
const items = ev.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
@@ -159,11 +176,14 @@ export const InputArea = ({ manager }: InputAreaProps) => {
* - **Only claim drags that carry files.** Dragging selected text across the composer would otherwise
* light it up and then swallow the drop, which is how you lose a text-drag into the input.
*/
function useImageDrop(attachImage: (file: File) => void) {
function useImageDrop(attachImage: (file: File) => void, enabled = true) {
const [isTarget, setIsTarget] = useState(false);
const depth = useRef(0);
const carriesFiles = (ev: DragEvent<HTMLElement>) => Array.from(ev.dataTransfer.types).includes('Files');
// When the model takes no images, do not claim the drag at all: no highlight, no preventDefault, and
// the drop is left to the browser. Claiming it and then dropping the file on the floor is the exact
// behaviour this gate exists to remove.
const carriesFiles = (ev: DragEvent<HTMLElement>) => enabled && Array.from(ev.dataTransfer.types).includes('Files');
const reset = () => {
depth.current = 0;