Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,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
Expand Down
16 changes: 13 additions & 3 deletions src/renderer/src/lib/ai-vault-tab-title-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading