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.