diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx index a9bd26804..9a5eed152 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -2,7 +2,7 @@ import '@testing-library/jest-dom/vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' import { AgentKanbanBoard } from './AgentKanbanBoard' @@ -11,15 +11,18 @@ import { AgentKanbanBoard } from './AgentKanbanBoard' vi.mock('./AgentKanbanCard', () => ({ AgentKanbanCard: ({ card, + now, onOpenTerminal }: { card: DashboardCard + now: number onOpenTerminal: (card: DashboardCard) => void }) => (
onOpenTerminal(card)} > {card.worktreeName} @@ -81,7 +84,9 @@ describe('AgentKanbanBoard', () => { }) afterEach(() => { cleanup() + vi.useRealTimers() vi.clearAllMocks() + vi.restoreAllMocks() }) it('renders the three fixed columns in order', () => { @@ -119,6 +124,47 @@ describe('AgentKanbanBoard', () => { expect(names).toEqual(['new-move', 'mid-move', 'old-move']) }) + it('does not start the clock when no card renders a relative timestamp', () => { + vi.useFakeTimers() + vi.setSystemTime(100_000) + + const { rerender } = render() + expect(vi.getTimerCount()).toBe(0) + + rerender( + + ) + const initialNow = screen.getByTestId('card').dataset.now + + expect(vi.getTimerCount()).toBe(0) + act(() => vi.advanceTimersByTime(30_000)) + expect(screen.getByTestId('card').dataset.now).toBe(initialNow) + }) + + it('parks the clock while hidden, catches up on reveal, and ticks while visible', () => { + vi.useFakeTimers() + vi.setSystemTime(100_000) + let visibilityState: DocumentVisibilityState = 'hidden' + vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState) + + renderBoard([card({ startedAt: 1 })]) + expect(screen.getByTestId('card').dataset.now).toBe('100000') + expect(vi.getTimerCount()).toBe(0) + + act(() => vi.advanceTimersByTime(60_000)) + expect(screen.getByTestId('card').dataset.now).toBe('100000') + + visibilityState = 'visible' + act(() => document.dispatchEvent(new Event('visibilitychange'))) + expect(screen.getByTestId('card').dataset.now).toBe('160000') + expect(vi.getTimerCount()).toBe(1) + + act(() => vi.advanceTimersByTime(30_000)) + expect(screen.getByTestId('card').dataset.now).toBe('190000') + }) + it('keeps the terminal dialog open across bucket moves and card removal', () => { const agent = card({ paneKey: 'pk-1', bucket: 'idle', worktreeName: 'wt1' }) const { rerender } = render() diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx index b386b5d0c..54cb1d684 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx @@ -6,6 +6,7 @@ import { type DashboardSnapshot } from '../../../../shared/dashboard-snapshot' import { cn } from '@/lib/utils' +import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { AgentKanbanCard } from './AgentKanbanCard' import { AgentTerminalDialog } from './AgentTerminalDialog' import './agent-board-transitions.css' @@ -89,12 +90,21 @@ function KanbanColumn({ /** The pop-out agent board: status columns fed by the relayed snapshot. */ export function AgentKanbanBoard({ snapshot }: { snapshot: DashboardSnapshot }): React.JSX.Element { const grouped = useMemo(() => groupByBucket(snapshot.cards), [snapshot.cards]) + const hasRelativeTimestamps = useMemo( + () => snapshot.cards.some((card) => (card.finishedAt ?? card.startedAt) > 0), + [snapshot.cards] + ) const [now, setNow] = useState(() => Date.now()) useEffect(() => { - const timer = setInterval(() => setNow(Date.now()), 30_000) - return () => clearInterval(timer) - }, []) + if (!hasRelativeTimestamps) { + return + } + return installWindowVisibilityInterval({ + run: () => setNow(Date.now()), + intervalMs: 30_000 + }) + }, [hasRelativeTimestamps]) // The open terminal dialog survives bucket moves: only the paneKey is // remembered, and the card data is re-resolved from each fresh snapshot.