Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions packages/app/e2e/regression/remote-tab-busy.spec.ts
Original file line number Diff line number Diff line change
@@ -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" })
}
Comment on lines +107 to +109
3 changes: 1 addition & 2 deletions packages/app/src/components/titlebar-tab-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export function TabNavItem(props: {
onClose: () => void
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
suppressNavigation?: () => boolean
dragging?: boolean
Expand Down Expand Up @@ -245,7 +244,7 @@ export function TabNavItem(props: {
project={project()}
directory={session().directory}
sessionId={session().id}
activeServer={props.activeServer}
server={props.server}
/>
</span>
)}
Expand Down
4 changes: 0 additions & 4 deletions packages/app/src/components/titlebar-tab-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()}
/>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
1 change: 0 additions & 1 deletion packages/app/src/components/titlebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
11 changes: 1 addition & 10 deletions packages/app/src/pages/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -612,7 +611,6 @@ export function NewHome() {
record={record}
showProjectName={!selectedProject()}
server={selection().server}
activeServer={selection().server === server.key}
openSession={openSession}
archiveSession={archiveSession}
/>
Expand Down Expand Up @@ -996,7 +994,6 @@ function HomeSessionLeading(props: {
project: LocalProject
session: Session
server: ServerConnection.Key
activeServer: boolean
revealProjectOnHover: boolean
}) {
const tabs = useTabs()
Expand All @@ -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>
Expand All @@ -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
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -1228,7 +1223,6 @@ function HomeSessionSearchResultRow(props: {
record: HomeSessionRecord
showProjectName: boolean
server: ServerConnection.Key
activeServer: boolean
selected: boolean
onHighlight: () => void
onSelect: (session: Session) => void
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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>
}) {
Expand All @@ -1320,7 +1312,6 @@ function HomeSessionRow(props: {
project={props.record.project}
session={props.record.session}
server={props.server}
activeServer={props.activeServer}
revealProjectOnHover={!!showProjectName()}
/>
<span
Expand Down
30 changes: 21 additions & 9 deletions packages/app/src/pages/layout/project-avatar-state.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
11 changes: 7 additions & 4 deletions packages/app/src/pages/layout/session-tab-avatar.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 })}
Expand Down
Loading