From 20aff6d9f643afe9abf8a048e68f019d049f5329 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:14:49 +1000 Subject: [PATCH] fix(app): keep workspace tab views alive (#48255) --- .../session-workspace-tab-cycle.spec.ts | 68 +++++++++++++++++++ packages/app/src/session/timeline/cache.ts | 15 +++- .../app/test-browser/timeline-cache.test.ts | 31 ++++++++- 3 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 packages/app/e2e/regression/session-workspace-tab-cycle.spec.ts diff --git a/packages/app/e2e/regression/session-workspace-tab-cycle.spec.ts b/packages/app/e2e/regression/session-workspace-tab-cycle.spec.ts new file mode 100644 index 00000000000..fb123dba73d --- /dev/null +++ b/packages/app/e2e/regression/session-workspace-tab-cycle.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from "@playwright/test" +import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise" +import { mockOpenCodeServer } from "../utils/mock-server" +import { fixture } from "../performance/timeline/session-timeline-stress.fixture" +import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers" + +test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" }) + +test("keeps five loaded workspace tabs visible and reactive through repeated switches", async ({ page }, info) => { + const sessions = Array.from({ length: 5 }, (_, index) => ({ + ...fixture.sessions[0]!, + id: `ses_workspace_cycle_${index}`, + directory: `${fixture.directory}/worktree-${index}`, + title: `Workspace session ${index}`, + })) + const events: OpenCodeEvent[] = [] + await mockOpenCodeServer(page, { + ...fixture, + sessions, + pageMessages: (id) => ({ + items: [ + { id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } }, + { + id: `msg_assistant_${id}`, + type: "assistant", + agent: "build", + model: { id: "claude-opus-4-6", providerID: "opencode" }, + time: { created: 2, completed: 3 }, + content: [{ type: "text", text: `Answer for ${id}` }], + }, + ] satisfies SessionMessageInfo[], + }), + events: () => events.splice(0), + }) + await page.route("**/api/location?*", (route) => + route.fulfill({ + json: { + directory: new URL(route.request().url()).searchParams.get("location[directory]"), + project: { id: fixture.project.id, directory: fixture.directory, canonical: fixture.directory }, + }, + }), + ) + await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) }) + await page.goto(stressSessionHref(sessions[0]!.id)) + await expect(page.getByText(`Answer for ${sessions[0]!.id}`, { exact: true })).toBeVisible() + + for (const session of [...sessions.slice(1), ...sessions, ...sessions.toReversed()]) { + await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(session.id)}"]`).click() + await expect(page.locator(`[data-timeline-part-id="msg_assistant_${session.id}:text:0"]`)).toBeVisible() + await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible") + } + const active = sessions[0]! + events.push({ + id: "evt_workspace_cycle_update", + created: 4, + type: "session.text.ended", + location: { directory: active.directory }, + durable: { aggregateID: active.id, seq: 0, version: 1 }, + data: { + sessionID: active.id, + assistantMessageID: `msg_assistant_${active.id}`, + ordinal: 0, + text: "Still receiving updates", + }, + }) + await expect(page.getByText("Still receiving updates", { exact: true })).toBeVisible() + await page.screenshot({ path: info.outputPath("workspace-tabs.png") }) +}) diff --git a/packages/app/src/session/timeline/cache.ts b/packages/app/src/session/timeline/cache.ts index 6496fb01ac5..055a5a55e21 100644 --- a/packages/app/src/session/timeline/cache.ts +++ b/packages/app/src/session/timeline/cache.ts @@ -18,6 +18,7 @@ export function createTimelineCache( visible: Accessor, ) { const owner = getOwner() + let workspace = untrack(session.identity.workspaceKey) const cache = createScopedCache( (key) => createRoot((dispose) => { @@ -51,8 +52,18 @@ export function createTimelineCache( { maxEntries: 16, dispose: (entry) => entry.dispose() }, ) onCleanup(cache.clear) + const syncWorkspace = (key: string) => { + if (workspace === key) return + workspace = key + cache.clear() + } // Providers follow the selected Location even while its history is loading. // Dispose detached views before their effects can read the new Location. - createComputed(on(session.identity.workspaceKey, cache.clear, { defer: true })) - return () => cache.get(session.identity.sessionKey()).value + createComputed(on(session.identity.workspaceKey, syncWorkspace, { defer: true })) + return () => { + // A tab's render can run before the workspace watcher in the same batch. + // Clear the old workspace here, and let that later watcher keep this view. + syncWorkspace(session.identity.workspaceKey()) + return cache.get(session.identity.sessionKey()).value + } } diff --git a/packages/app/test-browser/timeline-cache.test.ts b/packages/app/test-browser/timeline-cache.test.ts index 3d3842eba21..af1b691107d 100644 --- a/packages/app/test-browser/timeline-cache.test.ts +++ b/packages/app/test-browser/timeline-cache.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import type { SessionMessageInfo } from "@opencode/client/promise" -import { createMemo, createRoot, onCleanup } from "solid-js" +import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { ServerScope, SessionRouteKey, SessionStateKey } from "../src/runtime/server/scope" import { createTimelineCache } from "../src/session/timeline/cache" @@ -144,6 +144,35 @@ test("disposes views on workspace changes while the destination is not rendered" expect(input.disposed).toEqual(["ses_a", "ses_a"]) }) +for (const order of ["session-first", "workspace-first"] as const) { + test(`keeps views live across five workspaces when updates are ${order}`, () => { + const input = setup() + const render = createRoot((dispose) => ({ selected: createMemo(input.cache), dispose })) + const visited = ["ses_a"] + try { + ;["ses_b", "ses_c", "ses_d", "ses_e", "ses_a", "ses_c", "ses_b", "ses_e", "ses_d", "ses_a"].forEach( + (id, index) => { + batch(() => { + if (order === "workspace-first") input.setState("directory", `/repo/${id}`) + input.setState("id", id) + if (order === "session-first") input.setState("directory", `/repo/${id}`) + }) + expect(input.disposed).toEqual(visited) + input.setState("messages", id, [ + { id: `msg_live_${index}`, type: "user", text: "Live update", time: { created: index + 3 } }, + ]) + expect((render.selected() as HTMLDivElement).dataset.messages).toBe(`msg_live_${index}`) + expect(input.views.get(id)!.active()).toBe(true) + visited.push(id) + }, + ) + } finally { + render.dispose() + input.dispose() + } + }) +} + test("evicts the least recently selected view and disposes all retained owners", () => { const input = setup() try {