Compare commits

...
Author SHA1 Message Date
usrnk1 c64a29e148 Merge remote-tracking branch 'origin/v2' into desktop-dropzone 2026-09-01 16:19:48 +02:00
usrnk1 71c3de331c fix(desktop): refine session dropzone 2026-09-01 16:12:47 +02:00
usrnk1 0fc6f2c1ba Merge remote-tracking branch 'origin/v2' into desktop-dropzone
# Conflicts:
#	packages/app/src/components/prompt-input-v2.tsx
#	packages/app/src/pages/session.tsx
2026-09-01 15:10:31 +02:00
usrnk1 87788f018d feat(desktop): polish session dropzone 2026-08-14 11:56:12 +02:00
usrnk1 734012e609 Add fullscreen 2026-08-14 08:54:48 +02:00
usrnk1 6ddaa4ac49 feat(desktop): expand composer dropzone 2026-08-13 21:46:03 +02:00
usrnk1 22160508ee fix(desktop): refine composer dropzone 2026-08-13 15:14:36 +02:00
usrnk1 12fcc75201 feat(desktop): update composer dropzone 2026-08-13 14:16:49 +02:00
15 changed files with 234 additions and 19 deletions
@@ -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<File | null>
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)
})
+25 -2
View File
@@ -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 (
<div class="flex flex-col gap-3">
@@ -117,7 +117,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()
@@ -128,12 +127,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
onDragLeave={props.controller.onDragLeave}
onDrop={props.controller.onDrop}
>
<Show when={state.drag === "active"}>
<div class="pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-xl bg-v2-background-bg-base/90 text-v2-text-text-base">
{i18n.t("ui.promptInput.dropFiles")}
</div>
</Show>
<Show when={state.mode === "normal"}>
<ComposerAttachments
attachments={props.controller.attachments()}
+1
View File
@@ -345,6 +345,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
}),
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
onDragCancel: platform.onDragCancel,
store: platform.draftStore?.putBlob,
},
view: {
+63
View File
@@ -314,6 +314,69 @@
}
}
@keyframes session-dropzone-enter {
from {
opacity: 0;
}
}
@keyframes session-dropzone-content-enter {
from {
opacity: 0;
transform: translateY(4px) scale(0.96);
}
}
@keyframes session-dropzone-upload-enter {
from {
opacity: 0;
transform: translateY(4px) scale(0.92);
}
}
@keyframes session-dropzone-stripes-drift {
to {
background-position: 33.941px 0;
}
}
[data-component="session-dropzone"][data-visible="true"] {
animation: session-dropzone-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"] {
animation: session-dropzone-content-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"] {
animation: session-dropzone-upload-enter 240ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] {
animation: session-dropzone-stripes-drift 3s linear infinite;
}
[data-component="session-dropzone"] {
--session-dropzone-wash: 4%;
--session-dropzone-stripe: 7%;
--session-dropzone-card: var(--v2-background-bg-base);
}
[data-color-scheme="dark"] [data-component="session-dropzone"] {
--session-dropzone-wash: 6%;
--session-dropzone-stripe: 14%;
--session-dropzone-card: var(--v2-background-bg-layer-01);
}
@media (prefers-reduced-motion: reduce) {
[data-component="session-dropzone"][data-visible="true"],
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"],
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"],
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] {
animation: none;
}
}
[data-slot="home-projects-scroll"] {
timeline-scope: --home-projects-scroll;
}
@@ -59,6 +59,9 @@ type PlatformBase = {
/** Resolve the native source path for a desktop File. */
getPathForFile?(file: File): string
/** Observe native drag cancellation that does not reach the renderer event loop. */
onDragCancel?(callback: () => void): () => void
/** Open a native save file picker dialog (desktop only) */
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
+3 -2
View File
@@ -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, createMemo, on, onMount, Show } 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"
@@ -218,6 +218,7 @@ export function ActiveSessionComposerRegion(props: {
model: ActiveSessionRegionModel
session: SessionModel
onResponseSubmit: () => void
onDropStateChange?: (state: ComposerDropState) => void
}) {
const settings = useSettings()
const location = useWorkspaceLocation()
@@ -263,7 +264,7 @@ export function ActiveSessionComposerRegion(props: {
<div class="relative">
<SessionQueuePanel queue={queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay />
<Composer model={composer} borderUnderlay onDropStateChange={props.onDropStateChange} />
</div>
</div>
}
+72 -1
View File
@@ -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 { SessionHeader } from "@/session/header/session-header"
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
@@ -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 = () => (
<>
<div
data-slot="session-dropzone-blur"
data-visible={store.drop.active}
class="pointer-events-none absolute inset-0 z-[79] rounded-[inherit] opacity-[0.001] backdrop-blur-[1.5px] transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] will-change-[opacity,backdrop-filter] data-[visible=true]:opacity-100 motion-reduce:transition-none"
style={{
"-webkit-mask-image":
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
"-webkit-mask-composite": "source-in",
"mask-image":
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
"mask-composite": "intersect",
}}
/>
<Show when={dropPresence.present()}>
<div
ref={(element) => 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"
>
<div class="absolute inset-0 bg-v2-background-bg-base/25" />
<div
class="absolute inset-y-0 left-1/2 w-full -translate-x-1/2 md:max-w-200 2xl:max-w-[1000px]"
style={{
"-webkit-mask-image":
"linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
"mask-image": "linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
}}
>
<div
data-slot="session-dropzone-stripes"
class="absolute inset-0 opacity-60"
style={{
background:
"repeating-linear-gradient(135deg, transparent 0px, transparent 12px, color-mix(in srgb, var(--v2-text-text-base) var(--session-dropzone-stripe), transparent) 12px, color-mix(in srgb, var(--v2-text-text-base) var(--session-dropzone-stripe), transparent) 24px)",
"-webkit-mask-image":
"radial-gradient(ellipse 59% 40% at center, black 0%, rgba(0,0,0,0.72) 58%, transparent 100%)",
"mask-image":
"radial-gradient(ellipse 59% 40% at center, black 0%, rgba(0,0,0,0.72) 58%, transparent 100%)",
}}
/>
</div>
<div
data-slot="session-dropzone-content"
class="relative flex translate-y-0 flex-col items-center gap-5 opacity-100 transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=false]:translate-y-1 data-[visible=false]:opacity-0 motion-reduce:transition-none"
data-visible={store.drop.active}
>
<div
data-slot="session-dropzone-upload"
class="flex size-10 items-center justify-center rounded-full bg-[var(--session-dropzone-card)] text-v2-icon-icon-muted shadow-[var(--v2-elevation-floating)]"
aria-hidden="true"
>
<Icon name="arrow-up" size="normal" class="text-v2-icon-icon-muted" />
</div>
<div class="text-[15px] font-[530] leading-6 text-v2-text-text-base">{store.drop.label}</div>
</div>
</div>
</Show>
<Show when={!isDesktop() && !!session.identity.params.id}>{mobileTabs()}</Show>
{/* Surface query errors without suspending session metadata while messages load. */}
<Show when={timeline.resource.error}>
@@ -265,7 +331,12 @@ export function SessionScreen(props: { session: SessionModel }) {
<Show when={conversationVisible() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<ActiveSessionComposerRegion model={composer} session={session} onResponseSubmit={timeline.actions.resume} />
<ActiveSessionComposerRegion
model={composer}
session={session}
onResponseSubmit={timeline.actions.resume}
onDropStateChange={(drop) => setStore("drop", drop)}
/>
)}
</Show>
</>
+1 -1
View File
@@ -14,7 +14,7 @@ export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
export function SessionPanelFrame(props: ParentProps<{ raised?: boolean }>) {
return (
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] bg-v2-background-bg-base"
class="relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] bg-v2-background-bg-base"
classList={{
"shadow-[var(--v2-elevation-raised)]": props.raised,
}}
@@ -0,0 +1,36 @@
import { expect, test } from "bun:test"
import { render } from "solid-js/web"
import { createComposerAttachments } from "@/composer/attachments/attachments"
test("clears drag state on platform cancellation", async () => {
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()
})
+5 -1
View File
@@ -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()
+3 -1
View File
@@ -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),
@@ -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)
@@ -1 +1,2 @@
export const IpcTransportPort = "desktop-rpc-port"
export const DragCancelEvent = "opencode:drag-cancel"
+4 -1
View File
@@ -127,7 +127,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...",