diff --git a/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.test.tsx new file mode 100644 index 00000000000..05ce6113726 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.test.tsx @@ -0,0 +1,416 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import type * as AgentMapLayoutModule from './agent-map-layout' +import type * as AgentMapProjectPlacementModule from './agent-map-project-placement' +import { AGENT_MAP_TIME_MAX_INDEX, type AgentMapTimeRange } from './agent-map-time-filter' + +/** Counts the packing work one slider interaction costs. `repacks` only rises + * when `updateAgentMapLayout` misses its topology cache and runs the full + * `deriveAgentMapLayout` again; `updates` counts every layout evaluation. */ +const layoutCalls = vi.hoisted(() => ({ updates: 0, repacks: 0 })) +const packCalls = vi.hoisted(() => ({ count: 0 })) + +vi.mock('./agent-map-layout', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + updateAgentMapLayout: ( + ...args: Parameters + ): ReturnType => { + layoutCalls.updates += 1 + const result = actual.updateAgentMapLayout(...args) + // A fresh cache object is returned only on the deriveAgentMapLayout path. + if (result.cache !== args[0]) { + layoutCalls.repacks += 1 + } + return result + } + } +}) + +// Second, independent counter: the packer runs once per non-empty repack. +vi.mock('./agent-map-project-placement', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + placeAgentMapProjects: ( + ...args: Parameters + ): ReturnType => { + packCalls.count += 1 + return actual.placeAgentMapProjects(...args) + } + } +}) + +import { AgentDashboardMapView } from './AgentDashboardMapView' +import { AgentMapTimeRangeField } from './AgentMapTimeRangeField' + +const NOW = 2_000_000_000 +const MINUTE = 60_000 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +const SLIDER_WIDTH = 280 +/** Radix maps pointer x linearly onto [0, AGENT_MAP_TIME_MAX_INDEX]. */ +const clientXForStop = (stop: number): number => (stop / AGENT_MAP_TIME_MAX_INDEX) * SLIDER_WIDTH + +function card(overrides: Partial & { paneKey: string }): DashboardCard { + return { + ptyId: overrides.paneKey, + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Pack the map', + repoId: 'repo-1', + worktreeId: `worktree-${overrides.paneKey}`, + tabId: 'tab-1', + leafId: `leaf-${overrides.paneKey}`, + repoName: 'Orca', + worktreeName: overrides.paneKey, + startedAt: NOW - MINUTE, + finishedAt: null, + stateChangedAt: NOW - 1_000, + statusUpdatedAt: NOW - 1_000, + unseen: false, + hostKind: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + +/** One card per stop the drag crosses, each just old enough to be dropped by + * the next step down — so every value change is a real topology change. */ +const LIFESPANS: readonly { paneKey: string; lifespan: number }[] = [ + { paneKey: 'agent-20d', lifespan: 20 * DAY }, + { paneKey: 'agent-10d', lifespan: 10 * DAY }, + { paneKey: 'agent-5d', lifespan: 5 * DAY }, + { paneKey: 'agent-2_5d', lifespan: 2.5 * DAY }, + { paneKey: 'agent-36h', lifespan: 36 * HOUR }, + { paneKey: 'agent-18h', lifespan: 18 * HOUR }, + { paneKey: 'agent-5m', lifespan: 5 * MINUTE } +] + +const CARDS: DashboardCard[] = LIFESPANS.map(({ paneKey, lifespan }) => + card({ paneKey, startedAt: NOW - lifespan }) +) + +const SNAPSHOT: DashboardSnapshot = { + generatedAt: NOW, + cards: CARDS, + workspaces: [], + filterOptions: { projects: [], workspaceStatuses: [] } +} + +/** Stops the max thumb passes through on one drag: ∞ → 12h. */ +const DRAG_STOPS = [13, 12, 11, 10, 9, 8] +const EXPECTED_DRAG_REPACKS = 1 +const DRAFT_CANCELLATIONS = [ + { name: 'pointer cancellation', finish: (thumb: HTMLElement) => fireEvent.pointerCancel(thumb) }, + { + name: 'pointer capture loss', + finish: (thumb: HTMLElement) => fireEvent.lostPointerCapture(thumb, { pointerId: 1 }) + }, + { name: 'focus loss', finish: (thumb: HTMLElement) => fireEvent.blur(thumb) }, + { name: 'Escape', finish: (thumb: HTMLElement) => fireEvent.keyDown(thumb, { key: 'Escape' }) } +] + +function renderMapView(): ReturnType { + return render( + + ) +} + +async function openTimeSection(): Promise { + fireEvent.click(screen.getByRole('button', { name: /^Filter/ })) + fireEvent.click(await screen.findByRole('button', { name: /^Time/ })) + const slider = await screen.findByRole('slider', { name: 'Session lifespan maximum' }) + return slider +} + +/** Mirrors the panel's wiring: the field is controlled and the owner re-renders + * on every published range. */ +function ControlledField({ + label, + initial, + onChange +}: { + label: string + initial: AgentMapTimeRange + onChange: (range: AgentMapTimeRange) => void +}): React.JSX.Element { + const [range, setRange] = useState(initial) + return ( + { + setRange(next) + onChange(next) + }} + /> + ) +} + +/** Radix reads geometry off the root and gates moves on pointer capture. */ +function stubSliderGeometry(): () => void { + const captured = new Set() + const element = Element.prototype as unknown as { + setPointerCapture: (id: number) => void + hasPointerCapture: (id: number) => boolean + releasePointerCapture: (id: number) => void + } + const original = { + setPointerCapture: element.setPointerCapture, + hasPointerCapture: element.hasPointerCapture, + releasePointerCapture: element.releasePointerCapture + } + element.setPointerCapture = (id) => void captured.add(id) + element.hasPointerCapture = (id) => captured.has(id) + element.releasePointerCapture = (id) => void captured.delete(id) + const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: SLIDER_WIDTH, + bottom: 24, + width: SLIDER_WIDTH, + height: 24, + toJSON: () => ({}) + }) + return () => { + element.setPointerCapture = original.setPointerCapture + element.hasPointerCapture = original.hasPointerCapture + element.releasePointerCapture = original.releasePointerCapture + rect.mockRestore() + } +} + +function dragThumb(thumb: HTMLElement, stops: readonly number[], onStep?: () => void): void { + act(() => { + fireEvent.pointerDown(thumb, { pointerId: 1, button: 0, clientX: SLIDER_WIDTH }) + }) + for (const stop of stops) { + act(() => { + fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(stop) }) + }) + onStep?.() + } + act(() => { + fireEvent.pointerUp(thumb, { pointerId: 1, clientX: clientXForStop(stops.at(-1) ?? 0) }) + }) +} + +describe('AgentMapTimeRangeField', () => { + let restoreGeometry: () => void + + beforeEach(() => { + layoutCalls.updates = 0 + layoutCalls.repacks = 0 + packCalls.count = 0 + restoreGeometry = stubSliderGeometry() + }) + + afterEach(() => { + restoreGeometry() + cleanup() + vi.restoreAllMocks() + }) + + it('repacks the whole map once when a multi-stop drag commits', async () => { + renderMapView() + await waitFor(() => expect(document.querySelector('.agent-map-canvas')).toBeTruthy()) + const mountRepacks = layoutCalls.repacks + const mountUpdates = layoutCalls.updates + const mountPacks = packCalls.count + + const thumb = await openTimeSection() + dragThumb(thumb, DRAG_STOPS) + + expect(layoutCalls.repacks - mountRepacks).toBe(EXPECTED_DRAG_REPACKS) + expect(packCalls.count - mountPacks).toBe(EXPECTED_DRAG_REPACKS) + expect(layoutCalls.updates - mountUpdates).toBe(EXPECTED_DRAG_REPACKS) + expect(screen.getByText('of 7 agents shown').parentElement).toHaveTextContent( + '1 of 7 agents shown' + ) + await waitFor(() => expect(document.querySelectorAll('[data-agent-map-agent]')).toHaveLength(1)) + }) + + it('updates the thumb and readout at every intermediate drag stop', async () => { + renderMapView() + await waitFor(() => expect(document.querySelector('.agent-map-canvas')).toBeTruthy()) + const thumb = await openTimeSection() + const field = thumb.closest('[data-slot="slider"]')?.parentElement as HTMLElement + const readouts: string[] = [] + const thumbValues: string[] = [] + + dragThumb(thumb, DRAG_STOPS, () => { + readouts.push(within(field).getByText(/–|any/).textContent ?? '') + thumbValues.push(thumb.getAttribute('aria-valuenow') ?? '') + }) + + expect(readouts).toEqual(['0 – 14d', '0 – 7d', '0 – 3d', '0 – 2d', '0 – 1d', '0 – 12h']) + expect(thumbValues).toEqual(DRAG_STOPS.map(String)) + }) + + it('keeps the readout, chip, and map aligned after a full-range collapse', async () => { + renderMapView() + await waitFor(() => expect(document.querySelector('.agent-map-canvas')).toBeTruthy()) + + const thumb = await openTimeSection() + dragThumb(thumb, [0]) + + expect(screen.getByText('0 – 0')).toBeInTheDocument() + expect(screen.getByText('Session lifespan: 0–0')).toBeInTheDocument() + expect(screen.getByText('of 7 agents shown').parentElement).toHaveTextContent( + '0 of 7 agents shown' + ) + await waitFor(() => expect(document.querySelectorAll('[data-agent-map-agent]')).toHaveLength(0)) + }) + + it('publishes only the final range for a multi-stop drag', () => { + const onChange = vi.fn() + render( + + ) + + dragThumb(screen.getByRole('slider', { name: 'Session lifespan maximum' }), DRAG_STOPS) + + expect(onChange).toHaveBeenCalledExactlyOnceWith({ min: 0, max: DRAG_STOPS.at(-1) }) + }) + + it.each([ + { name: 'a narrowed range', initial: { min: 5, max: AGENT_MAP_TIME_MAX_INDEX }, stop: 5 }, + { name: 'the full range', initial: { min: 0, max: AGENT_MAP_TIME_MAX_INDEX }, stop: 0 } + ])('commits max-thumb collapse from $name', ({ initial, stop }) => { + const onChange = vi.fn() + render() + + dragThumb(screen.getByRole('slider', { name: 'Session lifespan maximum' }), [stop]) + + expect(onChange).toHaveBeenCalledExactlyOnceWith({ min: stop, max: stop }) + expect( + screen.getByText(`${stop === 0 ? '0' : '1h'} – ${stop === 0 ? '0' : '1h'}`) + ).toBeInTheDocument() + }) + + it('follows an external range change while a draft is active', () => { + const onChange = vi.fn() + const field = (range: AgentMapTimeRange): React.JSX.Element => ( + + ) + const view = render(field({ min: 0, max: AGENT_MAP_TIME_MAX_INDEX })) + expect(screen.getByText('any')).toBeInTheDocument() + + view.rerender(field({ min: 4, max: 9 })) + + expect(screen.getByText('30m – 1d')).toBeInTheDocument() + expect(screen.getByRole('slider', { name: 'Session lifespan minimum' })).toHaveAttribute( + 'aria-valuenow', + '4' + ) + expect(screen.getByRole('slider', { name: 'Session lifespan maximum' })).toHaveAttribute( + 'aria-valuenow', + '9' + ) + + const thumb = screen.getByRole('slider', { name: 'Session lifespan maximum' }) + act(() => { + fireEvent.pointerDown(thumb, { pointerId: 1, button: 0, clientX: clientXForStop(9) }) + fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(7) }) + }) + expect(screen.getByText('30m – 6h')).toBeInTheDocument() + + view.rerender(field({ min: 0, max: AGENT_MAP_TIME_MAX_INDEX })) + + expect(screen.getByText('any')).toBeInTheDocument() + expect(screen.getByRole('slider', { name: 'Session lifespan maximum' })).toHaveAttribute( + 'aria-valuenow', + String(AGENT_MAP_TIME_MAX_INDEX) + ) + }) + + it('does not commit an interaction invalidated by an external range change', () => { + const onChange = vi.fn() + const field = (range: AgentMapTimeRange): React.JSX.Element => ( + + ) + const view = render(field({ min: 0, max: AGENT_MAP_TIME_MAX_INDEX })) + const thumb = screen.getByRole('slider', { name: 'Session lifespan maximum' }) + + act(() => { + fireEvent.pointerDown(thumb, { pointerId: 1, button: 0, clientX: SLIDER_WIDTH }) + fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(8) }) + }) + view.rerender(field({ min: 4, max: 9 })) + act(() => { + fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(7) }) + fireEvent.pointerUp(thumb, { pointerId: 1, clientX: clientXForStop(7) }) + }) + + expect(onChange).not.toHaveBeenCalled() + }) + + it.each(DRAFT_CANCELLATIONS)('discards a pointer draft on $name', ({ finish }) => { + const onChange = vi.fn() + render( + + ) + const thumb = screen.getByRole('slider', { name: 'Session lifespan maximum' }) + + act(() => { + fireEvent.pointerDown(thumb, { pointerId: 1, button: 0, clientX: SLIDER_WIDTH }) + fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(8) }) + }) + expect(screen.getByText('0 – 12h')).toBeInTheDocument() + finish(thumb) + + expect(screen.getByText('any')).toBeInTheDocument() + expect(onChange).not.toHaveBeenCalled() + }) + + it('commits a keyboard arrow step', () => { + const onChange = vi.fn() + render( + + ) + + const thumb = screen.getByRole('slider', { name: 'Session lifespan maximum' }) + // Radix routes arrow keys to the last focused thumb, not the event target. + act(() => thumb.focus()) + fireEvent.keyDown(thumb, { key: 'ArrowLeft' }) + + expect(onChange).toHaveBeenCalledExactlyOnceWith({ min: 0, max: AGENT_MAP_TIME_MAX_INDEX - 1 }) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.tsx b/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.tsx index 1ad8132a879..752a00ea673 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.tsx @@ -1,6 +1,7 @@ import { Slider } from '@/components/ui/slider' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +import { useRef, useState } from 'react' import { AGENT_MAP_TIME_MAX_INDEX, agentMapTimeStopLabel, @@ -14,16 +15,46 @@ type AgentMapTimeRangeFieldProps = { onChange: (range: AgentMapTimeRange) => void } +type SliderInteraction = { + source: AgentMapTimeRange + value: AgentMapTimeRange | null +} + /** Ticks are sparse on purpose — the scale is non-linear, so labelling every * stop would read as evenly spaced time when it is not. */ const TICKS = [0, 5, 9, 12, AGENT_MAP_TIME_MAX_INDEX] +const SLIDER_KEYBOARD_COMMIT_KEYS = [ + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'End', + 'Home', + 'PageDown', + 'PageUp' +] export function AgentMapTimeRangeField({ label, range, onChange }: AgentMapTimeRangeFieldProps): React.JSX.Element { - const isFull = isFullAgentMapTimeRange(range) + const [draft, setDraft] = useState<{ + source: AgentMapTimeRange + value: AgentMapTimeRange + } | null>(null) + const interaction = useRef(null) + const reconcileInteraction = (value?: AgentMapTimeRange): void => { + const source = interaction.current?.source + interaction.current = null + setDraft(null) + if (value && source === range) { + onChange(value) + } + } + // New external range objects invalidate stale drafts from resets and quick views. + const displayedRange = draft?.source === range ? draft.value : range + const isFull = isFullAgentMapTimeRange(displayedRange) return (
@@ -36,7 +67,7 @@ export function AgentMapTimeRangeField({ > {isFull ? translate('dashboardPopout.map.filters.timeAny', 'any') - : `${agentMapTimeStopLabel(range.min)} – ${agentMapTimeStopLabel(range.max)}`} + : `${agentMapTimeStopLabel(displayedRange.min)} – ${agentMapTimeStopLabel(displayedRange.max)}`}
onChange({ min, max })} + thumbValueLabels={[ + agentMapTimeStopLabel(displayedRange.min), + agentMapTimeStopLabel(displayedRange.max) + ]} + onKeyDown={(event) => { + if (event.key === 'Escape') { + reconcileInteraction() + return + } + if (SLIDER_KEYBOARD_COMMIT_KEYS.includes(event.key)) { + interaction.current = { source: range, value: null } + } + }} + onPointerDown={() => { + interaction.current = { source: range, value: null } + }} + onPointerUp={() => { + reconcileInteraction(interaction.current?.value ?? undefined) + }} + onPointerCancel={() => { + reconcileInteraction() + }} + onLostPointerCapture={() => { + reconcileInteraction() + }} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + reconcileInteraction() + } + }} + onValueChange={([min, max]) => { + const current = interaction.current + if (!current) { + return + } + const value = { min, max } + current.value = value + setDraft({ source: current.source, value }) + }} + onValueCommit={([min, max]) => { + reconcileInteraction({ min, max }) + }} />
{TICKS.map((tick) => ( diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx b/src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx index 35c4cab5055..1cdddd5570a 100644 --- a/src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx +++ b/src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx @@ -2,7 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import { AGENT_MAP_TIME_MAX_INDEX } from './agent-map-time-filter' +import { AGENT_MAP_TIME_FIELDS, AGENT_MAP_TIME_MAX_INDEX } from './agent-map-time-filter' import { useAgentMapFilters } from './useAgentMapFilters' describe('useAgentMapFilters', () => { @@ -47,4 +47,23 @@ describe('useAgentMapFilters', () => { expect([...hook.result.current.agentTypes]).toEqual(['claude', 'grok']) }) + + it('preserves each time-range identity across unrelated facet updates', () => { + let agentTypes = ['claude', 'codex'] + const hook = renderHook(() => useAgentMapFilters(agentTypes)) + const ranges = hook.result.current.timeRanges + const fields = AGENT_MAP_TIME_FIELDS.map((field) => ranges[field]) + + act(() => hook.result.current.toggleState('done')) + act(() => hook.result.current.toggleAgentType('claude')) + act(() => hook.result.current.setUnreadOnly(true)) + act(() => hook.result.current.setOrchestrationOnly(true)) + agentTypes = ['claude', 'codex', 'grok'] + hook.rerender() + + expect(hook.result.current.timeRanges).toBe(ranges) + AGENT_MAP_TIME_FIELDS.forEach((field, index) => { + expect(hook.result.current.timeRanges[field]).toBe(fields[index]) + }) + }) })