fix(sidebar): keep newly-created worktrees on top during 5-min grace window

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 <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-04 17:47:29 -07:00
co-authored by Orca
parent ce54b3241f
commit f90eecfa00
5 changed files with 159 additions and 12 deletions
+1
View File
@@ -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,
+14 -2
View File
@@ -203,8 +203,16 @@ export async function createRemoteWorktree(
}
const worktreeId = `${repo.id}::${created.path}`
const now = Date.now()
const metaUpdates: Partial<WorktreeMeta> = {
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<WorktreeMeta> = {
// 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 }
: {}),
@@ -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> = {}): 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'])
})
})
@@ -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 ?? ''
+7
View File
@@ -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