mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }[] {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user