feat(sidebar): redesign Smart worktree sort around hook-reported agent state (#1701)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
54fc977f26
commit
28a5acb931
|
|
@ -158,6 +158,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
|
||||
const prCache = useAppStore((s) => s.prCache)
|
||||
const issueCache = useAppStore((s) => s.issueCache)
|
||||
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const activeTabType = useAppStore((s) => s.activeTabType)
|
||||
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
|
||||
|
|
@ -233,8 +234,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
visibleWorktrees,
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
undefined,
|
||||
agentStatusByPaneKey,
|
||||
runtimePaneTitlesByTabId,
|
||||
ptyIdsByTabId
|
||||
)
|
||||
: switchableWorktreesForRows,
|
||||
|
|
@ -244,7 +245,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
switchableWorktreesForRows,
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
agentStatusByPaneKey,
|
||||
runtimePaneTitlesByTabId,
|
||||
ptyIdsByTabId
|
||||
]
|
||||
)
|
||||
|
|
@ -260,11 +262,18 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
allWorktrees,
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
undefined,
|
||||
agentStatusByPaneKey,
|
||||
runtimePaneTitlesByTabId,
|
||||
ptyIdsByTabId
|
||||
)
|
||||
}, [allWorktrees, tabsByWorktree, repoMap, prCache, ptyIdsByTabId])
|
||||
}, [
|
||||
allWorktrees,
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
agentStatusByPaneKey,
|
||||
runtimePaneTitlesByTabId,
|
||||
ptyIdsByTabId
|
||||
])
|
||||
|
||||
// Why: browser rows need worktree lookups for repo badge colors, and browser
|
||||
// search intentionally includes archived worktrees. This map must cover all
|
||||
|
|
|
|||
|
|
@ -38,10 +38,14 @@ const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
|
|||
]
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ id: 'name', label: 'Name' },
|
||||
{ id: 'smart', label: 'Smart' },
|
||||
{ id: 'recent', label: 'Recent' },
|
||||
{ id: 'repo', label: 'Repo' }
|
||||
{ id: 'name', label: 'Name', description: null },
|
||||
{
|
||||
id: 'smart',
|
||||
label: 'Smart',
|
||||
description: 'Agents that need attention, then most recent activity.'
|
||||
},
|
||||
{ id: 'recent', label: 'Recent', description: null },
|
||||
{ id: 'repo', label: 'Repo', description: null }
|
||||
] as const
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
|
|
@ -116,17 +120,30 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
|||
value={sortBy}
|
||||
onValueChange={(v) => setSortBy(v as typeof sortBy)}
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={opt.id}
|
||||
value={opt.id}
|
||||
// Keep the menu open so people can compare sort modes and
|
||||
// toggle card properties without reopening the same panel.
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
{SORT_OPTIONS.map((opt) => {
|
||||
const radioItem = (
|
||||
<DropdownMenuRadioItem
|
||||
key={opt.id}
|
||||
value={opt.id}
|
||||
// Keep the menu open so people can compare sort modes and
|
||||
// toggle card properties without reopening the same panel.
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
if (!opt.description) {
|
||||
return radioItem
|
||||
}
|
||||
return (
|
||||
<Tooltip key={opt.id}>
|
||||
<TooltipTrigger asChild>{radioItem}</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{opt.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
|||
import { cn } from '@/lib/utils'
|
||||
import type { Worktree, Repo } from '../../../../shared/types'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import { buildWorktreeComparator } from './smart-sort'
|
||||
import {
|
||||
buildExplicitEntriesByTabId,
|
||||
buildWorktreeComparator,
|
||||
computeSmartScore,
|
||||
hasAnyLivePty
|
||||
} from './smart-sort'
|
||||
buildAttentionByWorktree,
|
||||
type SmartClass,
|
||||
type WorktreeAttention
|
||||
} from './smart-attention'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import {
|
||||
type GroupHeaderRow,
|
||||
type Row,
|
||||
|
|
@ -585,10 +587,10 @@ const WorktreeList = React.memo(function WorktreeList() {
|
|||
|
||||
const cardProps = useAppStore((s) => s.worktreeCardProperties)
|
||||
|
||||
// PR cache is needed for PR-status grouping, smart sorting, and when the
|
||||
// PR card property is visible.
|
||||
// PR cache is needed for PR-status grouping and when the PR card property
|
||||
// is visible.
|
||||
const prCache = useAppStore((s) =>
|
||||
groupBy === 'pr-status' || sortBy === 'smart' || cardProps.includes('pr') ? s.prCache : null
|
||||
groupBy === 'pr-status' || cardProps.includes('pr') ? s.prCache : null
|
||||
)
|
||||
|
||||
const sortEpoch = useAppStore((s) => s.sortEpoch)
|
||||
|
|
@ -655,26 +657,40 @@ const WorktreeList = React.memo(function WorktreeList() {
|
|||
// Why useMemo instead of useEffect: the sort order must be computed
|
||||
// synchronously *before* the worktrees memo reads it, otherwise the
|
||||
// first render (and epoch bumps) would use stale/empty data from the ref.
|
||||
// Why a ref alongside the memo: telemetry effects need access to the most
|
||||
// recently computed attention map without forcing every render to read it
|
||||
// from store state again. The ref captures whatever the memo last produced
|
||||
// for the smart branch.
|
||||
const lastAttentionByWorktreeRef = useRef<Map<string, WorktreeAttention> | null>(null)
|
||||
|
||||
const sortedIds = useMemo(() => {
|
||||
const state = useAppStore.getState()
|
||||
const nonArchivedWorktrees = getAllWorktreesFromState(state).filter(
|
||||
(worktree) => !worktree.isArchived
|
||||
)
|
||||
|
||||
// Why cold-start detection: the smart score is dominated by ephemeral
|
||||
// signals (running jobs +60, live terminals +12, needs attention +35)
|
||||
// that vanish after restart. Recomputing the smart score on cold start
|
||||
// produces a shuffled ordering because those signals are gone while
|
||||
// persistent ones (unread, linked PR) survive — changing relative ranks.
|
||||
// Instead, restore the pre-shutdown order from the persisted sortOrder
|
||||
// snapshot, and switch to the live smart score once PTYs start spawning.
|
||||
// Why cold-start detection: smart-class resolution depends on the
|
||||
// agent-status snapshot (agentStatusByPaneKey) hydrating from the hook
|
||||
// server, which lands asynchronously after launch. Running the warm
|
||||
// comparator before that arrives would collapse every worktree to Class 4
|
||||
// and shuffle the sidebar against the comparator's tiebreakers. Restore
|
||||
// the pre-shutdown order from the persisted sortOrder snapshot until any
|
||||
// live PTY appears, then switch to the live class layer. See Edge case 8
|
||||
// in docs/smart-worktree-order-redesign.md.
|
||||
if (sortBy === 'smart' && !sessionHasHadPty.current) {
|
||||
if (hasAnyLivePty(state.tabsByWorktree, state.ptyIdsByTabId)) {
|
||||
// Why: `tabHasLivePty` (over `ptyIdsByTabId`) is the source of truth for
|
||||
// liveness — slept terminals retain `tab.ptyId` as a wake hint, so reading
|
||||
// it directly would falsely keep cold-start ordering off after restart.
|
||||
const hasAnyLivePty = Object.values(state.tabsByWorktree)
|
||||
.flat()
|
||||
.some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id))
|
||||
if (hasAnyLivePty) {
|
||||
sessionHasHadPty.current = true
|
||||
} else {
|
||||
nonArchivedWorktrees.sort(
|
||||
(a, b) => b.sortOrder - a.sortOrder || a.displayName.localeCompare(b.displayName)
|
||||
)
|
||||
lastAttentionByWorktreeRef.current = null
|
||||
return nonArchivedWorktrees.map((w) => w.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -682,51 +698,22 @@ const WorktreeList = React.memo(function WorktreeList() {
|
|||
const currentTabs = state.tabsByWorktree
|
||||
const now = Date.now()
|
||||
// Why precompute: this is the hot sidebar sort. Array.sort invokes the
|
||||
// comparator O(N log N) times, and the smart-score computation would
|
||||
// otherwise scan `agentStatusByPaneKey` (O(E)) or do per-worktree O(T)
|
||||
// index lookups on every call. Two layered optimizations:
|
||||
// 1. Build the tabId → explicit-entries index ONCE (O(E)) so the
|
||||
// per-worktree scoring does cheap lookups instead of rescanning.
|
||||
// 2. Precompute scores once per worktree (decorate-sort-undecorate) so
|
||||
// the comparator does O(1) map lookups instead of re-scoring per
|
||||
// comparison.
|
||||
// Combined: O(E) index + O(N×T) scoring + O(N log N) sort, instead of
|
||||
// O(N × E × T) per sortEpoch bump. Only smart mode uses the score map;
|
||||
// other modes ignore it.
|
||||
const explicitByTabId =
|
||||
sortBy === 'smart' ? buildExplicitEntriesByTabId(state.agentStatusByPaneKey) : undefined
|
||||
const precomputedScores =
|
||||
// comparator O(N log N) times. Build the per-worktree attention map ONCE
|
||||
// (O(E + N×T×H) where H = stateHistory length, bounded at 20) so the
|
||||
// comparator does O(1) map lookups instead of re-resolving per comparison.
|
||||
const attentionByWorktree =
|
||||
sortBy === 'smart'
|
||||
? new Map<string, number>(
|
||||
nonArchivedWorktrees.map((w) => [
|
||||
w.id,
|
||||
computeSmartScore(
|
||||
w,
|
||||
currentTabs,
|
||||
repoMap,
|
||||
state.prCache,
|
||||
now,
|
||||
state.agentStatusByPaneKey,
|
||||
explicitByTabId,
|
||||
state.ptyIdsByTabId
|
||||
)
|
||||
])
|
||||
? buildAttentionByWorktree(
|
||||
nonArchivedWorktrees,
|
||||
currentTabs,
|
||||
state.agentStatusByPaneKey,
|
||||
state.runtimePaneTitlesByTabId,
|
||||
state.ptyIdsByTabId,
|
||||
now
|
||||
)
|
||||
: undefined
|
||||
nonArchivedWorktrees.sort(
|
||||
buildWorktreeComparator(
|
||||
sortBy,
|
||||
currentTabs,
|
||||
repoMap,
|
||||
state.prCache,
|
||||
now,
|
||||
null,
|
||||
state.agentStatusByPaneKey,
|
||||
precomputedScores,
|
||||
explicitByTabId,
|
||||
state.ptyIdsByTabId
|
||||
)
|
||||
)
|
||||
: new Map<string, WorktreeAttention>()
|
||||
lastAttentionByWorktreeRef.current = sortBy === 'smart' ? attentionByWorktree : null
|
||||
nonArchivedWorktrees.sort(buildWorktreeComparator(sortBy, repoMap, now, attentionByWorktree))
|
||||
return nonArchivedWorktrees.map((w) => w.id)
|
||||
// debouncedSortEpoch is an intentional trigger: it's not read inside the
|
||||
// memo, but its change signals that the sort order should be recomputed.
|
||||
|
|
@ -734,6 +721,95 @@ const WorktreeList = React.memo(function WorktreeList() {
|
|||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSortEpoch, repoMap, sortBy])
|
||||
|
||||
// Why a ref of prior class per worktree: smart_sort_class_1_promotion must
|
||||
// fire only on transitions INTO Class 1, not on every recompute that keeps
|
||||
// a worktree there. Suppressing repeats with a ref keeps the event signal
|
||||
// clean without growing component state.
|
||||
const prevClassByWorktreeIdRef = useRef<Map<string, SmartClass>>(new Map())
|
||||
// Why gate the first observation: when Smart mode first activates (app
|
||||
// start, or toggling away from Smart and back), the prev-class map is
|
||||
// empty, so every existing Class-1 worktree would look like a fresh
|
||||
// promotion and produce a burst of spurious events. Treat the first
|
||||
// observation as a silent baseline — populate the map but don't fire.
|
||||
const hasObservedSmartOnceRef = useRef<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const attention = lastAttentionByWorktreeRef.current
|
||||
if (sortBy !== 'smart' || !attention) {
|
||||
// Why reset: when the user switches off Smart, drop the prior-class map
|
||||
// so re-entering Smart doesn't fire stale promotion events for worktrees
|
||||
// whose state has since changed. Reset the first-observation gate too
|
||||
// so the next Smart-mode session starts with a fresh silent baseline.
|
||||
prevClassByWorktreeIdRef.current = new Map()
|
||||
hasObservedSmartOnceRef.current = false
|
||||
return
|
||||
}
|
||||
const next = new Map<string, SmartClass>()
|
||||
const isFirstObservation = !hasObservedSmartOnceRef.current
|
||||
for (const [worktreeId, info] of attention) {
|
||||
const prev = prevClassByWorktreeIdRef.current.get(worktreeId)
|
||||
if (!isFirstObservation && info.cls === 1 && prev !== 1 && info.cause) {
|
||||
track('smart_sort_class_1_promotion', { cause: info.cause })
|
||||
}
|
||||
next.set(worktreeId, info.cls)
|
||||
}
|
||||
prevClassByWorktreeIdRef.current = next
|
||||
hasObservedSmartOnceRef.current = true
|
||||
}, [sortBy, sortedIds])
|
||||
|
||||
// Why a 30s timer owned by the component: the class-distribution event is
|
||||
// a coarse health signal — we only need it often enough to see the steady
|
||||
// state, not every render. Cancelling on unmount or sort switch keeps the
|
||||
// timer from firing while the user is in Recent/Name/Repo modes.
|
||||
useEffect(() => {
|
||||
if (sortBy !== 'smart') {
|
||||
return
|
||||
}
|
||||
const fire = () => {
|
||||
const attention = lastAttentionByWorktreeRef.current
|
||||
if (!attention) {
|
||||
return
|
||||
}
|
||||
let class1 = 0
|
||||
let class2 = 0
|
||||
let class3 = 0
|
||||
let class4 = 0
|
||||
for (const info of attention.values()) {
|
||||
if (info.cls === 1) {
|
||||
class1++
|
||||
} else if (info.cls === 2) {
|
||||
class2++
|
||||
} else if (info.cls === 3) {
|
||||
class3++
|
||||
} else {
|
||||
class4++
|
||||
}
|
||||
}
|
||||
track('smart_sort_class_distribution', {
|
||||
class_1: class1,
|
||||
class_2: class2,
|
||||
class_3: class3,
|
||||
class_4: class4,
|
||||
total_worktrees: attention.size
|
||||
})
|
||||
}
|
||||
fire()
|
||||
const timer = setInterval(fire, 30_000)
|
||||
return () => clearInterval(timer)
|
||||
}, [sortBy])
|
||||
|
||||
// Why fire on the transition: switching away from Smart is the user signal
|
||||
// we care about (regression). Use a ref to compare against the previous
|
||||
// value so we don't double-fire when sortBy momentarily round-trips.
|
||||
const prevSortByRef = useRef(sortBy)
|
||||
useEffect(() => {
|
||||
const prev = prevSortByRef.current
|
||||
prevSortByRef.current = sortBy
|
||||
if (prev === 'smart' && sortBy === 'recent') {
|
||||
track('smart_to_recent_switch', {})
|
||||
}
|
||||
}, [sortBy])
|
||||
|
||||
// Persist the computed sort order so the sidebar can be restored after
|
||||
// restart. Only persist during live sessions (sessionHasHadPty latched) —
|
||||
// on cold start we are *reading* the persisted order, not overwriting it.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,526 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStateHistoryEntry,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import {
|
||||
IDLE,
|
||||
buildAttentionByWorktree,
|
||||
mostRecentAttentionInHistory,
|
||||
resolveAttention,
|
||||
type PaneInput
|
||||
} from './smart-attention'
|
||||
import type { TerminalTab, Worktree } from '../../../../shared/types'
|
||||
|
||||
function hookPane(entry: AgentStatusEntry): PaneInput {
|
||||
return { kind: 'hook', entry }
|
||||
}
|
||||
|
||||
function hookPanes(entries: AgentStatusEntry[]): PaneInput[] {
|
||||
return entries.map((entry) => ({ kind: 'hook', entry }))
|
||||
}
|
||||
|
||||
const NOW = new Date('2026-03-27T12:00:00.000Z').getTime()
|
||||
|
||||
function makeEntry(overrides: Partial<AgentStatusEntry> & { paneKey: string }): AgentStatusEntry {
|
||||
return {
|
||||
state: overrides.state ?? 'working',
|
||||
prompt: overrides.prompt ?? '',
|
||||
updatedAt: overrides.updatedAt ?? NOW - 30_000,
|
||||
stateStartedAt: overrides.stateStartedAt ?? overrides.updatedAt ?? NOW - 30_000,
|
||||
agentType: overrides.agentType ?? 'codex',
|
||||
paneKey: overrides.paneKey,
|
||||
terminalTitle: overrides.terminalTitle,
|
||||
stateHistory: overrides.stateHistory ?? [],
|
||||
interrupted: overrides.interrupted
|
||||
}
|
||||
}
|
||||
|
||||
function makeHistory(
|
||||
state: AgentStateHistoryEntry['state'],
|
||||
startedAt: number,
|
||||
interrupted = false
|
||||
): AgentStateHistoryEntry {
|
||||
return { state, prompt: '', startedAt, interrupted: interrupted || undefined }
|
||||
}
|
||||
|
||||
describe('mostRecentAttentionInHistory', () => {
|
||||
it('returns null on an empty history', () => {
|
||||
expect(mostRecentAttentionInHistory([])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the latest done/blocked/waiting startedAt', () => {
|
||||
const result = mostRecentAttentionInHistory([
|
||||
makeHistory('working', NOW - 5_000),
|
||||
makeHistory('done', NOW - 4_000),
|
||||
makeHistory('working', NOW - 3_000),
|
||||
makeHistory('blocked', NOW - 2_000),
|
||||
makeHistory('working', NOW - 1_000)
|
||||
])
|
||||
expect(result).toBe(NOW - 2_000)
|
||||
})
|
||||
|
||||
it('skips interrupted done rows', () => {
|
||||
expect(
|
||||
mostRecentAttentionInHistory([
|
||||
makeHistory('done', NOW - 4_000),
|
||||
makeHistory('done', NOW - 1_000, true)
|
||||
])
|
||||
).toBe(NOW - 4_000)
|
||||
})
|
||||
|
||||
it('returns null when only interrupted dones exist', () => {
|
||||
expect(mostRecentAttentionInHistory([makeHistory('done', NOW - 1_000, true)])).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores working rows entirely', () => {
|
||||
expect(
|
||||
mostRecentAttentionInHistory([
|
||||
makeHistory('working', NOW - 1_000),
|
||||
makeHistory('working', NOW - 2_000)
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('skips history rows with non-finite startedAt', () => {
|
||||
// Why: NaN passes through > silently; Infinity would pin the worktree
|
||||
// at the top of Class 3 forever. Treat non-finite as missing.
|
||||
expect(
|
||||
mostRecentAttentionInHistory([
|
||||
makeHistory('done', Number.NaN),
|
||||
makeHistory('blocked', Number.POSITIVE_INFINITY),
|
||||
makeHistory('done', NOW - 5_000)
|
||||
])
|
||||
).toBe(NOW - 5_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveAttention', () => {
|
||||
it('returns idle when there are no panes', () => {
|
||||
expect(resolveAttention([], NOW)).toEqual(IDLE)
|
||||
})
|
||||
|
||||
it('classifies a blocked pane as Class 1 with stateStartedAt', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 30_000
|
||||
})
|
||||
expect(resolveAttention([hookPane(entry)], NOW)).toEqual({
|
||||
cls: 1,
|
||||
attentionTimestamp: NOW - 60_000,
|
||||
cause: 'blocked'
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies a waiting pane as Class 1', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'waiting',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 30_000
|
||||
})
|
||||
expect(resolveAttention([hookPane(entry)], NOW).cls).toBe(1)
|
||||
})
|
||||
|
||||
it('classifies a done pane as Class 2', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 90_000,
|
||||
updatedAt: NOW - 30_000
|
||||
})
|
||||
expect(resolveAttention([hookPane(entry)], NOW)).toEqual({
|
||||
cls: 2,
|
||||
attentionTimestamp: NOW - 90_000
|
||||
})
|
||||
})
|
||||
|
||||
it('treats interrupted done as idle', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'done',
|
||||
interrupted: true,
|
||||
stateStartedAt: NOW - 90_000,
|
||||
updatedAt: NOW - 30_000
|
||||
})
|
||||
expect(resolveAttention([hookPane(entry)], NOW)).toEqual(IDLE)
|
||||
})
|
||||
|
||||
it('classifies a working pane with prior done as Class 3 with the prior timestamp', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 10_000,
|
||||
updatedAt: NOW - 1_000,
|
||||
stateHistory: [makeHistory('done', NOW - 5 * 60_000)]
|
||||
})
|
||||
expect(resolveAttention([hookPane(entry)], NOW)).toEqual({
|
||||
cls: 3,
|
||||
attentionTimestamp: NOW - 5 * 60_000
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to current stateStartedAt when working has no prior attention history', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 10_000,
|
||||
updatedAt: NOW - 1_000,
|
||||
stateHistory: []
|
||||
})
|
||||
expect(resolveAttention([hookPane(entry)], NOW)).toEqual({
|
||||
cls: 3,
|
||||
attentionTimestamp: NOW - 10_000
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back when history contains only interrupted done rows', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 10_000,
|
||||
updatedAt: NOW - 1_000,
|
||||
stateHistory: [makeHistory('done', NOW - 60_000, true)]
|
||||
})
|
||||
expect(resolveAttention([hookPane(entry)], NOW)).toEqual({
|
||||
cls: 3,
|
||||
attentionTimestamp: NOW - 10_000
|
||||
})
|
||||
})
|
||||
|
||||
it('skips stale entries (updatedAt older than the freshness window)', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000,
|
||||
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000
|
||||
})
|
||||
expect(resolveAttention([hookPane(entry)], NOW)).toEqual(IDLE)
|
||||
})
|
||||
|
||||
it('takes the most attention-demanding class across multiple panes', () => {
|
||||
const blocked = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 30_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
const done = makeEntry({
|
||||
paneKey: 't:2',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 5_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
const working = makeEntry({
|
||||
paneKey: 't:3',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 1_000,
|
||||
updatedAt: NOW - 100
|
||||
})
|
||||
expect(resolveAttention(hookPanes([done, working, blocked]), NOW).cls).toBe(1)
|
||||
})
|
||||
|
||||
it('within the resolved class, takes the freshest attention timestamp across panes', () => {
|
||||
const olderBlocked = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
const newerBlocked = makeEntry({
|
||||
paneKey: 't:2',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 5_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
expect(resolveAttention(hookPanes([olderBlocked, newerBlocked]), NOW)).toEqual({
|
||||
cls: 1,
|
||||
attentionTimestamp: NOW - 5_000,
|
||||
cause: 'blocked'
|
||||
})
|
||||
})
|
||||
|
||||
it('skips entries with non-finite stateStartedAt', () => {
|
||||
// Why: NaN > anything === false, so without the guard a corrupted entry
|
||||
// would silently sink the worktree to the bottom of its class.
|
||||
const corrupted = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: Number.NaN,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
expect(resolveAttention([hookPane(corrupted)], NOW)).toEqual(IDLE)
|
||||
})
|
||||
|
||||
it('title-heuristic permission maps to Class 1 with ts = now', () => {
|
||||
expect(
|
||||
resolveAttention(
|
||||
[{ kind: 'title', status: 'permission', worktreeLastActivityAt: NOW - 60_000 }],
|
||||
NOW
|
||||
)
|
||||
).toEqual({ cls: 1, attentionTimestamp: NOW, cause: 'title-heuristic' })
|
||||
})
|
||||
|
||||
it('title-heuristic working maps to Class 3 with ts = worktree.lastActivityAt', () => {
|
||||
expect(
|
||||
resolveAttention(
|
||||
[{ kind: 'title', status: 'working', worktreeLastActivityAt: NOW - 30_000 }],
|
||||
NOW
|
||||
)
|
||||
).toEqual({ cls: 3, attentionTimestamp: NOW - 30_000 })
|
||||
})
|
||||
|
||||
it('title-heuristic idle / null contributes nothing (Class 4)', () => {
|
||||
expect(
|
||||
resolveAttention(
|
||||
[
|
||||
{ kind: 'title', status: 'idle', worktreeLastActivityAt: NOW - 1_000 },
|
||||
{ kind: 'title', status: null, worktreeLastActivityAt: NOW - 1_000 }
|
||||
],
|
||||
NOW
|
||||
)
|
||||
).toEqual(IDLE)
|
||||
})
|
||||
|
||||
it('hook entry overrides title heuristic on the same pane (hook wins when fresh)', () => {
|
||||
// Why: per-pane authority means a fresh hook entry beats whatever the
|
||||
// title says — a 'done' hook plus a 'working'-classified title stays
|
||||
// Class 2.
|
||||
const done = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 30_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
expect(
|
||||
resolveAttention(
|
||||
[
|
||||
hookPane(done),
|
||||
{ kind: 'title', status: 'working', worktreeLastActivityAt: NOW - 60_000 }
|
||||
],
|
||||
NOW
|
||||
).cls
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
it('per-pane authority across panes: pane A hook=done, pane B title=permission → Class 1', () => {
|
||||
// Why: hook authority is per-pane, not per-worktree. A hookless pane
|
||||
// showing 'permission' must still promote the whole worktree to Class 1.
|
||||
const done = makeEntry({
|
||||
paneKey: 'tab:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 30_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
expect(
|
||||
resolveAttention(
|
||||
[
|
||||
hookPane(done),
|
||||
{ kind: 'title', status: 'permission', worktreeLastActivityAt: NOW - 1_000 }
|
||||
],
|
||||
NOW
|
||||
)
|
||||
).toEqual({ cls: 1, attentionTimestamp: NOW, cause: 'title-heuristic' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAttentionByWorktree', () => {
|
||||
function makeWorktree(id: string): Worktree {
|
||||
return {
|
||||
id,
|
||||
repoId: 'repo-1',
|
||||
path: `/tmp/${id}`,
|
||||
branch: `refs/heads/${id}`,
|
||||
head: 'abc',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
comment: '',
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
displayName: id,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function makeTab(id: string, worktreeId: string): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
ptyId: 'pty',
|
||||
worktreeId,
|
||||
title: 'bash',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function ptyMap(tabIds: string[]): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const id of tabIds) {
|
||||
out[id] = ['pty-1']
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
it('returns IDLE for worktrees with no tabs', () => {
|
||||
const w = makeWorktree('wt-1')
|
||||
const map = buildAttentionByWorktree([w], {}, {}, {}, {}, NOW)
|
||||
expect(map.get(w.id)).toEqual(IDLE)
|
||||
})
|
||||
|
||||
it('aggregates entries across multiple panes on the same tab', () => {
|
||||
const w = makeWorktree('wt-1')
|
||||
const tab = makeTab('tab-1', w.id)
|
||||
const entries: Record<string, AgentStatusEntry> = {
|
||||
'tab-1:1': makeEntry({
|
||||
paneKey: 'tab-1:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 10_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-1:2': makeEntry({
|
||||
paneKey: 'tab-1:2',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 5_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const map = buildAttentionByWorktree([w], { [w.id]: [tab] }, entries, {}, ptyMap([tab.id]), NOW)
|
||||
expect(map.get(w.id)).toEqual({
|
||||
cls: 1,
|
||||
attentionTimestamp: NOW - 5_000,
|
||||
cause: 'blocked'
|
||||
})
|
||||
})
|
||||
|
||||
it('skips malformed paneKeys (no colon)', () => {
|
||||
const w = makeWorktree('wt-1')
|
||||
const tab = makeTab('tab-1', w.id)
|
||||
const map = buildAttentionByWorktree(
|
||||
[w],
|
||||
{ [w.id]: [tab] },
|
||||
{
|
||||
malformed: makeEntry({
|
||||
paneKey: 'malformed',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 1_000,
|
||||
updatedAt: NOW - 100
|
||||
})
|
||||
},
|
||||
{},
|
||||
ptyMap([tab.id]),
|
||||
NOW
|
||||
)
|
||||
expect(map.get(w.id)).toEqual(IDLE)
|
||||
})
|
||||
|
||||
it('title-heuristic Class 1: hookless pane with permission title → Class 1 with ts = now', () => {
|
||||
const w = makeWorktree('wt-1')
|
||||
const tab = makeTab('tab-1', w.id)
|
||||
const map = buildAttentionByWorktree(
|
||||
[w],
|
||||
{ [w.id]: [tab] },
|
||||
{},
|
||||
{ [tab.id]: { 1: '✋ Gemini CLI' } },
|
||||
ptyMap([tab.id]),
|
||||
NOW
|
||||
)
|
||||
expect(map.get(w.id)).toEqual({
|
||||
cls: 1,
|
||||
attentionTimestamp: NOW,
|
||||
cause: 'title-heuristic'
|
||||
})
|
||||
})
|
||||
|
||||
it('title-heuristic Class 3: hookless pane with working title → ts = worktree.lastActivityAt', () => {
|
||||
const w = { ...makeWorktree('wt-1'), lastActivityAt: NOW - 30_000 }
|
||||
const tab = makeTab('tab-1', w.id)
|
||||
const map = buildAttentionByWorktree(
|
||||
[w],
|
||||
{ [w.id]: [tab] },
|
||||
{},
|
||||
{ [tab.id]: { 1: '⠋ Claude' } },
|
||||
ptyMap([tab.id]),
|
||||
NOW
|
||||
)
|
||||
expect(map.get(w.id)).toEqual({ cls: 3, attentionTimestamp: NOW - 30_000 })
|
||||
})
|
||||
|
||||
it('hook overrides title on the same pane (hook=done + working-style title stays Class 2)', () => {
|
||||
const w = makeWorktree('wt-1')
|
||||
const tab = makeTab('tab-1', w.id)
|
||||
const entries: Record<string, AgentStatusEntry> = {
|
||||
'tab-1:1': makeEntry({
|
||||
paneKey: 'tab-1:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 30_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const map = buildAttentionByWorktree(
|
||||
[w],
|
||||
{ [w.id]: [tab] },
|
||||
entries,
|
||||
// Same paneId 1 — must NOT double-promote into Class 3.
|
||||
{ [tab.id]: { 1: '⠋ Claude' } },
|
||||
ptyMap([tab.id]),
|
||||
NOW
|
||||
)
|
||||
expect(map.get(w.id)).toEqual({ cls: 2, attentionTimestamp: NOW - 30_000 })
|
||||
})
|
||||
|
||||
it('per-pane authority across panes: pane A fresh hook=done, pane B no hook + permission title → Class 1', () => {
|
||||
const w = makeWorktree('wt-1')
|
||||
const tab = makeTab('tab-1', w.id)
|
||||
const entries: Record<string, AgentStatusEntry> = {
|
||||
'tab-1:1': makeEntry({
|
||||
paneKey: 'tab-1:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 30_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const map = buildAttentionByWorktree(
|
||||
[w],
|
||||
{ [w.id]: [tab] },
|
||||
entries,
|
||||
// Pane 2 has no hook — title fallback fires for it.
|
||||
{ [tab.id]: { 1: 'something', 2: '✋ Gemini CLI' } },
|
||||
ptyMap([tab.id]),
|
||||
NOW
|
||||
)
|
||||
expect(map.get(w.id)).toEqual({
|
||||
cls: 1,
|
||||
attentionTimestamp: NOW,
|
||||
cause: 'title-heuristic'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fire title fallback for tabs without a live PTY', () => {
|
||||
// Why: runtimePaneTitlesByTabId is preserved under sleep; without the
|
||||
// tabHasLivePty gate, a slept tab whose preserved title still matches a
|
||||
// working pattern would leak into the comparator.
|
||||
const w = makeWorktree('wt-1')
|
||||
const tab = makeTab('tab-1', w.id)
|
||||
const map = buildAttentionByWorktree(
|
||||
[w],
|
||||
{ [w.id]: [tab] },
|
||||
{},
|
||||
{ [tab.id]: { 1: '✋ Gemini CLI' } },
|
||||
// No live pty for this tab.
|
||||
{},
|
||||
NOW
|
||||
)
|
||||
expect(map.get(w.id)).toEqual(IDLE)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import type { AgentStatus } from '../../../../shared/agent-detection'
|
||||
import type { TerminalTab, Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStateHistoryEntry,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
|
||||
/**
|
||||
* Ordinal class for the "Smart" sort. Lower number = more attention-demanding.
|
||||
* 1 — Needs you (`blocked` / `waiting`)
|
||||
* 2 — Done (`done`, not interrupted)
|
||||
* 3 — Working (`working`)
|
||||
* 4 — Idle (no live entry, stale entry, or interrupted `done`)
|
||||
*
|
||||
* Class is the primary sort key; within a class the comparator falls back to
|
||||
* the resolved attention timestamp. See docs/smart-worktree-order-redesign.md.
|
||||
*/
|
||||
export type SmartClass = 1 | 2 | 3 | 4
|
||||
|
||||
/**
|
||||
* What surfaced a worktree into Class 1. Carried only for Class 1 results
|
||||
* because that's the only class the telemetry promotion event reports on.
|
||||
* - `blocked` / `waiting`: hook entry in that state.
|
||||
* - `title-heuristic`: no fresh hook entry; runtime pane title classified
|
||||
* as `'permission'` by `detectAgentStatusFromTitle`.
|
||||
*/
|
||||
export type AttentionCause = 'blocked' | 'waiting' | 'title-heuristic'
|
||||
|
||||
/**
|
||||
* Per-worktree resolution computed once before sorting.
|
||||
*
|
||||
* `attentionTimestamp` semantics depend on the class:
|
||||
* - Class 1 / 2: `stateStartedAt` of the current entry (when the agent
|
||||
* entered the attention state).
|
||||
* - Class 3: `stateStartedAt` of the most recent prior `done`/`blocked`/
|
||||
* `waiting` entry in `stateHistory[]`, falling back to the current
|
||||
* `working` `stateStartedAt` when no prior attention event exists.
|
||||
* - Class 4: `0` — the comparator drops to `effectiveRecentActivity` for
|
||||
* within-class ordering on idle worktrees.
|
||||
*
|
||||
* `cause` is set only when `cls === 1`, and reflects the input that won the
|
||||
* within-class max-timestamp comparison. Used for the
|
||||
* `smart_sort_class_1_promotion` telemetry event.
|
||||
*/
|
||||
export type WorktreeAttention = {
|
||||
cls: SmartClass
|
||||
attentionTimestamp: number
|
||||
cause?: AttentionCause
|
||||
}
|
||||
|
||||
export const IDLE: WorktreeAttention = { cls: 4, attentionTimestamp: 0 }
|
||||
|
||||
/**
|
||||
* Walk a pane's state-history rows and return the timestamp of the most
|
||||
* recent `done`/`blocked`/`waiting` entry, ignoring `done` rows that were
|
||||
* interrupted (the user pressed Ctrl+C — that turn no longer demands
|
||||
* attention). Returns `null` when no qualifying row exists.
|
||||
*/
|
||||
export function mostRecentAttentionInHistory(history: AgentStateHistoryEntry[]): number | null {
|
||||
let max = 0
|
||||
for (const h of history) {
|
||||
// Why: setAgentStatus preserves `interrupted` on history rows when an
|
||||
// interrupted `done` transitions out, so we can filter on history the
|
||||
// same way the current entry does.
|
||||
if (h.state === 'done' && h.interrupted) {
|
||||
continue
|
||||
}
|
||||
if (h.state === 'done' || h.state === 'blocked' || h.state === 'waiting') {
|
||||
// Why: NaN is silently skipped by `>`, but Infinity from a corrupted
|
||||
// row would pin the worktree at the top of Class 3 forever. Treat
|
||||
// non-finite values as missing.
|
||||
if (!Number.isFinite(h.startedAt)) {
|
||||
continue
|
||||
}
|
||||
if (h.startedAt > max) {
|
||||
max = h.startedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
return max > 0 ? max : null
|
||||
}
|
||||
|
||||
/**
|
||||
* One pane's contribution to a worktree's attention class. Hook entries from
|
||||
* `agentStatusByPaneKey` are authoritative when fresh; otherwise we fall back
|
||||
* to the terminal-title heuristic for hookless agents (Edge case 9 in the
|
||||
* design doc). Hook authority is per-pane, not per-worktree — a worktree with
|
||||
* a fresh hook on pane A and only a title on pane B mixes both branches.
|
||||
*/
|
||||
export type PaneInput =
|
||||
| { kind: 'hook'; entry: AgentStatusEntry }
|
||||
// Why: TerminalTab has no per-tab lastActivityAt; the worktree-level value
|
||||
// is enough since within-class ordering compares across worktrees.
|
||||
| { kind: 'title'; status: AgentStatus | null; worktreeLastActivityAt: number }
|
||||
|
||||
/**
|
||||
* Resolve a worktree's class + attention timestamp from its panes' inputs.
|
||||
* Stale hook entries (older than `AGENT_STATUS_STALE_AFTER_MS`) are skipped
|
||||
* — the worktree falls to Class 4 if no fresh hook entry and no recognized
|
||||
* title heuristic exists.
|
||||
*
|
||||
* Across multiple panes:
|
||||
* - `cls` is the **min** (most attention-demanding pane wins).
|
||||
* - `attentionTimestamp` is the **max** within the resolved class.
|
||||
*/
|
||||
export function resolveAttention(panes: PaneInput[], now: number): WorktreeAttention {
|
||||
let bestCls: SmartClass = 4
|
||||
let bestTs = 0
|
||||
let bestCause: AttentionCause | undefined
|
||||
|
||||
for (const pane of panes) {
|
||||
let cls: SmartClass
|
||||
let ts: number
|
||||
let cause: AttentionCause | undefined
|
||||
|
||||
if (pane.kind === 'hook') {
|
||||
const entry = pane.entry
|
||||
if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
|
||||
continue
|
||||
}
|
||||
// Why: defensive guard. NaN/Infinity from a corrupted stateStartedAt would
|
||||
// poison comparisons (NaN > anything === false), silently dropping the
|
||||
// worktree to the bottom of its class. Treat as a missing entry.
|
||||
if (!Number.isFinite(entry.stateStartedAt)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.state === 'blocked' || entry.state === 'waiting') {
|
||||
cls = 1
|
||||
ts = entry.stateStartedAt
|
||||
cause = entry.state
|
||||
} else if (entry.state === 'done') {
|
||||
// Why: an interrupted `done` (user pressed Ctrl+C) is the user signalling
|
||||
// "I'm done with this turn". Treat as idle, not as Class 2 attention.
|
||||
if (entry.interrupted) {
|
||||
continue
|
||||
}
|
||||
cls = 2
|
||||
ts = entry.stateStartedAt
|
||||
} else {
|
||||
// working
|
||||
cls = 3
|
||||
// Why: within Class 3, sort by the most recent prior attention event so
|
||||
// a worktree that just transitioned done→working stays above one that's
|
||||
// been working for an hour. Falls back to the current stateStartedAt
|
||||
// when stateHistory is empty (e.g. fresh after restart).
|
||||
const prior = mostRecentAttentionInHistory(entry.stateHistory)
|
||||
ts = prior ?? entry.stateStartedAt
|
||||
}
|
||||
} else {
|
||||
// Title-heuristic fallback (no fresh hook entry for this pane). Hook
|
||||
// wins when fresh; this branch only fires for hookless panes.
|
||||
if (pane.status === 'permission') {
|
||||
cls = 1
|
||||
// Why now: the title detector exposes no stateStartedAt. Using `now`
|
||||
// pins the worktree to the top of Class 1 until a hook event or the
|
||||
// next sort, matching the user's "just noticed" mental model.
|
||||
ts = now
|
||||
cause = 'title-heuristic'
|
||||
} else if (pane.status === 'working') {
|
||||
cls = 3
|
||||
ts = pane.worktreeLastActivityAt
|
||||
} else {
|
||||
// 'idle' or null: nothing to assert; pane stays in Class 4.
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Why min on class: smaller class number = higher priority. Any pane in a
|
||||
// more attention-demanding class promotes the whole worktree. Within the
|
||||
// same class, take the max timestamp so the freshest attention event wins.
|
||||
if (cls < bestCls || (cls === bestCls && ts > bestTs)) {
|
||||
bestCls = cls
|
||||
bestTs = ts
|
||||
bestCause = cause
|
||||
}
|
||||
}
|
||||
|
||||
return bestCls === 1 && bestCause
|
||||
? { cls: bestCls, attentionTimestamp: bestTs, cause: bestCause }
|
||||
: { cls: bestCls, attentionTimestamp: bestTs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `tabId → entries[]` index over `agentStatusByPaneKey`. Entries are
|
||||
* keyed by the `tabId` prefix of their paneKey (paneKey format:
|
||||
* `${tabId}:${paneId}`). Doing this once per sort lets each worktree's
|
||||
* resolution pay O(T) lookups instead of scanning the full map.
|
||||
*/
|
||||
export function buildExplicitEntriesByTabId(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined
|
||||
): Map<string, AgentStatusEntry[]> {
|
||||
const byTab = new Map<string, AgentStatusEntry[]>()
|
||||
if (!agentStatusByPaneKey) {
|
||||
return byTab
|
||||
}
|
||||
for (const entry of Object.values(agentStatusByPaneKey)) {
|
||||
const colon = entry.paneKey.indexOf(':')
|
||||
// Why: paneKey must be `${tabId}:${paneId}`. Skip malformed entries (no
|
||||
// colon or leading colon) rather than bucketing them under an empty tabId.
|
||||
if (colon <= 0) {
|
||||
continue
|
||||
}
|
||||
const tabId = entry.paneKey.slice(0, colon)
|
||||
const bucket = byTab.get(tabId)
|
||||
if (bucket) {
|
||||
bucket.push(entry)
|
||||
} else {
|
||||
byTab.set(tabId, [entry])
|
||||
}
|
||||
}
|
||||
return byTab
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the paneId from a `${tabId}:${paneId}` paneKey, returning null for
|
||||
* malformed keys (no colon or non-numeric tail). Used for per-pane authority:
|
||||
* we need to know which paneIds already have a fresh hook entry so we don't
|
||||
* double-count them via the title fallback.
|
||||
*/
|
||||
function paneIdFromPaneKey(paneKey: string): number | null {
|
||||
const colon = paneKey.indexOf(':')
|
||||
if (colon <= 0) {
|
||||
return null
|
||||
}
|
||||
const id = Number(paneKey.slice(colon + 1))
|
||||
return Number.isFinite(id) ? id : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-worktree attention map consumed by the smart comparator.
|
||||
*
|
||||
* Hook authority is per-pane: each pane that has a fresh hook entry uses it;
|
||||
* each pane without one falls back to the title heuristic when its runtime
|
||||
* pane title (or tab title for unmounted tabs) maps to a known status. The
|
||||
* title branch is gated on `tabHasLivePty` so slept tabs whose preserved
|
||||
* titles still match a working pattern don't leak through.
|
||||
*
|
||||
* Cost: O(E + N × T × H) where E = total entries, N = worktrees, T = tabs per
|
||||
* worktree, H = history length (bounded at AGENT_STATE_HISTORY_MAX = 20).
|
||||
*/
|
||||
export function buildAttentionByWorktree(
|
||||
worktrees: Worktree[],
|
||||
tabsByWorktree: Record<string, TerminalTab[]> | null,
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined,
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string>>,
|
||||
ptyIdsByTabId: Record<string, string[]>,
|
||||
now: number
|
||||
): Map<string, WorktreeAttention> {
|
||||
const byTab = buildExplicitEntriesByTabId(agentStatusByPaneKey)
|
||||
const result = new Map<string, WorktreeAttention>()
|
||||
|
||||
for (const worktree of worktrees) {
|
||||
const tabs = tabsByWorktree?.[worktree.id]
|
||||
if (!tabs || tabs.length === 0) {
|
||||
result.set(worktree.id, IDLE)
|
||||
continue
|
||||
}
|
||||
const panes: PaneInput[] = []
|
||||
for (const tab of tabs) {
|
||||
const hookEntries = byTab.get(tab.id)
|
||||
// Why: paneIds covered by a hook entry skip the title fallback so we
|
||||
// don't double-count them. Hook authority is per-pane.
|
||||
const hookPaneIds = new Set<number>()
|
||||
if (hookEntries) {
|
||||
for (const entry of hookEntries) {
|
||||
panes.push({ kind: 'hook', entry })
|
||||
// Why: only fresh hook entries should suppress the title-heuristic
|
||||
// fallback for their pane. A stale hook is filtered out by
|
||||
// resolveAttention; if we marked its pane as "hook-covered" we'd hide
|
||||
// the live title behind a dead entry and drop the worktree to Class 4.
|
||||
if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
|
||||
continue
|
||||
}
|
||||
const paneId = paneIdFromPaneKey(entry.paneKey)
|
||||
if (paneId !== null) {
|
||||
hookPaneIds.add(paneId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why gate on tabHasLivePty: runtimePaneTitlesByTabId is preserved under
|
||||
// sleep (keepIdentifiers), so a slept tab whose pane titles still match
|
||||
// a working pattern would otherwise leak into the comparator.
|
||||
if (!tabHasLivePty(ptyIdsByTabId, tab.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const paneTitles = runtimePaneTitlesByTabId[tab.id]
|
||||
if (paneTitles && Object.keys(paneTitles).length > 0) {
|
||||
// Why: split-pane tabs can host multiple agents; each pane reports
|
||||
// its own title. Mirrors the precedence used by getWorkingAgentsPerWorktree.
|
||||
for (const [paneIdStr, title] of Object.entries(paneTitles)) {
|
||||
const paneId = Number(paneIdStr)
|
||||
if (hookPaneIds.has(paneId)) {
|
||||
continue
|
||||
}
|
||||
panes.push({
|
||||
kind: 'title',
|
||||
status: detectAgentStatusFromTitle(title),
|
||||
worktreeLastActivityAt: worktree.lastActivityAt
|
||||
})
|
||||
}
|
||||
} else if (hookPaneIds.size === 0) {
|
||||
// Why: tabs we have not mounted yet (restored-but-unvisited) only
|
||||
// expose the legacy tab title. Fall back to it only when no pane-level
|
||||
// titles or hook entries exist for this tab.
|
||||
panes.push({
|
||||
kind: 'title',
|
||||
status: detectAgentStatusFromTitle(tab.title),
|
||||
worktreeLastActivityAt: worktree.lastActivityAt
|
||||
})
|
||||
}
|
||||
}
|
||||
result.set(worktree.id, resolveAttention(panes, now))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
buildWorktreeComparator,
|
||||
computeSmartScore,
|
||||
CREATE_GRACE_MS,
|
||||
effectiveRecentActivity,
|
||||
sortWorktreesSmart,
|
||||
type SmartSortOverride
|
||||
sortWorktreesSmart
|
||||
} from './smart-sort'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { buildAttentionByWorktree } from './smart-attention'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStateHistoryEntry,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
|
||||
const NOW = new Date('2026-03-27T12:00:00.000Z').getTime()
|
||||
|
||||
|
|
@ -62,9 +65,7 @@ function makeTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
|
|||
}
|
||||
}
|
||||
|
||||
function makeAgentStatusEntry(
|
||||
overrides: Partial<AgentStatusEntry> & { paneKey: string }
|
||||
): AgentStatusEntry {
|
||||
function makeEntry(overrides: Partial<AgentStatusEntry> & { paneKey: string }): AgentStatusEntry {
|
||||
return {
|
||||
state: overrides.state ?? 'working',
|
||||
prompt: overrides.prompt ?? '',
|
||||
|
|
@ -73,428 +74,466 @@ function makeAgentStatusEntry(
|
|||
agentType: overrides.agentType ?? 'codex',
|
||||
paneKey: overrides.paneKey,
|
||||
terminalTitle: overrides.terminalTitle,
|
||||
stateHistory: overrides.stateHistory ?? []
|
||||
stateHistory: overrides.stateHistory ?? [],
|
||||
interrupted: overrides.interrupted
|
||||
}
|
||||
}
|
||||
|
||||
describe('computeSmartScore', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
function makeHistory(
|
||||
state: AgentStateHistoryEntry['state'],
|
||||
startedAt: number,
|
||||
interrupted = false
|
||||
): AgentStateHistoryEntry {
|
||||
return { state, prompt: '', startedAt, interrupted: interrupted || undefined }
|
||||
}
|
||||
|
||||
it('prioritizes recent activity over a merely linked worktree', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(NOW)
|
||||
|
||||
const active = makeWorktree({
|
||||
id: 'active',
|
||||
displayName: 'Active',
|
||||
lastActivityAt: NOW - 10 * 60 * 1000
|
||||
})
|
||||
const linked = makeWorktree({
|
||||
id: 'linked',
|
||||
displayName: 'Linked',
|
||||
linkedIssue: 42
|
||||
})
|
||||
|
||||
const prCache = {
|
||||
'/tmp/repo-1::linked': {
|
||||
data: { number: 17 },
|
||||
fetchedAt: NOW
|
||||
}
|
||||
function ptyMapForTabs(tabsByWorktree: Record<string, TerminalTab[]>): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const tabs of Object.values(tabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
out[tab.id] = ['pty-1']
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
expect(computeSmartScore(active, null, repoMap, null)).toBeGreaterThan(
|
||||
computeSmartScore(linked, null, repoMap, prCache)
|
||||
)
|
||||
})
|
||||
/**
|
||||
* Sort helper: builds the attention map and runs the smart comparator. Mirrors
|
||||
* what callers do in production (visible-worktrees, WorktreeList).
|
||||
*/
|
||||
function sortSmart(
|
||||
worktrees: Worktree[],
|
||||
tabsByWorktree: Record<string, TerminalTab[]>,
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>
|
||||
): Worktree[] {
|
||||
const attention = buildAttentionByWorktree(
|
||||
worktrees,
|
||||
tabsByWorktree,
|
||||
agentStatusByPaneKey,
|
||||
{},
|
||||
ptyMapForTabs(tabsByWorktree),
|
||||
NOW
|
||||
)
|
||||
return [...worktrees].sort(buildWorktreeComparator('smart', repoMap, NOW, attention))
|
||||
}
|
||||
|
||||
it('keeps recent activity relevant beyond a one-hour window', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(NOW)
|
||||
|
||||
const recent = makeWorktree({
|
||||
id: 'recent',
|
||||
lastActivityAt: NOW - 2 * 60 * 60 * 1000
|
||||
})
|
||||
const stale = makeWorktree({
|
||||
id: 'stale',
|
||||
lastActivityAt: NOW - 30 * 60 * 60 * 1000
|
||||
})
|
||||
|
||||
expect(computeSmartScore(recent, null, repoMap, null)).toBeGreaterThan(
|
||||
computeSmartScore(stale, null, repoMap, null)
|
||||
)
|
||||
})
|
||||
|
||||
it('rewards live terminals even without detected agent status', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(NOW)
|
||||
|
||||
const withLiveTerminal = makeWorktree({ id: 'live' })
|
||||
const withoutLiveTerminal = makeWorktree({ id: 'offline' })
|
||||
const tabsByWorktree = {
|
||||
[withLiveTerminal.id]: [makeTab({ worktreeId: withLiveTerminal.id, title: 'bash' })]
|
||||
describe('smart sort — class invariants', () => {
|
||||
it('ranks blocked above done regardless of which stateStartedAt is newer', () => {
|
||||
const blocked = makeWorktree({ id: 'blocked', displayName: 'Blocked' })
|
||||
const done = makeWorktree({ id: 'done', displayName: 'Done' })
|
||||
const tabs = {
|
||||
[blocked.id]: [makeTab({ id: 'tab-blocked', worktreeId: blocked.id })],
|
||||
[done.id]: [makeTab({ id: 'tab-done', worktreeId: done.id })]
|
||||
}
|
||||
|
||||
expect(computeSmartScore(withLiveTerminal, tabsByWorktree, repoMap, null)).toBeGreaterThan(
|
||||
computeSmartScore(withoutLiveTerminal, tabsByWorktree, repoMap, null)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not reward slept wake-hint tabs as live terminals', () => {
|
||||
const slept = makeWorktree({ id: 'slept' })
|
||||
const tabsByWorktree = {
|
||||
[slept.id]: [
|
||||
makeTab({
|
||||
id: 'tab-slept',
|
||||
worktreeId: slept.id,
|
||||
ptyId: 'wake-hint-session',
|
||||
title: 'codex working'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
expect(
|
||||
computeSmartScore(slept, tabsByWorktree, repoMap, null, NOW, undefined, undefined, {
|
||||
'tab-slept': []
|
||||
})
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('uses the current branch PR cache instead of persisted linkedPR metadata', () => {
|
||||
const staleLinked = makeWorktree({
|
||||
id: 'stale-linked',
|
||||
branch: 'refs/heads/no-pr-anymore',
|
||||
linkedPR: 17
|
||||
})
|
||||
const livePR = makeWorktree({
|
||||
id: 'live-pr',
|
||||
branch: 'refs/heads/has-pr-now',
|
||||
linkedPR: null
|
||||
})
|
||||
const prCache = {
|
||||
'/tmp/repo-1::no-pr-anymore': {
|
||||
data: null,
|
||||
fetchedAt: NOW
|
||||
},
|
||||
'/tmp/repo-1::has-pr-now': {
|
||||
data: { number: 42 },
|
||||
fetchedAt: NOW
|
||||
}
|
||||
}
|
||||
|
||||
expect(computeSmartScore(livePR, null, repoMap, prCache)).toBeGreaterThan(
|
||||
computeSmartScore(staleLinked, null, repoMap, prCache)
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to linkedPR when the current branch cache entry is still cold', () => {
|
||||
const linked = makeWorktree({
|
||||
id: 'linked',
|
||||
branch: 'refs/heads/not-fetched-yet',
|
||||
linkedPR: 17
|
||||
})
|
||||
const plain = makeWorktree({
|
||||
id: 'plain',
|
||||
branch: 'refs/heads/plain',
|
||||
linkedPR: null
|
||||
})
|
||||
|
||||
expect(computeSmartScore(linked, null, repoMap, {})).toBeGreaterThan(
|
||||
computeSmartScore(plain, null, repoMap, {})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not let stale explicit status mask a live heuristic permission prompt', () => {
|
||||
const worktree = makeWorktree({ id: 'wt-1' })
|
||||
const tabsByWorktree = {
|
||||
[worktree.id]: [makeTab({ worktreeId: worktree.id, title: 'codex permission needed' })]
|
||||
}
|
||||
const score = computeSmartScore(worktree, tabsByWorktree, repoMap, null, NOW, {
|
||||
'tab-1:1': makeAgentStatusEntry({
|
||||
paneKey: 'tab-1:1',
|
||||
const entries = {
|
||||
'tab-blocked:1': makeEntry({
|
||||
paneKey: 'tab-blocked:1',
|
||||
state: 'blocked',
|
||||
// older than the done timestamp
|
||||
stateStartedAt: NOW - 5 * 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-done:1': makeEntry({
|
||||
paneKey: 'tab-done:1',
|
||||
state: 'done',
|
||||
updatedAt: NOW - 45 * 60_000
|
||||
// newer
|
||||
stateStartedAt: NOW - 10_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
})
|
||||
|
||||
expect(score).toBeGreaterThanOrEqual(35)
|
||||
}
|
||||
const sorted = sortSmart([done, blocked], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['blocked', 'done'])
|
||||
})
|
||||
|
||||
it('does not stack heuristic working on top of fresh explicit done for the same tab', () => {
|
||||
const worktree = makeWorktree({ id: 'wt-1' })
|
||||
const tabsByWorktree = {
|
||||
[worktree.id]: [makeTab({ worktreeId: worktree.id, title: 'codex working' })]
|
||||
it('ranks done above working', () => {
|
||||
const done = makeWorktree({ id: 'done', displayName: 'Done' })
|
||||
const working = makeWorktree({ id: 'working', displayName: 'Working' })
|
||||
const tabs = {
|
||||
[done.id]: [makeTab({ id: 'tab-done', worktreeId: done.id })],
|
||||
[working.id]: [makeTab({ id: 'tab-working', worktreeId: working.id })]
|
||||
}
|
||||
|
||||
expect(
|
||||
computeSmartScore(worktree, tabsByWorktree, repoMap, null, NOW, {
|
||||
'tab-1:1': makeAgentStatusEntry({
|
||||
paneKey: 'tab-1:1',
|
||||
state: 'done',
|
||||
updatedAt: NOW - 60_000
|
||||
})
|
||||
const entries = {
|
||||
'tab-done:1': makeEntry({
|
||||
paneKey: 'tab-done:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 10 * 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-working:1': makeEntry({
|
||||
paneKey: 'tab-working:1',
|
||||
state: 'working',
|
||||
// newer than the done — must still lose because class wins
|
||||
stateStartedAt: NOW - 1_000,
|
||||
updatedAt: NOW - 500
|
||||
})
|
||||
).toBe(12)
|
||||
}
|
||||
const sorted = sortSmart([working, done], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['done', 'working'])
|
||||
})
|
||||
|
||||
it('ranks working above idle', () => {
|
||||
const working = makeWorktree({ id: 'working', displayName: 'Working' })
|
||||
const idle = makeWorktree({
|
||||
id: 'idle',
|
||||
displayName: 'Idle',
|
||||
// Make sure idle's effective recency is high enough that without the
|
||||
// class layer it would outrank the working worktree on a recency tie.
|
||||
lastActivityAt: NOW - 1_000
|
||||
})
|
||||
const tabs = {
|
||||
[working.id]: [makeTab({ id: 'tab-working', worktreeId: working.id })],
|
||||
[idle.id]: [makeTab({ id: 'tab-idle', worktreeId: idle.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-working:1': makeEntry({
|
||||
paneKey: 'tab-working:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortSmart([idle, working], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['working', 'idle'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWorktreeComparator', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
describe('smart sort — within-class recency', () => {
|
||||
it('orders two blocked worktrees by stateStartedAt (newer first)', () => {
|
||||
const older = makeWorktree({ id: 'older', displayName: 'A-Older' })
|
||||
const newer = makeWorktree({ id: 'newer', displayName: 'B-Newer' })
|
||||
const tabs = {
|
||||
[older.id]: [makeTab({ id: 'tab-older', worktreeId: older.id })],
|
||||
[newer.id]: [makeTab({ id: 'tab-newer', worktreeId: newer.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-older:1': makeEntry({
|
||||
paneKey: 'tab-older:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 5 * 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-newer:1': makeEntry({
|
||||
paneKey: 'tab-newer:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 30_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortSmart([older, newer], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['newer', 'older'])
|
||||
})
|
||||
|
||||
it('sorts smart mode by ongoing work signals before alphabetical order', () => {
|
||||
const active = makeWorktree({
|
||||
id: 'active',
|
||||
displayName: 'z-active',
|
||||
lastActivityAt: NOW - 10 * 60 * 1000
|
||||
it('ranks a working worktree with prior done above one with no history', () => {
|
||||
const withHistory = makeWorktree({ id: 'with-history', displayName: 'A-WithHistory' })
|
||||
const fresh = makeWorktree({ id: 'fresh', displayName: 'B-Fresh' })
|
||||
const tabs = {
|
||||
[withHistory.id]: [makeTab({ id: 'tab-with', worktreeId: withHistory.id })],
|
||||
[fresh.id]: [makeTab({ id: 'tab-fresh', worktreeId: fresh.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-with:1': makeEntry({
|
||||
paneKey: 'tab-with:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000,
|
||||
// Prior done from earlier in the session bumps within-class recency.
|
||||
stateHistory: [makeHistory('done', NOW - 5_000)]
|
||||
}),
|
||||
'tab-fresh:1': makeEntry({
|
||||
paneKey: 'tab-fresh:1',
|
||||
state: 'working',
|
||||
// Even newer current stateStartedAt — but with no history, falls back
|
||||
// to this timestamp (older than the prior done above).
|
||||
stateStartedAt: NOW - 2 * 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortSmart([fresh, withHistory], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['with-history', 'fresh'])
|
||||
})
|
||||
|
||||
it('falls back to current stateStartedAt when history is only interrupted dones', () => {
|
||||
const onlyInterrupted = makeWorktree({
|
||||
id: 'only-interrupted',
|
||||
displayName: 'A-OnlyInterrupted'
|
||||
})
|
||||
const recent = makeWorktree({
|
||||
id: 'recent',
|
||||
displayName: 'a-recent',
|
||||
lastActivityAt: NOW - 90 * 60 * 1000
|
||||
const fresh = makeWorktree({ id: 'fresh', displayName: 'B-Fresh' })
|
||||
const tabs = {
|
||||
[onlyInterrupted.id]: [makeTab({ id: 'tab-i', worktreeId: onlyInterrupted.id })],
|
||||
[fresh.id]: [makeTab({ id: 'tab-f', worktreeId: fresh.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-i:1': makeEntry({
|
||||
paneKey: 'tab-i:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000,
|
||||
stateHistory: [makeHistory('done', NOW - 5_000, true)]
|
||||
}),
|
||||
'tab-f:1': makeEntry({
|
||||
paneKey: 'tab-f:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 30_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortSmart([onlyInterrupted, fresh], tabs, entries)
|
||||
// fresh has newer current stateStartedAt and onlyInterrupted's history is
|
||||
// skipped, so fresh wins on within-class recency.
|
||||
expect(sorted.map((w) => w.id)).toEqual(['fresh', 'only-interrupted'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('smart sort — interrupted and stale handling', () => {
|
||||
it('interrupted done worktrees fall to Class 4 (idle), not Class 2', () => {
|
||||
const interrupted = makeWorktree({
|
||||
id: 'interrupted',
|
||||
displayName: 'Interrupted',
|
||||
lastActivityAt: NOW - 60_000
|
||||
})
|
||||
const realDone = makeWorktree({ id: 'real-done', displayName: 'Real Done' })
|
||||
const tabs = {
|
||||
[interrupted.id]: [makeTab({ id: 'tab-i', worktreeId: interrupted.id })],
|
||||
[realDone.id]: [makeTab({ id: 'tab-d', worktreeId: realDone.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-i:1': makeEntry({
|
||||
paneKey: 'tab-i:1',
|
||||
state: 'done',
|
||||
interrupted: true,
|
||||
stateStartedAt: NOW - 1_000,
|
||||
updatedAt: NOW - 500
|
||||
}),
|
||||
'tab-d:1': makeEntry({
|
||||
paneKey: 'tab-d:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 5 * 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortSmart([interrupted, realDone], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['real-done', 'interrupted'])
|
||||
})
|
||||
|
||||
it('stale entries fall to Class 4', () => {
|
||||
const stale = makeWorktree({
|
||||
id: 'stale',
|
||||
displayName: 'm-stale',
|
||||
lastActivityAt: NOW - 3 * 24 * 60 * 60 * 1000
|
||||
})
|
||||
|
||||
const worktrees = [recent, stale, active]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, null, NOW))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['active', 'recent', 'stale'])
|
||||
})
|
||||
|
||||
it('does not treat selection changes as recent activity', () => {
|
||||
const first = makeWorktree({
|
||||
id: 'first',
|
||||
displayName: 'First',
|
||||
sortOrder: NOW,
|
||||
displayName: 'Stale',
|
||||
lastActivityAt: NOW - 60_000
|
||||
})
|
||||
const second = makeWorktree({
|
||||
id: 'second',
|
||||
displayName: 'Second',
|
||||
sortOrder: NOW + 10_000,
|
||||
lastActivityAt: NOW - 120_000
|
||||
})
|
||||
|
||||
const worktrees = [second, first]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, null, NOW))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['first', 'second'])
|
||||
const fresh = makeWorktree({ id: 'fresh', displayName: 'Fresh' })
|
||||
const tabs = {
|
||||
[stale.id]: [makeTab({ id: 'tab-s', worktreeId: stale.id })],
|
||||
[fresh.id]: [makeTab({ id: 'tab-f', worktreeId: fresh.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-s:1': makeEntry({
|
||||
paneKey: 'tab-s:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000,
|
||||
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000
|
||||
}),
|
||||
'tab-f:1': makeEntry({
|
||||
paneKey: 'tab-f:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 5 * 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortSmart([stale, fresh], tabs, entries)
|
||||
// fresh is Class 2; stale falls to Class 4.
|
||||
expect(sorted.map((w) => w.id)).toEqual(['fresh', 'stale'])
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores stale sortOrder metadata when recent activity is identical', () => {
|
||||
const alpha = makeWorktree({
|
||||
id: 'alpha',
|
||||
displayName: 'Alpha',
|
||||
sortOrder: NOW + 50_000,
|
||||
describe('smart sort — Class 4 ordering', () => {
|
||||
it('breaks ties on effectiveRecentActivity, then displayName', () => {
|
||||
const recentlyActive = makeWorktree({
|
||||
id: 'recently-active',
|
||||
displayName: 'Z-Recent',
|
||||
lastActivityAt: NOW - 60_000
|
||||
})
|
||||
const beta = makeWorktree({
|
||||
id: 'beta',
|
||||
displayName: 'Beta',
|
||||
sortOrder: NOW - 50_000,
|
||||
const lessRecentlyActive = makeWorktree({
|
||||
id: 'older',
|
||||
displayName: 'A-Older',
|
||||
lastActivityAt: NOW - 10 * 60_000
|
||||
})
|
||||
const tabs = {
|
||||
[recentlyActive.id]: [makeTab({ id: 'tab-r', worktreeId: recentlyActive.id })],
|
||||
[lessRecentlyActive.id]: [makeTab({ id: 'tab-o', worktreeId: lessRecentlyActive.id })]
|
||||
}
|
||||
const sorted = sortSmart([lessRecentlyActive, recentlyActive], tabs, {})
|
||||
// Both Class 4, recency wins despite alphabetical ordering being inverted.
|
||||
expect(sorted.map((w) => w.id)).toEqual(['recently-active', 'older'])
|
||||
})
|
||||
|
||||
it('falls back to displayName when recency is identical', () => {
|
||||
const a = makeWorktree({
|
||||
id: 'a',
|
||||
displayName: 'A-First',
|
||||
lastActivityAt: NOW - 60_000
|
||||
})
|
||||
|
||||
const worktrees = [beta, alpha]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, null, NOW))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['alpha', 'beta'])
|
||||
})
|
||||
|
||||
it('prefers a worktree whose current branch has a live PR over stale linkedPR metadata', () => {
|
||||
const staleLinked = makeWorktree({
|
||||
id: 'stale-linked',
|
||||
displayName: 'Stale Linked',
|
||||
branch: 'refs/heads/no-pr-anymore',
|
||||
linkedPR: 17
|
||||
})
|
||||
const livePR = makeWorktree({
|
||||
id: 'live-pr',
|
||||
displayName: 'Live PR',
|
||||
branch: 'refs/heads/has-pr-now'
|
||||
})
|
||||
const worktrees = [staleLinked, livePR]
|
||||
const prCache = {
|
||||
'/tmp/repo-1::no-pr-anymore': {
|
||||
data: null,
|
||||
fetchedAt: NOW
|
||||
},
|
||||
'/tmp/repo-1::has-pr-now': {
|
||||
data: { number: 42 },
|
||||
fetchedAt: NOW
|
||||
}
|
||||
}
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, prCache, NOW))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['live-pr', 'stale-linked'])
|
||||
})
|
||||
|
||||
it('keeps linkedPR ordering when branch PR cache has not been fetched yet', () => {
|
||||
const coldCache = makeWorktree({
|
||||
id: 'cold-cache',
|
||||
displayName: 'Cold Cache',
|
||||
branch: 'refs/heads/not-fetched-yet',
|
||||
linkedPR: 17
|
||||
})
|
||||
const plain = makeWorktree({
|
||||
id: 'plain',
|
||||
displayName: 'Plain',
|
||||
branch: 'refs/heads/plain'
|
||||
})
|
||||
const worktrees = [plain, coldCache]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, {}, NOW))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['cold-cache', 'plain'])
|
||||
})
|
||||
|
||||
it('can freeze the active worktree recent signals without blocking background reordering', () => {
|
||||
const activeBeforeClick = makeWorktree({
|
||||
id: 'active',
|
||||
displayName: 'Active',
|
||||
isUnread: true,
|
||||
lastActivityAt: NOW - 30_000
|
||||
})
|
||||
const activeAfterClick = { ...activeBeforeClick, isUnread: false }
|
||||
const background = makeWorktree({
|
||||
id: 'background',
|
||||
displayName: 'Background',
|
||||
const b = makeWorktree({
|
||||
id: 'b',
|
||||
displayName: 'B-Second',
|
||||
lastActivityAt: NOW - 60_000
|
||||
})
|
||||
const worktrees = [background, activeAfterClick]
|
||||
const tabsByWorktree = {
|
||||
[background.id]: [makeTab({ worktreeId: background.id, title: 'Claude Code - working' })]
|
||||
const tabs = {
|
||||
[a.id]: [makeTab({ id: 'tab-a', worktreeId: a.id })],
|
||||
[b.id]: [makeTab({ id: 'tab-b', worktreeId: b.id })]
|
||||
}
|
||||
const smartSortOverrides: Record<string, SmartSortOverride> = {
|
||||
[activeAfterClick.id]: {
|
||||
worktree: activeBeforeClick,
|
||||
tabs: [],
|
||||
hasRecentPRSignal: false
|
||||
}
|
||||
}
|
||||
|
||||
worktrees.sort(
|
||||
buildWorktreeComparator('smart', tabsByWorktree, repoMap, null, NOW, smartSortOverrides)
|
||||
)
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['background', 'active'])
|
||||
const sorted = sortSmart([b, a], tabs, {})
|
||||
expect(sorted.map((w) => w.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('can keep the active worktree in place while its unread badge is cleared on selection', () => {
|
||||
const activeBeforeClick = makeWorktree({
|
||||
id: 'active',
|
||||
displayName: 'Active',
|
||||
isUnread: true,
|
||||
lastActivityAt: NOW - 30_000
|
||||
})
|
||||
const activeAfterClick = { ...activeBeforeClick, isUnread: false }
|
||||
const background = makeWorktree({
|
||||
id: 'background',
|
||||
displayName: 'Background',
|
||||
lastActivityAt: NOW - 2 * 60_000
|
||||
})
|
||||
const worktrees = [background, activeAfterClick]
|
||||
const smartSortOverrides: Record<string, SmartSortOverride> = {
|
||||
[activeAfterClick.id]: {
|
||||
worktree: activeBeforeClick,
|
||||
tabs: [],
|
||||
hasRecentPRSignal: false
|
||||
}
|
||||
}
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, null, NOW, smartSortOverrides))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['active', 'background'])
|
||||
})
|
||||
|
||||
it('keeps a more recent worktree ahead even without an override', () => {
|
||||
const activeAfterClick = makeWorktree({
|
||||
id: 'active',
|
||||
displayName: 'Active',
|
||||
isUnread: false,
|
||||
lastActivityAt: NOW - 30_000
|
||||
})
|
||||
const background = makeWorktree({
|
||||
id: 'background',
|
||||
displayName: 'Background',
|
||||
lastActivityAt: NOW - 2 * 60_000
|
||||
})
|
||||
const worktrees = [background, activeAfterClick]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, null, NOW))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['active', 'background'])
|
||||
})
|
||||
|
||||
it('ranks a just-created worktree above shutdown worktrees with passive signals', () => {
|
||||
const justCreated = makeWorktree({
|
||||
id: 'new',
|
||||
displayName: 'New',
|
||||
it('honors the create-grace floor for new worktrees in Class 4', () => {
|
||||
const fresh = makeWorktree({
|
||||
id: 'fresh',
|
||||
displayName: 'Z-Fresh',
|
||||
createdAt: NOW,
|
||||
lastActivityAt: NOW
|
||||
})
|
||||
// Shutdown worktree with max passive signals but no recent activity
|
||||
const shutdown = makeWorktree({
|
||||
id: 'shutdown',
|
||||
displayName: 'Shutdown',
|
||||
isUnread: true,
|
||||
linkedIssue: 42,
|
||||
lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000
|
||||
const bumped = makeWorktree({
|
||||
id: 'bumped',
|
||||
displayName: 'A-Bumped',
|
||||
lastActivityAt: NOW + 100
|
||||
})
|
||||
const prCache = {
|
||||
'/tmp/repo-1::shutdown': {
|
||||
data: { number: 17 },
|
||||
fetchedAt: NOW
|
||||
}
|
||||
const tabs = {
|
||||
[fresh.id]: [makeTab({ id: 'tab-fresh', worktreeId: fresh.id })],
|
||||
[bumped.id]: [makeTab({ id: 'tab-bumped', worktreeId: bumped.id })]
|
||||
}
|
||||
const worktrees = [shutdown, justCreated]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, prCache, NOW))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['new', 'shutdown'])
|
||||
const sorted = sortSmart([bumped, fresh], tabs, {})
|
||||
// Grace floor (createdAt + 5min) lifts fresh above the slightly-newer bump.
|
||||
expect(sorted.map((w) => w.id)).toEqual(['fresh', 'bumped'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortWorktreesSmart', () => {
|
||||
it('keeps cold-start persisted order when only slept wake-hint tabs exist', () => {
|
||||
const persistedFirst = makeWorktree({
|
||||
id: 'persisted-first',
|
||||
displayName: 'Persisted First',
|
||||
sortOrder: 20
|
||||
})
|
||||
const sleptWorking = makeWorktree({
|
||||
id: 'slept-working',
|
||||
displayName: 'Slept Working',
|
||||
sortOrder: 0
|
||||
})
|
||||
const tabsByWorktree = {
|
||||
[sleptWorking.id]: [
|
||||
makeTab({
|
||||
id: 'tab-slept',
|
||||
worktreeId: sleptWorking.id,
|
||||
ptyId: 'wake-hint-session',
|
||||
title: 'codex working'
|
||||
})
|
||||
]
|
||||
describe('smart sort — multi-pane resolution', () => {
|
||||
it('any blocked pane promotes the whole worktree to Class 1', () => {
|
||||
const splitWorktree = makeWorktree({ id: 'split', displayName: 'Split' })
|
||||
const otherDone = makeWorktree({ id: 'other-done', displayName: 'OtherDone' })
|
||||
const tabs = {
|
||||
[splitWorktree.id]: [makeTab({ id: 'tab-split', worktreeId: splitWorktree.id })],
|
||||
[otherDone.id]: [makeTab({ id: 'tab-other', worktreeId: otherDone.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-split:1': makeEntry({
|
||||
paneKey: 'tab-split:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-split:2': makeEntry({
|
||||
paneKey: 'tab-split:2',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 30_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-other:1': makeEntry({
|
||||
paneKey: 'tab-other:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 5_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortSmart([otherDone, splitWorktree], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['split', 'other-done'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortWorktreesSmart — cold start fallback', () => {
|
||||
it('falls back to persisted sortOrder when no PTY is alive', () => {
|
||||
const a = makeWorktree({ id: 'a', displayName: 'A', sortOrder: 1 })
|
||||
const b = makeWorktree({ id: 'b', displayName: 'B', sortOrder: 2 })
|
||||
// No tabs, no PTYs — cold start path.
|
||||
const sorted = sortWorktreesSmart([a, b], {}, repoMap, {}, {}, {})
|
||||
// Higher sortOrder wins on cold start.
|
||||
expect(sorted.map((w) => w.id)).toEqual(['b', 'a'])
|
||||
})
|
||||
|
||||
it('treats slept tabs (tab.ptyId without live entry) as cold start', () => {
|
||||
// Why: tab.ptyId is the wake-hint sessionId preserved under sleep — not a
|
||||
// liveness signal. With slept tabs but no live PTYs, sortWorktreesSmart
|
||||
// must fall back to persisted sortOrder.
|
||||
const a = makeWorktree({ id: 'a', sortOrder: 1, displayName: 'a' })
|
||||
const b = makeWorktree({ id: 'b', sortOrder: 2, displayName: 'b' })
|
||||
const tabsByWorktree = {
|
||||
[a.id]: [makeTab({ id: 'ta', worktreeId: a.id, ptyId: 'wake-hint' })]
|
||||
}
|
||||
// ptyIdsByTabId is empty — slept tab has wake-hint ptyId but no live entry.
|
||||
const sorted = sortWorktreesSmart([a, b], tabsByWorktree, repoMap, {}, {}, {})
|
||||
expect(sorted.map((w) => w.id)).toEqual(['b', 'a'])
|
||||
})
|
||||
|
||||
it('uses the smart comparator once a PTY is alive', () => {
|
||||
const blocked = makeWorktree({ id: 'blocked', displayName: 'Blocked', sortOrder: 0 })
|
||||
const done = makeWorktree({ id: 'done', displayName: 'Done', sortOrder: 100 })
|
||||
const tabsByWorktree = {
|
||||
[blocked.id]: [makeTab({ id: 'tab-blocked', worktreeId: blocked.id })],
|
||||
[done.id]: [makeTab({ id: 'tab-done', worktreeId: done.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-blocked:1': makeEntry({
|
||||
paneKey: 'tab-blocked:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-done:1': makeEntry({
|
||||
paneKey: 'tab-done:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 30_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortWorktreesSmart(
|
||||
[sleptWorking, persistedFirst],
|
||||
[done, blocked],
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
null,
|
||||
undefined,
|
||||
{ 'tab-slept': [] }
|
||||
entries,
|
||||
{},
|
||||
ptyMapForTabs(tabsByWorktree)
|
||||
)
|
||||
// Smart comparator wins over sortOrder because at least one PTY is live.
|
||||
expect(sorted.map((w) => w.id)).toEqual(['blocked', 'done'])
|
||||
})
|
||||
})
|
||||
|
||||
expect(sorted.map((worktree) => worktree.id)).toEqual(['persisted-first', 'slept-working'])
|
||||
describe('sortWorktreesSmart — palette caller regression', () => {
|
||||
// Why: WorktreeJumpPalette routes typed queries through sortWorktreesSmart.
|
||||
// This test pins that the palette path uses the class layer (not just the
|
||||
// recent-activity fallback) when threading agentStatusByPaneKey.
|
||||
it('palette ranks blocked above working when both flow through sortWorktreesSmart', () => {
|
||||
const blocked = makeWorktree({ id: 'blocked', displayName: 'A-Blocked' })
|
||||
const working = makeWorktree({ id: 'working', displayName: 'B-Working' })
|
||||
const tabsByWorktree = {
|
||||
[blocked.id]: [makeTab({ id: 'tab-blocked', worktreeId: blocked.id })],
|
||||
[working.id]: [makeTab({ id: 'tab-working', worktreeId: working.id })]
|
||||
}
|
||||
const agentStatusByPaneKey: Record<string, AgentStatusEntry> = {
|
||||
'tab-blocked:1': makeEntry({
|
||||
paneKey: 'tab-blocked:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-working:1': makeEntry({
|
||||
paneKey: 'tab-working:1',
|
||||
state: 'working',
|
||||
// newer than the blocked one — would win on recency alone
|
||||
stateStartedAt: NOW - 1_000,
|
||||
updatedAt: NOW - 500
|
||||
})
|
||||
}
|
||||
const sorted = sortWorktreesSmart(
|
||||
[working, blocked],
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
agentStatusByPaneKey,
|
||||
{},
|
||||
ptyMapForTabs(tabsByWorktree)
|
||||
)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['blocked', 'working'])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -512,7 +551,7 @@ describe('buildWorktreeComparator — recent (lastActivityAt)', () => {
|
|||
})
|
||||
const worktrees = [older, newer]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW))
|
||||
worktrees.sort(buildWorktreeComparator('recent', repoMap, NOW, new Map()))
|
||||
|
||||
expect(worktrees.map((w) => w.id)).toEqual(['newer', 'older'])
|
||||
})
|
||||
|
|
@ -530,7 +569,7 @@ describe('buildWorktreeComparator — recent (lastActivityAt)', () => {
|
|||
})
|
||||
const worktrees = [legacy, touched]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW))
|
||||
worktrees.sort(buildWorktreeComparator('recent', repoMap, NOW, new Map()))
|
||||
|
||||
expect(worktrees.map((w) => w.id)).toEqual(['touched', 'legacy'])
|
||||
})
|
||||
|
|
@ -548,14 +587,12 @@ describe('buildWorktreeComparator — recent (lastActivityAt)', () => {
|
|||
})
|
||||
const worktrees = [bravo, alpha]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW))
|
||||
worktrees.sort(buildWorktreeComparator('recent', repoMap, NOW, new Map()))
|
||||
|
||||
expect(worktrees.map((w) => w.id)).toEqual(['alpha', 'bravo'])
|
||||
})
|
||||
|
||||
it('ignores sortOrder entirely — activity alone determines the order', () => {
|
||||
// A worktree with a stale high sortOrder (e.g. baked in when meta was
|
||||
// first created) must not outrank a worktree with fresher activity.
|
||||
const staleHighOrder = makeWorktree({
|
||||
id: 'stale-high-order',
|
||||
displayName: 'Orca main',
|
||||
|
|
@ -570,7 +607,7 @@ describe('buildWorktreeComparator — recent (lastActivityAt)', () => {
|
|||
})
|
||||
const worktrees = [staleHighOrder, freshActive]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW))
|
||||
worktrees.sort(buildWorktreeComparator('recent', repoMap, NOW, new Map()))
|
||||
|
||||
expect(worktrees.map((w) => w.id)).toEqual(['fresh-active', 'stale-high-order'])
|
||||
})
|
||||
|
|
@ -597,19 +634,12 @@ describe('effectiveRecentActivity — create-grace floor', () => {
|
|||
})
|
||||
|
||||
it('returns lastActivityAt when real activity has surpassed the grace floor', () => {
|
||||
// A user who interacted 3 minutes after create has lastActivityAt > createdAt + 3min,
|
||||
// but createdAt + 5min still wins for the next 2 minutes.
|
||||
const createdAt = NOW - 3 * 60 * 1000
|
||||
const wt = makeWorktree({ id: 'used', createdAt, lastActivityAt: NOW - 60_000 })
|
||||
// createdAt + GRACE_MS = NOW + 2min, which exceeds lastActivityAt (NOW - 1min).
|
||||
expect(effectiveRecentActivity(wt, NOW)).toBe(createdAt + CREATE_GRACE_MS)
|
||||
})
|
||||
|
||||
it('returns lastActivityAt once the grace window has elapsed even when no other activity has occurred', () => {
|
||||
// Bug-fix case: a worktree created days ago that was never touched after
|
||||
// creation. Without the time-bound check, the floor would still apply and
|
||||
// the worktree would rank as `createdAt + 5min` forever, masking truly
|
||||
// fresher worktrees.
|
||||
const createdAt = NOW - CREATE_GRACE_MS - 1
|
||||
const wt = makeWorktree({ id: 'untouched', createdAt, lastActivityAt: createdAt })
|
||||
expect(effectiveRecentActivity(wt, NOW)).toBe(createdAt)
|
||||
|
|
@ -618,9 +648,6 @@ describe('effectiveRecentActivity — create-grace floor', () => {
|
|||
|
||||
describe('buildWorktreeComparator — recent with createdAt grace window', () => {
|
||||
it('keeps a newly-created worktree on top even when another worktree bumps lastActivityAt', () => {
|
||||
// Simulates the bug: user creates a worktree at t=0, then an ambient PTY
|
||||
// bump on a different worktree lands at t=+100ms. Without the grace
|
||||
// window, the bumped worktree would outrank the new one by 100ms.
|
||||
const newWorktree = makeWorktree({
|
||||
id: 'new',
|
||||
displayName: 'New',
|
||||
|
|
@ -634,7 +661,7 @@ describe('buildWorktreeComparator — recent with createdAt grace window', () =>
|
|||
})
|
||||
const worktrees = [bumpedByAmbient, newWorktree]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW))
|
||||
worktrees.sort(buildWorktreeComparator('recent', repoMap, NOW, new Map()))
|
||||
|
||||
expect(worktrees.map((w) => w.id)).toEqual(['new', 'bumped'])
|
||||
})
|
||||
|
|
@ -643,31 +670,27 @@ describe('buildWorktreeComparator — recent with createdAt grace window', () =>
|
|||
const oldCreated = makeWorktree({
|
||||
id: 'old-created',
|
||||
displayName: 'Old created',
|
||||
// Created longer ago than GRACE_MS so the floor has expired.
|
||||
createdAt: NOW - CREATE_GRACE_MS - 10_000,
|
||||
lastActivityAt: NOW - 30_000
|
||||
})
|
||||
const freshActivity = makeWorktree({
|
||||
id: 'fresh-activity',
|
||||
displayName: 'Fresh activity',
|
||||
// No createdAt (discovered on disk), but has recent real activity.
|
||||
lastActivityAt: NOW - 1000
|
||||
})
|
||||
const worktrees = [oldCreated, freshActivity]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW))
|
||||
worktrees.sort(buildWorktreeComparator('recent', repoMap, NOW, new Map()))
|
||||
|
||||
expect(worktrees.map((w) => w.id)).toEqual(['fresh-activity', 'old-created'])
|
||||
})
|
||||
|
||||
it('does not disturb ranking for worktrees without createdAt', () => {
|
||||
// All existing worktrees (persisted before createdAt field existed) stay
|
||||
// sorted by lastActivityAt alone.
|
||||
const alpha = makeWorktree({ id: 'alpha', displayName: 'Alpha', lastActivityAt: 5000 })
|
||||
const bravo = makeWorktree({ id: 'bravo', displayName: 'Bravo', lastActivityAt: 10_000 })
|
||||
const worktrees = [alpha, bravo]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW))
|
||||
worktrees.sort(buildWorktreeComparator('recent', repoMap, NOW, new Map()))
|
||||
|
||||
expect(worktrees.map((w) => w.id)).toEqual(['bravo', 'alpha'])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import { branchName } from '@/lib/git-utils'
|
||||
import type { Worktree, Repo, TerminalTab } from '../../../../shared/types'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import { IDLE, buildAttentionByWorktree, type WorktreeAttention } from './smart-attention'
|
||||
|
||||
type SortBy = 'name' | 'smart' | 'recent' | 'repo'
|
||||
|
||||
|
|
@ -41,298 +37,36 @@ export function effectiveRecentActivity(worktree: Worktree, now: number): number
|
|||
return Math.max(lastActivityAt, createdAt + CREATE_GRACE_MS)
|
||||
}
|
||||
|
||||
type PRCacheEntry = { data: object | null; fetchedAt: number }
|
||||
export type SmartSortOverride = {
|
||||
worktree: Worktree
|
||||
tabs: TerminalTab[]
|
||||
hasRecentPRSignal: boolean
|
||||
}
|
||||
|
||||
function terminalTabIsLive(
|
||||
tab: TerminalTab,
|
||||
ptyIdsByTabId?: Record<string, string[]> | null
|
||||
): boolean {
|
||||
// Why: slept terminals retain tab.ptyId as a wake hint, so the live PTY map is
|
||||
// the source of truth once callers can provide it.
|
||||
return ptyIdsByTabId ? tabHasLivePty(ptyIdsByTabId, tab.id) : Boolean(tab.ptyId)
|
||||
}
|
||||
|
||||
export function hasAnyLivePty(
|
||||
tabsByWorktree: Record<string, TerminalTab[]>,
|
||||
ptyIdsByTabId?: Record<string, string[]> | null
|
||||
): boolean {
|
||||
return Object.values(tabsByWorktree)
|
||||
.flat()
|
||||
.some((tab) => terminalTabIsLive(tab, ptyIdsByTabId))
|
||||
}
|
||||
|
||||
// Why: building this index once at the sort call site reduces the smart-
|
||||
// score computation from O(N × E × T) to O(E) build + O(T) lookups per
|
||||
// worktree. Before, each worktree's score computation scanned the full
|
||||
// agentStatusByPaneKey map, which made the decorate-sort-undecorate
|
||||
// precompute pay the scan N times even though the map is global. Entries are
|
||||
// keyed by the `tabId` prefix of their paneKey (paneKey format: `${tabId}:…`).
|
||||
export function buildExplicitEntriesByTabId(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined
|
||||
): Map<string, AgentStatusEntry[]> {
|
||||
const byTab = new Map<string, AgentStatusEntry[]>()
|
||||
if (!agentStatusByPaneKey) {
|
||||
return byTab
|
||||
}
|
||||
for (const entry of Object.values(agentStatusByPaneKey)) {
|
||||
const colon = entry.paneKey.indexOf(':')
|
||||
// Why: paneKey must be `${tabId}:${paneId}`. Skip malformed entries (no
|
||||
// colon or leading colon) rather than bucketing them under an empty tabId,
|
||||
// where they would never match a real tab and just waste memory.
|
||||
if (colon <= 0) {
|
||||
continue
|
||||
}
|
||||
const tabId = entry.paneKey.slice(0, colon)
|
||||
const bucket = byTab.get(tabId)
|
||||
if (bucket) {
|
||||
bucket.push(entry)
|
||||
} else {
|
||||
byTab.set(tabId, [entry])
|
||||
}
|
||||
}
|
||||
return byTab
|
||||
}
|
||||
|
||||
export function hasRecentPRSignal(
|
||||
worktree: Worktree,
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, PRCacheEntry> | null
|
||||
): boolean {
|
||||
const repo = repoMap.get(worktree.repoId)
|
||||
const branch = branchName(worktree.branch)
|
||||
if (!repo || !branch) {
|
||||
return worktree.linkedPR !== null
|
||||
}
|
||||
|
||||
const cacheKey = `${repo.path}::${branch}`
|
||||
const cachedEntry = prCache?.[cacheKey]
|
||||
if (cachedEntry) {
|
||||
return Boolean(cachedEntry.data)
|
||||
}
|
||||
|
||||
return worktree.linkedPR !== null
|
||||
}
|
||||
|
||||
function computeSmartScoreFromSignals(
|
||||
worktree: Worktree,
|
||||
tabs: TerminalTab[],
|
||||
hasRecentPR: boolean,
|
||||
now: number,
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry>,
|
||||
explicitByTabId?: Map<string, AgentStatusEntry[]>,
|
||||
ptyIdsByTabId?: Record<string, string[]> | null
|
||||
): number {
|
||||
const liveTabs = tabs.filter((tab) => terminalTabIsLive(tab, ptyIdsByTabId))
|
||||
|
||||
let score = 0
|
||||
|
||||
// Why: explicit agent status (OSC 9999) is authoritative over heuristic title
|
||||
// parsing. Check explicit status first; fall through to heuristics for tabs
|
||||
// that have no explicit status entry.
|
||||
//
|
||||
// Why the index parameter: when the caller precomputes the tabId → entries
|
||||
// index once (via buildExplicitEntriesByTabId) and threads it through, each
|
||||
// worktree does O(T) lookups instead of scanning the full map O(E) times.
|
||||
// This matters because `sortWorktreesSmart` calls this function N times in a
|
||||
// decorate-sort-undecorate pass; without the shared index we'd pay O(N×E×T)
|
||||
// overall. When the index is absent and `agentStatusByPaneKey` is provided
|
||||
// we build it inline to preserve backward compatibility for callers (tests,
|
||||
// palette) that haven't adopted the optimization.
|
||||
const resolvedExplicitByTabId =
|
||||
explicitByTabId ?? buildExplicitEntriesByTabId(agentStatusByPaneKey)
|
||||
|
||||
let hasExplicitWorking = false
|
||||
let hasExplicitBlocked = false
|
||||
let hasHeuristicWorking = false
|
||||
let hasHeuristicBlocked = false
|
||||
|
||||
for (const tab of liveTabs) {
|
||||
const tabExplicitEntries = resolvedExplicitByTabId.get(tab.id) ?? []
|
||||
// Why: compute freshness once per entry instead of recomputing inside each
|
||||
// of the three `.some(...)` passes below. Freshness is a pure function of
|
||||
// (entry, now) so filtering up front is equivalent and cheaper.
|
||||
const freshEntries = tabExplicitEntries.filter((entry) =>
|
||||
isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
|
||||
)
|
||||
|
||||
if (freshEntries.length > 0) {
|
||||
hasExplicitWorking ||= freshEntries.some((entry) => entry.state === 'working')
|
||||
hasExplicitBlocked ||= freshEntries.some(
|
||||
(entry) => entry.state === 'blocked' || entry.state === 'waiting'
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const heuristicState = detectAgentStatusFromTitle(tab.title)
|
||||
hasHeuristicWorking ||= heuristicState === 'working'
|
||||
hasHeuristicBlocked ||= heuristicState === 'permission'
|
||||
}
|
||||
|
||||
// Explicit working → +60, same weight as heuristic working
|
||||
// Explicit blocked/waiting → +35, same weight as heuristic permission
|
||||
// Explicit done → no bonus (task complete, no attention needed)
|
||||
const isRunning = hasExplicitWorking || hasHeuristicWorking
|
||||
if (isRunning) {
|
||||
score += 60
|
||||
}
|
||||
|
||||
const needsAttention = hasExplicitBlocked || hasHeuristicBlocked
|
||||
if (needsAttention) {
|
||||
score += 35
|
||||
}
|
||||
|
||||
if (worktree.isUnread) {
|
||||
score += 18
|
||||
}
|
||||
|
||||
if (liveTabs.length > 0) {
|
||||
score += 12
|
||||
}
|
||||
|
||||
if (hasRecentPR) {
|
||||
score += 10
|
||||
}
|
||||
|
||||
if (worktree.linkedIssue !== null) {
|
||||
score += 6
|
||||
}
|
||||
|
||||
const activityAge = now - (worktree.lastActivityAt || 0)
|
||||
if (worktree.lastActivityAt > 0) {
|
||||
const ONE_DAY = 24 * 60 * 60 * 1000
|
||||
// Why 36: a just-created worktree has only this signal (no live tab yet,
|
||||
// since the PTY spawns asynchronously after creation). Weight must exceed
|
||||
// the max passive-signal combination for shutdown worktrees
|
||||
// (isUnread 18 + PR 10 + issue 6 = 34) so brand-new worktrees always
|
||||
// appear at the top of the "smart" sort immediately.
|
||||
score += 36 * Math.max(0, 1 - activityAge / ONE_DAY)
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
function getSmartSortCandidate(
|
||||
worktree: Worktree,
|
||||
tabsByWorktree: Record<string, TerminalTab[]> | null,
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, PRCacheEntry> | null,
|
||||
smartSortOverrides: Record<string, SmartSortOverride> | null
|
||||
): SmartSortOverride {
|
||||
return (
|
||||
smartSortOverrides?.[worktree.id] ?? {
|
||||
worktree,
|
||||
tabs: tabsByWorktree?.[worktree.id] ?? [],
|
||||
hasRecentPRSignal: hasRecentPRSignal(worktree, repoMap, prCache)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a comparator for sorting worktrees based on the current sort mode.
|
||||
*
|
||||
* `precomputedScores` is the decorate-sort-undecorate optimization for the
|
||||
* `smart` mode: callers should compute each worktree's smart score once and
|
||||
* pass the map in, since `Array.prototype.sort` invokes the comparator
|
||||
* O(N log N) times and recomputing the score each call scans the
|
||||
* `agentStatusByPaneKey` map O(N log N × E) times. When omitted, the
|
||||
* comparator falls back to computing scores per-comparison so existing call
|
||||
* sites that haven't adopted the optimization keep working.
|
||||
*
|
||||
* `explicitByTabId` is a secondary optimization: when `precomputedScores` is
|
||||
* absent (fallback path), the comparator still has to call
|
||||
* `computeSmartScoreFromSignals` per comparison. Passing the prebuilt tabId
|
||||
* index avoids rescanning the full `agentStatusByPaneKey` map on every call.
|
||||
* When this index is also absent, the inner function builds one inline per
|
||||
* invocation to preserve backward compatibility.
|
||||
* Smart mode requires `attentionByWorktree` — a per-worktree class +
|
||||
* timestamp map built once before sorting (see `buildAttentionByWorktree`).
|
||||
* Why non-optional: a forgotten caller would silently regress every worktree
|
||||
* to Class 4 (idle) and degrade the comparator to recent-activity ordering;
|
||||
* making the param required surfaces the omission as a typecheck error.
|
||||
*/
|
||||
export function buildWorktreeComparator(
|
||||
sortBy: SortBy,
|
||||
tabsByWorktree: Record<string, TerminalTab[]> | null,
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, PRCacheEntry> | null,
|
||||
now: number = Date.now(),
|
||||
smartSortOverrides: Record<string, SmartSortOverride> | null = null,
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry>,
|
||||
precomputedScores?: Map<string, number>,
|
||||
explicitByTabId?: Map<string, AgentStatusEntry[]>,
|
||||
ptyIdsByTabId?: Record<string, string[]> | null
|
||||
now: number,
|
||||
attentionByWorktree: Map<string, WorktreeAttention>
|
||||
): (a: Worktree, b: Worktree) => number {
|
||||
// Why: when the caller does not pre-build the tabId index but does provide
|
||||
// the source map, build it ONCE here and close over it. Array.sort invokes
|
||||
// the comparator O(N log N) times in the fallback path (no precomputed
|
||||
// scores), and `computeSmartScoreFromSignals` would otherwise rebuild the
|
||||
// O(E) index on every comparison — re-introducing the O(N log N × E) cost
|
||||
// the precompute was meant to avoid. Only matters for the smart mode; for
|
||||
// other modes we skip construction.
|
||||
const resolvedExplicitByTabId =
|
||||
sortBy === 'smart' && !explicitByTabId && agentStatusByPaneKey
|
||||
? buildExplicitEntriesByTabId(agentStatusByPaneKey)
|
||||
: explicitByTabId
|
||||
|
||||
return (a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.displayName.localeCompare(b.displayName)
|
||||
case 'smart': {
|
||||
const smartA = getSmartSortCandidate(
|
||||
a,
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
smartSortOverrides
|
||||
)
|
||||
const smartB = getSmartSortCandidate(
|
||||
b,
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
smartSortOverrides
|
||||
)
|
||||
// Why precomputedScores: the smart-score computation iterates
|
||||
// `agentStatusByPaneKey` (O(E) per call) when no tabId index is
|
||||
// threaded in, and still does O(T) lookups per worktree when one is.
|
||||
// The comparator is invoked O(N log N) times by Array.sort, so without
|
||||
// memoization we pay O(N log N × E) (or O(N log N × T) with the
|
||||
// index). When the caller supplies a precomputed score map we get
|
||||
// O(1) lookups; when it doesn't we preserve the old behavior and pass
|
||||
// the optional `explicitByTabId` index to the inner function so the
|
||||
// fallback path avoids the full-map scan as well. Overrides bypass the
|
||||
// precomputed map because the override intentionally freezes the
|
||||
// candidate's inputs (tabs, hasRecentPRSignal) which may differ from
|
||||
// the live score.
|
||||
const scoreA =
|
||||
precomputedScores && !smartSortOverrides?.[a.id]
|
||||
? (precomputedScores.get(a.id) ?? 0)
|
||||
: computeSmartScoreFromSignals(
|
||||
smartA.worktree,
|
||||
smartA.tabs,
|
||||
smartA.hasRecentPRSignal,
|
||||
now,
|
||||
agentStatusByPaneKey,
|
||||
resolvedExplicitByTabId,
|
||||
ptyIdsByTabId
|
||||
)
|
||||
const scoreB =
|
||||
precomputedScores && !smartSortOverrides?.[b.id]
|
||||
? (precomputedScores.get(b.id) ?? 0)
|
||||
: computeSmartScoreFromSignals(
|
||||
smartB.worktree,
|
||||
smartB.tabs,
|
||||
smartB.hasRecentPRSignal,
|
||||
now,
|
||||
agentStatusByPaneKey,
|
||||
resolvedExplicitByTabId,
|
||||
ptyIdsByTabId
|
||||
)
|
||||
const aw = attentionByWorktree.get(a.id) ?? IDLE
|
||||
const bw = attentionByWorktree.get(b.id) ?? IDLE
|
||||
return (
|
||||
scoreB - scoreA ||
|
||||
effectiveRecentActivity(smartB.worktree, now) -
|
||||
effectiveRecentActivity(smartA.worktree, now) ||
|
||||
// Why: 1 < 2 < 3 < 4 — lower class outranks higher.
|
||||
aw.cls - bw.cls ||
|
||||
// Why: within a class, the more recent attention event ranks first.
|
||||
bw.attentionTimestamp - aw.attentionTimestamp ||
|
||||
// Why: idle worktrees fall through to recency (and the create-grace
|
||||
// floor for brand-new worktrees) before alphabetical.
|
||||
effectiveRecentActivity(b, now) - effectiveRecentActivity(a, now) ||
|
||||
a.displayName.localeCompare(b.displayName)
|
||||
)
|
||||
}
|
||||
|
|
@ -367,114 +101,52 @@ export function buildWorktreeComparator(
|
|||
}
|
||||
|
||||
/**
|
||||
* Sort worktrees by weighted smart-score signals, handling the cold-start /
|
||||
* warm distinction in one place. On cold start (no live PTYs yet), falls back
|
||||
* to persisted `sortOrder` descending with alphabetical `displayName` fallback.
|
||||
* Once any PTY is alive, uses the full smart-score comparator.
|
||||
* Sort worktrees by the smart-attention comparator (status class first,
|
||||
* recency-of-attention second). On cold start (no live PTYs yet), falls back
|
||||
* to persisted `sortOrder` descending so the sidebar restores the pre-quit
|
||||
* order until the agent-status snapshot lands.
|
||||
*
|
||||
* Both the palette and `getVisibleWorktreeIds()` import this to avoid
|
||||
* duplicating the cold/warm branching logic.
|
||||
*
|
||||
* `agentStatusByPaneKey` carries the primary signal; `runtimePaneTitlesByTabId`
|
||||
* and `ptyIdsByTabId` enable the title-heuristic fallback for hookless agents
|
||||
* (Edge case 9 in the design doc). Why all three are non-optional: a forgotten
|
||||
* caller would silently regress every worktree to Class 4 or quietly disable
|
||||
* the hookless-fallback path.
|
||||
*/
|
||||
export function sortWorktreesSmart(
|
||||
worktrees: Worktree[],
|
||||
tabsByWorktree: Record<string, TerminalTab[]>,
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, PRCacheEntry> | null,
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry>,
|
||||
ptyIdsByTabId?: Record<string, string[]> | null
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string>>,
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
): Worktree[] {
|
||||
if (!hasAnyLivePty(tabsByWorktree, ptyIdsByTabId)) {
|
||||
// Cold start: use persisted sortOrder snapshot
|
||||
// Why: `tabHasLivePty` (over `ptyIdsByTabId`) is the source of truth for
|
||||
// liveness — slept terminals retain `tab.ptyId` as a wake hint, so reading
|
||||
// it directly would falsely keep cold-start ordering off after restart.
|
||||
const hasAnyLivePty = Object.values(tabsByWorktree)
|
||||
.flat()
|
||||
.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id))
|
||||
|
||||
if (!hasAnyLivePty) {
|
||||
// Cold start: use persisted sortOrder snapshot until the agent-status
|
||||
// snapshot lands and a warm sort runs.
|
||||
return [...worktrees].sort(
|
||||
(a, b) => b.sortOrder - a.sortOrder || a.displayName.localeCompare(b.displayName)
|
||||
)
|
||||
}
|
||||
|
||||
// Why precompute: Array.sort calls the comparator O(N log N) times and the
|
||||
// smart-score computation needs to look up explicit-status entries per tab.
|
||||
// We apply two layered optimizations:
|
||||
// 1. Build the `explicitByTabId` index ONCE up front (O(E) work). Without
|
||||
// it, each per-worktree score would scan the full `agentStatusByPaneKey`
|
||||
// map to find matching entries, which is O(N × E × T) across all
|
||||
// worktrees — the same cost the decorate-sort-undecorate pass was
|
||||
// supposed to avoid.
|
||||
// 2. Precompute each worktree's score once (decorate-sort-undecorate) so
|
||||
// the comparator does O(1) map lookups instead of re-scoring per
|
||||
// comparison.
|
||||
// Combined cost: O(E) index build + O(N × T) scoring + O(N log N) sort,
|
||||
// instead of the prior O(N × E × T + N log N).
|
||||
const now = Date.now()
|
||||
const explicitByTabId = buildExplicitEntriesByTabId(agentStatusByPaneKey)
|
||||
const precomputedScores = new Map<string, number>(
|
||||
worktrees.map((w) => [
|
||||
w.id,
|
||||
computeSmartScore(
|
||||
w,
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
now,
|
||||
agentStatusByPaneKey,
|
||||
explicitByTabId,
|
||||
ptyIdsByTabId
|
||||
)
|
||||
])
|
||||
)
|
||||
|
||||
// Why: agentStatusByPaneKey is forwarded so the smart-score comparator can
|
||||
// use explicit agent status (OSC 9999) when ranking worktrees by recency.
|
||||
// `explicitByTabId` is forwarded too so the comparator's fallback path (used
|
||||
// for worktrees covered by smartSortOverrides) avoids rebuilding the index.
|
||||
return [...worktrees].sort(
|
||||
buildWorktreeComparator(
|
||||
'smart',
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
now,
|
||||
null,
|
||||
agentStatusByPaneKey,
|
||||
precomputedScores,
|
||||
explicitByTabId,
|
||||
ptyIdsByTabId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a recent-work score for a worktree.
|
||||
* Higher score = higher in the list.
|
||||
*
|
||||
* Scoring:
|
||||
* running AI job → +60
|
||||
* recent activity → +36 (decays over 24 hours)
|
||||
* needs attention → +35
|
||||
* unread → +18
|
||||
* open terminal → +12
|
||||
* live branch PR → +10
|
||||
* linked issue → +6
|
||||
*/
|
||||
export function computeSmartScore(
|
||||
worktree: Worktree,
|
||||
tabsByWorktree: Record<string, TerminalTab[]> | null,
|
||||
repoMap: Map<string, Repo> | null,
|
||||
prCache: Record<string, PRCacheEntry> | null,
|
||||
now: number = Date.now(),
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry>,
|
||||
explicitByTabId?: Map<string, AgentStatusEntry[]>,
|
||||
ptyIdsByTabId?: Record<string, string[]> | null
|
||||
): number {
|
||||
return computeSmartScoreFromSignals(
|
||||
worktree,
|
||||
tabsByWorktree?.[worktree.id] ?? [],
|
||||
// Why: branch-aware PR cache is the freshest signal, but off-screen
|
||||
// worktrees may not have fetched it yet. Fall back to persisted linkedPR
|
||||
// only while that branch cache entry is still cold so smart sorting stays
|
||||
// stable on launch without reviving stale PRs after a cache miss resolves.
|
||||
repoMap ? hasRecentPRSignal(worktree, repoMap, prCache) : worktree.linkedPR !== null,
|
||||
now,
|
||||
const attentionByWorktree = buildAttentionByWorktree(
|
||||
worktrees,
|
||||
tabsByWorktree,
|
||||
agentStatusByPaneKey,
|
||||
explicitByTabId,
|
||||
ptyIdsByTabId
|
||||
runtimePaneTitlesByTabId,
|
||||
ptyIdsByTabId,
|
||||
now
|
||||
)
|
||||
|
||||
return [...worktrees].sort(buildWorktreeComparator('smart', repoMap, now, attentionByWorktree))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,24 +184,15 @@ export function getVisibleWorktreeIds(): string[] {
|
|||
allWorktrees,
|
||||
state.tabsByWorktree,
|
||||
repoMap,
|
||||
state.prCache,
|
||||
state.agentStatusByPaneKey,
|
||||
state.runtimePaneTitlesByTabId,
|
||||
state.ptyIdsByTabId
|
||||
).map((w) => w.id)
|
||||
} else {
|
||||
// Why empty map: non-smart branches don't read attentionByWorktree, but
|
||||
// the param is required to keep smart-mode callers honest at the type level.
|
||||
const sorted = [...allWorktrees].sort(
|
||||
buildWorktreeComparator(
|
||||
state.sortBy,
|
||||
state.tabsByWorktree,
|
||||
repoMap,
|
||||
state.prCache,
|
||||
Date.now(),
|
||||
null,
|
||||
state.agentStatusByPaneKey,
|
||||
undefined,
|
||||
undefined,
|
||||
state.ptyIdsByTabId
|
||||
)
|
||||
buildWorktreeComparator(state.sortBy, repoMap, Date.now(), new Map())
|
||||
)
|
||||
sortedIds = sorted.map((w) => w.id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,40 @@ describe('agent status tool + assistant fields', () => {
|
|||
expect(store.getState().agentStatusEpoch).toBe(firstEpoch + 1)
|
||||
expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1)
|
||||
})
|
||||
|
||||
it('bumps global epochs when a stale same-state entry refreshes', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = createTestStore()
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
'tab-1:1',
|
||||
{ state: 'working', prompt: 'stale ping', agentType: 'claude' },
|
||||
'claude',
|
||||
{ updatedAt: 1_000, stateStartedAt: 1_000 }
|
||||
)
|
||||
const firstEpoch = store.getState().agentStatusEpoch
|
||||
const firstSortEpoch = store.getState().sortEpoch
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
'tab-1:1',
|
||||
{ state: 'working', prompt: 'fresh again', agentType: 'claude' },
|
||||
'claude',
|
||||
{
|
||||
updatedAt: 1_000 + AGENT_STATUS_STALE_AFTER_MS + 1,
|
||||
stateStartedAt: 1_000
|
||||
}
|
||||
)
|
||||
|
||||
const refreshedEntry = store.getState().agentStatusByPaneKey['tab-1:1']
|
||||
expect(refreshedEntry.prompt).toBe('fresh again')
|
||||
// Why: a stale same-state refresh can promote the worktree back into a
|
||||
// smart-sort attention class, so both freshness and sort epochs must tick.
|
||||
expect(store.getState().agentStatusEpoch).toBe(firstEpoch + 1)
|
||||
expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent status stateStartedAt', () => {
|
||||
|
|
|
|||
|
|
@ -239,16 +239,16 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
|||
// tool/prompt pings still update agentStatusByPaneKey for the owning
|
||||
// row, but they must not fan out through dashboard/sidebar aggregate
|
||||
// work across every card. Sort-relevant inputs are:
|
||||
// 1. `state` transitions — sort score is a function of state.
|
||||
// 2. Freshness transitions (stale → fresh) — `computeSmartScoreFromSignals`
|
||||
// in smart-sort.ts filters entries through
|
||||
// `isExplicitAgentStatusFresh(entry, updatedAt, AGENT_STATUS_STALE_AFTER_MS)`
|
||||
// 1. `state` transitions — smart-sort class is a function of state.
|
||||
// 2. Freshness transitions (stale → fresh) — `resolveAttention` in
|
||||
// smart-attention.ts filters entries through
|
||||
// `isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)`
|
||||
// (30-min TTL). A stale entry that refreshes with the SAME state
|
||||
// goes from "not contributing" to contributing +60 (working) or
|
||||
// +35 (blocked/waiting) to the score — order must update. Snapshot
|
||||
// hydration can pass an older updatedAt; in that case the entry is
|
||||
// still stored with its true age, and selectors will immediately
|
||||
// decay it if it is already stale.
|
||||
// goes from "not contributing" (Class 4) to driving a higher
|
||||
// class — order must update. Snapshot hydration can pass an older
|
||||
// updatedAt; in that case the entry is still stored with its true
|
||||
// age, and selectors will immediately decay it if it is already
|
||||
// stale.
|
||||
const wasFresh =
|
||||
!!existing && isExplicitAgentStatusFresh(existing, updatedAt, AGENT_STATUS_STALE_AFTER_MS)
|
||||
const sortRelevantChange = !existing || existing.state !== payload.state || !wasFresh
|
||||
|
|
@ -664,8 +664,9 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
|||
dismissRetainedAgent: (paneKey) => {
|
||||
// Why: no agentStatusEpoch / sortEpoch bump here (mirrors retainAgents).
|
||||
// Retained rows are a pure read-overlay on top of agentStatusByPaneKey —
|
||||
// they do not contribute to smart-sort scoring (see computeSmartScore*
|
||||
// in smart-sort.ts, which reads agentStatusByPaneKey only) and dashboard
|
||||
// they do not contribute to smart-sort class resolution (see
|
||||
// resolveAttention in smart-attention.ts, which reads
|
||||
// agentStatusByPaneKey only) and dashboard
|
||||
// selectors re-render on retainedAgentsByPaneKey identity changes
|
||||
// directly. Bumping epochs would force sidebar re-sorts and selector
|
||||
// recomputations for a change that cannot affect either result.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/runtime/sync-runtime-graph', () => ({
|
||||
scheduleRuntimeGraphSync: vi.fn()
|
||||
}))
|
||||
vi.mock('@/components/terminal-pane/pty-transport', () => ({
|
||||
registerEagerPtyBuffer: vi.fn(),
|
||||
ensurePtyDispatcher: vi.fn(),
|
||||
unregisterPtyDataHandlers: vi.fn()
|
||||
}))
|
||||
vi.mock('@/components/terminal-pane/shutdown-buffer-captures', () => ({
|
||||
shutdownBufferCaptures: vi.fn()
|
||||
}))
|
||||
|
||||
// @ts-expect-error -- minimal preload API stub for the slice's IPC writes
|
||||
globalThis.window = { api: {} }
|
||||
|
||||
import { createTestStore, makeTab, makeWorktree, seedStore } from './store-test-helpers'
|
||||
|
||||
describe('runtimePaneTitle → sortEpoch', () => {
|
||||
it('bumps sortEpoch when the new title classifies differently than the previous title', () => {
|
||||
// Why: smart sort's title-heuristic fallback (Edge case 9) reads
|
||||
// runtimePaneTitlesByTabId. A hookless agent transitioning from
|
||||
// 'working' to 'permission' must trigger a re-sort.
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: 'wt-bg', repoId: 'repo1', path: '/path/wt-bg' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
'wt-bg': [makeTab({ id: 'tab-1', worktreeId: 'wt-bg' })]
|
||||
}
|
||||
})
|
||||
const before = store.getState().sortEpoch
|
||||
store.getState().setRuntimePaneTitle('tab-1', 1, '⠋ Claude')
|
||||
const afterWorking = store.getState().sortEpoch
|
||||
expect(afterWorking).toBeGreaterThan(before)
|
||||
store.getState().setRuntimePaneTitle('tab-1', 1, '✋ Gemini CLI')
|
||||
expect(store.getState().sortEpoch).toBeGreaterThan(afterWorking)
|
||||
})
|
||||
|
||||
it('does not bump sortEpoch when the classification is unchanged', () => {
|
||||
// Why: incidental title noise (spinner frame, prompt suffix) shouldn't
|
||||
// churn the sidebar order.
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: 'wt-bg', repoId: 'repo1', path: '/path/wt-bg' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
'wt-bg': [makeTab({ id: 'tab-1', worktreeId: 'wt-bg' })]
|
||||
}
|
||||
})
|
||||
store.getState().setRuntimePaneTitle('tab-1', 1, '⠋ Claude')
|
||||
const baseline = store.getState().sortEpoch
|
||||
// Spinner frame change — still classifies as 'working'.
|
||||
store.getState().setRuntimePaneTitle('tab-1', 1, '⠙ Claude')
|
||||
expect(store.getState().sortEpoch).toBe(baseline)
|
||||
})
|
||||
|
||||
it('bumps sortEpoch when clearing a classified title back to none', () => {
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: 'wt-bg', repoId: 'repo1', path: '/path/wt-bg' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
'wt-bg': [makeTab({ id: 'tab-1', worktreeId: 'wt-bg' })]
|
||||
}
|
||||
})
|
||||
store.getState().setRuntimePaneTitle('tab-1', 1, '✋ Gemini CLI')
|
||||
const baseline = store.getState().sortEpoch
|
||||
store.getState().clearRuntimePaneTitle('tab-1', 1)
|
||||
expect(store.getState().sortEpoch).toBeGreaterThan(baseline)
|
||||
})
|
||||
|
||||
it('does not bump sortEpoch when the changing pane belongs to the active worktree (set)', () => {
|
||||
// Why: clicking a slept worktree wakes it; the PTY remount briefly
|
||||
// reclassifies its title, which must NOT re-rank the active worktree.
|
||||
// Stability beats freshness when the user is looking at it.
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({ id: 'wt-a', repoId: 'repo1', path: '/path/wt-a' }),
|
||||
makeWorktree({ id: 'wt-b', repoId: 'repo1', path: '/path/wt-b' })
|
||||
]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
'wt-a': [makeTab({ id: 'tab-1', worktreeId: 'wt-a' })],
|
||||
'wt-b': [makeTab({ id: 'tab-2', worktreeId: 'wt-b' })]
|
||||
},
|
||||
activeWorktreeId: 'wt-a'
|
||||
})
|
||||
const baseline = store.getState().sortEpoch
|
||||
store.getState().setRuntimePaneTitle('tab-1', 1, '⠋ Claude')
|
||||
expect(store.getState().sortEpoch).toBe(baseline)
|
||||
})
|
||||
|
||||
it('does not bump sortEpoch when the changing pane belongs to the active worktree (clear)', () => {
|
||||
// Why: same no-view-triggered-rerank invariant — when the active worktree's
|
||||
// pane title clears (e.g. on PTY remount during wake), the sidebar must
|
||||
// not reorder underneath the user's current selection.
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({ id: 'wt-a', repoId: 'repo1', path: '/path/wt-a' }),
|
||||
makeWorktree({ id: 'wt-b', repoId: 'repo1', path: '/path/wt-b' })
|
||||
]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
'wt-a': [makeTab({ id: 'tab-1', worktreeId: 'wt-a' })],
|
||||
'wt-b': [makeTab({ id: 'tab-2', worktreeId: 'wt-b' })]
|
||||
},
|
||||
activeWorktreeId: 'wt-b'
|
||||
})
|
||||
// Seed the classified title while wt-a is INACTIVE so the gate doesn't
|
||||
// suppress this preparatory write — we only want to test the gate on clear.
|
||||
store.getState().setRuntimePaneTitle('tab-1', 1, '✋ Gemini CLI')
|
||||
store.setState({ activeWorktreeId: 'wt-a' })
|
||||
const baseline = store.getState().sortEpoch
|
||||
store.getState().clearRuntimePaneTitle('tab-1', 1)
|
||||
expect(store.getState().sortEpoch).toBe(baseline)
|
||||
})
|
||||
})
|
||||
|
|
@ -403,7 +403,7 @@ describe('setActiveWorktree', () => {
|
|||
|
||||
const worktrees = [...store.getState().worktreesByRepo.repo1]
|
||||
const repoMap = new Map(store.getState().repos.map((repo) => [repo.id, repo]))
|
||||
worktrees.sort(buildWorktreeComparator('smart', {}, repoMap, null, now))
|
||||
worktrees.sort(buildWorktreeComparator('smart', repoMap, now, new Map()))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual([backgroundId, focusedId])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -797,14 +797,40 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
setRuntimePaneTitle: (tabId, paneId, title) => {
|
||||
set((s) => {
|
||||
const currentByPane = s.runtimePaneTitlesByTabId[tabId] ?? {}
|
||||
if (currentByPane[paneId] === title) {
|
||||
const prevTitle = currentByPane[paneId]
|
||||
if (prevTitle === title) {
|
||||
return s
|
||||
}
|
||||
// Why: smart sort's title-heuristic fallback (Edge case 9) reads
|
||||
// runtimePaneTitlesByTabId. A hookless agent transitioning from
|
||||
// 'working' → 'permission' via a title change must trigger a re-sort,
|
||||
// otherwise the worktree stays in its old class until some unrelated
|
||||
// event fires. Bumping only on classification change keeps incidental
|
||||
// title noise (spinner frame, prompt suffix) from churning the sidebar.
|
||||
const classificationChanged =
|
||||
detectAgentStatusFromTitle(prevTitle ?? '') !== detectAgentStatusFromTitle(title)
|
||||
// Why: locate the owning worktree so we can suppress the sortEpoch
|
||||
// bump when the changing pane lives in the active worktree. Title
|
||||
// changes there are side-effects of the user's click (PTY remount on
|
||||
// worktree activation emits a fresh shell prompt, then the agent
|
||||
// re-emits its working title) — bumping would re-rank the sidebar on
|
||||
// click, the exact bug PR #209 fixed for updateTabTitle. If no owner
|
||||
// is found the pane is orphaned; skip the bump as unsafe.
|
||||
let ownerWorktreeId: string | null = null
|
||||
for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) {
|
||||
if (tabs.some((t) => t.id === tabId)) {
|
||||
ownerWorktreeId = wId
|
||||
break
|
||||
}
|
||||
}
|
||||
const isActive = ownerWorktreeId !== null && ownerWorktreeId === s.activeWorktreeId
|
||||
const shouldBump = classificationChanged && ownerWorktreeId !== null && !isActive
|
||||
return {
|
||||
runtimePaneTitlesByTabId: {
|
||||
...s.runtimePaneTitlesByTabId,
|
||||
[tabId]: { ...currentByPane, [paneId]: title }
|
||||
}
|
||||
},
|
||||
...(shouldBump ? { sortEpoch: s.sortEpoch + 1 } : {})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
|
@ -815,6 +841,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
if (!currentByPane || !(paneId in currentByPane)) {
|
||||
return s
|
||||
}
|
||||
const prevTitle = currentByPane[paneId]
|
||||
const nextByPane = { ...currentByPane }
|
||||
delete nextByPane[paneId]
|
||||
|
||||
|
|
@ -825,7 +852,27 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
delete next[tabId]
|
||||
}
|
||||
|
||||
return { runtimePaneTitlesByTabId: next }
|
||||
// Why: clearing a 'working'/'permission'-classified title back to none
|
||||
// changes the title-heuristic verdict for that pane, so the smart sort
|
||||
// needs a re-sort. See setRuntimePaneTitle for the rationale.
|
||||
const hadClassification = detectAgentStatusFromTitle(prevTitle ?? '') !== null
|
||||
// Why: same active-worktree gate as setRuntimePaneTitle — clears that
|
||||
// fire as a side-effect of a click-driven PTY teardown in the active
|
||||
// worktree must not re-rank the sidebar. Skip bumping when no owner is
|
||||
// found (orphaned pane) for the same safety reason.
|
||||
let ownerWorktreeId: string | null = null
|
||||
for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) {
|
||||
if (tabs.some((t) => t.id === tabId)) {
|
||||
ownerWorktreeId = wId
|
||||
break
|
||||
}
|
||||
}
|
||||
const isActive = ownerWorktreeId !== null && ownerWorktreeId === s.activeWorktreeId
|
||||
const shouldBump = hadClassification && ownerWorktreeId !== null && !isActive
|
||||
return {
|
||||
runtimePaneTitlesByTabId: next,
|
||||
...(shouldBump ? { sortEpoch: s.sortEpoch + 1 } : {})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -508,6 +508,35 @@ const onboardingGhosttyDiscoveredSchema = z
|
|||
})
|
||||
.strict()
|
||||
const onboardingGhosttyImportClickedSchema = z.object({ cohort: cohortSchema }).strict()
|
||||
|
||||
// Why: smart-sort telemetry. The class distribution event tells us whether
|
||||
// real users have meaningful Class 1/2/3 populations (signal that the
|
||||
// redesign is doing work) or whether everyone collapses to Class 4 (signal
|
||||
// that hook coverage is too low). The Class 1 promotion event distinguishes
|
||||
// hook-driven attention from the title-heuristic fallback so we can tell
|
||||
// whether Edge case 9 is carrying weight. The smart→recent switch event is
|
||||
// our regression signal: users abandoning Smart for Recent.
|
||||
const smartSortClassDistributionSchema = z
|
||||
.object({
|
||||
class_1: z.number().int().nonnegative(),
|
||||
class_2: z.number().int().nonnegative(),
|
||||
class_3: z.number().int().nonnegative(),
|
||||
class_4: z.number().int().nonnegative(),
|
||||
total_worktrees: z.number().int().nonnegative()
|
||||
})
|
||||
.strict()
|
||||
const smartSortClass1PromotionSchema = z
|
||||
.object({
|
||||
cause: z.enum(['blocked', 'waiting', 'title-heuristic'])
|
||||
})
|
||||
.strict()
|
||||
// Why a placeholder field instead of `z.object({})`: an empty zod object
|
||||
// infers as TS `{}` (which in TS means "anything non-null/undefined"). That
|
||||
// upsets the `keyof EventMap[N]` probes used by COHORT_EXTENDED_SET and
|
||||
// ONBOARDING_COHORT_SET, breaking their compile-time roster sync checks.
|
||||
// Carrying a single optional `_v` discriminator dodges the issue and
|
||||
// preserves room to add future fields without renaming the event.
|
||||
const smartToRecentSwitchSchema = z.object({ _v: z.literal(1).optional() }).strict()
|
||||
const onboardingGhosttyImportFailedSchema = z
|
||||
.object({
|
||||
// `'no_config'` is reserved for a future explicit "preview returned
|
||||
|
|
@ -561,7 +590,11 @@ export const eventSchemas = {
|
|||
onboarding_ghostty_discovered: onboardingGhosttyDiscoveredSchema,
|
||||
onboarding_ghostty_import_clicked: onboardingGhosttyImportClickedSchema,
|
||||
onboarding_ghostty_import_failed: onboardingGhosttyImportFailedSchema,
|
||||
activation_checklist_item_completed: activationChecklistItemCompletedSchema
|
||||
activation_checklist_item_completed: activationChecklistItemCompletedSchema,
|
||||
|
||||
smart_sort_class_distribution: smartSortClassDistributionSchema,
|
||||
smart_sort_class_1_promotion: smartSortClass1PromotionSchema,
|
||||
smart_to_recent_switch: smartToRecentSwitchSchema
|
||||
} as const
|
||||
|
||||
export type EventMap = { [N in keyof typeof eventSchemas]: z.infer<(typeof eventSchemas)[N]> }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
import { test, expect } from './helpers/orca-app'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
|
||||
type SmartSortScenario = {
|
||||
blockedId: string
|
||||
doneId: string
|
||||
}
|
||||
|
||||
const WORKTREE_OPTION_PREFIX = 'worktree-list-option-'
|
||||
|
||||
async function getVisibleWorktreeIdsByTop(page: Page): Promise<string[]> {
|
||||
return page.locator(`[role="option"][id^="${WORKTREE_OPTION_PREFIX}"]`).evaluateAll((elements) =>
|
||||
elements
|
||||
.map((element) => ({
|
||||
id: decodeURIComponent(element.id.slice('worktree-list-option-'.length)),
|
||||
top: element.getBoundingClientRect().top
|
||||
}))
|
||||
.sort((a, b) => a.top - b.top)
|
||||
.map((row) => row.id)
|
||||
)
|
||||
}
|
||||
|
||||
async function seedSmartSortScenario(page: Page): Promise<SmartSortScenario> {
|
||||
return page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
|
||||
const state = store.getState()
|
||||
state.setActiveView('terminal')
|
||||
state.setSidebarOpen(true)
|
||||
state.setGroupBy('none')
|
||||
state.setSortBy('smart')
|
||||
|
||||
const worktrees = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.filter((worktree) => !worktree.isArchived)
|
||||
if (worktrees.length < 2) {
|
||||
throw new Error('Smart sort E2E needs at least two worktrees')
|
||||
}
|
||||
|
||||
const [blocked, done] = worktrees
|
||||
const now = Date.now()
|
||||
|
||||
store.setState((current) => ({
|
||||
worktreesByRepo: Object.fromEntries(
|
||||
Object.entries(current.worktreesByRepo).map(([repoId, repoWorktrees]) => [
|
||||
repoId,
|
||||
repoWorktrees.map((worktree) => {
|
||||
if (worktree.id === blocked.id) {
|
||||
return {
|
||||
...worktree,
|
||||
displayName: 'Z smart-sort blocked',
|
||||
lastActivityAt: now - 5 * 60_000,
|
||||
sortOrder: 0
|
||||
}
|
||||
}
|
||||
if (worktree.id === done.id) {
|
||||
return {
|
||||
...worktree,
|
||||
displayName: 'A smart-sort done',
|
||||
lastActivityAt: now,
|
||||
sortOrder: 10
|
||||
}
|
||||
}
|
||||
return worktree
|
||||
})
|
||||
])
|
||||
)
|
||||
}))
|
||||
|
||||
for (const worktree of [blocked, done]) {
|
||||
const currentState = store.getState()
|
||||
if ((currentState.tabsByWorktree[worktree.id] ?? []).length === 0) {
|
||||
currentState.createTab(worktree.id)
|
||||
}
|
||||
}
|
||||
|
||||
const stateWithTabs = store.getState()
|
||||
const blockedTab = stateWithTabs.tabsByWorktree[blocked.id]?.[0]
|
||||
const doneTab = stateWithTabs.tabsByWorktree[done.id]?.[0]
|
||||
if (!blockedTab || !doneTab) {
|
||||
throw new Error('Smart sort E2E failed to create terminal tabs')
|
||||
}
|
||||
|
||||
// Why: WorktreeList intentionally holds cold-start ordering until a live
|
||||
// PTY exists. E2E hidden windows can create tabs before panes mount, so
|
||||
// seed the live-PTY map explicitly and let agent-status writes drive the
|
||||
// same sortEpoch path that hook events use in the app.
|
||||
store.setState((current) => ({
|
||||
ptyIdsByTabId: {
|
||||
...current.ptyIdsByTabId,
|
||||
[blockedTab.id]: current.ptyIdsByTabId[blockedTab.id]?.length
|
||||
? current.ptyIdsByTabId[blockedTab.id]
|
||||
: [`e2e-${blockedTab.id}`],
|
||||
[doneTab.id]: current.ptyIdsByTabId[doneTab.id]?.length
|
||||
? current.ptyIdsByTabId[doneTab.id]
|
||||
: [`e2e-${doneTab.id}`]
|
||||
}
|
||||
}))
|
||||
|
||||
const actions = store.getState()
|
||||
actions.setAgentStatus(
|
||||
`${doneTab.id}:1`,
|
||||
{ state: 'done', prompt: 'Finished', agentType: 'codex' },
|
||||
'codex',
|
||||
{ updatedAt: now, stateStartedAt: now - 1_000 }
|
||||
)
|
||||
actions.setAgentStatus(
|
||||
`${blockedTab.id}:1`,
|
||||
{ state: 'blocked', prompt: 'Needs approval', agentType: 'codex' },
|
||||
'codex',
|
||||
{ updatedAt: now, stateStartedAt: now - 60_000 }
|
||||
)
|
||||
|
||||
return { blockedId: blocked.id, doneId: done.id }
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Worktree Smart Sort', () => {
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
})
|
||||
|
||||
test('renders attention-needed worktrees above finished agents in Smart mode', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const { blockedId, doneId } = await seedSmartSortScenario(orcaPage)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getVisibleWorktreeIdsByTop(orcaPage)).slice(0, 2), {
|
||||
timeout: 8_000,
|
||||
message: 'Smart sort did not promote the blocked worktree in the visible sidebar'
|
||||
})
|
||||
.toEqual([blockedId, doneId])
|
||||
|
||||
await expect(
|
||||
orcaPage.locator(`[id="${WORKTREE_OPTION_PREFIX}${encodeURIComponent(blockedId)}"]`)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
orcaPage.locator(`[id="${WORKTREE_OPTION_PREFIX}${encodeURIComponent(doneId)}"]`)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue