fix: resolve stale terminal pane activity status (#4321)
This commit is contained in:
parent
7be13cfa4a
commit
8c6235cda7
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ function makeState(
|
|||
browserTabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
agentStatusEpoch: 0,
|
||||
agentStatusByPaneKey: {},
|
||||
migrationUnsupportedByPtyId: {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue