fix(attention): rescan when focusing a split group

This commit is contained in:
Brennan Benson
2026-09-18 03:15:58 -04:00
parent 99794fbaa6
commit afcd7cd418
2 changed files with 120 additions and 5 deletions
@@ -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<typeof AgentAutoAckPresence>()),
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()
})
})
@@ -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.