Improve smart worktree sorting (#137)

This commit is contained in:
Jinjing
2026-03-27 09:47:17 -07:00
committed by GitHub
parent 97ef6d5e20
commit dd4eb42f5d
3 changed files with 186 additions and 22 deletions
@@ -83,10 +83,19 @@ const WorktreeList = React.memo(function WorktreeList() {
}
// Sort
all.sort(buildWorktreeComparator(sortBy, tabsByWorktree, repoMap))
all.sort(buildWorktreeComparator(sortBy, tabsByWorktree, repoMap, activeWorktreeId))
return all
}, [worktreesByRepo, filterRepoIds, searchQuery, showActiveOnly, sortBy, repoMap, tabsByWorktree])
}, [
worktreesByRepo,
filterRepoIds,
searchQuery,
showActiveOnly,
sortBy,
repoMap,
tabsByWorktree,
activeWorktreeId
])
// Collapsed group state
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
@@ -0,0 +1,127 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
import { buildWorktreeComparator, computeSmartScore } from './smart-sort'
const NOW = new Date('2026-03-27T12:00:00.000Z').getTime()
const repoMap = new Map<string, Repo>([
[
'repo-1',
{
id: 'repo-1',
path: '/tmp/repo-1',
displayName: 'repo-1',
badgeColor: '#000000',
addedAt: 0
}
]
])
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: overrides.id ?? 'wt-1',
repoId: overrides.repoId ?? 'repo-1',
path: overrides.path ?? `/tmp/${overrides.id ?? 'wt-1'}`,
branch: overrides.branch ?? `refs/heads/${overrides.id ?? 'wt-1'}`,
head: overrides.head ?? 'abc123',
isBare: overrides.isBare ?? false,
linkedIssue: overrides.linkedIssue ?? null,
linkedPR: overrides.linkedPR ?? null,
isArchived: overrides.isArchived ?? false,
comment: overrides.comment ?? '',
isUnread: overrides.isUnread ?? false,
displayName: overrides.displayName ?? (overrides.id ?? 'wt-1'),
sortOrder: overrides.sortOrder ?? 0,
lastActivityAt: overrides.lastActivityAt ?? 0
}
}
function makeTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
return {
id: overrides.id ?? 'tab-1',
ptyId: overrides.ptyId ?? 'pty-1',
worktreeId: overrides.worktreeId ?? 'wt-1',
title: overrides.title ?? 'bash',
customTitle: overrides.customTitle ?? null,
color: overrides.color ?? null,
sortOrder: overrides.sortOrder ?? 0,
createdAt: overrides.createdAt ?? 0
}
}
describe('computeSmartScore', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('prioritizes the active worktree over a merely linked worktree', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW)
const active = makeWorktree({ id: 'active', displayName: 'Active' })
const linked = makeWorktree({
id: 'linked',
displayName: 'Linked',
linkedPR: 17,
linkedIssue: 42
})
expect(computeSmartScore(active, null, active.id)).toBeGreaterThan(computeSmartScore(linked, null, active.id))
})
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)).toBeGreaterThan(computeSmartScore(stale, 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' })]
}
expect(computeSmartScore(withLiveTerminal, tabsByWorktree)).toBeGreaterThan(
computeSmartScore(withoutLiveTerminal, tabsByWorktree)
)
})
})
describe('buildWorktreeComparator', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('sorts smart mode by ongoing work signals before alphabetical order', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW)
const active = makeWorktree({ id: 'active', displayName: 'z-active' })
const recent = makeWorktree({
id: 'recent',
displayName: 'a-recent',
lastActivityAt: NOW - 90 * 60 * 1000
})
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, active.id))
expect(worktrees.map((worktree) => worktree.id)).toEqual(['active', 'recent', 'stale'])
})
})
@@ -9,7 +9,8 @@ type SortBy = 'name' | 'recent' | 'smart' | 'repo'
export function buildWorktreeComparator(
sortBy: SortBy,
tabsByWorktree: Record<string, TerminalTab[]> | null,
repoMap: Map<string, Repo>
repoMap: Map<string, Repo>,
activeWorktreeId: string | null = null
): (a: Worktree, b: Worktree) => number {
return (a, b) => {
switch (sortBy) {
@@ -37,7 +38,13 @@ export function buildWorktreeComparator(
return bActivity - aActivity
}
case 'smart':
return computeSmartScore(b, tabsByWorktree) - computeSmartScore(a, tabsByWorktree)
return (
computeSmartScore(b, tabsByWorktree, activeWorktreeId) -
computeSmartScore(a, tabsByWorktree, activeWorktreeId) ||
b.lastActivityAt - a.lastActivityAt ||
b.sortOrder - a.sortOrder ||
a.displayName.localeCompare(b.displayName)
)
case 'repo': {
const ra = repoMap.get(a.repoId)?.displayName ?? ''
const rb = repoMap.get(b.repoId)?.displayName ?? ''
@@ -55,15 +62,20 @@ export function buildWorktreeComparator(
* Higher score = higher in the list.
*
* Scoring:
* running AI job → +100
* needs attention → +40 (permission prompt, CI failure)
* unread → +20
* recent activity → +10 (scaled: 10 for just now, decaying over 1 hour)
* last viewed → +1 (scaled: 1 for just now, decaying over 1 hour)
* active worktree → +80
* running AI job → +60
* needs attention → +35
* unread → +18
* open terminal → +12
* linked PR → +10
* linked issue → +6
* recent activity → +24 (decays over 24 hours)
* last viewed → +8 (decays over 12 hours)
*/
export function computeSmartScore(
worktree: Worktree,
tabsByWorktree: Record<string, TerminalTab[]> | null
tabsByWorktree: Record<string, TerminalTab[]> | null,
activeWorktreeId: string | null = null
): number {
const tabs = tabsByWorktree?.[worktree.id] ?? []
const liveTabs = tabs.filter((t) => t.ptyId)
@@ -71,37 +83,53 @@ export function computeSmartScore(
let score = 0
if (worktree.id === activeWorktreeId) {
score += 80
}
// Running: any live PTY with an AI agent actively working
const isRunning = liveTabs.some((t) => detectAgentStatusFromTitle(t.title) === 'working')
if (isRunning) {
score += 100
} else if (liveTabs.length > 0) {
// Has live terminals but not actively working — still somewhat relevant
score += 5
score += 60
}
// Needs attention: permission prompt or CI-related unread
// Needs attention: permission prompt in a live agent terminal
const needsAttention = liveTabs.some((t) => detectAgentStatusFromTitle(t.title) === 'permission')
if (needsAttention) {
score += 40
score += 35
}
// Unread
if (worktree.isUnread) {
score += 20
score += 18
}
// Recent meaningful activity (decays over 1 hour)
// Live terminals are a strong sign of ongoing work, even if no agent title is detected.
if (liveTabs.length > 0) {
score += 12
}
if (worktree.linkedPR !== null) {
score += 10
}
if (worktree.linkedIssue !== null) {
score += 6
}
// Recent meaningful activity should stay relevant for the rest of the day,
// not vanish after an hour.
const activityAge = now - (worktree.lastActivityAt || 0)
const ONE_HOUR = 60 * 60 * 1000
if (worktree.lastActivityAt > 0) {
score += 10 * Math.max(0, 1 - activityAge / ONE_HOUR)
const ONE_DAY = 24 * 60 * 60 * 1000
score += 24 * Math.max(0, 1 - activityAge / ONE_DAY)
}
// Last viewed (minor tiebreaker, decays over 1 hour)
// Last viewed matters, but less than actual activity.
const viewAge = now - (worktree.sortOrder || 0)
if (worktree.sortOrder > 0) {
score += 1 * Math.max(0, 1 - viewAge / ONE_HOUR)
const TWELVE_HOURS = 12 * 60 * 60 * 1000
score += 8 * Math.max(0, 1 - viewAge / TWELVE_HOURS)
}
return score