From 211cd73f1ab75ccfa2624673245288dc6fca3a41 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:45:42 +0200 Subject: [PATCH] feat(desktop): update composer dropzone (#42312) --- .../src/composer/attachments/attachments.ts | 15 +++- packages/app/src/composer/composer.tsx | 27 ++++++- packages/app/src/composer/editor/editor.tsx | 7 -- packages/app/src/composer/model.ts | 1 + packages/app/src/index.css | 63 ++++++++++++++++ .../app/src/runtime/platform/platform.tsx | 3 + packages/app/src/session/composer/region.tsx | 5 +- packages/app/src/session/screen.tsx | 73 ++++++++++++++++++- packages/app/src/session/session-frame.tsx | 2 +- .../composer-drag-cancel.test.tsx | 36 +++++++++ packages/desktop/src/main/ipc.ts | 6 +- packages/desktop/src/preload/index.ts | 4 +- .../desktop/src/renderer/platform/index.ts | 5 ++ packages/desktop/src/shared/ipc-transport.ts | 1 + packages/ui/src/i18n/en.ts | 5 +- 15 files changed, 234 insertions(+), 19 deletions(-) create mode 100644 packages/app/test-browser/composer-drag-cancel.test.tsx diff --git a/packages/app/src/composer/attachments/attachments.ts b/packages/app/src/composer/attachments/attachments.ts index 5dea93352be..7d9d85cf1d5 100644 --- a/packages/app/src/composer/attachments/attachments.ts +++ b/packages/app/src/composer/attachments/attachments.ts @@ -1,4 +1,4 @@ -import { onMount } from "solid-js" +import { onCleanup, onMount } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import type { ComposerAttachment, ComposerPrompt } from "../types" @@ -78,6 +78,7 @@ export type ComposerAttachmentConfig = { onError: (error: unknown) => void readClipboardImage?: () => Promise getPathForFile?: (file: File) => string + onDragCancel?: (callback: () => void) => () => void store?: (file: File) => Promise<{ id: string; url: string }> } @@ -90,6 +91,9 @@ export function createComposerAttachments( setDraggingType: (type: "image" | "@mention" | null) => void }, ) { + const clearDrag = () => { + input.setDraggingType(null) + } const capture = () => { const prompt = input.capture() const editor = input.editor() @@ -178,7 +182,7 @@ export function createComposerAttachments( const handleDrop = async (event: DragEvent) => { if (input.isDialogActive()) return event.preventDefault() - input.setDraggingType(null) + clearDrag() const plainText = event.dataTransfer?.getData("text/plain") if (plainText?.startsWith("file:")) { const path = plainText.slice("file:".length) @@ -191,6 +195,8 @@ export function createComposerAttachments( } onMount(() => { + const cancel = input.onDragCancel?.(clearDrag) + if (cancel) onCleanup(cancel) makeEventListener(document, "dragover", (event) => { if (input.isDialogActive()) return event.preventDefault() @@ -198,7 +204,10 @@ export function createComposerAttachments( else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention") }) makeEventListener(document, "dragleave", (event) => { - if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null) + if (!input.isDialogActive() && !event.relatedTarget) clearDrag() + }) + makeEventListener(document, "keydown", (event) => { + if (event.key === "Escape") clearDrag() }) makeEventListener(document, "drop", handleDrop) }) diff --git a/packages/app/src/composer/composer.tsx b/packages/app/src/composer/composer.tsx index b9833b04165..93fe2b13234 100644 --- a/packages/app/src/composer/composer.tsx +++ b/packages/app/src/composer/composer.tsx @@ -1,4 +1,4 @@ -import { Show, createMemo } from "solid-js" +import { Show, createEffect, createMemo, onCleanup } from "solid-js" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Icon } from "@opencode-ai/ui/icon" @@ -12,10 +12,33 @@ import { formatKeybind, useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" import type { ComposerModel } from "./model" -export function Composer(props: { class?: string; model: ComposerModel; borderUnderlay?: boolean }) { +export type ComposerDropState = { active: boolean; label: string } + +export function Composer(props: { + class?: string + model: ComposerModel + borderUnderlay?: boolean + onDropStateChange?: (state: ComposerDropState) => void +}) { const dialog = useDialog() const command = useCommand() const language = useLanguage() + const dropInput = () => props.model.model.selection.current()?.capabilities.input + const dropLabel = createMemo(() => { + const input = dropInput() + if (!input?.image && !input?.pdf) return language.t("ui.promptInput.dropFiles") + if (!input.pdf) return language.t("ui.promptInput.dropFiles.image") + if (!input.image) return language.t("ui.promptInput.dropFiles.pdf") + return language.t("ui.promptInput.dropFiles.imagePdf") + }) + + createEffect(() => + props.onDropStateChange?.({ + active: props.model.state.drag === "active", + label: dropLabel(), + }), + ) + onCleanup(() => props.onDropStateChange?.({ active: false, label: dropLabel() })) return (
diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index 0ce34ee36a7..f672a020a00 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -118,7 +118,6 @@ export function ComposerEditor(props: ComposerEditorProps) { class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base" classList={{ "shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay, - "border border-v2-icon-icon-info border-dashed": state.drag === "active", }} onSubmit={(event) => { event.preventDefault() @@ -129,12 +128,6 @@ export function ComposerEditor(props: ComposerEditorProps) { onDragLeave={props.controller.onDragLeave} onDrop={props.controller.onDrop} > - -
- {i18n.t("ui.promptInput.dropFiles")} -
-
- void): () => void + /** Open a native save file dialog and write content to the selected path (desktop only) */ saveFile?(opts: SaveFilePickerOptions, content: string): Promise diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 2634b1e97d6..49a7ab391f3 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -5,7 +5,7 @@ import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/sc import { makeEventListener } from "@solid-primitives/event-listener" import { useNavigate } from "@solidjs/router" import { createEffect, on, onMount } from "solid-js" -import { Composer } from "@/composer/composer" +import { Composer, type ComposerDropState } from "@/composer/composer" import { createComposerModel, type ComposerModel } from "@/composer/model" import { useComposerState } from "@/composer/persistence" import { createComposerControls } from "@/composer/selection" @@ -217,6 +217,7 @@ export function ActiveSessionComposerRegion(props: { model: ActiveSessionRegionModel session: SessionModel onResponseSubmit: () => void + onDropStateChange?: (state: ComposerDropState) => void }) { const settings = useSettings() const region = createSessionComposerRegionController({ @@ -250,7 +251,7 @@ export function ActiveSessionComposerRegion(props: {
- +
} diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 77991338ccb..a18d2e43b52 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -12,6 +12,7 @@ import { } from "solid-js" import { createStore } from "solid-js/store" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { Icon } from "@opencode-ai/ui/icon" import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline" import { useServer } from "@/runtime/server/current" import { projectForSession } from "@/shell/layout/helpers" @@ -56,10 +57,12 @@ export function SessionScreen(props: { session: SessionModel }) { sideTerminalPresent: false, mobileTerminalCached: false, mobileMoveDismissed: false, + drop: { active: false, label: "" }, }) const [elements, setElements] = createStore<{ side?: HTMLDivElement bottomTerminal?: HTMLDivElement + dropzone?: HTMLDivElement }>({}) const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible) const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open()) @@ -74,6 +77,11 @@ export function SessionScreen(props: { session: SessionModel }) { () => elements.bottomTerminal ?? null, session.layout.tabKey, ) + const dropPresence = createAnimatedPresence( + () => store.drop.active || undefined, + () => elements.dropzone ?? null, + session.layout.tabKey, + ) const sideMotion = createMemo<{ key?: string region: boolean @@ -198,6 +206,64 @@ export function SessionScreen(props: { session: SessionModel }) { const sessionPanelContent = () => ( <> +
+ +
setElements("dropzone", element)} + data-component="session-dropzone" + data-visible={store.drop.active} + class="pointer-events-none absolute inset-0 z-[80] grid place-items-center overflow-hidden rounded-[inherit] bg-[color-mix(in_srgb,var(--v2-text-text-base)_var(--session-dropzone-wash),transparent)] opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=false]:opacity-0 motion-reduce:transition-none" + > +
+
+
+
+
+ +
{store.drop.label}
+
+
+ {mobileTabs()} {/* Surface query errors without suspending session metadata while messages load. */} @@ -265,7 +331,12 @@ export function SessionScreen(props: { session: SessionModel }) { {(_id) => ( - + setStore("drop", drop)} + /> )} diff --git a/packages/app/src/session/session-frame.tsx b/packages/app/src/session/session-frame.tsx index bf5005eac8a..a4606d405c1 100644 --- a/packages/app/src/session/session-frame.tsx +++ b/packages/app/src/session/session-frame.tsx @@ -14,7 +14,7 @@ export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) { export function SessionPanelFrame(props: ParentProps<{ raised?: boolean }>) { return (
{ + let cancel = () => {} + let subscribed = false + let dragging: "image" | "@mention" | null = "image" + const dispose = render(() => { + createComposerAttachments({ + capture: () => ({ current: () => [], cursor: () => 0, set: () => {} }), + editor: () => undefined, + focusEditor: () => {}, + addPart: () => false, + setDraggingType: (type) => (dragging = type), + directory: () => "", + isDialogActive: () => false, + warn: () => {}, + duplicate: () => {}, + onError: () => {}, + onDragCancel: (callback) => { + cancel = callback + subscribed = true + return () => (subscribed = false) + }, + }) + return null + }, document.createElement("div")) + await Promise.resolve() + + cancel() + + expect(dragging).toBeNull() + dispose() + expect(subscribed).toBeFalse() +}) diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index a5c7f966261..6918784dcb0 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -4,7 +4,7 @@ import { app, BrowserWindow, MessageChannelMain } from "electron" import { Effect, Layer } from "effect" import { RpcServer } from "effect/unstable/rpc" import { DesktopRpcs } from "../shared/ipc-rpc" -import { IpcTransportPort } from "../shared/ipc-transport" +import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport" import { DesktopFiles, openExternalURL } from "./files" import { appHandlers } from "./ipc-handlers/app" import { eventHandlers } from "./ipc-handlers/events" @@ -59,6 +59,10 @@ export const registerIpcHandlers = Effect.gen(function* () { relaunch: lifecycle.relaunch, } const wire = (_event: Electron.Event, win: BrowserWindow) => { + win.webContents.on("before-input-event", (_event, input) => { + if (input.type !== "keyDown" || input.key !== "Escape") return + win.webContents.send(DragCancelEvent) + }) win.webContents.on("did-finish-load", () => { if (win.isDestroyed() || win.webContents.isDestroyed()) return const channel = new MessageChannelMain() diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index fec4009668e..2c7b4a165c1 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer, webUtils } from "electron" -import { IpcTransportPort } from "../shared/ipc-transport" +import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport" import { windowIDFromArguments } from "../shared/window-bootstrap" ipcRenderer.on(IpcTransportPort, (event) => { @@ -7,6 +7,8 @@ ipcRenderer.on(IpcTransportPort, (event) => { if (port) window.postMessage(IpcTransportPort, "*", [port]) }) +ipcRenderer.on(DragCancelEvent, () => window.dispatchEvent(new Event(DragCancelEvent))) + contextBridge.exposeInMainWorld("electron", { windowID: windowIDFromArguments(process.argv), getPathForFile: (file: File) => webUtils.getPathForFile(file), diff --git a/packages/desktop/src/renderer/platform/index.ts b/packages/desktop/src/renderer/platform/index.ts index 4a127477cf1..7432294b1aa 100644 --- a/packages/desktop/src/renderer/platform/index.ts +++ b/packages/desktop/src/renderer/platform/index.ts @@ -7,6 +7,7 @@ import { import type { ElectronAPI } from "../api-types" import { setPinchZoomEnabled, webviewZoom } from "../window/zoom" import { windowFullscreen } from "../window/fullscreen" +import { DragCancelEvent } from "../../shared/ipc-transport" import { createDesktopFiles } from "./files" import { createDesktopMenuAction } from "./menu" import { createDesktopNotify } from "./notifications" @@ -53,6 +54,10 @@ export function createDesktopPlatform( windowFullscreen, getPinchZoomEnabled: () => api.getPinchZoomEnabled(), setPinchZoomEnabled, + onDragCancel: (callback) => { + window.addEventListener(DragCancelEvent, callback) + return () => window.removeEventListener(DragCancelEvent, callback) + }, runDesktopMenuAction: createDesktopMenuAction(api), checkAppExists: async (appName) => { return api.checkAppExists(appName) diff --git a/packages/desktop/src/shared/ipc-transport.ts b/packages/desktop/src/shared/ipc-transport.ts index 2efbd527a7b..2a75f2540e7 100644 --- a/packages/desktop/src/shared/ipc-transport.ts +++ b/packages/desktop/src/shared/ipc-transport.ts @@ -1 +1,2 @@ export const IpcTransportPort = "desktop-rpc-port" +export const DragCancelEvent = "opencode:drag-cancel" diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index da2ff5b90a3..d130ceb4e2d 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -133,7 +133,10 @@ const source = { "ui.promptInput.noMatchingItems": "No matching items", "ui.promptInput.commands": "Commands", - "ui.promptInput.dropFiles": "Drop files to attach", + "ui.promptInput.dropFiles": "Drop files to add", + "ui.promptInput.dropFiles.image": "Drop images or files to add", + "ui.promptInput.dropFiles.pdf": "Drop PDFs or files to add", + "ui.promptInput.dropFiles.imagePdf": "Drop images, PDFs, or files to add", "ui.promptInput.removeAttachment": "Remove attachment", "ui.promptInput.label": "Prompt", "ui.promptInput.placeholder.shell": "Enter shell command…",