fix(app): prevent pane flicker across worktrees

This commit is contained in:
Brendan Allan 2026-07-28 16:25:04 +08:00
commit 368f2c8433
No known key found for this signature in database
GPG key ID: 41E835AEA046A32E
6 changed files with 118 additions and 68 deletions

View file

@ -4,6 +4,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits" import { expectAppVisible, expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewTabSwitch" const directory = "C:/OpenCode/ReviewTabSwitch"
const otherDirectory = "C:/OpenCode/ReviewTabSwitchOther"
const projectID = "proj_review_tab_switch" const projectID = "proj_review_tab_switch"
const sessionA = "ses_review_tab_a" const sessionA = "ses_review_tab_a"
const sessionB = "ses_review_tab_b" 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() 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 } type Probed = HTMLElement & { __e2eProbe?: string }
async function switchTab(page: Page, title: 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) 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, { await mockOpenCodeServer(page, {
directory, directory,
project: { project: {
@ -96,19 +114,22 @@ async function setup(page: Page) {
connected: ["opencode"], connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" }, 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, vcsDiff: diffs,
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
}) })
await page.addInitScript( await page.addInitScript(
({ directory, server, sessions }) => { ({ directories, server, sessions }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:server", "opencode.global.dat:server",
JSON.stringify({ JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] }, projects: { local: directories.map((worktree: string) => ({ worktree, expanded: true })) },
lastProject: { local: directory }, lastProject: { local: directories[0] },
}), }),
) )
localStorage.setItem( localStorage.setItem(
@ -116,16 +137,16 @@ async function setup(page: Page) {
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), 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 { return {
id, id,
slug: id, slug: id,
projectID, projectID,
directory, directory: worktree,
title, title,
version: "dev", version: "dev",
time: { created, updated: created }, time: { created, updated: created },

View file

@ -4,6 +4,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/TerminalTabSwitch" const directory = "C:/OpenCode/TerminalTabSwitch"
const otherDirectory = "C:/OpenCode/TerminalTabSwitchOther"
const projectID = "proj_terminal_tab_switch" const projectID = "proj_terminal_tab_switch"
const sessionA = "ses_terminal_tab_a" const sessionA = "ses_terminal_tab_a"
const sessionB = "ses_terminal_tab_b" 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) 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 } type Probed = HTMLElement & { __e2eProbe?: string }
async function switchTab(page: Page, title: 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) 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, { await mockOpenCodeServer(page, {
directory, directory,
project: { project: {
@ -82,7 +106,10 @@ async function setup(page: Page) {
connected: ["opencode"], connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" }, 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: [] }), pageMessages: () => ({ items: [] }),
}) })
await page.route("**/pty", (route) => await page.route("**/pty", (route) =>
@ -109,13 +136,13 @@ async function setup(page: Page) {
}) })
await page.addInitScript( await page.addInitScript(
({ directory, server, sessions }) => { ({ directories, server, sessions }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:server", "opencode.global.dat:server",
JSON.stringify({ JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] }, projects: { local: directories.map((worktree: string) => ({ worktree, expanded: true })) },
lastProject: { local: directory }, lastProject: { local: directories[0] },
}), }),
) )
localStorage.setItem( localStorage.setItem(
@ -123,17 +150,17 @@ async function setup(page: Page) {
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), 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 return connections
} }
function session(id: string, title: string, created: number) { function session(id: string, title: string, created: number, worktree: string) {
return { return {
id, id,
slug: id, slug: id,
projectID, projectID,
directory, directory: worktree,
title, title,
version: "dev", version: "dev",
time: { created, updated: created }, time: { created, updated: created },

View file

@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { useParams } from "@solidjs/router" 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 { createStore } from "solid-js/store"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
@ -26,6 +26,13 @@ type Saved = {
session: Record<string, State | undefined> session: Record<string, State | undefined>
} }
type SavedEntry = {
saved: Saved
setSaved: ReturnType<typeof createStore<Saved>>[1]
ready: ReturnType<typeof persisted<Saved>>[3]
dispose: VoidFunction
}
const WORKSPACE_KEY = "__workspace__" const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>() const handoff = new Map<string, State>()
@ -71,15 +78,29 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list())) const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved, , savedReady] = persisted( const savedCache = new Map<string, SavedEntry>()
{ const loadSaved = (serverScope: ServerScope, directory: string) => {
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]), const key = ScopedKey.from(serverScope, directory, "model-selection")
migrate, const existing = savedCache.get(key)
}, if (existing) return existing
createStore<Saved>({ const entry = createRoot((dispose) => {
session: {}, const [saved, setSaved, , ready] = persisted(
}), {
) ...Persist.serverWorkspace(serverScope, directory, "model-selection", ["model-selection.v1"]),
migrate,
},
createStore<Saved>({ 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<{ const [store, setStore] = createStore<{
current?: string current?: string
@ -127,7 +148,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const scope = createMemo<State | undefined>(() => { const scope = createMemo<State | undefined>(() => {
const session = id() const session = id()
if (!session) return store.draft ?? store.promoting 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(() => { createEffect(() => {
@ -137,13 +158,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const key = handoffKey(serverSDK().scope, sdk().directory, session) const key = handoffKey(serverSDK().scope, sdk().directory, session)
const next = handoff.get(key) const next = handoff.get(key)
if (!next) return if (!next) return
if (saved.session[session] !== undefined) { if (saved().saved.session[session] !== undefined) {
handoff.delete(key) handoff.delete(key)
setStore("promoting", undefined) setStore("promoting", undefined)
return return
} }
setSaved("session", session, clone(next)) saved().setSaved("session", session, clone(next))
handoff.delete(key) handoff.delete(key)
setStore("promoting", undefined) setStore("promoting", undefined)
}) })
@ -209,7 +230,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} satisfies State } satisfies State
const session = id() const session = id()
if (session) { if (session) {
setSaved("session", session, next) saved().setSaved("session", session, next)
return return
} }
setStore("draft", next) setStore("draft", next)
@ -270,7 +291,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const session = id() const session = id()
if (session) { if (session) {
setSaved("session", session, state) saved().setSaved("session", session, state)
return return
} }
setStore("draft", state) setStore("draft", state)
@ -378,7 +399,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model, model,
agent, agent,
session: { session: {
ready: savedReady, ready: () => saved().ready(),
reset() { reset() {
setStore({ draft: undefined, promoting: undefined }) setStore({ draft: undefined, promoting: undefined })
}, },
@ -389,7 +410,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
handoff.set(key, next) handoff.set(key, next)
if (dir === sdk().directory) { if (dir === sdk().directory) {
setSaved("session", session, next) saved().setSaved("session", session, next)
} }
setStore("promoting", next) setStore("promoting", next)
@ -399,10 +420,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const session = id() const session = id()
if (!session) return if (!session) return
if (msg.sessionID !== 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 if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
setSaved("session", session, { saved().setSaved("session", session, {
agent: msg.agent, agent: msg.agent,
model: msg.model, model: msg.model,
variant: msg.model?.variant ?? null, variant: msg.model?.variant ?? null,

View file

@ -58,18 +58,14 @@ export function DirectoryDataProvider(
}) })
return ( return (
<Show when={directory()} keyed> <DataProvider
{(directory) => ( data={sync().data}
<DataProvider directory={directory}
data={sync().data} onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
directory={directory} onSessionHref={href}
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))} >
onSessionHref={href} <LocalProvider>{props.children}</LocalProvider>
> </DataProvider>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)}
</Show>
) )
} }

View file

@ -251,7 +251,7 @@ function ResolvedTargetSessionRoute() {
() => params.id, () => params.id,
() => sync().session.lineage, () => sync().session.lineage,
) )
const directory = createMemo(() => current()?.session.directory) const directory = createMemo<string | undefined>((previous) => current()?.session.directory ?? previous)
const targetDirectory = () => directory()! const targetDirectory = () => directory()!
createEffect(() => { createEffect(() => {
@ -264,33 +264,17 @@ function ResolvedTargetSessionRoute() {
}) })
return ( return (
// Non-keyed: closes only while the target's directory is unknown (uncached // Keep the previous workspace mounted while an uncached target resolves.
// 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.
<Show when={directory()}> <Show when={directory()}>
<SDKProvider directory={targetDirectory}> <SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}> <DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage /> <SessionPage />
</DirectoryDataProvider> </DirectoryDataProvider>
</SDKProvider> </SDKProvider>
</Show> </Show>
) )
} }
// 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 (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionPage />
</Show>
)
}
function TargetServerScopedProviders( function TargetServerScopedProviders(
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>, props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
) { ) {

View file

@ -1,6 +1,7 @@
import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2" import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import type { Accessor } from "solid-js"
export type NormalizedProviderListResponse = { export type NormalizedProviderListResponse = {
all: Map<string, Provider> all: Map<string, Provider>
@ -45,7 +46,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data", name: "Data",
init: (props: { init: (props: {
data: Data data: Data
directory: string directory: string | Accessor<string>
onNavigateToSession?: NavigateToSessionFn onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn onSessionHref?: SessionHrefFn
}) => { }) => {
@ -54,7 +55,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return props.data return props.data
}, },
get directory() { get directory() {
return props.directory return typeof props.directory === "function" ? props.directory() : props.directory
}, },
navigateToSession: props.onNavigateToSession, navigateToSession: props.onNavigateToSession,
sessionHref: props.onSessionHref, sessionHref: props.onSessionHref,