mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 00:02:24 +00:00
* feat(agents): distinguish Claude background monitoring Adds an optional `workingMode: 'monitoring'` discriminator for a Claude session whose lead turn finished but which still has background shell tasks or session crons registered. The wire state stays `working`, so older peers that never read the field keep rendering Working. (cherry picked from commitd5d54b4bdd) Rebased onto current main (554 commits of drift) by Brennan Benson; conflicts resolved by keeping both sides where main and this branch made independent additions to the same construct. * fix(sidebar): keep monitoring status visible (cherry picked from commitfd6b38654d) * test(agents): cover Claude monitoring drain (cherry picked from commitce4d61ebf8) * test(mobile): avoid unresolved renderer test type (cherry picked from commitbbcfa35ff9) * 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. * revert(agents): restore the yellow Radio glyph for monitoring Brennan chose #14205's original treatment over the turquoise dot, so the visual goes back to nwparker's: lucide Radio in text-yellow-500 across the sidebar, dashboard dot, cmd-j palette and agent-map ring. Reverts only the visual surface. The turn-boundary notification pins stay — the monitoring predicate and the turnCompletedAt stamp share an expression, so a rename can silently drop the stamp and kill a completion that works today with nothing else going red. The --agent-monitoring token is removed with its last consumer rather than left dead in main.css. --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
229 lines
6.4 KiB
TypeScript
229 lines
6.4 KiB
TypeScript
import {
|
|
createElement,
|
|
Fragment,
|
|
useCallback,
|
|
useState,
|
|
type Dispatch,
|
|
type SetStateAction
|
|
} from 'react'
|
|
import { Text } from 'react-native'
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
|
import { WorktreeAgentRow } from './WorktreeAgentRow'
|
|
import { WorktreeListRow, type WorktreeListRowItem } from './WorktreeListRow'
|
|
|
|
const { agentSpinnerRender, agentStateDotRender } = vi.hoisted(() => ({
|
|
agentSpinnerRender: vi.fn(),
|
|
agentStateDotRender: vi.fn()
|
|
}))
|
|
|
|
vi.mock('react-native', () => ({
|
|
Pressable: 'Pressable',
|
|
StyleSheet: { create: <T>(styles: T) => styles },
|
|
Text: 'Text',
|
|
View: 'View'
|
|
}))
|
|
|
|
vi.mock('lucide-react-native', () => ({
|
|
Bell: 'Bell',
|
|
ChevronDown: 'ChevronDown',
|
|
ChevronRight: 'ChevronRight',
|
|
GitBranch: 'GitBranch',
|
|
GitPullRequest: 'GitPullRequest'
|
|
}))
|
|
|
|
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: vi.fn() }))
|
|
vi.mock('./AgentSpinner', () => ({
|
|
AgentSpinner: (props: unknown) => {
|
|
agentSpinnerRender(props)
|
|
return null
|
|
}
|
|
}))
|
|
vi.mock('./AgentStateDot', () => ({
|
|
AgentStateDot: (props: unknown) => {
|
|
agentStateDotRender(props)
|
|
return null
|
|
}
|
|
}))
|
|
vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
|
|
vi.mock('./MobileRepoIcon', () => ({ MobileRepoIcon: () => null }))
|
|
vi.mock('./WorktreeAgentList', () => ({ WorktreeAgentList: () => null }))
|
|
vi.mock('./WorktreeMetaGlyphs', () => ({
|
|
prStateColor: () => '#000000',
|
|
WorktreeMetaGlyphs: () => null
|
|
}))
|
|
|
|
type TestItem = WorktreeListRowItem & {
|
|
status: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
|
lastOutputAt: number
|
|
}
|
|
|
|
const stableRepoIcon = { type: 'emoji', emoji: 'o' } as const
|
|
let updateSibling: Dispatch<SetStateAction<number>> = () => undefined
|
|
|
|
function ListRowHarness({ item, now }: { item: TestItem; now: number }) {
|
|
const [sibling, setSibling] = useState(0)
|
|
updateSibling = setSibling
|
|
const onPress = useCallback(() => undefined, [])
|
|
const onLongPress = useCallback(() => undefined, [])
|
|
const onToggleLineage = useCallback(() => undefined, [])
|
|
|
|
// Sibling state changes re-render the harness without changing the row's props,
|
|
// exercising the row's React.memo bailout.
|
|
return createElement(
|
|
Fragment,
|
|
null,
|
|
createElement(Text, null, sibling),
|
|
createElement(WorktreeListRow, {
|
|
item,
|
|
isReadOnly: false,
|
|
now,
|
|
repoColor: '#000000',
|
|
repoIcon: stableRepoIcon,
|
|
hideRepo: false,
|
|
status: item.status,
|
|
onPress,
|
|
onLongPress,
|
|
onToggleLineage
|
|
})
|
|
)
|
|
}
|
|
|
|
function agent(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeAgentRow {
|
|
return {
|
|
paneKey: 'agent-1',
|
|
parentPaneKey: null,
|
|
state: 'working',
|
|
agentType: null,
|
|
prompt: 'Fix the list',
|
|
taskTitle: null,
|
|
displayName: null,
|
|
lastAssistantMessage: null,
|
|
toolName: null,
|
|
toolInput: null,
|
|
interrupted: false,
|
|
stateStartedAt: 1_000,
|
|
updatedAt: 1_000,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
const baseItem: TestItem = {
|
|
worktreeId: 'worktree-1',
|
|
repo: 'orca',
|
|
branch: 'feature/mobile-list',
|
|
displayName: 'mobile-list',
|
|
liveTerminalCount: 1,
|
|
preview: 'Waiting',
|
|
unread: false,
|
|
linkedPR: null,
|
|
agents: [agent()],
|
|
status: 'active',
|
|
lastOutputAt: 1_000
|
|
}
|
|
|
|
describe('memoized worktree rows', () => {
|
|
let renderer: ReactTestRenderer | null = null
|
|
|
|
beforeEach(() => {
|
|
agentSpinnerRender.mockClear()
|
|
agentStateDotRender.mockClear()
|
|
})
|
|
|
|
afterEach(() => {
|
|
act(() => renderer?.unmount())
|
|
renderer = null
|
|
})
|
|
|
|
it('skips an unrelated parent render but updates for live item fields and time', async () => {
|
|
await act(async () => {
|
|
renderer = create(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
|
|
})
|
|
expect(agentSpinnerRender).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => updateSibling((value) => value + 1))
|
|
expect(agentSpinnerRender).toHaveBeenCalledTimes(1)
|
|
|
|
let expectedRenders = 1
|
|
const liveUpdates: TestItem[] = [
|
|
{ ...baseItem, preview: 'Running tests' },
|
|
{ ...baseItem, unread: true },
|
|
{ ...baseItem, lastOutputAt: 2_000 },
|
|
{ ...baseItem, agents: [agent({ state: 'waiting', updatedAt: 2_000 })] },
|
|
{ ...baseItem, status: 'working' }
|
|
]
|
|
for (const liveUpdate of liveUpdates) {
|
|
await act(async () =>
|
|
renderer!.update(createElement(ListRowHarness, { item: liveUpdate, now: 2_000 }))
|
|
)
|
|
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
|
|
await act(async () =>
|
|
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
|
|
)
|
|
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
|
|
}
|
|
|
|
await act(async () =>
|
|
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 32_000 }))
|
|
)
|
|
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
|
|
})
|
|
|
|
it('memoizes agent rows without hiding agent updates', async () => {
|
|
const firstAgent = agent()
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(WorktreeAgentRow, {
|
|
agent: firstAgent,
|
|
depth: 0,
|
|
now: 2_000,
|
|
unvisited: false
|
|
})
|
|
)
|
|
})
|
|
expect(agentStateDotRender).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => {
|
|
renderer!.update(
|
|
createElement(WorktreeAgentRow, {
|
|
agent: firstAgent,
|
|
depth: 0,
|
|
now: 2_000,
|
|
unvisited: false
|
|
})
|
|
)
|
|
})
|
|
expect(agentStateDotRender).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => {
|
|
renderer!.update(
|
|
createElement(WorktreeAgentRow, {
|
|
agent: agent({ state: 'working', workingMode: 'monitoring', updatedAt: 2_000 }),
|
|
depth: 0,
|
|
now: 2_000,
|
|
unvisited: false
|
|
})
|
|
)
|
|
})
|
|
expect(agentStateDotRender).toHaveBeenCalledTimes(2)
|
|
expect(agentStateDotRender).toHaveBeenLastCalledWith({ state: 'monitoring' })
|
|
})
|
|
|
|
it('passes workspace monitoring mode to the status indicator', async () => {
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(ListRowHarness, {
|
|
item: { ...baseItem, status: 'working', workingMode: 'monitoring' },
|
|
now: 2_000
|
|
})
|
|
)
|
|
})
|
|
|
|
expect(agentSpinnerRender).toHaveBeenLastCalledWith({
|
|
status: 'working',
|
|
workingMode: 'monitoring'
|
|
})
|
|
})
|
|
})
|