refactor(app): make session composer ownership explicit (#47254)

This commit is contained in:
Brendan Allan
2026-09-04 17:07:47 +08:00
committed by GitHub
Unverified
parent 211cd73f1a
commit e8481973ce
7 changed files with 130 additions and 125 deletions
+2 -25
View File
@@ -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 (
<div class="flex flex-col gap-3">
+8 -3
View File
@@ -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()
+2 -4
View File
@@ -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<ComposerControls>
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()
@@ -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<ComposerControls>
dock: Parameters<typeof createSessionComposerRegionController>[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<typeof createSessionComposerController>
+5 -6
View File
@@ -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<boolean>
behavior: Accessor<ComposerDelivery>
composer: Accessor<ComposerModel | undefined>
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
+32 -64
View File
@@ -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<boolean>
}) {
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<typeof createActiveSessionRegion>
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 (
<SessionComposerRegion
controller={region}
controller={props.model.region}
composer={
<div class="relative">
<SessionQueuePanel queue={queue} />
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay onDropStateChange={props.onDropStateChange} />
<Composer model={props.model.composer} borderUnderlay />
</div>
</div>
}
/>
)
}
function requireSessionID(session: SessionModel) {
const id = session.identity.params.id
if (!id) throw new Error("Active Composer requires a Session ID")
return id
}
+24 -23
View File
@@ -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()}
/>
)}
</Show>
@@ -208,7 +217,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<>
<div
data-slot="session-dropzone-blur"
data-visible={store.drop.active}
data-visible={composer.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":
@@ -223,15 +232,14 @@ export function SessionScreen(props: { session: SessionModel }) {
<div
ref={(element) => 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"
>
<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%)",
"-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%)",
}}
>
@@ -251,7 +259,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<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}
data-visible={composer.drop.active()}
>
<div
data-slot="session-dropzone-upload"
@@ -260,7 +268,7 @@ export function SessionScreen(props: { session: SessionModel }) {
>
<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 class="text-[15px] font-[530] leading-6 text-v2-text-text-base">{dropPresence.value()}</div>
</div>
</div>
</Show>
@@ -301,7 +309,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<MessageTimeline
hideHeader={!isDesktop()}
session={session}
background={composer.region.state.background}
background={composer.requests.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
@@ -329,15 +337,8 @@ export function SessionScreen(props: { session: SessionModel }) {
</Switch>
</div>
<Show when={conversationVisible() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<ActiveSessionComposerRegion
model={composer}
session={session}
onResponseSubmit={timeline.actions.resume}
onDropStateChange={(drop) => setStore("drop", drop)}
/>
)}
<Show when={composer.active()} keyed>
{(model) => <ActiveSessionComposerRegion model={model} />}
</Show>
</>
)