From 5904b204b4fdff25baeb6d0749a46f6e989529d1 Mon Sep 17 00:00:00 2001 From: LANG LIU <157685278+xiayuqin1@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:08:37 +0800 Subject: [PATCH] fix(sidebar): open retained agent rows whose exact pane still has a live PTY (#20804) Retained sidebar rows were unconditionally inert, so an idle agent whose live status was dropped could not be navigated to even though its terminal was still running. Gate activation on the runtime PTY map for the exact stable leaf (stablePaneHasLivePty) so sleeping panes, whose layout bindings survive sleep, stay inert and are never resumed implicitly. Fixes #20803 Co-authored-by: Neil --- .../WorktreeCardAgents.activation.test.tsx | 56 ++++++++++++++++++- .../sidebar/WorktreeCardAgents.test.tsx | 13 ----- .../components/sidebar/WorktreeCardAgents.tsx | 24 +++++++- 3 files changed, 75 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx index 4cf81f263fe..422a7089432 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx @@ -51,6 +51,8 @@ function mockAgent({ let mockAgents: DashboardAgentRowData[] = [] let mockAgentActivityDisplayMode: 'compact' | 'full' | undefined let mockTabsByWorktree: Record = {} +let mockTerminalLayoutsByTabId: Record = {} +let mockPtyIdsByTabId: Record = {} let mockStructuredTabIds = new Set() let mockAgentStatusByPaneKey: Record = {} let mockActiveTabId: string | null = null @@ -83,8 +85,8 @@ function buildMockStoreState(): Record { setActiveTab: mockSetActiveTab, setActiveTabType: mockSetActiveTabType, tabsByWorktree: mockTabsByWorktree, - terminalLayoutsByTabId: {}, - ptyIdsByTabId: {}, + terminalLayoutsByTabId: mockTerminalLayoutsByTabId, + ptyIdsByTabId: mockPtyIdsByTabId, runtimePaneTitlesByTabId: {}, sendPromptToSidebarAgentTarget: vi.fn(), settings: { @@ -173,6 +175,8 @@ describe('WorktreeCardAgents activation', () => { mockAgents = [] mockAgentActivityDisplayMode = undefined mockTabsByWorktree = {} + mockTerminalLayoutsByTabId = {} + mockPtyIdsByTabId = {} mockStructuredTabIds = new Set() mockAgentStatusByPaneKey = {} mockActiveTabId = null @@ -183,6 +187,54 @@ describe('WorktreeCardAgents activation', () => { ) }) + it.each([ + ['opens a retained row whose exact pane still has a live PTY', ['pty-a'], true], + ['keeps a retained row inert when its pane PTY is no longer live', [], false] + ])('%s (#20803)', async (_name, livePtyIds, shouldActivate) => { + mockAgentActivityDisplayMode = 'full' + const tabId = 'retained-tab' + const paneKey = makePaneKey(tabId, LEAF_A) + mockAgents = [ + { + ...mockAgent({ paneKey, tabId, agentType: 'codex', prompt: 'Idle', worktreeId: 'wt-1' }), + rowSource: 'retained', + state: 'done' + } + ] + mockTabsByWorktree = { 'wt-1': [{ id: tabId }] } + mockTerminalLayoutsByTabId = { + [tabId]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B } + }, + activeLeafId: LEAF_B, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } + } + } + mockPtyIdsByTabId = { [tabId]: [...livePtyIds, 'pty-b'] } + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + + renderToStaticMarkup() + capturedRowActivations[0].onActivate(tabId, paneKey) + + if (shouldActivate) { + expect(activationMocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1') + expect(activationMocks.activateTabAndFocusPane).toHaveBeenCalledWith( + tabId, + LEAF_A, + expect.anything() + ) + } else { + expect(activationMocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(activationMocks.activateTabAndFocusPane).not.toHaveBeenCalled() + } + expect(staleAgentRowMocks.dismissStaleAgentRowByKey).not.toHaveBeenCalled() + }) + it('activates a projected structured session row through the unified tab path', async () => { mockAgentActivityDisplayMode = 'full' const tabId = 'structured-tab' diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index 058ecf07d15..255317e16bd 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -396,19 +396,6 @@ describe('WorktreeCardAgents', () => { expect(markup).toContain('data-pane-key="tab-1:2"') }) - it('keeps retained completion rows passive when activated', async () => { - mockAgentActivityDisplayMode = 'full' - mockAgents = [mockAgent({ rowSource: 'retained', state: 'done' })] - const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') - - renderToStaticMarkup() - expect(capturedRowActivations).toHaveLength(1) - capturedRowActivations[0].onActivate('tab-1', 'tab-1:1') - - expect(activationMocks.activateAndRevealWorktree).not.toHaveBeenCalled() - expect(activationMocks.activateTabAndFocusPane).not.toHaveBeenCalled() - }) - it('shows orchestration child agent rows under their parent by default', async () => { mockAgentActivityDisplayMode = 'full' mockAgents = [ diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 696ad0928ee..d10162348cc 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -29,6 +29,7 @@ import { useWorktreeAgentExpansionState } from './worktree-card-agents-expansion import { translate } from '@/i18n/i18n' import { activateStructuredAgentSessionTab } from '@/lib/agent-session/structured-agent-session-tab-activation' import { selectAgentRowsUnread } from './worktree-card-agent-ack-inputs' +import { stablePaneHasLivePty } from '@/lib/sleeping-agent-pane-ownership' export const SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT = 'orca-suppress-worktree-list-scroll-adjustment' @@ -185,9 +186,26 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ }, [worktreeId] ) - const handleActivateRetainedAgent = useCallback(() => { - // Retained rows are completion evidence; activation would resume sleeping sessions, so keep them inert. - }, []) + const handleActivateRetainedAgent = useCallback( + (tabId: string, paneKey: string) => { + const parsed = parsePaneKey(paneKey) + const state = useAppStore.getState() + // Retained rows are completion evidence; only open a still-live pane so a sleeping target pane is never resumed. + if ( + parsed?.tabId === tabId && + state.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) && + stablePaneHasLivePty( + tabId, + parsed.leafId, + state.ptyIdsByTabId, + state.terminalLayoutsByTabId[tabId] + ) + ) { + handleActivateAgentTab(tabId, paneKey) + } + }, + [handleActivateAgentTab, worktreeId] + ) const now = useNow(30_000) const { rootRows: rootAgents, childrenByParentPaneKey } = useMemo(