diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index c087a7c7eed..8fe426de21a 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -267,6 +267,21 @@ describe('Store', () => { expect(ui.groupBy).toBe('none') // default preserved }) + it('migrates persisted smart sort to recent', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { sortBy: 'smart' }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().sortBy).toBe('recent') + }) + // ── GitHub Cache ─────────────────────────────────────────────────── it('get/set GitHub cache round-trips', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index b150f6252e1..9ace5f8a329 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -13,6 +13,16 @@ import { const DATA_FILE = join(app.getPath('userData'), 'orca-data.json') +function normalizeSortBy(sortBy: unknown): 'name' | 'recent' | 'repo' { + if (sortBy === 'recent' || sortBy === 'repo' || sortBy === 'name') { + return sortBy + } + if (sortBy === 'smart') { + return 'recent' + } + return getDefaultUIState().sortBy +} + export class Store { private state: PersistedState private writeTimer: ReturnType | null = null @@ -33,7 +43,11 @@ export class Store { ...defaults, ...parsed, settings: { ...defaults.settings, ...parsed.settings }, - ui: { ...defaults.ui, ...parsed.ui }, + ui: { + ...defaults.ui, + ...parsed.ui, + sortBy: normalizeSortBy(parsed.ui?.sortBy) + }, workspaceSession: { ...defaults.workspaceSession, ...parsed.workspaceSession } } } @@ -168,11 +182,21 @@ export class Store { // ── UI State ─────────────────────────────────────────────────────── getUI(): PersistedState['ui'] { - return { ...getDefaultUIState(), ...this.state.ui } + return { + ...getDefaultUIState(), + ...this.state.ui, + sortBy: normalizeSortBy(this.state.ui?.sortBy) + } } updateUI(updates: Partial): void { - this.state.ui = { ...this.state.ui, ...updates } + this.state.ui = { + ...this.state.ui, + ...updates, + sortBy: updates.sortBy + ? normalizeSortBy(updates.sortBy) + : normalizeSortBy(this.state.ui?.sortBy) + } this.scheduleSave() } diff --git a/src/renderer/src/components/sidebar/GroupControls.tsx b/src/renderer/src/components/sidebar/GroupControls.tsx index afda352a8f0..ff892427e10 100644 --- a/src/renderer/src/components/sidebar/GroupControls.tsx +++ b/src/renderer/src/components/sidebar/GroupControls.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { ArrowUpAZ, ArrowUpDown, Check, Clock3, FolderTree, Sparkles } from 'lucide-react' +import { ArrowUpAZ, ArrowUpDown, Check, Clock3, FolderTree } from 'lucide-react' import { useAppStore } from '@/store' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { @@ -20,10 +20,6 @@ const SORT_OPTIONS = { label: 'Recent', icon: Clock3 }, - smart: { - label: 'Smart', - icon: Sparkles - }, repo: { label: 'Repo', icon: FolderTree diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index b985d223e0b..6fc53bf7586 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -34,7 +34,7 @@ const WorktreeList = React.memo(function WorktreeList() { const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId) // Read tabsByWorktree when needed for filtering or sorting - const needsTabs = showActiveOnly || sortBy === 'recent' || sortBy === 'smart' + const needsTabs = showActiveOnly || sortBy === 'recent' const tabsByWorktree = useAppStore((s) => (needsTabs ? s.tabsByWorktree : null)) // PR cache only when grouping by pr-status @@ -83,19 +83,10 @@ const WorktreeList = React.memo(function WorktreeList() { } // Sort - all.sort(buildWorktreeComparator(sortBy, tabsByWorktree, repoMap, activeWorktreeId)) + all.sort(buildWorktreeComparator(sortBy, tabsByWorktree, repoMap, Date.now())) return all - }, [ - worktreesByRepo, - filterRepoIds, - searchQuery, - showActiveOnly, - sortBy, - repoMap, - tabsByWorktree, - activeWorktreeId - ]) + }, [worktreesByRepo, filterRepoIds, searchQuery, showActiveOnly, sortBy, repoMap, tabsByWorktree]) // 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 eacf7fd596c..38a0b9993eb 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -30,7 +30,7 @@ function makeWorktree(overrides: Partial = {}): Worktree { isArchived: overrides.isArchived ?? false, comment: overrides.comment ?? '', isUnread: overrides.isUnread ?? false, - displayName: overrides.displayName ?? (overrides.id ?? 'wt-1'), + displayName: overrides.displayName ?? overrides.id ?? 'wt-1', sortOrder: overrides.sortOrder ?? 0, lastActivityAt: overrides.lastActivityAt ?? 0 } @@ -54,10 +54,14 @@ describe('computeSmartScore', () => { vi.restoreAllMocks() }) - it('prioritizes the active worktree over a merely linked worktree', () => { + it('prioritizes recent activity over a merely linked worktree', () => { vi.spyOn(Date, 'now').mockReturnValue(NOW) - const active = makeWorktree({ id: 'active', displayName: 'Active' }) + const active = makeWorktree({ + id: 'active', + displayName: 'Active', + lastActivityAt: NOW - 10 * 60 * 1000 + }) const linked = makeWorktree({ id: 'linked', displayName: 'Linked', @@ -65,7 +69,7 @@ describe('computeSmartScore', () => { linkedIssue: 42 }) - expect(computeSmartScore(active, null, active.id)).toBeGreaterThan(computeSmartScore(linked, null, active.id)) + expect(computeSmartScore(active, null)).toBeGreaterThan(computeSmartScore(linked, null)) }) it('keeps recent activity relevant beyond a one-hour window', () => { @@ -103,10 +107,12 @@ describe('buildWorktreeComparator', () => { 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' }) + it('sorts recent mode by ongoing work signals before alphabetical order', () => { + const active = makeWorktree({ + id: 'active', + displayName: 'z-active', + lastActivityAt: NOW - 10 * 60 * 1000 + }) const recent = makeWorktree({ id: 'recent', displayName: 'a-recent', @@ -120,8 +126,50 @@ describe('buildWorktreeComparator', () => { const worktrees = [recent, stale, active] - worktrees.sort(buildWorktreeComparator('smart', null, repoMap, active.id)) + worktrees.sort(buildWorktreeComparator('recent', null, repoMap, 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, + 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('recent', null, repoMap, NOW)) + + expect(worktrees.map((worktree) => worktree.id)).toEqual(['first', 'second']) + }) + + it('ignores stale sortOrder metadata when recent activity is identical', () => { + const alpha = makeWorktree({ + id: 'alpha', + displayName: 'Alpha', + sortOrder: NOW + 50_000, + lastActivityAt: NOW - 60_000 + }) + const beta = makeWorktree({ + id: 'beta', + displayName: 'Beta', + sortOrder: NOW - 50_000, + lastActivityAt: NOW - 60_000 + }) + + const worktrees = [beta, alpha] + + worktrees.sort(buildWorktreeComparator('recent', null, repoMap, NOW)) + + expect(worktrees.map((worktree) => worktree.id)).toEqual(['alpha', 'beta']) + }) }) diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index 6328b9d1355..c7f748264c4 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -1,7 +1,7 @@ import { detectAgentStatusFromTitle } from '@/lib/agent-status' import type { Worktree, Repo, TerminalTab } from '../../../../shared/types' -type SortBy = 'name' | 'recent' | 'smart' | 'repo' +type SortBy = 'name' | 'recent' | 'repo' /** * Build a comparator for sorting worktrees based on the current sort mode. @@ -10,41 +10,20 @@ export function buildWorktreeComparator( sortBy: SortBy, tabsByWorktree: Record | null, repoMap: Map, - activeWorktreeId: string | null = null + now: number = Date.now() ): (a: Worktree, b: Worktree) => number { return (a, b) => { switch (sortBy) { case 'name': return a.displayName.localeCompare(b.displayName) case 'recent': { - // Sort by meaningful activity, NOT by last-viewed timestamp. - // Active worktrees still pin to top in stable (name) order. - const aTabs = tabsByWorktree?.[a.id] ?? [] - const bTabs = tabsByWorktree?.[b.id] ?? [] - const aActive = aTabs.some((t) => t.ptyId) - const bActive = bTabs.some((t) => t.ptyId) - if (aActive && bActive) { - return a.displayName.localeCompare(b.displayName) - } - if (aActive) { - return -1 - } - if (bActive) { - return 1 - } - // Fall back to lastActivityAt, then sortOrder for legacy data - const aActivity = a.lastActivityAt || a.sortOrder - const bActivity = b.lastActivityAt || b.sortOrder - return bActivity - aActivity - } - case 'smart': + // Recent means meaningful recent work, not selection time. return ( - computeSmartScore(b, tabsByWorktree, activeWorktreeId) - - computeSmartScore(a, tabsByWorktree, activeWorktreeId) || + computeSmartScore(b, tabsByWorktree, now) - computeSmartScore(a, tabsByWorktree, now) || 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 ?? '' @@ -58,11 +37,10 @@ export function buildWorktreeComparator( } /** - * Compute a smart sort score for a worktree. + * Compute a recent-work score for a worktree. * Higher score = higher in the list. * * Scoring: - * active worktree → +80 * running AI job → +60 * needs attention → +35 * unread → +18 @@ -70,23 +48,17 @@ export function buildWorktreeComparator( * 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, - activeWorktreeId: string | null = null + now: number = Date.now() ): number { const tabs = tabsByWorktree?.[worktree.id] ?? [] const liveTabs = tabs.filter((t) => t.ptyId) - const now = Date.now() 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) { @@ -125,12 +97,5 @@ export function computeSmartScore( score += 24 * Math.max(0, 1 - activityAge / ONE_DAY) } - // Last viewed matters, but less than actual activity. - const viewAge = now - (worktree.sortOrder || 0) - if (worktree.sortOrder > 0) { - const TWELVE_HOURS = 12 * 60 * 60 * 1000 - score += 8 * Math.max(0, 1 - viewAge / TWELVE_HOURS) - } - return score } diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 769e30d96e9..492e1f314d0 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -295,3 +295,31 @@ describe('removeWorktree cascade', () => { expect(s.terminalLayoutsByTabId['tab1']).toBeUndefined() }) }) + +describe('setActiveWorktree', () => { + beforeEach(() => { + vi.clearAllMocks() + mockApi.worktrees.updateMeta.mockResolvedValue({}) + }) + + it('does not rewrite sortOrder when selecting a worktree', () => { + const store = createTestStore() + const worktreeId = 'repo1::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', sortOrder: 123, isUnread: false })] + }, + refreshGitHubForWorktree: vi.fn() + }) + + store.getState().setActiveWorktree(worktreeId) + + const worktree = store.getState().worktreesByRepo.repo1[0] + expect(worktree.sortOrder).toBe(123) + expect(mockApi.worktrees.updateMeta).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index f5889206e03..5b9ea16a103 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -2,6 +2,8 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { PersistedUIState, UpdateStatus } from '../../../../shared/types' +type LegacyPersistedSortBy = PersistedUIState['sortBy'] | 'smart' + export type UISlice = { sidebarOpen: boolean sidebarWidth: number @@ -18,7 +20,7 @@ export type UISlice = { setSearchQuery: (q: string) => void groupBy: 'none' | 'repo' | 'pr-status' setGroupBy: (g: UISlice['groupBy']) => void - sortBy: 'name' | 'recent' | 'smart' | 'repo' + sortBy: 'name' | 'recent' | 'repo' setSortBy: (s: UISlice['sortBy']) => void showActiveOnly: boolean setShowActiveOnly: (v: boolean) => void @@ -73,11 +75,12 @@ export const createUISlice: StateCreator = (set) => ( hydratePersistedUI: (ui) => set((s) => { const validRepoIds = new Set(s.repos.map((repo) => repo.id)) + const sortBy = (ui.sortBy as LegacyPersistedSortBy) === 'smart' ? 'recent' : ui.sortBy return { sidebarWidth: ui.sidebarWidth, rightSidebarWidth: ui.rightSidebarWidth ?? 280, groupBy: ui.groupBy, - sortBy: ui.sortBy, + sortBy, filterRepoIds: (ui.filterRepoIds ?? []).filter((repoId) => validRepoIds.has(repoId)), persistedUIReady: true } diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 11227d45f67..0168e1eebb0 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -204,7 +204,6 @@ export const createWorktreeSlice: StateCreator setActiveWorktree: (worktreeId) => { let shouldClearUnread = false - const now = Date.now() set((s) => { if (!worktreeId) { return { activeWorktreeId: null } @@ -237,10 +236,11 @@ export const createWorktreeSlice: StateCreator activeWorktreeId: worktreeId, activeFileId, activeTabType, - worktreesByRepo: applyWorktreeUpdates(s.worktreesByRepo, worktreeId, { - ...(shouldClearUnread ? { isUnread: false } : {}), - sortOrder: now - }) + worktreesByRepo: applyWorktreeUpdates( + s.worktreesByRepo, + worktreeId, + shouldClearUnread ? { isUnread: false } : {} + ) } }) @@ -272,13 +272,15 @@ export const createWorktreeSlice: StateCreator return } - const updates: Parameters[0]['updates'] = { - sortOrder: now - } + const updates: Parameters[0]['updates'] = {} if (shouldClearUnread) { updates.isUnread = false } + if (Object.keys(updates).length === 0) { + return + } + void window.api.worktrees.updateMeta({ worktreeId, updates }).catch((err) => { console.error('Failed to persist worktree activation state:', err) void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) diff --git a/src/shared/types.ts b/src/shared/types.ts index 8bc0e783cab..b8f151b4381 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -180,7 +180,7 @@ export type PersistedUIState = { sidebarWidth: number rightSidebarWidth: number groupBy: 'none' | 'repo' | 'pr-status' - sortBy: 'name' | 'recent' | 'smart' | 'repo' + sortBy: 'name' | 'recent' | 'repo' filterRepoIds: string[] uiZoomLevel: number }