From 58c1bb6be8457cb347ede804d484bb4c4dede1f5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 19 May 2026 23:39:58 -0700 Subject: [PATCH] fix: notify inactive hook completions (#2408) --- ...gent-hook-completion-notifications.test.ts | 44 ++++++++++++++++++- .../agent-hook-completion-notifications.ts | 14 +++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 3d517ad90..f0993fc00 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -11,6 +11,15 @@ type MockStoreState = { } } ptyIdsByTabId: Record + terminalLayoutsByTabId: Record< + string, + { + root: { type: 'leaf'; leafId: string } + activeLeafId: string + expandedLeafId: string | null + ptyIdsByLeafId?: Record + } + > } let mockStoreState: MockStoreState @@ -49,7 +58,8 @@ describe('agent hook completion notifications', () => { }, ptyIdsByTabId: { 'tab-1': ['pty-1'] - } + }, + terminalLayoutsByTabId: {} } }) @@ -90,4 +100,36 @@ describe('agent hook completion notifications', () => { }) ) }) + + it('uses tab-level PTY liveness when an inactive pane leaf binding is temporarily missing', async () => { + mockStoreState.terminalLayoutsByTabId = { + 'tab-1': { + root: { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' }, + activeLeafId: '11111111-1111-4111-8111-111111111111', + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + const { observeAgentHookCompletionForNotification } = + await import('./agent-hook-completion-notifications') + + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('working') + }) + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('done') + }) + + expect(dispatchTerminalNotification).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ + source: 'agent-task-complete', + paneKey + }) + ) + }) }) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index ce85ead16..0973efa12 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -5,6 +5,7 @@ import { createAgentCompletionCoordinator } from '@/components/terminal-pane/age import type { AgentCompletionCoordinator } from '@/components/terminal-pane/agent-completion-coordinator-types' import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' import { dispatchTerminalNotification } from '@/components/terminal-pane/use-notification-dispatch' +import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' type CoordinatorEntry = { worktreeId: string @@ -49,10 +50,19 @@ function getPtyIdForPaneKey(paneKey: string): string | null { // 'done' hook event fire a spurious notification. Resolve liveness through // the leaf-keyed binding maintained by syncPanePtyLayoutBinding, which // deletes the entry when the leaf closes. - const ptyIdsByLeafId = state.terminalLayoutsByTabId?.[parsed.tabId]?.ptyIdsByLeafId + const layout = state.terminalLayoutsByTabId?.[parsed.tabId] + const ptyIdsByLeafId = layout?.ptyIdsByLeafId if (ptyIdsByLeafId) { const leafPtyId = ptyIdsByLeafId[parsed.leafId] - return leafPtyId && tabPtyIds.includes(leafPtyId) ? leafPtyId : null + if (leafPtyId && tabPtyIds.includes(leafPtyId)) { + return leafPtyId + } + // Why: switching worktrees can unmount the terminal pane and clear the + // leaf binding before the hook completion arrives, while the tab PTY is + // still live. Keep closed leaves suppressed by requiring the leaf in layout. + return collectLeafIdsInOrder(layout.root).includes(parsed.leafId) + ? (tabPtyIds[0] ?? null) + : null } return tabPtyIds[0] ?? null }