From 077444b94b9728a6fc42732efd6ff68357a734b0 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:59:52 +1000 Subject: [PATCH] fix(app): keep side panel tabs when a session moves (#53081) --- .../regression/session-location-sync.spec.ts | 95 +++++++++++++++++++ .../app/src/runtime/extension/host-apis.tsx | 43 ++++++++- packages/app/src/shell/state/layout.tsx | 23 +++++ 3 files changed, 159 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/regression/session-location-sync.spec.ts b/packages/app/e2e/regression/session-location-sync.spec.ts index 9a4310a2ef8..1f6a96c2f93 100644 --- a/packages/app/e2e/regression/session-location-sync.spec.ts +++ b/packages/app/e2e/regression/session-location-sync.spec.ts @@ -23,23 +23,30 @@ for (const endpoint of ["/api/location", "/api/agent"]) { let requests = 0 await page.route("**/api/**", (route) => { const url = new URL(route.request().url()) + if (url.pathname !== endpoint || url.searchParams.get("location[directory]") !== directory) return route.fallback() requests++ + if (recover && requests > 1) return route.fallback() + return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } }) }) + const failure = page.waitForResponse( (response) => new URL(response.url()).pathname === endpoint && response.status() === 500, ) + const settled = page.waitForResponse((response) => { const url = new URL(response.url()) + return ( url.pathname === endpoint && url.searchParams.get("location[directory]") === directory && (recover ? response.ok() : requests === 3 && response.status() === 500) ) }) + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) await failure await expect(page.getByText("Keep working in this worktree", { exact: true })).toBeVisible() @@ -86,10 +93,12 @@ test("follows a live session move while the agent catalog is still loading", asy }) await page.route("**/api/**", async (route) => { const url = new URL(route.request().url()) + if (url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === directory) { requested.resolve() await release.promise } + return route.fallback() }) await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) @@ -99,10 +108,13 @@ test("follows a live session move while the agent catalog is still loading", asy await prompt.fill("Keep this draft") await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) await transport.waitForConnection() + const resolved = page.waitForResponse((response) => { const url = new URL(response.url()) + return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok() }) + session.directory = destination await transport.send({ id: "evt_location_moved_while_loading", @@ -112,10 +124,13 @@ test("follows a live session move while the agent catalog is still loading", asy data: { sessionID, location: { directory: destination }, projectID: fixture.project.id }, }) await resolved + const delayed = page.waitForResponse((response) => { const url = new URL(response.url()) + return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === directory && response.ok() }) + release.resolve() await delayed await expect(prompt).toBeEditable() @@ -127,6 +142,76 @@ test("follows a live session move while the agent catalog is still loading", asy expect(recovery).toEqual([]) }) +test("keeps the side panel's tabs and selection when the session moves", async ({ page }) => { + const directory = "/projects/tabs-before-move" + const destination = "/projects/tabs-after-move" + const sessionID = "ses_location_moved_tabs" + const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved tabs" } + const transport = await installSseTransport(page, { server: fixture.serverKey }) + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + provider: fixture.provider, + sessions: [session], + fileList: () => [], + findFiles: (input) => ["README.md", "NOTES.md"].filter((name) => name.startsWith(input.query)), + fileContent: (path) => ({ type: "text", content: `contents:${session.directory}:${path}` }), + pageMessages: () => ({ + items: [{ id: "msg_saved", type: "user", text: "Keep my tabs", time: { created: 1 } }], + }), + }) + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) + await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable() + + const panel = page.getByRole("complementary", { name: "Review and files" }) + const files = panel.getByRole("tab", { name: /\.md$/ }) + const notes = panel.getByRole("tab", { name: "NOTES.md", exact: true }) + + // A lost selection would fall back to the first file tab, README.md, so NOTES.md stays selected only if it carries. + for (const name of ["README", "NOTES"]) { + await page.keyboard.press("ControlOrMeta+Shift+P") + + const dialog = page.getByRole("dialog") + const search = dialog.getByRole("textbox") + await search.fill(name) + await expect(dialog.getByRole("option", { name: `/ ${name}.md`, exact: true })).toHaveAttribute( + "aria-selected", + "true", + ) + await search.press("Enter") + await expect(dialog).toHaveCount(0) + await expect(panel.getByRole("tab", { name: `${name}.md`, exact: true })).toHaveAttribute("aria-selected", "true") + } + + await expect(files).toHaveText(["README.md", "NOTES.md"]) + await expect(panel.getByText(`contents:${directory}:NOTES.md`, { exact: true })).toBeVisible() + + await transport.waitForConnection() + + const resolved = page.waitForResponse((response) => { + const url = new URL(response.url()) + + return ( + url.pathname === "/api/location" && url.searchParams.get("location[directory]") === destination && response.ok() + ) + }) + + session.directory = destination + await transport.send({ + id: "evt_location_moved_tabs", + type: "session.moved", + created: 2, + durable: { aggregateID: sessionID, seq: 1, version: 1 }, + data: { sessionID, location: { directory: destination }, projectID: fixture.project.id }, + }) + await resolved + await expect(page.locator('[data-type="location-switched"]').getByText(destination, { exact: true })).toBeVisible() + await expect(files).toHaveText(["README.md", "NOTES.md"]) + await expect(notes).toHaveAttribute("aria-selected", "true") + // The carried tab shows the file in the new directory. + await expect(panel.getByText(`contents:${destination}:NOTES.md`, { exact: true })).toBeVisible() +}) + test("refreshes a session moved during disconnection without losing the draft", async ({ page }) => { const recovery = recoveryRequests(page) const directory = "/projects/before-reconnect" @@ -149,12 +234,15 @@ test("refreshes a session moved during disconnection without losing the draft", await expect(prompt).toBeEditable() await prompt.fill("Draft before disconnect") const connection = await transport.waitForConnection() + const resolved = page.waitForResponse((response) => { const url = new URL(response.url()) + return ( url.pathname === "/api/location" && url.searchParams.get("location[directory]") === destination && response.ok() ) }) + session.directory = destination await transport.close() await transport.waitForConnection({ after: connection.id }) @@ -186,9 +274,11 @@ test("ignores an old failed location read after reconnecting", async ({ page }) await page.route("**/api/location?**", async (route) => { if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback() requests++ + if (requests > 1) return route.fallback() requested.resolve() await release.promise + return route.fulfill({ status: 500, body: "", @@ -202,12 +292,15 @@ test("ignores an old failed location read after reconnecting", async ({ page }) await prompt.fill("Keep typing here") const connection = await transport.waitForConnection() const metadata = page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${sessionID}`) + const resolved = page.waitForResponse((response) => { const url = new URL(response.url()) + return ( url.pathname === "/api/location" && url.searchParams.get("location[directory]") === directory && response.ok() ) }) + await transport.close() await transport.waitForConnection({ after: connection.id }) await metadata @@ -227,7 +320,9 @@ function recoveryRequests(page: Page) { const requests: string[] = [] page.on("request", (request) => { const path = new URL(request.url()).pathname + if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move|worktree)$/.test(path)) requests.push(path) }) + return requests } diff --git a/packages/app/src/runtime/extension/host-apis.tsx b/packages/app/src/runtime/extension/host-apis.tsx index 5d90d76bc58..0fc20067214 100644 --- a/packages/app/src/runtime/extension/host-apis.tsx +++ b/packages/app/src/runtime/extension/host-apis.tsx @@ -1,5 +1,6 @@ import { batch, + createComputed, createEffect, createMemo, createSignal, @@ -373,6 +374,7 @@ export function createExtensionAttachment(apis: HostApis) { const refs = new Map() const connection = (id: string) => global.servers.list().find((item) => ServerConnection.key(item) === id) + const servers = useServers() // One ref per server id. A restarted server (e.g. an updated WSL server) gets a new controller under the same id, // so the ref follows the live controller instead of the one it was created with. @@ -481,17 +483,55 @@ export function createExtensionAttachment(apis: HostApis) { return global.ensureServerCtx(conn).sdk.scope } + // The registry's scope is pure: an unlisted server's sessions (e.g. a stopped WSL server, whose tabs stay open) keep + // their key, and no server controller starts for it. const stateKey = (session: SessionRef) => { const location = session.location if (!location) return return SessionStateKey.from( - scope(session.server.id), + servers.scope(ServerConnection.Key.make(session.server.id)), SessionRouteKey.fromRoute(base64Encode(location.directory), session.id), ) } + // Each session's layout key, kept while its location is unknown (e.g. after the server re-authenticates). + const stateKeys = createMemo>( + (previous) => + new Map( + sessions().flatMap((session) => { + const value = stateKey(session) ?? previous.get(session.key) + + return value ? [[session.key, value] as const] : [] + }), + ), + new Map(), + ) + + // The strip is stored per directory, so a session that moves copies its strip to its new key: its tabs look the same + // before and after the move. Synchronous, so no region renders the new key before the copy lands. A layout write + // before desktop layout storage loads would stop it loading, so `lastKeys` keeps each session's first key until then. + const lastKeys = new Map() + createComputed(() => { + const next = stateKeys() + Array.from(lastKeys.keys()).forEach((key) => { + if (!next.has(key)) lastKeys.delete(key) + }) + next.forEach((to, key) => { + if (!lastKeys.has(key)) lastKeys.set(key, to) + }) + + if (!layout.ready()) return + next.forEach((to, key) => { + const from = lastKeys.get(key) + + if (!from || from === to) return + layout.panel.copy(from, to) + lastKeys.set(key, to) + }) + }) + const shellTab = (session: SessionRef) => findSessionTab(tabs.store, ServerConnection.Key.make(session.server.id), session.id) @@ -665,7 +705,6 @@ export function createExtensionAttachment(apis: HostApis) { } // Open-project requests wait until their server is listed (e.g. an SSH server that just connected). - const servers = useServers() const picker = useDirectoryPicker() const [projects, setProjects] = createSignal([]) createEffect(() => { diff --git a/packages/app/src/shell/state/layout.tsx b/packages/app/src/shell/state/layout.tsx index 6ce7c20c436..fc9f001451d 100644 --- a/packages/app/src/shell/state/layout.tsx +++ b/packages/app/src/shell/state/layout.tsx @@ -567,6 +567,29 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setEphemeral("sessionTabPreview", session, next.preview) }) }, + /** + * Replaces `to`'s strip, selection, preview tab and scroll offsets with copies of `from`'s, keeping `from`; a key + * with nothing stored copies as empty. `to` counts as just used, then pruning runs as for any new key. + */ + copy(from: string, to: string) { + const current = store.sessionTabs[from] + usage.used.set(to, Date.now()) + batch(() => { + scroll.flush(from) + setStore("sessionTabs", to, { all: [...(current?.all ?? [])], active: current?.active }) + setEphemeral("sessionTabPreview", to, ephemeral.sessionTabPreview[from]) + scroll.drop([to]) + setStore("sessionView", to, { + scroll: Object.fromEntries( + Object.entries(store.sessionView[from]?.scroll ?? {}).map(([key, value]) => [ + key, + { x: value.x, y: value.y }, + ]), + ), + }) + prune(usage.active ?? to) + }) + }, scroll(session: string, tab: string) { return scroll.scroll(session, tab) },