mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
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:
@@ -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)
|
||||
}
|
||||
+20
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user