diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx index 0bc4dff619a..c800e3db75d 100644 --- a/mobile/src/components/AgentSpinner.tsx +++ b/mobile/src/components/AgentSpinner.tsx @@ -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 = { 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 ( + + + + ) + } + if (status === 'working') { return ( @@ -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, diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx index a5f18b7941d..dd3693b2f97 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -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 diff --git a/mobile/src/host-screen/host-workspace-list.tsx b/mobile/src/host-screen/host-workspace-list.tsx index 9475579c4ab..99b77d66dba 100644 --- a/mobile/src/host-screen/host-workspace-list.tsx +++ b/mobile/src/host-screen/host-workspace-list.tsx @@ -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} diff --git a/mobile/src/worktree/workspace-list-ordering.ts b/mobile/src/worktree/workspace-list-ordering.ts index 48254ea19ad..6f3cd9ca975 100644 --- a/mobile/src/worktree/workspace-list-ordering.ts +++ b/mobile/src/worktree/workspace-list-ordering.ts @@ -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 = new Set([ + '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) +} diff --git a/mobile/src/worktree/workspace-list-sections.test.ts b/mobile/src/worktree/workspace-list-sections.test.ts index 36d4cd99c74..c81adfcd4c2 100644 --- a/mobile/src/worktree/workspace-list-sections.test.ts +++ b/mobile/src/worktree/workspace-list-sections.test.ts @@ -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 { 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', () => { diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index b92e7d67147..ea5f74324af 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -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() 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) diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index 6beb96c726c..f059bde78b4 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -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[] } diff --git a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts index 9822698117d..5f33ac6202b 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts @@ -160,6 +160,7 @@ describe('OrcaRuntimeService', () => { isPinned: false, isActive: false, status: 'active', + sidebarStatus: 'active', unread: false, liveTerminalCount: 1, hasAttachedPty: true, diff --git a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts index 008527c9a4b..5079efcd0a0 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts @@ -58,7 +58,7 @@ function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummar const summariesById = new Map([[WORKTREE_ID, row]]) attachRuntimeWorktreeAgentRows({ summaries: summariesById, - workingTerminalEvidenceByWorktreeId: new Map(), + terminalTitleEvidenceByWorktreeId: new Map(), rowSources: collectRuntimeWorktreeAgentSources({ mirroredWorktreeIdByTabId: new Map(), connectedPtyEvidence: { diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts index ab0340a291f..7da16839402 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -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: { diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index 3b12b07e535..ce1de144fe9 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -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 rowSources: ReadonlyMap - workingTerminalEvidenceByWorktreeId: ReadonlyMap< - string, - readonly RuntimeWorkingTerminalEvidence[] - > + terminalTitleEvidenceByWorktreeId: ReadonlyMap orchestrationByPaneKey: Record | 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) } diff --git a/src/main/runtime/runtime-worktree-ps-activity.ts b/src/main/runtime/runtime-worktree-ps-activity.ts index 3ce4e99cc9b..3c5cc848d63 100644 --- a/src/main/runtime/runtime-worktree-ps-activity.ts +++ b/src/main/runtime/runtime-worktree-ps-activity.ts @@ -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 { - const workingEvidence = new Map() +}): Map { + const titleEvidence = new Map() const savedTabOwnerById = new Map() 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, +function addTerminalTitleEvidence( + evidenceByWorktreeId: Map, worktreeId: string, - evidence: RuntimeWorkingTerminalEvidence + evidence: RuntimeTerminalTitleEvidence ): void { const existing = evidenceByWorktreeId.get(worktreeId) if (existing) { diff --git a/src/main/runtime/runtime-worktree-ps-summaries.ts b/src/main/runtime/runtime-worktree-ps-summaries.ts index f98273564b3..cf420e40187 100644 --- a/src/main/runtime/runtime-worktree-ps-summaries.ts +++ b/src/main/runtime/runtime-worktree-ps-summaries.ts @@ -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 }): Map { - const repoById = new Map((args.store?.getRepos() ?? []).map((repo) => [repo.id, repo])) const summaries = new Map() 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, { diff --git a/src/main/runtime/runtime-worktree-sidebar-status.test.ts b/src/main/runtime/runtime-worktree-sidebar-status.test.ts new file mode 100644 index 00000000000..1cc80b39db5 --- /dev/null +++ b/src/main/runtime/runtime-worktree-sidebar-status.test.ts @@ -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' + ) + }) +}) diff --git a/src/main/runtime/runtime-worktree-sidebar-status.ts b/src/main/runtime/runtime-worktree-sidebar-status.ts new file mode 100644 index 00000000000..b3ea47ccba8 --- /dev/null +++ b/src/main/runtime/runtime-worktree-sidebar-status.ts @@ -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 + +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, + 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() + 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) +} diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index 9ff6ec42499..67c9f329d12 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -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: { diff --git a/src/main/runtime/workspaces/workspace-process-listing.ts b/src/main/runtime/workspaces/workspace-process-listing.ts index 031a44664aa..b7b009a876d 100644 --- a/src/main/runtime/workspaces/workspace-process-listing.ts +++ b/src/main/runtime/workspaces/workspace-process-listing.ts @@ -87,7 +87,7 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr ) const session = this.store?.getWorkspaceSession?.() const countedPtyIds = new Set() - 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, diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index dd52c7da082..d07ed32bc39 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -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 diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index ed63ae4962d..5faded5ee3d 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -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. diff --git a/src/renderer/src/lib/agent-pane-activity-flags.ts b/src/renderer/src/lib/agent-pane-activity-flags.ts deleted file mode 100644 index ad5dd0f1206..00000000000 --- a/src/renderer/src/lib/agent-pane-activity-flags.ts +++ /dev/null @@ -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 -): 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 - } -} diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 4c446c94165..f205cddc4d9 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -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) } diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index 70c96809455..83904dd09a9 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -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 & { diff --git a/src/shared/sidebar-list/workspace-review-facts.ts b/src/shared/sidebar-list/workspace-review-facts.ts index efcef01440a..4c63cab1d1d 100644 --- a/src/shared/sidebar-list/workspace-review-facts.ts +++ b/src/shared/sidebar-list/workspace-review-facts.ts @@ -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 export type WorkspaceReviewFactsSnapshot = z.infer +type CachedPR = { number?: number; state?: string } + /** The window's PR cache, or main's persisted copy of it. */ -export type PRCacheView = Readonly< - Record +export type PRCacheView = Readonly< + Record > -/** Cached PR excluding one the user unlinked. */ -export function readWorkspaceReviewFact( +/** Cached PR for the workspace branch, excluding one the user unlinked. */ +export function readWorkspaceReviewPR( worktree: Pick, repo: Pick | undefined, - prCache: PRCacheView -): WorkspaceReviewFact | null { + prCache: PRCacheView +): 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, + repo: Pick | 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 diff --git a/src/shared/sidebar-list/workspace-rows.ts b/src/shared/sidebar-list/workspace-rows.ts index fa16a6b05d8..50c45e6d2f1 100644 --- a/src/shared/sidebar-list/workspace-rows.ts +++ b/src/shared/sidebar-list/workspace-rows.ts @@ -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( +export function withOwnerRepos( worktrees: readonly W[], repos: readonly R[] ): { worktree: W; repo: R | undefined }[] { diff --git a/src/shared/worktree-status-rollup.ts b/src/shared/worktree-status-rollup.ts new file mode 100644 index 00000000000..0c4903397c1 --- /dev/null +++ b/src/shared/worktree-status-rollup.ts @@ -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 +): 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 & { + 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 +} diff --git a/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts index f3d6a203cf4..d95b004d270 100644 --- a/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts @@ -75,7 +75,9 @@ function publishRow(host: HostRowModules, mainAgent: Record): 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(), diff --git a/tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts index a89fde840bb..5951b6e5543 100644 --- a/tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts @@ -90,7 +90,9 @@ function publishRows(host: HostRowModules): Record { 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(),