From 7774a25ad9e0683ad8902690cc09f058dfa2ad84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 10 Aug 2026 02:49:34 +0000 Subject: [PATCH] gate the composer's image affordances on the model that will receive them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/apps/Chat/components/AttachButton.tsx | 18 ++++++++---- .../src/apps/Chat/components/InputArea.tsx | 28 ++++++++++++++++--- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/Chat/components/AttachButton.tsx b/src/workspaces/officerdev/src/apps/Chat/components/AttachButton.tsx index dfb4f171..568af2bc 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/AttachButton.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/AttachButton.tsx @@ -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 - imageInputRef.current?.click()}> - - Image - + {onAttachImage && ( + imageInputRef.current?.click()}> + + Image + + )} {/* "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 = ''; }} /> diff --git a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx index 5d900e03..49bf8bb2 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx @@ -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 (
{
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) => 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) => enabled && Array.from(ev.dataTransfer.types).includes('Files'); const reset = () => { depth.current = 0;