mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
refactor(app): make session composer ownership explicit (#47254)
This commit is contained in:
committed by
GitHub
Unverified
parent
211cd73f1a
commit
e8481973ce
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user