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.
This commit is contained in:
Brennan Benson
2026-08-14 18:22:29 -07:00
parent 78d5920446
commit c66bfd7514
6 changed files with 253 additions and 2 deletions
@@ -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(
@@ -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>): 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)
})
})
@@ -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<typeof createTestStore>, 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)
})
})
+10 -1
View File
@@ -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<AppState, [], [], AgentStatusS
tabId: statusTabId,
terminalTitle: effectiveTitle,
stateHistory: history,
// Why: `history` trims oldest-first at the cap, so latch the session's first state
// once — otherwise the sidebar's row clock advances with every turn past the cap and
// busy agents re-sort themselves. Falls back for entries that predate the field.
firstStateStartedAt:
existing?.firstStateStartedAt ??
existing?.stateHistory[0]?.startedAt ??
existing?.stateStartedAt ??
stateStartedAt,
toolName: payload.toolName,
toolInput: payload.toolInput,
// Why: full untruncated AskUserQuestion JSON so mobile/web can render the live prompt
+30
View File
@@ -0,0 +1,30 @@
import type { AgentStatusEntry } from './agent-status-types'
/** The subset of a hook entry a session start is derived from. */
export type AgentSessionStartSource = Pick<
AgentStatusEntry,
'firstStateStartedAt' | 'stateStartedAt' | 'stateHistory'
>
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
)
}
+5
View File
@@ -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). */