From 28a5acb9316e9cebbf3a282eaf21c6a33c8e586e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 14 May 2026 12:20:57 -0700 Subject: [PATCH] feat(sidebar): redesign Smart worktree sort around hook-reported agent state (#1701) Co-authored-by: Orca --- .../src/components/WorktreeJumpPalette.tsx | 21 +- .../src/components/sidebar/SidebarHeader.tsx | 47 +- .../src/components/sidebar/WorktreeList.tsx | 194 ++-- .../sidebar/smart-attention.test.ts | 526 +++++++++++ .../src/components/sidebar/smart-attention.ts | 322 +++++++ .../src/components/sidebar/smart-sort.test.ts | 829 +++++++++--------- .../src/components/sidebar/smart-sort.ts | 428 ++------- .../components/sidebar/visible-worktrees.ts | 17 +- .../src/store/slices/agent-status.test.ts | 34 + src/renderer/src/store/slices/agent-status.ts | 23 +- .../runtime-pane-title-sort-epoch.test.ts | 127 +++ .../src/store/slices/store-cascades.test.ts | 2 +- src/renderer/src/store/slices/terminals.ts | 53 +- src/shared/telemetry-events.ts | 35 +- tests/e2e/worktree-smart-sort.spec.ts | 148 ++++ 15 files changed, 1916 insertions(+), 890 deletions(-) create mode 100644 src/renderer/src/components/sidebar/smart-attention.test.ts create mode 100644 src/renderer/src/components/sidebar/smart-attention.ts create mode 100644 src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts create mode 100644 tests/e2e/worktree-smart-sort.spec.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index defa9db4a..cfa7f2a6d 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 5757f44a6..e84bde04b 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -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) => ( - e.preventDefault()} - > - {opt.label} - - ))} + {SORT_OPTIONS.map((opt) => { + const radioItem = ( + e.preventDefault()} + > + {opt.label} + + ) + if (!opt.description) { + return radioItem + } + return ( + + {radioItem} + + {opt.description} + + + ) + })} diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 356f33583..ef2250db6 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 | 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( - 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() + 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>(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(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() + 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. diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts new file mode 100644 index 000000000..05120eae9 --- /dev/null +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -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 & { 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 { + const out: Record = {} + 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 = { + '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 = { + '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 = { + '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) + }) +}) diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts new file mode 100644 index 000000000..516ced3a1 --- /dev/null +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -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 | undefined +): Map { + const byTab = new Map() + 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 | null, + agentStatusByPaneKey: Record | undefined, + runtimePaneTitlesByTabId: Record>, + ptyIdsByTabId: Record, + now: number +): Map { + const byTab = buildExplicitEntriesByTabId(agentStatusByPaneKey) + const result = new Map() + + 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() + 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 +} diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts index ca81d96ae..6c4f9a014 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -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 { } } -function makeAgentStatusEntry( - overrides: Partial & { paneKey: string } -): AgentStatusEntry { +function makeEntry(overrides: Partial & { 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): Record { + const out: Record = {} + 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, + agentStatusByPaneKey: Record +): 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 = { - [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 = { - [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 = { + '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']) }) diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index cf3c56c92..3d539c2f3 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -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 | 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, - ptyIdsByTabId?: Record | 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 | undefined -): Map { - const byTab = new Map() - 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, - prCache: Record | 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, - explicitByTabId?: Map, - ptyIdsByTabId?: Record | 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 | null, - repoMap: Map, - prCache: Record | null, - smartSortOverrides: Record | 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 | null, repoMap: Map, - prCache: Record | null, - now: number = Date.now(), - smartSortOverrides: Record | null = null, - agentStatusByPaneKey?: Record, - precomputedScores?: Map, - explicitByTabId?: Map, - ptyIdsByTabId?: Record | null + now: number, + attentionByWorktree: Map ): (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, repoMap: Map, - prCache: Record | null, - agentStatusByPaneKey?: Record, - ptyIdsByTabId?: Record | null + agentStatusByPaneKey: Record, + runtimePaneTitlesByTabId: Record>, + ptyIdsByTabId: Record ): 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( - 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 | null, - repoMap: Map | null, - prCache: Record | null, - now: number = Date.now(), - agentStatusByPaneKey?: Record, - explicitByTabId?: Map, - ptyIdsByTabId?: Record | 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)) } diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 68015c62c..2f6a632b8 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -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) } diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 1b1eaa07c..0ebbf1115 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -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', () => { diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index e363c0ebd..361db4a40 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -239,16 +239,16 @@ export const createAgentStatusSlice: StateCreator { // 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. diff --git a/src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts b/src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts new file mode 100644 index 000000000..8f39b0db3 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index b788d0097..a20b9768d 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -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]) }) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 16d9358a1..66fa22d9d 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -797,14 +797,40 @@ export const createTerminalSlice: StateCreator 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 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 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 } : {}) + } }) }, diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 9fa028272..48ba1f0b3 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -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]> } diff --git a/tests/e2e/worktree-smart-sort.spec.ts b/tests/e2e/worktree-smart-sort.spec.ts new file mode 100644 index 000000000..0430f5fa3 --- /dev/null +++ b/tests/e2e/worktree-smart-sort.spec.ts @@ -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 { + 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 { + 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() + }) +})