From 368f2c843376921996441bb94bdf7f77673aeb85 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 28 Jul 2026 16:25:04 +0800 Subject: [PATCH] fix(app): prevent pane flicker across worktrees --- .../e2e/regression/review-tab-switch.spec.ts | 37 +++++++++--- .../regression/terminal-tab-switch.spec.ts | 43 +++++++++++--- packages/app/src/context/local.tsx | 59 +++++++++++++------ packages/app/src/pages/directory-layout.tsx | 20 +++---- packages/app/src/pages/session.tsx | 22 +------ packages/session-ui/src/context/data.tsx | 5 +- 6 files changed, 118 insertions(+), 68 deletions(-) diff --git a/packages/app/e2e/regression/review-tab-switch.spec.ts b/packages/app/e2e/regression/review-tab-switch.spec.ts index 8634166e51..52225eea31 100644 --- a/packages/app/e2e/regression/review-tab-switch.spec.ts +++ b/packages/app/e2e/regression/review-tab-switch.spec.ts @@ -4,6 +4,7 @@ import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/ReviewTabSwitch" +const otherDirectory = "C:/OpenCode/ReviewTabSwitchOther" const projectID = "proj_review_tab_switch" const sessionA = "ses_review_tab_a" const sessionB = "ses_review_tab_b" @@ -58,6 +59,23 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible() }) +test("keeps the v2 review pane mounted when switching session tabs across worktrees", async ({ page }) => { + await setup(page, otherDirectory) + + await page.goto(sessionHref(sessionA)) + await expectSessionTitle(page, titleA) + + await page.getByRole("button", { name: "Toggle review" }).click() + const review = page.locator('#review-panel [data-component="session-review-v2"]') + await expectAppVisible(review) + await writeProbe(page) + + await switchTab(page, titleB) + await expectSessionTitle(page, titleB) + await expectAppVisible(review) + expect(await readProbe(page)).toBe(PROBE) +}) + type Probed = HTMLElement & { __e2eProbe?: string } async function switchTab(page: Page, title: string) { @@ -74,7 +92,7 @@ async function readProbe(page: Page) { return page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el) => (el as Probed).__e2eProbe) } -async function setup(page: Page) { +async function setup(page: Page, destinationDirectory = directory) { await mockOpenCodeServer(page, { directory, project: { @@ -96,19 +114,22 @@ async function setup(page: Page) { connected: ["opencode"], default: { providerID: "opencode", modelID: "test" }, }, - sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], + sessions: [ + session(sessionA, titleA, 1700000000000, directory), + session(sessionB, titleB, 1700000001000, destinationDirectory), + ], vcsDiff: diffs, pageMessages: () => ({ items: [] }), }) await page.addInitScript( - ({ directory, server, sessions }) => { + ({ directories, server, sessions }) => { 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 }, + projects: { local: directories.map((worktree: string) => ({ worktree, expanded: true })) }, + lastProject: { local: directories[0] }, }), ) localStorage.setItem( @@ -116,16 +137,16 @@ async function setup(page: Page) { JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), ) }, - { directory, server, sessions: [sessionA, sessionB] }, + { directories: [...new Set([directory, destinationDirectory])], server, sessions: [sessionA, sessionB] }, ) } -function session(id: string, title: string, created: number) { +function session(id: string, title: string, created: number, worktree: string) { return { id, slug: id, projectID, - directory, + directory: worktree, title, version: "dev", time: { created, updated: created }, diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts index cbb72958ad..5f40f701bc 100644 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -4,6 +4,7 @@ import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/TerminalTabSwitch" +const otherDirectory = "C:/OpenCode/TerminalTabSwitchOther" const projectID = "proj_terminal_tab_switch" const sessionA = "ses_terminal_tab_a" const sessionB = "ses_terminal_tab_b" @@ -44,6 +45,29 @@ test("keeps the terminal session alive when switching session tabs in a workspac expect(connections.length).toBe(1) }) +test("keeps the terminal pane mounted when switching session tabs across worktrees", async ({ page }) => { + await setup(page, otherDirectory) + + await page.goto(sessionHref(sessionB)) + await expectSessionTitle(page, titleB) + await page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toBeVisible() + + await switchTab(page, titleA) + await expectSessionTitle(page, titleA) + await page.keyboard.press("Control+Backquote") + + const panel = page.locator("#terminal-panel") + await expect(panel).toBeVisible() + await panel.evaluate((el, probe) => { + ;(el as Probed).__e2eProbe = probe + }, PROBE) + + await switchTab(page, titleB) + await expectSessionTitle(page, titleB) + expect(await panel.evaluate((el) => (el as Probed).__e2eProbe)).toBe(PROBE) +}) + type Probed = HTMLElement & { __e2eProbe?: string } async function switchTab(page: Page, title: string) { @@ -60,7 +84,7 @@ async function readProbe(page: Page) { return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe) } -async function setup(page: Page) { +async function setup(page: Page, destinationDirectory = directory) { await mockOpenCodeServer(page, { directory, project: { @@ -82,7 +106,10 @@ async function setup(page: Page) { connected: ["opencode"], default: { providerID: "opencode", modelID: "test" }, }, - sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], + sessions: [ + session(sessionA, titleA, 1700000000000, directory), + session(sessionB, titleB, 1700000001000, destinationDirectory), + ], pageMessages: () => ({ items: [] }), }) await page.route("**/pty", (route) => @@ -109,13 +136,13 @@ async function setup(page: Page) { }) await page.addInitScript( - ({ directory, server, sessions }) => { + ({ directories, server, sessions }) => { 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 }, + projects: { local: directories.map((worktree: string) => ({ worktree, expanded: true })) }, + lastProject: { local: directories[0] }, }), ) localStorage.setItem( @@ -123,17 +150,17 @@ async function setup(page: Page) { JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), ) }, - { directory, server, sessions: [sessionA, sessionB] }, + { directories: [...new Set([directory, destinationDirectory])], server, sessions: [sessionA, sessionB] }, ) return connections } -function session(id: string, title: string, created: number) { +function session(id: string, title: string, created: number, worktree: string) { return { id, slug: id, projectID, - directory, + directory: worktree, title, version: "dev", time: { created, updated: created }, diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 83db571385..2efba90563 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -1,7 +1,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { base64Encode } from "@opencode-ai/core/util/encode" import { useParams } from "@solidjs/router" -import { batch, createEffect, createMemo, startTransition } from "solid-js" +import { batch, createEffect, createMemo, createRoot, onCleanup, startTransition } from "solid-js" import { createStore } from "solid-js/store" import { useModels } from "@/context/models" import { useSettings } from "@/context/settings" @@ -26,6 +26,13 @@ type Saved = { session: Record } +type SavedEntry = { + saved: Saved + setSaved: ReturnType>[1] + ready: ReturnType>[3] + dispose: VoidFunction +} + const WORKSPACE_KEY = "__workspace__" const handoff = new Map() @@ -71,15 +78,29 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list())) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) - const [saved, setSaved, , savedReady] = persisted( - { - ...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]), - migrate, - }, - createStore({ - session: {}, - }), - ) + const savedCache = new Map() + const loadSaved = (serverScope: ServerScope, directory: string) => { + const key = ScopedKey.from(serverScope, directory, "model-selection") + const existing = savedCache.get(key) + if (existing) return existing + const entry = createRoot((dispose) => { + const [saved, setSaved, , ready] = persisted( + { + ...Persist.serverWorkspace(serverScope, directory, "model-selection", ["model-selection.v1"]), + migrate, + }, + createStore({ session: {} }), + ) + return { saved, setSaved, ready, dispose } + }) + savedCache.set(key, entry) + return entry + } + const saved = createMemo(() => loadSaved(serverSDK().scope, sdk().directory)) + onCleanup(() => { + for (const entry of savedCache.values()) entry.dispose() + savedCache.clear() + }) const [store, setStore] = createStore<{ current?: string @@ -127,7 +148,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const scope = createMemo(() => { const session = id() if (!session) return store.draft ?? store.promoting - return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session)) + return saved().saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session)) }) createEffect(() => { @@ -137,13 +158,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const key = handoffKey(serverSDK().scope, sdk().directory, session) const next = handoff.get(key) if (!next) return - if (saved.session[session] !== undefined) { + if (saved().saved.session[session] !== undefined) { handoff.delete(key) setStore("promoting", undefined) return } - setSaved("session", session, clone(next)) + saved().setSaved("session", session, clone(next)) handoff.delete(key) setStore("promoting", undefined) }) @@ -209,7 +230,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } satisfies State const session = id() if (session) { - setSaved("session", session, next) + saved().setSaved("session", session, next) return } setStore("draft", next) @@ -270,7 +291,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const session = id() if (session) { - setSaved("session", session, state) + saved().setSaved("session", session, state) return } setStore("draft", state) @@ -378,7 +399,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ model, agent, session: { - ready: savedReady, + ready: () => saved().ready(), reset() { setStore({ draft: undefined, promoting: undefined }) }, @@ -389,7 +410,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ handoff.set(key, next) if (dir === sdk().directory) { - setSaved("session", session, next) + saved().setSaved("session", session, next) } setStore("promoting", next) @@ -399,10 +420,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const session = id() if (!session) return if (msg.sessionID !== session) return - if (saved.session[session] !== undefined) return + if (saved().saved.session[session] !== undefined) return if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return - setSaved("session", session, { + saved().setSaved("session", session, { agent: msg.agent, model: msg.model, variant: msg.model?.variant ?? null, diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 77c120e57b..866cb429e2 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -58,18 +58,14 @@ export function DirectoryDataProvider( }) return ( - - {(directory) => ( - navigate(href(sessionID))} - onSessionHref={href} - > - {props.children} - - )} - + navigate(href(sessionID))} + onSessionHref={href} + > + {props.children} + ) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 067a796945..02fffe7e5e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -251,7 +251,7 @@ function ResolvedTargetSessionRoute() { () => params.id, () => sync().session.lineage, ) - const directory = createMemo(() => current()?.session.directory) + const directory = createMemo((previous) => current()?.session.directory ?? previous) const targetDirectory = () => directory()! createEffect(() => { @@ -264,33 +264,17 @@ function ResolvedTargetSessionRoute() { }) return ( - // Non-keyed: closes only while the target's directory is unknown (uncached - // lineage mid-resolution), which tears down the workspace subtree including - // the terminal. Same-workspace tab switches keep it open because warm - // targets resolve synchronously from the sync cache. + // Keep the previous workspace mounted while an uncached target resolves. - + ) } -// Owns the workspace-identity remount. Must not include the session ID in the -// key: SessionPage handles session changes reactively, and remounting here -// destroys workspace-scoped state (terminal PTYs, file/prompt providers). -function TargetSessionPage() { - const sdk = useSDK() - const serverSDK = useServerSDK() - return ( - - - - ) -} - function TargetServerScopedProviders( props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>, ) { diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index 999ff510d5..4ba0074c8d 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -1,6 +1,7 @@ import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "@opencode-ai/ui/context" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" +import type { Accessor } from "solid-js" export type NormalizedProviderListResponse = { all: Map @@ -45,7 +46,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: (props: { data: Data - directory: string + directory: string | Accessor onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn }) => { @@ -54,7 +55,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return props.data }, get directory() { - return props.directory + return typeof props.directory === "function" ? props.directory() : props.directory }, navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref,