diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts new file mode 100644 index 000000000000..e66cf81504ee --- /dev/null +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -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 } })) + }) +} diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts new file mode 100644 index 000000000000..fd0b00f71cdd --- /dev/null +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -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 +} + +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 = { [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('[data-component="session-todo-dock"]') + const clip = dock?.parentElement?.parentElement + const label = dock?.querySelector('[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) +} diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index b722282e7b65..875c3b7a96c3 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -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) { diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx index b56287a87871..fcdd544ec0a0 100644 --- a/packages/app/src/components/prompt-input.stories.tsx +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -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 (
- +
), } + +export const DockAlreadyOpen = { + render: () => ( +
+

Prompt Input with open Todo dock

+ +
+ ), +} diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 39ec2f251b3d..f26042819128 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -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() { { + 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={() => {}} /> diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b3f2cc31a7c1..898977e390dc 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -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") => ( { + 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() diff --git a/packages/app/src/pages/session/composer/index.ts b/packages/app/src/pages/session/composer/index.ts index b0069de53fbe..e6867461def6 100644 --- a/packages/app/src/pages/session/composer/index.ts +++ b/packages/app/src/pages/session/composer/index.ts @@ -1,2 +1,3 @@ export { SessionComposerRegion } from "./session-composer-region" +export { createSessionComposerControls } from "./session-composer-controls" export { createSessionComposerState } from "./session-composer-state" diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts new file mode 100644 index 000000000000..44ad636a2191 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -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 + sessionID: Accessor + queryOptions: Pick +}) { + 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(() => ({ + 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(), + })) +} diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index fdb993324066..39f8a1f8f7a5 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -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 + 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: { + +
+
setStore("body", el)}> + +
+
+
@@ -296,35 +225,15 @@ export function SessionComposerRegion(props: {
)} -
+
{handoffPrompt() || language.t("prompt.loading")}
} > - -
-
setStore("body", el)}> - view.todoCollapsed.set(!view.todoCollapsed.get())} - collapseLabel={language.t("session.todo.collapse")} - expandLabel={language.t("session.todo.expand")} - dockProgress={value()} - /> -
-
-
{(revert) => (
} >
{language.t("session.child.promptDisabled")} diff --git a/packages/app/src/pages/session/composer/session-composer-state.test.ts b/packages/app/src/pages/session/composer/session-composer-state.test.ts index c27454f7e18a..49efcc971a9b 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.test.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.test.ts @@ -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) + }) +}) diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 5c1cc5a1e857..67e5c82b3f9b 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -21,6 +21,8 @@ export const todoState = (input: { return "close" } +export const todoDockAtBoundary = (state: ReturnType) => 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, } } diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index fccbeec1774e..f68428fcda93 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -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) diff --git a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx index 4bf8fe74b713..7ee47faa870e 100644 --- a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx +++ b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx @@ -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(() => { @@ -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 = {
-
+
{}, 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()} diff --git a/packages/app/test-browser/motion-spring.test.ts b/packages/app/test-browser/motion-spring.test.ts new file mode 100644 index 000000000000..fc251af0088b --- /dev/null +++ b/packages/app/test-browser/motion-spring.test.ts @@ -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(queueMicrotask) + state.setTarget(1) + expect(state.progress()).toBe(0) + + state.setSession("session-b") + expect(state.progress()).toBe(1) + state.dispose() +}) diff --git a/packages/storybook/.storybook/mocks/app/context/prompt.ts b/packages/storybook/.storybook/mocks/app/context/prompt.ts index 1e38313b8c94..fa5f06f81d45 100644 --- a/packages/storybook/.storybook/mocks/app/context/prompt.ts +++ b/packages/storybook/.storybook/mocks/app/context/prompt.ts @@ -74,7 +74,7 @@ export function createPromptState() { }) return { - ready: () => ready, + ready, current: () => store.prompt, cursor: () => store.cursor, dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), diff --git a/packages/ui/src/components/motion-spring.tsx b/packages/ui/src/components/motion-spring.tsx index a5104a1a3ef7..8fe97f0ee583 100644 --- a/packages/ui/src/components/motion-spring.tsx +++ b/packages/ui/src/components/motion-spring.tsx @@ -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> 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(() => {