mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
feat(agents): render Claude monitoring as a static turquoise dot
Replaces the yellow Radio glyph from #14205 with a static dot in a new --agent-monitoring token (#8abeb7), defined once for light and once for dark like --workspace-status-done, so the status keeps its identity when the theme flips. Deliberately a fixed UI value: it never reads terminal theme state at runtime. Adds the turn-boundary notification pins. The monitoring predicate and the turnCompletedAt stamp are computed from the same "lead said done but the pane resolves to working" expression, so a rename can silently drop the stamp and kill a completion notification that works today with nothing else going red.
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Radio } from 'lucide-react-native'
|
||||
import { Animated, Easing, StyleSheet, View } from 'react-native'
|
||||
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
|
||||
|
||||
@@ -18,6 +17,7 @@ const STATUS_COLORS: Record<WorktreeStatus, string> = {
|
||||
permission: '#ef4444',
|
||||
inactive: 'rgba(115,115,115,0.4)'
|
||||
}
|
||||
const MONITORING_COLOR = '#8abeb7'
|
||||
|
||||
export function AgentSpinner({
|
||||
status,
|
||||
@@ -48,9 +48,11 @@ export function AgentSpinner({
|
||||
const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive
|
||||
|
||||
if (monitoring) {
|
||||
// Why: static dot, not a spinner — the lead turn is over. Keep MONITORING_COLOR
|
||||
// in step with --agent-monitoring in the desktop main.css.
|
||||
return (
|
||||
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
|
||||
<Radio size={12} color={STATUS_COLORS.working} />
|
||||
<View style={[styles.dot, { backgroundColor: MONITORING_COLOR }]} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Radio } from 'lucide-react-native'
|
||||
import { Animated, Easing, StyleSheet, View } from 'react-native'
|
||||
import type { AgentDotState } from '../worktree/agent-row-display'
|
||||
|
||||
@@ -8,8 +7,10 @@ import type { AgentDotState } from '../worktree/agent-row-display'
|
||||
// emerald for 'done', red for blocked/waiting/interrupted (attention), neutral
|
||||
// for idle. Distinct from the worktree-level AgentSpinner, which collapses the
|
||||
// agent vocabulary into the 5-state rollup the sidebar dot uses.
|
||||
const DOT_COLORS: Record<Exclude<AgentDotState, 'working' | 'monitoring'>, string> = {
|
||||
const DOT_COLORS: Record<Exclude<AgentDotState, 'working'>, string> = {
|
||||
done: '#10b981',
|
||||
// Keep in step with --agent-monitoring in the desktop main.css.
|
||||
monitoring: '#8abeb7',
|
||||
blocked: '#ef4444',
|
||||
waiting: '#ef4444',
|
||||
interrupted: '#ef4444',
|
||||
@@ -46,16 +47,11 @@ export function AgentStateDot({ state }: { state: AgentDotState }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'monitoring') {
|
||||
return (
|
||||
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
|
||||
<Radio size={10} color={WORKING_COLOR} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View
|
||||
style={styles.wrapper}
|
||||
accessibilityLabel={state === 'monitoring' ? 'Monitoring background tasks' : undefined}
|
||||
>
|
||||
<View style={[styles.dot, { backgroundColor: DOT_COLORS[state] }]} />
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -4,22 +4,38 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentSpinner } from './AgentSpinner'
|
||||
import { AgentStateDot } from './AgentStateDot'
|
||||
|
||||
const DESKTOP_WORKING_COLOR = '#eab308'
|
||||
// Why: pinned to --agent-monitoring in the desktop main.css. Mobile has no CSS
|
||||
// tokens, so this literal is the only thing keeping the two surfaces in step.
|
||||
const DESKTOP_MONITORING_COLOR = '#8abeb7'
|
||||
const DESKTOP_DONE_COLOR = '#10b981'
|
||||
|
||||
type MonitoringTestRenderer = {
|
||||
readonly root: {
|
||||
findByType(type: string): { props: Record<string, unknown> }
|
||||
findAllByType(type: string): { props: Record<string, unknown> }[]
|
||||
}
|
||||
unmount(): void
|
||||
}
|
||||
|
||||
/** Background colour of the inner dot (the wrapper View is index 0). */
|
||||
function dotColor(renderer: MonitoringTestRenderer | null): string | undefined {
|
||||
const inner = renderer?.root.findAllByType('View')[1]
|
||||
const style = inner?.props.style
|
||||
const layers = Array.isArray(style) ? style : [style]
|
||||
for (const layer of layers) {
|
||||
const color = (layer as { backgroundColor?: string } | undefined)?.backgroundColor
|
||||
if (typeof color === 'string') {
|
||||
return color
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { animationLoop, animationTiming, setValue } = vi.hoisted(() => ({
|
||||
animationLoop: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
|
||||
animationTiming: vi.fn(() => ({})),
|
||||
setValue: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({ Radio: 'Radio' }))
|
||||
vi.mock('react-native', () => ({
|
||||
Animated: {
|
||||
Value: function Value() {
|
||||
@@ -48,30 +64,33 @@ describe('mobile monitoring indicators', () => {
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('renders a static Radio for a monitoring agent', async () => {
|
||||
it('renders a static turquoise dot for a monitoring agent', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(AgentStateDot, { state: 'monitoring' }))
|
||||
})
|
||||
|
||||
expect(renderer?.root.findByType('Radio').props).toMatchObject({
|
||||
color: DESKTOP_WORKING_COLOR,
|
||||
size: 10
|
||||
})
|
||||
expect(dotColor(renderer)).toBe(DESKTOP_MONITORING_COLOR)
|
||||
// Why: the lead turn is over — spinning here is the bug this state exists to fix.
|
||||
expect(animationTiming).not.toHaveBeenCalled()
|
||||
expect(animationLoop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a static Radio for an all-monitoring workspace', async () => {
|
||||
it('keeps a monitoring agent dot separable from the done dot', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(AgentStateDot, { state: 'monitoring' }))
|
||||
})
|
||||
|
||||
expect(dotColor(renderer)).not.toBe(DESKTOP_DONE_COLOR)
|
||||
})
|
||||
|
||||
it('renders a static turquoise dot for an all-monitoring workspace', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' })
|
||||
)
|
||||
})
|
||||
|
||||
expect(renderer?.root.findByType('Radio').props).toMatchObject({
|
||||
color: DESKTOP_WORKING_COLOR,
|
||||
size: 12
|
||||
})
|
||||
expect(dotColor(renderer)).toBe(DESKTOP_MONITORING_COLOR)
|
||||
expect(animationTiming).not.toHaveBeenCalled()
|
||||
expect(animationLoop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -27,6 +27,52 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AgentHookServer ingestTerminalStatus', () => {
|
||||
it('keeps hook monitoring mode across an equivalent OSC ping until a hook clears it', () => {
|
||||
const server = new AgentHookServer()
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
source: 'claude',
|
||||
hookEventName: 'Stop',
|
||||
payload: {
|
||||
state: 'working',
|
||||
workingMode: 'monitoring',
|
||||
prompt: 'watch the build',
|
||||
agentType: 'claude'
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
connectionId: 'conn-1',
|
||||
payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' }
|
||||
})
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
workingMode: 'monitoring'
|
||||
})
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
source: 'claude',
|
||||
hookEventName: 'PreToolUse',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'watch the build',
|
||||
agentType: 'claude',
|
||||
toolName: 'Read'
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]?.workingMode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves a hook turn stamp when an OSC repaint omits hook-only completion text', () => {
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
|
||||
@@ -228,6 +228,7 @@ describe('AgentHookServer listener replay', () => {
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
expect(baseline).not.toHaveProperty('claudeRunningNonAgentTask')
|
||||
expect(baseline).toMatchObject({ state: 'working', workingMode: 'monitoring' })
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
|
||||
@@ -77,7 +77,11 @@ describe('Last-status persistence', () => {
|
||||
worktreeId: 'wt-1',
|
||||
receivedAt: expect.any(Number),
|
||||
stateStartedAt: expect.any(Number),
|
||||
payload: expect.objectContaining({ state: 'working', prompt: 'persist me' })
|
||||
payload: expect.objectContaining({
|
||||
state: 'working',
|
||||
workingMode: 'monitoring',
|
||||
prompt: 'persist me'
|
||||
})
|
||||
})
|
||||
expect(file.entries[PANE].launchToken).toBeUndefined()
|
||||
expect(file.entries[PANE].launchTokenHash).toBe(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,7 @@
|
||||
--color-ring: var(--ring);
|
||||
--color-agent-question: var(--agent-question);
|
||||
--color-agent-question-text: var(--agent-question-text);
|
||||
--color-agent-monitoring: var(--agent-monitoring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
@@ -181,6 +182,13 @@
|
||||
--agent-question: var(--color-orange-600);
|
||||
/* Legible weight for text/glyphs on a tinted --agent-question surface. */
|
||||
--agent-question-text: var(--color-orange-700);
|
||||
/* "The turn is over; background work is still registered." Turquoise, not a
|
||||
green or a yellow: it has to stay separable from the emerald done/active dot
|
||||
at dot size, and from working-yellow on the map. Same literal in both themes,
|
||||
like --workspace-status-done — a status keeps its identity when the theme
|
||||
flips. Deliberately NOT read from the terminal theme at runtime: this is a
|
||||
fixed UI value like every other status colour. */
|
||||
--agent-monitoring: #8abeb7;
|
||||
--status-success: #15803d;
|
||||
--status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent);
|
||||
--status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent);
|
||||
@@ -291,6 +299,7 @@
|
||||
--ai-action-accent: var(--color-violet-400);
|
||||
--agent-question: var(--color-orange-500);
|
||||
--agent-question-text: var(--color-orange-300);
|
||||
--agent-monitoring: #8abeb7;
|
||||
--status-success: #86efac;
|
||||
--status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent);
|
||||
--status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent);
|
||||
|
||||
@@ -42,13 +42,15 @@ describe('AgentStateDot', () => {
|
||||
expect(markup).toContain('motion-reduce:border-t-yellow-500')
|
||||
})
|
||||
|
||||
it('renders monitoring as a static yellow radio glyph', () => {
|
||||
it('renders monitoring as a static turquoise dot, never a spinner', () => {
|
||||
const markup = renderMarkup('monitoring')
|
||||
|
||||
expect(markup).toContain('aria-label="Monitoring background tasks"')
|
||||
expect(markup).toContain('lucide-radio')
|
||||
expect(markup).toContain('text-yellow-500')
|
||||
expect(markup).toContain('bg-agent-monitoring')
|
||||
// Why: the turn is over — a spinner here is the bug this state exists to fix.
|
||||
expect(markup).not.toContain('data-agent-spinner')
|
||||
// Why: must stay visually separable from the done/active dot.
|
||||
expect(markup).not.toContain('bg-emerald-500')
|
||||
})
|
||||
|
||||
it('renders done as an emerald check icon', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { CircleCheck, Radio } from 'lucide-react'
|
||||
import { CircleCheck } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
|
||||
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
|
||||
@@ -84,17 +84,6 @@ export const AgentStateDot = React.memo(function AgentStateDot({
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'monitoring') {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
|
||||
aria-label={agentStateLabel(state)}
|
||||
>
|
||||
<Radio className={cn('text-yellow-500', icon)} aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'done') {
|
||||
// Why: the dashboard lists many agents, so a check glyph scans well for
|
||||
// agent-reported completion and keeps 'done' visually distinct from
|
||||
@@ -132,7 +121,11 @@ export const AgentStateDot = React.memo(function AgentStateDot({
|
||||
inner,
|
||||
state === 'blocked' || state === 'interrupted' || state === 'failed'
|
||||
? 'bg-red-500'
|
||||
: 'bg-neutral-500/40'
|
||||
: // Why: static dot, not a spinner — the turn is over. Turquoise stays
|
||||
// separable from the emerald done dot at this size.
|
||||
state === 'monitoring'
|
||||
? 'bg-agent-monitoring'
|
||||
: 'bg-neutral-500/40'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('palette live status', () => {
|
||||
await render()
|
||||
|
||||
expect(testContainer.querySelector('[data-spinner]')).toBeNull()
|
||||
expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500')
|
||||
expect(testContainer.querySelector('.bg-agent-monitoring')).not.toBeNull()
|
||||
expect(dotLabels()).toEqual(['Monitoring background tasks'])
|
||||
})
|
||||
|
||||
@@ -276,7 +276,7 @@ describe('palette live status', () => {
|
||||
})
|
||||
|
||||
expect(testContainer.querySelector('[data-spinner]')).toBeNull()
|
||||
expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500')
|
||||
expect(testContainer.querySelector('.bg-agent-monitoring')).not.toBeNull()
|
||||
expect(dotLabels()).toEqual(['Monitoring background tasks'])
|
||||
expect(testContainer.querySelector('[title="Monitoring background tasks"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
@@ -420,8 +420,10 @@
|
||||
stroke: var(--color-yellow-500);
|
||||
}
|
||||
|
||||
/* Why: static turquoise, distinct from working-yellow — the turn is over but
|
||||
background work is still registered. Same token as the sidebar/dashboard dot. */
|
||||
.fleet-status-monitoring .agent-map-agent-mark {
|
||||
stroke: var(--color-yellow-500);
|
||||
stroke: var(--agent-monitoring);
|
||||
}
|
||||
|
||||
.fleet-status-blocked .agent-map-agent-mark {
|
||||
|
||||
@@ -362,8 +362,7 @@ describe('DashboardAgentRow', () => {
|
||||
)
|
||||
|
||||
expect(markup).toContain('Monitoring background tasks')
|
||||
expect(markup).toContain('lucide-radio')
|
||||
expect(classTokens(markup)).toContain('text-yellow-500')
|
||||
expect(classTokens(markup)).toContain('bg-agent-monitoring')
|
||||
expect(markup).not.toContain('data-agent-spinner')
|
||||
expect(markup).not.toContain('data-agent-row-tool-slot')
|
||||
})
|
||||
|
||||
@@ -31,13 +31,13 @@ describe('StatusIndicator', () => {
|
||||
expect(markup).toContain('motion-reduce:border-t-yellow-500')
|
||||
})
|
||||
|
||||
it('renders monitoring as a static radio glyph', () => {
|
||||
it('renders monitoring as a static turquoise dot, never a spinner', () => {
|
||||
const markup = renderMarkup('monitoring')
|
||||
|
||||
expect(markup).toContain('title="Monitoring background tasks"')
|
||||
expect(markup).toContain('lucide-radio')
|
||||
expect(markup).toContain('text-yellow-500')
|
||||
expect(markup).toContain('bg-agent-monitoring')
|
||||
expect(markup).not.toContain('data-agent-spinner')
|
||||
expect(markup).not.toContain('bg-emerald-500')
|
||||
})
|
||||
|
||||
it('renders permission as the shared question glyph', () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import { Radio } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
|
||||
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
|
||||
@@ -41,18 +40,6 @@ const StatusIndicator = React.memo(function StatusIndicator({
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'monitoring') {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
|
||||
title={resolvedTitle}
|
||||
{...rest}
|
||||
>
|
||||
<Radio className="size-3 text-yellow-500" aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'permission') {
|
||||
return (
|
||||
<span
|
||||
@@ -79,7 +66,11 @@ const StatusIndicator = React.memo(function StatusIndicator({
|
||||
// 'active' (terminal open, quiet). Working uses a yellow
|
||||
// ring above; 'inactive' stays grey.
|
||||
'bg-emerald-500'
|
||||
: 'bg-neutral-500/40'
|
||||
: // Why: static turquoise — the lead turn ended, but background work
|
||||
// is still registered, so this is neither working nor done.
|
||||
status === 'monitoring'
|
||||
? 'bg-agent-monitoring'
|
||||
: 'bg-neutral-500/40'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
|
||||
import { useAgentCompletionCoordinatorLifecycle } from './agent-completion-coordinator-test-harness'
|
||||
import {
|
||||
createHookListenerState,
|
||||
normalizeHookPayload
|
||||
} from '../../../../shared/agent-hook-listener'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
|
||||
// Why: STA-4119's second complaint is the missing completion notification. This drives the REAL
|
||||
// hook listener and feeds its real output into the REAL coordinator, rather than hand-writing a
|
||||
// payload — the whole question is whether the two layers actually agree about a monitoring turn.
|
||||
const PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
|
||||
const RUNNING_SHELL = {
|
||||
id: 'shell-1',
|
||||
type: 'shell',
|
||||
status: 'running',
|
||||
description: 'Run the dev server',
|
||||
command: 'pnpm dev'
|
||||
}
|
||||
|
||||
function hookPayload(
|
||||
state: ReturnType<typeof createHookListenerState>,
|
||||
payload: Record<string, unknown>
|
||||
) {
|
||||
const parsed = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{ paneKey: PANE, payload },
|
||||
'production'
|
||||
)?.payload
|
||||
if (!parsed) {
|
||||
throw new Error('listener produced no payload')
|
||||
}
|
||||
// The hook server stamps stateStartedAt on the way to the renderer.
|
||||
return { ...parsed, stateStartedAt: 1_700_000_000_000 }
|
||||
}
|
||||
|
||||
function createCoordinator() {
|
||||
const dispatchCompletion = vi.fn()
|
||||
const dispatchHookLifecycle = vi.fn()
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: PANE,
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
dispatchHookLifecycle,
|
||||
isLive: () => true
|
||||
})
|
||||
return { coordinator, dispatchCompletion, dispatchHookLifecycle }
|
||||
}
|
||||
|
||||
/** Meta the coordinator hands the notification dispatcher. */
|
||||
function completionMeta(dispatchCompletion: ReturnType<typeof vi.fn>) {
|
||||
return dispatchCompletion.mock.calls[0]?.[1] as
|
||||
| { source?: string; agentStatus?: { state?: string; workingMode?: string } }
|
||||
| undefined
|
||||
}
|
||||
|
||||
describe('completion notification when a lead turn ends into monitoring', () => {
|
||||
useAgentCompletionCoordinatorLifecycle()
|
||||
|
||||
it('announces completion when the turn ends but the pane stays working for a background shell', () => {
|
||||
const listener = createHookListenerState()
|
||||
const { coordinator, dispatchCompletion, dispatchHookLifecycle } = createCoordinator()
|
||||
|
||||
coordinator.observeHookStatus(
|
||||
hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' })
|
||||
)
|
||||
const monitoring = hookPayload(listener, {
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [RUNNING_SHELL]
|
||||
})
|
||||
|
||||
// Precondition: the pane really is in the monitoring state, not done.
|
||||
expect(monitoring).toMatchObject({ state: 'working', workingMode: 'monitoring' })
|
||||
expect(typeof monitoring.turnCompletedAt).toBe('number')
|
||||
|
||||
coordinator.observeHookStatus(monitoring)
|
||||
|
||||
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
|
||||
const meta = completionMeta(dispatchCompletion)
|
||||
expect(meta?.source).toBe('hook')
|
||||
// Why: the announcement is a synthesized `done` while the reported row stays `working` —
|
||||
// the notification follows the TURN boundary, not the rendered state.
|
||||
expect(meta?.agentStatus?.state).toBe('done')
|
||||
// Why: announce only. Running pane lifecycle here would settle a pane that is still working.
|
||||
expect(dispatchHookLifecycle).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ state: 'done' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not announce twice when the background shell later clears to done', () => {
|
||||
const listener = createHookListenerState()
|
||||
const { coordinator, dispatchCompletion } = createCoordinator()
|
||||
|
||||
coordinator.observeHookStatus(
|
||||
hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' })
|
||||
)
|
||||
coordinator.observeHookStatus(
|
||||
hookPayload(listener, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] })
|
||||
)
|
||||
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The shell exits; the same turn's all-clear must not re-announce.
|
||||
coordinator.observeHookStatus(
|
||||
hookPayload(listener, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'KillShell',
|
||||
background_tasks: []
|
||||
})
|
||||
)
|
||||
|
||||
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not announce mid-turn while the agent is still working in the foreground', () => {
|
||||
const listener = createHookListenerState()
|
||||
const { coordinator, dispatchCompletion } = createCoordinator()
|
||||
|
||||
coordinator.observeHookStatus(
|
||||
hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
|
||||
)
|
||||
coordinator.observeHookStatus(
|
||||
hookPayload(listener, { hook_event_name: 'PreToolUse', tool_name: 'Bash' })
|
||||
)
|
||||
|
||||
expect(dispatchCompletion).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -145,6 +145,88 @@ describe('Claude background task status', () => {
|
||||
expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(false)
|
||||
})
|
||||
|
||||
// Why: STA-4119's second complaint is the missing completion notification. The renderer's
|
||||
// completion coordinator announces off `turnCompletedAt` on a still-`working` payload, so the
|
||||
// stamp — not the rendered state — is what makes the notification fire when a turn ends into
|
||||
// monitoring. The stamp predicate and the monitoring predicate are computed from the same
|
||||
// "lead said done but the pane resolves to working" expression, so a refactor that renames one
|
||||
// can silently drop the other with nothing else going red. These pin both halves.
|
||||
it('stamps the turn end when a finished lead falls back to monitoring', () => {
|
||||
const state = createHookListenerState()
|
||||
|
||||
claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: 'start the dev server'
|
||||
})
|
||||
const monitoring = claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [RUNNING_SHELL]
|
||||
})
|
||||
|
||||
expect(monitoring).toMatchObject({ state: 'working', workingMode: 'monitoring' })
|
||||
expect(typeof monitoring?.turnCompletedAt).toBe('number')
|
||||
})
|
||||
|
||||
it('stamps a lead that ends into a working subagent, which carries no monitoring mode', () => {
|
||||
const state = createHookListenerState()
|
||||
|
||||
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' })
|
||||
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'SubagentStart', agent_id: 'child-1' })
|
||||
const childWorking = claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [{ id: 'child-1', type: 'subagent', status: 'running' }]
|
||||
})
|
||||
|
||||
// Why: real agent work keeps the spinner (no workingMode) but the lead turn still ended,
|
||||
// so the stamp must be present. The two predicates are deliberately not the same.
|
||||
expect(childWorking).toMatchObject({ state: 'working', workingMode: undefined })
|
||||
expect(typeof childWorking?.turnCompletedAt).toBe('number')
|
||||
})
|
||||
|
||||
it('does not stamp a turn that is still running in the foreground', () => {
|
||||
const state = createHookListenerState()
|
||||
|
||||
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'work' })
|
||||
const midTurn = claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Bash'
|
||||
})
|
||||
|
||||
expect(midTurn).toMatchObject({ state: 'working', workingMode: undefined })
|
||||
expect(midTurn?.turnCompletedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not stamp an interrupted lead, which settles done instead of monitoring', () => {
|
||||
const state = createHookListenerState()
|
||||
|
||||
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'start it' })
|
||||
markClaudeLeadTurnInterrupted(state, SOURCE_PANE)
|
||||
const interrupted = claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [RUNNING_SHELL]
|
||||
})
|
||||
|
||||
expect(interrupted?.state).toBe('done')
|
||||
expect(interrupted?.workingMode).toBeUndefined()
|
||||
expect(interrupted?.turnCompletedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not stamp a turn that simply finished with nothing left running', () => {
|
||||
const state = createHookListenerState()
|
||||
|
||||
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'quick job' })
|
||||
const finished = claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [],
|
||||
session_crons: []
|
||||
})
|
||||
|
||||
// Why: the stamp exists to announce a turn whose pane STAYS working. A plain done
|
||||
// needs no stamp — the ordinary done path already notifies.
|
||||
expect(finished?.state).toBe('done')
|
||||
expect(finished?.turnCompletedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps foreground child work active before falling back to monitoring', () => {
|
||||
const state = createHookListenerState()
|
||||
claudeEvent(state, SOURCE_PANE, {
|
||||
|
||||
Reference in New Issue
Block a user