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 ? ( +