mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
Fix recent worktree sorting migration
This commit is contained in:
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user