From f1ffb1b2d2d8e91b3d25bee89410fcb9be2442b4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:33:37 -0700 Subject: [PATCH] feat(agent-map): make an unread finish visible and let a glance demote it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../AgentKanbanBoard.test.tsx | 10 +- .../dashboard-popout/AgentMap.test.tsx | 12 +- .../AgentMapStatusGlow.test.tsx | 24 ++-- .../AgentMapWorktreeRingNode.tsx | 61 +++++---- .../agent-map-agent-placement.ts | 13 +- .../dashboard-popout/agent-map-filter.test.ts | 11 +- .../dashboard-popout/agent-map-filter.ts | 14 ++- .../agent-map-glow.performance.test.ts | 44 ++++++- .../agent-map-label-declutter.test.ts | 16 +-- .../agent-map-layout-metadata.ts | 21 ++-- .../dashboard-popout/agent-map-layout.test.ts | 4 +- .../dashboard-popout/agent-map-layout.ts | 28 +++-- .../agent-map-node-metadata.test.ts | 117 ++++++++++++++++++ .../agent-map-node-metadata.ts | 51 +++++++- .../agent-map-node-presentation.ts | 42 +++++++ .../agent-map-worktree-active-status.test.ts | 23 +++- .../agent-map-worktree-active-status.ts | 16 ++- .../components/dashboard-popout/agent-map.css | 74 ++++++++++- 18 files changed, 477 insertions(+), 104 deletions(-) create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-node-metadata.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-node-presentation.ts diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx index a62a4cce9f4..ea72a3d3129 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -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' + ) }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx index 699a698f008..a28a5987860 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -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', () => { diff --git a/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx index 8803b3336a5..7480857cfde 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx @@ -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() - }) + ) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index ee49a574901..087caa671d4 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -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, 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)} - {agentStateLabel(agent.status)} · {formatDuration(agent.durationMinutes)} + {agentMapStatusLabel(agent.status)} · {formatDuration(agent.durationMinutes)} - + )) )} @@ -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 ( { @@ -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 ? ( +