mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user