mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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 ?? ''
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user