Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/renderer/src/components/AgentStateDot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ describe('AgentStateDot', () => {
expect(markup).not.toContain('data-agent-spinner')
})

it('renders unreported as a muted dashed ring, never the amber lost-contact ring', () => {
const markup = renderMarkup('unreported')

expect(markup).toContain('lucide-circle-dashed')
expect(markup).toContain('text-muted-foreground')
expect(markup).not.toContain('text-amber-500')
expect(markup).toContain('aria-label="No status reported"')
})

it.each(['blocked', 'interrupted'] satisfies AgentDotState[])(
'renders %s as a red attention dot',
(state) => {
Expand All @@ -116,6 +125,7 @@ describe('AgentStateDot', () => {
'done',
'idle',
'unverifiable',
'unreported',
'permission'
] satisfies AgentDotState[]

Expand Down
16 changes: 16 additions & 0 deletions src/renderer/src/components/AgentStateDot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Activity, CircleCheck, CircleDashed } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
import { agentNoStatusReportedLabel } from '@/lib/agent-row-decay-state'
import {
StateIndicatorTooltip,
type StateIndicatorTooltipSide
Expand Down Expand Up @@ -35,6 +36,9 @@ export type AgentDotState =
// held there, and never rendered as 'done' or 'working' — it asserts nothing about
// the agent, only about what Orca last heard.
| 'unverifiable'
// Why: the title names the agent and nothing says what it is doing. Not 'idle' (no rest
// evidence) and not 'unverifiable' (Orca never heard a status, so none went quiet).
| 'unreported'
// Why: the sidebar's title-based status flow (StatusIndicator/WorktreeCard)
// collapses blocked + waiting into a single "needs attention" state. Keep
// this as a distinct member so that flow can render without inventing a new
Expand Down Expand Up @@ -63,6 +67,8 @@ export function agentStateLabel(state: AgentDotState): string {
return 'Idle'
case 'unverifiable':
return 'No recent update'
case 'unreported':
return agentNoStatusReportedLabel()
case 'permission':
return 'Needs attention'
}
Expand Down Expand Up @@ -134,6 +140,16 @@ export const AgentStateDot = React.memo(function AgentStateDot({
<CircleDashed className={cn('text-amber-500', icon)} aria-hidden="true" />
</span>
)
} else if (state === 'unreported') {
// Why: the same "incomplete information" ring, muted: nothing went wrong, so no warning.
indicator = (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
>
<CircleDashed className={cn('text-muted-foreground', icon)} aria-hidden="true" />
</span>
)
} else if (state === 'permission' || state === 'waiting') {
indicator = (
<span
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ const ACTIVITY_STATUS_GROUP_RANK: Record<ActivityThreadStatusId, number> = {
monitoring: 6,
unverifiable: 7,
done: 8,
idle: 9
unreported: 9,
idle: 10
}

function activityStatusRank(thread: AgentPaneThread): number {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AgentDotState } from '@/components/AgentStateDot'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview'
import { agentNoStatusReportedLabel } from '@/lib/agent-row-decay-state'
import {
agentMainAgentVerdict,
agentVerdictDisplayMark
Expand Down Expand Up @@ -205,6 +206,8 @@ export function threadAgentStateLabel(thread: AgentPaneThread): string {
'auto.components.activity.ActivityPrototypePage.state.unverifiable',
'No recent update'
)
case 'unreported':
return agentNoStatusReportedLabel()
case 'permission':
return translate(
'auto.components.activity.ActivityPrototypePage.state.permission',
Expand Down
8 changes: 4 additions & 4 deletions src/renderer/src/components/dashboard/dashboard-row-bucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import type { AgentRowState } from '@/lib/agent-row-decay-state'
/**
* Project a row state onto the published card vocabulary.
*
* `unverifiable` stays renderer-local: `DashboardCardDotState` is validated against a fixed
* allowlist in main (`dashboard-payload-validation.ts`) and read by pop-out windows that may
* predate the member, so a new value would be dropped rather than rendered. Publishing today's
* `unverifiable` and `unreported` stay renderer-local: `DashboardCardDotState` is validated
* against a fixed allowlist in main (`dashboard-payload-validation.ts`) and read by pop-out
* windows that may predate the member, so a new value would be dropped rather than rendered. Publishing today's
* `idle` keeps those surfaces at today's behavior instead of silently losing the card.
*/
export function dashboardCardDotState(state: AgentRowState): DashboardCardDotState {
return state === 'unverifiable' ? 'idle' : state
return state === 'unverifiable' || state === 'unreported' ? 'idle' : state
}

export type DashboardRowBucketProjection = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ function agentRow(
entry: {
paneKey,
worktreeId: WORKSPACE,
state: state === 'idle' || state === 'unverifiable' ? 'done' : state,
state:
state === 'idle' || state === 'unverifiable' || state === 'unreported' ? 'done' : state,
prompt: paneKey === PANE_A ? 'Review agent A' : 'Review agent B',
lastAssistantMessage: 'Result details',
stateStartedAt: 2000,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { IDLE, buildAttentionByWorktree, resolveAttention } from './smart-attention'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'

const NOW = new Date('2026-03-27T12:00:00.000Z').getTime()

const WORKTREE: Worktree = {
id: 'wt-1',
repoId: 'repo-1',
path: '/tmp/wt-1',
branch: 'refs/heads/wt-1',
head: 'abc',
isBare: false,
isMainWorktree: false,
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
comment: '',
isUnread: false,
isPinned: false,
displayName: 'wt-1',
sortOrder: 0,
lastActivityAt: 0
}

const TAB: TerminalTab = {
id: 'tab-1',
ptyId: 'pty',
worktreeId: WORKTREE.id,
title: 'bash',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}

function attentionForTitle(title: string) {
return buildAttentionByWorktree(
[WORKTREE],
{ [WORKTREE.id]: [TAB] },
{},
{ [TAB.id]: { 1: title } },
{ [TAB.id]: ['pty-1'] },
NOW
).get(WORKTREE.id)
}

// Regression guards, hand-built titles: on main a bare name read idle, which ranked the same.
describe('smart attention for a title that only names its agent', () => {
it('ranks the pane with idle, never as attention', () => {
for (const title of ['agy', 'grok', 'OpenCode']) {
expect(attentionForTitle(title)).toEqual(IDLE)
}
// Positive control: the same pane with a spinner reaches the working class.
expect(attentionForTitle('⠋ agy')?.cls).toBe(3)
})

it('lets an unreported title pane contribute nothing', () => {
expect(
resolveAttention(
[{ kind: 'title', status: 'unreported', worktreeLastActivityAt: NOW - 1_000 }],
NOW
)
).toEqual(IDLE)
})
})
13 changes: 7 additions & 6 deletions src/renderer/src/components/sidebar/smart-attention.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { readAgentTitleActivity } from '../../../../shared/agent-title-status'
import type { AgentTitleActivity } from '../../../../shared/agent-title-core'
import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time'
import { agentTurnStoppedByUser } from '../../../../shared/agent-main-agent-verdict'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { resolveDecayedAgentRowState } from '@/lib/agent-row-decay-state'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { isSyntheticAgentPermissionTitle } from '../../../../shared/synthetic-agent-title'
import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id'
import type { AgentStatus } from '../../../../shared/agent-detection'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
import {
Expand Down Expand Up @@ -114,7 +115,7 @@ export type PaneInput =
// pane's PTY — losing the reporting stream is not the same as nothing running there.
| { kind: 'hook'; entry: AgentStatusEntry; hasLivePty: boolean }
// Why: TerminalTab has no per-tab lastActivityAt; the worktree-level value suffices for cross-worktree ordering.
| { kind: 'title'; status: AgentStatus | null; worktreeLastActivityAt: number }
| { kind: 'title'; status: AgentTitleActivity | null; worktreeLastActivityAt: number }

/**
* Resolve a worktree's class + attention timestamp from its panes' inputs.
Expand Down Expand Up @@ -199,7 +200,7 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten
cls = 3
ts = pane.worktreeLastActivityAt
} else {
// 'idle' or null: nothing to assert; pane stays in Class 4.
// 'idle', 'unreported' (a bare agent name asserts no activity) or null: never attention.
continue
}
}
Expand Down Expand Up @@ -333,7 +334,7 @@ export function collectTabPaneInputs(
// Why: unmounted tabs (restored-but-unvisited) expose only the legacy tab title.
panes.push({
kind: 'title',
status: classifyTitleActivity(tab.title),
status: readAgentTitleActivity(tab.title),
worktreeLastActivityAt
})
}
Expand All @@ -353,7 +354,7 @@ export function collectTabPaneInputs(
if ((leafId !== null && coveredLeafIds.has(leafId)) || hasSingleUnmappedHook) {
continue
}
panes.push({ kind: 'title', status: classifyTitleActivity(title), worktreeLastActivityAt })
panes.push({ kind: 'title', status: readAgentTitleActivity(title), worktreeLastActivityAt })
}
return panes
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const SUMMARY_STATE_ORDER: AgentDotState[] = [
'done',
// Why: below every reporting state, above true idle — the pane is still held.
'unverifiable',
// Why: sorts with idle; it asserts no activity, so it never outranks a reporting state.
'unreported',
'idle'
]

Expand Down Expand Up @@ -46,6 +48,8 @@ export function formatSummaryStateLabel(state: AgentDotState): string {
return 'idle'
case 'unverifiable':
return 'not reporting'
case 'unreported':
return 'no status reported'
Comment on lines +51 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Summary label remains English When the UI is set to Spanish, French, Japanese, Korean, or Chinese, the compact worktree summary and its accessible expansion label still say “no status reported” in English, while the agent row and dot use the translated phrase. Translating this summary label would keep the new state consistent across the same card.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

case 'permission':
return 'needs attention'
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { dashboardRowBucketProjection } from '@/components/dashboard/dashboard-row-bucket'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { buildWorktreeAgentRows } from './worktree-agent-rows'
import { CompactAgentRow } from './worktree-card-compact-agent-row'
import { summarizeAgents } from './worktree-card-agent-summary'

const LEAF_ID = '77777777-7777-4777-8777-777777777777'

function makeTab(id: string): TerminalTab {
return {
id,
worktreeId: 'wt-1',
ptyId: null,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}

// Hand-built titles: the sidebar row for a pane whose title is `title` and that has no hook row.
function titleRow(title: string, tabId = 'tab-1') {
const [row] = buildWorktreeAgentRows({
tabs: [makeTab(tabId)],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { [tabId]: { 1: title } },
ptyIdsByTabId: { [tabId]: [`pty-${tabId}`] },
terminalLayoutsByTabId: {
[tabId]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null
}
},
now: 2000
})
expect(row).toBeDefined()
return row
}

describe('a sidebar row whose title only names the agent', () => {
it('reads "No status reported" beside a muted dashed ring, not Idle', () => {
const markup = renderToStaticMarkup(
createElement(
TooltipProvider,
null,
createElement(CompactAgentRow, { agent: titleRow('agy'), now: 2000, onActivate: vi.fn() })
)
)

expect(markup).toContain('No status reported')
expect(markup).toContain('aria-label="No status reported"')
expect(markup).toContain('lucide-circle-dashed')
expect(markup).toContain('text-muted-foreground')
expect(markup).not.toContain('text-amber-500')
expect(markup).not.toContain('>Idle<')
})

it('names the state with the same phrase in the worktree summary, ranked just above idle', () => {
const unreported = titleRow('grok', 'tab-1')
const idle = titleRow('agy ready', 'tab-2')

expect(summarizeAgents([idle, unreported], 'Agents')).toBe(
'Agents: 1 no status reported, 1 idle'
)
})

it('publishes idle to the dashboard card, whose validator drops unknown states', () => {
const unreported = dashboardRowBucketProjection(titleRow('aider'))
const idle = dashboardRowBucketProjection(titleRow('aider ready'))

expect(unreported.dotState).toBe('idle')
expect(unreported).toEqual(idle)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ function subagentRowKey(parentPaneKey: string, subagentId: string): string {
}

/** The lifecycle word every reader of a CLI row already understands; the row's own dot says more. */
function agentRowStateFor(displayState: AgentChildDisplayState): AgentRowState {
function agentRowStateFor(
displayState: AgentChildDisplayState
): Exclude<AgentRowState, 'unreported'> {
const runState = agentChildRunStateFor(displayState)
// A CLI row carries monitoring as `working` plus its `workingMode`.
return runState === 'monitoring' ? 'working' : runState
Expand Down
Loading
Loading