From f90eecfa005ec29d4fc311420b8e6c64c8afbdda Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Mon, 4 May 2026 17:47:29 -0700 Subject: [PATCH] fix(sidebar): keep newly-created worktrees on top during 5-min grace window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new worktree's lastActivityAt is stamped when createLocalWorktree finishes (git fetch + worktree add can take several seconds). In Recent sort, ambient PTY bumps on other worktrees (data, exit, reconnect) then outrank it by milliseconds and push it down — users saw it 'start low, jump to top on interaction'. Add a createdAt field to WorktreeMeta, stamped once at creation. Recent comparator uses max(lastActivityAt, createdAt + CREATE_GRACE_MS) so the new worktree holds the top slot for 5 min regardless of other activity, then steady-state recency ordering resumes. lastActivityAt semantics stay honest (no future timestamps, no monotonic-bump shim). Absent createdAt (pre-existing meta, discovered-on-disk worktrees) falls through to plain lastActivityAt — no migration needed. Co-authored-by: Orca --- src/main/ipc/worktree-logic.ts | 1 + src/main/ipc/worktree-remote.ts | 16 ++- .../src/components/sidebar/smart-sort.test.ts | 98 ++++++++++++++++++- .../src/components/sidebar/smart-sort.ts | 49 ++++++++-- src/shared/types.ts | 7 ++ 5 files changed, 159 insertions(+), 12 deletions(-) diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index f4fc0e292b8..d6472413bf3 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -172,6 +172,7 @@ export function mergeWorktree( isPinned: meta?.isPinned ?? false, sortOrder: meta?.sortOrder ?? 0, lastActivityAt: meta?.lastActivityAt ?? 0, + ...(meta?.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), ...(git.isSparse === true ? { sparseDirectories: meta?.sparseDirectories, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 49385ef2f37..e33ae1b8e5d 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -203,8 +203,16 @@ export async function createRemoteWorktree( } const worktreeId = `${repo.id}::${created.path}` + const now = Date.now() const metaUpdates: Partial = { - lastActivityAt: Date.now(), + lastActivityAt: now, + // Why: grants the new worktree a short grace window at the top of the + // Recent sort. During worktree creation (git fetch + add can take several + // seconds) other worktrees get ambient PTY bumps that would otherwise + // leave the newly-created one below them; the Recent comparator uses + // max(lastActivityAt, createdAt + GRACE_MS) to keep it on top until the + // window elapses. See smart-sort.ts `CREATE_GRACE_MS`. + createdAt: now, ...(shouldSetDisplayName(requestedName, branchName, sanitizedName) ? { displayName: requestedName } : {}) @@ -427,11 +435,15 @@ export async function createLocalWorktree( } const worktreeId = `${repo.id}::${created.path}` + const now = Date.now() const metaUpdates: Partial = { // Stamp activity so the worktree sorts into its final position // immediately — prevents scroll-to-reveal racing with a later // bumpWorktreeActivity that would re-sort the list. - lastActivityAt: Date.now(), + lastActivityAt: now, + // See createRemoteWorktree above: createdAt protects the newly-created + // worktree from ambient PTY bumps in other worktrees for CREATE_GRACE_MS. + createdAt: now, ...(shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName) ? { displayName: effectiveRequestedName } : {}), diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts index dc470ce89eb..91a3a3cd511 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -1,7 +1,13 @@ /* eslint-disable max-lines */ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' -import { buildWorktreeComparator, computeSmartScore, type SmartSortOverride } from './smart-sort' +import { + buildWorktreeComparator, + computeSmartScore, + CREATE_GRACE_MS, + effectiveRecentActivity, + type SmartSortOverride +} from './smart-sort' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' const NOW = new Date('2026-03-27T12:00:00.000Z').getTime() @@ -37,7 +43,8 @@ function makeWorktree(overrides: Partial = {}): Worktree { isPinned: overrides.isPinned ?? false, displayName: overrides.displayName ?? overrides.id ?? 'wt-1', sortOrder: overrides.sortOrder ?? 0, - lastActivityAt: overrides.lastActivityAt ?? 0 + lastActivityAt: overrides.lastActivityAt ?? 0, + ...(overrides.createdAt !== undefined ? { createdAt: overrides.createdAt } : {}) } } @@ -511,3 +518,90 @@ describe('buildWorktreeComparator — recent (lastActivityAt)', () => { expect(worktrees.map((w) => w.id)).toEqual(['fresh-active', 'stale-high-order']) }) }) + +describe('effectiveRecentActivity — create-grace floor', () => { + it('returns lastActivityAt when createdAt is absent', () => { + const wt = makeWorktree({ id: 'old', lastActivityAt: 12345 }) + expect(effectiveRecentActivity(wt)).toBe(12345) + }) + + it('returns createdAt + CREATE_GRACE_MS when grace window exceeds lastActivityAt', () => { + const wt = makeWorktree({ id: 'fresh', lastActivityAt: NOW, createdAt: NOW }) + expect(effectiveRecentActivity(wt)).toBe(NOW + CREATE_GRACE_MS) + }) + + it('returns lastActivityAt when grace window has elapsed', () => { + const wt = makeWorktree({ + id: 'post-grace', + createdAt: NOW - CREATE_GRACE_MS - 60_000, + lastActivityAt: NOW - 1000 + }) + expect(effectiveRecentActivity(wt)).toBe(NOW - 1000) + }) + + it('returns lastActivityAt when real activity has surpassed the grace floor', () => { + // A user who interacted 3 minutes after create has lastActivityAt > createdAt + 3min, + // but createdAt + 5min still wins for the next 2 minutes. + const createdAt = NOW - 3 * 60 * 1000 + const wt = makeWorktree({ id: 'used', createdAt, lastActivityAt: NOW - 60_000 }) + // createdAt + GRACE_MS = NOW + 2min, which exceeds lastActivityAt (NOW - 1min). + expect(effectiveRecentActivity(wt)).toBe(createdAt + CREATE_GRACE_MS) + }) +}) + +describe('buildWorktreeComparator — recent with createdAt grace window', () => { + it('keeps a newly-created worktree on top even when another worktree bumps lastActivityAt', () => { + // Simulates the bug: user creates a worktree at t=0, then an ambient PTY + // bump on a different worktree lands at t=+100ms. Without the grace + // window, the bumped worktree would outrank the new one by 100ms. + const newWorktree = makeWorktree({ + id: 'new', + displayName: 'New', + createdAt: NOW, + lastActivityAt: NOW + }) + const bumpedByAmbient = makeWorktree({ + id: 'bumped', + displayName: 'Bumped', + lastActivityAt: NOW + 100 + }) + const worktrees = [bumpedByAmbient, newWorktree] + + worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW)) + + expect(worktrees.map((w) => w.id)).toEqual(['new', 'bumped']) + }) + + it('falls through to normal recency once the grace window has elapsed', () => { + const oldCreated = makeWorktree({ + id: 'old-created', + displayName: 'Old created', + // Created longer ago than GRACE_MS so the floor has expired. + createdAt: NOW - CREATE_GRACE_MS - 10_000, + lastActivityAt: NOW - 30_000 + }) + const freshActivity = makeWorktree({ + id: 'fresh-activity', + displayName: 'Fresh activity', + // No createdAt (discovered on disk), but has recent real activity. + lastActivityAt: NOW - 1000 + }) + const worktrees = [oldCreated, freshActivity] + + worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW)) + + expect(worktrees.map((w) => w.id)).toEqual(['fresh-activity', 'old-created']) + }) + + it('does not disturb ranking for worktrees without createdAt', () => { + // All existing worktrees (persisted before createdAt field existed) stay + // sorted by lastActivityAt alone. + const alpha = makeWorktree({ id: 'alpha', displayName: 'Alpha', lastActivityAt: 5000 }) + const bravo = makeWorktree({ id: 'bravo', displayName: 'Bravo', lastActivityAt: 10_000 }) + const worktrees = [alpha, bravo] + + worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW)) + + expect(worktrees.map((w) => w.id)).toEqual(['bravo', 'alpha']) + }) +}) diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index 8200cb357ff..1c310759941 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -8,6 +8,31 @@ import { type SortBy = 'name' | 'smart' | 'recent' | 'repo' +// Why: a newly-created worktree's lastActivityAt is stamped at the moment +// createLocalWorktree finishes git + setup-runner prep (often several seconds +// after the user clicked Create). During and after that window, ambient PTY +// bumps on OTHER worktrees (data flush, exit, reconnect) can push the new +// worktree below them in Recent sort. This grace period gives the new +// worktree a floor of `createdAt + CREATE_GRACE_MS` in the Recent comparator +// so it stays on top until the user has had a chance to notice it. 5 min is +// long enough for the user to interact, short enough that steady-state +// ordering resumes quickly. +export const CREATE_GRACE_MS = 5 * 60 * 1000 + +/** + * Rank a worktree in Recent sort using `lastActivityAt`, but with a floor of + * `createdAt + CREATE_GRACE_MS` while that window is active. Returns + * `lastActivityAt` unchanged for worktrees without `createdAt` (discovered on + * disk, or persisted before this field existed). + */ +export function effectiveRecentActivity(worktree: Worktree): number { + const { lastActivityAt, createdAt } = worktree + if (createdAt === undefined) { + return lastActivityAt + } + return Math.max(lastActivityAt, createdAt + CREATE_GRACE_MS) +} + type PRCacheEntry = { data: object | null; fetchedAt: number } export type SmartSortOverride = { worktree: Worktree @@ -276,18 +301,26 @@ export function buildWorktreeComparator( ) return ( scoreB - scoreA || - smartB.worktree.lastActivityAt - smartA.worktree.lastActivityAt || + effectiveRecentActivity(smartB.worktree) - effectiveRecentActivity(smartA.worktree) || a.displayName.localeCompare(b.displayName) ) } case 'recent': - // Why lastActivityAt (not sortOrder): sortOrder is a snapshot of the - // smart-sort ranking that only gets repersisted while the user is in - // "Smart" mode, so it's frozen in Recent mode and ignores new terminal - // events, meta edits, etc. lastActivityAt is the real "recency" signal - // — it's bumped by bumpWorktreeActivity (PTY spawn, background events) - // and by meaningful meta edits (comment, isUnread). - return b.lastActivityAt - a.lastActivityAt || a.displayName.localeCompare(b.displayName) + // Why effectiveRecentActivity (not raw lastActivityAt): newly-created + // worktrees get a CREATE_GRACE_MS floor on top of lastActivityAt so + // ambient PTY bumps in other worktrees don't immediately push them + // down. See CREATE_GRACE_MS above. + // + // Why not sortOrder: sortOrder is a snapshot of the smart-sort + // ranking that only gets repersisted while the user is in "Smart" + // mode, so it's frozen in Recent mode and ignores new terminal + // events, meta edits, etc. lastActivityAt is the real "recency" + // signal — bumped by bumpWorktreeActivity (PTY spawn, background + // events) and by meaningful meta edits (comment, isUnread). + return ( + effectiveRecentActivity(b) - effectiveRecentActivity(a) || + a.displayName.localeCompare(b.displayName) + ) case 'repo': { const ra = repoMap.get(a.repoId)?.displayName ?? '' const rb = repoMap.get(b.repoId)?.displayName ?? '' diff --git a/src/shared/types.ts b/src/shared/types.ts index a10f9a333d6..f7b883cb168 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -89,6 +89,11 @@ export type Worktree = { isPinned: boolean sortOrder: number lastActivityAt: number + /** Set once when Orca creates the worktree. Absent for worktrees discovered + * on disk or persisted before this field existed. Used by the sidebar to + * grant newly-created worktrees a short grace window at the top of Recent, + * immune to ambient PTY-bump reordering in other worktrees. */ + createdAt?: number sparseDirectories?: string[] sparseBaseRef?: string /** ID of the saved preset this worktree was created from, if any. Cleared @@ -109,6 +114,8 @@ export type WorktreeMeta = { isPinned: boolean sortOrder: number lastActivityAt: number + /** See {@link Worktree.createdAt}. Persisted to orca-data.json. */ + createdAt?: number sparseDirectories?: string[] sparseBaseRef?: string sparsePresetId?: string