diff --git a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts index c65cbfd8dbb..0da92e7d763 100644 --- a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts +++ b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts @@ -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([ diff --git a/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts b/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts index c98820cda9d..a2d86cc5066 100644 --- a/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts +++ b/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts @@ -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) }, diff --git a/src/renderer/src/components/sidebar/sidebar-list-parity.test-fixture.ts b/src/renderer/src/components/sidebar/sidebar-list-parity.test-fixture.ts index ce8eb85787d..1349c797e46 100644 --- a/src/renderer/src/components/sidebar/sidebar-list-parity.test-fixture.ts +++ b/src/renderer/src/components/sidebar/sidebar-list-parity.test-fixture.ts @@ -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 { + 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 { + 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): 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()] }], diff --git a/src/renderer/src/components/sidebar/sidebar-list-parity.test.tsx b/src/renderer/src/components/sidebar/sidebar-list-parity.test.tsx index fd3e243d12d..277428a8531 100644 --- a/src/renderer/src/components/sidebar/sidebar-list-parity.test.tsx +++ b/src/renderer/src/components/sidebar/sidebar-list-parity.test.tsx @@ -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': [ diff --git a/src/shared/sidebar-list/paired-server-activity-facts.test.ts b/src/shared/sidebar-list/paired-server-activity-facts.test.ts new file mode 100644 index 00000000000..34ef0287006 --- /dev/null +++ b/src/shared/sidebar-list/paired-server-activity-facts.test.ts @@ -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 = {}) { + 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 + } = {} +) { + 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 }) + }) +}) diff --git a/src/shared/sidebar-list/paired-server-activity-facts.ts b/src/shared/sidebar-list/paired-server-activity-facts.ts new file mode 100644 index 00000000000..251297ca043 --- /dev/null +++ b/src/shared/sidebar-list/paired-server-activity-facts.ts @@ -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 | null +): Record { + const facts: Record = {} + 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 +} diff --git a/src/shared/sidebar-list/paired-server-rows.ts b/src/shared/sidebar-list/paired-server-rows.ts index f3cbd6aea6f..2e0abd8ebfc 100644 --- a/src/shared/sidebar-list/paired-server-rows.ts +++ b/src/shared/sidebar-list/paired-server-rows.ts @@ -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): 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) } } diff --git a/src/shared/sidebar-list/workspace-activity-facts.ts b/src/shared/sidebar-list/workspace-activity-facts.ts index 096091f397c..0d47ccf5208 100644 --- a/src/shared/sidebar-list/workspace-activity-facts.ts +++ b/src/shared/sidebar-list/workspace-activity-facts.ts @@ -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 export type WorkspaceActivityFacts = z.infer export type WorkspaceTerminalLiveness = NonNullable export type WorkspaceActivityFactsSnapshot = z.infer +export type HostReportedActivity = z.infer export const IDLE_WORKSPACE_ACTIVITY: WorkspaceActivityFacts = { terminal: null, diff --git a/src/shared/workspace-session-tab-activity.ts b/src/shared/workspace-session-tab-activity.ts index b7547b1331d..8745cd388e2 100644 --- a/src/shared/workspace-session-tab-activity.ts +++ b/src/shared/workspace-session-tab-activity.ts @@ -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 + /** * 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 | null | undefined -): Set { - const ids = new Set() +export function getWorkspaceSessionTabActivity(session: SessionTabs | null | undefined): { + browser: Set + structuredChat: Set +} { + const browser = new Set() + const structuredChat = new Set() 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 { + const { browser, structuredChat } = getWorkspaceSessionTabActivity(session) + return new Set([...browser, ...structuredChat]) }