fix(mobile): use desktop's PR lanes and status-dot rollup in the workspace list (#33966)

* fix(mobile): ask before running orca.yaml default-tab commands from the phone

The host's repo.hooks reply now carries desktop's setup config, so the phone
gates the Run/Skip choice and the trust prompt on defaultTabs commands too.

* fix(setup): keep the local script in run-both setup config without orca.yaml commands

* test(setup): drop the shared duplicate-host trust test desktop already covers

* refactor(setup): derive host setup trust from the shared setup config

* fix(mobile): count every host's repo rows before honouring repo-wide setup trust

* fix(mobile): show desktop's PR lanes and failed/interrupted status dots on the phone

The host builds worktree.ps linkedPR with the desktop's PR-lane cache reader
and sends the desktop sidebar dot as an optional sidebarStatus field.

* fix(mobile): keep uncached PR badges and let finished turns show through working titles

* fix(mobile): suppress agent panes' permission and parent titles in the host status dot

* test(runtime): expect sidebarStatus on the compact ps summary

* test(runtime): keep only host-specific sidebar status cases

* refactor(mobile): keep host title suppression and the uncached PR lane in one place each

* test(wire): pass the terminal title evidence map to new host builds

* fix(runtime): read each workspace's PR through its own host's repo
This commit is contained in:
Kelvin Amoaba
2026-10-27 03:20:58 +00:00
committed by GitHub
parent 71b55ab8a9
commit 3347d47a7d
27 changed files with 424 additions and 167 deletions
+25 -4
View File
@@ -1,16 +1,20 @@
import { Activity } from 'lucide-react-native'
import { Animated, StyleSheet, View } from 'react-native'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
import type { WorktreeStatus } from '../../../src/shared/worktree-status-rollup'
import { colors } from '../theme/mobile-theme'
import { useWorkingRingRotation } from './use-working-ring-rotation'
type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
// Match desktop sidebar StatusIndicator colors/sizes for five-state worktree rollup; palette distinguishes moving/alive/completed states.
// Match desktop sidebar StatusIndicator colors/sizes for the worktree rollup; palette distinguishes moving/alive/completed states.
const STATUS_COLORS: Record<WorktreeStatus, string> = {
working: '#eab308',
monitoring: '#eab308',
active: '#10b981',
done: '#10b981',
permission: '#ef4444',
failed: '#ef4444',
interrupted: colors.textMuted,
unconfirmed: colors.statusAmber,
inactive: 'rgba(115,115,115,0.4)'
}
@@ -21,7 +25,8 @@ export function AgentSpinner({
status: WorktreeStatus
workingMode?: AgentWorkingMode
}) {
const monitoring = status === 'working' && workingMode === 'monitoring'
const monitoring =
status === 'monitoring' || (status === 'working' && workingMode === 'monitoring')
const rotate = useWorkingRingRotation(status === 'working' && !monitoring)
const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive
@@ -34,6 +39,14 @@ export function AgentSpinner({
)
}
if (status === 'unconfirmed') {
return (
<View style={styles.wrapper} accessibilityLabel="Couldn’t confirm">
<View style={styles.dashedRing} />
</View>
)
}
if (status === 'working') {
return (
<View style={styles.wrapper}>
@@ -62,6 +75,14 @@ const styles = StyleSheet.create({
height: 8,
borderRadius: 4
},
dashedRing: {
width: 8,
height: 8,
borderRadius: 4,
borderWidth: 1.5,
borderStyle: 'dashed',
borderColor: STATUS_COLORS.unconfirmed
},
spinner: {
width: 8,
height: 8,
+2 -3
View File
@@ -8,6 +8,7 @@ import type { Worktree } from '../worktree/workspace-list-types'
import type { WorktreeCardDetails } from '../../../src/shared/worktree/card-properties'
import { isRemoteExecutionHostId } from '../../../src/shared/execution-host'
import type { AgentActivityDisplayMode } from '../../../src/shared/ui-chrome-types'
import type { WorktreeStatus } from '../../../src/shared/worktree-status-rollup'
import { areStructurallyEqual } from '../worktree/structural-equality'
import { projectGroupRowIndent } from '../worktree/project-group-indent'
import { getWorktreeMetaGlyphPresence } from '../worktree/worktree-meta-glyph-presence'
@@ -22,8 +23,6 @@ function displayBranch(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
type WorktreeRollupStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
type Props = {
item: Worktree
isReadOnly: boolean
@@ -35,7 +34,7 @@ type Props = {
/** What the desktop's shared Card layout and properties show, and where identity goes. */
cardDetails: WorktreeCardDetails
agentActivityDisplayMode: AgentActivityDisplayMode
status: WorktreeRollupStatus
status: WorktreeStatus
onPress: (item: Worktree) => void
onAgentPress?: (item: Worktree, paneKey: string) => void
statusLive?: boolean
@@ -22,7 +22,7 @@ import {
HostWorkspaceListReopen,
HostWorkspaceListStates
} from '../worktree/host-workspace-list-states'
import { getWorktreeStatus } from '../worktree/workspace-list-sections'
import { getWorktreeDotStatus } from '../worktree/workspace-list-ordering'
import { repoColor } from '../worktree/repo-color'
import { getWorktreeCardDetails } from '../../../src/shared/worktree/card-properties'
import { hostScreenStyles as styles } from './host-screen-styles'
@@ -247,7 +247,7 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
item={item}
isReadOnly={isReadOnly}
now={now}
status={getWorktreeStatus(item)}
status={getWorktreeDotStatus(item)}
repoColor={repoLookById.get(item.repoId)?.color ?? repoColor(item.repo)}
repoIcon={repoLookById.get(item.repoId)?.icon ?? null}
hideRepo={state.view.groupBy === 'repo' && !item.showProjectName}
@@ -1,4 +1,5 @@
import type { Worktree } from './workspace-list-types'
import type { WorktreeStatus } from '../../../src/shared/worktree-status-rollup'
import type { WorkspaceSortBy } from '../../../src/shared/workspace-view/workspace-view-options'
import {
buildWorkspaceRowComparator,
@@ -114,3 +115,22 @@ export function getWorktreeStatus(
}
return 'inactive'
}
// Only the host sees agent verdicts; older hosts omit sidebarStatus, and activity stays the phone's rule above.
const HOST_VERDICT_DOT_STATUSES: ReadonlySet<string> = new Set<WorktreeStatus>([
'permission',
'failed',
'working',
'monitoring',
'unconfirmed',
'interrupted',
'done'
])
function isHostVerdictDotStatus(value: string | undefined): value is WorktreeStatus {
return value !== undefined && HOST_VERDICT_DOT_STATUSES.has(value)
}
export function getWorktreeDotStatus(w: Worktree): WorktreeStatus {
return isHostVerdictDotStatus(w.sidebarStatus) ? w.sidebarStatus : getWorktreeStatus(w)
}
@@ -12,6 +12,7 @@ import {
} from './workspace-list-sections'
import { DEFAULT_WORKSPACE_STATUSES } from '../../../src/shared/workspace-statuses'
import { getMobileWorkspaceLineageGroupKey } from './mobile-workspace-lineage'
import { getWorktreeDotStatus } from './workspace-list-ordering'
function worktree(overrides: Partial<Worktree> = {}): Worktree {
const worktreePath = join('/tmp', 'orca', 'worktrees', 'feature')
@@ -253,6 +254,16 @@ describe('getWorktreeStatus', () => {
})
})
describe('getWorktreeDotStatus', () => {
it('shows the host’s agent verdict and keeps the phone rule for activity and unknown states', () => {
const row = worktree({ status: 'active', hasHostSidebarActivity: true })
expect(getWorktreeDotStatus({ ...row, sidebarStatus: 'failed' })).toBe('failed')
expect(getWorktreeDotStatus({ ...row, sidebarStatus: 'inactive' })).toBe('active')
expect(getWorktreeDotStatus({ ...row, sidebarStatus: 'from-a-newer-host' })).toBe('active')
expect(getWorktreeDotStatus(row)).toBe('active')
})
})
describe('buildSections', () => {
it('keys same-id rows by host inside a section', () => {
const worktreeId = 'repo-1::/work/orca'
@@ -649,7 +660,8 @@ describe('buildSections', () => {
[
worktree({ worktreeId: 'none', linkedPR: null }),
worktree({ worktreeId: 'open', linkedPR: { number: 1, state: 'open' } }),
worktree({ worktreeId: 'merged', linkedPR: { number: 2, state: 'merged' } })
worktree({ worktreeId: 'merged', linkedPR: { number: 2, state: 'merged' } }),
worktree({ worktreeId: 'uncached', linkedPR: { number: 3, state: 'unknown' } })
],
'name',
{ ...NO_WORKSPACE_ROW_FILTERS },
@@ -662,6 +674,7 @@ describe('buildSections', () => {
{ key: 'pr:in-review', title: 'In review' },
{ key: 'pr:in-progress', title: 'In progress' }
])
expect(sections.at(-1)?.data.map((row) => row.worktreeId)).toEqual(['none', 'uncached'])
})
it('groups by desktop workspace status labels and order', () => {
@@ -1,3 +1,4 @@
import { prGroupOfPsLinkedPR } from '../../../src/shared/sidebar-list/workspace-review-facts'
import {
buildWorktreeOccurrencePlan,
type WorktreeOccurrenceSection
@@ -14,7 +15,6 @@ import {
getMobileWorkspaceLineageChildren
} from './mobile-workspace-lineage'
import {
getPRGroupKeyForState,
getPRLaneKey,
PR_GROUP_LABELS,
PR_GROUP_ORDER
@@ -263,7 +263,7 @@ export function buildMobileWorkspacePlan(
} else if (groupMode === 'pr-status') {
const byGroup = new Map<string, Worktree[]>()
for (const w of canonicalGroupWorktrees) {
const key = getPRGroupKeyForState(w.linkedPR?.state)
const key = prGroupOfPsLinkedPR(w.linkedPR)
const list = byGroup.get(key)
if (list) {
list.push(w)
@@ -80,6 +80,8 @@ export type Worktree = {
linkedItems?: RuntimeWorktreePsSummary['linkedItems']
comment?: string
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
/** Unchecked wire data: a newer host may send a state this build does not know. */
sidebarStatus?: string
workingMode?: AgentWorkingMode
agents?: RuntimeWorktreeAgentRow[]
}
@@ -160,6 +160,7 @@ describe('OrcaRuntimeService', () => {
isPinned: false,
isActive: false,
status: 'active',
sidebarStatus: 'active',
unread: false,
liveTerminalCount: 1,
hasAttachedPty: true,
@@ -58,7 +58,7 @@ function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummar
const summariesById = new Map<string, RuntimeWorktreePsSummary>([[WORKTREE_ID, row]])
attachRuntimeWorktreeAgentRows({
summaries: summariesById,
workingTerminalEvidenceByWorktreeId: new Map(),
terminalTitleEvidenceByWorktreeId: new Map(),
rowSources: collectRuntimeWorktreeAgentSources({
mirroredWorktreeIdByTabId: new Map(),
connectedPtyEvidence: {
@@ -112,7 +112,7 @@ function ingest(summary: AgentSessionStatusSummary, children: AgentChildWorkEvid
const summaries = new Map([[WORKSPACE_ID, row]])
attachRuntimeWorktreeAgentRows({
summaries,
workingTerminalEvidenceByWorktreeId: new Map(),
terminalTitleEvidenceByWorktreeId: new Map(),
rowSources: collectRuntimeWorktreeAgentSources({
mirroredWorktreeIdByTabId: new Map(),
connectedPtyEvidence: {
+17 -22
View File
@@ -2,8 +2,12 @@ import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-types'
import { isAgentStatusFresh } from '../../shared/agent-status-freshness'
import type { RuntimeWorktreeAgentRow, RuntimeWorktreePsSummary } from '../../shared/runtime-types'
import { mergeWorktreeSummaryStatus } from './runtime-worktree-status-projection'
import {
resolveRuntimeWorktreeSidebarStatus,
terminalTitleEvidenceMatchesSource
} from './runtime-worktree-sidebar-status'
import type { RuntimeWorktreePsSummaryLookup } from './runtime-worktree-summary-paths'
import type { RuntimeWorkingTerminalEvidence } from './runtime-worktree-ps-activity'
import type { RuntimeTerminalTitleEvidence } from './runtime-worktree-ps-activity'
import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source'
export type { RuntimeAgentRowSnapshot } from './runtime-hook-agent-row-selection'
@@ -16,10 +20,7 @@ type OrchestrationDisplay = {
export function attachRuntimeWorktreeAgentRows(args: {
summaries: Map<string, RuntimeWorktreePsSummary>
rowSources: ReadonlyMap<string, RuntimeWorktreeAgentSource>
workingTerminalEvidenceByWorktreeId: ReadonlyMap<
string,
readonly RuntimeWorkingTerminalEvidence[]
>
terminalTitleEvidenceByWorktreeId: ReadonlyMap<string, readonly RuntimeTerminalTitleEvidence[]>
orchestrationByPaneKey: Record<string, OrchestrationDisplay> | null | undefined
getSummary: RuntimeWorktreePsSummaryLookup
}): void {
@@ -96,9 +97,11 @@ export function attachRuntimeWorktreeAgentRows(args: {
}
if (hasForegroundWorkingAgent || monitoringSources.length > 0) {
const hasIndependentWorkingTerminal = (
args.workingTerminalEvidenceByWorktreeId.get(worktreeId) ?? []
).some((evidence) =>
monitoringSources.every((source) => !workingTerminalEvidenceMatchesSource(evidence, source))
args.terminalTitleEvidenceByWorktreeId.get(worktreeId) ?? []
).some(
(evidence) =>
evidence.status === 'working' &&
monitoringSources.every((source) => !terminalTitleEvidenceMatchesSource(evidence, source))
)
mergeWorktreeSummaryStatus(
summary,
@@ -107,20 +110,12 @@ export function attachRuntimeWorktreeAgentRows(args: {
)
}
}
}
function workingTerminalEvidenceMatchesSource(
evidence: RuntimeWorkingTerminalEvidence,
source: RuntimeWorktreeAgentSource
): boolean {
if (evidence.paneKey) {
return (
evidence.paneKey === source.paneKey ||
Boolean(evidence.ptyId && source.ptyId && evidence.ptyId === source.ptyId)
for (const summary of args.summaries.values()) {
summary.sidebarStatus = resolveRuntimeWorktreeSidebarStatus(
summary,
now,
args.terminalTitleEvidenceByWorktreeId.get(summary.worktreeId) ?? [],
(paneKey) => rowSources.get(paneKey)
)
}
if (evidence.ptyId && source.ptyId) {
return evidence.ptyId === source.ptyId
}
return Boolean(evidence.tabId && evidence.tabId === source.tabId)
}
@@ -20,7 +20,9 @@ import {
import { worktreeIdsEqual } from '../../shared/worktree/id'
import type { PtyPaneOf } from './terminal/surface/pty-pane-query'
export type RuntimeWorkingTerminalEvidence = {
/** A live terminal whose title reads working or needs-permission, kept per pane so agent rows can suppress it. */
export type RuntimeTerminalTitleEvidence = {
status: 'working' | 'permission'
paneKey: string | null
ptyId: string | null
tabId: string | null
@@ -38,8 +40,8 @@ export function applyRuntimeWorktreePsTerminalActivity(args: {
paneOf: PtyPaneOf
getTitleDisplayClear: (ptyId: string) => TitleDisplayClear | null
getSummary: RuntimeWorktreePsSummaryLookup
}): Map<string, RuntimeWorkingTerminalEvidence[]> {
const workingEvidence = new Map<string, RuntimeWorkingTerminalEvidence[]>()
}): Map<string, RuntimeTerminalTitleEvidence[]> {
const titleEvidence = new Map<string, RuntimeTerminalTitleEvidence[]>()
const savedTabOwnerById = new Map<string, { worktreeId: string; title: string }>()
for (const [worktreeId, tabs] of Object.entries(args.session?.tabsByWorktree ?? {})) {
for (const tab of tabs) {
@@ -85,8 +87,9 @@ export function applyRuntimeWorktreePsTerminalActivity(args: {
getLeafDisplayRecord(leaf, args.getTitleDisplayClear(leaf.ptyId)),
args.tabs.get(leaf.tabId)?.title ?? null
)
if (leafStatus === 'working') {
addWorkingTerminalEvidence(workingEvidence, summary.worktreeId, {
if (leafStatus === 'working' || leafStatus === 'permission') {
addTerminalTitleEvidence(titleEvidence, summary.worktreeId, {
status: leafStatus,
paneKey: args.getPaneKey(leaf),
ptyId: leaf.ptyId,
tabId: leaf.tabId
@@ -147,8 +150,9 @@ export function applyRuntimeWorktreePsTerminalActivity(args: {
summary.hasHostSidebarActivity = true
summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt)
const ptyStatus = getSavedTabWorktreeStatus(owner.title, true)
if (ptyStatus === 'working') {
addWorkingTerminalEvidence(workingEvidence, summary.worktreeId, {
if (ptyStatus === 'working' || ptyStatus === 'permission') {
addTerminalTitleEvidence(titleEvidence, summary.worktreeId, {
status: ptyStatus,
paneKey: recorded.paneKey,
ptyId: pty.ptyId,
tabId: recorded.tabId ?? persistedTabId ?? null
@@ -162,13 +166,13 @@ export function applyRuntimeWorktreePsTerminalActivity(args: {
summary.preview = pty.preview
}
}
return workingEvidence
return titleEvidence
}
function addWorkingTerminalEvidence(
evidenceByWorktreeId: Map<string, RuntimeWorkingTerminalEvidence[]>,
function addTerminalTitleEvidence(
evidenceByWorktreeId: Map<string, RuntimeTerminalTitleEvidence[]>,
worktreeId: string,
evidence: RuntimeWorkingTerminalEvidence
evidence: RuntimeTerminalTitleEvidence
): void {
const existing = evidenceByWorktreeId.get(worktreeId)
if (existing) {
@@ -5,6 +5,9 @@ import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree'
import { getWorkspaceAttachments } from '../../shared/workspace-attachments'
import { getWorkspaceRowKindFacts } from '../../shared/worktree/workspace-kinds'
import { readWorkspaceReviewPR } from '../../shared/sidebar-list/workspace-review-facts'
import { withOwnerRepos } from '../../shared/sidebar-list/workspace-rows'
import { UNCACHED_LINKED_PR_STATE } from '../../shared/runtime-worktree-contracts'
import type { RuntimeStore } from './runtime-store-contract'
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
@@ -13,27 +16,27 @@ export function buildRuntimeWorktreePsSummaries(args: {
resolvedWorktrees: readonly ResolvedWorktree[]
platformByRepoId: ReadonlyMap<string, NodeJS.Platform>
}): Map<string, RuntimeWorktreePsSummary> {
const repoById = new Map((args.store?.getRepos() ?? []).map((repo) => [repo.id, repo]))
const summaries = new Map<string, RuntimeWorktreePsSummary>()
const ghCache = args.store?.getGitHubCache?.()
for (const worktree of args.resolvedWorktrees) {
for (const { worktree, repo } of withOwnerRepos(
args.resolvedWorktrees,
args.store?.getRepos() ?? []
)) {
const meta =
args.store?.getWorktreeMeta?.(worktree.id) ?? args.store?.getAllWorktreeMeta()[worktree.id]
const repo = repoById.get(worktree.repoId)
let linkedPR: RuntimeWorktreePsSummary['linkedPR'] = null
const branch = worktree.branch.replace(/^refs\/heads\//, '')
if (branch && ghCache) {
const cached =
(repo?.id ? ghCache.pr[`${repo.id}::${branch}`] : undefined) ??
(repo?.path ? ghCache.pr[`${repo.path}::${branch}`] : undefined)
if (cached?.data) {
const { number, state, title, url, checksStatus } = cached.data
linkedPR = { number, state, title, url, checksStatus }
}
}
if (!linkedPR && meta?.linkedPR != null) {
linkedPR = { number: meta.linkedPR, state: 'unknown' }
}
// Why: read the cache entry the desktop's PR lanes read; an uncached link keeps its badge as state 'unknown'.
const cachedPR = ghCache ? readWorkspaceReviewPR(worktree, repo, ghCache.pr) : null
const linkedPR: RuntimeWorktreePsSummary['linkedPR'] = cachedPR
? {
number: cachedPR.number,
state: cachedPR.state,
title: cachedPR.title,
url: cachedPR.url,
checksStatus: cachedPR.checksStatus
}
: meta?.linkedPR != null
? { number: meta.linkedPR, state: UNCACHED_LINKED_PR_STATE }
: null
const lineage = worktree.lineage
const priorWorktreeIds = meta?.priorWorktreeIds ?? worktree.priorWorktreeIds
summaries.set(worktree.id, {
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-freshness'
import type { RuntimeWorktreeAgentRow } from '../../shared/runtime-types'
import type { RuntimeTerminalTitleEvidence } from './runtime-worktree-ps-activity'
import { resolveRuntimeWorktreeSidebarStatus } from './runtime-worktree-sidebar-status'
const NOW = 10_000_000
const AGENT_PANE = 'tab-1:1'
const OTHER_PANE = 'tab-2:1'
function doneRow(outcome: 'failure' | 'success', updatedAt = NOW): RuntimeWorktreeAgentRow {
return {
paneKey: AGENT_PANE,
parentPaneKey: null,
state: 'done',
agentType: 'claude',
prompt: '',
taskTitle: null,
displayName: null,
lastAssistantMessage: null,
toolName: null,
toolInput: null,
interrupted: false,
mainAgent: { state: 'done', outcome, stateStartedAt: updatedAt },
stateStartedAt: updatedAt,
updatedAt
}
}
function title(
status: RuntimeTerminalTitleEvidence['status'],
paneKey: string
): RuntimeTerminalTitleEvidence {
return { status, paneKey, ptyId: null, tabId: null }
}
function resolve(
status: 'active' | 'working' | 'permission',
agents: RuntimeWorktreeAgentRow[],
titles: RuntimeTerminalTitleEvidence[] = []
) {
return resolveRuntimeWorktreeSidebarStatus({ status, agents }, NOW, titles, (paneKey) =>
paneKey === AGENT_PANE ? { paneKey, ptyId: undefined, tabId: 'tab-1' } : undefined
)
}
describe('resolveRuntimeWorktreeSidebarStatus', () => {
it('shows a fresh failed turn over a live terminal', () => {
expect(resolve('active', [doneRow('failure')])).toBe('failed')
})
it('keeps another pane’s question above a failed turn, but not the failed pane’s own title', () => {
expect(resolve('permission', [doneRow('failure')], [title('permission', OTHER_PANE)])).toBe(
'permission'
)
expect(resolve('permission', [doneRow('failure')], [title('permission', AGENT_PANE)])).toBe(
'failed'
)
})
it('lets a finished turn show through its own pane’s lingering working title', () => {
expect(resolve('working', [doneRow('success')], [title('working', AGENT_PANE)])).toBe('done')
expect(resolve('working', [doneRow('success')], [title('working', OTHER_PANE)])).toBe('working')
})
it('falls back to the host status once the agent row is stale', () => {
expect(resolve('active', [doneRow('failure', NOW - AGENT_STATUS_STALE_AFTER_MS - 1)])).toBe(
'active'
)
})
})
@@ -0,0 +1,73 @@
import { isAgentStatusFresh } from '../../shared/agent-status-freshness'
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
import {
applyAgentPaneActivityFlags,
createAgentPaneActivityFlags,
rankWorktreeStatus,
type WorktreeStatus
} from '../../shared/worktree-status-rollup'
import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source'
import type { RuntimeTerminalTitleEvidence } from './runtime-worktree-ps-activity'
type AgentTitleSource = Pick<RuntimeWorktreeAgentSource, 'paneKey' | 'ptyId' | 'tabId'>
export function terminalTitleEvidenceMatchesSource(
evidence: RuntimeTerminalTitleEvidence,
source: AgentTitleSource
): boolean {
if (evidence.paneKey) {
return (
evidence.paneKey === source.paneKey ||
Boolean(evidence.ptyId && source.ptyId && evidence.ptyId === source.ptyId)
)
}
if (evidence.ptyId && source.ptyId) {
return evidence.ptyId === source.ptyId
}
return Boolean(evidence.tabId && evidence.tabId === source.tabId)
}
/** The desktop sidebar dot: host status stands in for the title heuristic under fresh agent verdicts. */
export function resolveRuntimeWorktreeSidebarStatus(
summary: Pick<RuntimeWorktreePsSummary, 'status' | 'agents'>,
now: number,
titleEvidence: readonly RuntimeTerminalTitleEvidence[],
sourceOf: (paneKey: string) => AgentTitleSource | undefined
): WorktreeStatus {
if (summary.agents.length === 0 && titleEvidence.length === 0) {
return summary.status === 'working' || summary.status === 'permission'
? 'active'
: summary.status
}
const flags = createAgentPaneActivityFlags()
const freshSources: AgentTitleSource[] = []
// Why: like desktop, a finished worker also silences its orchestrating parent's title.
const finishedWorkerParents = new Set<string>()
for (const row of summary.agents) {
if (!isAgentStatusFresh(row, now)) {
continue
}
applyAgentPaneActivityFlags(flags, row)
const source = sourceOf(row.paneKey)
if (source) {
freshSources.push(source)
}
if (row.state === 'done' && row.parentPaneKey) {
finishedWorkerParents.add(row.parentPaneKey)
}
}
// Why: desktop ignores titles of panes with a fresh agent row; live rows return through the flags.
const titles = titleEvidence.filter(
(evidence) =>
!(evidence.paneKey && finishedWorkerParents.has(evidence.paneKey)) &&
freshSources.every((source) => !terminalTitleEvidenceMatchesSource(evidence, source))
)
const titleStatus = titles.some((evidence) => evidence.status === 'permission')
? 'permission'
: titles.length > 0
? 'working'
: 'active'
const heuristic =
summary.status === 'working' || summary.status === 'permission' ? titleStatus : summary.status
return rankWorktreeStatus(heuristic, flags)
}
@@ -125,7 +125,7 @@ function worktreeFor(store: AgentHookServer): RuntimeWorktreePsSummary {
const summaries = new Map([[WORKTREE_ID, row]])
attachRuntimeWorktreeAgentRows({
summaries,
workingTerminalEvidenceByWorktreeId: new Map(),
terminalTitleEvidenceByWorktreeId: new Map(),
rowSources: collectRuntimeWorktreeAgentSources({
mirroredWorktreeIdByTabId: new Map(),
connectedPtyEvidence: {
@@ -87,7 +87,7 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr
)
const session = this.store?.getWorkspaceSession?.()
const countedPtyIds = new Set<string>()
const workingTerminalEvidenceByWorktreeId = applyRuntimeWorktreePsTerminalActivity({
const terminalTitleEvidenceByWorktreeId = applyRuntimeWorktreePsTerminalActivity({
freshPtyLiveness,
countedPtyIds,
leaves: this.leaves.values(),
@@ -119,7 +119,7 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr
})
attachRuntimeWorktreeAgentRows({
summaries,
workingTerminalEvidenceByWorktreeId,
terminalTitleEvidenceByWorktreeId,
rowSources: collectRuntimeWorktreeAgentSources({
mirroredWorktreeIdByTabId,
connectedPtyEvidence,
@@ -11,7 +11,7 @@ import {
type AgentStatusOrchestrationContext
} from '../../../../shared/agent-status-types'
import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict'
import { applyAgentPaneActivityFlags } from '@/lib/agent-pane-activity-flags'
import { applyAgentPaneActivityFlags } from '../../../../shared/worktree-status-rollup'
export type WorktreeAgentActivitySummary = {
hasPermission: boolean
@@ -14,7 +14,7 @@ import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stab
import {
applyAgentPaneActivityFlags,
type AgentPaneActivityFlags
} from '@/lib/agent-pane-activity-flags'
} from '../../../../shared/worktree-status-rollup'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
// Share sidebar pane-state resolution, excluding worktree-only retained-done promotion.
@@ -1,45 +0,0 @@
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict'
export type AgentPaneActivityFlags = {
hasPermission: boolean
hasLiveWorking: boolean
hasLiveMonitoring: boolean
hasFailed: boolean
hasInterrupted: boolean
hasUnconfirmed: boolean
hasLiveDone: boolean
}
export function applyAgentPaneActivityFlags(
flags: AgentPaneActivityFlags,
entry: Pick<AgentStatusEntry, 'state' | 'workingMode' | 'interrupted' | 'mainAgent'>
): void {
const mark = agentVerdictDisplayMark(entry)
if (entry.state === 'blocked' || entry.state === 'waiting') {
flags.hasPermission = true
}
// Keep main-agent failure alongside a subagent’s question so it remains visible after the question clears.
switch (mark) {
case 'failed':
flags.hasFailed = true
return
case 'interrupted':
flags.hasInterrupted = true
return
case 'unconfirmed':
flags.hasUnconfirmed = true
return
case null:
break
}
if (entry.state === 'working') {
if (entry.workingMode === 'monitoring') {
flags.hasLiveMonitoring = true
} else {
flags.hasLiveWorking = true
}
} else if (entry.state === 'done') {
flags.hasLiveDone = true
}
}
+3 -44
View File
@@ -11,17 +11,9 @@ import type {
} from '../../../shared/terminal-tab-types'
import type { TuiAgent } from '../../../shared/tui-agent'
import type { LiveAgentWorktreeStatus } from './worktree-activity-state'
import { rankWorktreeStatus, type WorktreeStatus } from '../../../shared/worktree-status-rollup'
export type WorktreeStatus =
| 'active'
| 'working'
| 'monitoring'
| 'permission'
| 'failed'
| 'interrupted'
| 'unconfirmed'
| 'done'
| 'inactive'
export type { WorktreeStatus } from '../../../shared/worktree-status-rollup'
type WorktreeStatusHeuristicOptions = {
liveAgentStatus?: LiveAgentWorktreeStatus
@@ -206,38 +198,5 @@ export function resolveWorktreeStatus(args: {
terminalLayoutRootsByTabId: args.terminalLayoutRootsByTabId
}
)
if (args.hasPermission) {
return 'permission'
}
// Why: heuristic 'permission' outranks heuristic 'working' — the user-actionable signal wins when panes in one tab disagree.
if (heuristic === 'permission') {
return 'permission'
}
// Failure outranks live work, including surviving subagents; only a pending question comes first.
if (args.hasFailed) {
return 'failed'
}
// Why: restored cards get the hook snapshot before panes mount; trust the explicit working row so they stay yellow on restart.
if (args.hasLiveWorking || heuristic === 'working') {
return 'working'
}
if (args.hasLiveMonitoring || heuristic === 'monitoring') {
return 'monitoring'
}
// Why: a departed agent's failure has no expiry, so it must not pin the card over live work.
if (args.hasRetainedFailed) {
return 'failed'
}
// Why: an end Orca cannot prove is not a finish, and unlike a user's Stop it is news.
if (args.hasUnconfirmed) {
return 'unconfirmed'
}
// A user's Stop follows every state that is news, but must not collapse into success.
if (args.hasInterrupted) {
return 'interrupted'
}
if (args.hasLiveDone || args.hasRetainedDone) {
return 'done'
}
return heuristic
return rankWorktreeStatus(heuristic, args)
}
+7 -1
View File
@@ -15,6 +15,7 @@ import type {
import type { RuntimeListingHostScope } from './runtime-listing-host-scope'
import type { GitWorktreeInfo, Worktree } from './worktree/types'
import type { WorkspaceStatusWarning } from './workspace-status-warning'
import type { WorktreeStatus } from './worktree-status-rollup'
export type RuntimeWorktreeSetResult = {
worktree: RuntimeWorktreeRecord
@@ -82,7 +83,7 @@ export type RuntimeWorktreePsSummary = {
createdAt?: number
creatorProvenance?: Worktree['creatorProvenance']
linkedIssue: number | null
/** Host-cached PR, else linked number with unknown state; old hosts omit title/url/checks. */
/** The cached PR the desktop's PR lanes read, else a linked number with state UNCACHED_LINKED_PR_STATE. Old hosts omit title/url/checks. */
linkedPR: {
number: number
state: string
@@ -107,6 +108,8 @@ export type RuntimeWorktreePsSummary = {
status: RuntimeWorktreeStatus
/** Optional discriminator for a working workspace; older clients fall back to ordinary working. */
workingMode?: AgentWorkingMode
/** The desktop sidebar dot, including failed/interrupted/unconfirmed; older hosts omit it. */
sidebarStatus?: WorktreeStatus
agents: RuntimeWorktreeAgentRow[]
/** See `Worktree.removing`; sent only to clients that advertise background removal. */
removing?: true
@@ -151,6 +154,9 @@ export type RuntimeGitCheckoutResult = {
branch: string
}
/** A linked PR the host has no cached state for: it shows a badge but, like desktop, sits in the In progress lane. */
export const UNCACHED_LINKED_PR_STATE = 'unknown'
export type RuntimeWorktreeStatus = 'active' | 'working' | 'permission' | 'done' | 'inactive'
export type RuntimeWorktreeRecord = Worktree & {
@@ -11,6 +11,7 @@ import {
type HostScopedWorktreeKey
} from '../worktree/host-qualified-identity'
import { listGitWorkspaceRows } from './workspace-rows'
import { UNCACHED_LINKED_PR_STATE } from '../runtime-worktree-contracts'
/** PR grouping currently admits only GitHub facts. */
const WorkspaceReviewFactSchema = z.object({
@@ -26,17 +27,19 @@ export const WorkspaceReviewFactsSnapshotSchema = z.object({
export type WorkspaceReviewFact = z.infer<typeof WorkspaceReviewFactSchema>
export type WorkspaceReviewFactsSnapshot = z.infer<typeof WorkspaceReviewFactsSnapshotSchema>
type CachedPR = { number?: number; state?: string }
/** The window's PR cache, or main's persisted copy of it. */
export type PRCacheView = Readonly<
Record<string, { data: { number?: number; state?: string } | null } | undefined>
export type PRCacheView<T extends CachedPR = CachedPR> = Readonly<
Record<string, { data: T | null } | undefined>
>
/** Cached PR excluding one the user unlinked. */
export function readWorkspaceReviewFact(
/** Cached PR for the workspace branch, excluding one the user unlinked. */
export function readWorkspaceReviewPR<T extends CachedPR>(
worktree: Pick<Worktree, 'branch' | 'linkedPR' | 'suppressedGitHubPR'>,
repo: Pick<Repo, 'id' | 'path' | 'connectionId' | 'executionHostId'> | undefined,
prCache: PRCacheView
): WorkspaceReviewFact | null {
prCache: PRCacheView<T>
): T | null {
const branch = worktree.branch.replace(/^refs\/heads\//, '')
if (!repo || !branch) {
return null
@@ -49,13 +52,29 @@ export function readWorkspaceReviewFact(
if (!pr || (typeof pr.number === 'number' && isGitHubPRSuppressed(worktree, pr.number))) {
return null
}
return { provider: 'github', reviewState: pr.state ?? '' }
return pr
}
export function readWorkspaceReviewFact(
worktree: Pick<Worktree, 'branch' | 'linkedPR' | 'suppressedGitHubPR'>,
repo: Pick<Repo, 'id' | 'path' | 'connectionId' | 'executionHostId'> | undefined,
prCache: PRCacheView
): WorkspaceReviewFact | null {
const pr = readWorkspaceReviewPR(worktree, repo, prCache)
return pr ? { provider: 'github', reviewState: pr.state ?? '' } : null
}
export function prGroupOfReviewFact(fact: WorkspaceReviewFact | null | undefined): PRGroupKey {
return fact ? getPRGroupKeyForState(fact.reviewState) : 'in-progress'
}
/** PR lane for a worktree.ps row, whose uncached linked PR carries no review fact. */
export function prGroupOfPsLinkedPR(linkedPR: { state: string } | null | undefined): PRGroupKey {
return linkedPR && linkedPR.state !== UNCACHED_LINKED_PR_STATE
? getPRGroupKeyForState(linkedPR.state)
: 'in-progress'
}
export function workspaceReviewFactsEqual(
a: WorkspaceReviewFact | null | undefined,
b: WorkspaceReviewFact | null | undefined
+1 -1
View File
@@ -148,7 +148,7 @@ function listedRow(worktree: ListedWorktree, repo: ListedRepo | undefined): List
}
/** Resolve each row’s own repo; repo IDs can collide across hosts. */
function withOwnerRepos<W extends ListedWorktree, R extends ListedRepo>(
export function withOwnerRepos<W extends ListedWorktree, R extends ListedRepo>(
worktrees: readonly W[],
repos: readonly R[]
): { worktree: W; repo: R | undefined }[] {
+112
View File
@@ -0,0 +1,112 @@
import type { AgentStatusEntry } from './agent-status-types'
import { agentVerdictDisplayMark } from './agent-main-agent-verdict'
export type WorktreeStatus =
| 'active'
| 'working'
| 'monitoring'
| 'permission'
| 'failed'
| 'interrupted'
| 'unconfirmed'
| 'done'
| 'inactive'
export type AgentPaneActivityFlags = {
hasPermission: boolean
hasLiveWorking: boolean
hasLiveMonitoring: boolean
hasFailed: boolean
hasInterrupted: boolean
hasUnconfirmed: boolean
hasLiveDone: boolean
}
export function createAgentPaneActivityFlags(): AgentPaneActivityFlags {
return {
hasPermission: false,
hasLiveWorking: false,
hasLiveMonitoring: false,
hasFailed: false,
hasInterrupted: false,
hasUnconfirmed: false,
hasLiveDone: false
}
}
export function applyAgentPaneActivityFlags(
flags: AgentPaneActivityFlags,
entry: Pick<AgentStatusEntry, 'state' | 'workingMode' | 'interrupted' | 'mainAgent'>
): void {
const mark = agentVerdictDisplayMark(entry)
if (entry.state === 'blocked' || entry.state === 'waiting') {
flags.hasPermission = true
}
// Keep main-agent failure alongside a subagent’s question so it remains visible after the question clears.
switch (mark) {
case 'failed':
flags.hasFailed = true
return
case 'interrupted':
flags.hasInterrupted = true
return
case 'unconfirmed':
flags.hasUnconfirmed = true
return
case null:
break
}
if (entry.state === 'working') {
if (entry.workingMode === 'monitoring') {
flags.hasLiveMonitoring = true
} else {
flags.hasLiveWorking = true
}
} else if (entry.state === 'done') {
flags.hasLiveDone = true
}
}
/** Overlay explicit agent evidence on the terminal-title heuristic; the sidebar dot's priority order. */
export function rankWorktreeStatus(
heuristic: WorktreeStatus,
flags: Partial<AgentPaneActivityFlags> & {
hasRetainedDone?: boolean
hasRetainedFailed?: boolean
}
): WorktreeStatus {
if (flags.hasPermission) {
return 'permission'
}
// Why: heuristic 'permission' outranks heuristic 'working' — the user-actionable signal wins when panes in one tab disagree.
if (heuristic === 'permission') {
return 'permission'
}
// Failure outranks live work, including surviving subagents; only a pending question comes first.
if (flags.hasFailed) {
return 'failed'
}
// Why: restored cards get the hook snapshot before panes mount; trust the explicit working row so they stay yellow on restart.
if (flags.hasLiveWorking || heuristic === 'working') {
return 'working'
}
if (flags.hasLiveMonitoring || heuristic === 'monitoring') {
return 'monitoring'
}
// Why: a departed agent's failure has no expiry, so it must not pin the card over live work.
if (flags.hasRetainedFailed) {
return 'failed'
}
// Why: an end Orca cannot prove is not a finish, and unlike a user's Stop it is news.
if (flags.hasUnconfirmed) {
return 'unconfirmed'
}
// A user's Stop follows every state that is news, but must not collapse into success.
if (flags.hasInterrupted) {
return 'interrupted'
}
if (flags.hasLiveDone || flags.hasRetainedDone) {
return 'done'
}
return heuristic
}
@@ -75,7 +75,9 @@ function publishRow(host: HostRowModules, mainAgent: Record<string, unknown>): R
summaries,
pathIndex: { byPath: new Map(), byRealPath: new Map() },
missingWorktreeIds: new Set(),
// Older builds read workingTerminalEvidenceByWorktreeId; newer ones read terminalTitleEvidenceByWorktreeId.
workingTerminalEvidenceByWorktreeId: new Map(),
terminalTitleEvidenceByWorktreeId: new Map(),
rowSources: host.collectRuntimeWorktreeAgentSources({
hookSnapshots: [snapshot],
mirroredWorktreeIdByTabId: new Map(),
@@ -90,7 +90,9 @@ function publishRows(host: HostRowModules): Record<string, AgentRow> {
summaries,
pathIndex: { byPath: new Map(), byRealPath: new Map() },
missingWorktreeIds: new Set(),
// Older builds read workingTerminalEvidenceByWorktreeId; newer ones read terminalTitleEvidenceByWorktreeId.
workingTerminalEvidenceByWorktreeId: new Map(),
terminalTitleEvidenceByWorktreeId: new Map(),
rowSources: host.collectRuntimeWorktreeAgentSources({
hookSnapshots: SNAPSHOTS,
mirroredWorktreeIdByTabId: new Map(),