mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix(agent-status): cover secondary background presentations
Carry the background-only marker through dashboard snapshots and live popout patches so the board, map, and notes target menu do not retain a foreground spinner. Keep the agent state and dashboard grouping working for liveness.
This commit is contained in:
@@ -99,6 +99,19 @@ function imageIconSrc(bodyBytes: number, withWhitespace = false): string {
|
||||
describe('dashboard payload validation', () => {
|
||||
it('accepts a complete dashboard snapshot', () => {
|
||||
expect(isDashboardSnapshot(SNAPSHOT)).toBe(true)
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
cards: [
|
||||
{
|
||||
...SNAPSHOT.cards[0],
|
||||
bucket: 'working',
|
||||
dotState: 'working',
|
||||
backgroundOnly: true
|
||||
}
|
||||
]
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects malformed or unbounded snapshot fields', () => {
|
||||
@@ -109,6 +122,18 @@ describe('dashboard payload validation', () => {
|
||||
cards: [{ ...SNAPSHOT.cards[0], bucket: 'unexpected' }]
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
cards: [{ ...SNAPSHOT.cards[0], backgroundOnly: 'true' }]
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
cards: [{ ...SNAPSHOT.cards[0], backgroundOnly: true }]
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
|
||||
@@ -256,6 +256,8 @@ function isDashboardCard(value: unknown): boolean {
|
||||
DASHBOARD_BUCKETS.has(card.bucket) &&
|
||||
typeof card.dotState === 'string' &&
|
||||
DASHBOARD_DOT_STATES.has(card.dotState) &&
|
||||
(card.backgroundOnly === undefined ||
|
||||
(card.backgroundOnly === true && card.dotState === 'working')) &&
|
||||
isBoundedString(card.task, AGENT_STATUS_MAX_FIELD_LENGTH, true) &&
|
||||
isOptionalBoundedString(card.lastUserMessage, AGENT_STATUS_MAX_FIELD_LENGTH) &&
|
||||
isOptionalBoundedString(card.lastAgentMessage, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH) &&
|
||||
|
||||
@@ -19,7 +19,9 @@ vi.mock('@/lib/agent-catalog', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/components/AgentStateDot', () => ({
|
||||
AgentStateDot: () => <span data-testid="state-dot" />
|
||||
AgentStateDot: ({ state }: { state: string }) => (
|
||||
<span data-testid="state-dot" data-state={state} />
|
||||
)
|
||||
}))
|
||||
|
||||
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
|
||||
@@ -78,6 +80,23 @@ describe('AgentKanbanCard', () => {
|
||||
expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders background-only work without an active foreground spinner', () => {
|
||||
const { rerender } = renderCard({ card: card(), now: 2_000 })
|
||||
expect(screen.getByTestId('state-dot')).toHaveAttribute('data-state', 'working')
|
||||
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={card({ backgroundOnly: true })}
|
||||
now={2_000}
|
||||
onOpenTerminal={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('state-dot')).toHaveAttribute('data-state', 'background')
|
||||
})
|
||||
|
||||
it('shows the question glyph once when a summary is available', () => {
|
||||
const attentionCard = card({
|
||||
bucket: 'attention',
|
||||
|
||||
@@ -87,6 +87,7 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean {
|
||||
a.agentType === b.agentType &&
|
||||
a.bucket === b.bucket &&
|
||||
a.dotState === b.dotState &&
|
||||
a.backgroundOnly === b.backgroundOnly &&
|
||||
a.task === b.task &&
|
||||
a.lastUserMessage === b.lastUserMessage &&
|
||||
a.lastAgentMessage === b.lastAgentMessage &&
|
||||
@@ -208,6 +209,8 @@ export const AgentKanbanCard = memo(
|
||||
// it". Everything else stays neutral so the tint keeps meaning something.
|
||||
const needsYou = card.bucket === 'attention'
|
||||
const displayState = dashboardCardDisplayState(card)
|
||||
const dotState =
|
||||
card.backgroundOnly === true && displayState === 'working' ? 'background' : displayState
|
||||
const isDone = displayState === 'done'
|
||||
// Why: the session's own name heads the card. Without one the worktree is
|
||||
// the best heading left — and then the footer drops it rather than say it
|
||||
@@ -248,7 +251,7 @@ export const AgentKanbanCard = memo(
|
||||
>
|
||||
{heading}
|
||||
</span>
|
||||
{card.askSummary ? null : <AgentStateDot state={displayState} className="ml-auto" />}
|
||||
{card.askSummary ? null : <AgentStateDot state={dotState} className="ml-auto" />}
|
||||
</div>
|
||||
|
||||
{card.lastUserMessage || card.lastAgentMessage ? (
|
||||
|
||||
@@ -18,6 +18,7 @@ import { AgentMapQuestionMarker } from './AgentMapQuestionMarker'
|
||||
import type { AgentMapFlareStatus } from './agent-map-node-metadata'
|
||||
import {
|
||||
agentMapAttentionMarkerScale,
|
||||
agentMapDotState,
|
||||
agentMapStatusLabel,
|
||||
agentName,
|
||||
formatDuration,
|
||||
@@ -111,13 +112,10 @@ function WorktreeDetails({
|
||||
{agentName(agent.card)}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{agentMapStatusLabel(agent.status)} · {formatDuration(agent.durationMinutes)}
|
||||
{agentMapStatusLabel(agent)} · {formatDuration(agent.durationMinutes)}
|
||||
</span>
|
||||
</span>
|
||||
<AgentStateDot
|
||||
state={agent.status === 'done-seen' ? 'done' : agent.status}
|
||||
size="md"
|
||||
/>
|
||||
<AgentStateDot state={agentMapDotState(agent)} size="md" />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
@@ -321,7 +319,7 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
|
||||
tabIndex={agentExiting ? -1 : 0}
|
||||
aria-hidden={agentExiting || undefined}
|
||||
aria-pressed={selectedPaneKey === agent.card.paneKey}
|
||||
aria-label={`${agentName(agent.card)}, ${agentMapStatusLabel(agent.status)}${agent.card.unseen ? ', unread' : ''}, ${formatDuration(agent.durationMinutes)}, ${worktree.name}, ${project.name}`}
|
||||
aria-label={`${agentName(agent.card)}, ${agentMapStatusLabel(agent)}${agent.card.unseen ? ', unread' : ''}, ${formatDuration(agent.durationMinutes)}, ${worktree.name}, ${project.name}`}
|
||||
className={`agent-map-agent-node fleet-status-${agent.status}${selectedPaneKey === agent.card.paneKey ? ' is-selected' : ''}${agent.motionState ? ` is-${agent.motionState}` : ''}`}
|
||||
transform={`translate(${agent.x} ${agent.y})`}
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { agentMapStatusLabel } from './agent-map-node-presentation'
|
||||
import { agentMapDotState, agentMapStatusLabel } from './agent-map-node-presentation'
|
||||
import type { AgentMapAgentNode } from './agent-map-layout'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (key: string, fallback: string) => `${key}:${fallback}`
|
||||
}))
|
||||
|
||||
describe('agentMapStatusLabel', () => {
|
||||
function node(status: AgentMapAgentNode['status'], backgroundOnly = false): AgentMapAgentNode {
|
||||
return {
|
||||
status,
|
||||
card: backgroundOnly ? { backgroundOnly: true } : {}
|
||||
} as AgentMapAgentNode
|
||||
}
|
||||
|
||||
it('localizes the map-only acknowledged completion state', () => {
|
||||
expect(agentMapStatusLabel('done-seen')).toBe('dashboardPopout.map.status.doneSeen:Done, seen')
|
||||
expect(agentMapStatusLabel(node('done-seen'))).toBe(
|
||||
'dashboardPopout.map.status.doneSeen:Done, seen'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps shared agent states on their existing labels', () => {
|
||||
expect(agentMapStatusLabel('working')).toBe('Working')
|
||||
expect(agentMapStatusLabel('done')).toBe('Done')
|
||||
expect(agentMapStatusLabel(node('working'))).toBe('Working')
|
||||
expect(agentMapStatusLabel(node('done'))).toBe('Done')
|
||||
})
|
||||
|
||||
it('presents a background-only working node without changing its map status', () => {
|
||||
const background = node('working', true)
|
||||
expect(agentMapDotState(background)).toBe('background')
|
||||
expect(agentMapStatusLabel(background)).toBe('Background work')
|
||||
expect(background.status).toBe('working')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { agentStateLabel } from '@/components/AgentStateDot'
|
||||
import { agentStateLabel, type AgentDotState } from '@/components/AgentStateDot'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
|
||||
import { agentMapDirectLineageChevronPath } from './agent-map-lineage-chevron-path'
|
||||
import type { AgentMapAgentNode } from './agent-map-layout'
|
||||
import type { AgentMapNodeStatus } from './agent-map-node-metadata'
|
||||
|
||||
/** Lives here, not in `agent-map-node-metadata`: `agentStateLabel` drags in React and
|
||||
* lucide-react, and that module is on the layout and filter paths, which must stay
|
||||
* free of component imports. `agentStateLabel` is shared with every other dot
|
||||
* renderer, so the map's extra state gets its label here rather than widening
|
||||
* `AgentDotState`. */
|
||||
export function agentMapStatusLabel(status: AgentMapNodeStatus): string {
|
||||
return status === 'done-seen'
|
||||
export function agentMapDotState(node: AgentMapAgentNode): AgentDotState {
|
||||
if (node.card.backgroundOnly === true && node.status === 'working') {
|
||||
return 'background'
|
||||
}
|
||||
return node.status === 'done-seen' ? 'done' : node.status
|
||||
}
|
||||
|
||||
export function agentMapStatusLabel(node: AgentMapAgentNode): string {
|
||||
return node.status === 'done-seen'
|
||||
? translate('dashboardPopout.map.status.doneSeen', 'Done, seen')
|
||||
: agentStateLabel(status)
|
||||
: agentStateLabel(agentMapDotState(node))
|
||||
}
|
||||
|
||||
export function formatDuration(minutes: number): string {
|
||||
|
||||
@@ -86,6 +86,25 @@ describe('patchDashboardSnapshotFromAgentStatus', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('updates background-only presentation without changing the working bucket', () => {
|
||||
const marked = patchDashboardSnapshotFromAgentStatus(
|
||||
snapshot(),
|
||||
event({ state: 'working', backgroundOnly: true })
|
||||
).snapshot.cards[0]
|
||||
|
||||
expect(marked).toMatchObject({
|
||||
bucket: 'working',
|
||||
dotState: 'working',
|
||||
backgroundOnly: true
|
||||
})
|
||||
|
||||
const cleared = patchDashboardSnapshotFromAgentStatus(
|
||||
snapshot([{ ...marked, statusUpdatedAt: 300 }]),
|
||||
event({ state: 'working', backgroundOnly: undefined, receivedAt: 301 })
|
||||
).snapshot.cards[0]
|
||||
expect(cleared.backgroundOnly).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores stale, wrong-workspace, and session-only events', () => {
|
||||
const original = snapshot()
|
||||
expect(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type DashboardSnapshot
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
import { dashboardBucketForDotState } from '../dashboard/dashboard-card-bucket'
|
||||
import { isBackgroundOnlyAgentActivity } from '../../../../shared/agent-background-only-activity'
|
||||
|
||||
export type DashboardAgentStatusPatchResult = {
|
||||
matched: boolean
|
||||
@@ -62,6 +63,7 @@ export function patchDashboardSnapshotFromAgentStatus(
|
||||
: {}),
|
||||
bucket,
|
||||
dotState,
|
||||
backgroundOnly: isBackgroundOnlyAgentActivity(event) || undefined,
|
||||
unseen,
|
||||
stateChangedAt: stateChanged ? event.stateStartedAt : card.stateChangedAt,
|
||||
statusUpdatedAt: event.receivedAt,
|
||||
|
||||
@@ -200,6 +200,23 @@ describe('buildDashboardSnapshot', () => {
|
||||
expect(card.unseen).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves background-only presentation without moving the live card', () => {
|
||||
const snapshot = buildDashboardSnapshot(
|
||||
baseState({
|
||||
agentStatusByPaneKey: {
|
||||
[PANE_KEY]: entry({ state: 'working', backgroundOnly: true })
|
||||
}
|
||||
}),
|
||||
NOW
|
||||
)
|
||||
|
||||
expect(snapshot.cards[0]).toMatchObject({
|
||||
bucket: 'working',
|
||||
dotState: 'working',
|
||||
backgroundOnly: true
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes terminal-backed orchestrated workers under their direct parent', () => {
|
||||
const snapshot = buildDashboardSnapshot(
|
||||
baseState({
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
} from './dashboard-worktree-launch-options'
|
||||
import { buildDashboardSnapshotFilterOptions } from './dashboard-snapshot-filter-options'
|
||||
import { dashboardBucketForDotState } from './dashboard-card-bucket'
|
||||
import { isBackgroundOnlyAgentActivity } from '../../../../shared/agent-background-only-activity'
|
||||
|
||||
/** The store slices the snapshot builder reads. Kept as a Pick so unit tests
|
||||
* can pass a partial store without constructing the whole AppState. */
|
||||
@@ -255,7 +256,6 @@ export function buildDashboardSnapshot(
|
||||
osRelease: clientHost.osRelease
|
||||
})
|
||||
: null
|
||||
const finishedAt = lastEnteredDoneAt(row)
|
||||
const hostMetadata = includeCardDetails
|
||||
? dashboardCardMapWorkspaceMetadata(
|
||||
workspace,
|
||||
@@ -266,13 +266,13 @@ export function buildDashboardSnapshot(
|
||||
: undefined
|
||||
// Only repos that actually contribute a card ship their icon.
|
||||
repoIconsByRepoId[workspace.projectId] = workspace.repoIcon
|
||||
|
||||
cards.push({
|
||||
paneKey: row.paneKey,
|
||||
ptyId,
|
||||
agentType: row.agentType,
|
||||
bucket,
|
||||
dotState,
|
||||
...(isBackgroundOnlyAgentActivity(row.entry) ? { backgroundOnly: true } : {}),
|
||||
task: isTitleDerived ? '' : rowTask(row),
|
||||
repoId: workspace.projectId,
|
||||
worktreeId,
|
||||
@@ -296,7 +296,7 @@ export function buildDashboardSnapshot(
|
||||
lastUserMessage: isTitleDerived ? undefined : nonEmpty(row.entry.prompt),
|
||||
lastAgentMessage: isTitleDerived ? undefined : nonEmpty(row.entry.lastAssistantMessage),
|
||||
startedAt: row.startedAt,
|
||||
finishedAt,
|
||||
finishedAt: lastEnteredDoneAt(row),
|
||||
stateChangedAt: row.entry.stateStartedAt || row.startedAt,
|
||||
statusUpdatedAt: row.entry.updatedAt,
|
||||
// Same derivation as WorktreeCardAgents' unvisitedByPaneKey, so the
|
||||
|
||||
@@ -118,6 +118,8 @@ vi.mock('@/components/AgentStateDot', () => ({
|
||||
switch (state) {
|
||||
case 'working':
|
||||
return 'Working'
|
||||
case 'background':
|
||||
return 'Background work'
|
||||
case 'blocked':
|
||||
return 'Blocked'
|
||||
case 'waiting':
|
||||
@@ -193,7 +195,8 @@ function agentRow({
|
||||
title,
|
||||
agentType,
|
||||
state = 'done',
|
||||
startedAt = harness.now
|
||||
startedAt = harness.now,
|
||||
backgroundOnly = false
|
||||
}: {
|
||||
paneKey: string
|
||||
tabId: string
|
||||
@@ -201,11 +204,15 @@ function agentRow({
|
||||
agentType: TuiAgent
|
||||
state?: AgentStatusState | 'idle'
|
||||
startedAt?: number
|
||||
backgroundOnly?: boolean
|
||||
}): DashboardAgentRowData {
|
||||
const entryState: AgentStatusState = state === 'idle' ? 'working' : state
|
||||
return {
|
||||
paneKey,
|
||||
entry: agentEntry(paneKey, agentType, entryState, startedAt),
|
||||
entry: {
|
||||
...agentEntry(paneKey, agentType, entryState, startedAt),
|
||||
...(backgroundOnly ? { backgroundOnly: true } : {})
|
||||
},
|
||||
tab: tab(tabId, { title }) as DashboardAgentRowData['tab'],
|
||||
agentType,
|
||||
state,
|
||||
@@ -450,6 +457,36 @@ describe('ReviewNotesSendMenuContent', () => {
|
||||
expect(collectText(items[1])).toContain('Claude')
|
||||
})
|
||||
|
||||
it('labels a background-only target without foreground working presentation', () => {
|
||||
const paneKey = makePaneKey(TAB_A, LEAF_A)
|
||||
harness.worktreeAgentRows = [
|
||||
agentRow({
|
||||
paneKey,
|
||||
tabId: TAB_A,
|
||||
title: 'Background session',
|
||||
agentType: 'claude',
|
||||
state: 'working',
|
||||
backgroundOnly: true
|
||||
})
|
||||
]
|
||||
harness.noteTargets = [
|
||||
{
|
||||
paneKey,
|
||||
tabId: TAB_A,
|
||||
leafId: LEAF_A,
|
||||
agentType: 'claude',
|
||||
tabTitle: 'Background session',
|
||||
status: 'eligible'
|
||||
}
|
||||
]
|
||||
|
||||
const item = findByType(render(), 'DropdownMenuItem')
|
||||
const dot = findByType(item, 'AgentStateDot')
|
||||
|
||||
expect(dot.props.state).toBe('background')
|
||||
expect(collectText(item)).toContain('Background work')
|
||||
})
|
||||
|
||||
it('does not target title-detected rows skipped by target derivation', async () => {
|
||||
const paneKey = makePaneKey(TAB_B, LEAF_B)
|
||||
harness.worktreeAgentRows = [
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useWorktreeAgentRows } from '@/components/sidebar/useWorktreeAgentRows'
|
||||
import type { LaunchSource } from '../../../../shared/telemetry-events'
|
||||
import type { AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getAgentDotState } from '@/components/sidebar/worktree-card-agent-summary'
|
||||
|
||||
type OrderedSendTarget = {
|
||||
target: NotesSendAgentTarget
|
||||
@@ -245,7 +246,7 @@ function AgentTargetMenuItem({
|
||||
onSend: (target: NotesSendAgentTarget) => void
|
||||
}): React.JSX.Element {
|
||||
const tabTitle = target.tabTitle.trim()
|
||||
const state = asDotState(agent?.state ?? 'idle')
|
||||
const state = agent ? getAgentDotState(agent) : asDotState('idle')
|
||||
const timeAgo = agent ? formatAgentRelativeTime(agent, now) : null
|
||||
const secondaryParts = [
|
||||
agentStateLabel(state),
|
||||
|
||||
@@ -84,6 +84,9 @@ export type DashboardCard = {
|
||||
agentType: AgentType
|
||||
bucket: DashboardBucket
|
||||
dotState: DashboardCardDotState
|
||||
/** Presentation-only marker for a finished foreground turn whose registered
|
||||
* background work keeps the card in the working bucket. */
|
||||
backgroundOnly?: true
|
||||
/** One-line task/prompt text shown on the card. */
|
||||
task: string
|
||||
/** The most recent message the user sent this agent (its current prompt). */
|
||||
|
||||
Reference in New Issue
Block a user