mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(chat): surface agent monitoring status
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user