repoDrag.onHandlePointerDown(e, projectIdForHeader)
- : undefined
- }
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-[4px]',
repoHeaderColor ? 'text-muted-foreground' : row.tone
@@ -3616,6 +3638,7 @@ const WorktreeList = React.memo(function WorktreeList({
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
const sortBy = useAppStore((s) => s.sortBy)
const setSortBy = useAppStore((s) => s.setSortBy)
+ const projectOrderBy = useAppStore((s) => s.projectOrderBy)
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
@@ -4058,8 +4081,6 @@ const WorktreeList = React.memo(function WorktreeList({
return getEmptyProjectPlaceholderRepoIds({ groupBy, repos, worktreesByRepo, filterRepoIds })
}, [filterRepoIds, groupBy, repos, worktreesByRepo])
const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos])
- const reorderReposAction = useAppStore((s) => s.reorderRepos)
- const projectGroupOrdering = getProjectGroupOrdering(groupBy, sortBy)
// Build flat row list for rendering
const rows: Row[] = useMemo(
@@ -4072,7 +4093,7 @@ const WorktreeList = React.memo(function WorktreeList({
effectiveCollapsedGroups,
repoOrder,
workspaceStatuses,
- projectGroupOrdering,
+ projectOrderBy,
worktreeLineageById,
worktreeMap,
true,
@@ -4089,7 +4110,7 @@ const WorktreeList = React.memo(function WorktreeList({
effectiveCollapsedGroups,
repoOrder,
workspaceStatuses,
- projectGroupOrdering,
+ projectOrderBy,
worktreeLineageById,
worktreeMap,
settings,
@@ -4675,7 +4696,7 @@ const WorktreeList = React.memo(function WorktreeList({
activeWorktreeId={selectedSidebarWorktreeId}
currentWorktreeId={currentSidebarWorktreeId}
groupBy={groupBy}
- projectGroupOrdering={projectGroupOrdering}
+ projectOrderBy={projectOrderBy}
toggleGroup={toggleGroup}
collapsedGroups={effectiveCollapsedGroups}
handleCreateForRepo={handleCreateForRepo}
@@ -4706,9 +4727,6 @@ const WorktreeList = React.memo(function WorktreeList({
worktreeLineageById={worktreeLineageById}
repoOrder={repoOrder}
allRepoIds={allRepoIds}
- reorderRepos={(orderedIds) => {
- void reorderReposAction(orderedIds)
- }}
prCache={prCache}
workspaceStatuses={workspaceStatuses}
projectGroups={projectGroups}
diff --git a/src/renderer/src/components/sidebar/project-header-drag.ts b/src/renderer/src/components/sidebar/project-header-drag.ts
index 3172f76c2..c02d59171 100644
--- a/src/renderer/src/components/sidebar/project-header-drag.ts
+++ b/src/renderer/src/components/sidebar/project-header-drag.ts
@@ -118,10 +118,15 @@ export function useRepoHeaderDrag({
// indicator just above the target header keeps it at the visual top of
// where the dragged group would land.
const INDICATOR_GAP_PX = 4
- const indicatorY =
+ const rawIndicatorY =
insertBefore >= rects.length
? rects.at(-1)!.bottom + INDICATOR_GAP_PX
: Math.max(0, rects[insertBefore].top - INDICATOR_GAP_PX)
+ // Why: while scrolled, the topmost mounted header is pinned flush at the
+ // container top, so `top - GAP` lands above the overflow clip region and
+ // the line is painted invisibly. Floor the indicator at the current
+ // scroll offset so a top-of-list drop stays visible just below the edge.
+ const indicatorY = Math.max(container.scrollTop, rawIndicatorY)
return { dropIndex: insertBefore, dropIndicatorY: indicatorY }
},
[]
diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts
index 182f5de0e..f3ee0dbbb 100644
--- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts
@@ -9,8 +9,7 @@ import {
getGroupKeysForWorktree,
getLineageGroupKey,
getLineageRenderInfo,
- getPRGroupKey,
- getProjectGroupOrdering
+ getPRGroupKey
} from './worktree-list-groups'
import type {
DetectedWorktree,
@@ -611,10 +610,36 @@ describe('buildRows project grouping order', () => {
[repoB.id, repoB],
[repoC.id, repoC]
])
- const wA: Worktree = { ...worktree, id: 'wt-a', repoId: repoA.id, displayName: 'a' }
- const wAStale: Worktree = { ...worktree, id: 'wt-a-stale', repoId: repoA.id, displayName: 'a2' }
- const wB: Worktree = { ...worktree, id: 'wt-b', repoId: repoB.id, displayName: 'b' }
- const wC: Worktree = { ...worktree, id: 'wt-c', repoId: repoC.id, displayName: 'c' }
+ // Activity: C (300) is freshest, then A (200), then B (100). wAStale (50) is
+ // an older sibling of A so a repo's rank is its max child, not its first.
+ const wA: Worktree = {
+ ...worktree,
+ id: 'wt-a',
+ repoId: repoA.id,
+ displayName: 'a',
+ lastActivityAt: 200
+ }
+ const wAStale: Worktree = {
+ ...worktree,
+ id: 'wt-a-stale',
+ repoId: repoA.id,
+ displayName: 'a2',
+ lastActivityAt: 50
+ }
+ const wB: Worktree = {
+ ...worktree,
+ id: 'wt-b',
+ repoId: repoB.id,
+ displayName: 'b',
+ lastActivityAt: 100
+ }
+ const wC: Worktree = {
+ ...worktree,
+ id: 'wt-c',
+ repoId: repoC.id,
+ displayName: 'c',
+ lastActivityAt: 300
+ }
it('orders repo headers by explicit repoOrder, not first-encounter', () => {
// Worktree stream encounters in order C, A, B — but repoOrder says B, A, C.
@@ -636,11 +661,10 @@ describe('buildRows project grouping order', () => {
expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c'])
})
- it('orders repo headers by first encounter when caller uses visible worktree order', () => {
- // Caller already sorted worktrees by recency: C is freshest, then A, then B.
- // Even though repoOrder pins B, A, C, dynamic sorts must follow the freshest
- // worktree out of each repo so a just-active worktree's parent group
- // bubbles to the top of the sidebar.
+ it('orders repo headers by max(lastActivityAt) per repo in Recent mode', () => {
+ // repoOrder pins B, A, C, but Recent ignores it: C (300) > A (200) > B (100).
+ // The incoming array is name-sorted (not pre-sorted by recency), proving the
+ // resolver computes the timestamp itself rather than trusting encounter order.
const repoOrder = new Map([
[repoB.id, 0],
[repoA.id, 1],
@@ -648,58 +672,57 @@ describe('buildRows project grouping order', () => {
])
const rows = buildRows(
'repo',
- [wC, wA, wB],
+ [wA, wB, wC],
map,
null,
new Set(),
repoOrder,
undefined,
- 'visible-worktree-order'
+ 'recent'
)
const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key)
expect(headerKeys).toEqual(['repo:repo-c', 'repo:repo-a', 'repo:repo-b'])
})
- it('orders repo headers by each repo highest-ranked visible child', () => {
- const repoOrder = new Map([
- [repoB.id, 0],
- [repoA.id, 1],
- [repoC.id, 2]
- ])
+ it("uses each repo's freshest visible child, not its first, in Recent mode", () => {
+ // repo-a has a fresh child (200) and a stale one (50); its rank is the max.
const rows = buildRows(
'repo',
- [wA, wB, wAStale, wC],
+ [wAStale, wA, wB, wC],
map,
null,
new Set(),
- repoOrder,
undefined,
- 'visible-worktree-order'
+ undefined,
+ 'recent'
)
expect(rows).toMatchObject([
- { type: 'header', key: 'repo:repo-a' },
- { type: 'item', worktree: { id: 'wt-a' } },
- { type: 'item', worktree: { id: 'wt-a-stale' } },
- { type: 'header', key: 'repo:repo-b' },
- { type: 'item', worktree: { id: 'wt-b' } },
{ type: 'header', key: 'repo:repo-c' },
- { type: 'item', worktree: { id: 'wt-c' } }
+ { type: 'item', worktree: { id: 'wt-c' } },
+ { type: 'header', key: 'repo:repo-a' },
+ // Child rows keep their input order; only the header rank uses max activity.
+ { type: 'item', worktree: { id: 'wt-a-stale' } },
+ { type: 'item', worktree: { id: 'wt-a' } },
+ { type: 'header', key: 'repo:repo-b' },
+ { type: 'item', worktree: { id: 'wt-b' } }
])
})
- it('keeps the main workspace first inside its project group', () => {
+ it('keeps the main workspace first inside its project group in Recent mode', () => {
const main = {
...wA,
id: 'wt-a-main',
displayName: 'main',
- isMainWorktree: true
+ isMainWorktree: true,
+ lastActivityAt: 10
}
const freshChild = {
...wA,
id: 'wt-a-fresh-child',
displayName: 'fresh-child',
- isMainWorktree: false
+ isMainWorktree: false,
+ lastActivityAt: 500
}
const rows = buildRows(
'repo',
@@ -709,7 +732,7 @@ describe('buildRows project grouping order', () => {
new Set(),
undefined,
undefined,
- 'visible-worktree-order'
+ 'recent'
)
expect(rows).toMatchObject([
@@ -721,7 +744,7 @@ describe('buildRows project grouping order', () => {
])
})
- it('keeps repoOrder for manual project group ordering', () => {
+ it('orders repo headers by repoOrder in Manual mode (default), ignoring activity', () => {
const repoOrder = new Map([
[repoB.id, 0],
[repoA.id, 1],
@@ -749,17 +772,43 @@ describe('buildRows project grouping order', () => {
})
})
-describe('getProjectGroupOrdering', () => {
- it.each([
- ['repo', 'recent', 'visible-worktree-order'],
- ['repo', 'smart', 'visible-worktree-order'],
- ['repo', 'name', 'manual'],
- ['repo', 'repo', 'manual'],
- ['none', 'recent', 'manual'],
- ['workspace-status', 'recent', 'manual'],
- ['pr-status', 'recent', 'manual']
- ] as const)('uses %s/%s -> %s', (groupBy, sortBy, expected) => {
- expect(getProjectGroupOrdering(groupBy, sortBy)).toBe(expected)
+describe('buildRows Recent project order fallbacks', () => {
+ const active: Repo = { ...repo, id: 'repo-active', displayName: 'active', addedAt: 0 }
+ // Empty project has no visible worktrees, so Recent falls back to addedAt.
+ const empty: Repo = { ...repo, id: 'repo-empty', displayName: 'empty', addedAt: 999 }
+ const map = new Map([
+ [active.id, active],
+ [empty.id, empty]
+ ])
+ const activeWorktree: Worktree = {
+ ...worktree,
+ id: 'wt-active',
+ repoId: active.id,
+ displayName: 'active',
+ lastActivityAt: 100
+ }
+
+ it('sorts placeholder projects after projects with activity', () => {
+ // empty.addedAt (999) is numerically higher than active's worktree (100),
+ // but a real activity timestamp must always outrank an addedAt fallback.
+ const rows = buildRows(
+ 'repo',
+ [activeWorktree],
+ map,
+ null,
+ new Set(),
+ undefined,
+ undefined,
+ 'recent',
+ {},
+ undefined,
+ false,
+ undefined,
+ [],
+ new Set([empty.id])
+ )
+ const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key)
+ expect(headerKeys).toEqual(['repo:repo-active', 'repo:repo-empty'])
})
})
@@ -1134,6 +1183,71 @@ describe('project groups', () => {
])
})
+ it('orders repos inside a Project Group by activity in recent mode, keeping tabOrder', () => {
+ const groupA: ProjectGroup = {
+ id: 'group-a',
+ name: 'Platform',
+ parentPath: '/platform',
+ parentGroupId: null,
+ createdFrom: 'folder-scan',
+ tabOrder: 1,
+ isCollapsed: false,
+ color: null,
+ createdAt: 1,
+ updatedAt: 1
+ }
+ const groupB: ProjectGroup = { ...groupA, id: 'group-b', name: 'Infra', tabOrder: 0 }
+ // Inside group A: repoStale ordered first by projectGroupOrder, but repoFresh
+ // is more recently active so recent mode must lift it above repoStale.
+ const repoStale: Repo = {
+ ...repo,
+ id: 'repo-stale',
+ displayName: 'stale',
+ projectGroupId: groupA.id,
+ projectGroupOrder: 0
+ }
+ const repoFresh: Repo = {
+ ...repo,
+ id: 'repo-fresh',
+ displayName: 'fresh',
+ projectGroupId: groupA.id,
+ projectGroupOrder: 1
+ }
+ const groupedMap = new Map([
+ [repoStale.id, repoStale],
+ [repoFresh.id, repoFresh]
+ ])
+ const worktrees = [
+ { ...worktree, id: 'wt-stale', repoId: repoStale.id, lastActivityAt: 10 },
+ { ...worktree, id: 'wt-fresh', repoId: repoFresh.id, lastActivityAt: 500 }
+ ]
+
+ const rows = buildRows(
+ 'repo',
+ worktrees,
+ groupedMap,
+ null,
+ new Set(),
+ undefined,
+ undefined,
+ 'recent',
+ {},
+ new Map(worktrees.map((entry) => [entry.id, entry])),
+ false,
+ undefined,
+ // Group headers always follow tabOrder (Infra=0 before Platform=1),
+ // independent of projectOrderBy.
+ [groupA, groupB]
+ )
+
+ expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([
+ 'project-group:group-b',
+ 'project-group:group-a',
+ 'repo:repo-fresh',
+ 'repo:repo-stale'
+ ])
+ })
+
it('renders nested Project Groups before repos assigned to their leaf group', () => {
const rootGroup: ProjectGroup = {
id: 'group-root',
diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts
index 930ca53a0..7741cb82f 100644
--- a/src/renderer/src/components/sidebar/worktree-list-groups.ts
+++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts
@@ -5,6 +5,7 @@ import type {
DetectedWorktree,
Repo,
ProjectGroup,
+ ProjectOrderBy,
Worktree,
WorktreeLineage,
WorkspaceStatusDefinition
@@ -22,7 +23,6 @@ import {
ConductorReviewIcon
} from './workspace-status-icons'
import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
-import type { SortBy } from './smart-sort'
import type { AppState } from '@/store/types'
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import { UNGROUPED_PROJECT_GROUP_KEY } from '../../../../shared/project-groups'
@@ -31,16 +31,6 @@ import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels'
export { branchName }
export type WorktreeGroupBy = 'none' | 'workspace-status' | 'repo' | 'pr-status'
-export type ProjectGroupOrdering = 'manual' | 'visible-worktree-order'
-
-export function getProjectGroupOrdering(
- groupBy: WorktreeGroupBy,
- sortBy: SortBy
-): ProjectGroupOrdering {
- return groupBy === 'repo' && (sortBy === 'recent' || sortBy === 'smart')
- ? 'visible-worktree-order'
- : 'manual'
-}
export type GroupHeaderRow = {
type: 'header'
@@ -413,6 +403,88 @@ function withRepoSectionDisplayLabels(entries: readonly OrderedGroupEntry[]): Or
])
}
+/**
+ * Recent rank for a project header. `hasActivity` projects (at least one
+ * visible worktree) always sort before fallback projects, regardless of the
+ * numeric values — a placeholder's `addedAt` must never outrank real activity.
+ * Within each tier, higher timestamps come first.
+ */
+type RecentRank = { hasActivity: boolean; ts: number }
+
+function recentRankForEntry(entry: OrderedGroupEntry): RecentRank {
+ let max = Number.NEGATIVE_INFINITY
+ for (const worktree of entry[1].items) {
+ if (worktree.lastActivityAt > max) {
+ max = worktree.lastActivityAt
+ }
+ }
+ if (max !== Number.NEGATIVE_INFINITY) {
+ // Why: Recent must be timestamp-based, not encounter order — the incoming
+ // array is no longer pre-sorted by recency once decoupled from sortBy.
+ return { hasActivity: true, ts: max }
+ }
+ const addedAt = entry[1].repo?.addedAt
+ return {
+ hasActivity: false,
+ ts: typeof addedAt === 'number' ? addedAt : Number.NEGATIVE_INFINITY
+ }
+}
+
+function compareRecentRank(a: RecentRank, b: RecentRank): number {
+ if (a.hasActivity !== b.hasActivity) {
+ return a.hasActivity ? -1 : 1
+ }
+ return b.ts - a.ts
+}
+
+function manualRankForEntry(
+ entry: OrderedGroupEntry,
+ repoOrder: Map
| undefined
+): number {
+ const key = entry[0]
+ const repoId = key.startsWith('repo:') ? key.slice('repo:'.length) : key
+ const rank = repoOrder?.get(repoId)
+ return rank === undefined ? Number.POSITIVE_INFINITY : rank
+}
+
+/**
+ * Order project header entries by the user's project-order preference. Manual
+ * follows the canonical repoOrder; Recent follows each project's most recent
+ * visible workspace activity (descending), with empty/imported-only projects
+ * sorting after active ones, then by manual rank, then label.
+ */
+function sortProjectEntries(
+ entries: OrderedGroupEntry[],
+ projectOrderBy: ProjectOrderBy,
+ repoOrder: Map | undefined
+): OrderedGroupEntry[] {
+ if (projectOrderBy === 'recent') {
+ return [...entries].sort((a, b) => {
+ const byRecent = compareRecentRank(recentRankForEntry(a), recentRankForEntry(b))
+ if (byRecent !== 0) {
+ return byRecent
+ }
+ const ma = manualRankForEntry(a, repoOrder)
+ const mb = manualRankForEntry(b, repoOrder)
+ if (ma !== mb) {
+ return ma - mb
+ }
+ return a[1].label.localeCompare(b[1].label)
+ })
+ }
+ if (!repoOrder) {
+ return entries
+ }
+ return [...entries].sort((a, b) => {
+ const ra = manualRankForEntry(a, repoOrder)
+ const rb = manualRankForEntry(b, repoOrder)
+ if (ra !== rb) {
+ return ra - rb
+ }
+ return a[1].label.localeCompare(b[1].label)
+ })
+}
+
/**
* Build the flat row list consumed by the virtualizer.
* Extracted here to keep WorktreeList.tsx under the line-count lint limit.
@@ -425,7 +497,7 @@ export function buildRows(
collapsedGroups: Set,
repoOrder?: Map,
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
- projectGroupOrdering: ProjectGroupOrdering = 'manual',
+ projectOrderBy: ProjectOrderBy = 'manual',
lineageById: Record = {},
worktreeMap: Map = new Map(
worktrees.map((worktree) => [worktree.id, worktree])
@@ -546,25 +618,10 @@ export function buildRows(
}
}
} else {
- // Why: dynamic sorts need repo headers to follow their highest-ranked
- // visible child. Manual ordering still uses the canonical state.repos
- // order so repo-header drag has a stable source of truth.
- const entries = Array.from(grouped.entries())
- if (projectGroupOrdering === 'manual' && repoOrder) {
- const rankFor = (key: string): number => {
- const repoId = key.startsWith('repo:') ? key.slice('repo:'.length) : key
- const rank = repoOrder.get(repoId)
- return rank === undefined ? Number.POSITIVE_INFINITY : rank
- }
- entries.sort((a, b) => {
- const ra = rankFor(a[0])
- const rb = rankFor(b[0])
- if (ra !== rb) {
- return ra - rb
- }
- return a[1].label.localeCompare(b[1].label)
- })
- }
+ // Why: project header order is its own user choice (projectOrderBy),
+ // decoupled from workspace sortBy. Manual uses the canonical repoOrder so
+ // header drag has a stable source of truth; Recent follows activity.
+ const entries = sortProjectEntries(Array.from(grouped.entries()), projectOrderBy, repoOrder)
// Why: large imported repo sets can have one group per repo; spreading
// those entries into push can exceed V8's argument limit.
for (const entry of entries) {
@@ -656,9 +713,13 @@ export function buildRows(
}
const sortRepoEntriesWithinGroup = (entries: OrderedGroupEntry[]): OrderedGroupEntry[] => {
- if (projectGroupOrdering !== 'manual') {
- return entries
+ if (projectOrderBy === 'recent') {
+ return [...entries].sort((left, right) =>
+ compareRecentRank(recentRankForEntry(left), recentRankForEntry(right))
+ )
}
+ // Manual: within a Project Group, projects order by their per-group rank
+ // (projectGroupOrder), not the global repoOrder.
return [...entries].sort((left, right) => {
const leftOrder = left[1].repo?.projectGroupOrder
const rightOrder = right[1].repo?.projectGroupOrder
diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts
index 92576add6..8735e305c 100644
--- a/src/renderer/src/lib/startup-ui-hydration.ts
+++ b/src/renderer/src/lib/startup-ui-hydration.ts
@@ -40,6 +40,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState
rightSidebarWidth: 350,
groupBy: 'repo',
sortBy: 'name',
+ projectOrderBy: 'manual',
showActiveOnly: false,
hideSleepingWorkspaces: DEFAULT_HIDE_SLEEPING_WORKSPACES,
showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES,
diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts
new file mode 100644
index 000000000..716151471
--- /dev/null
+++ b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest'
+import type { Repo } from '../../../../shared/types'
+import { reconcileFetchedRepos } from './repo-identity-reconcile'
+
+function makeRepo(id: string, overrides: Partial = {}): Repo {
+ return { id, path: `/${id}`, displayName: id, badgeColor: '#000', addedAt: 1, ...overrides }
+}
+
+describe('reconcileFetchedRepos', () => {
+ it('returns the previous array when the fetched list is field-identical', () => {
+ const previous = [makeRepo('a'), makeRepo('b')]
+ const next = [makeRepo('a'), makeRepo('b')]
+ expect(reconcileFetchedRepos(previous, next)).toBe(previous)
+ })
+
+ it('reuses unchanged repo objects while reflecting a reorder', () => {
+ const previous = [makeRepo('a'), makeRepo('b')]
+ const next = [makeRepo('b'), makeRepo('a')]
+ const result = reconcileFetchedRepos(previous, next)
+ expect(result).not.toBe(previous)
+ expect(result.map((r) => r.id)).toEqual(['b', 'a'])
+ // Identity preserved so memos keyed on repo objects don't churn.
+ expect(result[0]).toBe(previous[1])
+ expect(result[1]).toBe(previous[0])
+ })
+
+ it('keeps a new object only for the repo whose fields changed', () => {
+ const previous = [makeRepo('a'), makeRepo('b')]
+ const next = [makeRepo('a'), makeRepo('b', { displayName: 'renamed' })]
+ const result = reconcileFetchedRepos(previous, next)
+ expect(result[0]).toBe(previous[0])
+ expect(result[1]).toBe(next[1])
+ })
+
+ it('keeps fetched data when optional repo keys differ with the same key count', () => {
+ const previous = [makeRepo('a', { projectGroupId: undefined })]
+ const next = [makeRepo('a', { projectGroupOrder: 2 })]
+ const result = reconcileFetchedRepos(previous, next)
+ expect(result[0]).toBe(next[0])
+ })
+
+ it('returns a rebuilt array when repos are added or removed', () => {
+ const previous = [makeRepo('a')]
+ const next = [makeRepo('a'), makeRepo('b')]
+ const result = reconcileFetchedRepos(previous, next)
+ expect(result).not.toBe(previous)
+ expect(result[0]).toBe(previous[0])
+ expect(result.map((r) => r.id)).toEqual(['a', 'b'])
+ })
+})
diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.ts b/src/renderer/src/store/slices/repo-identity-reconcile.ts
new file mode 100644
index 000000000..55d313e0c
--- /dev/null
+++ b/src/renderer/src/store/slices/repo-identity-reconcile.ts
@@ -0,0 +1,44 @@
+import type { Repo } from '../../../../shared/types'
+
+// Why: after a drag-reorder we optimistically set `repos`, persist, and main
+// broadcasts `repos:changed`. The renderer's own echo handler refetches, which
+// would otherwise hand back field-identical repos as brand-new objects. New
+// identities invalidate the repoMap/repoOrder/rows memos and force the
+// virtualizer to rebuild + re-measure a tick after the drop — the visible jump.
+// Reusing equal objects (and the whole array when nothing moved) makes the echo
+// a no-op render.
+function areReposEqual(a: Repo, b: Repo): boolean {
+ if (a === b) {
+ return true
+ }
+ const keys = Object.keys(a) as (keyof Repo)[]
+ if (keys.length !== Object.keys(b).length) {
+ return false
+ }
+ for (const key of keys) {
+ if (!Object.prototype.hasOwnProperty.call(b, key)) {
+ return false
+ }
+ if (a[key] !== b[key]) {
+ return false
+ }
+ }
+ return true
+}
+
+export function reconcileFetchedRepos(previous: readonly Repo[], next: Repo[]): Repo[] {
+ const previousById = new Map(previous.map((repo) => [repo.id, repo]))
+ let identical = next.length === previous.length
+ const reconciled = next.map((repo, index) => {
+ const existing = previousById.get(repo.id)
+ if (existing && areReposEqual(existing, repo)) {
+ if (existing !== previous[index]) {
+ identical = false
+ }
+ return existing
+ }
+ identical = false
+ return repo
+ })
+ return identical ? (previous as Repo[]) : reconciled
+}
diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts
index 0a5a244a5..fb4422183 100644
--- a/src/renderer/src/store/slices/repos.ts
+++ b/src/renderer/src/store/slices/repos.ts
@@ -16,6 +16,7 @@ import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
import { getRepoIdFromWorktreeId } from './worktree-helpers'
+import { reconcileFetchedRepos } from './repo-identity-reconcile'
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup'
@@ -170,8 +171,9 @@ export const createRepoSlice: StateCreator = (set,
).repos
set((s) => {
const validRepoIds = new Set(repos.map((repo) => repo.id))
+ const reconciledRepos = reconcileFetchedRepos(s.repos, repos)
return {
- repos,
+ repos: reconciledRepos,
activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null,
filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)),
setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos(
diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts
index 44c286b88..c824461ba 100644
--- a/src/renderer/src/store/slices/ui.ts
+++ b/src/renderer/src/store/slices/ui.ts
@@ -20,6 +20,7 @@ import type {
UpdateStatus,
WorkspaceStatusDefinition,
AgentActivityDisplayMode,
+ ProjectOrderBy,
WorktreeCardProperty
} from '../../../../shared/types'
import type { LaunchSource } from '../../../../shared/telemetry-events'
@@ -729,6 +730,8 @@ export type UISlice = {
setGroupBy: (g: UISlice['groupBy']) => void
sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual'
setSortBy: (s: UISlice['sortBy']) => void
+ projectOrderBy: ProjectOrderBy
+ setProjectOrderBy: (p: ProjectOrderBy) => void
showActiveOnly: boolean
setShowActiveOnly: (v: boolean) => void
showSleepingWorkspaces: boolean
@@ -1636,6 +1639,11 @@ export const createUISlice: StateCreator = (set, get)
sortBy: 'recent',
setSortBy: (s) => set({ sortBy: s }),
+ // Why: like setSortBy, this is a bare set — it persists only via the
+ // debounced window.api.ui.set writer in App.tsx, not on its own.
+ projectOrderBy: 'manual',
+ setProjectOrderBy: (p) => set({ projectOrderBy: p }),
+
showActiveOnly: false,
setShowActiveOnly: (v) => set({ showActiveOnly: v }),
@@ -1898,6 +1906,9 @@ export const createUISlice: StateCreator = (set, get)
rightSidebarTab: normalizePersistedRightSidebarTab(ui.rightSidebarTab),
groupBy: (ui.groupBy as UISlice['groupBy'] | 'parent') === 'parent' ? 'repo' : ui.groupBy,
sortBy,
+ // Why: main-process getUI() already normalized this to a valid value
+ // (defaulting to 'manual'); read it through without migrating sortBy.
+ projectOrderBy: ui.projectOrderBy,
// Why: Active-only was retired. Force the old persisted flag off so an
// old profile cannot invisibly keep narrowing the workspace list.
showActiveOnly: false,
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 12029d1fb..d0a5e14f3 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -396,6 +396,7 @@ export function getDefaultUIState(): PersistedUIState {
rightSidebarWidth: 350,
groupBy: 'repo',
sortBy: 'recent',
+ projectOrderBy: 'manual',
showActiveOnly: false,
hideSleepingWorkspaces: DEFAULT_HIDE_SLEEPING_WORKSPACES,
showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES,
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 634347261..33850606f 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -2539,6 +2539,8 @@ export type TaskResumeState = {
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks' | 'ports'
+export type ProjectOrderBy = 'manual' | 'recent'
+
export type PersistedUIState = {
lastActiveRepoId: string | null
lastActiveWorktreeId: string | null
@@ -2548,6 +2550,11 @@ export type PersistedUIState = {
rightSidebarWidth: number
groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status'
sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual'
+ /** Project header ordering in `groupBy: 'repo'`, independent of workspace
+ * `sortBy`. 'manual' (default) uses the persisted repo order and enables
+ * header drag; 'recent' orders by each project's most recent visible
+ * workspace activity. */
+ projectOrderBy: ProjectOrderBy
/** Deprecated; the Active only filter is retired and ignored on hydration. */
showActiveOnly: boolean
/** Hide sleeping/inactive workspaces from workspace navigation. Off by default. */