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;