From 764e2fe61f887cc6c19ded4eb19ddd2cbd7e47ef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:29:03 -0700 Subject: [PATCH] Keep sidebar agent rows stable during status updates (#6961) Co-authored-by: Orca --- .../sidebar/useWorktreeAgentRows.test.ts | 108 +++++++++++++++++- .../worktree-agent-row-fallback-tab.ts | 29 +++++ .../sidebar/worktree-agent-row-order.ts | 24 ++++ .../components/sidebar/worktree-agent-rows.ts | 34 +++--- 4 files changed, 172 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts create mode 100644 src/renderer/src/components/sidebar/worktree-agent-row-order.ts diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index 4e86f07c0f6..4219df7cc14 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: this suite covers one row-building seam - shared by retention, stale decay, and orchestration lineage regressions. */ import { describe, expect, it } from 'vitest' import { AGENT_STATUS_STALE_AFTER_MS, @@ -9,6 +7,7 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/typ import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { comparePaneKeysOrdinal } from './worktree-agent-row-order' import { buildWorktreeAgentRows } from './worktree-agent-rows' const ORPHAN_PANE_KEY = makePaneKey('tab-orphan', '11111111-1111-4111-8111-111111111111') @@ -293,6 +292,111 @@ describe('buildWorktreeAgentRows', () => { }) }) + it('keeps simultaneous worktree-attributed agents in a deterministic order', () => { + const first = makeEntry(PANE_KEY_1, 1000, { + state: 'working', + worktreeId: 'wt-1', + tabId: 'tab-1', + prompt: 'omp worker' + }) + const second = makeEntry(PANE_KEY_2, 1000, { + state: 'working', + worktreeId: 'wt-1', + tabId: 'tab-2', + prompt: 'omp worker' + }) + const third = makeEntry(PANE_KEY_3, 1000, { + state: 'working', + worktreeId: 'wt-1', + tabId: 'tab-3', + prompt: 'omp worker' + }) + + const build = (entries: AgentStatusEntry[]) => + buildWorktreeAgentRows({ + tabs: [], + entries, + retained: [], + now: 2000 + }).map((row) => row.paneKey) + + // Why: OMP can send frequent same-state updates for several panes. When + // those workers are worktree-attributed before their tabs arrive, row order + // must not inherit a noisy status-map iteration order. + expect(build([third, first, second])).toEqual([PANE_KEY_1, PANE_KEY_2, PANE_KEY_3]) + expect(build([{ ...second, updatedAt: 1500 }, third, first])).toEqual([ + PANE_KEY_1, + PANE_KEY_2, + PANE_KEY_3 + ]) + }) + + it('keeps missing-tab row order stable after real state transitions', () => { + const first = makeEntry(PANE_KEY_1, 2000, { + state: 'done', + worktreeId: 'wt-1', + tabId: 'tab-1', + stateHistory: [{ state: 'working', prompt: 'omp worker', startedAt: 1000 }] + }) + const second = makeEntry(PANE_KEY_2, 1500, { + state: 'blocked', + worktreeId: 'wt-1', + tabId: 'tab-2', + stateHistory: [{ state: 'working', prompt: 'omp worker', startedAt: 1000 }] + }) + + const rows = buildWorktreeAgentRows({ + tabs: [], + entries: [second, first], + retained: [], + now: 2500 + }) + + // Why: both rows originally started together. A later working -> terminal + // transition must not make fallback tab createdAt outrank paneKey order. + expect(rows.map((row) => [row.paneKey, row.startedAt, row.tab.createdAt])).toEqual([ + [PANE_KEY_1, 1000, 1000], + [PANE_KEY_2, 1000, 1000] + ]) + }) + + it('anchors missing-tab row order to the oldest state history entry', () => { + const first = makeEntry(PANE_KEY_1, 2400, { + state: 'done', + worktreeId: 'wt-1', + tabId: 'tab-1', + stateHistory: [ + { state: 'working', prompt: 'omp worker', startedAt: 1000 }, + { state: 'blocked', prompt: 'omp worker', startedAt: 1800 } + ] + }) + const second = makeEntry(PANE_KEY_2, 1600, { + state: 'waiting', + worktreeId: 'wt-1', + tabId: 'tab-2', + stateHistory: [ + { state: 'working', prompt: 'omp worker', startedAt: 1000 }, + { state: 'blocked', prompt: 'omp worker', startedAt: 1200 } + ] + }) + + const rows = buildWorktreeAgentRows({ + tabs: [], + entries: [second, first], + retained: [], + now: 2500 + }) + + expect(rows.map((row) => [row.paneKey, row.startedAt, row.tab.createdAt])).toEqual([ + [PANE_KEY_1, 1000, 1000], + [PANE_KEY_2, 1000, 1000] + ]) + }) + + it('uses ordinal pane-key comparison for final row ordering ties', () => { + expect(comparePaneKeysOrdinal('tab-\u00e4', 'tab-z')).toBeGreaterThan(0) + }) + it('uses runtime orchestration metadata for hook-reported live rows', () => { const parent = makeEntry(PANE_KEY_1, 1000, { prompt: 'parent', 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 new file mode 100644 index 00000000000..d722d39c4bb --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts @@ -0,0 +1,29 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../../shared/types' + +export function effectiveWorktreeAgentRowStartedAt(entry: AgentStatusEntry): number { + return entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt +} + +export function tabFromWorktreeAttributedStatusEntry( + entry: AgentStatusEntry, + effectiveStartedAt: number +): TerminalTab | null { + const parsed = parsePaneKey(entry.paneKey) + if (!parsed || !entry.worktreeId) { + return null + } + return { + id: parsed.tabId, + ptyId: null, + worktreeId: entry.worktreeId, + title: entry.terminalTitle ?? 'Agent', + customTitle: null, + color: null, + sortOrder: Number.MAX_SAFE_INTEGER, + // Why: missing-tab rows must keep their original clock through real state + // transitions; current stateStartedAt is a status timestamp, not tab order. + createdAt: effectiveStartedAt + } +} diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-order.ts b/src/renderer/src/components/sidebar/worktree-agent-row-order.ts new file mode 100644 index 00000000000..053a2dd6511 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-agent-row-order.ts @@ -0,0 +1,24 @@ +import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' + +function comparableNumber(value: number | undefined, fallback = 0): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +export function comparePaneKeysOrdinal(a: string, b: string): number { + if (a < b) { + return -1 + } + if (a > b) { + return 1 + } + return 0 +} + +export function compareWorktreeAgentRows(a: DashboardAgentRow, b: DashboardAgentRow): number { + return ( + comparableNumber(a.startedAt) - comparableNumber(b.startedAt) || + comparableNumber(a.tab.sortOrder) - comparableNumber(b.tab.sortOrder) || + comparableNumber(a.tab.createdAt) - comparableNumber(b.tab.createdAt) || + comparePaneKeysOrdinal(a.paneKey, b.paneKey) + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-agent-rows.ts index 72dca370eb1..493f18d6294 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-rows.ts @@ -23,23 +23,11 @@ import { resolveAgentTypeFromTerminalTitle } from './worktree-title-derived-agent-rows' import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' - -function tabFromAttributedStatusEntry(entry: AgentStatusEntry): TerminalTab | null { - const parsed = parsePaneKey(entry.paneKey) - if (!parsed || !entry.worktreeId) { - return null - } - return { - id: parsed.tabId, - ptyId: null, - worktreeId: entry.worktreeId, - title: entry.terminalTitle ?? 'Agent', - customTitle: null, - color: null, - sortOrder: Number.MAX_SAFE_INTEGER, - createdAt: entry.stateStartedAt - } -} +import { compareWorktreeAgentRows } from './worktree-agent-row-order' +import { + effectiveWorktreeAgentRowStartedAt, + tabFromWorktreeAttributedStatusEntry +} from './worktree-agent-row-fallback-tab' /** * Resolves the sidebar row agent type, prioritizing launch agent configuration @@ -250,6 +238,7 @@ export function buildWorktreeAgentRows(args: { (rowEntry.state === 'working' || rowEntry.state === 'blocked' || rowEntry.state === 'waiting') + const startedAt = effectiveWorktreeAgentRowStartedAt(rowEntry) rows.push({ paneKey: rowEntry.paneKey, entry: rowEntry, @@ -257,7 +246,7 @@ export function buildWorktreeAgentRows(args: { agentType: resolveRowAgentType(rowEntry, tab), rowSource: 'live', state: shouldDecay ? 'idle' : rowEntry.state, - startedAt: rowEntry.stateHistory[0]?.startedAt ?? rowEntry.stateStartedAt + startedAt }) seenPaneKeys.add(rowEntry.paneKey) } @@ -282,7 +271,8 @@ export function buildWorktreeAgentRows(args: { continue } const rowEntry = entryWithRuntimeOrchestration(entry, args.runtimeAgentOrchestrationByPaneKey) - const tab = tabFromAttributedStatusEntry(rowEntry) + const startedAt = effectiveWorktreeAgentRowStartedAt(rowEntry) + const tab = tabFromWorktreeAttributedStatusEntry(rowEntry, startedAt) if (!tab) { continue } @@ -297,7 +287,7 @@ export function buildWorktreeAgentRows(args: { agentType: resolveRowAgentType(rowEntry, tab), rowSource: 'live', state: shouldDecay ? 'idle' : rowEntry.state, - startedAt: rowEntry.stateHistory[0]?.startedAt ?? rowEntry.stateStartedAt + startedAt }) seenPaneKeys.add(rowEntry.paneKey) } @@ -330,6 +320,8 @@ export function buildWorktreeAgentRows(args: { }) } - rows.sort((a, b) => a.startedAt - b.startedAt) + // Why: hook pings can rebuild the live entry list in a different iteration + // order. Equal-start agents still need a deterministic sidebar order. + rows.sort(compareWorktreeAgentRows) return rows }