From e8481973cef2f21e1f145851fcd7aabaefb4608c Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:07:47 +0800 Subject: [PATCH] refactor(app): make session composer ownership explicit (#47254) --- packages/app/src/composer/composer.tsx | 27 +----- packages/app/src/composer/model.ts | 11 ++- packages/app/src/session/composer/adapter.ts | 6 +- .../app/src/session/composer/controller.ts | 57 +++++++++++ packages/app/src/session/composer/queue.ts | 11 +-- packages/app/src/session/composer/region.tsx | 96 +++++++------------ packages/app/src/session/screen.tsx | 47 ++++----- 7 files changed, 130 insertions(+), 125 deletions(-) create mode 100644 packages/app/src/session/composer/controller.ts 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 }) {
-
{store.drop.label}
+
{dropPresence.value()}
@@ -301,7 +309,7 @@ export function SessionScreen(props: { session: SessionModel }) {
- - {(_id) => ( - setStore("drop", drop)} - /> - )} + + {(model) => } )