fix(sidebar): stamp lastActivityAt on first worktree discovery (#905)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
55d3a42079
commit
8de43cd3b4
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -204,8 +204,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 }
|
||||
: {})
|
||||
|
|
@ -434,11 +442,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 }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -378,6 +378,136 @@ describe('registerWorktreeHandlers', () => {
|
|||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stamps lastActivityAt on first discovery so newly-added worktrees sort to the top of Recent', async () => {
|
||||
// Why: a worktree that exists on disk but has no persisted WorktreeMeta
|
||||
// (e.g. a folder repo just added, or a pre-existing worktree in a
|
||||
// newly-added git repo) would otherwise fall back to `lastActivityAt: 0`
|
||||
// and rank dead last in the Recent sort.
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/discovered-wt',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
const stampedMeta = { lastActivityAt: 1_700_000_000_000 }
|
||||
store.setWorktreeMeta.mockReturnValue(stampedMeta)
|
||||
|
||||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||||
id: string
|
||||
lastActivityAt: number
|
||||
}[]
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/discovered-wt',
|
||||
expect.objectContaining({ lastActivityAt: expect.any(Number) })
|
||||
)
|
||||
expect(listed[0]).toMatchObject({
|
||||
id: 'repo-1::/workspace/discovered-wt',
|
||||
lastActivityAt: 1_700_000_000_000
|
||||
})
|
||||
})
|
||||
|
||||
it('does not re-stamp lastActivityAt when a worktree already has persisted meta', async () => {
|
||||
// Why: only the *first* discovery should stamp. Re-stamping on every list
|
||||
// would overwrite real activity and reshuffle the sidebar on refresh.
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/existing-wt',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.getWorktreeMeta.mockReturnValue({
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 42
|
||||
})
|
||||
|
||||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||||
id: string
|
||||
lastActivityAt: number
|
||||
}[]
|
||||
|
||||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(listed[0].lastActivityAt).toBe(42)
|
||||
})
|
||||
|
||||
it('stamps lastActivityAt on first discovery for folder-mode repos', async () => {
|
||||
// Why: folder repos produce a synthetic worktree that flows through the
|
||||
// same list path. Without the stamp, adding a folder puts its card at the
|
||||
// bottom of Recent even though the user just added it.
|
||||
store.getRepos.mockReturnValue([
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
}
|
||||
])
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-1',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
})
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 })
|
||||
|
||||
await handlers['worktrees:list'](null, { repoId: 'repo-1' })
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/folder',
|
||||
expect.objectContaining({ lastActivityAt: expect.any(Number) })
|
||||
)
|
||||
})
|
||||
|
||||
it('stamps lastActivityAt on first discovery via worktrees:listAll', async () => {
|
||||
// Why: the stamping logic lives in both worktrees:list and worktrees:listAll.
|
||||
// Without a dedicated test, a regression in the listAll loop would silently
|
||||
// bury newly-discovered worktrees from the multi-repo sidebar view.
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/discovered-wt',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 })
|
||||
|
||||
const listed = (await handlers['worktrees:listAll'](null, undefined)) as {
|
||||
id: string
|
||||
lastActivityAt: number
|
||||
}[]
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/discovered-wt',
|
||||
expect.objectContaining({ lastActivityAt: expect.any(Number) })
|
||||
)
|
||||
expect(listed[0]).toMatchObject({
|
||||
id: 'repo-1::/workspace/discovered-wt',
|
||||
lastActivityAt: 1_700_000_000_000
|
||||
})
|
||||
})
|
||||
|
||||
it('skips past a suffix that already belongs to a PR after an initial branch conflict', async () => {
|
||||
// Why: `gh pr list` is network-bound and previously fired on every single
|
||||
// create, adding 1–3s to the happy path. We now only probe PR conflicts
|
||||
|
|
|
|||
|
|
@ -41,6 +41,20 @@ import { removeWorktreeSymlinks } from './worktree-symlinks'
|
|||
import { track } from '../telemetry/client'
|
||||
import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events'
|
||||
|
||||
// Why: worktrees discovered on disk (not created via Orca's UI) have no
|
||||
// persisted WorktreeMeta, so mergeWorktree falls back to `lastActivityAt: 0`.
|
||||
// That makes them sort to the bottom of "Recent" even though the user just
|
||||
// added the repo / folder. Stamp discovery time the first time we see a
|
||||
// worktree so its very existence counts as a recency signal. Subsequent
|
||||
// list calls find the persisted meta and skip the stamp.
|
||||
function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string): WorktreeMeta {
|
||||
const existing = store.getWorktreeMeta(worktreeId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() })
|
||||
}
|
||||
|
||||
export function registerWorktreeHandlers(
|
||||
mainWindow: BrowserWindow,
|
||||
store: Store,
|
||||
|
|
@ -86,7 +100,7 @@ export function registerWorktreeHandlers(
|
|||
}
|
||||
return gitWorktrees.map((gw) => {
|
||||
const worktreeId = `${repo.id}::${gw.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId)
|
||||
return mergeWorktree(repo.id, gw, meta, repo.displayName)
|
||||
})
|
||||
} catch {
|
||||
|
|
@ -128,7 +142,7 @@ export function registerWorktreeHandlers(
|
|||
}
|
||||
return gitWorktrees.map((gw) => {
|
||||
const worktreeId = `${repo.id}::${gw.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId)
|
||||
return mergeWorktree(repo.id, gw, meta, repo.displayName)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1104,6 +1104,54 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined)
|
||||
})
|
||||
|
||||
it('stamps createdAt alongside lastActivityAt so CLI-created worktrees get the Recent-sort grace window', async () => {
|
||||
// Why: parity with createLocalWorktree / createRemoteWorktree. Without
|
||||
// createdAt, ambient PTY bumps in OTHER worktrees during the few seconds
|
||||
// after creation can push the new worktree below them in Recent sort.
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-grace')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-grace')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: {} })
|
||||
vi.mocked(listWorktrees).mockResolvedValueOnce([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-grace',
|
||||
head: 'def',
|
||||
branch: 'runtime-grace',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const before = Date.now()
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-grace'
|
||||
})
|
||||
const after = Date.now()
|
||||
|
||||
expect(result.worktree.createdAt).toBeDefined()
|
||||
expect(result.worktree.createdAt).toBeGreaterThanOrEqual(before)
|
||||
expect(result.worktree.createdAt).toBeLessThanOrEqual(after)
|
||||
// Both fields must be stamped from the same `now` so the grace-window
|
||||
// math (max(lastActivityAt, createdAt + GRACE_MS)) is well-defined.
|
||||
expect(result.worktree.createdAt).toBe(result.worktree.lastActivityAt)
|
||||
})
|
||||
|
||||
it('skips archive hooks for CLI worktree removal by default', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -2967,8 +2967,14 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
const now = Date.now()
|
||||
const meta = this.store.setWorktreeMeta(worktreeId, {
|
||||
lastActivityAt: Date.now(),
|
||||
lastActivityAt: now,
|
||||
// See createRemoteWorktree: createdAt grants the new worktree a grace
|
||||
// window in Recent sort so ambient PTY bumps in OTHER worktrees can't
|
||||
// push it down before the user has had a chance to notice it. Smart-sort
|
||||
// uses max(lastActivityAt, createdAt + CREATE_GRACE_MS).
|
||||
createdAt: now,
|
||||
...(shouldSetDisplayName(requestedName, branchName, sanitizedName)
|
||||
? { displayName: requestedName }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -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,100 @@ 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, NOW)).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, NOW)).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, NOW)).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, NOW)).toBe(createdAt + CREATE_GRACE_MS)
|
||||
})
|
||||
|
||||
it('returns lastActivityAt once the grace window has elapsed even when no other activity has occurred', () => {
|
||||
// Bug-fix case: a worktree created days ago that was never touched after
|
||||
// creation. Without the time-bound check, the floor would still apply and
|
||||
// the worktree would rank as `createdAt + 5min` forever, masking truly
|
||||
// fresher worktrees.
|
||||
const createdAt = NOW - CREATE_GRACE_MS - 1
|
||||
const wt = makeWorktree({ id: 'untouched', createdAt, lastActivityAt: createdAt })
|
||||
expect(effectiveRecentActivity(wt, NOW)).toBe(createdAt)
|
||||
})
|
||||
})
|
||||
|
||||
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,38 @@ 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` *only during* the grace window (i.e. while
|
||||
* `now < createdAt + CREATE_GRACE_MS`). Once the window has elapsed, returns
|
||||
* `lastActivityAt` unchanged. Returns `lastActivityAt` unchanged for worktrees
|
||||
* without `createdAt` (discovered on disk, or persisted before this field
|
||||
* existed).
|
||||
*/
|
||||
export function effectiveRecentActivity(worktree: Worktree, now: number): number {
|
||||
const { lastActivityAt, createdAt } = worktree
|
||||
// Why bound by now: a worktree with createdAt set but no subsequent activity
|
||||
// should not retain artificially-high recency forever; the floor exists to
|
||||
// absorb the noisy creation window only. Without this bound, a worktree
|
||||
// created days ago and never touched would keep ranking as if its activity
|
||||
// were `createdAt + 5min`, masking truly fresher worktrees indefinitely.
|
||||
if (createdAt === undefined || now >= createdAt + CREATE_GRACE_MS) {
|
||||
return lastActivityAt
|
||||
}
|
||||
return Math.max(lastActivityAt, createdAt + CREATE_GRACE_MS)
|
||||
}
|
||||
|
||||
type PRCacheEntry = { data: object | null; fetchedAt: number }
|
||||
export type SmartSortOverride = {
|
||||
worktree: Worktree
|
||||
|
|
@ -276,18 +308,27 @@ export function buildWorktreeComparator(
|
|||
)
|
||||
return (
|
||||
scoreB - scoreA ||
|
||||
smartB.worktree.lastActivityAt - smartA.worktree.lastActivityAt ||
|
||||
effectiveRecentActivity(smartB.worktree, now) -
|
||||
effectiveRecentActivity(smartA.worktree, now) ||
|
||||
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, now) - effectiveRecentActivity(a, now) ||
|
||||
a.displayName.localeCompare(b.displayName)
|
||||
)
|
||||
case 'repo': {
|
||||
const ra = repoMap.get(a.repoId)?.displayName ?? ''
|
||||
const rb = repoMap.get(b.repoId)?.displayName ?? ''
|
||||
|
|
|
|||
|
|
@ -101,6 +101,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
|
||||
|
|
@ -121,6 +126,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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* E2E test for newly-added worktrees sorting correctly in "Recent" mode.
|
||||
*
|
||||
* Why this exists:
|
||||
* Before the fix in `src/main/ipc/worktrees.ts`, a worktree that existed
|
||||
* on disk but had no persisted WorktreeMeta (the case for folder-mode
|
||||
* repos and pre-existing worktrees discovered when adding a new git repo)
|
||||
* fell back to `lastActivityAt: 0`. "Recent" sort orders by
|
||||
* `lastActivityAt` descending, so those worktrees landed dead last —
|
||||
* even though the user had just added them.
|
||||
*
|
||||
* The `worktrees:list` / `worktrees:listAll` handlers now stamp
|
||||
* `lastActivityAt = Date.now()` on first discovery. This test locks that
|
||||
* behavior in end-to-end.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForSessionReady, waitForActiveWorktree } from './helpers/store'
|
||||
|
||||
async function addFolderRepo(page: Page, folderPath: string): Promise<string> {
|
||||
return page.evaluate(async (p) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is unavailable')
|
||||
}
|
||||
// Why: go through the public addNonGitFolder path (not window.api.repos.add
|
||||
// directly) so the test exercises the same flow the "Add Folder" dialog
|
||||
// uses. That path fetches worktrees internally, which is what triggers the
|
||||
// discovery stamp we're asserting about.
|
||||
const repo = await store.getState().addNonGitFolder(p)
|
||||
if (!repo) {
|
||||
throw new Error('addNonGitFolder returned null')
|
||||
}
|
||||
return repo.id
|
||||
}, folderPath)
|
||||
}
|
||||
|
||||
async function readFolderWorktreeLastActivity(page: Page, repoId: string): Promise<number> {
|
||||
return page.evaluate((id) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is unavailable')
|
||||
}
|
||||
const worktree = store.getState().worktreesByRepo[id]?.[0]
|
||||
if (!worktree) {
|
||||
throw new Error(`No worktree found for repo ${id}`)
|
||||
}
|
||||
return worktree.lastActivityAt
|
||||
}, repoId)
|
||||
}
|
||||
|
||||
test.describe('Worktree Recent Sort', () => {
|
||||
// Why: keep fixture tracking scoped to this describe block. Module-level
|
||||
// shared arrays race if the file ever flips to parallel mode or another
|
||||
// describe is added.
|
||||
const createdFolderFixtures: string[] = []
|
||||
|
||||
function createFolderFixture(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-folder-'))
|
||||
createdFolderFixtures.push(dir)
|
||||
mkdirSync(path.join(dir, 'src'), { recursive: true })
|
||||
writeFileSync(path.join(dir, 'README.md'), '# folder fixture\n')
|
||||
return dir
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
// Why: mkdtempSync fixtures leak unless we clean them up explicitly —
|
||||
// matches the mkdtempSync/rmSync pairing used in helpers/orca-app.ts
|
||||
// and helpers/orca-restart.ts.
|
||||
while (createdFolderFixtures.length) {
|
||||
const dir = createdFolderFixtures.pop()
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('stamps lastActivityAt on a newly-added folder repo so it sorts to the top of Recent', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const folderPath = createFolderFixture()
|
||||
|
||||
const repoId = await addFolderRepo(orcaPage, folderPath)
|
||||
const lastActivityAt = await readFolderWorktreeLastActivity(orcaPage, repoId)
|
||||
|
||||
// Why: the exact failure mode before the fix was `lastActivityAt === 0`
|
||||
// (the fallback in mergeWorktree when meta is undefined). Asserting
|
||||
// `> 0` captures that regression precisely without coupling to the
|
||||
// wall-clock of the main process, which would introduce cross-process
|
||||
// clock-skew flakiness in CI.
|
||||
expect(lastActivityAt).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('leaves lastActivityAt stable across repeated list refreshes', async ({ orcaPage }) => {
|
||||
// Why: the stamp fires only on *first* discovery. Re-fetching must not
|
||||
// overwrite it, or every sidebar refresh would reshuffle Recent order.
|
||||
const folderPath = createFolderFixture()
|
||||
const repoId = await addFolderRepo(orcaPage, folderPath)
|
||||
|
||||
const first = await readFolderWorktreeLastActivity(orcaPage, repoId)
|
||||
|
||||
await orcaPage.evaluate(async (id) => {
|
||||
await window.__store?.getState().fetchWorktrees(id)
|
||||
await window.__store?.getState().fetchWorktrees(id)
|
||||
}, repoId)
|
||||
|
||||
const second = await readFolderWorktreeLastActivity(orcaPage, repoId)
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue