summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-19 15:02:45 -0600
committerGitHub <[email protected]>2026-02-19 15:02:45 -0600
commit49cc872c4415f081b4208d16fd0d85e425a75eed (patch)
treeed78239c2a2bb941c5008f378a00592f5b10717a /packages/app/src/pages
parentf8dad0ae170acb9667d9402c162f7c29980373c1 (diff)
downloadopencode-49cc872c4415f081b4208d16fd0d85e425a75eed.tar.gz
opencode-49cc872c4415f081b4208d16fd0d85e425a75eed.zip
chore: refactor composer/dock components (#14328)
Diffstat (limited to 'packages/app/src/pages')
-rw-r--r--packages/app/src/pages/session.tsx13
-rw-r--r--packages/app/src/pages/session/composer/index.ts3
-rw-r--r--packages/app/src/pages/session/composer/session-composer-region.tsx124
-rw-r--r--packages/app/src/pages/session/composer/session-composer-state.ts158
-rw-r--r--packages/app/src/pages/session/composer/session-permission-dock.tsx74
-rw-r--r--packages/app/src/pages/session/composer/session-question-dock.tsx425
-rw-r--r--packages/app/src/pages/session/composer/session-todo-dock.tsx211
-rw-r--r--packages/app/src/pages/session/session-prompt-dock.tsx318
8 files changed, 1000 insertions, 326 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 21ba4e7d7..496f0487d 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -27,7 +27,7 @@ import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { useSessionCommands } from "@/pages/session/use-session-commands"
-import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
+import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
@@ -54,11 +54,7 @@ export default function Page() {
},
})
- const blocked = createMemo(() => {
- const sessionID = params.id
- if (!sessionID) return false
- return !!sync.data.permission[sessionID]?.[0] || !!sync.data.question[sessionID]?.[0]
- })
+ const composer = createSessionComposerState()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const workspaceKey = createMemo(() => params.dir ?? "")
@@ -401,7 +397,7 @@ export default function Page() {
}
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
- if (blocked()) return
+ if (composer.blocked()) return
inputRef?.focus()
}
}
@@ -1090,7 +1086,8 @@ export default function Page() {
</Switch>
</div>
- <SessionPromptDock
+ <SessionComposerRegion
+ state={composer}
centered={centered()}
inputRef={(el) => {
inputRef = el
diff --git a/packages/app/src/pages/session/composer/index.ts b/packages/app/src/pages/session/composer/index.ts
new file mode 100644
index 000000000..e244a1536
--- /dev/null
+++ b/packages/app/src/pages/session/composer/index.ts
@@ -0,0 +1,3 @@
+export { SessionComposerRegion } from "./session-composer-region"
+export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
+export type { SessionComposerState } from "./session-composer-state"
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx
new file mode 100644
index 000000000..ccf39f797
--- /dev/null
+++ b/packages/app/src/pages/session/composer/session-composer-region.tsx
@@ -0,0 +1,124 @@
+import { Show, createEffect, createMemo } from "solid-js"
+import { useParams } from "@solidjs/router"
+import { PromptInput } from "@/components/prompt-input"
+import { useLanguage } from "@/context/language"
+import { usePrompt } from "@/context/prompt"
+import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
+import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
+import { SessionQuestionDock } from "@/pages/session/composer/session-question-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
+ centered: boolean
+ inputRef: (el: HTMLDivElement) => void
+ newSessionWorktree: string
+ onNewSessionWorktreeReset: () => void
+ onSubmit: () => void
+ setPromptDockRef: (el: HTMLDivElement) => void
+}) {
+ const params = useParams()
+ const prompt = usePrompt()
+ const language = useLanguage()
+
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+ 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() })
+ })
+
+ 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} />
+ </div>
+ )}
+ </Show>
+
+ <Show when={props.state.permissionRequest()} keyed>
+ {(request) => (
+ <div>
+ <SessionPermissionDock
+ request={request}
+ responding={props.state.permissionResponding()}
+ onDecide={props.state.decide}
+ />
+ </div>
+ )}
+ </Show>
+
+ <Show when={!props.state.blocked()}>
+ <Show
+ when={prompt.ready()}
+ fallback={
+ <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={props.state.dock()}>
+ <div
+ classList={{
+ "transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
+ "max-h-[320px]": !props.state.closing(),
+ "max-h-0 pointer-events-none": props.state.closing(),
+ "opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
+ "opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
+ }}
+ >
+ <SessionTodoDock
+ todos={props.state.todos()}
+ title={language.t("session.todo.title")}
+ collapseLabel={language.t("session.todo.collapse")}
+ expandLabel={language.t("session.todo.expand")}
+ />
+ </div>
+ </Show>
+ <div
+ classList={{
+ "relative z-10": true,
+ "transition-[margin] duration-[400ms] ease-out": true,
+ "-mt-9": props.state.dock() && !props.state.closing(),
+ "mt-0": !props.state.dock() || props.state.closing(),
+ }}
+ >
+ <PromptInput
+ ref={props.inputRef}
+ newSessionWorktree={props.newSessionWorktree}
+ onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
+ onSubmit={props.onSubmit}
+ />
+ </div>
+ </Show>
+ </Show>
+ </div>
+ </div>
+ )
+}
diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts
new file mode 100644
index 000000000..04c6f7e69
--- /dev/null
+++ b/packages/app/src/pages/session/composer/session-composer-state.ts
@@ -0,0 +1,158 @@
+import { createEffect, createMemo, on, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
+import { useParams } from "@solidjs/router"
+import { showToast } from "@opencode-ai/ui/toast"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+
+export function createSessionComposerBlocked() {
+ const params = useParams()
+ const sync = useSync()
+ return createMemo(() => {
+ const id = params.id
+ if (!id) return false
+ return !!sync.data.permission[id]?.[0] || !!sync.data.question[id]?.[0]
+ })
+}
+
+export function createSessionComposerState() {
+ const params = useParams()
+ const sdk = useSDK()
+ const sync = useSync()
+ const globalSync = useGlobalSync()
+ const language = useLanguage()
+
+ const questionRequest = createMemo((): QuestionRequest | undefined => {
+ const id = params.id
+ if (!id) return
+ return sync.data.question[id]?.[0]
+ })
+
+ const permissionRequest = createMemo((): PermissionRequest | undefined => {
+ const id = params.id
+ if (!id) return
+ return sync.data.permission[id]?.[0]
+ })
+
+ const blocked = createSessionComposerBlocked()
+
+ const todos = createMemo((): Todo[] => {
+ const id = params.id
+ if (!id) return []
+ return globalSync.data.session_todo[id] ?? []
+ })
+
+ const [store, setStore] = createStore({
+ responding: undefined as string | undefined,
+ dock: todos().length > 0,
+ closing: false,
+ opening: false,
+ })
+
+ const permissionResponding = createMemo(() => {
+ const perm = permissionRequest()
+ if (!perm) return false
+ return store.responding === perm.id
+ })
+
+ const decide = (response: "once" | "always" | "reject") => {
+ const perm = permissionRequest()
+ if (!perm) return
+ if (store.responding === perm.id) return
+
+ setStore("responding", perm.id)
+ sdk.client.permission
+ .respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
+ .catch((err: unknown) => {
+ const description = err instanceof Error ? err.message : String(err)
+ showToast({ title: language.t("common.requestFailed"), description })
+ })
+ .finally(() => {
+ setStore("responding", (id) => (id === perm.id ? undefined : id))
+ })
+ }
+
+ const done = createMemo(
+ () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
+ )
+
+ let timer: number | undefined
+ let raf: number | undefined
+
+ const scheduleClose = () => {
+ if (timer) window.clearTimeout(timer)
+ timer = window.setTimeout(() => {
+ setStore({ dock: false, closing: false })
+ timer = undefined
+ }, 400)
+ }
+
+ createEffect(
+ on(
+ () => [todos().length, done()] as const,
+ ([count, complete], prev) => {
+ if (raf) cancelAnimationFrame(raf)
+ raf = undefined
+
+ if (count === 0) {
+ if (timer) window.clearTimeout(timer)
+ timer = undefined
+ setStore({ dock: false, closing: false, opening: false })
+ return
+ }
+
+ if (!complete) {
+ if (timer) window.clearTimeout(timer)
+ timer = undefined
+ const hidden = !store.dock || store.closing
+ setStore({ dock: true, closing: false })
+ if (hidden) {
+ setStore("opening", true)
+ raf = requestAnimationFrame(() => {
+ setStore("opening", false)
+ raf = undefined
+ })
+ return
+ }
+ setStore("opening", false)
+ return
+ }
+
+ if (prev && prev[1]) {
+ if (store.closing && !timer) scheduleClose()
+ return
+ }
+
+ setStore({ dock: true, opening: false, closing: true })
+ scheduleClose()
+ },
+ ),
+ )
+
+ onCleanup(() => {
+ if (!timer) return
+ window.clearTimeout(timer)
+ })
+
+ onCleanup(() => {
+ if (!raf) return
+ cancelAnimationFrame(raf)
+ })
+
+ return {
+ blocked,
+ questionRequest,
+ permissionRequest,
+ permissionResponding,
+ decide,
+ todos,
+ dock: () => store.dock,
+ closing: () => store.closing,
+ opening: () => store.opening,
+ }
+}
+
+export type SessionComposerState = ReturnType<typeof createSessionComposerState>
diff --git a/packages/app/src/pages/session/composer/session-permission-dock.tsx b/packages/app/src/pages/session/composer/session-permission-dock.tsx
new file mode 100644
index 000000000..06ff4f4aa
--- /dev/null
+++ b/packages/app/src/pages/session/composer/session-permission-dock.tsx
@@ -0,0 +1,74 @@
+import { For, Show } from "solid-js"
+import type { PermissionRequest } from "@opencode-ai/sdk/v2"
+import { Button } from "@opencode-ai/ui/button"
+import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
+import { Icon } from "@opencode-ai/ui/icon"
+import { useLanguage } from "@/context/language"
+
+export function SessionPermissionDock(props: {
+ request: PermissionRequest
+ responding: boolean
+ onDecide: (response: "once" | "always" | "reject") => void
+}) {
+ const language = useLanguage()
+
+ const toolDescription = () => {
+ const key = `settings.permissions.tool.${props.request.permission}.description`
+ const value = language.t(key as Parameters<typeof language.t>[0])
+ if (value === key) return ""
+ return value
+ }
+
+ return (
+ <DockPrompt
+ kind="permission"
+ header={
+ <div data-slot="permission-row" data-variant="header">
+ <span data-slot="permission-icon">
+ <Icon name="warning" size="normal" />
+ </span>
+ <div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
+ </div>
+ }
+ footer={
+ <>
+ <div />
+ <div data-slot="permission-footer-actions">
+ <Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
+ {language.t("ui.permission.deny")}
+ </Button>
+ <Button
+ variant="secondary"
+ size="normal"
+ onClick={() => props.onDecide("always")}
+ disabled={props.responding}
+ >
+ {language.t("ui.permission.allowAlways")}
+ </Button>
+ <Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
+ {language.t("ui.permission.allowOnce")}
+ </Button>
+ </div>
+ </>
+ }
+ >
+ <Show when={toolDescription()}>
+ <div data-slot="permission-row">
+ <span data-slot="permission-spacer" aria-hidden="true" />
+ <div data-slot="permission-hint">{toolDescription()}</div>
+ </div>
+ </Show>
+
+ <Show when={props.request.patterns.length > 0}>
+ <div data-slot="permission-row">
+ <span data-slot="permission-spacer" aria-hidden="true" />
+ <div data-slot="permission-patterns">
+ <For each={props.request.patterns}>
+ {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
+ </For>
+ </div>
+ </div>
+ </Show>
+ </DockPrompt>
+ )
+}
diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx
new file mode 100644
index 000000000..97c81a49a
--- /dev/null
+++ b/packages/app/src/pages/session/composer/session-question-dock.tsx
@@ -0,0 +1,425 @@
+import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Button } from "@opencode-ai/ui/button"
+import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
+import { Icon } from "@opencode-ai/ui/icon"
+import { showToast } from "@opencode-ai/ui/toast"
+import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
+import { useLanguage } from "@/context/language"
+import { useSDK } from "@/context/sdk"
+
+export const SessionQuestionDock: Component<{ request: QuestionRequest }> = (props) => {
+ const sdk = useSDK()
+ const language = useLanguage()
+
+ const questions = createMemo(() => props.request.questions)
+ const total = createMemo(() => questions().length)
+
+ const [store, setStore] = createStore({
+ tab: 0,
+ answers: [] as QuestionAnswer[],
+ custom: [] as string[],
+ customOn: [] as boolean[],
+ editing: false,
+ sending: false,
+ })
+
+ let root: HTMLDivElement | undefined
+
+ const question = createMemo(() => questions()[store.tab])
+ const options = createMemo(() => question()?.options ?? [])
+ const input = createMemo(() => store.custom[store.tab] ?? "")
+ const on = createMemo(() => store.customOn[store.tab] === true)
+ const multi = createMemo(() => question()?.multiple === true)
+
+ const summary = createMemo(() => {
+ const n = Math.min(store.tab + 1, total())
+ return `${n} of ${total()} questions`
+ })
+
+ const last = createMemo(() => store.tab >= total() - 1)
+
+ const customUpdate = (value: string, selected: boolean = on()) => {
+ const prev = input().trim()
+ const next = value.trim()
+
+ setStore("custom", store.tab, value)
+ if (!selected) return
+
+ if (multi()) {
+ setStore("answers", store.tab, (current = []) => {
+ const removed = prev ? current.filter((item) => item.trim() !== prev) : current
+ if (!next) return removed
+ if (removed.some((item) => item.trim() === next)) return removed
+ return [...removed, next]
+ })
+ return
+ }
+
+ setStore("answers", store.tab, next ? [next] : [])
+ }
+
+ const measure = () => {
+ if (!root) return
+
+ const scroller = document.querySelector(".session-scroller")
+ const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
+ const top =
+ head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
+ if (!top) {
+ root.style.removeProperty("--question-prompt-max-height")
+ return
+ }
+
+ const dock = root.closest('[data-component="session-prompt-dock"]')
+ if (!(dock instanceof HTMLElement)) return
+
+ const dockBottom = dock.getBoundingClientRect().bottom
+ const below = Math.max(0, dockBottom - root.getBoundingClientRect().bottom)
+ const gap = 8
+ const max = Math.max(240, Math.floor(dockBottom - top - gap - below))
+ root.style.setProperty("--question-prompt-max-height", `${max}px`)
+ }
+
+ onMount(() => {
+ let raf: number | undefined
+ const update = () => {
+ if (raf !== undefined) cancelAnimationFrame(raf)
+ raf = requestAnimationFrame(() => {
+ raf = undefined
+ measure()
+ })
+ }
+
+ update()
+ window.addEventListener("resize", update)
+
+ const dock = root?.closest('[data-component="session-prompt-dock"]')
+ const scroller = document.querySelector(".session-scroller")
+ const observer = new ResizeObserver(update)
+ if (dock instanceof HTMLElement) observer.observe(dock)
+ if (scroller instanceof HTMLElement) observer.observe(scroller)
+
+ onCleanup(() => {
+ window.removeEventListener("resize", update)
+ observer.disconnect()
+ if (raf !== undefined) cancelAnimationFrame(raf)
+ })
+ })
+
+ const fail = (err: unknown) => {
+ const message = err instanceof Error ? err.message : String(err)
+ showToast({ title: language.t("common.requestFailed"), description: message })
+ }
+
+ const reply = async (answers: QuestionAnswer[]) => {
+ if (store.sending) return
+
+ setStore("sending", true)
+ try {
+ await sdk.client.question.reply({ requestID: props.request.id, answers })
+ } catch (err) {
+ fail(err)
+ } finally {
+ setStore("sending", false)
+ }
+ }
+
+ const reject = async () => {
+ if (store.sending) return
+
+ setStore("sending", true)
+ try {
+ await sdk.client.question.reject({ requestID: props.request.id })
+ } catch (err) {
+ fail(err)
+ } finally {
+ setStore("sending", false)
+ }
+ }
+
+ const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
+
+ const pick = (answer: string, custom: boolean = false) => {
+ setStore("answers", store.tab, [answer])
+ if (custom) setStore("custom", store.tab, answer)
+ if (!custom) setStore("customOn", store.tab, false)
+ setStore("editing", false)
+ }
+
+ const toggle = (answer: string) => {
+ setStore("answers", store.tab, (current = []) => {
+ if (current.includes(answer)) return current.filter((item) => item !== answer)
+ return [...current, answer]
+ })
+ }
+
+ const customToggle = () => {
+ if (store.sending) return
+
+ if (!multi()) {
+ setStore("customOn", store.tab, true)
+ setStore("editing", true)
+ customUpdate(input(), true)
+ return
+ }
+
+ const next = !on()
+ setStore("customOn", store.tab, next)
+ if (next) {
+ setStore("editing", true)
+ customUpdate(input(), true)
+ return
+ }
+
+ const value = input().trim()
+ if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
+ setStore("editing", false)
+ }
+
+ const customOpen = () => {
+ if (store.sending) return
+ if (!on()) setStore("customOn", store.tab, true)
+ setStore("editing", true)
+ customUpdate(input(), true)
+ }
+
+ const selectOption = (optIndex: number) => {
+ if (store.sending) return
+
+ if (optIndex === options().length) {
+ customOpen()
+ return
+ }
+
+ const opt = options()[optIndex]
+ if (!opt) return
+ if (multi()) {
+ toggle(opt.label)
+ return
+ }
+ pick(opt.label)
+ }
+
+ const commitCustom = () => {
+ setStore("editing", false)
+ customUpdate(input())
+ }
+
+ const next = () => {
+ if (store.sending) return
+ if (store.editing) commitCustom()
+
+ if (store.tab >= total() - 1) {
+ submit()
+ return
+ }
+
+ setStore("tab", store.tab + 1)
+ setStore("editing", false)
+ }
+
+ const back = () => {
+ if (store.sending) return
+ if (store.tab <= 0) return
+ setStore("tab", store.tab - 1)
+ setStore("editing", false)
+ }
+
+ const jump = (tab: number) => {
+ if (store.sending) return
+ setStore("tab", tab)
+ setStore("editing", false)
+ }
+
+ return (
+ <DockPrompt
+ kind="question"
+ ref={(el) => (root = el)}
+ header={
+ <>
+ <div data-slot="question-header-title">{summary()}</div>
+ <div data-slot="question-progress">
+ <For each={questions()}>
+ {(_, i) => (
+ <button
+ type="button"
+ data-slot="question-progress-segment"
+ data-active={i() === store.tab}
+ data-answered={
+ (store.answers[i()]?.length ?? 0) > 0 ||
+ (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
+ }
+ disabled={store.sending}
+ onClick={() => jump(i())}
+ aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
+ />
+ )}
+ </For>
+ </div>
+ </>
+ }
+ footer={
+ <>
+ <Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
+ {language.t("ui.common.dismiss")}
+ </Button>
+ <div data-slot="question-footer-actions">
+ <Show when={store.tab > 0}>
+ <Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
+ {language.t("ui.common.back")}
+ </Button>
+ </Show>
+ <Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
+ {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
+ </Button>
+ </div>
+ </>
+ }
+ >
+ <div data-slot="question-text">{question()?.question}</div>
+ <Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
+ <div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
+ </Show>
+ <div data-slot="question-options">
+ <For each={options()}>
+ {(opt, i) => {
+ const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
+ return (
+ <button
+ data-slot="question-option"
+ data-picked={picked()}
+ role={multi() ? "checkbox" : "radio"}
+ aria-checked={picked()}
+ disabled={store.sending}
+ onClick={() => selectOption(i())}
+ >
+ <span data-slot="question-option-check" aria-hidden="true">
+ <span
+ data-slot="question-option-box"
+ data-type={multi() ? "checkbox" : "radio"}
+ data-picked={picked()}
+ >
+ <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
+ <Icon name="check-small" size="small" />
+ </Show>
+ </span>
+ </span>
+ <span data-slot="question-option-main">
+ <span data-slot="option-label">{opt.label}</span>
+ <Show when={opt.description}>
+ <span data-slot="option-description">{opt.description}</span>
+ </Show>
+ </span>
+ </button>
+ )
+ }}
+ </For>
+
+ <Show
+ when={store.editing}
+ fallback={
+ <button
+ data-slot="question-option"
+ data-custom="true"
+ data-picked={on()}
+ role={multi() ? "checkbox" : "radio"}
+ aria-checked={on()}
+ disabled={store.sending}
+ onClick={customOpen}
+ >
+ <span
+ data-slot="question-option-check"
+ aria-hidden="true"
+ onClick={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ customToggle()
+ }}
+ >
+ <span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
+ <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
+ <Icon name="check-small" size="small" />
+ </Show>
+ </span>
+ </span>
+ <span data-slot="question-option-main">
+ <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
+ <span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
+ </span>
+ </button>
+ }
+ >
+ <form
+ data-slot="question-option"
+ data-custom="true"
+ data-picked={on()}
+ role={multi() ? "checkbox" : "radio"}
+ aria-checked={on()}
+ onMouseDown={(e) => {
+ if (store.sending) {
+ e.preventDefault()
+ return
+ }
+ if (e.target instanceof HTMLTextAreaElement) return
+ const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
+ if (input instanceof HTMLTextAreaElement) input.focus()
+ }}
+ onSubmit={(e) => {
+ e.preventDefault()
+ commitCustom()
+ }}
+ >
+ <span
+ data-slot="question-option-check"
+ aria-hidden="true"
+ onClick={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ customToggle()
+ }}
+ >
+ <span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
+ <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
+ <Icon name="check-small" size="small" />
+ </Show>
+ </span>
+ </span>
+ <span data-slot="question-option-main">
+ <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
+ <textarea
+ ref={(el) =>
+ setTimeout(() => {
+ el.focus()
+ el.style.height = "0px"
+ el.style.height = `${el.scrollHeight}px`
+ }, 0)
+ }
+ data-slot="question-custom-input"
+ placeholder={language.t("ui.question.custom.placeholder")}
+ value={input()}
+ rows={1}
+ disabled={store.sending}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ e.preventDefault()
+ setStore("editing", false)
+ return
+ }
+ if (e.key !== "Enter" || e.shiftKey) return
+ e.preventDefault()
+ commitCustom()
+ }}
+ onInput={(e) => {
+ customUpdate(e.currentTarget.value)
+ e.currentTarget.style.height = "0px"
+ e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
+ }}
+ />
+ </span>
+ </form>
+ </Show>
+ </div>
+ </DockPrompt>
+ )
+}
diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx
new file mode 100644
index 000000000..ca7a5abd1
--- /dev/null
+++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx
@@ -0,0 +1,211 @@
+import type { Todo } from "@opencode-ai/sdk/v2"
+import { Checkbox } from "@opencode-ai/ui/checkbox"
+import { DockTray } from "@opencode-ai/ui/dock-surface"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+
+function dot(status: Todo["status"]) {
+ if (status !== "in_progress") return undefined
+ return (
+ <svg
+ viewBox="0 0 12 12"
+ width="12"
+ height="12"
+ fill="currentColor"
+ xmlns="http://www.w3.org/2000/svg"
+ class="block"
+ >
+ <circle
+ cx="6"
+ cy="6"
+ r="3"
+ style={{
+ animation: "var(--animate-pulse-scale)",
+ "transform-origin": "center",
+ "transform-box": "fill-box",
+ }}
+ />
+ </svg>
+ )
+}
+
+export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
+ const [store, setStore] = createStore({
+ collapsed: false,
+ })
+
+ const toggle = () => setStore("collapsed", (value) => !value)
+
+ const summary = createMemo(() => {
+ const total = props.todos.length
+ if (total === 0) return ""
+ const completed = props.todos.filter((todo) => todo.status === "completed").length
+ return `${completed} of ${total} ${props.title.toLowerCase()} completed`
+ })
+
+ const active = createMemo(
+ () =>
+ props.todos.find((todo) => todo.status === "in_progress") ??
+ props.todos.find((todo) => todo.status === "pending") ??
+ props.todos.filter((todo) => todo.status === "completed").at(-1) ??
+ props.todos[0],
+ )
+
+ const preview = createMemo(() => active()?.content ?? "")
+
+ return (
+ <DockTray
+ data-component="session-todo-dock"
+ classList={{
+ "h-[78px]": store.collapsed,
+ }}
+ >
+ <div
+ data-action="session-todo-toggle"
+ class="pl-3 pr-2 py-2 flex items-center gap-2"
+ role="button"
+ tabIndex={0}
+ onClick={toggle}
+ onKeyDown={(event) => {
+ if (event.key !== "Enter" && event.key !== " ") return
+ event.preventDefault()
+ toggle()
+ }}
+ >
+ <span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
+ <Show when={store.collapsed}>
+ <div class="ml-1 flex-1 min-w-0">
+ <Show when={preview()}>
+ <div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
+ </Show>
+ </div>
+ </Show>
+ <div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
+ <IconButton
+ data-action="session-todo-toggle-button"
+ icon="chevron-down"
+ size="normal"
+ variant="ghost"
+ classList={{ "rotate-180": !store.collapsed }}
+ onMouseDown={(event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ }}
+ onClick={(event) => {
+ event.stopPropagation()
+ toggle()
+ }}
+ aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
+ />
+ </div>
+ </div>
+
+ <div data-slot="session-todo-list" hidden={store.collapsed}>
+ <TodoList todos={props.todos} open={!store.collapsed} />
+ </div>
+ </DockTray>
+ )
+}
+
+function TodoList(props: { todos: Todo[]; open: boolean }) {
+ const [stuck, setStuck] = createSignal(false)
+ const [scrolling, setScrolling] = createSignal(false)
+ let scrollRef!: HTMLDivElement
+ let timer: number | undefined
+
+ const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress"))
+
+ const ensure = () => {
+ if (!props.open) return
+ if (scrolling()) return
+ if (!scrollRef || scrollRef.offsetParent === null) return
+
+ const el = scrollRef.querySelector("[data-in-progress]")
+ if (!(el instanceof HTMLElement)) return
+
+ const topFade = 16
+ const bottomFade = 44
+ const container = scrollRef.getBoundingClientRect()
+ const rect = el.getBoundingClientRect()
+ const top = rect.top - container.top + scrollRef.scrollTop
+ const bottom = rect.bottom - container.top + scrollRef.scrollTop
+ const viewTop = scrollRef.scrollTop + topFade
+ const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade
+
+ if (top < viewTop) {
+ scrollRef.scrollTop = Math.max(0, top - topFade)
+ } else if (bottom > viewBottom) {
+ scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
+ }
+
+ setStuck(scrollRef.scrollTop > 0)
+ }
+
+ createEffect(
+ on([() => props.open, inProgress], () => {
+ if (!props.open || inProgress() < 0) return
+ requestAnimationFrame(ensure)
+ }),
+ )
+
+ onCleanup(() => {
+ if (!timer) return
+ window.clearTimeout(timer)
+ })
+
+ return (
+ <div class="relative">
+ <div
+ class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
+ ref={scrollRef}
+ style={{ "overflow-anchor": "none" }}
+ onScroll={(e) => {
+ setStuck(e.currentTarget.scrollTop > 0)
+ setScrolling(true)
+ if (timer) window.clearTimeout(timer)
+ timer = window.setTimeout(() => {
+ setScrolling(false)
+ if (inProgress() < 0) return
+ requestAnimationFrame(ensure)
+ }, 250)
+ }}
+ >
+ <For each={props.todos}>
+ {(todo) => (
+ <Checkbox
+ readOnly
+ checked={todo.status === "completed"}
+ indeterminate={todo.status === "in_progress"}
+ data-in-progress={todo.status === "in_progress" ? "" : undefined}
+ icon={dot(todo.status)}
+ style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
+ >
+ <span
+ class="text-14-regular min-w-0 break-words"
+ classList={{
+ "text-text-weak": todo.status === "completed" || todo.status === "cancelled",
+ "text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
+ }}
+ style={{
+ "line-height": "var(--line-height-normal)",
+ "text-decoration":
+ todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
+ }}
+ >
+ {todo.content}
+ </span>
+ </Checkbox>
+ )}
+ </For>
+ </div>
+ <div
+ class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
+ style={{
+ background: "linear-gradient(to bottom, var(--background-base), transparent)",
+ opacity: stuck() ? 1 : 0,
+ }}
+ />
+ </div>
+ )
+}
diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx
deleted file mode 100644
index 0e0d06071..000000000
--- a/packages/app/src/pages/session/session-prompt-dock.tsx
+++ /dev/null
@@ -1,318 +0,0 @@
-import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
-import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
-import { useParams } from "@solidjs/router"
-import { Button } from "@opencode-ai/ui/button"
-import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
-import { Icon } from "@opencode-ai/ui/icon"
-import { showToast } from "@opencode-ai/ui/toast"
-import { PromptInput } from "@/components/prompt-input"
-import { QuestionDock } from "@/components/question-dock"
-import { SessionTodoDock } from "@/components/session-todo-dock"
-import { useGlobalSync } from "@/context/global-sync"
-import { useLanguage } from "@/context/language"
-import { usePrompt } from "@/context/prompt"
-import { useSDK } from "@/context/sdk"
-import { useSync } from "@/context/sync"
-import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
-
-export function SessionPromptDock(props: {
- centered: boolean
- inputRef: (el: HTMLDivElement) => void
- newSessionWorktree: string
- onNewSessionWorktreeReset: () => void
- onSubmit: () => void
- setPromptDockRef: (el: HTMLDivElement) => void
-}) {
- const params = useParams()
- const sdk = useSDK()
- const sync = useSync()
- const globalSync = useGlobalSync()
- const prompt = usePrompt()
- const language = useLanguage()
-
- const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
- const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
-
- const todos = createMemo((): Todo[] => {
- const id = params.id
- if (!id) return []
- return globalSync.data.session_todo[id] ?? []
- })
-
- const questionRequest = createMemo((): QuestionRequest | undefined => {
- const sessionID = params.id
- if (!sessionID) return
- return sync.data.question[sessionID]?.[0]
- })
-
- const permissionRequest = createMemo((): PermissionRequest | undefined => {
- const sessionID = params.id
- if (!sessionID) return
- return sync.data.permission[sessionID]?.[0]
- })
-
- const blocked = createMemo(() => !!permissionRequest() || !!questionRequest())
-
- 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 [responding, setResponding] = createSignal<string | undefined>()
- const permissionResponding = () => {
- const perm = permissionRequest()
- if (!perm) return false
- return responding() === perm.id
- }
-
- const decide = (response: "once" | "always" | "reject") => {
- const perm = permissionRequest()
- if (!perm) return
- if (responding() === perm.id) return
-
- setResponding(perm.id)
- sdk.client.permission
- .respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
- .catch((err: unknown) => {
- const message = err instanceof Error ? err.message : String(err)
- showToast({ title: language.t("common.requestFailed"), description: message })
- })
- .finally(() => {
- setResponding((id) => (id === perm.id ? undefined : id))
- })
- }
-
- const done = createMemo(
- () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
- )
-
- const [dock, setDock] = createSignal(todos().length > 0)
- const [closing, setClosing] = createSignal(false)
- const [opening, setOpening] = createSignal(false)
- let timer: number | undefined
- let raf: number | undefined
-
- const scheduleClose = () => {
- if (timer) window.clearTimeout(timer)
- timer = window.setTimeout(() => {
- setDock(false)
- setClosing(false)
- timer = undefined
- }, 400)
- }
-
- createEffect(
- on(
- () => [todos().length, done()] as const,
- ([count, complete], prev) => {
- if (raf) cancelAnimationFrame(raf)
- raf = undefined
-
- if (count === 0) {
- if (timer) window.clearTimeout(timer)
- timer = undefined
- setDock(false)
- setClosing(false)
- setOpening(false)
- return
- }
-
- if (!complete) {
- if (timer) window.clearTimeout(timer)
- timer = undefined
- const wasHidden = !dock() || closing()
- setDock(true)
- setClosing(false)
- if (wasHidden) {
- setOpening(true)
- raf = requestAnimationFrame(() => {
- setOpening(false)
- raf = undefined
- })
- return
- }
- setOpening(false)
- return
- }
-
- if (prev && prev[1]) {
- if (closing() && !timer) scheduleClose()
- return
- }
-
- setDock(true)
- setOpening(false)
- setClosing(true)
- scheduleClose()
- },
- ),
- )
-
- onCleanup(() => {
- if (!timer) return
- window.clearTimeout(timer)
- })
-
- onCleanup(() => {
- if (!raf) return
- cancelAnimationFrame(raf)
- })
-
- 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={questionRequest()} keyed>
- {(req) => {
- return (
- <div>
- <QuestionDock request={req} />
- </div>
- )
- }}
- </Show>
-
- <Show when={permissionRequest()} keyed>
- {(perm) => {
- const toolDescription = () => {
- const key = `settings.permissions.tool.${perm.permission}.description`
- const value = language.t(key as Parameters<typeof language.t>[0])
- if (value === key) return ""
- return value
- }
-
- return (
- <div>
- <DockPrompt
- kind="permission"
- header={
- <div data-slot="permission-row" data-variant="header">
- <span data-slot="permission-icon">
- <Icon name="warning" size="normal" />
- </span>
- <div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
- </div>
- }
- footer={
- <>
- <div />
- <div data-slot="permission-footer-actions">
- <Button
- variant="ghost"
- size="normal"
- onClick={() => decide("reject")}
- disabled={permissionResponding()}
- >
- {language.t("ui.permission.deny")}
- </Button>
- <Button
- variant="secondary"
- size="normal"
- onClick={() => decide("always")}
- disabled={permissionResponding()}
- >
- {language.t("ui.permission.allowAlways")}
- </Button>
- <Button
- variant="primary"
- size="normal"
- onClick={() => decide("once")}
- disabled={permissionResponding()}
- >
- {language.t("ui.permission.allowOnce")}
- </Button>
- </div>
- </>
- }
- >
- <Show when={toolDescription()}>
- <div data-slot="permission-row">
- <span data-slot="permission-spacer" aria-hidden="true" />
- <div data-slot="permission-hint">{toolDescription()}</div>
- </div>
- </Show>
-
- <Show when={perm.patterns.length > 0}>
- <div data-slot="permission-row">
- <span data-slot="permission-spacer" aria-hidden="true" />
- <div data-slot="permission-patterns">
- <For each={perm.patterns}>
- {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
- </For>
- </div>
- </div>
- </Show>
- </DockPrompt>
- </div>
- )
- }}
- </Show>
-
- <Show when={!blocked()}>
- <Show
- when={prompt.ready()}
- fallback={
- <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={{
- "transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
- "max-h-[320px]": !closing(),
- "max-h-0 pointer-events-none": closing(),
- "opacity-0 translate-y-9": closing() || opening(),
- "opacity-100 translate-y-0": !closing() && !opening(),
- }}
- >
- <SessionTodoDock
- todos={todos()}
- title={language.t("session.todo.title")}
- collapseLabel={language.t("session.todo.collapse")}
- expandLabel={language.t("session.todo.expand")}
- />
- </div>
- </Show>
- <div
- classList={{
- "relative z-10": true,
- "transition-[margin] duration-[400ms] ease-out": true,
- "-mt-9": dock() && !closing(),
- "mt-0": !dock() || closing(),
- }}
- >
- <PromptInput
- ref={props.inputRef}
- newSessionWorktree={props.newSessionWorktree}
- onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
- onSubmit={props.onSubmit}
- />
- </div>
- </Show>
- </Show>
- </div>
- </div>
- )
-}