fix: resolve stale terminal pane activity status (#4321)

This commit is contained in:
Jinjing 2026-05-31 15:21:46 -07:00 committed by GitHub
parent 7be13cfa4a
commit 8c6235cda7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 342 additions and 70 deletions

View File

@ -3260,6 +3260,7 @@ const WorktreeList = React.memo(function WorktreeList({
const sectionActivityBrowserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
const sectionActivityPtyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const sectionActivityRuntimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const sectionActivityTerminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId)
const sectionActivityAgentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
const sectionActivityMigrationUnsupportedByPtyId = useAppStore(
(s) => s.migrationUnsupportedByPtyId
@ -3651,6 +3652,7 @@ const WorktreeList = React.memo(function WorktreeList({
browserTabsByWorktree: sectionActivityBrowserTabsByWorktree,
ptyIdsByTabId: sectionActivityPtyIdsByTabId,
runtimePaneTitlesByTabId: sectionActivityRuntimePaneTitlesByTabId,
terminalLayoutsByTabId: sectionActivityTerminalLayoutsByTabId,
agentStatusEpoch: sectionActivityAgentStatusEpoch,
// Why: agentStatusByPaneKey can tick for same-state tool details. The
// section counts only need structural status transitions, tracked by
@ -3666,6 +3668,7 @@ const WorktreeList = React.memo(function WorktreeList({
sectionActivityPtyIdsByTabId,
sectionActivityRetainedAgentsByPaneKey,
sectionActivityRuntimePaneTitlesByTabId,
sectionActivityTerminalLayoutsByTabId,
sectionActivityTabsByWorktree
])
const sectionActivityByGroupKey = useMemo(

View File

@ -0,0 +1,53 @@
import { FIRST_PANE_ID } from '../../../../shared/pane-key'
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types'
function getLeftmostLeafId(node: TerminalPaneLayoutNode): string {
return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first)
}
function collectReplayCreatedPaneLeafIds(
node: TerminalPaneLayoutNode,
leafIdsInReplayCreationOrder: string[]
): void {
if (node.type === 'leaf') {
return
}
leafIdsInReplayCreationOrder.push(getLeftmostLeafId(node.second))
if (node.first.type === 'split') {
collectReplayCreatedPaneLeafIds(node.first, leafIdsInReplayCreationOrder)
}
if (node.second.type === 'split') {
collectReplayCreatedPaneLeafIds(node.second, leafIdsInReplayCreationOrder)
}
}
function collectLeafIdsInReplayCreationOrder(
node: TerminalPaneLayoutNode | null | undefined
): string[] {
if (!node) {
return []
}
const leafIdsInReplayCreationOrder = [getLeftmostLeafId(node)]
if (node.type === 'split') {
collectReplayCreatedPaneLeafIds(node, leafIdsInReplayCreationOrder)
}
return leafIdsInReplayCreationOrder
}
export function resolveRuntimePaneTitleLeafId(
tabLayout: TerminalLayoutSnapshot | undefined,
runtimePaneId: string
): string | null {
if (isTerminalLeafId(runtimePaneId)) {
return runtimePaneId
}
const numericPaneId = Number(runtimePaneId)
if (!Number.isInteger(numericPaneId) || numericPaneId < FIRST_PANE_ID) {
return null
}
const leafIds = collectLeafIdsInReplayCreationOrder(tabLayout?.root)
return leafIds[numericPaneId - FIRST_PANE_ID] ?? null
}

View File

@ -1,21 +1,16 @@
import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { resolveRuntimePaneTitleLeafId } from './runtime-pane-title-leaf-id'
import type { AgentStatus } from '../../../../shared/agent-detection'
import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
TerminalTab,
Worktree
} from '../../../../shared/types'
import type { TerminalLayoutSnapshot, TerminalTab, Worktree } from '../../../../shared/types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStateHistoryEntry,
type AgentStatusEntry,
type MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
import { isTerminalLeafId, parsePaneKey } from '../../../../shared/stable-pane-id'
import { FIRST_PANE_ID } from '../../../../shared/pane-key'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
/**
* Ordinal class for the "Smart" sort. Lower number = more attention-demanding.
@ -240,52 +235,6 @@ function leafIdFromPaneKey(paneKey: string): string | null {
return parsePaneKey(paneKey)?.leafId ?? null
}
function getLeftmostLeafId(node: TerminalPaneLayoutNode): string {
return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first)
}
function collectReplayCreatedPaneLeafIds(
node: Extract<TerminalPaneLayoutNode, { type: 'split' }>,
leafIdsInReplayCreationOrder: string[]
): void {
leafIdsInReplayCreationOrder.push(getLeftmostLeafId(node.second))
if (node.first.type === 'split') {
collectReplayCreatedPaneLeafIds(node.first, leafIdsInReplayCreationOrder)
}
if (node.second.type === 'split') {
collectReplayCreatedPaneLeafIds(node.second, leafIdsInReplayCreationOrder)
}
}
function collectLeafIdsInReplayCreationOrder(
node: TerminalPaneLayoutNode | null | undefined
): string[] {
if (!node) {
return []
}
const leafIdsInReplayCreationOrder = [getLeftmostLeafId(node)]
if (node.type === 'split') {
collectReplayCreatedPaneLeafIds(node, leafIdsInReplayCreationOrder)
}
return leafIdsInReplayCreationOrder
}
function resolveRuntimePaneTitleLeafId(
tabLayout: TerminalLayoutSnapshot | undefined,
runtimePaneId: string
): string | null {
if (isTerminalLeafId(runtimePaneId)) {
return runtimePaneId
}
const numericPaneId = Number(runtimePaneId)
if (!Number.isInteger(numericPaneId) || numericPaneId < FIRST_PANE_ID) {
return null
}
const leafIds = collectLeafIdsInReplayCreationOrder(tabLayout?.root)
return leafIds[numericPaneId - FIRST_PANE_ID] ?? null
}
/**
* Build the per-worktree attention map consumed by the smart comparator.
*

View File

@ -2,15 +2,17 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalTab } from '../../../../shared/types'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
import { useWorktreeActivityStatus } from './use-worktree-activity-status'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
type MockState = {
tabsByWorktree: Record<string, TerminalTab[]>
browserTabsByWorktree: Record<string, { id: string }[]>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
ptyIdsByTabId: Record<string, string[]>
agentStatusEpoch: number
agentStatusByPaneKey: Record<string, AgentStatusEntry>
@ -51,6 +53,19 @@ function makeAgentStatusEntry(args: {
}
}
function makeSplitLayout(): TerminalLayoutSnapshot {
return {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
},
activeLeafId: LEAF_ID,
expandedLeafId: null
}
}
function StatusProbe({ worktreeId }: { worktreeId: string }) {
return <span>{useWorktreeActivityStatus(worktreeId)}</span>
}
@ -62,6 +77,7 @@ describe('useWorktreeActivityStatus', () => {
tabsByWorktree: {},
browserTabsByWorktree: {},
runtimePaneTitlesByTabId: {},
terminalLayoutsByTabId: {},
ptyIdsByTabId: {},
agentStatusEpoch: 0,
agentStatusByPaneKey: {},
@ -95,6 +111,35 @@ describe('useWorktreeActivityStatus', () => {
)
})
it('lets a fresh hook done state override the same pane stale working title', () => {
const worktreeId = 'repo1::/path/wt1'
const paneKey = makePaneKey('tab-1', LEAF_ID)
mockState = {
...mockState,
tabsByWorktree: {
[worktreeId]: [makeTab('tab-1', worktreeId)]
},
ptyIdsByTabId: {
'tab-1': ['pty-1']
},
runtimePaneTitlesByTabId: {
'tab-1': {
1: '⠋ Codex',
2: 'bash'
}
},
terminalLayoutsByTabId: {
'tab-1': makeSplitLayout()
},
agentStatusEpoch: 1,
agentStatusByPaneKey: {
[paneKey]: makeAgentStatusEntry({ paneKey, state: 'done' })
}
}
expect(renderToStaticMarkup(<StatusProbe worktreeId={worktreeId} />)).toBe('<span>done</span>')
})
it('scopes cached agent summaries to the matching worktree', () => {
const firstWorktreeId = 'repo1::/path/wt1'
const secondWorktreeId = 'repo1::/path/wt2'

View File

@ -7,6 +7,7 @@ import {
selectLivePtyIdsForWorktree,
selectRuntimePaneTitlesForWorktree
} from './worktree-card-status-inputs'
import { selectTerminalLayoutsForWorktree } from './worktree-agent-row-selectors'
import { selectWorktreeAgentActivitySummary } from './worktree-agent-activity-summary'
export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
@ -18,9 +19,11 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
const ptyIdsForWorktree = useAppStore(
useShallow((s) => selectLivePtyIdsForWorktree(s, worktreeId))
)
const { hasPermission, hasLiveWorking, hasLiveDone, hasRetainedDone } = useAppStore(
useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId))
const terminalLayoutsByTabId = useAppStore(
useShallow((s) => selectTerminalLayoutsForWorktree(s, worktreeId))
)
const { hasPermission, hasLiveWorking, hasLiveDone, hasRetainedDone, freshHookLeafIdsByTabId } =
useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId)))
// Why: compact and detailed cards need the same status-dot semantics:
// runtime liveness gates title-derived states, then explicit agent rows can
@ -32,6 +35,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
browserTabs,
ptyIdsByTabId: ptyIdsForWorktree,
runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree,
freshHookLeafIdsByTabId,
terminalLayoutsByTabId,
hasPermission,
hasLiveWorking,
hasLiveDone,
@ -42,6 +47,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
browserTabs,
ptyIdsForWorktree,
runtimePaneTitlesForWorktree,
freshHookLeafIdsByTabId,
terminalLayoutsByTabId,
hasPermission,
hasLiveWorking,
hasLiveDone,

View File

@ -12,13 +12,17 @@ export type WorktreeAgentActivitySummary = {
hasLiveWorking: boolean
hasLiveDone: boolean
hasRetainedDone: boolean
freshHookLeafIdsByTabId: Record<string, ReadonlySet<string>>
}
const EMPTY_HOOK_LEAF_IDS_BY_TAB_ID: Record<string, ReadonlySet<string>> = {}
const EMPTY_SUMMARY: WorktreeAgentActivitySummary = {
hasPermission: false,
hasLiveWorking: false,
hasLiveDone: false,
hasRetainedDone: false
hasRetainedDone: false,
freshHookLeafIdsByTabId: EMPTY_HOOK_LEAF_IDS_BY_TAB_ID
}
export type AgentActivityInput = Pick<
@ -82,11 +86,18 @@ function getWorktreeAgentActivitySummaries(
const now = Date.now()
for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
const worktreeId = worktreeIdForPaneKey(paneKey, tabIdToWorktreeId)
const parsed = parsePaneKey(paneKey)
const worktreeId = parsed
? (tabIdToWorktreeId.get(parsed.tabId) ?? null)
: worktreeIdForLegacyPaneKey(paneKey, tabIdToWorktreeId)
if (!worktreeId || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
continue
}
applyLiveAgentState(summaryForWorktree(worktreeId), entry)
const summary = summaryForWorktree(worktreeId)
if (parsed) {
addFreshHookLeafId(summary, parsed.tabId, parsed.leafId)
}
applyLiveAgentState(summary, entry)
}
for (const unsupported of Object.values(state.migrationUnsupportedByPtyId ?? {})) {
@ -124,9 +135,35 @@ function applyLiveAgentState(
}
}
function addFreshHookLeafId(
summary: WorktreeAgentActivitySummary,
tabId: string,
leafId: string
): void {
if (summary.freshHookLeafIdsByTabId === EMPTY_HOOK_LEAF_IDS_BY_TAB_ID) {
summary.freshHookLeafIdsByTabId = {}
}
let leafIds = summary.freshHookLeafIdsByTabId[tabId] as Set<string> | undefined
if (!leafIds) {
leafIds = new Set<string>()
summary.freshHookLeafIdsByTabId[tabId] = leafIds
}
leafIds.add(leafId)
}
function worktreeIdForPaneKey(
paneKey: string,
tabIdToWorktreeId: Map<string, string>
): string | null {
const parsed = parsePaneKey(paneKey)
return parsed
? (tabIdToWorktreeId.get(parsed.tabId) ?? null)
: worktreeIdForLegacyPaneKey(paneKey, tabIdToWorktreeId)
}
function worktreeIdForLegacyPaneKey(
paneKey: string,
tabIdToWorktreeId: Map<string, string>
): string | null {
const tabId = getPaneKeyTabId(paneKey)
return tabId ? (tabIdToWorktreeId.get(tabId) ?? null) : null

View File

@ -83,6 +83,7 @@ function makeState(
browserTabsByWorktree: {},
ptyIdsByTabId: {},
runtimePaneTitlesByTabId: {},
terminalLayoutsByTabId: {},
agentStatusEpoch: 0,
agentStatusByPaneKey: {},
migrationUnsupportedByPtyId: {},

View File

@ -15,6 +15,7 @@ import {
selectLivePtyIdsForWorktree,
selectRuntimePaneTitlesForWorktree
} from './worktree-card-status-inputs'
import { selectTerminalLayoutsForWorktree } from './worktree-agent-row-selectors'
import { selectWorktreeAgentActivitySummary } from './worktree-agent-activity-summary'
export type WorktreeSectionActivityState = Pick<
@ -23,6 +24,7 @@ export type WorktreeSectionActivityState = Pick<
| 'browserTabsByWorktree'
| 'ptyIdsByTabId'
| 'runtimePaneTitlesByTabId'
| 'terminalLayoutsByTabId'
| 'agentStatusEpoch'
| 'agentStatusByPaneKey'
| 'migrationUnsupportedByPtyId'
@ -100,6 +102,8 @@ function getSectionWorktreeStatus(
browserTabs: state.browserTabsByWorktree[worktreeId] ?? [],
ptyIdsByTabId: selectLivePtyIdsForWorktree(state, worktreeId),
runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(state, worktreeId),
freshHookLeafIdsByTabId: agentSummary.freshHookLeafIdsByTabId,
terminalLayoutsByTabId: selectTerminalLayoutsForWorktree(state, worktreeId),
hasPermission: agentSummary.hasPermission,
hasLiveWorking: agentSummary.hasLiveWorking,
hasLiveDone: agentSummary.hasLiveDone,

View File

@ -1,12 +1,12 @@
import { describe, expect, it } from 'vitest'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
import type { TerminalLayoutSnapshot, TerminalTab, TuiAgent } from '../../../../shared/types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { buildWorktreeAgentRows } from './worktree-agent-rows'
const LEAF_ID_1 = '77777777-7777-4777-8777-777777777777'
const LEAF_ID_2 = '88888888-8888-4888-8888-888888888888'
function makeTab(id: string): TerminalTab {
function makeTab(id: string, overrides: Partial<TerminalTab> = {}): TerminalTab {
return {
id,
worktreeId: 'wt-1',
@ -15,7 +15,8 @@ function makeTab(id: string): TerminalTab {
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
createdAt: 0,
...overrides
}
}
@ -74,4 +75,37 @@ describe('buildTitleDerivedAgentRows', () => {
expect(rows).toHaveLength(0)
})
it('does not infer Claude Code from a spinner-only non-agent title', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1')],
entries: [],
retained: [],
runtimePaneTitlesByTabId: {
'tab-1': { 1: '⠋ installing dependencies' }
},
ptyIdsByTabId: { 'tab-1': ['pty-plain'] },
terminalLayoutsByTabId: { 'tab-1': makeSplitLayout() },
now: 2000
})
expect(rows).toHaveLength(0)
})
it('does not turn generic Codex-launched task titles into Claude Code rows', () => {
const launchAgent: TuiAgent = 'codex'
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent })],
entries: [],
retained: [],
runtimePaneTitlesByTabId: {
'tab-1': { 1: '✳ refactor split-pane status' }
},
ptyIdsByTabId: { 'tab-1': ['pty-codex'] },
terminalLayoutsByTabId: { 'tab-1': makeSplitLayout() },
now: 2000
})
expect(rows).toHaveLength(0)
})
})

View File

@ -33,6 +33,8 @@ const TITLE_AGENT_LABEL_TO_TYPE: Record<string, AgentType> = {
Pi: 'pi'
}
const CLAUDE_AGENT_TOKEN_RE = /(?<![\w./\\-])claude(?![\w./\\-])/i
export function buildTitleDerivedAgentRows(args: {
tabs: TerminalTab[]
runtimePaneTitlesByTabId?: Record<string, Record<number, string>>
@ -108,7 +110,10 @@ function buildTitleDerivedAgentRow(args: {
return null
}
const paneKey = makePaneKey(args.tab.id, args.leafId)
const agentType = TITLE_AGENT_LABEL_TO_TYPE[label] ?? 'unknown'
const agentType = resolveTitleDerivedAgentType(args.title, label)
if (!agentType) {
return null
}
const rowState = titleStatusToRowState(status)
const secondary =
status === 'permission' ? 'Needs input' : status === 'working' ? 'Running' : 'Idle'
@ -134,6 +139,17 @@ function buildTitleDerivedAgentRow(args: {
}
}
function resolveTitleDerivedAgentType(title: string, label: string): AgentType | null {
const agentType = TITLE_AGENT_LABEL_TO_TYPE[label] ?? 'unknown'
if (agentType !== 'claude') {
return agentType
}
// Why: Claude's task-title spinner heuristic has no provider identity. In
// split panes it can match arbitrary terminal spinners, so sidebar rows only
// accept Claude when the title itself names Claude.
return CLAUDE_AGENT_TOKEN_RE.test(title) ? agentType : null
}
function titleStatusToRowState(
status: 'working' | 'permission' | 'idle'
): AgentStatusState | 'idle' {

View File

@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { TerminalLayoutSnapshot } from '../../../shared/types'
import { getWorktreeStatus, getWorktreeStatusLabel, resolveWorktreeStatus } from './worktree-status'
const LEAF_ID_1 = '11111111-1111-4111-8111-111111111111'
const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222'
// Why: build a live-pty map from tab ids so each test can declare which
// tabs are alive without manually tracking parallel `tab.ptyId` values.
// `tab.ptyId` is the wake-hint sessionId preserved across sleep, not a
@ -9,6 +13,19 @@ function livePtyMap(...tabIds: string[]): Record<string, string[]> {
return Object.fromEntries(tabIds.map((id, i) => [id, [`pty-${i}`]]))
}
function splitLayout(): TerminalLayoutSnapshot {
return {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: LEAF_ID_1 },
second: { type: 'leaf', leafId: LEAF_ID_2 }
},
activeLeafId: LEAF_ID_1,
expandedLeafId: null
}
}
describe('getWorktreeStatus', () => {
it('prioritizes permission over other live activity states', () => {
const status = getWorktreeStatus(
@ -206,6 +223,80 @@ describe('resolveWorktreeStatus', () => {
expect(status).toBe('working')
})
it('lets a hook-covered done pane suppress its stale working title', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'claude [working]' }],
browserTabs: [],
ptyIdsByTabId: livePtyMap('tab-1'),
runtimePaneTitlesByTabId: {
'tab-1': {
1: 'codex [working]',
2: 'bash'
}
},
freshHookLeafIdsByTabId: {
'tab-1': new Set([LEAF_ID_1])
},
terminalLayoutsByTabId: {
'tab-1': splitLayout()
},
hasPermission: false,
hasLiveWorking: false,
hasLiveDone: true,
hasRetainedDone: false
})
expect(status).toBe('done')
})
it('lets a single hook-covered done pane suppress an unmapped single working title', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'claude [working]' }],
browserTabs: [],
ptyIdsByTabId: livePtyMap('tab-1'),
runtimePaneTitlesByTabId: {
'tab-1': {
1: 'codex [working]'
}
},
freshHookLeafIdsByTabId: {
'tab-1': new Set([LEAF_ID_1])
},
hasPermission: false,
hasLiveWorking: false,
hasLiveDone: true,
hasRetainedDone: false
})
expect(status).toBe('done')
})
it('keeps sibling pane working when hook done covers only another pane', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'claude [working]' }],
browserTabs: [],
ptyIdsByTabId: livePtyMap('tab-1'),
runtimePaneTitlesByTabId: {
'tab-1': {
1: 'bash',
2: 'codex [working]'
}
},
freshHookLeafIdsByTabId: {
'tab-1': new Set([LEAF_ID_1])
},
terminalLayoutsByTabId: {
'tab-1': splitLayout()
},
hasPermission: false,
hasLiveWorking: false,
hasLiveDone: true,
hasRetainedDone: false
})
expect(status).toBe('working')
})
// Why: title-heuristic permission must beat hasLiveDone/hasRetainedDone —
// the priority "permission > working > done > heuristic" applies to BOTH
// sources of permission (the args.hasPermission overlay AND the heuristic

View File

@ -1,9 +1,15 @@
import { detectAgentStatusFromTitle } from '@/lib/agent-status'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import type { TerminalTab } from '../../../shared/types'
import { resolveRuntimePaneTitleLeafId } from '@/components/sidebar/runtime-pane-title-leaf-id'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/types'
export type WorktreeStatus = 'active' | 'working' | 'permission' | 'done' | 'inactive'
type WorktreeStatusHeuristicOptions = {
freshHookLeafIdsByTabId?: Record<string, ReadonlySet<string>>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
}
const STATUS_LABELS: Record<WorktreeStatus, string> = {
active: 'Active',
working: 'Working',
@ -16,7 +22,8 @@ export function getWorktreeStatus(
tabs: Pick<TerminalTab, 'id' | 'title'>[],
browserTabs: { id: string }[],
ptyIdsByTabId: Record<string, string[]>,
runtimePaneTitlesByTabId: Record<string, Record<number, string>> = {}
runtimePaneTitlesByTabId: Record<string, Record<number, string>> = {},
options: WorktreeStatusHeuristicOptions = {}
): WorktreeStatus {
// Why: liveness gates every promotion. tab.ptyId is the wake-hint sessionId
// preserved across sleep (so wake can reattach to the same daemon history
@ -35,7 +42,7 @@ export function getWorktreeStatus(
// titles first (same pattern as countWorkingAgentsForTab) and only fall back
// to `tab.title` for tabs that have no mounted panes yet.
const hasStatus = (status: 'permission' | 'working'): boolean =>
liveTabs.some((tab) => tabHasStatus(tab, runtimePaneTitlesByTabId, status))
liveTabs.some((tab) => tabHasStatus(tab, runtimePaneTitlesByTabId, status, options))
if (hasStatus('permission')) {
return 'permission'
@ -56,17 +63,36 @@ export function getWorktreeStatus(
function tabHasStatus(
tab: Pick<TerminalTab, 'id' | 'title'>,
runtimePaneTitlesByTabId: Record<string, Record<number, string>>,
status: 'permission' | 'working'
status: 'permission' | 'working',
options: WorktreeStatusHeuristicOptions
): boolean {
const hookLeafIds = options.freshHookLeafIdsByTabId?.[tab.id]
const paneTitles = runtimePaneTitlesByTabId[tab.id]
if (paneTitles && Object.keys(paneTitles).length > 0) {
for (const title of Object.values(paneTitles)) {
const tabLayout = options.terminalLayoutsByTabId?.[tab.id]
const paneTitleEntries = Object.entries(paneTitles)
for (const [runtimePaneId, title] of paneTitleEntries) {
const leafId = resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId)
// Why: runtime titles can arrive before layout hydration in SSH/replay
// paths. With exactly one title and one hook leaf, the tab is
// unambiguous enough to prefer hook authority over a stale spinner.
const hasSingleUnmappedHookLeaf =
leafId === null && hookLeafIds?.size === 1 && paneTitleEntries.length === 1
if ((leafId !== null && hookLeafIds?.has(leafId)) || hasSingleUnmappedHookLeaf) {
continue
}
if (detectAgentStatusFromTitle(title) === status) {
return true
}
}
return false
}
// Why: a tab-level title does not identify which split pane it came from.
// Once any fresh hook owns a leaf in that tab, prefer the hook overlay to
// avoid resurrecting stale "working" titles for a completed pane.
if (hookLeafIds && hookLeafIds.size > 0) {
return false
}
return detectAgentStatusFromTitle(tab.title) === status
}
@ -101,6 +127,8 @@ export function resolveWorktreeStatus(args: {
browserTabs: { id: string }[]
ptyIdsByTabId: Record<string, string[]>
runtimePaneTitlesByTabId?: Record<string, Record<number, string>>
freshHookLeafIdsByTabId?: Record<string, ReadonlySet<string>>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
hasPermission: boolean
hasLiveWorking: boolean
hasLiveDone: boolean
@ -110,7 +138,11 @@ export function resolveWorktreeStatus(args: {
args.tabs,
args.browserTabs,
args.ptyIdsByTabId,
args.runtimePaneTitlesByTabId ?? {}
args.runtimePaneTitlesByTabId ?? {},
{
freshHookLeafIdsByTabId: args.freshHookLeafIdsByTabId,
terminalLayoutsByTabId: args.terminalLayoutsByTabId
}
)
if (args.hasPermission) {
return 'permission'