Files
opencode/packages/app/src/pages/session/composer/session-composer-region.tsx
2026-03-12 11:32:05 -05:00

223 lines
7.1 KiB
TypeScript

import { Show, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { useSessionKey } from "@/pages/session/session-layout"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
export function SessionComposerRegion(props: {
state: SessionComposerState
ready: boolean
centered: boolean
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
onNewSessionWorktreeReset: () => void
onSubmit: () => void
onResponseSubmit: () => void
revert?: {
items: { id: string; text: string }[]
restoring?: string
onRestore: (id: string) => void
}
setPromptDockRef: (el: HTMLDivElement) => void
}) {
const prompt = usePrompt()
const language = useLanguage()
const { sessionKey } = useSessionKey()
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
const [store, setStore] = createStore({
ready: false,
height: 320,
body: undefined as HTMLDivElement | undefined,
})
let timer: number | undefined
let frame: number | undefined
const clear = () => {
if (timer !== undefined) {
window.clearTimeout(timer)
timer = undefined
}
if (frame !== undefined) {
cancelAnimationFrame(frame)
frame = undefined
}
}
createEffect(() => {
sessionKey()
const ready = props.ready
const delay = 140
clear()
setStore("ready", false)
if (!ready) return
frame = requestAnimationFrame(() => {
frame = undefined
timer = window.setTimeout(() => {
setStore("ready", true)
timer = undefined
}, delay)
})
})
onCleanup(clear)
const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing())
const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001)
const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined))
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
const full = createMemo(() => Math.max(78, store.height))
createEffect(() => {
const el = store.body
if (!el) return
const update = () => {
setStore("height", el.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
return (
<div
ref={props.setPromptDockRef}
data-component="session-prompt-dock"
class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
>
<div
classList={{
"w-full px-3 pointer-events-auto": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={props.state.questionRequest()} keyed>
{(request) => (
<div>
<SessionQuestionDock request={request} onSubmit={props.onResponseSubmit} />
</div>
)}
</Show>
<Show when={props.state.permissionRequest()} keyed>
{(request) => (
<div>
<SessionPermissionDock
request={request}
responding={props.state.permissionResponding()}
onDecide={(response) => {
props.onResponseSubmit()
props.state.decide(response)
}}
/>
</div>
)}
</Show>
<Show when={!props.state.blocked()}>
<Show
when={prompt.ready()}
fallback={
<>
<Show when={rolled()} keyed>
{(revert) => (
<div class="pb-2">
<SessionRevertDock
items={revert.items}
restoring={revert.restoring}
onRestore={revert.onRestore}
/>
</div>
)}
</Show>
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{handoffPrompt() || language.t("prompt.loading")}
</div>
</>
}
>
<Show when={dock()}>
<div
classList={{
"overflow-hidden": true,
"pointer-events-none": value() < 0.98,
}}
style={{
"max-height": `${full() * value()}px`,
}}
>
<div ref={(el) => setStore("body", el)}>
<SessionTodoDock
todos={props.state.todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={value()}
/>
</div>
</div>
</Show>
<Show when={rolled()} keyed>
{(revert) => (
<div
style={{
"margin-top": `${-36 * value()}px`,
}}
>
<SessionRevertDock items={revert.items} restoring={revert.restoring} onRestore={revert.onRestore} />
</div>
)}
</Show>
<div
classList={{
"relative z-10": true,
}}
style={{
"margin-top": `${-lift()}px`,
}}
>
<PromptInput
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
onSubmit={props.onSubmit}
/>
</div>
</Show>
</Show>
</div>
</div>
)
}