fix(sidebar): Hide sleeping keeps a paired server's active workspaces (dark) (#30624)

* fix(sidebar): Hide sleeping reads a paired server's own terminal report (dark)

With ORCA_SIDEBAR_INPUTS=main, main's activity domain now lists a paired
server's rows: their terminals and agents as the server's worktree.ps
reports them, plus the browser and chat tabs this desktop's saved session
holds there. A server out of contact keeps live terminals unverifiable.
Switch-off builds are unchanged.

* fix(sidebar): keep server activity out of the published paired rows

Review: a strict field on paired rows could drop a whole row for an older
reader; the activity domain already carries it.
This commit is contained in:
Jinwoo Hong
2026-10-19 03:49:36 -04:00
committed by GitHub
parent 4aba68561d
commit 5f2bb930d1
9 changed files with 324 additions and 22 deletions
@@ -119,7 +119,9 @@ describe('PairedServerRowsProducer', () => {
it('populates from a server in contact once its change stream is ready', async () => {
const { hosts, call, watches } = fakeHosts([{ id: 'box', live: true }])
call.mockResolvedValue(psReply([row('repo::/a', { workspaceKind: 'git' })]))
call.mockResolvedValue(
psReply([row('repo::/a', { workspaceKind: 'git', liveTerminalCount: 1, status: 'active' })])
)
const producer = new PairedServerRowsProducer({
hasDemand: () => true,
hostLabelOverrides: () => new Map()
@@ -158,7 +160,9 @@ describe('PairedServerRowsProducer', () => {
isPinned: false,
isMainWorktree: false,
lineage: null,
workspaceStatus: 'in-progress'
workspaceStatus: 'in-progress',
// The server's own terminals and agents.
activity: { terminal: 'live', agent: null }
}
]
})
@@ -405,7 +409,8 @@ describe('PairedServerRowsProducer', () => {
isPinned: false,
isMainWorktree: false,
lineage: null,
workspaceStatus: 'in-progress'
workspaceStatus: 'in-progress',
activity: { terminal: null, agent: null }
}
])
expect(rowsOf(producer, 'runtime:two')?.rows.map((entry) => [entry.key, entry.kind])).toEqual([
@@ -14,6 +14,8 @@ import type { RepoIconDataUrls } from './repo-icon-data-urls'
import type { SidebarInputsFeedDomain } from './sidebar-inputs-feed'
import { readDesktopSidebarFacts } from './desktop-sidebar-facts'
import { buildPairedServerReviewFacts } from '../../../shared/sidebar-list/workspace-review-facts'
import { buildPairedServerActivityFacts } from '../../../shared/sidebar-list/paired-server-activity-facts'
import { parseExecutionHostId } from '../../../shared/execution-host'
/** This host's store and connection registries, read the way sidebarInputs.desktopFacts reads them. */
export function readRuntimeDesktopSidebarFacts(store: RuntimeStore): DesktopSidebarFacts {
@@ -70,8 +72,27 @@ export function sidebarInputsFeedDomains(
return [
{
name: 'activity',
read: () => facts.getWorkspaceActivityFacts().facts,
watch: (changed) => facts.workspaceActivityFacts.revision.onChange(changed)
read: () => ({
// A paired server's rows too; this host's own rows win an id both list.
...buildPairedServerActivityFacts(
pairedServerRows.snapshot().servers,
(hostId) => sources.store()?.getWorkspaceSession?.(hostId) ?? null
),
...facts.getWorkspaceActivityFacts().facts
}),
watch: (changed) => {
const detaches = [
facts.workspaceActivityFacts.revision.onChange(changed),
pairedServerRows.revision.onChange(changed),
// This desktop's saved session for a paired server holds its browser and chat tabs there.
sources.store()?.onWorkspaceSessionWritten?.((hostId) => {
if (hostId === null || parseExecutionHostId(hostId)?.kind === 'runtime') {
changed()
}
})
]
return () => detaches.forEach((detach) => detach?.())
}
},
{
name: 'temporal',
@@ -107,7 +128,10 @@ export function sidebarInputsFeedDomains(
Object.fromEntries(
pairedServerRows
.snapshot()
.servers.flatMap(({ rows }) => rows.map((row) => [row.key, row]))
// Activity goes out in its own domain, so a terminal count does not resend the row.
.servers.flatMap(({ rows }) =>
rows.map(({ activity: _activity, ...row }) => [row.key, row])
)
),
watch: (changed) => pairedServerRows.revision.onChange(changed)
},
@@ -20,6 +20,7 @@ import type {
SidebarFeedView
} from '../../../../shared/sidebar-list/sidebar-feed-rows'
import type { SmartStampFields } from '../../../../shared/sidebar-list/sort-stamps'
import { buildPairedServerActivityFacts } from '../../../../shared/sidebar-list/paired-server-activity-facts'
import { GOLDEN_NOW, folder, group, lineage } from './sidebar-list-golden-factories.test-fixture'
import { sidebarWorkspaceRowFilters } from './sidebar-filter-actions'
import {
@@ -130,6 +131,51 @@ export function parityActiveState(
return { activeWorktreeId: worktreeId, activeWorkspaceExecutionHostId: hostId }
}
const SERVER_WORKTREE_ID = 'api::/w/api-y'
/** A desktop terminal in a server workspace: the PTY is the server's, which reports it. */
export function parityServerTerminalState(): Partial<AppState> {
return {
tabsByWorktree: {
[SERVER_WORKTREE_ID]: [
{
id: 'tab-server',
ptyId: 'pty-server',
worktreeId: SERVER_WORKTREE_ID,
title: 'Terminal 1',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: GOLDEN_NOW
}
]
},
ptyIdsByTabId: { 'tab-server': ['pty-server'] }
}
}
/** A desktop browser tab in a server workspace: the server never sees it. */
export function parityServerBrowserTabState(): Partial<AppState> {
return {
browserTabsByWorktree: {
[SERVER_WORKTREE_ID]: [
{
id: 'browser-server',
worktreeId: SERVER_WORKTREE_ID,
url: 'about:blank',
title: '',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: GOLDEN_NOW
}
]
}
}
}
/**
* The feed domains main would publish for this store, read through the window-side projections
* the shadow comparison held equal to main's producers before the window read main's feed.
@@ -137,7 +183,13 @@ export function parityActiveState(
export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDomain[] {
const worktrees = getAllWorktreesFromState(state)
const servers = readWindowPairedServerHosts(state, new Set([SERVER_HOST_ID]))
const windowActivity = readWindowWorkspaceActivityFacts(state, GOLDEN_NOW)
const serverRows = readWindowPairedServerRows(state, new Set(servers.keys()))
// The server reports the terminals and agents the desktop runs in its workspaces.
const reportedRows = [...serverRows.values()].map((row) => {
const facts = windowActivity.get(row.worktreeId)
return { ...row, activity: { terminal: facts?.terminal ?? null, agent: facts?.agent ?? null } }
})
const domain = (name: string, read: () => Record<string, unknown>): SidebarInputsFeedDomain => ({
name,
read,
@@ -154,11 +206,17 @@ export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDo
})
)
),
domain('activity', () =>
Object.fromEntries(
toLocallyOwnedRows(state, readWindowWorkspaceActivityFacts(state, GOLDEN_NOW))
)
),
domain('activity', () => ({
// The window's browser and chat tabs, as its saved session for the server holds them.
...buildPairedServerActivityFacts(
[{ hostId: SERVER_HOST_ID, freshness: 'live', rows: reportedRows }],
() => ({
browserTabsByWorktree: state.browserTabsByWorktree,
unifiedTabs: state.unifiedTabsByWorktree
})
),
...Object.fromEntries(toLocallyOwnedRows(state, windowActivity))
})),
domain('review', () => ({
...buildPairedServerReviewFacts(
[{ hostId: SERVER_HOST_ID, rows: [...serverRows.values()] }],
@@ -10,8 +10,8 @@
* and agent-send reveal; its imported-worktrees and new-external-worktrees cards; its create
* preflight; host-header health and detail.
* Known gaps the phone shows differently, so the fixture leaves them out: a creation a paired
* server runs; a paired server's activity under "Hide sleeping" (the window reads its own tabs);
* a PR unlinked on a server workspace; an SSH workspace reached through a paired server.
* server runs; a PR unlinked on a server workspace; an SSH workspace reached through a paired
* server; a server terminal only another client opened (the server counts it, the window does not).
*/
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, renderHook } from '@testing-library/react'
@@ -42,6 +42,8 @@ import {
import {
feedDomainsFromWindowState,
parityActiveState,
parityServerBrowserTabState,
parityServerTerminalState,
parityWorldOverrides,
phoneInputsFromWindowState,
projectParityRows
@@ -243,6 +245,15 @@ describe('sidebar list parity: desktop window and feed reader', () => {
{ showSleepingWorkspaces: false, ...goldenLiveTerminalState() },
/^ITEM .*\|orca::\/w\/feat-a /
],
// The server reports the desktop's terminal; only the desktop knows its browser tab.
'hide sleeping with a paired server terminal': [
{ showSleepingWorkspaces: false, ...parityServerTerminalState() },
/^ITEM .*runtime:env-1\|api::\/w\/api-y /
],
'hide sleeping with a browser tab in a paired server workspace': [
{ showSleepingWorkspaces: false, ...parityServerBrowserTabState() },
/^ITEM .*runtime:env-1\|api::\/w\/api-y /
],
'hide default branch': [{ hideDefaultBranchWorkspace: true }, /^ITEM .*api-y /],
// The same id on two hosts: only the SSH copy is open.
'open on one of two hosts': [
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import type { ExecutionHostId } from '../execution-host'
import type { WorkspaceSessionState } from '../workspace-session-state-types'
import { buildPairedServerActivityFacts } from './paired-server-activity-facts'
import { normalizePairedServerRow, type PairedServerWorkspaceRow } from './paired-server-rows'
const HOST = 'runtime:env-1'
function serverRow(worktreeId: string, fields: Record<string, unknown> = {}) {
const row = normalizePairedServerRow(HOST, {
worktreeId,
repoId: 'repo',
path: `/srv/${worktreeId}`,
branch: worktreeId,
liveTerminalCount: 0,
status: 'inactive',
...fields
})
if (!row) {
throw new Error('row did not normalize')
}
return row
}
function build(
rows: PairedServerWorkspaceRow[],
options: {
freshness?: 'live' | 'unverifiable'
session?: Pick<WorkspaceSessionState, 'browserTabsByWorktree' | 'unifiedTabs'>
} = {}
) {
const asked: ExecutionHostId[] = []
const facts = buildPairedServerActivityFacts(
[{ hostId: HOST, freshness: options.freshness ?? 'live', rows }],
(hostId) => {
asked.push(hostId)
return options.session ?? null
}
)
return { facts, asked }
}
describe('paired server activity facts', () => {
it("reads the server's own terminals and agents off its rows", () => {
const { facts } = build([
serverRow('live', { liveTerminalCount: 2, status: 'active' }),
serverRow('lost', { unverifiableTerminalCount: 1 }),
serverRow('agent', { status: 'working', workingMode: 'monitoring' }),
serverRow('asks', { liveTerminalCount: 1, status: 'permission' }),
serverRow('idle', { status: 'done' })
])
expect(facts).toEqual({
live: { terminal: 'live', browser: false, structuredChat: false, agent: null },
lost: { terminal: 'unverifiable', browser: false, structuredChat: false, agent: null },
agent: { terminal: null, browser: false, structuredChat: false, agent: 'monitoring' },
asks: { terminal: 'live', browser: false, structuredChat: false, agent: 'permission' }
})
})
it('keeps a server out of contact unverifiable, never asleep', () => {
const { facts } = build([serverRow('live', { liveTerminalCount: 1, status: 'active' })], {
freshness: 'unverifiable'
})
expect(facts.live?.terminal).toBe('unverifiable')
})
it("adds the browser tabs this desktop's session holds in a server workspace", () => {
const { facts, asked } = build([serverRow('browsing')], {
session: {
browserTabsByWorktree: {
browsing: [
{
id: 'b1',
worktreeId: 'browsing',
url: 'about:blank',
title: '',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
]
},
unifiedTabs: {}
}
})
expect(asked).toEqual([HOST])
expect(facts.browsing).toEqual({
terminal: null,
browser: true,
structuredChat: false,
agent: null
})
})
it('an older server that sends no counts reports nothing', () => {
const row = normalizePairedServerRow(HOST, { worktreeId: 'old', repoId: 'repo' })
expect(row?.activity).toEqual({ terminal: null, agent: null })
})
})
@@ -0,0 +1,56 @@
import {
parseExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../execution-host'
import type { WorkspaceSessionState } from '../workspace-session-state-types'
import { getWorkspaceSessionTabActivity } from '../workspace-session-tab-activity'
import { worktreeIdentityKey } from '../worktree/id'
import type { PairedServerRowsSnapshot } from './paired-server-rows'
import { isIdleWorkspaceActivity, type WorkspaceActivityFacts } from './workspace-activity-facts'
/**
* A paired server's rows under the server's bare ids, like the review facts: its terminals and
* agents as the server reports them, and the browser and chat tabs this desktop's session holds
* there, which only the desktop knows. Rows from a server out of contact keep their terminals
* unverifiable, never gone (docs/reference/ssh-execution-boundary.md).
*/
export function buildPairedServerActivityFacts(
servers: readonly Pick<
PairedServerRowsSnapshot['servers'][number],
'hostId' | 'freshness' | 'rows'
>[],
sessionOf: (
hostId: ExecutionHostId
) => Pick<WorkspaceSessionState, 'browserTabsByWorktree' | 'unifiedTabs'> | null
): Record<string, WorkspaceActivityFacts> {
const facts: Record<string, WorkspaceActivityFacts> = {}
for (const server of servers) {
const parsed = parseExecutionHostId(server.hostId)
if (parsed?.kind !== 'runtime') {
continue
}
const tabs = getWorkspaceSessionTabActivity(
sessionOf(toRuntimeExecutionHostId(parsed.environmentId))
)
const browser = new Set([...tabs.browser].map(worktreeIdentityKey))
const structuredChat = new Set([...tabs.structuredChat].map(worktreeIdentityKey))
for (const row of server.rows) {
const reported = row.activity
const key = worktreeIdentityKey(row.worktreeId)
const fact: WorkspaceActivityFacts = {
terminal:
reported?.terminal === 'live' && server.freshness !== 'live'
? 'unverifiable'
: (reported?.terminal ?? null),
browser: browser.has(key),
structuredChat: structuredChat.has(key),
agent: reported?.agent ?? null
}
if (!isIdleWorkspaceActivity(fact)) {
facts[row.worktreeId] = fact
}
}
}
return facts
}
+29 -2
View File
@@ -5,6 +5,7 @@ import { openEnum } from '../zod-salvage'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../workspace-statuses'
import { SMART_TIERS } from './sort-stamps'
import { WorkspaceRowSchema } from './workspace-rows'
import { HostReportedActivitySchema, type HostReportedActivity } from './workspace-activity-facts'
/** One paired-server workspace as the sidebar lists it, keyed by its host-qualified id. */
const PairedServerWorkspaceRowSchema = z
@@ -41,6 +42,10 @@ const PairedServerWorkspaceRowSchema = z
smartAt: true
}).partial().shape
)
.extend({
/** The server's own terminals and agents there; older desktops omit it. */
activity: HostReportedActivitySchema.optional().catch(undefined)
})
/** See PairedServerHost; health and compatibility stay open strings, as a reader only compares them. */
const PairedServerHostFactsSchema = z.object({
@@ -96,9 +101,26 @@ const ServerWorktreePsRowSchema = z.object({
detachedHead: z.literal(true).optional().catch(undefined),
lastActivityAt: z.number().optional().catch(undefined),
smartTier: z.enum(SMART_TIERS).optional().catch(undefined),
smartAt: z.number().optional().catch(undefined)
smartAt: z.number().optional().catch(undefined),
liveTerminalCount: z.number().optional().catch(undefined),
unverifiableTerminalCount: z.number().optional().catch(undefined),
status: z.string().optional().catch(undefined),
workingMode: z.string().optional().catch(undefined)
})
function reportedActivity(row: z.infer<typeof ServerWorktreePsRowSchema>): HostReportedActivity {
const working = row.workingMode === 'monitoring' ? 'monitoring' : 'working'
return {
terminal:
(row.liveTerminalCount ?? 0) > 0
? 'live'
: (row.unverifiableTerminalCount ?? 0) > 0
? 'unverifiable'
: null,
agent: row.status === 'permission' ? 'permission' : row.status === 'working' ? working : null
}
}
/** A server's own `worktree.ps` row, with defaults for what older servers omit; null without an id. */
export function normalizePairedServerRow(
hostId: ExecutionHostId,
@@ -116,6 +138,10 @@ export function normalizePairedServerRow(
parentWorktreeId,
lineageWorktreeInstanceId,
parentWorktreeInstanceId,
liveTerminalCount: _liveTerminalCount,
unverifiableTerminalCount: _unverifiableTerminalCount,
status: _status,
workingMode: _workingMode,
...fields
} = parsed.data
return {
@@ -132,6 +158,7 @@ export function normalizePairedServerRow(
parentWorktreeInstanceId
}
: null,
recentAt: lastActivityAt
recentAt: lastActivityAt,
activity: reportedActivity(parsed.data)
}
}
@@ -19,10 +19,17 @@ export const WorkspaceActivityFactsSnapshotSchema = z.object({
facts: z.record(z.string(), WorkspaceActivityFactsSchema)
})
/** What a paired server reports for one of its own workspaces; the tabs are its clients' to add. */
export const HostReportedActivitySchema = WorkspaceActivityFactsSchema.pick({
terminal: true,
agent: true
})
export type LiveAgentWorktreeStatus = z.infer<typeof LiveAgentWorktreeStatusSchema>
export type WorkspaceActivityFacts = z.infer<typeof WorkspaceActivityFactsSchema>
export type WorkspaceTerminalLiveness = NonNullable<WorkspaceActivityFacts['terminal']>
export type WorkspaceActivityFactsSnapshot = z.infer<typeof WorkspaceActivityFactsSnapshotSchema>
export type HostReportedActivity = z.infer<typeof HostReportedActivitySchema>
export const IDLE_WORKSPACE_ACTIVITY: WorkspaceActivityFacts = {
terminal: null,
+19 -7
View File
@@ -2,25 +2,37 @@ import type { WorkspaceSessionState } from './workspace-session-state-types'
import { isStructuredTab } from './structured-agent-session-tab'
import { normalizeWorkspaceSessionKeyToWorkspaceId } from './workspace-scope'
type SessionTabs = Pick<WorkspaceSessionState, 'browserTabsByWorktree' | 'unifiedTabs'>
/**
* Workspaces a saved session holds open browser or native-chat tabs for: the two signals of the
* desktop sidebar's activity rule (worktree-activity-state.ts) that live in the client's session
* rather than on the execution host. Live terminals and agents are the host's to report. Reads
* the saved session's keys; the renderer's live-state twin is getWorktreeIdsWithStructuredChat.
*/
export function getWorkspaceSessionTabActivityIds(
session: Pick<WorkspaceSessionState, 'browserTabsByWorktree' | 'unifiedTabs'> | null | undefined
): Set<string> {
const ids = new Set<string>()
export function getWorkspaceSessionTabActivity(session: SessionTabs | null | undefined): {
browser: Set<string>
structuredChat: Set<string>
} {
const browser = new Set<string>()
const structuredChat = new Set<string>()
for (const [key, tabs] of Object.entries(session?.browserTabsByWorktree ?? {})) {
if (tabs.length > 0) {
ids.add(normalizeWorkspaceSessionKeyToWorkspaceId(key))
browser.add(normalizeWorkspaceSessionKeyToWorkspaceId(key))
}
}
for (const [key, tabs] of Object.entries(session?.unifiedTabs ?? {})) {
if (tabs.some(isStructuredTab)) {
ids.add(normalizeWorkspaceSessionKeyToWorkspaceId(key))
structuredChat.add(normalizeWorkspaceSessionKeyToWorkspaceId(key))
}
}
return ids
return { browser, structuredChat }
}
/** Either kind of getWorkspaceSessionTabActivity. */
export function getWorkspaceSessionTabActivityIds(
session: SessionTabs | null | undefined
): Set<string> {
const { browser, structuredChat } = getWorkspaceSessionTabActivity(session)
return new Set([...browser, ...structuredChat])
}