diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index ba22f551575..9af27a26d75 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import type { Worktree, Repo } from '../../../../shared/types' -import { buildWorktreeComparator } from './smart-sort' +import { buildWorktreeComparator, hasRecentPRSignal, type RecentSortOverride } from './smart-sort' import { branchName, type Row, buildRows, getGroupKeyForWorktree } from './worktree-list-groups' const WorktreeList = React.memo(function WorktreeList() { @@ -15,6 +15,7 @@ const WorktreeList = React.memo(function WorktreeList() { const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const repos = useAppStore((s) => s.repos) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const activeWorktreeSelectionNonce = useAppStore((s) => s.activeWorktreeSelectionNonce) const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) const searchQuery = useAppStore((s) => s.searchQuery) const groupBy = useAppStore((s) => s.groupBy) @@ -45,8 +46,8 @@ const WorktreeList = React.memo(function WorktreeList() { return m }, [repos]) - // Flatten, filter, sort - const worktrees = useMemo(() => { + // Flatten and filter the current live worktree data first. + const visibleWorktrees = useMemo(() => { let all: Worktree[] = Object.values(worktreesByRepo).flat() // Filter archived @@ -77,20 +78,66 @@ const WorktreeList = React.memo(function WorktreeList() { }) } - // Sort - all.sort(buildWorktreeComparator(sortBy, tabsByWorktree, repoMap, prCache)) - return all - }, [ - worktreesByRepo, - filterRepoIds, - searchQuery, - showActiveOnly, - sortBy, - repoMap, - tabsByWorktree, - prCache - ]) + }, [worktreesByRepo, filterRepoIds, searchQuery, showActiveOnly, repoMap, tabsByWorktree]) + + const latestRecentSortInputsRef = useRef>({}) + const frozenActiveRecentSortRef = useRef<{ + selectionNonce: number + override: RecentSortOverride | null + }>({ selectionNonce: -1, override: null }) + + // Why: snapshot the active worktree's sort inputs at the moment the user + // selects it, so that subsequent background updates (e.g. clearing isUnread) + // don't cause the active card to jump in the list. The ref mutation is + // idempotent for a given nonce value, which makes it safe even if React + // replays the render under concurrent mode. + if (sortBy === 'recent') { + if (activeWorktreeSelectionNonce !== frozenActiveRecentSortRef.current.selectionNonce) { + frozenActiveRecentSortRef.current = { + selectionNonce: activeWorktreeSelectionNonce, + override: activeWorktreeId + ? (latestRecentSortInputsRef.current[activeWorktreeId] ?? null) + : null + } + } + } + + // Why: grab the stable primitive/ref values instead of creating a new object + // literal every render, which would bust the useMemo cache and cause the + // worktree list to re-sort on every single React render loop. + const activeOverride = + sortBy === 'recent' && activeWorktreeId ? frozenActiveRecentSortRef.current.override : null + + const worktrees = useMemo(() => { + // Only construct the record literal inside the memo so it doesn't + // break memoization as a new object reference each render. + const overrides = + activeOverride && activeWorktreeId ? { [activeWorktreeId]: activeOverride } : null + const sorted = [...visibleWorktrees] + sorted.sort( + buildWorktreeComparator(sortBy, tabsByWorktree, repoMap, prCache, Date.now(), overrides) + ) + return sorted + }, [visibleWorktrees, sortBy, tabsByWorktree, repoMap, prCache, activeWorktreeId, activeOverride]) + + // Why: memoize the per-worktree recent-sort inputs so we only iterate + // visible worktrees and call hasRecentPRSignal when the underlying data + // actually changes, rather than on every render. + const nextRecentSortInputs = useMemo(() => { + const inputs: Record = {} + if (sortBy === 'recent') { + for (const wt of visibleWorktrees) { + inputs[wt.id] = { + worktree: wt, + tabs: tabsByWorktree?.[wt.id] ?? [], + hasRecentPRSignal: hasRecentPRSignal(wt, repoMap, prCache) + } + } + } + return inputs + }, [visibleWorktrees, tabsByWorktree, repoMap, prCache, sortBy]) + latestRecentSortInputsRef.current = nextRecentSortInputs // 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 index 29cdcb54701..9b3d9ee92a7 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -1,6 +1,7 @@ +/* eslint-disable max-lines */ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' -import { buildWorktreeComparator, computeSmartScore } from './smart-sort' +import { buildWorktreeComparator, computeSmartScore, type RecentSortOverride } from './smart-sort' const NOW = new Date('2026-03-27T12:00:00.000Z').getTime() @@ -275,4 +276,82 @@ describe('buildWorktreeComparator', () => { 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', + lastActivityAt: NOW - 60_000 + }) + const worktrees = [background, activeAfterClick] + const tabsByWorktree = { + [background.id]: [makeTab({ worktreeId: background.id, title: 'Claude Code - working' })] + } + const recentSortOverrides: Record = { + [activeAfterClick.id]: { + worktree: activeBeforeClick, + tabs: [], + hasRecentPRSignal: false + } + } + + worktrees.sort( + buildWorktreeComparator('recent', tabsByWorktree, repoMap, null, NOW, recentSortOverrides) + ) + + expect(worktrees.map((worktree) => worktree.id)).toEqual(['background', 'active']) + }) + + 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 recentSortOverrides: Record = { + [activeAfterClick.id]: { + worktree: activeBeforeClick, + tabs: [], + hasRecentPRSignal: false + } + } + + worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW, recentSortOverrides)) + + 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('recent', null, repoMap, null, NOW)) + + expect(worktrees.map((worktree) => worktree.id)).toEqual(['active', 'background']) + }) }) diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index b5d63b32433..7f8d0e60c09 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -4,12 +4,17 @@ import type { Worktree, Repo, TerminalTab } from '../../../../shared/types' type SortBy = 'name' | 'recent' | 'repo' type PRCacheEntry = { data: object | null; fetchedAt: number } +export type RecentSortOverride = { + worktree: Worktree + tabs: TerminalTab[] + hasRecentPRSignal: boolean +} function branchDisplayName(branch: string): string { return branch.replace(/^refs\/heads\//, '') } -function hasRecentPRSignal( +export function hasRecentPRSignal( worktree: Worktree, repoMap: Map, prCache: Record | null @@ -29,6 +34,67 @@ function hasRecentPRSignal( return worktree.linkedPR !== null } +function computeSmartScoreFromSignals( + worktree: Worktree, + tabs: TerminalTab[], + hasRecentPR: boolean, + now: number +): number { + const liveTabs = tabs.filter((t) => t.ptyId) + + let score = 0 + + const isRunning = liveTabs.some((t) => detectAgentStatusFromTitle(t.title) === 'working') + if (isRunning) { + score += 60 + } + + const needsAttention = liveTabs.some((t) => detectAgentStatusFromTitle(t.title) === 'permission') + 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 + score += 24 * Math.max(0, 1 - activityAge / ONE_DAY) + } + + return score +} + +function getRecentSortCandidate( + worktree: Worktree, + tabsByWorktree: Record | null, + repoMap: Map, + prCache: Record | null, + recentSortOverrides: Record | null +): RecentSortOverride { + return ( + recentSortOverrides?.[worktree.id] ?? { + worktree, + tabs: tabsByWorktree?.[worktree.id] ?? [], + hasRecentPRSignal: hasRecentPRSignal(worktree, repoMap, prCache) + } + ) +} + /** * Build a comparator for sorting worktrees based on the current sort mode. */ @@ -37,18 +103,43 @@ export function buildWorktreeComparator( tabsByWorktree: Record | null, repoMap: Map, prCache: Record | null, - now: number = Date.now() + now: number = Date.now(), + recentSortOverrides: Record | null = null ): (a: Worktree, b: Worktree) => number { return (a, b) => { switch (sortBy) { case 'name': return a.displayName.localeCompare(b.displayName) case 'recent': { + const recentA = getRecentSortCandidate( + a, + tabsByWorktree, + repoMap, + prCache, + recentSortOverrides + ) + const recentB = getRecentSortCandidate( + b, + tabsByWorktree, + repoMap, + prCache, + recentSortOverrides + ) // Recent means meaningful recent work, not selection time. return ( - computeSmartScore(b, tabsByWorktree, repoMap, prCache, now) - - computeSmartScore(a, tabsByWorktree, repoMap, prCache, now) || - b.lastActivityAt - a.lastActivityAt || + computeSmartScoreFromSignals( + recentB.worktree, + recentB.tabs, + recentB.hasRecentPRSignal, + now + ) - + computeSmartScoreFromSignals( + recentA.worktree, + recentA.tabs, + recentA.hasRecentPRSignal, + now + ) || + recentB.worktree.lastActivityAt - recentA.worktree.lastActivityAt || a.displayName.localeCompare(b.displayName) ) } @@ -84,52 +175,14 @@ export function computeSmartScore( prCache: Record | null, now: number = Date.now() ): number { - const tabs = tabsByWorktree?.[worktree.id] ?? [] - const liveTabs = tabs.filter((t) => t.ptyId) - - let score = 0 - - // Running: any live PTY with an AI agent actively working - const isRunning = liveTabs.some((t) => detectAgentStatusFromTitle(t.title) === 'working') - if (isRunning) { - score += 60 - } - - // Needs attention: permission prompt in a live agent terminal - const needsAttention = liveTabs.some((t) => detectAgentStatusFromTitle(t.title) === 'permission') - if (needsAttention) { - score += 35 - } - - // Unread - if (worktree.isUnread) { - score += 18 - } - - // Live terminals are a strong sign of ongoing work, even if no agent title is detected. - if (liveTabs.length > 0) { - score += 12 - } - - // 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 recent sorting stays - // stable on launch without reviving stale PRs after a cache miss resolves. - if (repoMap && hasRecentPRSignal(worktree, repoMap, prCache)) { - 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) - if (worktree.lastActivityAt > 0) { - const ONE_DAY = 24 * 60 * 60 * 1000 - score += 24 * Math.max(0, 1 - activityAge / ONE_DAY) - } - - return score + 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 recent sorting stays + // stable on launch without reviving stale PRs after a cache miss resolves. + repoMap ? hasRecentPRSignal(worktree, repoMap, prCache) : worktree.linkedPR !== null, + now + ) } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index c4637f997d4..88bf32723f0 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -14,6 +14,7 @@ export type WorktreeDeleteState = { export type WorktreeSlice = { worktreesByRepo: Record activeWorktreeId: string | null + activeWorktreeSelectionNonce: number deleteStateByWorktreeId: Record /** * Monotonically increasing counter that signals when the sidebar sort order diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index df43f4a8b0e..aa3c6309501 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { Worktree } from '../../../../shared/types' @@ -39,6 +40,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b export const createWorktreeSlice: StateCreator = (set, get) => ({ worktreesByRepo: {}, activeWorktreeId: null, + activeWorktreeSelectionNonce: 0, deleteStateByWorktreeId: {}, sortEpoch: 0, @@ -272,7 +274,16 @@ export const createWorktreeSlice: StateCreator let shouldClearUnread = false set((s) => { if (!worktreeId) { - return { activeWorktreeId: null } + return { + activeWorktreeId: null, + // Why: only bump the nonce when the selection genuinely changes. + // Re-selecting null when already null should not invalidate the + // frozen sort override, which would defeat the freeze effect. + activeWorktreeSelectionNonce: + s.activeWorktreeId === null + ? s.activeWorktreeSelectionNonce + : s.activeWorktreeSelectionNonce + 1 + } } const worktree = findWorktreeById(s.worktreesByRepo, worktreeId) @@ -300,6 +311,14 @@ export const createWorktreeSlice: StateCreator return { activeWorktreeId: worktreeId, + // Why: only bump the nonce when the worktreeId genuinely changes. + // Clicking the already-active worktree again would re-freeze the + // sort override with its current (post-click, isUnread:false) state, + // defeating the freeze and causing the card to drop in the list. + activeWorktreeSelectionNonce: + worktreeId === s.activeWorktreeId + ? s.activeWorktreeSelectionNonce + : s.activeWorktreeSelectionNonce + 1, activeFileId, activeTabType, worktreesByRepo: applyWorktreeUpdates(