fix(app): scope session tab indicators to the tab's server (#34861)
This commit is contained in:
parent
27c9b130c7
commit
1fb2ecc721
7 changed files with 139 additions and 30 deletions
109
packages/app/e2e/regression/remote-tab-busy.spec.ts
Normal file
109
packages/app/e2e/regression/remote-tab-busy.spec.ts
Normal file
|
|
@ -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" })
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,6 @@ export function TabNavItem(props: {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onNavigate: () => void
|
onNavigate: () => void
|
||||||
active?: boolean
|
active?: boolean
|
||||||
activeServer: boolean
|
|
||||||
forceTruncate?: boolean
|
forceTruncate?: boolean
|
||||||
suppressNavigation?: () => boolean
|
suppressNavigation?: () => boolean
|
||||||
dragging?: boolean
|
dragging?: boolean
|
||||||
|
|
@ -245,7 +244,7 @@ export function TabNavItem(props: {
|
||||||
project={project()}
|
project={project()}
|
||||||
directory={session().directory}
|
directory={session().directory}
|
||||||
sessionId={session().id}
|
sessionId={session().id}
|
||||||
activeServer={props.activeServer}
|
server={props.server}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ function SessionTabSlot(props: {
|
||||||
id: string
|
id: string
|
||||||
index: () => number
|
index: () => number
|
||||||
active: () => boolean
|
active: () => boolean
|
||||||
activeServerKey: ServerConnection.Key
|
|
||||||
forceTruncate: boolean
|
forceTruncate: boolean
|
||||||
serverCtx: () => ServerCtx | undefined
|
serverCtx: () => ServerCtx | undefined
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
|
|
@ -112,7 +111,6 @@ function SessionTabSlot(props: {
|
||||||
onNavigate={() => props.onNavigate(ref)}
|
onNavigate={() => props.onNavigate(ref)}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
active={props.active()}
|
active={props.active()}
|
||||||
activeServer={props.tab.server === props.activeServerKey}
|
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
dragging={sortable.isDragSource()}
|
dragging={sortable.isDragSource()}
|
||||||
/>
|
/>
|
||||||
|
|
@ -165,7 +163,6 @@ function DraftTabSlot(props: {
|
||||||
export function TitlebarTabStrip(props: {
|
export function TitlebarTabStrip(props: {
|
||||||
tabs: Tab[]
|
tabs: Tab[]
|
||||||
currentTab: () => Tab | undefined
|
currentTab: () => Tab | undefined
|
||||||
activeServerKey: ServerConnection.Key
|
|
||||||
forceTruncate: boolean
|
forceTruncate: boolean
|
||||||
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
||||||
onClose: (tab: Tab) => void
|
onClose: (tab: Tab) => void
|
||||||
|
|
@ -271,7 +268,6 @@ export function TitlebarTabStrip(props: {
|
||||||
id={id}
|
id={id}
|
||||||
index={index}
|
index={index}
|
||||||
active={() => props.currentTab() === tab}
|
active={() => props.currentTab() === tab}
|
||||||
activeServerKey={props.activeServerKey}
|
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
serverCtx={serverCtx}
|
serverCtx={serverCtx}
|
||||||
onNavigate={(element) => {
|
onNavigate={(element) => {
|
||||||
|
|
|
||||||
|
|
@ -456,7 +456,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||||
<TitlebarTabStrip
|
<TitlebarTabStrip
|
||||||
tabs={tabsStore}
|
tabs={tabsStore}
|
||||||
currentTab={currentTab}
|
currentTab={currentTab}
|
||||||
activeServerKey={server.key}
|
|
||||||
forceTruncate={tabsAreOverflowing()}
|
forceTruncate={tabsAreOverflowing()}
|
||||||
onOverflowChange={setTabsAreOverflowing}
|
onOverflowChange={setTabsAreOverflowing}
|
||||||
onNavigate={(tab, el) => {
|
onNavigate={(tab, el) => {
|
||||||
|
|
|
||||||
|
|
@ -552,7 +552,6 @@ export function NewHome() {
|
||||||
results={searchResults()}
|
results={searchResults()}
|
||||||
showProjectName={!selectedProject()}
|
showProjectName={!selectedProject()}
|
||||||
server={selection().server}
|
server={selection().server}
|
||||||
activeServer={selection().server === server.key}
|
|
||||||
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
||||||
bindFocus={(focus) => {
|
bindFocus={(focus) => {
|
||||||
focusSessionSearch = focus
|
focusSessionSearch = focus
|
||||||
|
|
@ -612,7 +611,6 @@ export function NewHome() {
|
||||||
record={record}
|
record={record}
|
||||||
showProjectName={!selectedProject()}
|
showProjectName={!selectedProject()}
|
||||||
server={selection().server}
|
server={selection().server}
|
||||||
activeServer={selection().server === server.key}
|
|
||||||
openSession={openSession}
|
openSession={openSession}
|
||||||
archiveSession={archiveSession}
|
archiveSession={archiveSession}
|
||||||
/>
|
/>
|
||||||
|
|
@ -996,7 +994,6 @@ function HomeSessionLeading(props: {
|
||||||
project: LocalProject
|
project: LocalProject
|
||||||
session: Session
|
session: Session
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
activeServer: boolean
|
|
||||||
revealProjectOnHover: boolean
|
revealProjectOnHover: boolean
|
||||||
}) {
|
}) {
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
|
@ -1014,7 +1011,7 @@ function HomeSessionLeading(props: {
|
||||||
project={props.project}
|
project={props.project}
|
||||||
directory={props.session.directory}
|
directory={props.session.directory}
|
||||||
sessionId={props.session.id}
|
sessionId={props.session.id}
|
||||||
activeServer={props.activeServer}
|
server={props.server}
|
||||||
revealProjectOnHover={props.revealProjectOnHover}
|
revealProjectOnHover={props.revealProjectOnHover}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1029,7 +1026,6 @@ function HomeSessionSearch(props: {
|
||||||
results: HomeSessionRecord[]
|
results: HomeSessionRecord[]
|
||||||
showProjectName: boolean
|
showProjectName: boolean
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
activeServer: boolean
|
|
||||||
noResultsLabel: string
|
noResultsLabel: string
|
||||||
bindFocus: (focus: () => void) => void
|
bindFocus: (focus: () => void) => void
|
||||||
onInput: (value: string) => void
|
onInput: (value: string) => void
|
||||||
|
|
@ -1147,7 +1143,6 @@ function HomeSessionSearch(props: {
|
||||||
record={record}
|
record={record}
|
||||||
showProjectName={props.showProjectName}
|
showProjectName={props.showProjectName}
|
||||||
server={props.server}
|
server={props.server}
|
||||||
activeServer={props.activeServer}
|
|
||||||
selected={store.active === homeSessionSearchKey(record)}
|
selected={store.active === homeSessionSearchKey(record)}
|
||||||
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
|
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
|
||||||
onSelect={(session) => props.onSelect(session)}
|
onSelect={(session) => props.onSelect(session)}
|
||||||
|
|
@ -1228,7 +1223,6 @@ function HomeSessionSearchResultRow(props: {
|
||||||
record: HomeSessionRecord
|
record: HomeSessionRecord
|
||||||
showProjectName: boolean
|
showProjectName: boolean
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
activeServer: boolean
|
|
||||||
selected: boolean
|
selected: boolean
|
||||||
onHighlight: () => void
|
onHighlight: () => void
|
||||||
onSelect: (session: Session) => void
|
onSelect: (session: Session) => void
|
||||||
|
|
@ -1258,7 +1252,6 @@ function HomeSessionSearchResultRow(props: {
|
||||||
project={props.record.project}
|
project={props.record.project}
|
||||||
session={props.record.session}
|
session={props.record.session}
|
||||||
server={props.server}
|
server={props.server}
|
||||||
activeServer={props.activeServer}
|
|
||||||
revealProjectOnHover={!!showProjectName()}
|
revealProjectOnHover={!!showProjectName()}
|
||||||
/>
|
/>
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
|
|
@ -1297,7 +1290,6 @@ function HomeSessionRow(props: {
|
||||||
record: HomeSessionRecord
|
record: HomeSessionRecord
|
||||||
showProjectName: boolean
|
showProjectName: boolean
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
activeServer: boolean
|
|
||||||
openSession: (session: Session) => void
|
openSession: (session: Session) => void
|
||||||
archiveSession: (session: Session) => Promise<void>
|
archiveSession: (session: Session) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -1320,7 +1312,6 @@ function HomeSessionRow(props: {
|
||||||
project={props.record.project}
|
project={props.record.project}
|
||||||
session={props.record.session}
|
session={props.record.session}
|
||||||
server={props.server}
|
server={props.server}
|
||||||
activeServer={props.activeServer}
|
|
||||||
revealProjectOnHover={!!showProjectName()}
|
revealProjectOnHover={!!showProjectName()}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,41 @@
|
||||||
import { createMemo, type Accessor } from "solid-js"
|
import { createMemo, type Accessor } from "solid-js"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
|
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
|
||||||
|
|
||||||
export function useSessionTabAvatarState(
|
export function useSessionTabAvatarState(
|
||||||
|
server: Accessor<ServerConnection.Key>,
|
||||||
directory: Accessor<string>,
|
directory: Accessor<string>,
|
||||||
sessionId: Accessor<string>,
|
sessionId: Accessor<string>,
|
||||||
active: Accessor<boolean> = () => true,
|
|
||||||
) {
|
) {
|
||||||
const globalSync = useServerSync()
|
const global = useGlobal()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const permission = usePermission()
|
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(() => {
|
const hasPermissions = createMemo(() => {
|
||||||
if (!active()) return false
|
const serverSync = sync()
|
||||||
const [store] = globalSync().child(directory(), { bootstrap: false })
|
if (!serverSync) return false
|
||||||
return !!sessionPermissionRequest(store.session, globalSync().session.data.permission, sessionId(), (item) => {
|
const [store] = serverSync.child(directory(), { bootstrap: false })
|
||||||
|
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
|
||||||
return !permission.autoResponds(item, directory())
|
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(() => {
|
const loading = createMemo(() => {
|
||||||
if (!active()) return false
|
const serverSync = sync()
|
||||||
|
if (!serverSync) return false
|
||||||
if (hasPermissions()) return false
|
if (hasPermissions()) return false
|
||||||
return globalSync().session.data.session_working(sessionId())
|
return serverSync.session.data.session_working(sessionId())
|
||||||
})
|
})
|
||||||
return { unread, loading }
|
return { unread, loading }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { LocalProject } from "@/context/layout"
|
import type { LocalProject } from "@/context/layout"
|
||||||
import { getProjectAvatarVariant } from "@/context/layout"
|
import { getProjectAvatarVariant } from "@/context/layout"
|
||||||
|
import type { ServerConnection } from "@/context/server"
|
||||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
|
|
@ -10,12 +11,14 @@ export function SessionTabAvatar(props: {
|
||||||
project?: LocalProject
|
project?: LocalProject
|
||||||
directory: string
|
directory: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
activeServer: boolean
|
server: ServerConnection.Key
|
||||||
revealProjectOnHover?: boolean
|
revealProjectOnHover?: boolean
|
||||||
}) {
|
}) {
|
||||||
const directory = () => props.directory
|
const state = useSessionTabAvatarState(
|
||||||
const sessionId = () => props.sessionId
|
() => props.server,
|
||||||
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
|
() => props.directory,
|
||||||
|
() => props.sessionId,
|
||||||
|
)
|
||||||
const projectAvatar = () => (
|
const projectAvatar = () => (
|
||||||
<ProjectAvatar
|
<ProjectAvatar
|
||||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue