diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx index a28a5987860..22cf8034c2d 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -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()], { diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx index 2181e4c0f2c..4ca92550269 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -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} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index 087caa671d4..3e9e7f65fd6 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -33,6 +33,7 @@ type AgentMapWorktreeRingNodeProps = { selectedPaneKey: string | null allowAggregation: boolean showOrchestrationLinks: boolean + recentFinishPaneKeys: ReadonlySet launchableAgents?: readonly TuiAgent[] nodeRefs: MutableRefObject> 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 (
@@ -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 } )} @@ -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 ? ( @@ -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) } }) } diff --git a/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts index 5bca33fb10e..477cff4b0ad 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts @@ -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(/ { 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', () => { diff --git a/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts b/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts index 6e0e319d3de..0b33bbae115 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts @@ -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 { + 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 export function emptyAgentMapStatusCounts(): AgentMapStatusCounts { diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css index 0db4113aa9b..b99ea8b37cc 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -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; }