diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index f2a0b2da3a6..b985d223e0b 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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>(new Set()) diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts new file mode 100644 index 00000000000..eacf7fd596c --- /dev/null +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -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([ + [ + 'repo-1', + { + id: 'repo-1', + path: '/tmp/repo-1', + displayName: 'repo-1', + badgeColor: '#000000', + addedAt: 0 + } + ] +]) + +function makeWorktree(overrides: Partial = {}): 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 { + 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']) + }) +}) diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index 184fd95db52..6328b9d1355 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -9,7 +9,8 @@ type SortBy = 'name' | 'recent' | 'smart' | 'repo' export function buildWorktreeComparator( sortBy: SortBy, tabsByWorktree: Record | null, - repoMap: Map + repoMap: Map, + 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 | null + tabsByWorktree: Record | 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