fix(app): preserve todo dock across sessions (#33778)

This commit is contained in:
Brendan Allan 2026-06-25 13:34:37 +08:00 committed by GitHub
parent bdee94ca40
commit d13779b1d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 765 additions and 219 deletions

View file

@ -0,0 +1,132 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/RequestDocks"
const projectID = "proj_request_docks"
const sessionID = "ses_request_docks"
const title = "Request dock regression"
test("shows a pending question dock", async ({ page }) => {
await mockServer(page, {
questions: [
{
id: "question-request",
sessionID,
questions: [
{
header: "Implementation",
question: "Which implementation should be used?",
options: [
{ label: "Minimal", description: "Use the smallest correct change" },
{ label: "Extended", description: "Include additional behavior" },
],
},
],
},
],
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
)
await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
})
test("shows a pending permission dock", async ({ page }) => {
await mockServer(page, {
permissions: [
{
id: "permission-request",
sessionID,
permission: "bash",
patterns: ["git status", "git diff"],
metadata: {},
always: [],
},
],
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
await expect(permission).toBeVisible()
await expect(permission.getByText("git status")).toBeVisible()
await expect(permission.getByText("git diff")).toBeVisible()
await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3)
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
const reply = page.waitForRequest((request) => request.method() === "POST")
await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
expect(request.postDataJSON()).toEqual({ response: "once" })
})
async function mockServer(
page: Page,
requests: {
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
},
) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "request-docks",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"claude-opus-4-6": {
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
limit: { context: 200_000 },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [
{
id: sessionID,
slug: "request-docks",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
permissions: requests.permissions,
questions: requests.questions,
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
}

View file

@ -0,0 +1,186 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/TodoDockNavigation"
const projectID = "proj_todo_dock_navigation"
const sourceID = "ses_todo_dock_source"
const otherID = "ses_todo_dock_other"
const sourceTitle = "Todo dock animation"
const otherTitle = "Separate session"
const activeTodos = [
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
]
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000)
const events: EventPayload[] = []
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "todo-dock-navigation",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"claude-opus-4-6": {
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
limit: { context: 200_000 },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
todos: (sessionID) => todos[sessionID] ?? [],
})
await configurePage(page)
await page.goto(sessionHref(sourceID))
await expectSessionTitle(page, sourceTitle)
const dock = page.locator('[data-component="session-todo-dock"]')
await expect(dock).toHaveCount(0)
events.push(statusEvent(sourceID, "busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await page.waitForTimeout(700)
const opening = sampleDock(page, 1_000)
todos[sourceID] = activeTodos
events.push(todoEvent(sourceID, activeTodos))
await expect(dock).toBeVisible()
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
await switchSession(page, otherID, otherTitle)
await expect(dock).toHaveCount(0)
const returningOpen = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
const openSamples = (await returningOpen).filter((sample) => sample.present)
expect(openSamples.length).toBeGreaterThan(0)
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
expect(openSamples[0]!.height).toBeGreaterThan(70)
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
const closing = sampleDock(page, 1_000)
todos[sourceID] = completedTodos
events.push(todoEvent(sourceID, completedTodos))
await expect(dock).toHaveCount(0)
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
todos[sourceID] = []
events.push(todoEvent(sourceID, []))
await switchSession(page, otherID, otherTitle)
const returningEmpty = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
await expect(dock).toHaveCount(0)
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
})
function session(id: string, title: string, created: number) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
}
}
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
return {
directory,
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
}
}
async function configurePage(page: Page) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ directory, dirBase64, server, sessionIDs }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
)
},
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
)
}
function sessionHref(sessionID: string) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
return `/server/${base64Encode(server)}/session/${sessionID}`
}
async function switchSession(page: Page, sessionID: string, title: string) {
const href = sessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
}
function sampleDock(page: Page, duration: number) {
return page.evaluate(async (duration) => {
const samples: { present: boolean; height: number; opacity: number }[] = []
const start = performance.now()
while (performance.now() - start < duration) {
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
const clip = dock?.parentElement?.parentElement
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
samples.push({
present: !!dock,
height: clip?.getBoundingClientRect().height ?? 0,
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
})
await new Promise(requestAnimationFrame)
}
return samples
}, duration)
}

View file

@ -1,15 +1,6 @@
import type { Page, Route } from "@playwright/test"
const emptyList = new Set([
"/skill",
"/command",
"/lsp",
"/formatter",
"/permission",
"/question",
"/vcs/status",
"/vcs/diff",
])
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
export interface MockServerConfig {
@ -23,6 +14,9 @@ export interface MockServerConfig {
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
events?: () => unknown[]
eventRetry?: number
todos?: (sessionID: string) => unknown[]
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
@ -55,6 +49,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true })
if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
if (path === "/question")
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
@ -66,7 +64,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, session ?? {})
}
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) {

View file

@ -1,11 +1,27 @@
// @ts-nocheck
import { createStore } from "solid-js/store"
import type { Todo } from "@opencode-ai/sdk/v2"
import { createPromptState } from "@/context/prompt"
import { SessionComposerRegion } from "@/pages/session/composer"
import { createPromptInputHistory, PromptInput } from "./prompt-input"
function PromptInputExample() {
function createPromptInputStoryRuntime() {
const state = createPromptState()
const history = createPromptInputHistory()
return {
state,
history: createPromptInputHistory(),
submission: {
abort() {},
handleSubmit(event: Event) {
event.preventDefault()
state.reset()
},
},
}
}
function PromptInputExample() {
const input = createPromptInputStoryRuntime()
const [controls, setControls] = createStore({
agent: "build",
variant: undefined as string | undefined,
@ -22,13 +38,6 @@ function PromptInputExample() {
set: (variant?: string) => setControls("variant", variant),
},
}
const submission = {
abort() {},
handleSubmit(event: Event) {
event.preventDefault()
state.reset()
},
}
const inputControls = {
agents: {
available: [{ name: "review", hidden: false, mode: "subagent" }],
@ -69,7 +78,7 @@ function PromptInputExample() {
const addReviewComment = () => {
const comment = controls.comments + 1
setControls("comments", comment)
state.context.add({
input.state.context.add({
type: "file",
path: "src/components/prompt-input.tsx",
selection: {
@ -87,7 +96,7 @@ function PromptInputExample() {
return (
<div class="flex flex-col gap-3">
<PromptInput controls={inputControls} state={state} history={history} submission={submission} />
<PromptInput controls={inputControls} {...input} />
<div>
<button
type="button"
@ -101,6 +110,82 @@ function PromptInputExample() {
)
}
const todos: Todo[] = [
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
]
function PromptInputWithOpenDock() {
const input = createPromptInputStoryRuntime()
const [controls, setControls] = createStore({
agent: "build",
activeTab: undefined as string | undefined,
todoCollapsed: false,
})
const inputControls = {
agents: {
available: [],
options: ["build"],
get current() {
return controls.agent
},
loading: false,
visible: true,
select: (agent?: string) => setControls("agent", agent ?? "build"),
},
model: {
selection: {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: { list: () => [], current: () => undefined, set: () => {} },
},
paid: true,
loading: false,
},
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
session: {
id: "story-session",
tabs: {
active: () => controls.activeTab,
all: () => [],
open: () => {},
setActive: (tab: string) => setControls("activeTab", tab),
},
reviewPanel: { opened: () => false, open: () => {} },
},
newLayoutDesigns: true,
}
const state = {
blocked: () => false,
questionRequest: () => undefined,
permissionRequest: () => undefined,
permissionResponding: () => false,
decide: () => {},
todos: () => todos,
dock: () => true,
closing: () => false,
opening: () => false,
}
return (
<SessionComposerRegion
state={state}
sessionKey="story-session"
sessionID="story-session"
controls={inputControls}
promptInput={{ ...input, ref: () => {}, newSessionWorktree: "", onNewSessionWorktreeReset: () => {} }}
todo={{
collapsed: controls.todoCollapsed,
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
}}
ready
centered={false}
onResponseSubmit={() => {}}
setPromptDockRef={() => {}}
/>
)
}
export default {
title: "App/PromptInput",
id: "app-prompt-input",
@ -115,3 +200,12 @@ export const Basic = {
</div>
),
}
export const DockAlreadyOpen = {
render: () => (
<div class="pt-10">
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
<PromptInputWithOpenDock />
</div>
),
}

View file

@ -6,7 +6,13 @@ import { useComments } from "@/context/comments"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { useServerSync } from "@/context/server-sync"
import {
createSessionComposerControls,
createSessionComposerState,
SessionComposerRegion,
} from "@/pages/session/composer"
import { useSessionKey } from "@/pages/session/session-layout"
/**
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
@ -17,12 +23,19 @@ export default function NewSessionPage() {
const prompt = usePrompt()
const sdk = useSDK()
const sync = useSync()
const serverSync = useServerSync()
const comments = useComments()
const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
let inputRef: HTMLDivElement | undefined
const composer = createSessionComposerState()
const composerControls = createSessionComposerControls({
sessionKey: route.sessionKey,
sessionID: () => route.params.id,
queryOptions: serverSync().queryOptions,
})
const [store, setStore] = createStore({
worktree: "main",
@ -57,15 +70,21 @@ export default function NewSessionPage() {
<NewSessionDesignView>
<SessionComposerRegion
state={composer}
sessionKey={route.sessionKey()}
sessionID={route.params.id}
controls={composerControls()}
promptInput={{
ref: (el) => {
inputRef = el
},
newSessionWorktree: newSessionWorktree(),
onNewSessionWorktreeReset: () => setStore("worktree", "main"),
onSubmit: () => comments.clear(),
}}
todo={{ collapsed: false, onToggle: () => {} }}
ready
centered={false}
placement="inline"
inputRef={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
onSubmit={() => comments.clear()}
onResponseSubmit={() => {}}
setPromptDockRef={() => {}}
/>

View file

@ -43,7 +43,11 @@ import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import {
createSessionComposerControls,
createSessionComposerState,
SessionComposerRegion,
} from "@/pages/session/composer"
import {
createOpenReviewFile,
createSessionTabs,
@ -152,6 +156,11 @@ export default function Page() {
})
const composer = createSessionComposerState()
const composerControls = createSessionComposerControls({
sessionKey,
sessionID: () => params.id,
queryOptions: serverSync().queryOptions,
})
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
@ -1561,6 +1570,24 @@ export default function Page() {
const composerRegion = (placement: "dock" | "inline") => (
<SessionComposerRegion
state={composer}
sessionKey={sessionKey()}
sessionID={params.id}
controls={composerControls()}
promptInput={{
ref: (el) => {
inputRef = el
},
newSessionWorktree: newSessionWorktree(),
onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"),
onSubmit: () => {
comments.clear()
resumeScroll()
},
}}
todo={{
collapsed: view().todoCollapsed.get(),
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
}}
ready={!store.deferRender && messagesReady()}
centered={placement === "dock" && centered()}
placement={placement}
@ -1573,15 +1600,6 @@ export default function Page() {
: legacySessionHref(sdk().directory, id),
)
}}
inputRef={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={() => {
comments.clear()
resumeScroll()
}}
onResponseSubmit={resumeScroll}
followup={
params.id && !isChildSession()

View file

@ -1,2 +1,3 @@
export { SessionComposerRegion } from "./session-composer-region"
export { createSessionComposerControls } from "./session-composer-controls"
export { createSessionComposerState } from "./session-composer-state"

View file

@ -0,0 +1,115 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createQuery } from "@tanstack/solid-query"
import { useNavigate, useSearchParams } from "@solidjs/router"
import { type Accessor, createMemo } from "solid-js"
import type { PromptInputControls } from "@/components/prompt-input"
import { useDirectoryPicker } from "@/components/directory-picker"
import { useGlobal } from "@/context/global"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import type { QueryOptionsApi } from "@/context/server-sync"
import { ServerConnection, useServer } from "@/context/server"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { type DraftTab, useTabs } from "@/context/tabs"
import { useProviders } from "@/hooks/use-providers"
import { pathKey } from "@/utils/path-key"
export function createSessionComposerControls(input: {
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
}) {
const navigate = useNavigate()
const layout = useLayout()
const local = useLocal()
const providers = useProviders()
const settings = useSettings()
const server = useServer()
const sync = useSync()
const sdk = useSDK()
const tabs = useTabs()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
const [search] = useSearchParams<{ draftId?: string }>()
const view = layout.view(input.sessionKey)
const draft = createMemo(() => {
if (!search.draftId) return
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
})
const projectServer = createMemo(() => {
if (!search.draftId) return server.current
const target = draft()?.server
if (!target) return
return server.list.find((conn) => ServerConnection.key(conn) === target)
})
const projectServerCtx = createMemo(() => {
const conn = projectServer()
if (conn) return global.ensureServerCtx(conn)
})
const projects = createMemo(() =>
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
)
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
const selectProject = (worktree: string) => {
const conn = projectServer()
const target = projectServerCtx()
if (search.draftId) {
if (!conn || !target) return
target.projects.open(worktree)
target.projects.touch(worktree)
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
return
}
layout.projects.open(worktree)
server.projects.touch(worktree)
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = (title: string) => {
const conn = projectServer()
if (!conn) return
pickDirectory({
server: conn,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
if (directory) selectProject(directory)
},
})
}
return createMemo<PromptInputControls>(() => ({
agents: {
available: sync().data.agent,
options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "",
loading: agentsQuery.isLoading,
visible: settings.visibility.customAgents(),
select: local.agent.set,
},
model: {
selection: local.model,
paid: providers.paid().length > 0,
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
projects: {
available: projects(),
directory: sdk().directory,
select: selectProject,
add: addProject,
},
session: {
id: input.sessionID(),
tabs: layout.tabs(input.sessionKey),
reviewPanel: view.reviewPanel,
},
newLayoutDesigns: settings.general.newLayoutDesigns(),
}))
}

View file

@ -1,14 +1,11 @@
import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate, useSearchParams } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { useLayout } from "@/context/layout"
import { PromptInput } from "@/components/prompt-input"
import { PromptInput, type PromptInputControls, type PromptInputProps } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { useSync } from "@/context/sync"
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 { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
@ -18,29 +15,21 @@ import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import type { FollowupDraft } from "@/components/prompt-input/submit"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { createQuery } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/server-sync"
import { useSDK } from "@/context/sdk"
import { pathKey } from "@/utils/path-key"
import { useLocal } from "@/context/local"
import { useProviders } from "@/hooks/use-providers"
import { useSettings } from "@/context/settings"
import { ServerConnection, useServer } from "@/context/server"
import { type DraftTab, useTabs } from "@/context/tabs"
import { useDirectoryPicker } from "@/components/directory-picker"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useGlobal } from "@/context/global"
export function SessionComposerRegion(props: {
state: SessionComposerState
sessionKey: string
sessionID?: string
controls: PromptInputControls
promptInput: Omit<PromptInputProps, "controls" | "variant">
todo: {
collapsed: boolean
onToggle: () => void
}
ready: boolean
centered: boolean
placement?: "dock" | "inline"
openParent?: () => void
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
onNewSessionWorktreeReset: () => void
onSubmit: () => void
onResponseSubmit: () => void
followup?: {
queue: () => boolean
@ -61,102 +50,12 @@ export function SessionComposerRegion(props: {
}
setPromptDockRef: (el: HTMLDivElement) => void
}) {
const navigate = useNavigate()
const layout = useLayout()
const prompt = usePrompt()
const prompt = props.promptInput.state ?? usePrompt()
const language = useLanguage()
const route = useSessionKey()
const sync = useSync()
const sdk = useSDK()
const queryOptions = useQueryOptions()
const local = useLocal()
const providers = useProviders(() => sdk().directory)
const settings = useSettings()
const server = useServer()
const tabs = useTabs()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
const [search] = useSearchParams<{ draftId?: string }>()
const view = layout.view(route.sessionKey)
const draft = createMemo(() => {
if (!search.draftId) return
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
})
const projectServer = createMemo(() => {
if (!search.draftId) return server.current
const target = draft()?.server
if (!target) return
return server.list.find((conn) => ServerConnection.key(conn) === target)
})
const projectServerCtx = createMemo(() => {
const conn = projectServer()
if (conn) return global.ensureServerCtx(conn)
})
const projects = createMemo(() =>
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
)
const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
const selectProject = (worktree: string) => {
const conn = projectServer()
const target = projectServerCtx()
if (search.draftId) {
if (!conn || !target) return
target.projects.open(worktree)
target.projects.touch(worktree)
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
return
}
layout.projects.open(worktree)
server.projects.touch(worktree)
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = (title: string) => {
const conn = projectServer()
if (!conn) return
pickDirectory({
server: conn,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
if (directory) selectProject(directory)
},
})
}
const controls = createMemo(() => ({
agents: {
available: sync().data.agent,
options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "",
loading: agentsQuery.isLoading,
visible: settings.visibility.customAgents(),
select: local.agent.set,
},
model: {
selection: local.model,
paid: providers.paid().length > 0,
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
projects: {
available: projects(),
directory: sdk().directory,
select: selectProject,
add: addProject,
},
session: {
id: route.params.id,
tabs: layout.tabs(route.sessionKey),
reviewPanel: view.reviewPanel,
},
newLayoutDesigns: settings.general.newLayoutDesigns(),
}))
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
const info = createMemo(() => (route.params.id ? sync().session.get(route.params.id) : undefined))
const handoffPrompt = createMemo(() => getSessionHandoff(props.sessionKey)?.prompt)
const info = createMemo(() => (props.sessionID ? sync().session.get(props.sessionID) : undefined))
const parentID = createMemo(() => info()?.parentID)
const child = createMemo(() => !!parentID())
const showComposer = createMemo(() => !props.state.blocked() || child())
@ -175,11 +74,11 @@ export function SessionComposerRegion(props: {
createEffect(() => {
if (!prompt.ready()) return
setSessionHandoff(route.sessionKey(), { prompt: previewPrompt() })
setSessionHandoff(props.sessionKey, { prompt: previewPrompt() })
})
const [store, setStore] = createStore({
ready: false,
ready: props.ready || props.state.dock(),
height: 320,
body: undefined as HTMLDivElement | undefined,
})
@ -198,13 +97,17 @@ export function SessionComposerRegion(props: {
}
createEffect(() => {
route.sessionKey()
props.sessionKey
const ready = props.ready
const dock = props.state.dock()
const delay = 140
clear()
setStore("ready", false)
if (!ready) return
if (store.ready || (!ready && !dock)) return
if (dock) {
setStore("ready", true)
return
}
frame = requestAnimationFrame(() => {
frame = undefined
@ -218,7 +121,11 @@ export function SessionComposerRegion(props: {
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 progress = useSpring(
() => (open() ? 1 : 0),
{ visualDuration: 0.3, bounce: 0 },
() => `${props.sessionKey}\0${store.ready}`,
)
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))
@ -280,8 +187,30 @@ export function SessionComposerRegion(props: {
</Show>
<Show when={showComposer()}>
<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()}
collapsed={props.todo.collapsed}
onToggle={props.todo.onToggle}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={value()}
/>
</div>
</div>
</Show>
<Show
when={promptReadyResource()}
when={prompt.ready() || promptReadyResource()}
fallback={
<>
<Show when={rolled()} keyed>
@ -296,35 +225,15 @@ export function SessionComposerRegion(props: {
</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">
<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"
style={{ "margin-top": `${-36 * value()}px` }}
>
{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
sessionID={route.params.id}
todos={props.state.todos()}
collapsed={view.todoCollapsed.get()}
onToggle={() => view.todoCollapsed.set(!view.todoCollapsed.get())}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={value()}
/>
</div>
</div>
</Show>
<Show when={rolled()} keyed>
{(revert) => (
<div
@ -362,23 +271,20 @@ export function SessionComposerRegion(props: {
fallback={
<Show when={!props.state.blocked()}>
<PromptInput
controls={controls()}
{...props.promptInput}
controls={props.controls}
variant={props.placement === "inline" ? "new-session" : undefined}
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
edit={props.followup?.edit}
onEditLoaded={props.followup?.onEditLoaded}
shouldQueue={props.followup?.queue}
onQueue={props.followup?.onQueue}
onAbort={props.followup?.onAbort}
onSubmit={props.onSubmit}
/>
</Show>
}
>
<div
ref={props.inputRef}
ref={props.promptInput.ref}
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
>
<span>{language.t("session.child.promptDisabled")} </span>

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { todoState } from "./session-composer-state"
import { todoDockAtBoundary, todoState } from "./session-composer-state"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
@ -126,3 +126,13 @@ describe("todoState", () => {
expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
})
})
describe("todoDockAtBoundary", () => {
test("shows active todos when entering a session", () => {
expect(todoDockAtBoundary("open")).toBe(true)
})
test("hides completed todos when entering a session", () => {
expect(todoDockAtBoundary("close")).toBe(false)
})
})

View file

@ -21,6 +21,8 @@ export const todoState = (input: {
return "close"
}
export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state === "open"
const idle = { type: "idle" as const }
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
@ -60,8 +62,9 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
const [store, setStore] = createStore({
sessionID: params.id,
responding: undefined as string | undefined,
dock: todos().length > 0 && live(),
dock: todos().length > 0 && !done() && live(),
closing: false,
opening: false,
})
@ -116,8 +119,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
createEffect(
on(
() => [todos().length, done(), live()] as const,
([count, complete, active]) => {
() => [params.id, todos().length, done(), live()] as const,
([id, count, complete, active], previous) => {
if (raf) cancelAnimationFrame(raf)
raf = undefined
@ -127,6 +130,14 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
live: active,
})
if (!previous || previous[0] !== id) {
if (timer) window.clearTimeout(timer)
timer = undefined
setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false })
if (next === "clear") clear()
return
}
if (next === "hide") {
if (timer) window.clearTimeout(timer)
timer = undefined
@ -181,9 +192,12 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
permissionResponding,
decide,
todos,
dock: () => store.dock,
closing: () => store.closing,
opening: () => store.opening,
dock: () =>
store.sessionID === params.id
? store.dock
: todoDockAtBoundary(todoState({ count: todos().length, done: done(), live: live() })),
closing: () => store.sessionID === params.id && store.closing,
opening: () => store.sessionID === params.id && store.opening,
}
}

View file

@ -40,7 +40,6 @@ function dot(status: Todo["status"]) {
}
export function SessionTodoDock(props: {
sessionID?: string
todos: Todo[]
collapsed: boolean
onToggle: () => void
@ -50,7 +49,7 @@ export function SessionTodoDock(props: {
}) {
const language = useLanguage()
const [store, setStore] = createStore({
height: 320,
height: 78,
})
const total = createMemo(() => props.todos.length)
@ -85,7 +84,7 @@ export function SessionTodoDock(props: {
const el = contentRef
if (!el) return
const update = () => {
setStore("height", el.getBoundingClientRect().height)
setStore("height", (height) => Math.max(height, el.scrollHeight))
}
update()
createResizeObserver(el, update)

View file

@ -50,6 +50,25 @@ const btn = (accent?: boolean) =>
"font-size": "13px",
}) as const
const controls = {
agents: { available: [], options: ["build"], current: "build", loading: false, visible: true, select: () => {} },
model: {
selection: {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: { list: () => [], current: () => undefined, set: () => {} },
},
paid: true,
loading: false,
},
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
session: {
id: "story-session",
tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
reviewPanel: { opened: () => false, open: () => {} },
},
newLayoutDesigns: true,
}
const css = `
[data-component="todo-stage"] {
display: grid;
@ -132,6 +151,7 @@ export const Playground = {
const global = useServerSync()
const [cfg, setCfg] = createStore({
open: true,
collapsed: false,
step: 1,
dockOpenDuration: 0.3,
dockOpenBounce: 0,
@ -170,7 +190,6 @@ export const Playground = {
const countWidthDuration = () => cfg.countWidthDuration
const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
let frame
let composerRef
let scrollRef
const todos = createMemo<Todo[]>(() => {
@ -196,15 +215,8 @@ export const Playground = {
scrollRef.scrollTop = scrollRef.scrollHeight
}
const collapsed = () =>
!!composerRef?.querySelector('[data-action="session-todo-toggle-button"][data-collapsed="true"]')
const setCollapsed = (value: boolean) => {
const button = composerRef?.querySelector('[data-action="session-todo-toggle-button"]')
if (!(button instanceof HTMLButtonElement)) return
if (collapsed() === value) return
button.click()
}
const collapsed = () => cfg.collapsed
const setCollapsed = (value: boolean) => setCfg("collapsed", value)
const openDock = () => {
clear()
@ -267,14 +279,21 @@ export const Playground = {
</div>
</div>
<div ref={composerRef}>
<div>
<SessionComposerRegion
state={state}
sessionKey="story-session"
sessionID="story-session"
controls={controls}
promptInput={{
submission: { abort: () => {}, handleSubmit: (event) => event.preventDefault() },
ref: () => {},
newSessionWorktree: "",
onNewSessionWorktreeReset: () => {},
}}
todo={{ collapsed: collapsed(), onToggle: () => setCollapsed(!collapsed()) }}
ready
centered={false}
inputRef={() => {}}
newSessionWorktree=""
onNewSessionWorktreeReset={() => {}}
onSubmit={() => {}}
onResponseSubmit={pin}
setPromptDockRef={() => {}}
dockOpenVisualDuration={dockOpenDuration()}

View file

@ -0,0 +1,20 @@
import { expect, test } from "bun:test"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createRoot, createSignal } from "solid-js"
test("snaps spring progress when the session changes", async () => {
const state = createRoot((dispose) => {
const [target, setTarget] = createSignal(0)
const [session, setSession] = createSignal("session-a")
const progress = useSpring(target, { visualDuration: 0.3, bounce: 0 }, session)
return { dispose, progress, setTarget, setSession }
})
await new Promise<void>(queueMicrotask)
state.setTarget(1)
expect(state.progress()).toBe(0)
state.setSession("session-b")
expect(state.progress()).toBe(1)
state.dispose()
})

View file

@ -74,7 +74,7 @@ export function createPromptState() {
})
return {
ready: () => ready,
ready,
current: () => store.prompt,
cursor: () => store.cursor,
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),

View file

@ -1,6 +1,6 @@
import { attachSpring, motionValue } from "motion"
import type { SpringOptions } from "motion"
import { createEffect, createSignal, onCleanup } from "solid-js"
import { createComputed, createEffect, createSignal, onCleanup } from "solid-js"
type Opt = Partial<Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">>
const eq = (a: Opt | undefined, b: Opt | undefined) =>
@ -11,17 +11,30 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
a?.mass === b?.mass &&
a?.velocity === b?.velocity
export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
export function useSpring(target: () => number, options?: Opt | (() => Opt), snapKey?: () => unknown) {
const read = () => (typeof options === "function" ? options() : options)
const [value, setValue] = createSignal(target())
const source = motionValue(value())
const spring = motionValue(value())
let config = read()
let snapValue = snapKey?.()
let stop = attachSpring(spring, source, config)
let off = spring.on("change", (next: number) => setValue(next))
createEffect(() => {
source.set(target())
createComputed(() => {
const next = target()
const nextSnap = snapKey?.()
if (snapKey && nextSnap !== snapValue) {
// State boundaries should adopt their target without animating from the previous context.
snapValue = nextSnap
stop()
spring.jump(next)
source.jump(next)
stop = attachSpring(spring, source, config)
setValue(next)
return
}
source.set(next)
})
createEffect(() => {