fix(chat): surface agent monitoring status

This commit is contained in:
Merge Sim
2026-09-04 13:45:20 -07:00
parent b0253673c9
commit 80d4bcd4e6
6 changed files with 129 additions and 25 deletions
@@ -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(<NativeChatMonitoringStatus monitoring={false} />)
expect(screen.queryByRole('status')).toBeNull()
})
it('shows the shared monitoring glyph with visible copy', () => {
render(<NativeChatMonitoringStatus monitoring />)
const status = screen.getByRole('status')
expect(status).toHaveTextContent('Monitoring background tasks')
expect(status.querySelector('svg')).toBeInTheDocument()
})
})
@@ -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 (
<div
data-native-chat-monitoring-status="true"
className="mx-auto flex w-full max-w-4xl items-center gap-1.5 px-4 py-1.5 text-xs text-muted-foreground"
role="status"
>
<span aria-hidden="true">
<AgentStateDot state="monitoring" title={null} />
</span>
<span>{MONITORING_LABEL}</span>
</div>
)
}
@@ -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"
>
<NativeChatOrchestrationPausedNotice dispatchStatus={orchestrationDispatchStatus} />
<NativeChatMonitoringStatus monitoring={monitoring} />
<div className="flex min-h-0 flex-1 flex-col">
{viewState.kind === 'loading' ? (
<NativeChatEmptyState kind="loading" />
@@ -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<HTMLDivElement>(null)
const composerRef = useRef<NativeChatComposerHandle>(null)
const monitoring = useNativeChatMonitoringStatus(paneKey)
useNativeChatPasteBridge({ rootRef, composerRef })
const session = useMemo<NativeChatLiveSession>(
() => ({
@@ -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"
>
<NativeChatOrchestrationPausedNotice dispatchStatus={props.orchestrationDispatchStatus} />
<NativeChatMonitoringStatus monitoring={monitoring} />
<div className="flex min-h-0 flex-1 flex-col">
{viewState.kind === 'loading' ? (
<NativeChatEmptyState kind="loading" />
@@ -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)
})
})
@@ -16,10 +16,40 @@ export function resolveNativeChatHookState(
| undefined,
now = Date.now()
): AgentStatusState | null {
return resolveNativeChatHookStatus(entry, now).state
}
export function resolveNativeChatMonitoringStatus(
entry:
| Pick<AgentStatusEntry, 'state' | 'workingMode' | 'updatedAt' | 'restoredUnconfirmed'>
| undefined,
now = Date.now()
): boolean {
return resolveNativeChatHookStatus(entry, now).monitoring
}
function resolveNativeChatHookStatus(
entry:
| Pick<AgentStatusEntry, 'state' | 'workingMode' | 'updatedAt' | 'restoredUnconfirmed'>
| 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(