Files
orca/src/main/runtime/runtime-worktree-ps-activity.ts
T
Jinwoo Hong 3b11b7ac94 fix(mobile): phone server rows follow the desktop's other-client and sleeping filters (#28819)
* fix(mobile): phone server rows follow the desktop's other-client and sleeping filters

A phone created through a desktop now records whom it acted for, so the
desktop's Hide other-client workspaces filter keeps the user's own phone
workspaces (locally and on servers) and hides only other people's.
The desktop relay marks each server row its sidebar would hide, and the
rows its own browser or chat tabs keep awake; the phone applies both.

* test(e2e): dedupe sidebar row ids in the visibility spec

* fix(mobile): address review: owner naming, old-server relayed phones, scope trim

* refactor(mobile): record a phone's owner as creator and fold desktop tab activity into the host flag

A phone now records its owner as the creator (this host for a directly
paired phone, the relaying desktop for a delegated one), so the shared rule
is unchanged and onBehalfOf is gone. The relay ORs the desktop's saved
browser/chat tabs into hasHostSidebarActivity, so the phone's status dot
follows too, and local worktree.ps counts the same signals. The old-server
fallback is removed.

* test(e2e): poll the phone and the sidebar together in the visibility spec
2026-10-14 21:18:01 -04:00

255 lines
9.2 KiB
TypeScript

import type { RuntimeSyncedTab, RuntimeWorktreePsSummary } from '../../shared/runtime-types'
import type { Repo } from '../../shared/repo-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import { parsePaneKey } from '../../shared/stable-pane-id'
import { getWorkspaceSessionTabActivityIds } from '../../shared/workspace-session-tab-activity'
import type { RuntimeStore } from './runtime-store-contract'
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import type { RuntimeWorktreePsSummaryLookup } from './runtime-worktree-summary-paths'
import {
getLatestPtyTitle,
getLeafDisplayRecord,
getLeafWorktreeStatus,
getPtyDisplayRecord,
getSavedTabWorktreeStatus,
maxTimestamp,
mergeWorktreeSummaryStatus,
type TitleDisplayClear
} from './runtime-worktree-status-projection'
import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity'
export type RuntimeWorkingTerminalEvidence = {
paneKey: string | null
ptyId: string | null
tabId: string | null
}
export function applyRuntimeWorktreePsTerminalActivity(args: {
freshPtyLiveness: ReadonlySet<string> | null
/** Filled with every PTY this pass counts as live. */
countedPtyIds: Set<string>
leaves: Iterable<RuntimeLeafRecord>
ptysById: ReadonlyMap<string, RuntimePtyWorktreeRecord>
tabs: ReadonlyMap<string, RuntimeSyncedTab>
session: WorkspaceSessionState | null | undefined
getPaneKey: (leaf: RuntimeLeafRecord) => string
getTitleDisplayClear: (ptyId: string) => TitleDisplayClear | null
getSummary: RuntimeWorktreePsSummaryLookup
}): Map<string, RuntimeWorkingTerminalEvidence[]> {
const workingEvidence = new Map<string, RuntimeWorkingTerminalEvidence[]>()
const savedTabOwnerById = new Map<string, { worktreeId: string; title: string }>()
for (const [worktreeId, tabs] of Object.entries(args.session?.tabsByWorktree ?? {})) {
for (const tab of tabs) {
savedTabOwnerById.set(tab.id, { worktreeId, title: tab.title })
}
}
const savedLayoutTabIdByPtyId = new Map<string, string>()
for (const [tabId, layout] of Object.entries(args.session?.terminalLayoutsByTabId ?? {})) {
for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) {
if (ptyId) {
savedLayoutTabIdByPtyId.set(ptyId, tabId)
}
}
}
const countedPtyIds = args.countedPtyIds
for (const leaf of args.leaves) {
if (
!leaf.ptyId ||
!leaf.connected ||
(args.freshPtyLiveness !== null && !args.freshPtyLiveness.has(leaf.ptyId))
) {
continue
}
const freshOwner = args.ptysById.get(leaf.ptyId)
if (
args.freshPtyLiveness !== null &&
freshOwner?.connected &&
!runtimeWorktreeIdsEqual(freshOwner.worktreeId, leaf.worktreeId)
) {
continue
}
const summary = args.getSummary(leaf.worktreeId)
if (!summary) {
continue
}
countedPtyIds.add(leaf.ptyId)
summary.hasHostSidebarActivity = true
const previousLastOutputAt = summary.lastOutputAt
summary.liveTerminalCount += 1
summary.hasAttachedPty = true
summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt)
const leafStatus = getLeafWorktreeStatus(
getLeafDisplayRecord(leaf, args.getTitleDisplayClear(leaf.ptyId)),
args.tabs.get(leaf.tabId)?.title ?? null
)
if (leafStatus === 'working') {
addWorkingTerminalEvidence(workingEvidence, summary.worktreeId, {
paneKey: args.getPaneKey(leaf),
ptyId: leaf.ptyId,
tabId: leaf.tabId
})
}
mergeWorktreeSummaryStatus(summary, leafStatus)
if (
leaf.preview &&
(summary.preview.length === 0 || (leaf.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1))
) {
summary.preview = leaf.preview
}
}
for (const pty of args.ptysById.values()) {
if (
!pty.connected ||
countedPtyIds.has(pty.ptyId) ||
(args.freshPtyLiveness !== null && !args.freshPtyLiveness.has(pty.ptyId))
) {
continue
}
const persistedTabId = savedLayoutTabIdByPtyId.get(pty.ptyId)
const displayTitle = getLatestPtyTitle(
getPtyDisplayRecord(pty, args.getTitleDisplayClear(pty.ptyId))
)
let owner = persistedTabId ? savedTabOwnerById.get(persistedTabId) : undefined
if (args.freshPtyLiveness !== null) {
owner = { worktreeId: pty.worktreeId, title: owner?.title ?? displayTitle ?? '' }
}
if (!owner && persistedTabId && pty.tabId === persistedTabId) {
owner = { worktreeId: pty.worktreeId, title: displayTitle ?? '' }
}
const pane = parsePaneKey(pty.paneKey ?? '')
const hasExplicitOwner =
pty.tabId !== null && pane?.tabId === pty.tabId && pane.leafId.length > 0
const savedOwner = pty.tabId ? savedTabOwnerById.get(pty.tabId) : undefined
const hasSavedLayout =
pty.tabId !== null && Object.hasOwn(args.session?.terminalLayoutsByTabId ?? {}, pty.tabId)
if (!owner && hasExplicitOwner && !hasSavedLayout) {
owner = {
worktreeId: savedOwner?.worktreeId ?? pty.worktreeId,
title: savedOwner?.title ?? displayTitle ?? ''
}
}
if (!owner) {
continue
}
const summary = args.getSummary(owner.worktreeId)
if (!summary) {
continue
}
countedPtyIds.add(pty.ptyId)
const previousLastOutputAt = summary.lastOutputAt
summary.liveTerminalCount += 1
summary.hasAttachedPty = true
summary.hasHostSidebarActivity = true
summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt)
const ptyStatus = getSavedTabWorktreeStatus(owner.title, true)
if (ptyStatus === 'working') {
addWorkingTerminalEvidence(workingEvidence, summary.worktreeId, {
paneKey: pty.paneKey,
ptyId: pty.ptyId,
tabId: pty.tabId ?? persistedTabId ?? null
})
}
mergeWorktreeSummaryStatus(summary, ptyStatus)
if (
pty.preview &&
(summary.preview.length === 0 || (pty.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1))
) {
summary.preview = pty.preview
}
}
return workingEvidence
}
function addWorkingTerminalEvidence(
evidenceByWorktreeId: Map<string, RuntimeWorkingTerminalEvidence[]>,
worktreeId: string,
evidence: RuntimeWorkingTerminalEvidence
): void {
const existing = evidenceByWorktreeId.get(worktreeId)
if (existing) {
existing.push(evidence)
} else {
evidenceByWorktreeId.set(worktreeId, [evidence])
}
}
export function applyRuntimeWorktreePsSessionActivity(args: {
store: RuntimeStore | null
summaries: Map<string, RuntimeWorktreePsSummary>
repoById: ReadonlyMap<string, Repo>
ptysById: ReadonlyMap<string, RuntimePtyWorktreeRecord>
tabs: ReadonlyMap<string, RuntimeSyncedTab>
/** Non-minting: a listing must not issue handles, only recognise the ones already bound. */
getTerminalHandlesForPty: (ptyId: string) => readonly string[]
getSummary: RuntimeWorktreePsSummaryLookup
}): {
mirroredWorktreeIdByTabId: Map<string, string>
connectedPtyEvidence: {
tabIds: Set<string>
paneKeys: Set<string>
ptyIdByTerminalHandle: Map<string, string>
}
} {
const mirroredWorktreeIdByTabId = new Map<string, string>()
const sessionsByHostId = new Map<ExecutionHostId, WorkspaceSessionState>()
for (const summary of args.summaries.values()) {
const repo = args.repoById.get(summary.repoId)
const session = args.store?.getWorkspaceSession?.(repo ? getRepoExecutionHostId(repo) : 'local')
if (session) {
sessionsByHostId.set(repo ? getRepoExecutionHostId(repo) : 'local', session)
}
}
for (const session of sessionsByHostId.values()) {
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
for (const tab of tabs) {
mirroredWorktreeIdByTabId.set(tab.id, worktreeId)
}
if (tabs.length === 0) {
continue
}
const summary = args.getSummary(worktreeId)
if (summary && tabs.some((tab) => tab.ptyId && args.ptysById.get(tab.ptyId)?.connected)) {
summary.hasHostSidebarActivity = true
}
}
for (const worktreeId of getWorkspaceSessionTabActivityIds(session)) {
const summary = args.getSummary(worktreeId)
if (summary) {
summary.hasHostSidebarActivity = true
}
}
if (session.activeWorktreeId) {
const summary = args.getSummary(session.activeWorktreeId)
if (summary) {
summary.isActive = true
}
}
}
for (const [tabId, tab] of args.tabs) {
if (!mirroredWorktreeIdByTabId.has(tabId)) {
mirroredWorktreeIdByTabId.set(tabId, tab.worktreeId)
}
}
const connectedPtyEvidence = {
tabIds: new Set<string>(),
paneKeys: new Set<string>(),
ptyIdByTerminalHandle: new Map<string, string>()
}
for (const pty of args.ptysById.values()) {
if (!pty.connected) {
continue
}
if (pty.tabId) {
connectedPtyEvidence.tabIds.add(pty.tabId)
}
if (pty.paneKey) {
connectedPtyEvidence.paneKeys.add(pty.paneKey)
}
for (const terminalHandle of args.getTerminalHandlesForPty(pty.ptyId)) {
connectedPtyEvidence.ptyIdByTerminalHandle.set(terminalHandle, pty.ptyId)
}
}
return { mirroredWorktreeIdByTabId, connectedPtyEvidence }
}