fix(app): keep side panel tabs when a session moves (#53081)

This commit is contained in:
Luke Parker
2026-10-04 16:59:52 +10:00
committed by GitHub
Unverified
parent 5589ca43f1
commit 077444b94b
3 changed files with 159 additions and 2 deletions
@@ -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
}
@@ -1,5 +1,6 @@
import {
batch,
createComputed,
createEffect,
createMemo,
createSignal,
@@ -373,6 +374,7 @@ export function createExtensionAttachment(apis: HostApis) {
const refs = new Map<string, SessionRef>()
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<ReadonlyMap<string, string>>(
(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<string, string>()
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<readonly { server: string; title: string }[]>([])
createEffect(() => {
+23
View File
@@ -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)
},