mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
fix(app): keep side panel tabs when a session moves (#53081)
This commit is contained in:
committed by
GitHub
Unverified
parent
5589ca43f1
commit
077444b94b
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user