Переглянути джерело

fix(app): scope session tab indicators to the tab's server (#34861)

Luke Parker 1 місяць тому
батько
коміт
1fb2ecc721

+ 109 - 0
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" })
+}

+ 1 - 2
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}
                 />
               </span>
             )}

+ 0 - 4
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) => {

+ 0 - 1
packages/app/src/components/titlebar.tsx

@@ -456,7 +456,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                 <TitlebarTabStrip
                   tabs={tabsStore}
                   currentTab={currentTab}
-                  activeServerKey={server.key}
                   forceTruncate={tabsAreOverflowing()}
                   onOverflowChange={setTabsAreOverflowing}
                   onNavigate={(tab, el) => {

+ 1 - 10
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}
       />
     </div>
@@ -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()}
       />
       <div class="flex min-w-0 flex-1 items-center gap-1.5">
@@ -1297,7 +1290,6 @@ function HomeSessionRow(props: {
   record: HomeSessionRecord
   showProjectName: boolean
   server: ServerConnection.Key
-  activeServer: boolean
   openSession: (session: Session) => void
   archiveSession: (session: Session) => Promise<void>
 }) {
@@ -1320,7 +1312,6 @@ function HomeSessionRow(props: {
           project={props.record.project}
           session={props.record.session}
           server={props.server}
-          activeServer={props.activeServer}
           revealProjectOnHover={!!showProjectName()}
         />
         <span

+ 21 - 9
packages/app/src/pages/layout/project-avatar-state.ts

@@ -1,29 +1,41 @@
 import { createMemo, type Accessor } from "solid-js"
-import { useServerSync } from "@/context/server-sync"
+import { useGlobal } from "@/context/global"
 import { useNotification } from "@/context/notification"
 import { usePermission } from "@/context/permission"
+import { ServerConnection } from "@/context/server"
 import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
 
 export function useSessionTabAvatarState(
+  server: Accessor<ServerConnection.Key>,
   directory: Accessor<string>,
   sessionId: Accessor<string>,
-  active: Accessor<boolean> = () => 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 }
 }

+ 7 - 4
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 = () => (
     <ProjectAvatar
       fallback={displayName(props.project ?? { worktree: props.directory })}