diff --git a/src/renderer/src/components/native-chat/NativeChatMonitoringStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatMonitoringStatus.test.tsx new file mode 100644 index 00000000000..5f6c2537d0e --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMonitoringStatus.test.tsx @@ -0,0 +1,25 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { NativeChatMonitoringStatus } from './NativeChatMonitoringStatus' + +describe('NativeChatMonitoringStatus', () => { + afterEach(cleanup) + + it('stays hidden outside monitoring', () => { + render() + + expect(screen.queryByRole('status')).toBeNull() + }) + + it('shows the shared monitoring glyph with visible copy', () => { + render() + + const status = screen.getByRole('status') + expect(status).toHaveTextContent('Monitoring background tasks') + expect(status.querySelector('svg')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMonitoringStatus.tsx b/src/renderer/src/components/native-chat/NativeChatMonitoringStatus.tsx new file mode 100644 index 00000000000..4775417bd77 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMonitoringStatus.tsx @@ -0,0 +1,26 @@ +import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' + +const MONITORING_LABEL = agentStateLabel('monitoring') + +export function NativeChatMonitoringStatus({ + monitoring +}: { + monitoring: boolean +}): React.JSX.Element | null { + if (!monitoring) { + return null + } + + return ( +
+ + {MONITORING_LABEL} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index dc152df4c70..608d8f655e8 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -52,6 +52,8 @@ import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' import type { NativeChatResolvedViewProps } from './native-chat-view-types' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' +import { NativeChatMonitoringStatus } from './NativeChatMonitoringStatus' +import { useNativeChatMonitoringStatus } from './use-native-chat-hook-status' /** Renders the bridge UI after NativeChatSessionGate resolves its agent session. */ export function NativeChatResolvedView({ @@ -136,6 +138,7 @@ export function NativeChatResolvedView({ ...(contextMenuActions ?? emptyNativeChatContextMenuActions) } }) + const monitoring = useNativeChatMonitoringStatus(paneKey) // Optimistic "queued" sends (mobile parity): a composer send is echoed // immediately and pruned once its real user turn lands in the transcript, so @@ -324,6 +327,7 @@ export function NativeChatResolvedView({ ref={rootRef} data-native-chat-root="true" data-native-chat-working={isWorking ? 'true' : 'false'} + data-native-chat-monitoring={monitoring ? 'true' : 'false'} tabIndex={-1} onPointerDownCapture={(event) => { if (event.button === 2) { @@ -358,6 +362,7 @@ export function NativeChatResolvedView({ className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" > +
{viewState.kind === 'loading' ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index f7d2fd62663..bce7ce40093 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -23,6 +23,8 @@ import { translate } from '@/i18n/i18n' import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' import { useNativeChatImageRuntimeContext } from './native-chat-image-runtime-context' +import { NativeChatMonitoringStatus } from './NativeChatMonitoringStatus' +import { useNativeChatMonitoringStatus } from './use-native-chat-hook-status' function encodeQuestionAnswer(questionId: string, answer: string): string { return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` @@ -49,6 +51,7 @@ export function NativeChatStructuredSession(props: { ) const rootRef = useRef(null) const composerRef = useRef(null) + const monitoring = useNativeChatMonitoringStatus(paneKey) useNativeChatPasteBridge({ rootRef, composerRef }) const session = useMemo( () => ({ @@ -126,10 +129,12 @@ export function NativeChatStructuredSession(props: { ref={rootRef} data-native-chat-root="true" data-native-chat-working={controller.isWorking ? 'true' : 'false'} + data-native-chat-monitoring={monitoring ? 'true' : 'false'} tabIndex={-1} className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" > +
{viewState.kind === 'loading' ? ( 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 index 90d31d09e3d..29d723be9e5 100644 --- 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 @@ -1,21 +1,22 @@ import { describe, expect, it } from 'vitest' -import { resolveNativeChatHookState } from './use-native-chat-hook-status' +import { + resolveNativeChatHookState, + resolveNativeChatMonitoringStatus +} 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() + const entry = { + state: 'working' as const, + workingMode: undefined, + updatedAt: now, + restoredUnconfirmed: true + } + + expect(resolveNativeChatHookState(entry, now)).toBeNull() + expect(resolveNativeChatMonitoringStatus(entry, now)).toBe(false) }) it('keeps confirmed working activity live', () => { @@ -33,17 +34,15 @@ describe('resolveNativeChatHookState', () => { }) it('continues to suppress monitoring rows', () => { - expect( - resolveNativeChatHookState( - { - state: 'working', - workingMode: 'monitoring', - updatedAt: now, - restoredUnconfirmed: false - }, - now - ) - ).toBeNull() + const entry = { + state: 'working' as const, + workingMode: 'monitoring' as const, + updatedAt: now, + restoredUnconfirmed: false + } + + expect(resolveNativeChatHookState(entry, now)).toBeNull() + expect(resolveNativeChatMonitoringStatus(entry, now)).toBe(true) }) it('does not keep an expired working row live', () => { @@ -59,4 +58,18 @@ describe('resolveNativeChatHookState', () => { ) ).toBeNull() }) + + it('does not show expired monitoring activity', () => { + expect( + resolveNativeChatMonitoringStatus( + { + state: 'working', + workingMode: 'monitoring', + updatedAt: now - 30 * 60 * 1000 - 1, + restoredUnconfirmed: false + }, + now + ) + ).toBe(false) + }) }) 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 5cfa0f11679..e2a7d87749a 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 @@ -16,10 +16,40 @@ export function resolveNativeChatHookState( | undefined, now = Date.now() ): AgentStatusState | null { + return resolveNativeChatHookStatus(entry, now).state +} + +export function resolveNativeChatMonitoringStatus( + entry: + | Pick + | undefined, + now = Date.now() +): boolean { + return resolveNativeChatHookStatus(entry, now).monitoring +} + +function resolveNativeChatHookStatus( + entry: + | Pick + | undefined, + now: number +): { state: AgentStatusState | null; monitoring: boolean } { if (!entry || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { - return null + return { state: null, monitoring: false } } - return entry.state === 'working' && entry.workingMode === 'monitoring' ? null : entry.state + if (entry.state === 'working' && entry.workingMode === 'monitoring') { + return { state: null, monitoring: true } + } + return { state: entry.state, monitoring: false } +} + +export function useNativeChatMonitoringStatus(paneKey: string): boolean { + // Why: monitoring stays out of foreground lifecycle while remaining visible from the tab glyph's pane-status source. + const agentStatusEpoch = useAppStore((store) => store.agentStatusEpoch) + void agentStatusEpoch + return useAppStore((store) => + resolveNativeChatMonitoringStatus(store.agentStatusByPaneKey[paneKey]) + ) } export function useNativeChatHookStatus(