From 973dff5da5d191d71726b7b9d33221dfc289633a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:33:26 +1000 Subject: [PATCH] fix(app): scope session tab indicators to the tab's server --- .../e2e/regression/remote-tab-busy.spec.ts | 109 ++++++++++++++++++ .../app/src/components/titlebar-tab-nav.tsx | 3 +- .../app/src/components/titlebar-tab-strip.tsx | 4 - packages/app/src/components/titlebar.tsx | 1 - packages/app/src/pages/home.tsx | 11 +- .../src/pages/layout/project-avatar-state.ts | 30 +++-- .../src/pages/layout/session-tab-avatar.tsx | 11 +- 7 files changed, 139 insertions(+), 30 deletions(-) create mode 100644 packages/app/e2e/regression/remote-tab-busy.spec.ts diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts new file mode 100644 index 000000000000..f19907745c63 --- /dev/null +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -0,0 +1,109 @@ +import { expect, test, type Page, type Route } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" + +const serverA = "http://127.0.0.1:4096" +const serverB = "http://127.0.0.1:4097" +const sessionA = session("ses_server_a", "C:/server-a", "Server A session") +const sessionB = session("ses_server_b", "/home/server-b", "Server B session") + +test("tab busy indicator reflects the tab server's own session status", async ({ page }) => { + await mockServers(page) + await page.addInitScript( + ({ serverA, serverB, sessionA, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server: serverA, sessionId: sessionA }, + { type: "session", server: serverB, sessionId: sessionB }, + ]), + ) + }, + { serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id }, + ) + + const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}` + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(hrefA) + await expect(page.getByText(sessionA.title).first()).toBeVisible() + + // Session B is busy on server B while server A stays the active server, so the + // busy indicator must come from the tab server's status, not the active server's. + const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`) + await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible() + + const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`) + await expect(tabA.locator('[data-titlebar-tab-title]')).toHaveText(sessionA.title) + await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0) +}) + +function session(id: string, directory: string, title: string) { + return { + id, + slug: id, + projectID: `project-${id}`, + directory, + title, + version: "dev", + time: { created: 1, updated: 1 }, + } +} + +async function mockServers(page: Page) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== serverA && url.origin !== serverB) return route.fallback() + const current = url.origin === serverA ? sessionA : sessionB + const directory = url.searchParams.get("directory") + if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) + if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/session/status") + return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {}) + if (url.pathname === "/session") return json(route, [current]) + if (url.pathname === `/session/${current.id}`) return json(route, current) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (url.pathname === `/session/${current.id}/message`) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (url.pathname === "/provider") + return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: current.projectID, + worktree: current.directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + return json(route, {}) + }) +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +} diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index b9ea3208c13e..3d5b310f18e1 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -28,7 +28,6 @@ export function TabNavItem(props: { onClose: () => void onNavigate: () => void active?: boolean - activeServer: boolean forceTruncate?: boolean suppressNavigation?: () => boolean dragging?: boolean @@ -245,7 +244,7 @@ export function TabNavItem(props: { project={project()} directory={session().directory} sessionId={session().id} - activeServer={props.activeServer} + server={props.server} /> )} diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 9b877614d11d..68032e83c998 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -24,7 +24,6 @@ function SessionTabSlot(props: { id: string index: () => number active: () => boolean - activeServerKey: ServerConnection.Key forceTruncate: boolean serverCtx: () => ServerCtx | undefined onNavigate: (element: HTMLDivElement) => void @@ -112,7 +111,6 @@ function SessionTabSlot(props: { onNavigate={() => props.onNavigate(ref)} onClose={props.onClose} active={props.active()} - activeServer={props.tab.server === props.activeServerKey} forceTruncate={props.forceTruncate} dragging={sortable.isDragSource()} /> @@ -165,7 +163,6 @@ function DraftTabSlot(props: { export function TitlebarTabStrip(props: { tabs: Tab[] currentTab: () => Tab | undefined - activeServerKey: ServerConnection.Key forceTruncate: boolean onNavigate: (tab: Tab, el?: HTMLDivElement) => void onClose: (tab: Tab) => void @@ -271,7 +268,6 @@ export function TitlebarTabStrip(props: { id={id} index={index} active={() => props.currentTab() === tab} - activeServerKey={props.activeServerKey} forceTruncate={props.forceTruncate} serverCtx={serverCtx} onNavigate={(element) => { diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index e772aa400acb..703082d55370 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -456,7 +456,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { { diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index faea274b849e..799049526059 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -552,7 +552,6 @@ export function NewHome() { results={searchResults()} showProjectName={!selectedProject()} server={selection().server} - activeServer={selection().server === server.key} noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })} bindFocus={(focus) => { focusSessionSearch = focus @@ -612,7 +611,6 @@ export function NewHome() { record={record} showProjectName={!selectedProject()} server={selection().server} - activeServer={selection().server === server.key} openSession={openSession} archiveSession={archiveSession} /> @@ -996,7 +994,6 @@ function HomeSessionLeading(props: { project: LocalProject session: Session server: ServerConnection.Key - activeServer: boolean revealProjectOnHover: boolean }) { const tabs = useTabs() @@ -1014,7 +1011,7 @@ function HomeSessionLeading(props: { project={props.project} directory={props.session.directory} sessionId={props.session.id} - activeServer={props.activeServer} + server={props.server} revealProjectOnHover={props.revealProjectOnHover} /> @@ -1029,7 +1026,6 @@ function HomeSessionSearch(props: { results: HomeSessionRecord[] showProjectName: boolean server: ServerConnection.Key - activeServer: boolean noResultsLabel: string bindFocus: (focus: () => void) => void onInput: (value: string) => void @@ -1147,7 +1143,6 @@ function HomeSessionSearch(props: { record={record} showProjectName={props.showProjectName} server={props.server} - activeServer={props.activeServer} selected={store.active === homeSessionSearchKey(record)} onHighlight={() => setStore("active", homeSessionSearchKey(record))} onSelect={(session) => props.onSelect(session)} @@ -1228,7 +1223,6 @@ function HomeSessionSearchResultRow(props: { record: HomeSessionRecord showProjectName: boolean server: ServerConnection.Key - activeServer: boolean selected: boolean onHighlight: () => void onSelect: (session: Session) => void @@ -1258,7 +1252,6 @@ function HomeSessionSearchResultRow(props: { project={props.record.project} session={props.record.session} server={props.server} - activeServer={props.activeServer} revealProjectOnHover={!!showProjectName()} />
@@ -1297,7 +1290,6 @@ function HomeSessionRow(props: { record: HomeSessionRecord showProjectName: boolean server: ServerConnection.Key - activeServer: boolean openSession: (session: Session) => void archiveSession: (session: Session) => Promise }) { @@ -1320,7 +1312,6 @@ function HomeSessionRow(props: { project={props.record.project} session={props.record.session} server={props.server} - activeServer={props.activeServer} revealProjectOnHover={!!showProjectName()} /> , directory: Accessor, sessionId: Accessor, - active: Accessor = () => true, ) { - const globalSync = useServerSync() + const global = useGlobal() const notification = useNotification() const permission = usePermission() + const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server())) + const sync = createMemo(() => { + const conn = connection() + if (conn) return global.ensureServerCtx(conn).sync + }) const hasPermissions = createMemo(() => { - if (!active()) return false - const [store] = globalSync().child(directory(), { bootstrap: false }) - return !!sessionPermissionRequest(store.session, globalSync().session.data.permission, sessionId(), (item) => { + const serverSync = sync() + if (!serverSync) return false + const [store] = serverSync.child(directory(), { bootstrap: false }) + return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => { return !permission.autoResponds(item, directory()) }) }) - const unread = createMemo(() => active() && (hasPermissions() || notification.session.unseenCount(sessionId()) > 0)) + const unread = createMemo(() => { + if (hasPermissions()) return true + if (!connection()) return false + return notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0 + }) const loading = createMemo(() => { - if (!active()) return false + const serverSync = sync() + if (!serverSync) return false if (hasPermissions()) return false - return globalSync().session.data.session_working(sessionId()) + return serverSync.session.data.session_working(sessionId()) }) return { unread, loading } } diff --git a/packages/app/src/pages/layout/session-tab-avatar.tsx b/packages/app/src/pages/layout/session-tab-avatar.tsx index 9124f3216fdc..3c776c86712d 100644 --- a/packages/app/src/pages/layout/session-tab-avatar.tsx +++ b/packages/app/src/pages/layout/session-tab-avatar.tsx @@ -1,5 +1,6 @@ import type { LocalProject } from "@/context/layout" import { getProjectAvatarVariant } from "@/context/layout" +import type { ServerConnection } from "@/context/server" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" @@ -10,12 +11,14 @@ export function SessionTabAvatar(props: { project?: LocalProject directory: string sessionId: string - activeServer: boolean + server: ServerConnection.Key revealProjectOnHover?: boolean }) { - const directory = () => props.directory - const sessionId = () => props.sessionId - const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer) + const state = useSessionTabAvatarState( + () => props.server, + () => props.directory, + () => props.sessionId, + ) const projectAvatar = () => (