mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
fix(agent-map): bound completion paint work
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user