feat(agent-map): make an unread finish visible and let a glance demote it

A finished agent was the quietest mark on the map. `status-glow` was defined
only for working/waiting/blocked, so a finish got a 1.7px emerald stroke on a
6px mark and no halo — invisible across a 200-agent fleet.

Worse, `dashboardCardDisplayState` folds `done && !unseen` into `idle`, so
opening an agent erased it: finished-but-unlanded work looked exactly like a
workspace that never ran. Looking at something is not the same as dealing
with it.

Split the two on the map only, via a local `AgentMapNodeStatus` at the single
`agentMapNodeStatus` seam. `DashboardCardDotState` crosses the pop-out bridge
and is unchanged, so there is no wire change and bucket counts are untouched.

- done + unseen: filled emerald core, emerald halo, one-shot 1.4s flare
- done + seen:   hollow emerald, no halo — still yours to land
- workspace ring turns green only once the whole workspace has settled

The flare is gated on a wall-clock recency window, not the map's `now` prop:
`now` ticks every 30s to refresh relative timestamps, so measuring a 1.4s
window against it fired at random moments instead of on the transition.
This commit is contained in:
Brennan Benson
2026-08-12 21:34:00 -07:00
parent e97faa1650
commit f1ffb1b2d2
18 changed files with 477 additions and 104 deletions
@@ -502,7 +502,7 @@ describe('AgentKanbanBoard', () => {
expect(ackAgent).toHaveBeenCalledWith('pk-ack')
})
it('keeps an acknowledged result visible as Idle in the map without review state', async () => {
it('keeps an acknowledged result visible as a seen finish in the map without review state', async () => {
const fresh = card({
paneKey: 'fresh-result',
bucket: 'done',
@@ -530,7 +530,9 @@ describe('AgentKanbanBoard', () => {
initialView="map"
/>
)
expect(screen.getByRole('button', { name: /Fresh result/ })).toHaveClass('fleet-status-idle')
expect(screen.getByRole('button', { name: /Fresh result/ })).toHaveClass(
'fleet-status-done-seen'
)
expect(screen.getByTestId('terminal-panel')).toBeInTheDocument()
view.unmount()
@@ -543,6 +545,8 @@ describe('AgentKanbanBoard', () => {
initialView="map"
/>
)
expect(screen.getByRole('button', { name: /Fresh result/ })).toHaveClass('fleet-status-idle')
expect(screen.getByRole('button', { name: /Fresh result/ })).toHaveClass(
'fleet-status-done-seen'
)
})
})
@@ -4,7 +4,7 @@ import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { AgentMap } from './AgentMap'
import { agentMapAttentionMarkerScale } from './AgentMapWorktreeRingNode'
import { agentMapAttentionMarkerScale } from './agent-map-node-presentation'
import type { AgentMapState } from './agent-map-filter'
import { AGENT_MAP_AGENT_RADIUS } from './agent-map-layout'
import { card, installAgentMapEnvironment, NOW, renderMap } from './agent-map-render-test-harness'
@@ -574,7 +574,7 @@ describe('AgentMap', () => {
expect(quietGroup).not.toHaveClass('is-visible')
})
it('shows unseen completions as Done and acknowledged completions as Idle by default', () => {
it('keeps acknowledged completions green and distinct from both unseen and idle', () => {
const seenResult = card({
paneKey: 'seen',
conversationName: 'Seen result',
@@ -595,7 +595,13 @@ describe('AgentMap', () => {
expect(screen.getByRole('button', { name: /Agent alpha/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /New result/ })).toHaveClass('fleet-status-done')
expect(screen.getByRole('button', { name: /Seen result/ })).toHaveClass('fleet-status-idle')
// Not `fleet-status-idle`: acknowledging a finish demotes it, but the work is still
// yours to land, so it must not look like a workspace that never ran.
const seen = screen.getByRole('button', { name: /Seen result/ })
expect(seen).toHaveClass('fleet-status-done-seen')
expect(seen).not.toHaveClass('fleet-status-idle')
expect(seen).not.toHaveClass('fleet-status-done')
expect(seen.querySelector('[data-agent-map-agent-status-glow]')).not.toBeInTheDocument()
})
it('hides the states the toolbar filter has muted', () => {
@@ -11,16 +11,22 @@ describe('AgentMap status glow', () => {
{ bucket: 'working', dotState: 'working', unseen: false, glows: true },
{ bucket: 'attention', dotState: 'waiting', unseen: false, glows: true },
{ bucket: 'attention', dotState: 'blocked', unseen: false, glows: true },
{ bucket: 'done', dotState: 'done', unseen: true, glows: false },
// An unread finish is the state the map exists to surface, so it halos like the
// rest. Acknowledging it drops the halo — that is the seen/unseen difference.
{ bucket: 'done', dotState: 'done', unseen: true, glows: true },
{ bucket: 'done', dotState: 'done', unseen: false, glows: false },
{ bucket: 'idle', dotState: 'idle', unseen: false, glows: false }
] as const)('applies the expected halo for $dotState agents', (state) => {
const { container } = renderMap([card(state)])
const glow = container.querySelector('[data-agent-map-agent-status-glow]')
] as const)(
'applies the expected halo for $dotState agents (unseen: $unseen)',
({ glows, ...state }) => {
const { container } = renderMap([card(state)])
const glow = container.querySelector('[data-agent-map-agent-status-glow]')
if (state.glows) {
expect(glow).toHaveAttribute('data-agent-active-status', state.dotState)
return
if (glows) {
expect(glow).toHaveAttribute('data-agent-active-status', state.dotState)
return
}
expect(glow).not.toBeInTheDocument()
}
expect(glow).not.toBeInTheDocument()
})
)
})
@@ -1,6 +1,6 @@
import { memo, useState, type MutableRefObject } from 'react'
import { Plus } from 'lucide-react'
import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot'
import { AgentStateDot } from '@/components/AgentStateDot'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { translate } from '@/i18n/i18n'
@@ -14,7 +14,13 @@ import type {
AgentMapWorktreeRing
} from './agent-map-layout'
import { AGENT_MAP_LINEAGE_RELATION, shouldAggregateAgentMapWorktree } from './agent-map-layout'
import { agentMapDirectLineageChevronPath } from './agent-map-lineage-chevron-path'
import {
agentMapAttentionMarkerScale,
agentMapStatusLabel,
agentName,
formatDuration,
lineagePath
} from './agent-map-node-presentation'
import { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status'
type AgentMapWorktreeRingNodeProps = {
@@ -40,33 +46,6 @@ type AgentMapWorktreeRingNodeProps = {
onAgentKeyDown: (event: React.KeyboardEvent<SVGGElement>, agent: AgentMapAgentNode) => void
}
function formatDuration(minutes: number): string {
if (minutes < 1) {
return translate('dashboardPopout.card.time.justNow', 'just now')
}
if (minutes < 60) {
return translate('dashboardPopout.card.time.minutes', '{{count}}m', {
count: Math.floor(minutes)
})
}
return translate('dashboardPopout.card.time.hours', '{{count}}h', {
count: Math.floor(minutes / 60)
})
}
function lineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string {
return agentMapDirectLineageChevronPath(parent, child)
}
function agentName(card: DashboardCard): string {
return card.conversationName ?? (card.task.trim() || card.agentType)
}
export function agentMapAttentionMarkerScale(mapScale: number): number {
const inverseScale = 1 / Math.max(mapScale, 0.001)
return Math.max(1, inverseScale ** 0.72, inverseScale * 0.5)
}
function WorktreeDetails({
project,
worktree,
@@ -128,10 +107,13 @@ function WorktreeDetails({
{agentName(agent.card)}
</span>
<span className="block truncate text-[11px] text-muted-foreground">
{agentStateLabel(agent.status)} · {formatDuration(agent.durationMinutes)}
{agentMapStatusLabel(agent.status)} · {formatDuration(agent.durationMinutes)}
</span>
</span>
<AgentStateDot state={agent.status} size="md" />
<AgentStateDot
state={agent.status === 'done-seen' ? 'done' : agent.status}
size="md"
/>
</button>
))
)}
@@ -310,10 +292,13 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
{worktree.agents.map((agent) => {
const iconSize = Math.max(12, Math.min(22, agent.radius * 1.05))
const agentExiting = exiting || agent.motionState === 'exiting'
// `done` here is the unread finish only — `done-seen` demotes to a bare
// emerald ring so the halo keeps meaning "this one is still unread".
const hasStatusGlow =
agent.status === 'working' ||
agent.status === 'waiting' ||
agent.status === 'blocked'
agent.status === 'blocked' ||
agent.status === 'done'
return (
<g
key={agent.card.paneKey}
@@ -330,7 +315,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)}, ${agentStateLabel(agent.status)}${agent.card.unseen ? ', unread' : ''}, ${formatDuration(agent.durationMinutes)}, ${worktree.name}, ${project.name}`}
aria-label={`${agentName(agent.card)}, ${agentMapStatusLabel(agent.status)}${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) => {
@@ -356,6 +341,16 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
aria-hidden="true"
/>
) : null}
{/* One-shot ripple at the moment of finishing. Mounted only inside the
flare window so animated paint stays off the resting fleet. */}
{agent.finishedRecently && !agentExiting ? (
<circle
className="agent-map-agent-finish-flare"
data-agent-map-agent-finish-flare=""
r={agent.radius + 1}
aria-hidden="true"
/>
) : null}
<circle className="agent-map-agent-hit" r={Math.max(10, agent.radius + 3)} />
<circle className="agent-map-agent-mark" r={agent.radius} />
<foreignObject
@@ -1,5 +1,10 @@
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata'
import {
agentMapDurationMinutes,
agentMapNodeStatus,
isAgentMapRecentFinish,
type AgentMapNodeStatus
} from './agent-map-node-metadata'
const GOLDEN_ANGLE = 2.399963229728653
@@ -30,7 +35,8 @@ export function placeAgentMapAgents({
y: number
radius: number
durationMinutes: number
status: DashboardCard['dotState']
status: AgentMapNodeStatus
finishedRecently: boolean
}[] {
const availableRadius = Math.max(0, radius - agentRadius - 6)
const sorted = [...cards].sort((a, b) =>
@@ -48,7 +54,8 @@ export function placeAgentMapAgents({
y: Math.sin(angle) * orbit,
radius: agentRadius,
durationMinutes: agentMapDurationMinutes(card, now),
status: agentMapNodeStatus(card)
status: agentMapNodeStatus(card),
finishedRecently: isAgentMapRecentFinish(card)
}
})
}
@@ -28,9 +28,11 @@ function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
}
describe('agent map filtering', () => {
it('keeps unseen completions done and settles acknowledged completions into idle', () => {
it('files both unseen and acknowledged completions under done, never idle', () => {
expect(agentMapState(card({ unseen: true }))).toBe('done')
expect(agentMapState(card({ unseen: false }))).toBe('idle')
// Why not idle: an acknowledged finish still paints emerald, so hiding "idle"
// must not blank it out. Only a card that never finished is idle.
expect(agentMapState(card({ unseen: false }))).toBe('done')
expect(agentMapState(card({ bucket: 'idle', dotState: 'idle' }))).toBe('idle')
})
@@ -64,13 +66,14 @@ describe('agent map filtering', () => {
card({ paneKey: 'done-new', unseen: true }),
card({ paneKey: 'done-seen', unseen: false }),
card({ paneKey: 'working', bucket: 'working', dotState: 'working', finishedAt: null }),
card({ paneKey: 'waiting', bucket: 'attention', dotState: 'waiting', finishedAt: null })
card({ paneKey: 'waiting', bucket: 'attention', dotState: 'waiting', finishedAt: null }),
card({ paneKey: 'idle', bucket: 'idle', dotState: 'idle', finishedAt: null })
]
expect(countAgentMapCards(cards)).toEqual({
attention: 1,
working: 1,
done: 1,
done: 2,
idle: 1
})
})
@@ -1,18 +1,20 @@
import {
dashboardCardDisplayState,
type DashboardCard,
type DashboardCardHostKind
} from '../../../../shared/dashboard-snapshot'
import type { DashboardCard, DashboardCardHostKind } from '../../../../shared/dashboard-snapshot'
import { agentMapNodeStatus } from './agent-map-node-metadata'
export type AgentMapState = 'attention' | 'working' | 'done' | 'idle'
export type AgentMapHostFilter = 'all' | DashboardCardHostKind
export type AgentMapCounts = Record<AgentMapState, number>
export function agentMapState(card: DashboardCard): AgentMapState {
const state = dashboardCardDisplayState(card)
const state = agentMapNodeStatus(card)
if (state === 'blocked' || state === 'waiting') {
return 'attention'
}
// Why: an acknowledged finish still paints emerald, so it has to answer the Done
// chip. Filtering it as idle would let "hide idle" blank out visibly green nodes.
if (state === 'done-seen') {
return 'done'
}
return state
}
@@ -32,9 +32,49 @@ describe('Agent Map glow performance boundary', () => {
expect(rule).toContain('pointer-events: none')
expect(rule).toContain('vector-effect: non-scaling-stroke')
}
expect(glowRules).toHaveLength(8)
// 2 base + 4 agent statuses + 4 worktree statuses.
expect(glowRules).toHaveLength(10)
for (const rule of glowRules ?? []) {
expect(rule).not.toMatch(/filter:|animation:|transition:/)
// Keyframes and filters stay banned outright: they repaint the halo continuously,
// and the halo is drawn once per active node across the whole fleet.
expect(rule).not.toMatch(/filter:|animation:/)
// A transition is allowed, but only to cross-fade the status colour. Anything that
// animates geometry (width, transform, opacity) would repaint on every frame of a
// change for every active node, which is what this boundary exists to prevent.
const transition = rule.match(/transition:([^;]+);/)?.[1]
if (transition !== undefined) {
expect(transition.trim()).toMatch(/^stroke \d+ms ease$/)
}
}
})
it('confines the finish flare to nodes inside the recency window', () => {
const component = source('AgentMapWorktreeRingNode.tsx')
const metadata = source('agent-map-node-metadata.ts')
// The flare is the one animated element on an agent node, so it must stay gated on
// `finishedRecently` rather than on the status — otherwise every done agent on the
// map animates forever and the glow budget above is meaningless.
expect(component.match(/data-agent-map-agent-finish-flare/g)).toHaveLength(1)
expect(component).toMatch(/agent\.finishedRecently\s*&&/)
expect(component).not.toMatch(/<filter|filter=/)
const css = source('agent-map.css')
const flareRule = css.match(/\.agent-map-agent-finish-flare\s*\{[^}]+\}/s)?.[0] ?? ''
expect(flareRule).toContain('pointer-events: none')
expect(flareRule).toContain('vector-effect: non-scaling-stroke')
// Transform and opacity only — no filter or layout-affecting paint work.
expect(flareRule).not.toMatch(/filter:/)
expect(css).toMatch(/@keyframes agent-map-finish-flare/)
// The mount window and the CSS duration are declared in different languages and
// drift silently: too short a window unmounts the element mid-ripple, too long
// leaves an invisible node animating. Pin them to the same number.
const windowMs = Number(
metadata.match(/AGENT_MAP_FINISH_FLARE_MS = ([\d_]+)/)?.[1].replaceAll('_', '')
)
const cssMs = Number(flareRule.match(/animation: agent-map-finish-flare (\d+)ms/)?.[1])
expect(windowMs).toBeGreaterThan(0)
expect(cssMs).toBe(windowMs)
})
})
@@ -1,14 +1,14 @@
import { describe, expect, it } from 'vitest'
import { selectVisibleAgentMapLabels } from './agent-map-label-declutter'
import type {
AgentMapLayout,
AgentMapProjectRing,
AgentMapStatusCounts,
AgentMapWorktreeRing
} from './agent-map-layout'
import type { AgentMapLayout, AgentMapProjectRing, AgentMapWorktreeRing } from './agent-map-layout'
import {
agentMapQuietCount,
emptyAgentMapStatusCounts,
type AgentMapStatusCounts
} from './agent-map-node-metadata'
function statusCounts(overrides: Partial<AgentMapStatusCounts> = {}): AgentMapStatusCounts {
return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0, ...overrides }
return { ...emptyAgentMapStatusCounts(), ...overrides }
}
function worktree(overrides: Partial<AgentMapWorktreeRing> = {}): AgentMapWorktreeRing {
@@ -26,7 +26,7 @@ function worktree(overrides: Partial<AgentMapWorktreeRing> = {}): AgentMapWorktr
// Sparse placeholders keep tests that only care about label-to-label collisions concise.
agents: Array.from({ length: total }) as AgentMapWorktreeRing['agents'],
statusCounts: counts,
quiet: counts.idle === total,
quiet: agentMapQuietCount(counts) === total,
...overrides
}
}
@@ -1,10 +1,12 @@
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type { AgentMapLayout, AgentMapStatusCounts } from './agent-map-layout'
import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata'
function emptyStatusCounts(): AgentMapStatusCounts {
return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0 }
}
import type { AgentMapLayout } from './agent-map-layout'
import {
agentMapDurationMinutes,
agentMapNodeStatus,
agentMapQuietCount,
emptyAgentMapStatusCounts,
isAgentMapRecentFinish
} from './agent-map-node-metadata'
export function refreshAgentMapMetadata(
geometry: AgentMapLayout,
@@ -18,7 +20,7 @@ export function refreshAgentMapMetadata(
const worktrees = project.worktrees.map((worktree) => {
let worktreeName = worktree.name
let workspaceKind = worktree.workspaceKind
const statusCounts = emptyStatusCounts()
const statusCounts = emptyAgentMapStatusCounts()
const agents = worktree.agents.flatMap((agent) => {
const card = cardsByPaneKey.get(agent.card.paneKey)
if (!card) {
@@ -34,7 +36,8 @@ export function refreshAgentMapMetadata(
...agent,
card,
durationMinutes: agentMapDurationMinutes(card, now),
status: agentMapNodeStatus(card)
status: agentMapNodeStatus(card),
finishedRecently: isAgentMapRecentFinish(card)
}
]
})
@@ -44,7 +47,7 @@ export function refreshAgentMapMetadata(
workspaceKind,
agents,
statusCounts,
quiet: statusCounts.idle === agents.length
quiet: agentMapQuietCount(statusCounts) === agents.length
}
})
return { ...project, name: projectName, worktrees, agentCount }
@@ -496,12 +496,12 @@ describe('agent map layout', () => {
expect(agentMapDurationMinutes(finished, NOW)).toBe(10)
})
it('maps acknowledged completions to idle independently from elapsed time', () => {
it('maps acknowledged completions to done-seen independently from elapsed time', () => {
for (const dotState of ['working', 'blocked', 'waiting', 'idle'] as const) {
expect(agentMapNodeStatus(card({ dotState }))).toBe(dotState)
}
expect(agentMapNodeStatus(card({ dotState: 'done', unseen: true }))).toBe('done')
expect(agentMapNodeStatus(card({ dotState: 'done', unseen: false }))).toBe('idle')
expect(agentMapNodeStatus(card({ dotState: 'done', unseen: false }))).toBe('done-seen')
const shortBlocked = card({ dotState: 'blocked', startedAt: NOW - 60_000 })
const longBlocked = card({ dotState: 'blocked', startedAt: NOW - 45 * 60_000 })
expect(agentMapNodeStatus(shortBlocked)).toBe(agentMapNodeStatus(longBlocked))
@@ -2,7 +2,15 @@ import type * as DashboardSnapshotTypes from '../../../../shared/dashboard-snaps
import { placeAgentMapAgents } from './agent-map-agent-placement'
import { layoutAgentMapLineage } from './agent-map-lineage-layout'
import { refreshAgentMapMetadata } from './agent-map-layout-metadata'
import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata'
import {
agentMapDurationMinutes,
agentMapNodeStatus,
agentMapQuietCount,
emptyAgentMapStatusCounts,
isAgentMapRecentFinish,
type AgentMapNodeStatus,
type AgentMapStatusCounts
} from './agent-map-node-metadata'
import { placeAgentMapProjects } from './agent-map-project-placement'
import { selectAgentMapSpawnParentContainer } from './agent-map-spawn-clustering'
import {
@@ -15,7 +23,6 @@ import {
import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout'
type DashboardCard = DashboardSnapshotTypes.DashboardCard
type DashboardCardDotState = DashboardSnapshotTypes.DashboardCardDotState
type DashboardWorkspace = DashboardSnapshotTypes.DashboardWorkspace
export { AGENT_MAP_WORKTREE_GAP } from './agent-map-worktree-packing'
@@ -39,15 +46,15 @@ const PROJECT_PADDING = 12
const WORLD_MARGIN = 32
const RING_CONTENT_OFFSET = AGENT_MAP_RING_HEADER_HEIGHT / 2
export type AgentMapStatusCounts = Record<DashboardCardDotState, number>
export type AgentMapAgentNode = {
card: DashboardCard
x: number
y: number
radius: number
durationMinutes: number
status: DashboardCardDotState
status: AgentMapNodeStatus
/** True only inside the flare window after a finish; drives the one-shot emphasis. */
finishedRecently: boolean
motionState?: AgentMapMotionState
}
@@ -130,10 +137,6 @@ export function shouldAggregateAgentMapWorktree(
)
}
function emptyStatusCounts(): AgentMapStatusCounts {
return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0 }
}
function worktreeRadius(agentCount: number): number {
return Math.max(
52,
@@ -150,7 +153,7 @@ function buildLocalWorktree(
const lineageLayout = layoutAgentMapLineage(cards, AGENT_MAP_AGENT_RADIUS)
const contentRadius = lineageLayout?.radius ?? worktreeRadius(cards.length)
const radius = contentRadius + RING_CONTENT_OFFSET
const statusCounts = emptyStatusCounts()
const statusCounts = emptyAgentMapStatusCounts()
for (const card of cards) {
statusCounts[agentMapNodeStatus(card)] += 1
}
@@ -175,7 +178,8 @@ function buildLocalWorktree(
y,
radius: AGENT_MAP_AGENT_RADIUS,
durationMinutes: agentMapDurationMinutes(card, now),
status: agentMapNodeStatus(card)
status: agentMapNodeStatus(card),
finishedRecently: isAgentMapRecentFinish(card)
})) ??
placeAgentMapAgents({
worktreeId: id,
@@ -186,7 +190,7 @@ function buildLocalWorktree(
})
).map((agent) => ({ ...agent, y: agent.y + RING_CONTENT_OFFSET })),
statusCounts,
quiet: statusCounts.idle === cards.length
quiet: agentMapQuietCount(statusCounts) === cards.length
}
}
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import {
AGENT_MAP_FINISH_FLARE_MS,
agentMapNodeStatus,
agentMapQuietCount,
emptyAgentMapStatusCounts,
isAgentMapRecentFinish
} from './agent-map-node-metadata'
const NOW = 2_000_000_000
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: 'pty-1',
agentType: 'codex',
bucket: 'done',
dotState: 'done',
task: '',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Agent map',
startedAt: NOW - 60_000,
finishedAt: NOW - 30_000,
stateChangedAt: NOW - 30_000,
unseen: false,
hostKind: 'local',
...overrides
}
}
describe('agentMapNodeStatus', () => {
it('splits a finish by whether it has been acknowledged', () => {
expect(agentMapNodeStatus(card({ unseen: true }))).toBe('done')
expect(agentMapNodeStatus(card({ unseen: false }))).toBe('done-seen')
})
it('never collapses an acknowledged finish into idle', () => {
// The shared `dashboardCardDisplayState` does exactly that for bucket counts, which
// would make finished-but-unlanded work indistinguishable from a workspace that
// never ran. The map keeps them apart.
expect(agentMapNodeStatus(card({ unseen: false }))).not.toBe('idle')
expect(agentMapNodeStatus(card({ bucket: 'idle', dotState: 'idle', finishedAt: null }))).toBe(
'idle'
)
})
it('leaves every non-done state on the shared display state', () => {
for (const dotState of ['working', 'blocked', 'waiting', 'idle'] as const) {
expect(agentMapNodeStatus(card({ dotState, unseen: true }))).toBe(dotState)
expect(agentMapNodeStatus(card({ dotState, unseen: false }))).toBe(dotState)
}
})
})
describe('isAgentMapRecentFinish', () => {
afterEach(() => {
vi.useRealTimers()
})
it('flares on the transition into done, measured against the wall clock', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
const justFinished = card({ dotState: 'done', unseen: true, stateChangedAt: NOW })
expect(isAgentMapRecentFinish(justFinished)).toBe(true)
vi.setSystemTime(NOW + AGENT_MAP_FINISH_FLARE_MS - 1)
expect(isAgentMapRecentFinish(justFinished)).toBe(true)
vi.setSystemTime(NOW + AGENT_MAP_FINISH_FLARE_MS + 1)
expect(isAgentMapRecentFinish(justFinished)).toBe(false)
})
it('ignores the map now prop, which only ticks every 30s', () => {
// The regression this guards: gating the flare on the `now` passed through the layout
// made it sample a 1s window against a 30s clock, so it fired at random moments
// instead of on the transition. The predicate must not take `now` at all.
expect(isAgentMapRecentFinish).toHaveLength(1)
})
it('does not flare work that finished before this session', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
expect(
isAgentMapRecentFinish(card({ dotState: 'done', unseen: true, stateChangedAt: NOW - 60_000 }))
).toBe(false)
// A clock skew that puts the finish in the future must not latch a flare on forever.
expect(
isAgentMapRecentFinish(card({ dotState: 'done', unseen: true, stateChangedAt: NOW + 5_000 }))
).toBe(false)
})
it('never flares a state that is not an unread finish', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
expect(
isAgentMapRecentFinish(card({ dotState: 'done', unseen: false, stateChangedAt: NOW }))
).toBe(false)
expect(
isAgentMapRecentFinish(card({ dotState: 'working', unseen: true, stateChangedAt: NOW }))
).toBe(false)
})
})
describe('agentMapQuietCount', () => {
it('treats an acknowledged finish as quiet so label declutter is unchanged', () => {
expect(agentMapQuietCount({ ...emptyAgentMapStatusCounts(), 'done-seen': 3, idle: 2 })).toBe(5)
})
it('keeps an unread finish loud', () => {
expect(agentMapQuietCount({ ...emptyAgentMapStatusCounts(), done: 4 })).toBe(0)
expect(agentMapQuietCount({ ...emptyAgentMapStatusCounts(), working: 4 })).toBe(0)
})
})
@@ -4,6 +4,12 @@ import {
type DashboardCardDotState
} from '../../../../shared/dashboard-snapshot'
/** Map-only refinement of the shared dot state. `dashboardCardDisplayState` folds an
* acknowledged finish into `idle`, which is right for bucket counts but loses the one
* distinction the map exists to show: finished-and-unread vs finished-and-still-yours.
* Kept local so `DashboardCardDotState` — which crosses the pop-out bridge — is unchanged. */
export type AgentMapNodeStatus = DashboardCardDotState | 'done-seen'
export function agentMapDurationMinutes(card: DashboardCard, now: number): number {
if (!Number.isFinite(card.startedAt) || card.startedAt <= 0) {
return 0
@@ -12,6 +18,49 @@ export function agentMapDurationMinutes(card: DashboardCard, now: number): numbe
return Math.max(0, (end - card.startedAt) / 60_000)
}
export function agentMapNodeStatus(card: DashboardCard): DashboardCardDotState {
export function agentMapNodeStatus(card: DashboardCard): AgentMapNodeStatus {
if (card.dotState === 'done') {
return card.unseen ? 'done' : 'done-seen'
}
return dashboardCardDisplayState(card)
}
/** How long a fresh finish keeps its one-shot flare. Long enough to catch the eye from
* across the map, short enough that a busy fleet is never permanently animating.
* Must stay in step with the `agent-map-finish-flare` duration in `agent-map.css`, or
* the element unmounts mid-ripple; the glow performance test asserts the two match. */
export const AGENT_MAP_FINISH_FLARE_MS = 1_400
/** Gates the flare element so only the handful of nodes that just changed carry animated
* paint work — the rest of the fleet stays static, per the glow performance boundary.
*
* Deliberately reads the wall clock instead of the map's `now` prop: `now` ticks once
* every 30s to refresh relative timestamps, so measuring a 1s window against it fires at
* random moments rather than on the transition. The map re-renders when the card's state
* changes, so the wall clock is ~0ms past `stateChangedAt` exactly when it matters. */
export function isAgentMapRecentFinish(card: DashboardCard): boolean {
if (agentMapNodeStatus(card) !== 'done') {
return false
}
const changedAt = card.stateChangedAt || card.finishedAt || 0
if (changedAt <= 0) {
return false
}
const elapsed = Date.now() - changedAt
// A fleet that loads with finished work already on it must not flare all at once, so a
// finish that happened before this render window is never treated as fresh.
return elapsed >= 0 && elapsed < AGENT_MAP_FINISH_FLARE_MS
}
export type AgentMapStatusCounts = Record<AgentMapNodeStatus, number>
export function emptyAgentMapStatusCounts(): AgentMapStatusCounts {
return { working: 0, blocked: 0, waiting: 0, done: 0, 'done-seen': 0, idle: 0 }
}
/** Finished work you have already opened is still yours to land, but it is not asking for
* attention. Counting it as quiet keeps ring aggregation and label declutter behaving
* exactly as they did when an acknowledged finish rendered as plain idle. */
export function agentMapQuietCount(counts: AgentMapStatusCounts): number {
return counts.idle + counts['done-seen']
}
@@ -0,0 +1,42 @@
import { agentStateLabel } 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' ? 'Done, seen' : agentStateLabel(status)
}
export function formatDuration(minutes: number): string {
if (minutes < 1) {
return translate('dashboardPopout.card.time.justNow', 'just now')
}
if (minutes < 60) {
return translate('dashboardPopout.card.time.minutes', '{{count}}m', {
count: Math.floor(minutes)
})
}
return translate('dashboardPopout.card.time.hours', '{{count}}h', {
count: Math.floor(minutes / 60)
})
}
export function lineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string {
return agentMapDirectLineageChevronPath(parent, child)
}
export function agentName(card: DashboardCard): string {
return card.conversationName ?? (card.task.trim() || card.agentType)
}
export function agentMapAttentionMarkerScale(mapScale: number): number {
const inverseScale = 1 / Math.max(mapScale, 0.001)
return Math.max(1, inverseScale ** 0.72, inverseScale * 0.5)
}
@@ -1,12 +1,27 @@
import { describe, expect, it } from 'vitest'
import type { AgentMapStatusCounts } from './agent-map-layout'
import { emptyAgentMapStatusCounts, type AgentMapStatusCounts } from './agent-map-node-metadata'
import { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status'
function counts(overrides: Partial<AgentMapStatusCounts> = {}): AgentMapStatusCounts {
return { blocked: 0, waiting: 0, working: 0, done: 0, idle: 0, ...overrides }
return { ...emptyAgentMapStatusCounts(), ...overrides }
}
describe('agentMapWorktreeActiveStatus', () => {
it('turns the ring green only once the whole workspace has settled', () => {
expect(agentMapWorktreeActiveStatus(counts({ done: 1 }))).toBe('done')
// Anything still running outranks a finished sibling — the workspace is still working.
expect(agentMapWorktreeActiveStatus(counts({ done: 1, working: 1 }))).toBe('working')
expect(agentMapWorktreeActiveStatus(counts({ done: 1, waiting: 1 }))).toBe('waiting')
expect(agentMapWorktreeActiveStatus(counts({ done: 1, blocked: 1 }))).toBe('blocked')
})
it('leaves the ring unlit for acknowledged finishes and idle workspaces', () => {
// Acknowledging is what releases the attention, exactly as at the node level.
expect(agentMapWorktreeActiveStatus(counts({ 'done-seen': 3 }))).toBeNull()
expect(agentMapWorktreeActiveStatus(counts({ idle: 2 }))).toBeNull()
expect(agentMapWorktreeActiveStatus(counts())).toBeNull()
})
it('prioritizes attention over working', () => {
expect(agentMapWorktreeActiveStatus(counts({ working: 2, waiting: 1 }))).toBe('waiting')
expect(agentMapWorktreeActiveStatus(counts({ working: 2, waiting: 1, blocked: 1 }))).toBe(
@@ -16,6 +31,8 @@ describe('agentMapWorktreeActiveStatus', () => {
it('uses working only when no agent needs attention', () => {
expect(agentMapWorktreeActiveStatus(counts({ working: 1, done: 2 }))).toBe('working')
expect(agentMapWorktreeActiveStatus(counts({ done: 2, idle: 1 }))).toBeNull()
// Was null before unread finishes lit the ring; idle siblings do not mute a finish.
expect(agentMapWorktreeActiveStatus(counts({ done: 2, idle: 1 }))).toBe('done')
expect(agentMapWorktreeActiveStatus(counts({ 'done-seen': 2, idle: 1 }))).toBeNull()
})
})
@@ -1,7 +1,14 @@
import type { AgentMapStatusCounts } from './agent-map-layout'
import type { AgentMapStatusCounts } from './agent-map-node-metadata'
export type AgentMapWorktreeActiveStatus = 'blocked' | 'waiting' | 'working'
export type AgentMapWorktreeActiveStatus = 'blocked' | 'waiting' | 'working' | 'done'
/**
* Most urgent first. `done` ranks last on purpose: a workspace with anything still
* running is a working workspace, even if a sibling agent already finished — the ring
* only turns green once the whole workspace has settled and a finish is still unread.
* `done-seen` never lights the ring, matching the node treatment where acknowledging a
* finish is what releases the attention.
*/
export function agentMapWorktreeActiveStatus(
counts: AgentMapStatusCounts
): AgentMapWorktreeActiveStatus | null {
@@ -11,5 +18,8 @@ export function agentMapWorktreeActiveStatus(
if (counts.waiting > 0) {
return 'waiting'
}
return counts.working > 0 ? 'working' : null
if (counts.working > 0) {
return 'working'
}
return counts.done > 0 ? 'done' : null
}
@@ -134,9 +134,9 @@
transform-box: fill-box;
transform-origin: center;
transition:
fill 160ms ease,
stroke 160ms ease,
stroke-width 160ms ease,
fill 260ms ease,
stroke 260ms ease,
stroke-width 260ms ease,
transform 210ms cubic-bezier(0.2, 1.35, 0.4, 1);
vector-effect: non-scaling-stroke;
}
@@ -145,6 +145,9 @@
fill: none;
pointer-events: none;
stroke-width: 9;
/* Colour only, so a status change cross-fades instead of snapping. Deliberately
narrow: no filter, no keyframes, nothing that repaints while at rest. */
transition: stroke 260ms ease;
vector-effect: non-scaling-stroke;
}
@@ -160,6 +163,10 @@
stroke: color-mix(in srgb, var(--color-yellow-500) 28%, transparent);
}
.agent-map-worktree-status-glow.fleet-status-done {
stroke: color-mix(in srgb, var(--color-emerald-500) 34%, transparent);
}
:where(
.agent-map-worktree-group:hover,
.agent-map-worktree-group:focus-within,
@@ -202,6 +209,11 @@
stroke: var(--color-red-500);
}
/* Whole workspace has settled and something in it is still unread. */
.agent-map-worktree-ring.is-done {
stroke: var(--color-emerald-500);
}
.agent-map-worktree-label {
font-size: 12px;
font-weight: 500;
@@ -317,13 +329,47 @@
.agent-map-agent-mark {
fill: var(--background);
stroke-width: 1.5;
/* State changes are discrete but the eye reads a swap as a glitch; ease the colour
across instead so working -> done -> seen looks like one node changing. */
transition:
fill 300ms ease,
stroke 300ms ease,
stroke-width 300ms ease;
vector-effect: non-scaling-stroke;
}
.agent-map-agent-finish-flare {
fill: none;
pointer-events: none;
stroke: var(--color-emerald-500);
stroke-width: 2;
transform-box: fill-box;
transform-origin: center;
vector-effect: non-scaling-stroke;
/* Keep in step with AGENT_MAP_FINISH_FLARE_MS, which gates how long the element stays
mounted. The performance test asserts the two agree. */
animation: agent-map-finish-flare 1400ms cubic-bezier(0.25, 0.5, 0.25, 1) both;
}
@keyframes agent-map-finish-flare {
0% {
opacity: 0.9;
transform: scale(0.62);
}
100% {
opacity: 0;
transform: scale(2.5);
}
}
.agent-map-agent-status-glow {
fill: none;
pointer-events: none;
stroke-width: 7;
/* Colour only, so a status change cross-fades instead of snapping. Deliberately
narrow: no filter, no keyframes, nothing that repaints while at rest. */
transition: stroke 260ms ease;
vector-effect: non-scaling-stroke;
}
@@ -339,6 +385,12 @@
stroke: color-mix(in srgb, var(--color-red-500) 38%, transparent);
}
/* Unread finishes only. `fleet-status-done-seen` gets no glow — that is the whole
difference between "you have not looked at this" and "you have". */
.agent-map-agent-status-glow.fleet-status-done {
stroke: color-mix(in srgb, var(--color-emerald-500) 46%, transparent);
}
.agent-map-agent-icon {
overflow: visible;
pointer-events: none;
@@ -381,8 +433,17 @@
stroke: var(--color-amber-500);
}
/* Unread: filled core. Fill survives zoom-out further than a halo does, and it is the
one channel still legible once the node is a few pixels wide. */
.fleet-status-done .agent-map-agent-mark {
fill: color-mix(in srgb, var(--color-emerald-500) 38%, var(--background));
stroke: var(--color-emerald-500);
stroke-width: 2;
}
/* Seen: hollow, and still unmistakably green — you read it, you have not landed it. */
.fleet-status-done-seen .agent-map-agent-mark {
stroke: color-mix(in srgb, var(--color-emerald-500) 62%, transparent);
}
.fleet-status-idle .agent-map-agent-mark {
@@ -490,12 +551,19 @@
.agent-map-worktree-label-group,
.agent-map-agent-node,
.agent-map-agent-visual,
.agent-map-agent-mark,
.agent-map-agent-finish-flare,
.agent-map-lineage-link,
.agent-map-worktree-lineage-link {
animation: none;
transition: none;
}
/* No flare without motion — the halo and filled core already carry the state. */
.agent-map-agent-finish-flare {
display: none;
}
:where(
.agent-map-project-node:hover,
.agent-map-project-node:focus-within,