Fix recent worktree sorting migration

This commit is contained in:
Jinjing
2026-03-27 16:08:29 -07:00
parent 0637883cce
commit 41754d2d4b
10 changed files with 154 additions and 82 deletions
+15
View File
@@ -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 () => {
+27 -3
View File
@@ -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<typeof setTimeout> | 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<PersistedState['ui']>): 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()
}
@@ -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
@@ -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<Set<string>>(new Set())
@@ -30,7 +30,7 @@ function makeWorktree(overrides: Partial<Worktree> = {}): 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'])
})
})
@@ -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<string, TerminalTab[]> | null,
repoMap: Map<string, Repo>,
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<string, TerminalTab[]> | 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
}
@@ -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()
})
})
+5 -2
View File
@@ -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<AppState, [], [], UISlice> = (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
}
+10 -8
View File
@@ -204,7 +204,6 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
setActiveWorktree: (worktreeId) => {
let shouldClearUnread = false
const now = Date.now()
set((s) => {
if (!worktreeId) {
return { activeWorktreeId: null }
@@ -237,10 +236,11 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
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<AppState, [], [], WorktreeSlice>
return
}
const updates: Parameters<typeof window.api.worktrees.updateMeta>[0]['updates'] = {
sortOrder: now
}
const updates: Parameters<typeof window.api.worktrees.updateMeta>[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))
+1 -1
View File
@@ -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
}