mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
fix(agent-map): commit slider changes once (STA-4281) (#14442)
* fix(agent-map): commit time slider changes * fix(agent-map): cancel invalidated slider commits * fix(agent-map): reconcile collapsed slider drags
This commit is contained in:
@@ -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<typeof AgentMapLayoutModule>()
|
||||
return {
|
||||
...actual,
|
||||
updateAgentMapLayout: (
|
||||
...args: Parameters<typeof actual.updateAgentMapLayout>
|
||||
): ReturnType<typeof actual.updateAgentMapLayout> => {
|
||||
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<typeof AgentMapProjectPlacementModule>()
|
||||
return {
|
||||
...actual,
|
||||
placeAgentMapProjects: (
|
||||
...args: Parameters<typeof actual.placeAgentMapProjects>
|
||||
): ReturnType<typeof actual.placeAgentMapProjects> => {
|
||||
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<DashboardCard> & { 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<typeof render> {
|
||||
return render(
|
||||
<AgentDashboardMapView
|
||||
snapshot={SNAPSHOT}
|
||||
cards={CARDS}
|
||||
query=""
|
||||
onQueryChange={vi.fn()}
|
||||
filters={{ projects: [], workspaceStatuses: [], reviewStates: [] }}
|
||||
onFiltersChange={vi.fn()}
|
||||
searchInputRef={{ current: null }}
|
||||
now={NOW}
|
||||
dialogCard={null}
|
||||
onDialogOpenChange={vi.fn()}
|
||||
onRevealAgent={vi.fn()}
|
||||
onOpenTerminal={vi.fn()}
|
||||
workspaceContextMenusEnabled={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
async function openTimeSection(): Promise<HTMLElement> {
|
||||
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 (
|
||||
<AgentMapTimeRangeField
|
||||
label={label}
|
||||
range={range}
|
||||
onChange={(next) => {
|
||||
setRange(next)
|
||||
onChange(next)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Radix reads geometry off the root and gates moves on pointer capture. */
|
||||
function stubSliderGeometry(): () => void {
|
||||
const captured = new Set<number>()
|
||||
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(
|
||||
<ControlledField
|
||||
label="Session lifespan"
|
||||
initial={{ min: 0, max: AGENT_MAP_TIME_MAX_INDEX }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
|
||||
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(<ControlledField label="Session lifespan" initial={initial} onChange={onChange} />)
|
||||
|
||||
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 => (
|
||||
<AgentMapTimeRangeField label="Session lifespan" range={range} onChange={onChange} />
|
||||
)
|
||||
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 => (
|
||||
<AgentMapTimeRangeField label="Session lifespan" range={range} onChange={onChange} />
|
||||
)
|
||||
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(
|
||||
<AgentMapTimeRangeField
|
||||
label="Session lifespan"
|
||||
range={{ min: 0, max: AGENT_MAP_TIME_MAX_INDEX }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<AgentMapTimeRangeField
|
||||
label="Session lifespan"
|
||||
range={{ min: 0, max: AGENT_MAP_TIME_MAX_INDEX }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -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<SliderInteraction | null>(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 (
|
||||
<div className="px-1.5 pt-1 pb-2">
|
||||
<div className="flex items-baseline gap-2 text-xs">
|
||||
@@ -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)}`}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
@@ -46,13 +77,53 @@ export function AgentMapTimeRangeField({
|
||||
max={AGENT_MAP_TIME_MAX_INDEX}
|
||||
step={1}
|
||||
minStepsBetweenThumbs={0}
|
||||
value={[range.min, range.max]}
|
||||
value={[displayedRange.min, displayedRange.max]}
|
||||
thumbLabels={[
|
||||
translate('dashboardPopout.map.filters.timeMinimum', '{{label}} minimum', { label }),
|
||||
translate('dashboardPopout.map.filters.timeMaximum', '{{label}} maximum', { label })
|
||||
]}
|
||||
thumbValueLabels={[agentMapTimeStopLabel(range.min), agentMapTimeStopLabel(range.max)]}
|
||||
onValueChange={([min, 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 })
|
||||
}}
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-[9px] text-muted-foreground">
|
||||
{TICKS.map((tick) => (
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user