From 8de43cd3b47d6eda1c4947ef304dcfdb802ee2ba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 5 May 2026 16:17:51 -0700 Subject: [PATCH] fix(sidebar): stamp lastActivityAt on first worktree discovery (#905) Co-authored-by: Orca --- src/main/ipc/worktree-logic.ts | 1 + src/main/ipc/worktree-remote.ts | 16 ++- src/main/ipc/worktrees.test.ts | 130 ++++++++++++++++++ src/main/ipc/worktrees.ts | 18 ++- src/main/runtime/orca-runtime.test.ts | 48 +++++++ src/main/runtime/orca-runtime.ts | 8 +- .../src/components/sidebar/smart-sort.test.ts | 108 ++++++++++++++- .../src/components/sidebar/smart-sort.ts | 57 ++++++-- src/shared/types.ts | 7 + tests/e2e/worktree-recent-sort.spec.ts | 119 ++++++++++++++++ 10 files changed, 497 insertions(+), 15 deletions(-) create mode 100644 tests/e2e/worktree-recent-sort.spec.ts diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index f4fc0e292..d6472413b 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 cb37a482a..416206d26 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -204,8 +204,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 } : {}) @@ -434,11 +442,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/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 12547caec..e2e055f08 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -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 diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index f2c0b806f..772742126 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -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) }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d4d67b917..20d66814f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8ea72e381..9e5075917 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 } : {}), diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts index dc470ce89..b56f9c2c0 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,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']) + }) +}) diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index 8200cb357..54867cef8 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -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 ?? '' diff --git a/src/shared/types.ts b/src/shared/types.ts index 01a63fa0d..f83802006 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 diff --git a/tests/e2e/worktree-recent-sort.spec.ts b/tests/e2e/worktree-recent-sort.spec.ts new file mode 100644 index 000000000..82e24ca6e --- /dev/null +++ b/tests/e2e/worktree-recent-sort.spec.ts @@ -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 { + 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 { + 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) + }) +})