From b27da40a488ac3681e004e3ebccb5b686f64142c Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 25 Jun 2026 11:53:37 +0800 Subject: [PATCH 1/4] fix(app): preserve todo dock across sessions --- .../session-todo-dock-navigation.spec.ts | 192 ++++++++++++++++ packages/app/e2e/utils/mock-server.ts | 5 +- packages/app/package.json | 2 +- .../src/components/prompt-input.stories.tsx | 116 +++++++++- packages/app/src/pages/new-session.tsx | 34 ++- packages/app/src/pages/session.tsx | 39 +++- .../app/src/pages/session/composer/index.ts | 1 + .../composer/session-composer-controls.ts | 115 ++++++++++ .../composer/session-composer-region.tsx | 209 +++++------------- .../composer/session-composer-state.test.ts | 12 +- .../composer/session-composer-state.ts | 16 +- .../session/composer/session-todo-dock.tsx | 5 +- .../composer/todo-panel-motion.stories.tsx | 49 ++-- .../app/test-browser/motion-spring.test.ts | 21 ++ .../.storybook/mocks/app/context/prompt.ts | 2 +- packages/ui/src/components/motion-spring.tsx | 17 +- 16 files changed, 630 insertions(+), 205 deletions(-) create mode 100644 packages/app/e2e/regression/session-todo-dock-navigation.spec.ts create mode 100644 packages/app/src/pages/session/composer/session-composer-controls.ts create mode 100644 packages/app/test-browser/motion-spring.test.ts 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..d9de214cbc13 --- /dev/null +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -0,0 +1,192 @@ +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({ video: "on", 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 page.waitForTimeout(900) + + await switchSession(page, otherID, otherTitle) + await expect(dock).toHaveCount(0) + await page.waitForTimeout(700) + + 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) + await page.waitForTimeout(900) + + 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 page.waitForTimeout(900) + + await switchSession(page, otherID, otherTitle) + await page.waitForTimeout(700) + const returningEmpty = sampleDock(page, 700) + await switchSession(page, sourceID, sourceTitle) + await expect(dock).toHaveCount(0) + expect((await returningEmpty).every((sample) => !sample.present)).toBe(true) + await page.waitForTimeout(900) +}) + +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..926700f346dc 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -23,6 +23,7 @@ export interface MockServerConfig { onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void events?: () => unknown[] eventRetry?: number + todos?: (sessionID: string) => unknown[] } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { @@ -66,7 +67,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/package.json b/packages/app/package.json index 1c5d3a80bf0f..50960b1e4d17 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -19,7 +19,7 @@ "serve": "vite preview", "test": "bun run test:unit && bun run test:virtualizer", "test:unit": "bun test --only-failures --preload ./happydom.ts ./src", - "test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts", + "test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts ./test-browser/motion-spring.test.ts", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", 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..0417082c2262 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,22 @@ 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 12960d436da7..c2d9042bbbcb 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, @@ -119,6 +123,11 @@ export default function Page() { }) const composer = createSessionComposerState() + const composerControls = createSessionComposerControls({ + sessionKey, + sessionID: () => params.id, + queryOptions: serverSync().queryOptions, + }) const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) @@ -1539,18 +1548,28 @@ 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} - 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 ca4fee890212..22bf9c178ac8 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,12 @@ import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import { useNavigate, useSearchParams } from "@solidjs/router" +import { useNavigate } 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 +16,23 @@ 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 { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" -import { useGlobal } from "@/context/global" export function SessionComposerRegion(props: { state: SessionComposerState + sessionKey: string + sessionID?: string + serverKey?: string + controls: PromptInputControls + promptInput: Omit + todo: { + collapsed: boolean + onToggle: () => void + } ready: boolean centered: boolean placement?: "dock" | "inline" - inputRef: (el: HTMLDivElement) => void - newSessionWorktree: string - onNewSessionWorktreeReset: () => void - onSubmit: () => void onResponseSubmit: () => void followup?: { queue: () => boolean @@ -62,101 +54,13 @@ 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() - 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 +79,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 +102,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 +126,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)) @@ -229,9 +141,7 @@ export function SessionComposerRegion(props: { const id = parentID() if (!id) return navigate( - route.params.serverKey - ? sessionHref(requireServerKey(route.params.serverKey), id) - : legacySessionHref(sdk().directory, id), + props.serverKey ? sessionHref(requireServerKey(props.serverKey), id) : legacySessionHref(sdk().directory, id), ) } @@ -290,8 +200,30 @@ export function SessionComposerRegion(props: { + +
+
setStore("body", el)}> + +
+
+
@@ -306,35 +238,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..473febd752b9 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) }) { @@ -61,7 +63,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => const [store, setStore] = createStore({ responding: undefined as string | undefined, - dock: todos().length > 0 && live(), + dock: todos().length > 0 && !done() && live(), closing: false, opening: false, }) @@ -116,8 +118,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 +129,14 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => live: active, }) + if (!previous || previous[0] !== id) { + if (timer) window.clearTimeout(timer) + timer = undefined + setStore({ dock: todoDockAtBoundary(next), closing: false, opening: false }) + if (next === "clear") clear() + return + } + if (next === "hide") { if (timer) window.clearTimeout(timer) timer = undefined 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..9f248ca919a6 --- /dev/null +++ b/packages/app/test-browser/motion-spring.test.ts @@ -0,0 +1,21 @@ +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") + await Bun.sleep(0) + 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..7469294b532c 100644 --- a/packages/ui/src/components/motion-spring.tsx +++ b/packages/ui/src/components/motion-spring.tsx @@ -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()) + 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(() => { From 3709f0474819f39c0cb17c3466d4b92af4acfc4e Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 25 Jun 2026 11:59:34 +0800 Subject: [PATCH 2/4] test(app): streamline todo dock regression --- .../e2e/regression/session-todo-dock-navigation.spec.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts index d9de214cbc13..fd0b00f71cdd 100644 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -21,7 +21,7 @@ type EventPayload = { payload: Record } -test.use({ video: "on", viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" }) +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) @@ -78,11 +78,9 @@ test("animates todo lifecycle without replaying it across session tabs", async ( 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 page.waitForTimeout(900) await switchSession(page, otherID, otherTitle) await expect(dock).toHaveCount(0) - await page.waitForTimeout(700) const returningOpen = sampleDock(page, 700) await switchSession(page, sourceID, sourceTitle) @@ -91,7 +89,6 @@ test("animates todo lifecycle without replaying it across session tabs", async ( expect(openSamples[0]!.opacity).toBeGreaterThan(0.98) expect(openSamples[0]!.height).toBeGreaterThan(70) await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1) - await page.waitForTimeout(900) const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" })) const closing = sampleDock(page, 1_000) @@ -101,15 +98,12 @@ test("animates todo lifecycle without replaying it across session tabs", async ( expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) todos[sourceID] = [] events.push(todoEvent(sourceID, [])) - await page.waitForTimeout(900) await switchSession(page, otherID, otherTitle) - await page.waitForTimeout(700) const returningEmpty = sampleDock(page, 700) await switchSession(page, sourceID, sourceTitle) await expect(dock).toHaveCount(0) expect((await returningEmpty).every((sample) => !sample.present)).toBe(true) - await page.waitForTimeout(900) }) function session(id: string, title: string, created: number) { From 5c41857525922d6f8be865bc77a07ff669d6068a Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 25 Jun 2026 13:07:57 +0800 Subject: [PATCH 3/4] test(app): cover session request docks --- .../regression/session-request-docks.spec.ts | 132 ++++++++++++++++++ packages/app/e2e/utils/mock-server.ts | 17 +-- 2 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 packages/app/e2e/regression/session-request-docks.spec.ts 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/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 926700f346dc..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 { @@ -24,6 +15,8 @@ export interface MockServerConfig { events?: () => unknown[] eventRetry?: number todos?: (sessionID: string) => unknown[] + permissions?: unknown[] | (() => unknown[]) + questions?: unknown[] | (() => unknown[]) } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { @@ -56,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, []) From fec51424f7723920e5a3981aafbfccf6ceff0e22 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 25 Jun 2026 13:27:19 +0800 Subject: [PATCH 4/4] fix(app): preserve session ownership changes --- packages/app/src/pages/session.tsx | 180 ++++++++++++++++------------- 1 file changed, 100 insertions(+), 80 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4bcc723e89df..898977e390dc 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -72,6 +72,7 @@ import { extractPromptFromParts } from "@/utils/prompt" import { formatServerError } from "@/utils/server-errors" import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" +import { createSessionOwnership } from "./session/session-ownership" type FollowupItem = FollowupDraft & { id: string } type FollowupEdit = Pick @@ -80,8 +81,36 @@ const emptyFollowups: FollowupItem[] = [] type ChangeMode = "git" | "branch" | "turn" type VcsMode = "git" | "branch" +const sessionViewState = () => ({ + messageId: undefined as string | undefined, + mobileTab: "session" as "session" | "changes", + changes: "git" as ChangeMode, +}) + +async function runPromptRollbackMutation(input: { + capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void } + optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void + request: () => Promise + complete: (result: R) => void + rollback: () => void + fail: (error: unknown) => void +}) { + const prompt = input.capturePrompt() + const previous = prompt.current().slice() + batch(() => input.optimistic(prompt)) + await input + .request() + .then(input.complete) + .catch((error) => { + batch(() => { + input.rollback() + prompt.set(previous) + }) + input.fail(error) + }) +} + export default function Page() { - const navigate = useNavigate() const serverSync = useServerSync() const layout = useLayout() const local = useLocal() @@ -99,7 +128,9 @@ export default function Page() { const terminal = useTerminal() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const location = useLocation() + const navigate = useNavigate() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() + const sessionOwnership = createSessionOwnership(sessionKey) const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) createEffect(() => { @@ -267,9 +298,7 @@ export default function Page() { ) const [store, setStore] = createStore({ - messageId: undefined as string | undefined, - mobileTab: "session" as "session" | "changes", - changes: "git" as ChangeMode, + ...sessionViewState(), newSessionWorktree: "main", deferRender: false, }) @@ -293,8 +322,9 @@ export default function Page() { const key = sessionKey() if (key !== prev) { setStore("deferRender", true) + const owner = sessionOwnership.capture() requestAnimationFrame(() => { - setTimeout(() => setStore("deferRender", false), 0) + setTimeout(() => owner.run(() => setStore("deferRender", false)), 0) }) } return key @@ -560,8 +590,7 @@ export default function Page() { on( sessionKey, () => { - setStore("messageId", undefined) - setStore("changes", "git") + setStore(sessionViewState()) setUi("pendingMessage", undefined) }, { defer: true }, @@ -1138,27 +1167,37 @@ export default function Page() { let captureHistoryAnchor = () => {} let restoreHistoryAnchor = (_done: boolean) => {} - let historyRequest = false + const historyRequests = new Set() let historyContinuationFrame: number | undefined const loadOlder = async () => { - if (historyRequest || historyLoading()) return - historyRequest = true + const owner = sessionOwnership.capture() + if (historyLoading() || historyRequests.has(owner.key)) return + historyRequests.add(owner.key) const before = timeline.messages().length try { - await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor }) + await timeline.history.loadOlder({ + before: () => owner.run(captureHistoryAnchor), + after: (done) => owner.run(() => restoreHistoryAnchor(done)), + }) } finally { - historyRequest = false + historyRequests.delete(owner.key) } - if (timeline.messages().length <= before) return + if (!owner.current() || timeline.messages().length <= before) return if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame) historyContinuationFrame = requestAnimationFrame(() => { historyContinuationFrame = undefined - onHistoryScroll() + owner.run(onHistoryScroll) }) } const onHistoryScroll = () => { - if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) + if ( + historyRequests.has(sessionOwnership.key()) || + historyLoading() || + !autoScroll.userScrolled() || + !scroller || + scroller.scrollTop >= 200 + ) return void loadOlder() } @@ -1229,23 +1268,13 @@ export default function Page() { }) } - const merge = (next: NonNullable>) => - sync().set("session", (list) => { - const idx = list.findIndex((item) => item.id === next.id) - if (idx < 0) return list - const out = list.slice() - out[idx] = next - return out - }) + const merge = (next: NonNullable>, target = sync()) => target.session.remember(next) - const roll = (sessionID: string, next: NonNullable>["revert"]) => - sync().set("session", (list) => { - const idx = list.findIndex((item) => item.id === sessionID) - if (idx < 0) return list - const out = list.slice() - out[idx] = { ...out[idx], revert: next } - return out - }) + const roll = (sessionID: string, next: NonNullable>["revert"], target = sync()) => { + const session = target.session.get(sessionID) + if (!session) return + target.session.remember({ ...session, revert: next }) + } const busy = (sessionID: string) => sync().data.session_working(sessionID) @@ -1263,6 +1292,7 @@ export default function Page() { const followupMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { + const owner = sessionOwnership.capture() const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) if (!item) return @@ -1283,7 +1313,7 @@ export default function Page() { if (!ok) return setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id)) - if (input.manual) resumeScroll() + if (input.manual) owner.run(resumeScroll) }, })) @@ -1372,25 +1402,23 @@ export default function Page() { const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { - const prev = prompt.current().slice() - const last = info()?.revert + const client = sdk().client + const target = sync() + const last = target.session.get(input.sessionID)?.revert const value = draft(input.messageID) - batch(() => { - roll(input.sessionID, { messageID: input.messageID }) - prompt.set(value) + await runPromptRollbackMutation({ + capturePrompt: prompt.capture, + optimistic: (prompt) => { + roll(input.sessionID, { messageID: input.messageID }, target) + prompt.set(value) + }, + request: () => halt(input.sessionID).then(() => client.session.revert(input)), + complete: (result) => { + if (result.data) merge(result.data, target) + }, + rollback: () => roll(input.sessionID, last, target), + fail, }) - await halt(input.sessionID) - .then(() => sdk().client.session.revert(input)) - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(input.sessionID, last) - prompt.set(prev) - }) - fail(err) - }) }, })) @@ -1399,39 +1427,31 @@ export default function Page() { const sessionID = params.id if (!sessionID) return + const client = sdk().client + const target = sync() const next = userMessages().find((item) => item.id > id) - const prev = prompt.current().slice() - const last = info()?.revert - - batch(() => { - roll(sessionID, next ? { messageID: next.id } : undefined) - if (next) { - prompt.set(draft(next.id)) - return - } - prompt.reset() + const last = target.session.get(sessionID)?.revert + + await runPromptRollbackMutation({ + capturePrompt: prompt.capture, + optimistic: (promptSession) => { + roll(sessionID, next ? { messageID: next.id } : undefined, target) + if (next) { + promptSession.set(draft(next.id)) + return + } + promptSession.reset() + }, + request: () => + !next + ? halt(sessionID).then(() => client.session.unrevert({ sessionID })) + : halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })), + complete: (result) => { + if (result.data) merge(result.data, target) + }, + rollback: () => roll(sessionID, last, target), + fail, }) - - const task = !next - ? halt(sessionID).then(() => sdk().client.session.unrevert({ sessionID })) - : halt(sessionID).then(() => - sdk().client.session.revert({ - sessionID, - messageID: next.id, - }), - ) - - await task - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(sessionID, last) - prompt.set(prev) - }) - fail(err) - }) }, }))