From bd54dc508f940e71aa07cd07a43ce57889c60a97 Mon Sep 17 00:00:00 2001
From: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Date: Fri, 4 Sep 2026 17:19:05 +0200
Subject: [PATCH] feat(desktop): add dropzone to new sessions (#47303)
---
.../regression/new-session-dropzone.spec.ts | 60 +++++++++++++
packages/app/src/composer/dropzone.tsx | 87 +++++++++++++++++++
packages/app/src/new-session/view.tsx | 5 ++
packages/app/src/session/screen.tsx | 78 ++---------------
4 files changed, 157 insertions(+), 73 deletions(-)
create mode 100644 packages/app/e2e/regression/new-session-dropzone.spec.ts
create mode 100644 packages/app/src/composer/dropzone.tsx
diff --git a/packages/app/e2e/regression/new-session-dropzone.spec.ts b/packages/app/e2e/regression/new-session-dropzone.spec.ts
new file mode 100644
index 00000000000..6472581b969
--- /dev/null
+++ b/packages/app/e2e/regression/new-session-dropzone.spec.ts
@@ -0,0 +1,60 @@
+import { expect, test } from "@playwright/test"
+import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectAppVisible } from "../utils/waits"
+
+const draftID = "draft_new_session_dropzone"
+const directory = "C:/OpenCode/NewSessionDropzone"
+const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
+
+test("shows the dropzone and attaches a dropped file", async ({ page }) => {
+ await mockOpenCodeServer(page, {
+ directory,
+ project: {
+ id: "proj_new_session_dropzone",
+ worktree: directory,
+ vcs: "git",
+ name: "new-session-dropzone",
+ time: { created: 1700000000000, updated: 1700000000000 },
+ sandboxes: [],
+ },
+ provider: { all: [], connected: [], default: {} },
+ sessions: [],
+ pageMessages: () => ({ items: [] }),
+ })
+ await page.addInitScript(
+ ({ directory, draftID, server }) => {
+ localStorage.setItem(
+ "opencode.global.dat:server",
+ JSON.stringify({
+ projects: { local: [{ worktree: directory, expanded: true }] },
+ lastProject: { local: directory },
+ }),
+ )
+ localStorage.setItem(
+ "opencode.window.browser.dat:tabs",
+ JSON.stringify([{ type: "draft", draftID, server, directory }]),
+ )
+ },
+ { directory, draftID, server },
+ )
+
+ await page.goto(`/new-session?draftId=${draftID}`)
+ await expectAppVisible(page.locator('[data-component="composer-editor"]'))
+
+ const surface = page.locator('[data-component="new-session"]')
+ const dropzone = page.locator('[data-component="session-dropzone"]')
+ const transfer = await page.evaluateHandle(() => {
+ const value = new DataTransfer()
+ value.items.add(new File(["Dropzone fixture"], "dropzone.txt", { type: "text/plain" }))
+ return value
+ })
+
+ await expect(dropzone).toHaveCount(0)
+ await surface.dispatchEvent("dragover", { dataTransfer: transfer })
+ await expect(dropzone).toHaveAttribute("data-visible", "true")
+ await expect(dropzone).toContainText("Drop files to add")
+
+ await surface.dispatchEvent("drop", { dataTransfer: transfer })
+ await expect(page.locator('[data-component="composer-attachments"]')).toContainText("dropzone.txt")
+ await expect(dropzone).toHaveCount(0)
+})
diff --git a/packages/app/src/composer/dropzone.tsx b/packages/app/src/composer/dropzone.tsx
new file mode 100644
index 00000000000..b7621c009ce
--- /dev/null
+++ b/packages/app/src/composer/dropzone.tsx
@@ -0,0 +1,87 @@
+import { Icon } from "@opencode-ai/ui/icon"
+import { Show, createMemo } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useLanguage } from "@/runtime/i18n/language"
+import { createAnimatedPresence } from "@/runtime/animated-presence"
+
+export function ComposerDropzone(props: {
+ active: boolean
+ input?: { image?: boolean; pdf?: boolean }
+ identity?: () => unknown
+}) {
+ const language = useLanguage()
+ const [elements, setElements] = createStore<{ dropzone?: HTMLDivElement }>({})
+ const label = createMemo(() => {
+ if (!props.input?.image && !props.input?.pdf) return language.t("ui.promptInput.dropFiles")
+ if (!props.input.pdf) return language.t("ui.promptInput.dropFiles.image")
+ if (!props.input.image) return language.t("ui.promptInput.dropFiles.pdf")
+ return language.t("ui.promptInput.dropFiles.imagePdf")
+ })
+ const presence = createAnimatedPresence(
+ () => (props.active ? label() : undefined),
+ () => elements.dropzone ?? null,
+ props.identity,
+ )
+
+ return (
+ <>
+
+
+ setElements("dropzone", element)}
+ data-component="session-dropzone"
+ data-visible={props.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"
+ >
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx
index ffdede6ae52..19a0c4ce8ef 100644
--- a/packages/app/src/new-session/view.tsx
+++ b/packages/app/src/new-session/view.tsx
@@ -6,6 +6,7 @@ import { Show, createMemo, createSignal } from "solid-js"
import { Schema } from "effect"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
+import { ComposerDropzone } from "@/composer/dropzone"
import type { ComposerModel } from "@/composer/model"
import { PromptGitStatus, PromptWorkspaceSelector } from "@/new-session/workspace/selector"
import {
@@ -54,6 +55,10 @@ export function NewSessionView(props: {
data-component="new-session"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
+
diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx
index 2c7706c8ee7..6f4f04c0c5f 100644
--- a/packages/app/src/session/screen.tsx
+++ b/packages/app/src/session/screen.tsx
@@ -12,11 +12,10 @@ 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 { useLanguage } from "@/runtime/i18n/language"
import { projectForSession } from "@/shell/layout/helpers"
+import { ComposerDropzone } from "@/composer/dropzone"
import type { SessionModel } from "@/session/model"
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
import { SessionPanelFrame } from "@/session/session-frame"
@@ -41,7 +40,6 @@ const SessionMobileFiles = lazy(async () => {
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const server = useServer()
- const language = useLanguage()
const detailsProject = createMemo(() => {
const info = session.data.info()
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
@@ -63,7 +61,6 @@ export function SessionScreen(props: { session: SessionModel }) {
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())
@@ -140,19 +137,6 @@ export function SessionScreen(props: { session: SessionModel }) {
timeline,
visible: conversationVisible,
})
- const dropLabel = createMemo(() => {
- const input = composer.drop.input()
- 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")
- })
- const dropPresence = createAnimatedPresence(
- () => (composer.drop.active() ? dropLabel() : undefined),
- () => elements.dropzone ?? null,
- session.layout.tabKey,
- )
-
useUsageExceededDialogs()
const sessionErrorFallback = (error: unknown, reset: () => void) => {
@@ -215,63 +199,11 @@ export function SessionScreen(props: { session: SessionModel }) {
const sessionPanelContent = () => (
<>
-
-
- setElements("dropzone", element)}
- data-component="session-dropzone"
- data-visible={composer.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"
- >
-
-
-
-
-
{mobileTabs()}
{/* Surface query errors without suspending session metadata while messages load. */}