fix(app): keep workspace tab views alive (#48255)

This commit is contained in:
Luke Parker
2026-09-10 11:14:49 +10:00
committed by GitHub
Unverified
parent bdb66747e7
commit 20aff6d9f6
3 changed files with 111 additions and 3 deletions
@@ -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") })
})
+13 -2
View File
@@ -18,6 +18,7 @@ export function createTimelineCache(
visible: Accessor<boolean>,
) {
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
}
}
@@ -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 {