From c66bfd75147ae6ec1051701e05207e7f686d3f00 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:22:24 -0700 Subject: [PATCH] fix(sidebar): keep agent rows ordered by their session start once history wraps Sidebar agent rows are ordered by the session's first reported state, read as `stateHistory[0].startedAt`. `stateHistory` is a rolling window capped at AGENT_STATE_HISTORY_MAX that trims oldest-first, so past ~20 transitions that row is a recent turn: the row's identity clock silently becomes an activity clock and busy agents re-sort themselves on every turn. Latch the session origin in `firstStateStartedAt` when the entry is built and read it through `agentEntrySessionStartedAt`, keeping the history lookup as a fallback for entries that predate the field. --- .../worktree-agent-row-fallback-tab.ts | 3 +- .../sidebar/worktree-agent-row-order.test.ts | 114 ++++++++++++++++++ .../agent-status-session-start-latch.test.ts | 92 ++++++++++++++ src/renderer/src/store/slices/agent-status.ts | 11 +- src/shared/agent-session-start-time.ts | 30 +++++ src/shared/agent-status-types.ts | 5 + 6 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-agent-row-order.test.ts create mode 100644 src/renderer/src/store/slices/agent-status-session-start-latch.test.ts create mode 100644 src/shared/agent-session-start-time.ts diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts b/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts index 9a0b6185a1a..ba76dbb9801 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts @@ -1,9 +1,10 @@ +import { agentEntrySessionStartedAt } from '../../../../shared/agent-session-start-time' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/terminal-tab-types' export function effectiveWorktreeAgentRowStartedAt(entry: AgentStatusEntry): number { - return entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt + return agentEntrySessionStartedAt(entry) } export function tabFromWorktreeAttributedStatusEntry( diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-order.test.ts b/src/renderer/src/components/sidebar/worktree-agent-row-order.test.ts new file mode 100644 index 00000000000..00df9be97dc --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-agent-row-order.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_STATE_HISTORY_MAX, + type AgentStateHistoryEntry, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { buildWorktreeAgentRows } from './worktree-agent-rows' + +const BUSY_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const QUIET_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const BUSY_PANE_KEY = makePaneKey('tab-busy', BUSY_LEAF_ID) +const QUIET_PANE_KEY = makePaneKey('tab-quiet', QUIET_LEAF_ID) + +const NOW = 900_000 + +function makeTab(id: string, sortOrder: number): TerminalTab { + return { + id, + worktreeId: 'wt-1', + ptyId: null, + title: 'Claude', + customTitle: null, + color: null, + sortOrder, + createdAt: 0 + } +} + +/** A history window that has already wrapped: the session's real first states were trimmed away. */ +function wrappedHistory(oldestRetainedAt: number): AgentStateHistoryEntry[] { + return Array.from({ length: AGENT_STATE_HISTORY_MAX }, (_, index) => ({ + state: index % 2 === 0 ? ('working' as const) : ('done' as const), + prompt: `turn ${index}`, + startedAt: oldestRetainedAt + index * 1000 + })) +} + +function makeEntry(paneKey: string, overrides: Partial): AgentStatusEntry { + return { + paneKey, + state: 'working', + prompt: 'keep going', + agentType: 'claude', + stateStartedAt: NOW - 1000, + updatedAt: NOW - 1000, + stateHistory: [], + ...overrides + } +} + +describe('worktree agent row order across new activity', () => { + // Why: the busy agent started FIRST, so it must stay first. Its stateHistory has wrapped the + // AGENT_STATE_HISTORY_MAX cap, so stateHistory[0] no longer marks the session start — reading + // it directly would rank the row by its most recent turns and jump it past the quiet agent. + it('keeps the older session ahead once its history window has wrapped', () => { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-busy', 0), makeTab('tab-quiet', 1)], + entries: [ + makeEntry(BUSY_PANE_KEY, { + firstStateStartedAt: 1000, + stateHistory: wrappedHistory(500_000) + }), + makeEntry(QUIET_PANE_KEY, { firstStateStartedAt: 2000, stateStartedAt: 2000 }) + ], + retained: [], + now: NOW + }) + + expect(rows.map((row) => row.paneKey)).toEqual([BUSY_PANE_KEY, QUIET_PANE_KEY]) + expect(rows[0].startedAt).toBe(1000) + }) + + it('does not re-sort the busy row when its next turn slides the history window', () => { + const buildRows = (oldestRetainedAt: number): string[] => + buildWorktreeAgentRows({ + tabs: [makeTab('tab-busy', 0), makeTab('tab-quiet', 1)], + entries: [ + makeEntry(BUSY_PANE_KEY, { + firstStateStartedAt: 1000, + stateHistory: wrappedHistory(oldestRetainedAt) + }), + makeEntry(QUIET_PANE_KEY, { firstStateStartedAt: 2000, stateStartedAt: 2000 }) + ], + retained: [], + now: NOW + }).map((row) => row.paneKey) + + const before = buildRows(500_000) + // One more turn: the oldest retained history row is dropped, so stateHistory[0] advances. + const after = buildRows(502_000) + + expect(after).toEqual(before) + expect(after).toEqual([BUSY_PANE_KEY, QUIET_PANE_KEY]) + }) + + // Why: entries rehydrated from disk (or synthesized by main) carry no latched origin; they must + // still order by their oldest known state rather than collapsing to the current status clock. + it('falls back to the oldest retained history row when no origin was latched', () => { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-busy', 0), makeTab('tab-quiet', 1)], + entries: [ + makeEntry(BUSY_PANE_KEY, { stateHistory: wrappedHistory(1000) }), + makeEntry(QUIET_PANE_KEY, { stateStartedAt: 2000 }) + ], + retained: [], + now: NOW + }) + + expect(rows.map((row) => row.paneKey)).toEqual([BUSY_PANE_KEY, QUIET_PANE_KEY]) + expect(rows[0].startedAt).toBe(1000) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-session-start-latch.test.ts b/src/renderer/src/store/slices/agent-status-session-start-latch.test.ts new file mode 100644 index 00000000000..a125bf14d51 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-session-start-latch.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_STATE_HISTORY_MAX } from '../../../../shared/agent-status-types' +import { agentEntrySessionStartedAt } from '../../../../shared/agent-session-start-time' +import { createTestStore } from './store-test-helpers' + +const PANE_KEY = 'tab-1:leaf-1' +const SESSION_START = 10_000 + +function runTurns(store: ReturnType, turns: number): void { + for (let turn = 0; turn < turns; turn++) { + const at = SESSION_START + (turn + 1) * 1000 + store + .getState() + .setAgentStatus( + PANE_KEY, + { state: 'working', prompt: `turn ${turn}`, agentType: 'claude' }, + 'Claude', + { updatedAt: at, stateStartedAt: at } + ) + store + .getState() + .setAgentStatus( + PANE_KEY, + { state: 'done', prompt: `turn ${turn}`, agentType: 'claude' }, + 'Claude', + { updatedAt: at + 500, stateStartedAt: at + 500 } + ) + } +} + +describe('agent status session-start latch', () => { + it('latches the first reported state as the session start', () => { + const store = createTestStore() + store + .getState() + .setAgentStatus( + PANE_KEY, + { state: 'working', prompt: 'first prompt', agentType: 'claude' }, + 'Claude', + { updatedAt: SESSION_START, stateStartedAt: SESSION_START } + ) + + expect(store.getState().agentStatusByPaneKey[PANE_KEY]?.firstStateStartedAt).toBe(SESSION_START) + }) + + // Why: stateHistory is a rolling window that trims oldest-first, so past the cap its first row + // is a recent turn. Without a latched origin the sidebar's row clock would follow activity. + it('holds the session start once stateHistory has wrapped its cap', () => { + const store = createTestStore() + store + .getState() + .setAgentStatus( + PANE_KEY, + { state: 'working', prompt: 'first prompt', agentType: 'claude' }, + 'Claude', + { updatedAt: SESSION_START, stateStartedAt: SESSION_START } + ) + // Two transitions per turn, so this comfortably overruns AGENT_STATE_HISTORY_MAX. + runTurns(store, AGENT_STATE_HISTORY_MAX) + + const entry = store.getState().agentStatusByPaneKey[PANE_KEY] + expect(entry).toBeDefined() + expect(entry!.stateHistory).toHaveLength(AGENT_STATE_HISTORY_MAX) + // The window really did slide: the oldest retained row is no longer the session start. + expect(entry!.stateHistory[0]!.startedAt).toBeGreaterThan(SESSION_START) + expect(entry!.firstStateStartedAt).toBe(SESSION_START) + expect(agentEntrySessionStartedAt(entry!)).toBe(SESSION_START) + }) + + it('starts a fresh session clock after the pane status is dropped', () => { + const store = createTestStore() + store + .getState() + .setAgentStatus( + PANE_KEY, + { state: 'working', prompt: 'first prompt', agentType: 'claude' }, + 'Claude', + { updatedAt: SESSION_START, stateStartedAt: SESSION_START } + ) + store.getState().dropAgentStatus(PANE_KEY) + store + .getState() + .setAgentStatus( + PANE_KEY, + { state: 'working', prompt: 'new session', agentType: 'claude' }, + 'Claude', + { updatedAt: 900_000, stateStartedAt: 900_000 } + ) + + expect(store.getState().agentStatusByPaneKey[PANE_KEY]?.firstStateStartedAt).toBe(900_000) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 013b98f01a5..7eb691f8450 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -27,6 +27,7 @@ import { } from '../../../../shared/agent-status-identity' import { isCommandCodeNewTurnWhileWorking } from '../../../../shared/command-code-turn-boundary' import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' +import { agentEntrySessionStartedAt } from '../../../../shared/agent-session-start-time' import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' import { getRepoExecutionHostId, @@ -550,7 +551,7 @@ function retainedAgentEntryFromLive( worktreeId, tab, agentType, - startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt + startedAt: agentEntrySessionStartedAt(entry) } } @@ -2133,6 +2134,14 @@ export const createAgentStatusSlice: StateCreator + +function finiteOrNull(value: number | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +/** + * When this pane's agent session first reported a state — the stable identity clock the + * sidebar orders agent rows by. + * + * Why not `stateHistory[0].startedAt` alone: `stateHistory` is a rolling window capped at + * `AGENT_STATE_HISTORY_MAX` that trims oldest-first, so once a session passes the cap its + * first *retained* row advances on every transition. Reading it directly turns a stable + * identity clock into an activity clock, and the row re-sorts on each new turn. + * `firstStateStartedAt` is latched at session start and never moves; history remains the + * fallback for entries that predate it (rehydrated from disk, derived rows). + */ +export function agentEntrySessionStartedAt(entry: AgentSessionStartSource): number { + return ( + finiteOrNull(entry.firstStateStartedAt) ?? + finiteOrNull(entry.stateHistory[0]?.startedAt) ?? + entry.stateStartedAt + ) +} diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 80ce1a4f823..02a98dbf563 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -117,6 +117,11 @@ export type AgentStatusEntry = { terminalTitle?: string /** Rolling log of previous states, capped at AGENT_STATE_HISTORY_MAX. */ stateHistory: AgentStateHistoryEntry[] + /** Timestamp (ms) of this session's FIRST reported state, latched once and never moved. + * Why: `stateHistory` trims oldest-first at the cap, so `stateHistory[0]` stops being the + * session origin after ~20 transitions; row order keyed off it would follow activity. + * Read it via `agentEntrySessionStartedAt`, which still falls back to history. */ + firstStateStartedAt?: number /** Name of the tool the agent is currently using (e.g. "Edit", "Bash"). */ toolName?: string /** Short preview of the tool input (e.g. file path, command). */