From 7314ada3fb6d1ff02ccdf8a0572c4daaed78b89f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:31:23 -0700 Subject: [PATCH] fix(native-chat): ignore stale restored working status (#17995) * fix(native-chat): ignore stale restored working status * fix(native-chat): sample status freshness per epoch * Revert "fix(native-chat): sample status freshness per epoch" This reverts commit 8e49b1badd27253c56e8eb6b0de05559a761d2bb. * test(native-chat): include hook status timestamps * test(native-chat): include visibility hook timestamp --------- Co-authored-by: Merge Sim --- .../use-native-chat-hook-status.test.ts | 62 +++++++++++++++++++ .../use-native-chat-hook-status.ts | 31 ++++++++-- ...ative-chat-live-session-visibility.test.ts | 2 +- .../use-native-chat-live-session.test.ts | 39 +++++++++--- 4 files changed, 120 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts diff --git a/src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts b/src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts new file mode 100644 index 00000000000..90d31d09e3d --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { resolveNativeChatHookState } from './use-native-chat-hook-status' + +describe('resolveNativeChatHookState', () => { + const now = 1_000_000 + + it('does not treat a restored working row as live activity', () => { + expect( + resolveNativeChatHookState( + { + state: 'working', + workingMode: undefined, + updatedAt: now, + restoredUnconfirmed: true + }, + now + ) + ).toBeNull() + }) + + it('keeps confirmed working activity live', () => { + expect( + resolveNativeChatHookState( + { + state: 'working', + workingMode: undefined, + updatedAt: now, + restoredUnconfirmed: false + }, + now + ) + ).toBe('working') + }) + + it('continues to suppress monitoring rows', () => { + expect( + resolveNativeChatHookState( + { + state: 'working', + workingMode: 'monitoring', + updatedAt: now, + restoredUnconfirmed: false + }, + now + ) + ).toBeNull() + }) + + it('does not keep an expired working row live', () => { + expect( + resolveNativeChatHookState( + { + state: 'working', + workingMode: undefined, + updatedAt: now - 30 * 60 * 1000 - 1, + restoredUnconfirmed: false + }, + now + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts index 687dbc83ad2..5cfa0f11679 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts @@ -1,16 +1,39 @@ import { useAppStore } from '../../store' -import type { AgentStatusState } from '../../../../shared/agent-status-types' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentStatusState +} from '../../../../shared/agent-status-types' + +/** + * Hydrated nonterminal rows are only recovery evidence until a live hook event + * confirms the turn. They must not make Native Chat look permanently busy. + */ +export function resolveNativeChatHookState( + entry: + | Pick + | undefined, + now = Date.now() +): AgentStatusState | null { + if (!entry || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + return null + } + return entry.state === 'working' && entry.workingMode === 'monitoring' ? null : entry.state +} export function useNativeChatHookStatus( paneKey: string ): readonly [AgentStatusState | null, number | null, boolean] { + // Freshness is time-based; subscribe to the scheduler epoch so a silent + // working row stops driving Native Chat when its TTL expires. + const agentStatusEpoch = useAppStore((store) => store.agentStatusEpoch) + void agentStatusEpoch // Why: primitive selectors keep unrelated pane/status updates from rerendering // native chat while still exposing the three fields used for reconciliation. const state = useAppStore((store) => { const entry = store.agentStatusByPaneKey[paneKey] - return entry?.state === 'working' && entry.workingMode === 'monitoring' - ? null - : (entry?.state ?? null) + return resolveNativeChatHookState(entry) }) const stateStartedAt = useAppStore( (store) => store.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts index a4855b9600b..80002ac263d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts @@ -122,7 +122,7 @@ describe('useNativeChatLiveSession visibility', () => { it('unsubscribes on hide, retains committed messages, and rejects hidden work', async () => { useAppStore.setState({ agentStatusByPaneKey: { - [BASE_ARGS.paneKey]: { state: 'working', stateStartedAt: 100 } + [BASE_ARGS.paneKey]: { state: 'working', stateStartedAt: 100, updatedAt: Date.now() } } } as never) await render(BASE_ARGS) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index 3bb2e2785e4..d08eb4bd6d6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -385,7 +385,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it("self-heals a stale 'working' hook once the turn-complete marker lands", async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -403,7 +405,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('stops foreground working UI when Claude enters monitoring', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -419,7 +423,12 @@ describe('useNativeChatLiveSession — transport routing', () => { await act(async () => { useAppStore.setState({ agentStatusByPaneKey: { - [PANE]: { state: 'working', workingMode: 'monitoring', stateStartedAt: 1 } as never + [PANE]: { + state: 'working', + workingMode: 'monitoring', + stateStartedAt: 1, + updatedAt: Date.now() + } as never } }) }) @@ -429,7 +438,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('applies a lifecycle-only append after the final message frame', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -456,7 +467,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('applies a terminal-side interruption frame without a local Stop action', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -483,7 +496,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('does not let an older pagination read rewind a live completion', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, index) => @@ -529,7 +544,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('reconciles completion from a reconnect snapshot', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 10 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 10, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -557,7 +574,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('reconciles interruption from a reconnect snapshot', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 10 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 10, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -708,7 +727,9 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } + } } as never) await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })