Skip to content

Commit 1fb2ecc

Browse files
authored
fix(app): scope session tab indicators to the tab's server (#34861)
1 parent 27c9b13 commit 1fb2ecc

7 files changed

Lines changed: 139 additions & 30 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { expect, test, type Page, type Route } from "@playwright/test"
2+
import { base64Encode } from "@opencode-ai/core/util/encode"
3+
4+
const serverA = "http://127.0.0.1:4096"
5+
const serverB = "http://127.0.0.1:4097"
6+
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
7+
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
8+
9+
test("tab busy indicator reflects the tab server's own session status", async ({ page }) => {
10+
await mockServers(page)
11+
await page.addInitScript(
12+
({ serverA, serverB, sessionA, sessionB }) => {
13+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
14+
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
15+
localStorage.setItem(
16+
"opencode.window.browser.dat:tabs",
17+
JSON.stringify([
18+
{ type: "session", server: serverA, sessionId: sessionA },
19+
{ type: "session", server: serverB, sessionId: sessionB },
20+
]),
21+
)
22+
},
23+
{ serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id },
24+
)
25+
26+
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
27+
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
28+
await page.goto(hrefA)
29+
await expect(page.getByText(sessionA.title).first()).toBeVisible()
30+
31+
// Session B is busy on server B while server A stays the active server, so the
32+
// busy indicator must come from the tab server's status, not the active server's.
33+
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
34+
await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
35+
36+
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
37+
await expect(tabA.locator('[data-titlebar-tab-title]')).toHaveText(sessionA.title)
38+
await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
39+
})
40+
41+
function session(id: string, directory: string, title: string) {
42+
return {
43+
id,
44+
slug: id,
45+
projectID: `project-${id}`,
46+
directory,
47+
title,
48+
version: "dev",
49+
time: { created: 1, updated: 1 },
50+
}
51+
}
52+
53+
async function mockServers(page: Page) {
54+
await page.route("**/*", async (route) => {
55+
const url = new URL(route.request().url())
56+
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
57+
const current = url.origin === serverA ? sessionA : sessionB
58+
const directory = url.searchParams.get("directory")
59+
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
60+
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
61+
if (url.pathname === "/global/health") return json(route, { healthy: true })
62+
if (url.pathname === "/session/status")
63+
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
64+
if (url.pathname === "/session") return json(route, [current])
65+
if (url.pathname === `/session/${current.id}`) return json(route, current)
66+
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
67+
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
68+
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
69+
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
70+
return json(route, [])
71+
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
72+
if (url.pathname === "/provider")
73+
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
74+
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
75+
if (url.pathname === "/project" || url.pathname === "/project/current") {
76+
const project = {
77+
id: current.projectID,
78+
worktree: current.directory,
79+
vcs: "git",
80+
time: { created: 1, updated: 1 },
81+
sandboxes: [],
82+
}
83+
return json(route, url.pathname === "/project" ? [project] : project)
84+
}
85+
if (url.pathname === "/path")
86+
return json(route, {
87+
state: current.directory,
88+
config: current.directory,
89+
worktree: current.directory,
90+
directory: current.directory,
91+
home: current.directory,
92+
})
93+
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
94+
return json(route, {})
95+
})
96+
}
97+
98+
function json(route: Route, body: unknown, status = 200) {
99+
return route.fulfill({
100+
status,
101+
contentType: "application/json",
102+
headers: { "access-control-allow-origin": "*" },
103+
body: JSON.stringify(body),
104+
})
105+
}
106+
107+
function sse(route: Route) {
108+
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
109+
}

packages/app/src/components/titlebar-tab-nav.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ export function TabNavItem(props: {
2828
onClose: () => void
2929
onNavigate: () => void
3030
active?: boolean
31-
activeServer: boolean
3231
forceTruncate?: boolean
3332
suppressNavigation?: () => boolean
3433
dragging?: boolean
@@ -245,7 +244,7 @@ export function TabNavItem(props: {
245244
project={project()}
246245
directory={session().directory}
247246
sessionId={session().id}
248-
activeServer={props.activeServer}
247+
server={props.server}
249248
/>
250249
</span>
251250
)}

packages/app/src/components/titlebar-tab-strip.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ function SessionTabSlot(props: {
2424
id: string
2525
index: () => number
2626
active: () => boolean
27-
activeServerKey: ServerConnection.Key
2827
forceTruncate: boolean
2928
serverCtx: () => ServerCtx | undefined
3029
onNavigate: (element: HTMLDivElement) => void
@@ -112,7 +111,6 @@ function SessionTabSlot(props: {
112111
onNavigate={() => props.onNavigate(ref)}
113112
onClose={props.onClose}
114113
active={props.active()}
115-
activeServer={props.tab.server === props.activeServerKey}
116114
forceTruncate={props.forceTruncate}
117115
dragging={sortable.isDragSource()}
118116
/>
@@ -165,7 +163,6 @@ function DraftTabSlot(props: {
165163
export function TitlebarTabStrip(props: {
166164
tabs: Tab[]
167165
currentTab: () => Tab | undefined
168-
activeServerKey: ServerConnection.Key
169166
forceTruncate: boolean
170167
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
171168
onClose: (tab: Tab) => void
@@ -271,7 +268,6 @@ export function TitlebarTabStrip(props: {
271268
id={id}
272269
index={index}
273270
active={() => props.currentTab() === tab}
274-
activeServerKey={props.activeServerKey}
275271
forceTruncate={props.forceTruncate}
276272
serverCtx={serverCtx}
277273
onNavigate={(element) => {

packages/app/src/components/titlebar.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
456456
<TitlebarTabStrip
457457
tabs={tabsStore}
458458
currentTab={currentTab}
459-
activeServerKey={server.key}
460459
forceTruncate={tabsAreOverflowing()}
461460
onOverflowChange={setTabsAreOverflowing}
462461
onNavigate={(tab, el) => {

packages/app/src/pages/home.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,6 @@ export function NewHome() {
552552
results={searchResults()}
553553
showProjectName={!selectedProject()}
554554
server={selection().server}
555-
activeServer={selection().server === server.key}
556555
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
557556
bindFocus={(focus) => {
558557
focusSessionSearch = focus
@@ -612,7 +611,6 @@ export function NewHome() {
612611
record={record}
613612
showProjectName={!selectedProject()}
614613
server={selection().server}
615-
activeServer={selection().server === server.key}
616614
openSession={openSession}
617615
archiveSession={archiveSession}
618616
/>
@@ -996,7 +994,6 @@ function HomeSessionLeading(props: {
996994
project: LocalProject
997995
session: Session
998996
server: ServerConnection.Key
999-
activeServer: boolean
1000997
revealProjectOnHover: boolean
1001998
}) {
1002999
const tabs = useTabs()
@@ -1014,7 +1011,7 @@ function HomeSessionLeading(props: {
10141011
project={props.project}
10151012
directory={props.session.directory}
10161013
sessionId={props.session.id}
1017-
activeServer={props.activeServer}
1014+
server={props.server}
10181015
revealProjectOnHover={props.revealProjectOnHover}
10191016
/>
10201017
</div>
@@ -1029,7 +1026,6 @@ function HomeSessionSearch(props: {
10291026
results: HomeSessionRecord[]
10301027
showProjectName: boolean
10311028
server: ServerConnection.Key
1032-
activeServer: boolean
10331029
noResultsLabel: string
10341030
bindFocus: (focus: () => void) => void
10351031
onInput: (value: string) => void
@@ -1147,7 +1143,6 @@ function HomeSessionSearch(props: {
11471143
record={record}
11481144
showProjectName={props.showProjectName}
11491145
server={props.server}
1150-
activeServer={props.activeServer}
11511146
selected={store.active === homeSessionSearchKey(record)}
11521147
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
11531148
onSelect={(session) => props.onSelect(session)}
@@ -1228,7 +1223,6 @@ function HomeSessionSearchResultRow(props: {
12281223
record: HomeSessionRecord
12291224
showProjectName: boolean
12301225
server: ServerConnection.Key
1231-
activeServer: boolean
12321226
selected: boolean
12331227
onHighlight: () => void
12341228
onSelect: (session: Session) => void
@@ -1258,7 +1252,6 @@ function HomeSessionSearchResultRow(props: {
12581252
project={props.record.project}
12591253
session={props.record.session}
12601254
server={props.server}
1261-
activeServer={props.activeServer}
12621255
revealProjectOnHover={!!showProjectName()}
12631256
/>
12641257
<div class="flex min-w-0 flex-1 items-center gap-1.5">
@@ -1297,7 +1290,6 @@ function HomeSessionRow(props: {
12971290
record: HomeSessionRecord
12981291
showProjectName: boolean
12991292
server: ServerConnection.Key
1300-
activeServer: boolean
13011293
openSession: (session: Session) => void
13021294
archiveSession: (session: Session) => Promise<void>
13031295
}) {
@@ -1320,7 +1312,6 @@ function HomeSessionRow(props: {
13201312
project={props.record.project}
13211313
session={props.record.session}
13221314
server={props.server}
1323-
activeServer={props.activeServer}
13241315
revealProjectOnHover={!!showProjectName()}
13251316
/>
13261317
<span
Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,41 @@
11
import { createMemo, type Accessor } from "solid-js"
2-
import { useServerSync } from "@/context/server-sync"
2+
import { useGlobal } from "@/context/global"
33
import { useNotification } from "@/context/notification"
44
import { usePermission } from "@/context/permission"
5+
import { ServerConnection } from "@/context/server"
56
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
67

78
export function useSessionTabAvatarState(
9+
server: Accessor<ServerConnection.Key>,
810
directory: Accessor<string>,
911
sessionId: Accessor<string>,
10-
active: Accessor<boolean> = () => true,
1112
) {
12-
const globalSync = useServerSync()
13+
const global = useGlobal()
1314
const notification = useNotification()
1415
const permission = usePermission()
16+
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
17+
const sync = createMemo(() => {
18+
const conn = connection()
19+
if (conn) return global.ensureServerCtx(conn).sync
20+
})
1521
const hasPermissions = createMemo(() => {
16-
if (!active()) return false
17-
const [store] = globalSync().child(directory(), { bootstrap: false })
18-
return !!sessionPermissionRequest(store.session, globalSync().session.data.permission, sessionId(), (item) => {
22+
const serverSync = sync()
23+
if (!serverSync) return false
24+
const [store] = serverSync.child(directory(), { bootstrap: false })
25+
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
1926
return !permission.autoResponds(item, directory())
2027
})
2128
})
22-
const unread = createMemo(() => active() && (hasPermissions() || notification.session.unseenCount(sessionId()) > 0))
29+
const unread = createMemo(() => {
30+
if (hasPermissions()) return true
31+
if (!connection()) return false
32+
return notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0
33+
})
2334
const loading = createMemo(() => {
24-
if (!active()) return false
35+
const serverSync = sync()
36+
if (!serverSync) return false
2537
if (hasPermissions()) return false
26-
return globalSync().session.data.session_working(sessionId())
38+
return serverSync.session.data.session_working(sessionId())
2739
})
2840
return { unread, loading }
2941
}

packages/app/src/pages/layout/session-tab-avatar.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { LocalProject } from "@/context/layout"
22
import { getProjectAvatarVariant } from "@/context/layout"
3+
import type { ServerConnection } from "@/context/server"
34
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
45
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
56
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
@@ -10,12 +11,14 @@ export function SessionTabAvatar(props: {
1011
project?: LocalProject
1112
directory: string
1213
sessionId: string
13-
activeServer: boolean
14+
server: ServerConnection.Key
1415
revealProjectOnHover?: boolean
1516
}) {
16-
const directory = () => props.directory
17-
const sessionId = () => props.sessionId
18-
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
17+
const state = useSessionTabAvatarState(
18+
() => props.server,
19+
() => props.directory,
20+
() => props.sessionId,
21+
)
1922
const projectAvatar = () => (
2023
<ProjectAvatar
2124
fallback={displayName(props.project ?? { worktree: props.directory })}

0 commit comments

Comments
 (0)