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" + > +
+
+
+
+
+ +
{presence.value()}
+
+
+ + + ) +} 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" - > -
-
-
-
-
- -
{dropPresence.value()}
-
-
- {mobileTabs()} {/* Surface query errors without suspending session metadata while messages load. */}