Keep sidebar agent rows stable during status updates (#6961)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-06-30 17:29:03 -07:00
committed by GitHub
co-authored by Orca
parent 79828fb1eb
commit 764e2fe61f
4 changed files with 172 additions and 23 deletions
@@ -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',
@@ -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
}
}
@@ -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)
)
}
@@ -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
}