diff --git a/packages/app/src/composer/composer.tsx b/packages/app/src/composer/composer.tsx
index 93fe2b13234..b9833b04165 100644
--- a/packages/app/src/composer/composer.tsx
+++ b/packages/app/src/composer/composer.tsx
@@ -1,4 +1,4 @@
-import { Show, createEffect, createMemo, onCleanup } from "solid-js"
+import { Show, createMemo } 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,33 +12,10 @@ import { formatKeybind, useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import type { ComposerModel } from "./model"
-export type ComposerDropState = { active: boolean; label: string }
-
-export function Composer(props: {
- class?: string
- model: ComposerModel
- borderUnderlay?: boolean
- onDropStateChange?: (state: ComposerDropState) => void
-}) {
+export function Composer(props: { class?: string; model: ComposerModel; borderUnderlay?: boolean }) {
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/model.ts b/packages/app/src/composer/model.ts
index 43a3e6cf285..3056cbda1bb 100644
--- a/packages/app/src/composer/model.ts
+++ b/packages/app/src/composer/model.ts
@@ -42,9 +42,14 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
const interaction = createComposerEditorState(prompt.mode.current())
createEffect(
- on(adapter.ready, (ready) => {
- if (ready) interaction[1]("mode", prompt.mode.current())
- }),
+ on(
+ () => (adapter.ready() ? prompt.mode.current() : undefined),
+ (mode) => {
+ if (!mode) return
+ // Project external draft changes without another mode write clearing restored retry metadata.
+ interaction[1](mode === "shell" ? { mode, popover: { type: "closed" } } : { mode })
+ },
+ ),
)
const mode = () => interaction[0].mode
const history = createComposerHistory()
diff --git a/packages/app/src/session/composer/adapter.ts b/packages/app/src/session/composer/adapter.ts
index 4e97a13c22f..28c2ac2054a 100644
--- a/packages/app/src/session/composer/adapter.ts
+++ b/packages/app/src/session/composer/adapter.ts
@@ -4,16 +4,14 @@ import { useComposerState } from "@/composer/persistence"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
-import type { SessionModel } from "../model"
export function createActiveComposerAdapter(input: {
- session: SessionModel
+ sessionID: string
controls: Accessor
submitted: () => void
setEditor: (element: HTMLDivElement) => void
}) {
- const id = input.session.identity.params.id
- if (!id) throw new Error("Active Composer requires a Session ID")
+ const id = input.sessionID
const prompt = useComposerState()
prompt.current()
diff --git a/packages/app/src/session/composer/controller.ts b/packages/app/src/session/composer/controller.ts
new file mode 100644
index 00000000000..4832b00989f
--- /dev/null
+++ b/packages/app/src/session/composer/controller.ts
@@ -0,0 +1,57 @@
+import { createEffect, createMemo, on, type Accessor } from "solid-js"
+import type { ComposerControls } from "@/composer/adapter"
+import { setCursorPosition } from "@/composer/editor/dom"
+import { createComposerModel } from "@/composer/model"
+import { useSettings } from "@/settings/model"
+import { createActiveComposerAdapter } from "./adapter"
+import { createSessionQueue } from "./queue"
+import { createSessionComposerRegionController } from "./session-composer-region-controller"
+
+export function createSessionComposerController(input: {
+ sessionID: string
+ controls: Accessor
+ dock: Parameters[0]
+}) {
+ const settings = useSettings()
+ const region = createSessionComposerRegionController(input.dock)
+ let editor: HTMLDivElement | undefined
+ const adapter = createActiveComposerAdapter({
+ sessionID: input.sessionID,
+ controls: input.controls,
+ submitted: region.onResponseSubmit,
+ setEditor: (element) => {
+ editor = element
+ region.setPromptRef(element)
+ },
+ })
+ const queue = createSessionQueue({
+ sessionID: input.sessionID,
+ draft: adapter.state,
+ working: adapter.working,
+ behavior: settings.general.followUpBehavior,
+ restoreFocus: (cursor) => {
+ const target = editor
+ if (!target) return
+ requestAnimationFrame(() => {
+ target.focus()
+ setCursorPosition(target, cursor)
+ })
+ },
+ })
+ const composer = createComposerModel(adapter, { queue })
+ const editable = createMemo(() => region.showComposer() && !region.child())
+ // Requests hide the view without disposing its draft or queue edit.
+ createEffect(on(editable, () => composer.onDragLeave()))
+
+ return {
+ region,
+ queue,
+ composer,
+ drop: {
+ active: () => editable() && composer.state.drag === "active",
+ input: () => composer.model.selection.current()?.capabilities.input,
+ },
+ }
+}
+
+export type SessionComposerController = ReturnType
diff --git a/packages/app/src/session/composer/queue.ts b/packages/app/src/session/composer/queue.ts
index de3930d788a..ed81cbac8ed 100644
--- a/packages/app/src/session/composer/queue.ts
+++ b/packages/app/src/session/composer/queue.ts
@@ -4,7 +4,6 @@ import { useMutation } from "@tanstack/solid-query"
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { ComposerDelivery } from "@/composer/adapter"
-import type { ComposerModel } from "@/composer/model"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
@@ -30,7 +29,7 @@ export function createSessionQueue(input: {
draft: ComposerStateTarget
working: Accessor
behavior: Accessor
- composer: Accessor
+ restoreFocus: (cursor: number) => void
}) {
const data = useData()
const server = useServerSDK()
@@ -159,9 +158,9 @@ export function createSessionQueue(input: {
},
})
const text = queuedPromptText(item)
- input.composer()?.dispatch({ type: "mode.normal" })
+ input.draft.mode.set("normal")
input.draft.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
- input.composer()?.restoreFocus(text.length)
+ input.restoreFocus(text.length)
return true
}
const cancelEdit = () => {
@@ -170,10 +169,10 @@ export function createSessionQueue(input: {
setState("editing", undefined)
// Mode first, then prompt, then retry: mode and prompt writes both clear
// the retry marker.
- input.composer()?.dispatch({ type: editing.stash.mode === "shell" ? "mode.shell" : "mode.normal" })
+ input.draft.mode.set(editing.stash.mode)
input.draft.set(editing.stash.prompt, editing.stash.cursor)
if (editing.stash.retry) input.draft.retry.set(editing.stash.retry)
- input.composer()?.restoreFocus(editing.stash.cursor)
+ input.restoreFocus(editing.stash.cursor)
}
const confirmEdit = (delivery: ComposerDelivery) => {
const editing = state.editing
diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx
index 49a7ab391f3..eb1b4591615 100644
--- a/packages/app/src/session/composer/region.tsx
+++ b/packages/app/src/session/composer/region.tsx
@@ -4,9 +4,8 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate } from "@solidjs/router"
-import { createEffect, on, onMount } from "solid-js"
-import { Composer, type ComposerDropState } from "@/composer/composer"
-import { createComposerModel, type ComposerModel } from "@/composer/model"
+import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
+import { Composer } from "@/composer/composer"
import { useComposerState } from "@/composer/persistence"
import { createComposerControls } from "@/composer/selection"
import { setCursorPosition } from "@/composer/editor/dom"
@@ -25,18 +24,16 @@ import { restorePromptModel, syncPromptModel, syncSessionModel } from "../sessio
import type { SessionTimelineInteraction } from "../timeline/interaction"
import { createSessionRevert } from "../revert"
import { SessionComposerRegion } from "./session-composer-region"
-import { createSessionComposerRegionController } from "./session-composer-region-controller"
-import { createActiveComposerAdapter } from "./adapter"
-import { createSessionQueue } from "./queue"
+import { createSessionComposerController, type SessionComposerController } from "./controller"
import { SessionQueuePanel } from "./queue-panel"
import { resolveSessionComposerSelection } from "./selection"
import { createSessionRequestModel } from "../requests/model"
-import { useSettings } from "@/settings/model"
export function createActiveSessionRegion(input: {
session: SessionModel
screen: SessionScreenLayout
timeline: SessionTimelineInteraction
+ visible: Accessor
}) {
const command = useCommand()
const dialog = useDialog()
@@ -180,7 +177,30 @@ export function createActiveSessionRegion(input: {
},
])
+ const dock = {
+ state,
+ parentID: input.session.data.parentID,
+ centered: input.screen.centered,
+ onResponseSubmit: input.timeline.actions.resume,
+ openParent,
+ setPromptRef: (element: HTMLDivElement) => {
+ promptRef = element
+ },
+ setDockRef: input.timeline.view.setDockRef,
+ }
+ const active = createMemo(
+ on(
+ () => (input.visible() ? input.session.identity.sessionID() : undefined),
+ (sessionID) => (sessionID ? createSessionComposerController({ sessionID, controls, dock }) : undefined),
+ ),
+ )
+
return {
+ active,
+ drop: {
+ active: () => active()?.drop.active() ?? false,
+ input: () => active()?.drop.input(),
+ },
actions: {
timeline: {
get revert() {
@@ -190,77 +210,25 @@ export function createActiveSessionRegion(input: {
openAttachment,
} satisfies SessionUserActions,
},
- region: {
- centered: input.screen.centered,
- openParent,
- prompt,
- setDockRef: input.timeline.view.setDockRef,
- setPromptRef: (element: HTMLDivElement) => {
- promptRef = element
- },
- state,
- },
- input: {
- controls,
- setPromptRef: (element: HTMLDivElement) => {
- promptRef = element
- },
- },
- submitted: () => input.timeline.actions.resume(),
+ requests: state,
workspaceMoveEligible: () => true,
}
}
export type ActiveSessionRegionModel = ReturnType
-export function ActiveSessionComposerRegion(props: {
- model: ActiveSessionRegionModel
- session: SessionModel
- onResponseSubmit: () => void
- onDropStateChange?: (state: ComposerDropState) => void
-}) {
- const settings = useSettings()
- const region = createSessionComposerRegionController({
- state: props.model.region.state,
- parentID: props.session.data.parentID,
- centered: props.model.region.centered,
- onResponseSubmit: props.onResponseSubmit,
- openParent: props.model.region.openParent,
- setPromptRef: props.model.region.setPromptRef,
- setDockRef: props.model.region.setDockRef,
- })
- const adapter = createActiveComposerAdapter({
- session: props.session,
- controls: props.model.input.controls,
- submitted: props.model.submitted,
- setEditor: props.model.input.setPromptRef,
- })
- let composer: ComposerModel | undefined
- const queue = createSessionQueue({
- sessionID: requireSessionID(props.session),
- draft: adapter.state,
- working: adapter.working,
- behavior: settings.general.followUpBehavior,
- composer: () => composer,
- })
- composer = createComposerModel(adapter, { queue })
+export function ActiveSessionComposerRegion(props: { model: SessionComposerController }) {
return (
-
+
-
+
}
/>
)
}
-
-function requireSessionID(session: SessionModel) {
- const id = session.identity.params.id
- if (!id) throw new Error("Active Composer requires a Session ID")
- return id
-}
diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx
index a18d2e43b52..2c7706c8ee7 100644
--- a/packages/app/src/session/screen.tsx
+++ b/packages/app/src/session/screen.tsx
@@ -15,6 +15,7 @@ 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 type { SessionModel } from "@/session/model"
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
@@ -40,6 +41,7 @@ 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
@@ -57,7 +59,6 @@ export function SessionScreen(props: { session: SessionModel }) {
sideTerminalPresent: false,
mobileTerminalCached: false,
mobileMoveDismissed: false,
- drop: { active: false, label: "" },
})
const [elements, setElements] = createStore<{
side?: HTMLDivElement
@@ -77,11 +78,6 @@ 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
@@ -142,7 +138,20 @@ export function SessionScreen(props: { session: SessionModel }) {
session,
screen,
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()
@@ -184,7 +193,7 @@ export function SessionScreen(props: { session: SessionModel }) {
review.mobile.setTab("changes")
session.layout.view().terminal.close()
}}
- backgroundTasks={composer.region.state.background.tasks()}
+ backgroundTasks={composer.requests.background.tasks()}
/>
)}
@@ -208,7 +217,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<>
setElements("dropzone", element)}
data-component="session-dropzone"
- data-visible={store.drop.active}
+ 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"
>
@@ -251,7 +259,7 @@ export function SessionScreen(props: { session: SessionModel }) {
@@ -301,7 +309,7 @@ export function SessionScreen(props: { session: SessionModel }) {
-
- {(_id) => (
- setStore("drop", drop)}
- />
- )}
+
+ {(model) => }
>
)