fix(agent-map): bound completion paint work

This commit is contained in:
Brennan Benson
2026-08-13 00:45:49 -07:00
parent f1ffb1b2d2
commit 59a5101464
10 changed files with 93 additions and 59 deletions
@@ -162,6 +162,20 @@ describe('AgentMap', () => {
expect(ring).not.toHaveClass('is-open')
})
it('counts acknowledged completions as done in worktree details', () => {
renderMap([
card({
bucket: 'idle',
dotState: 'done',
unseen: false,
finishedAt: NOW - 60_000
})
])
fireEvent.click(screen.getByRole('button', { name: 'Open Agent map worktree details' }))
expect(screen.getByText('1 agent · 0 active · 1 done')).toBeInTheDocument()
})
it('starts a new agent from the worktree details picker', () => {
const onSpawnAgent = vi.fn()
renderMap([card()], {
@@ -11,6 +11,7 @@ import type {
AgentMapWorktreeRing
} from './agent-map-layout'
import { AGENT_MAP_LINEAGE_RELATION, shouldAggregateAgentMapWorktree } from './agent-map-layout'
import { selectAgentMapRecentFinishPaneKeys } from './agent-map-node-metadata'
import { selectVisibleAgentMapLabels } from './agent-map-label-declutter'
import { agentMapDirectLineageChevronPath } from './agent-map-lineage-chevron-path'
import { AgentMapWorktreeLabel } from './AgentMapWorktreeLabel'
@@ -124,6 +125,15 @@ export const AgentMapScene = memo(function AgentMapScene({
}
return agents
}, [allowAggregation, layout, selectedPaneKey, zoom])
const recentFinishPaneKeys = useMemo(
() =>
selectAgentMapRecentFinishPaneKeys(
layout.projects.flatMap((project) =>
project.worktrees.flatMap((worktree) => worktree.agents.map((agent) => agent.card))
)
),
[layout]
)
return (
<>
{layout.projects.map((project) => {
@@ -210,6 +220,7 @@ export const AgentMapScene = memo(function AgentMapScene({
selectedPaneKey={selectedPaneKey}
allowAggregation={allowAggregation}
showOrchestrationLinks={showOrchestrationLinks}
recentFinishPaneKeys={recentFinishPaneKeys}
launchableAgents={launchableAgentsByWorktreeId?.[worktree.worktreeId]}
nodeRefs={nodeRefs}
onSelectAgent={onSelectAgent}
@@ -33,6 +33,7 @@ type AgentMapWorktreeRingNodeProps = {
selectedPaneKey: string | null
allowAggregation: boolean
showOrchestrationLinks: boolean
recentFinishPaneKeys: ReadonlySet<string>
launchableAgents?: readonly TuiAgent[]
nodeRefs: MutableRefObject<Map<string, SVGGElement>>
onSelectAgent: (card: DashboardCard) => void
@@ -61,6 +62,7 @@ function WorktreeDetails({
}): React.JSX.Element {
const activeCount =
worktree.statusCounts.working + worktree.statusCounts.blocked + worktree.statusCounts.waiting
const doneCount = worktree.statusCounts.done + worktree.statusCounts['done-seen']
return (
<PopoverContent align="center" sideOffset={10} className="w-80 p-0">
<header className="border-b border-border px-3 py-2.5">
@@ -76,7 +78,7 @@ function WorktreeDetails({
defaultValue_other: '{{total}} agents · {{active}} active · {{done}} done',
total: worktree.agents.length,
active: activeCount,
done: worktree.statusCounts.done
done: doneCount
}
)}
</span>
@@ -164,6 +166,7 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
selectedPaneKey,
allowAggregation,
showOrchestrationLinks,
recentFinishPaneKeys,
launchableAgents,
nodeRefs,
onSelectAgent,
@@ -343,7 +346,7 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
) : 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 ? (
{recentFinishPaneKeys.has(agent.card.paneKey) && !agentExiting ? (
<circle
className="agent-map-agent-finish-flare"
data-agent-map-agent-finish-flare=""
@@ -2,7 +2,6 @@ import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import {
agentMapDurationMinutes,
agentMapNodeStatus,
isAgentMapRecentFinish,
type AgentMapNodeStatus
} from './agent-map-node-metadata'
@@ -36,7 +35,6 @@ export function placeAgentMapAgents({
radius: number
durationMinutes: number
status: AgentMapNodeStatus
finishedRecently: boolean
}[] {
const availableRadius = Math.max(0, radius - agentRadius - 6)
const sorted = [...cards].sort((a, b) =>
@@ -54,8 +52,7 @@ export function placeAgentMapAgents({
y: Math.sin(angle) * orbit,
radius: agentRadius,
durationMinutes: agentMapDurationMinutes(card, now),
status: agentMapNodeStatus(card),
finishedRecently: isAgentMapRecentFinish(card)
status: agentMapNodeStatus(card)
}
})
}
@@ -35,17 +35,11 @@ describe('Agent Map glow performance boundary', () => {
// 2 base + 4 agent statuses + 4 worktree statuses.
expect(glowRules).toHaveLength(10)
for (const rule of glowRules ?? []) {
// 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$/)
}
expect(rule).not.toMatch(/filter:|animation:|transition:/)
}
const markRule = css.match(/\.agent-map-agent-mark\s*\{[^}]+\}/s)?.[0]
expect(markRule).not.toMatch(/filter:|animation:|transition:/)
})
it('confines the finish flare to nodes inside the recency window', () => {
@@ -53,10 +47,9 @@ describe('Agent Map glow performance boundary', () => {
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.
// the globally capped recent-finish set rather than on status alone.
expect(component.match(/data-agent-map-agent-finish-flare/g)).toHaveLength(1)
expect(component).toMatch(/agent\.finishedRecently\s*&&/)
expect(component).toMatch(/recentFinishPaneKeys\.has\(agent\.card\.paneKey\)\s*&&/)
expect(component).not.toMatch(/<filter|filter=/)
const css = source('agent-map.css')
@@ -4,8 +4,7 @@ import {
agentMapDurationMinutes,
agentMapNodeStatus,
agentMapQuietCount,
emptyAgentMapStatusCounts,
isAgentMapRecentFinish
emptyAgentMapStatusCounts
} from './agent-map-node-metadata'
export function refreshAgentMapMetadata(
@@ -36,8 +35,7 @@ export function refreshAgentMapMetadata(
...agent,
card,
durationMinutes: agentMapDurationMinutes(card, now),
status: agentMapNodeStatus(card),
finishedRecently: isAgentMapRecentFinish(card)
status: agentMapNodeStatus(card)
}
]
})
@@ -7,7 +7,6 @@ import {
agentMapNodeStatus,
agentMapQuietCount,
emptyAgentMapStatusCounts,
isAgentMapRecentFinish,
type AgentMapNodeStatus,
type AgentMapStatusCounts
} from './agent-map-node-metadata'
@@ -53,8 +52,6 @@ export type AgentMapAgentNode = {
radius: number
durationMinutes: number
status: AgentMapNodeStatus
/** True only inside the flare window after a finish; drives the one-shot emphasis. */
finishedRecently: boolean
motionState?: AgentMapMotionState
}
@@ -178,8 +175,7 @@ function buildLocalWorktree(
y,
radius: AGENT_MAP_AGENT_RADIUS,
durationMinutes: agentMapDurationMinutes(card, now),
status: agentMapNodeStatus(card),
finishedRecently: isAgentMapRecentFinish(card)
status: agentMapNodeStatus(card)
})) ??
placeAgentMapAgents({
worktreeId: id,
@@ -2,10 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import {
AGENT_MAP_FINISH_FLARE_MS,
AGENT_MAP_MAX_CONCURRENT_FINISH_FLARES,
agentMapNodeStatus,
agentMapQuietCount,
emptyAgentMapStatusCounts,
isAgentMapRecentFinish
isAgentMapRecentFinish,
selectAgentMapRecentFinishPaneKeys
} from './agent-map-node-metadata'
const NOW = 2_000_000_000
@@ -60,6 +62,7 @@ describe('agentMapNodeStatus', () => {
describe('isAgentMapRecentFinish', () => {
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('flares on the transition into done, measured against the wall clock', () => {
@@ -74,11 +77,17 @@ describe('isAgentMapRecentFinish', () => {
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('samples the wall clock once and caps bursty fleet updates', () => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(NOW)
const selected = selectAgentMapRecentFinishPaneKeys(
Array.from({ length: 200 }, (_, index) =>
card({ paneKey: `pane-${index}`, unseen: true, stateChangedAt: NOW - index })
)
)
expect(clock).toHaveBeenCalledOnce()
expect(selected.size).toBe(AGENT_MAP_MAX_CONCURRENT_FINISH_FLARES)
expect([...selected]).toEqual(['pane-0', 'pane-1', 'pane-2', 'pane-3'])
})
it('does not flare work that finished before this session', () => {
@@ -30,28 +30,53 @@ export function agentMapNodeStatus(card: DashboardCard): AgentMapNodeStatus {
* 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
// Static completion emphasis remains uncapped; this bounds animated SVG paint only.
export const AGENT_MAP_MAX_CONCURRENT_FINISH_FLARES = 4
/** 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') {
/** Uses wall time because the map's relative-timestamp clock advances only every 30s. */
export function isAgentMapRecentFinish(card: DashboardCard, currentTime = Date.now()): boolean {
if (card.dotState !== 'done' || !card.unseen) {
return false
}
const changedAt = card.stateChangedAt || card.finishedAt || 0
if (changedAt <= 0) {
return false
}
const elapsed = Date.now() - changedAt
const elapsed = currentTime - 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
}
/** Selects only the freshest finishes so burst updates cannot animate the whole fleet. */
export function selectAgentMapRecentFinishPaneKeys(
cards: readonly DashboardCard[]
): ReadonlySet<string> {
const currentTime = Date.now()
const recent: { paneKey: string; changedAt: number }[] = []
for (const card of cards) {
if (!isAgentMapRecentFinish(card, currentTime)) {
continue
}
const changedAt = card.stateChangedAt || card.finishedAt || 0
const index = recent.findIndex(
(item) =>
changedAt > item.changedAt || (changedAt === item.changedAt && card.paneKey < item.paneKey)
)
if (index === -1) {
if (recent.length < AGENT_MAP_MAX_CONCURRENT_FINISH_FLARES) {
recent.push({ paneKey: card.paneKey, changedAt })
}
continue
}
recent.splice(index, 0, { paneKey: card.paneKey, changedAt })
if (recent.length > AGENT_MAP_MAX_CONCURRENT_FINISH_FLARES) {
recent.pop()
}
}
return new Set(recent.map((item) => item.paneKey))
}
export type AgentMapStatusCounts = Record<AgentMapNodeStatus, number>
export function emptyAgentMapStatusCounts(): AgentMapStatusCounts {
@@ -134,9 +134,9 @@
transform-box: fill-box;
transform-origin: center;
transition:
fill 260ms ease,
stroke 260ms ease,
stroke-width 260ms ease,
fill 160ms ease,
stroke 160ms ease,
stroke-width 160ms ease,
transform 210ms cubic-bezier(0.2, 1.35, 0.4, 1);
vector-effect: non-scaling-stroke;
}
@@ -145,9 +145,6 @@
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;
}
@@ -329,12 +326,6 @@
.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;
}
@@ -367,9 +358,6 @@
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;
}