diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index dd712a134d7..d5127e97ba0 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -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> = { + 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( diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts index ba3dad4c43b..2c792933b64 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts @@ -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 diff --git a/src/renderer/src/lib/ai-vault-tab-title-requests.ts b/src/renderer/src/lib/ai-vault-tab-title-requests.ts index 1efeb48e001..6fd355c0af6 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-requests.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-requests.ts @@ -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() + 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,