fix(tab-bar): offer Resume in New Native Chat only while the tab's agent is running (#27520)

The terminal tab menu found its conversation through the tab-title lookup, which also
falls back to retained and sleeping records an exited agent leaves behind. Pass a
running-agents-only option for the move; tab titles keep the remembered conversation.
This commit is contained in:
Brennan Benson
2026-10-12 00:59:13 -07:00
committed by GitHub
parent 627716fdc1
commit 5428bd97b9
3 changed files with 114 additions and 5 deletions
@@ -3,6 +3,7 @@ import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { makeRepo, makeTerminalTab, makeWorktree } from '../worktree-jump-palette-test-fixtures'
import { makeAgentStatusEntry } from '@/runtime/sync-runtime-graph-test-harness'
import { collectAiVaultTitleRequests } from '@/lib/ai-vault-tab-title-requests'
import type { AiVaultListArgs, AiVaultSession } from '../../../../shared/ai-vault-types'
import {
claimAiVaultForcedRescan,
@@ -152,6 +153,103 @@ describe('resolveTabSessionHistorySubject', () => {
).toEqual(cliSubject)
})
it('finds an agent a restart reattached to before it reports again', () => {
const [[paneKey, entry]] = Object.entries(liveClaudeEntry)
expect(
resolveTabSessionHistorySubject(
makeState({ [paneKey]: { ...entry, restoredUnconfirmed: true } }),
{ tab: { id: 'term-1', worktreeId: WORKTREE_ID } }
)
).toEqual(cliSubject)
})
// What Orca remembers once the agent exits and the pane is back at its shell prompt.
const exitedAgentRecords: Record<string, Partial<AppState>> = {
retained: {
retainedAgentsByPaneKey: {
'term-1:leaf-1': {
entry: liveClaudeEntry['term-1:leaf-1'],
worktreeId: WORKTREE_ID,
tab: makeTerminalTab('term-1', WORKTREE_ID, 'claude'),
agentType: 'claude',
startedAt: 1
}
}
},
sleeping: {
sleepingAgentSessionsByPaneKey: {
'term-1:leaf-1': {
paneKey: 'term-1:leaf-1',
tabId: 'term-1',
worktreeId: WORKTREE_ID,
agent: 'claude',
providerSession: { key: 'session_id', id: 'claude-session-1' },
prompt: '',
state: 'working',
capturedAt: 1,
updatedAt: 1,
origin: 'live'
}
}
}
}
it.each(Object.keys(exitedAgentRecords))(
'offers nothing from a %s record once the agent has exited, though the tab title keeps it',
(kind) => {
makeState()
useAppStore.setState(exitedAgentRecords[kind])
const state = useAppStore.getState()
expect(
resolveTabSessionHistorySubject(state, { tab: { id: 'term-1', worktreeId: WORKTREE_ID } })
).toBeNull()
expect(collectAiVaultTitleRequests(state)).toEqual([
expect.objectContaining({
tabId: 'term-1',
providerSession: { key: 'session_id', id: 'claude-session-1' }
})
])
}
)
it("prefers a split pane's running agent over the focused pane's exited one", () => {
makeState({
'term-1:leaf-2': makeAgentStatusEntry({
agentType: 'claude',
paneKey: 'term-1:leaf-2',
tabId: 'term-1',
worktreeId: WORKTREE_ID,
providerSession: { key: 'session_id', id: 'claude-session-1' }
})
})
const exited = makeAgentStatusEntry({
agentType: 'claude',
paneKey: 'term-1:leaf-1',
tabId: 'term-1',
worktreeId: WORKTREE_ID,
providerSession: { key: 'session_id', id: 'claude-session-old' }
})
useAppStore.setState({
terminalLayoutsByTabId: {
'term-1': { root: null, activeLeafId: 'leaf-1', expandedLeafId: null }
},
retainedAgentsByPaneKey: {
'term-1:leaf-1': {
entry: exited,
worktreeId: WORKTREE_ID,
tab: makeTerminalTab('term-1', WORKTREE_ID, 'claude'),
agentType: 'claude',
startedAt: exited.stateStartedAt
}
}
})
expect(
resolveTabSessionHistorySubject(useAppStore.getState(), {
tab: { id: 'term-1', worktreeId: WORKTREE_ID }
})
).toEqual(cliSubject)
})
it('looks nothing up over SSH, where neither move is ever offered', () => {
const state = makeState(liveClaudeEntry, { connectionId: 'dev-box' })
expect(
@@ -91,8 +91,9 @@ export function resolveTabSessionHistorySubject(
: null
return target ? { ...target, kind: 'chat', sessionId: args.structuredSessionId } : null
}
// The same pane-to-conversation mapping tab titles use: live agent, then sleeping, then retained.
const titleRequest = collectAiVaultTitleRequests(state).find(
// The pane-to-conversation mapping tab titles use, limited to agents still running: once the agent
// exits, the tab title keeps its conversation but the move is no longer offered.
const titleRequest = collectAiVaultTitleRequests(state, { runningAgentsOnly: true }).find(
(candidate) => candidate.tabId === args.tab.id
)
const target = titleRequest ? workspace(titleRequest.worktreeId, 'cli') : null
@@ -67,15 +67,25 @@ function registerCandidate(
})
}
export function collectAiVaultTitleRequests(state: AppState): AiVaultTitleRequest[] {
export function collectAiVaultTitleRequests(
state: AppState,
options: {
/** Skip retained and sleeping records: those remember a conversation after its agent exited. */
runningAgentsOnly?: boolean
} = {}
): AiVaultTitleRequest[] {
const tabsById = new Map(
Object.values(state.tabsByWorktree)
.flat()
.map((tab) => [tab.id, tab] as const)
)
const candidates = new Map<string, RequestCandidate>()
const retained = options.runningAgentsOnly ? [] : Object.values(state.retainedAgentsByPaneKey)
const sleeping = options.runningAgentsOnly
? []
: Object.values(state.sleepingAgentSessionsByPaneKey)
for (const entry of Object.values(state.retainedAgentsByPaneKey)) {
for (const entry of retained) {
registerCandidate(state, tabsById, candidates, {
agent: entry.agentType,
paneKey: entry.entry.paneKey,
@@ -86,7 +96,7 @@ export function collectAiVaultTitleRequests(state: AppState): AiVaultTitleReques
worktreeId: entry.worktreeId
})
}
for (const record of Object.values(state.sleepingAgentSessionsByPaneKey)) {
for (const record of sleeping) {
registerCandidate(state, tabsById, candidates, {
agent: record.agent,
paneKey: record.paneKey,