diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.focus-group.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.focus-group.test.ts new file mode 100644 index 00000000000..b6891321a17 --- /dev/null +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.focus-group.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentAutoAckPresence from './agent-auto-ack-presence' +import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' +import { useAppStore } from '../store' +import { makeTab, makeTabGroup, makeUnifiedTab } from '../store/slices/store-test-helpers' +import { structuredAgentSessionPaneKey } from '../../../shared/structured-agent-session-projection' + +vi.mock('./agent-auto-ack-presence', async (importOriginal) => ({ + ...(await importOriginal()), + createAutoAckPresenceCheck: (_read: unknown, onPresent: () => void) => ({ + request: onPresent, + dispose() {} + }) +})) + +const WORKSPACE = 'focus-group-workspace' +const TERMINAL_GROUP = 'terminal-group' +const CHAT_GROUP = 'chat-group' +const TERMINAL_TAB = 'terminal-tab' +const CHAT_TAB = 'chat-tab' +const CHAT_SESSION = 'chat-session' +const CHAT_SUBJECT = structuredAgentSessionPaneKey(CHAT_TAB, CHAT_SESSION) + +describe('useAutoAckViewedAgent focus-group transitions', () => { + beforeEach(() => { + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + useAppStore.setState({ + activeView: 'terminal', + activeWorktreeId: WORKSPACE, + activeTabId: TERMINAL_TAB, + activeTabIdByWorktree: { [WORKSPACE]: TERMINAL_TAB }, + activeGroupIdByWorktree: { [WORKSPACE]: TERMINAL_GROUP }, + activeTabType: 'terminal', + tabsByWorktree: { [WORKSPACE]: [makeTab({ id: TERMINAL_TAB, worktreeId: WORKSPACE })] }, + unifiedTabsByWorktree: { + [WORKSPACE]: [ + makeUnifiedTab({ + id: TERMINAL_TAB, + worktreeId: WORKSPACE, + groupId: TERMINAL_GROUP + }), + makeUnifiedTab({ + id: CHAT_TAB, + worktreeId: WORKSPACE, + groupId: CHAT_GROUP, + contentType: 'agent-session', + entityId: CHAT_SESSION, + agentSessionAgent: 'claude' + }) + ] + }, + groupsByWorktree: { + [WORKSPACE]: [ + makeTabGroup({ + id: TERMINAL_GROUP, + worktreeId: WORKSPACE, + activeTabId: TERMINAL_TAB, + tabOrder: [TERMINAL_TAB] + }), + makeTabGroup({ + id: CHAT_GROUP, + worktreeId: WORKSPACE, + activeTabId: CHAT_TAB, + tabOrder: [CHAT_TAB] + }) + ] + }, + unreadAgentCompletionPanes: { [CHAT_SUBJECT]: 'agent-completion' }, + unreadTerminalTabs: {}, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + acknowledgedAgentsByPaneKey: {} + }) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('rescans when focusing a group whose structured tab becomes visible', () => { + renderHook(() => useAutoAckViewedAgent(false)) + expect(useAppStore.getState().unreadAgentCompletionPanes[CHAT_SUBJECT]).toBe('agent-completion') + + act(() => { + useAppStore.getState().focusGroup(WORKSPACE, CHAT_GROUP) + }) + + expect(useAppStore.getState().activeGroupIdByWorktree[WORKSPACE]).toBe(CHAT_GROUP) + expect(useAppStore.getState().unreadAgentCompletionPanes[CHAT_SUBJECT]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.ts index d2b090dc9ff..0e38b0c96c2 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.ts @@ -32,14 +32,16 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { // Init to undefined so the first maybeAck() (on mount) always passes the ref guard and scans. let lastActiveView: unknown = undefined let lastActiveTabId: unknown = undefined + let lastActiveWorktreeId: unknown = undefined + let lastActiveWorkspaceGroupId: unknown = undefined + let lastActiveWorkspaceGroups: unknown = undefined let lastFloatingWorkspaceActiveTabId: unknown = undefined + let lastFloatingWorkspaceGroupId: unknown = undefined + let lastFloatingWorkspaceGroups: unknown = undefined let lastAgentStatus: unknown = undefined let lastRetained: unknown = undefined let lastAcknowledged: unknown = undefined let lastLayouts: unknown = undefined - // Why: activating a structured chat only moves its layout group's activeTabId — no terminal - // slice changes, so without this the scan that clears its attention would be skipped. - let lastGroups: unknown = undefined let lastUnreadAgentCompletionPanes: unknown = undefined // `force` re-scans after a signal the store never sees: panel open/closed is React-local state. @@ -49,18 +51,32 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { ) const maybeAck = (options?: { force?: boolean; presenceConfirmed?: boolean }): void => { const s = useAppStore.getState() + const activeWorktreeId = s.activeWorktreeId + const activeWorkspaceGroupId = activeWorktreeId + ? (s.activeGroupIdByWorktree[activeWorktreeId] ?? null) + : null + const activeWorkspaceGroups = activeWorktreeId + ? s.groupsByWorktree[activeWorktreeId] + : undefined const floatingWorkspaceActiveTabId = s.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null + const floatingWorkspaceGroupId = + s.activeGroupIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null + const floatingWorkspaceGroups = s.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] if ( !options?.force && s.activeView === lastActiveView && s.activeTabId === lastActiveTabId && + activeWorktreeId === lastActiveWorktreeId && + activeWorkspaceGroupId === lastActiveWorkspaceGroupId && + activeWorkspaceGroups === lastActiveWorkspaceGroups && floatingWorkspaceActiveTabId === lastFloatingWorkspaceActiveTabId && + floatingWorkspaceGroupId === lastFloatingWorkspaceGroupId && + floatingWorkspaceGroups === lastFloatingWorkspaceGroups && s.agentStatusByPaneKey === lastAgentStatus && s.retainedAgentsByPaneKey === lastRetained && s.acknowledgedAgentsByPaneKey === lastAcknowledged && s.terminalLayoutsByTabId === lastLayouts && - s.groupsByWorktree === lastGroups && s.unreadAgentCompletionPanes === lastUnreadAgentCompletionPanes ) { return @@ -69,12 +85,16 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { // Presence signals force a rescan; unrelated writes must not retry an away result. lastActiveView = s.activeView lastActiveTabId = s.activeTabId + lastActiveWorktreeId = activeWorktreeId + lastActiveWorkspaceGroupId = activeWorkspaceGroupId + lastActiveWorkspaceGroups = activeWorkspaceGroups lastFloatingWorkspaceActiveTabId = floatingWorkspaceActiveTabId + lastFloatingWorkspaceGroupId = floatingWorkspaceGroupId + lastFloatingWorkspaceGroups = floatingWorkspaceGroups lastAgentStatus = s.agentStatusByPaneKey lastRetained = s.retainedAgentsByPaneKey lastAcknowledged = s.acknowledgedAgentsByPaneKey lastLayouts = s.terminalLayoutsByTabId - lastGroups = s.groupsByWorktree lastUnreadAgentCompletionPanes = s.unreadAgentCompletionPanes // Why: tab-active only proxies "seen"; gate on window visible+focused so away-time transitions don't silently clear the bold signal.