diff --git a/src/renderer/src/components/sidebar/hide-sleeping-transient-pty-gap.test.ts b/src/renderer/src/components/sidebar/hide-sleeping-transient-pty-gap.test.ts index eb1481f3cd2..f3b9c56f30b 100644 --- a/src/renderer/src/components/sidebar/hide-sleeping-transient-pty-gap.test.ts +++ b/src/renderer/src/components/sidebar/hide-sleeping-transient-pty-gap.test.ts @@ -3,7 +3,7 @@ import { computeVisibleWorktreeIds } from './visible-worktrees' import { createSleepingSweepRetentionState, updateSleepingSweepRetention -} from './sleeping-sweep-retention' +} from '../../../../shared/sidebar-list/sleeping-sweep-retention' import { hasActiveWorkspaceActivity } from '@/lib/worktree-activity-state' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-sleeping-sweep-retention.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-sleeping-sweep-retention.ts index 8b0c53d50b2..93ac0144500 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-sleeping-sweep-retention.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-sleeping-sweep-retention.ts @@ -8,7 +8,7 @@ import { cloneSleepingSweepRetentionState, createSleepingSweepRetentionState, updateSleepingSweepRetention -} from '../../sleeping-sweep-retention' +} from '../../../../../../shared/sidebar-list/sleeping-sweep-retention' type SweepActivityInputs = Pick< VisibleWorktreeOptions, diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.test.tsx index 908c925ac99..25de8e6f805 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.test.tsx @@ -360,4 +360,31 @@ describe('useSidebarWorktreeSortOrder', () => { vi.unstubAllGlobals() } }) + + it('keeps Smart latched after its live signal ends, until the list unmounts', () => { + vi.stubGlobal('api', { worktrees: { persistSortOrder: vi.fn(async () => ({ updated: 0 })) } }) + try { + seed( + [makeWorktree('a', 'A', { sortOrder: 2 }), makeWorktree('b', 'B', { sortOrder: 1 })], + 'smart' + ) + useAppStore.setState({ + tabsByWorktree: { a: [makeTerminalTab({ id: 'tab-a', worktreeId: 'a' })] }, + ptyIdsByTabId: { 'tab-a': ['pty-a'] } + }) + const latched = renderSortOrder() + act(() => useAppStore.setState({ ptyIdsByTabId: {} })) + const before = latched.result.current + act(() => publishSortOrders({ a: 1, b: 2 })) + expect(latched.result.current).toBe(before) + latched.unmount() + + const cold = renderSortOrder() + expect(cold.result.current).toEqual(['b', 'a']) + act(() => publishSortOrders({ a: 3 })) + expect(cold.result.current).toEqual(['a', 'b']) + } finally { + vi.unstubAllGlobals() + } + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts index 3d1b5ec5761..6937a854c3c 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts @@ -17,6 +17,10 @@ import { type SmartClass, type WorktreeAttention } from '../../smart-attention' +import { + ranksFollowPersistedOrder, + smartSortPhase +} from '../../../../../../shared/sidebar-list/smart-sort-phase' import { useReusedArrayIdentity } from './use-reused-array-identity' function trackSmartClassDistribution(attention: ReadonlyMap): void { @@ -63,10 +67,11 @@ export function useSidebarWorktreeSortOrder(args: { // Why a latching ref: a live signal makes Smart authoritative for the session, even after that activity ends. const sessionHasHadLiveSmartSignal = useRef(false) // Why: cold Smart and Manual fallback rows rank by sortOrder and never persist it, so they can follow another client's save without looping. - const persistedRankTrigger = - sortBy === 'manual' || (sortBy === 'smart' && !sessionHasHadLiveSmartSignal.current) - ? sortOrderEpoch - : 0 + const followsPersistedRanks = ranksFollowPersistedOrder( + sortBy, + sessionHasHadLiveSmartSignal.current + ) + const persistedRankTrigger = followsPersistedRanks ? sortOrderEpoch : 0 const recomputedSort = useMemo(() => { const state = useAppStore.getState() @@ -79,7 +84,7 @@ export function useSidebarWorktreeSortOrder(args: { let detectedLiveSmartSignal = false // Why cold-start detection: agent-status hydrates async, so the warm comparator would collapse all to Class 4; keep the persisted order until a live signal appears. - if (sortBy === 'smart' && !sessionHasHadLiveSmartSignal.current) { + if (smartSortPhase(sortBy, sessionHasHadLiveSmartSignal.current) === 'cold') { // Why tabHasLivePty over tab.ptyId: slept terminals keep tab.ptyId as a wake hint, so it'd falsely keep cold-start ordering off. const hasAnyLivePty = Object.values(state.tabsByWorktree).some((tabs) => tabs.some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) @@ -189,7 +194,10 @@ export function useSidebarWorktreeSortOrder(args: { // Why: only persist during live sessions so cold start reads the persisted order instead of overwriting it. useEffect(() => { - if (sortBy !== 'smart' || sortedIds.length === 0 || !sessionHasHadLiveSmartSignal.current) { + if ( + sortedIds.length === 0 || + smartSortPhase(sortBy, sessionHasHadLiveSmartSignal.current) !== 'live' + ) { return } // Why: sortOrder lives in each host's worktreeMeta, so persist each host's ids on that host. diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees-retention.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees-retention.test.tsx index 82a59397903..6f6b4a2fab4 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees-retention.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees-retention.test.tsx @@ -10,7 +10,7 @@ import { makeTerminalTab, makeWorktree } from '../../../worktree-jump-palette-test-fixtures' -import { SLEEPING_SWEEP_SETTLE_MS } from '../../sleeping-sweep-retention' +import { SLEEPING_SWEEP_SETTLE_MS } from '../../../../../../shared/sidebar-list/sleeping-sweep-retention' import { forgetWorktreeSleepIntent, markWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { useVisibleSidebarWorktrees } from './use-visible-worktrees' diff --git a/src/renderer/src/store/settled-sort-epoch.test.ts b/src/renderer/src/store/settled-sort-epoch.test.ts index a3a9cbe812e..bda7866f65d 100644 --- a/src/renderer/src/store/settled-sort-epoch.test.ts +++ b/src/renderer/src/store/settled-sort-epoch.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from './index' -import { SORT_SETTLE_MS } from './settled-sort-epoch' +import { SORT_SETTLE_MS } from '../../../shared/sidebar-list/sort-settle' import { applyWebSessionTabsStorePatch } from '@/runtime/web-session-tabs-sync/store-patch' import { makeWorktree } from '@/components/worktree-jump-palette-test-fixtures' diff --git a/src/renderer/src/store/settled-sort-epoch.ts b/src/renderer/src/store/settled-sort-epoch.ts index 0e505aeae21..409d078a539 100644 --- a/src/renderer/src/store/settled-sort-epoch.ts +++ b/src/renderer/src/store/settled-sort-epoch.ts @@ -8,12 +8,14 @@ * the settled value stays correct without any component mounted. */ import type { StoreApi } from 'zustand' +import { + INITIAL_SORT_SETTLE_STATE, + reduceSortSettle, + type SortSettleEvent +} from '../../../shared/sidebar-list/sort-settle' import type { AppState } from './types' import { getIndexedAllWorktrees } from './worktree-repo-index' -// Why: time-decaying scores would make rows jump on every bump; coalesce a burst into one re-sort. -export const SORT_SETTLE_MS = 3_000 - function countLiveWorktrees(worktreesByRepo: AppState['worktreesByRepo']): number { let count = 0 for (const worktree of getIndexedAllWorktrees(worktreesByRepo)) { @@ -28,37 +30,38 @@ function countLiveWorktrees(worktreesByRepo: AppState['worktreesByRepo']): numbe export function installSettledSortEpoch( api: Pick, 'setState' | 'subscribe'> ): void { + let settleState = INITIAL_SORT_SETTLE_STATE let timer: ReturnType | undefined - // Why a baseline from the last bump (not the previous write): a row change that skipped - // its bump (stale-host purge) must not re-sort on its own. - let liveWorktreeCountAtLastBump = 0 - const settle = (): void => api.setState((s) => ({ settledSortEpoch: s.sortEpoch })) - - api.subscribe((state, previous) => { - const epochChanged = state.sortEpoch !== previous.sortEpoch - let structuralChange = false - // Why also on row writes: a bump-less add during a pending window must still settle now. - if (epochChanged || state.worktreesByRepo !== previous.worktreesByRepo) { - const count = countLiveWorktrees(state.worktreesByRepo) - structuralChange = count !== liveWorktreeCountAtLastBump - if (epochChanged) { - liveWorktreeCountAtLastBump = count + const dispatch = (event: SortSettleEvent): void => { + const now = Date.now() + const step = reduceSortSettle(settleState, event, now) + // Why: the timer mirrors the reducer's deadline, re-armed only when it moves. + if (step.state.deadline !== settleState.deadline) { + clearTimeout(timer) + if (step.state.deadline !== null) { + timer = setTimeout(() => dispatch({ kind: 'deadline' }), step.state.deadline - now) } } - if (state.settledSortEpoch === state.sortEpoch) { - // Why: any write that lands settled — settle() itself or a store reset — retires the pending timer. - clearTimeout(timer) - return - } - // Why: adds/removes, Manual (direct manipulation), and a mode switch never wait out the window. - if (structuralChange || state.sortBy === 'manual' || state.sortBy !== previous.sortBy) { - settle() - return - } - if (epochChanged) { - clearTimeout(timer) - timer = setTimeout(settle, SORT_SETTLE_MS) + settleState = step.state + if (step.settle) { + api.setState((s) => ({ settledSortEpoch: s.sortEpoch })) } + } + + api.subscribe((state, previous) => { + const epochBumped = state.sortEpoch !== previous.sortEpoch + // Why also on row writes: a bump-less add during a pending window must still settle now. + const rowsChanged = state.worktreesByRepo !== previous.worktreesByRepo + dispatch({ + kind: 'write', + change: + epochBumped || rowsChanged + ? { epochBumped, liveCount: countLiveWorktrees(state.worktreesByRepo) } + : null, + settled: state.settledSortEpoch === state.sortEpoch, + sortBy: state.sortBy, + previousSortBy: previous.sortBy + }) }) } diff --git a/src/renderer/src/components/sidebar/sleeping-sweep-retention.test.ts b/src/shared/sidebar-list/sleeping-sweep-retention.test.ts similarity index 100% rename from src/renderer/src/components/sidebar/sleeping-sweep-retention.test.ts rename to src/shared/sidebar-list/sleeping-sweep-retention.test.ts diff --git a/src/renderer/src/components/sidebar/sleeping-sweep-retention.ts b/src/shared/sidebar-list/sleeping-sweep-retention.ts similarity index 100% rename from src/renderer/src/components/sidebar/sleeping-sweep-retention.ts rename to src/shared/sidebar-list/sleeping-sweep-retention.ts diff --git a/src/shared/sidebar-list/smart-sort-phase.test.ts b/src/shared/sidebar-list/smart-sort-phase.test.ts new file mode 100644 index 00000000000..c1aa7bd81bc --- /dev/null +++ b/src/shared/sidebar-list/smart-sort-phase.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { ranksFollowPersistedOrder, smartSortPhase } from './smart-sort-phase' + +describe('smartSortPhase', () => { + it('is cold until latched, then live, and absent outside Smart', () => { + expect(smartSortPhase('smart', false)).toBe('cold') + expect(smartSortPhase('smart', true)).toBe('live') + expect(smartSortPhase('recent', true)).toBeNull() + }) + + it('follows persisted ranks under Manual and cold Smart only', () => { + expect(ranksFollowPersistedOrder('manual', true)).toBe(true) + expect(ranksFollowPersistedOrder('smart', false)).toBe(true) + expect(ranksFollowPersistedOrder('smart', true)).toBe(false) + expect(ranksFollowPersistedOrder('recent', false)).toBe(false) + }) +}) diff --git a/src/shared/sidebar-list/smart-sort-phase.ts b/src/shared/sidebar-list/smart-sort-phase.ts new file mode 100644 index 00000000000..a00f62d4d62 --- /dev/null +++ b/src/shared/sidebar-list/smart-sort-phase.ts @@ -0,0 +1,21 @@ +import type { WorkspaceSortBy } from '../workspace-view/workspace-row-sort' + +/** + * Smart's cold start. Agent status hydrates after the list mounts, so until the list has seen a + * live signal Smart keeps the persisted order and never writes it back. Once latched, Smart stays + * live for the list's lifetime, even after that activity ends. + */ +export type SmartSortPhase = 'cold' | 'live' + +/** Null outside Smart. */ +export function smartSortPhase(sortBy: WorkspaceSortBy, latched: boolean): SmartSortPhase | null { + if (sortBy !== 'smart') { + return null + } + return latched ? 'live' : 'cold' +} + +/** Manual and cold Smart rank by persisted order, so they follow another client's save. */ +export function ranksFollowPersistedOrder(sortBy: WorkspaceSortBy, latched: boolean): boolean { + return sortBy === 'manual' || smartSortPhase(sortBy, latched) === 'cold' +} diff --git a/src/shared/sidebar-list/sort-settle.test.ts b/src/shared/sidebar-list/sort-settle.test.ts new file mode 100644 index 00000000000..80f93ed603b --- /dev/null +++ b/src/shared/sidebar-list/sort-settle.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + reduceSortSettle, + SORT_SETTLE_MS, + type SortSettleEvent, + type SortSettleState +} from './sort-settle' + +const settled = { liveCountAtLastBump: 2, deadline: null } + +function write(overrides: Partial>): SortSettleEvent { + return { + kind: 'write', + change: { epochBumped: true, liveCount: 2 }, + settled: false, + sortBy: 'recent', + previousSortBy: 'recent', + ...overrides + } +} + +describe('reduceSortSettle', () => { + it('restarts the window on every bump in a burst and settles once at its deadline', () => { + let state: SortSettleState = settled + for (const now of [0, 1_000, 2_500]) { + const step = reduceSortSettle(state, write({}), now) + expect(step).toEqual({ state: { ...settled, deadline: now + SORT_SETTLE_MS }, settle: false }) + state = step.state + } + const quiet = reduceSortSettle(state, write({ change: null }), 4_000) + expect(quiet.state.deadline).toBe(2_500 + SORT_SETTLE_MS) + expect(reduceSortSettle(quiet.state, { kind: 'deadline' }, 5_500)).toEqual({ + state: settled, + settle: true + }) + }) + + it('settles adds and removes at once, against the count at the last bump', () => { + const pending = reduceSortSettle(settled, write({}), 0).state + expect( + reduceSortSettle(pending, write({ change: { epochBumped: false, liveCount: 3 } }), 10) + ).toEqual({ + state: settled, + settle: true + }) + expect( + reduceSortSettle(pending, write({ change: { epochBumped: true, liveCount: 1 } }), 10) + ).toEqual({ + state: { liveCountAtLastBump: 1, deadline: null }, + settle: true + }) + }) + + it('settles Manual and mode switches without waiting', () => { + for (const modes of [{ sortBy: 'manual' }, { previousSortBy: 'smart' }] as const) { + expect(reduceSortSettle(settled, write(modes), 0)).toEqual({ state: settled, settle: true }) + } + }) + + it('retires a pending deadline when a write lands settled', () => { + const pending = reduceSortSettle(settled, write({}), 0).state + const reset = reduceSortSettle(pending, write({ change: null, settled: true }), 10) + expect(reset).toEqual({ state: settled, settle: false }) + expect(reduceSortSettle(reset.state, { kind: 'deadline' }, 3_000).settle).toBe(false) + }) +}) diff --git a/src/shared/sidebar-list/sort-settle.ts b/src/shared/sidebar-list/sort-settle.ts new file mode 100644 index 00000000000..b67a570795f --- /dev/null +++ b/src/shared/sidebar-list/sort-settle.ts @@ -0,0 +1,49 @@ +import type { WorkspaceSortBy } from '../workspace-view/workspace-row-sort' + +// Why: time-decaying scores would make rows jump on every bump; coalesce a burst into one re-sort. +export const SORT_SETTLE_MS = 3_000 + +export type SortSettleState = { + /** Why from the last bump (not the previous write): a row change that skipped its bump (stale-host purge) must not re-sort on its own. */ + liveCountAtLastBump: number + /** When the pending burst settles; null when nothing is pending. */ + deadline: number | null +} + +export type SortSettleEvent = + | { + kind: 'write' + /** Null when the write changed neither sortEpoch nor the rows. */ + change: { epochBumped: boolean; liveCount: number } | null + /** The write already lands settled: settling itself, or a store reset. */ + settled: boolean + sortBy: WorkspaceSortBy + previousSortBy: WorkspaceSortBy + } + | { kind: 'deadline' } + +export type SortSettleStep = { state: SortSettleState; settle: boolean } + +export const INITIAL_SORT_SETTLE_STATE: SortSettleState = { liveCountAtLastBump: 0, deadline: null } + +export function reduceSortSettle( + state: SortSettleState, + event: SortSettleEvent, + now: number +): SortSettleStep { + if (event.kind === 'deadline') { + return { state: { ...state, deadline: null }, settle: state.deadline !== null } + } + const { change } = event + const structuralChange = change !== null && change.liveCount !== state.liveCountAtLastBump + const liveCountAtLastBump = change?.epochBumped ? change.liveCount : state.liveCountAtLastBump + if (event.settled) { + return { state: { liveCountAtLastBump, deadline: null }, settle: false } + } + // Why: adds/removes, Manual (direct manipulation), and a mode switch never wait out the window. + if (structuralChange || event.sortBy === 'manual' || event.sortBy !== event.previousSortBy) { + return { state: { liveCountAtLastBump, deadline: null }, settle: true } + } + const deadline = change?.epochBumped ? now + SORT_SETTLE_MS : state.deadline + return { state: { liveCountAtLastBump, deadline }, settle: false } +} diff --git a/src/shared/workspace-view/workspace-row-filters.test.ts b/src/shared/workspace-view/workspace-row-filters.test.ts index e202142e004..ecac72863da 100644 --- a/src/shared/workspace-view/workspace-row-filters.test.ts +++ b/src/shared/workspace-view/workspace-row-filters.test.ts @@ -7,7 +7,11 @@ import { type WorkspaceRowFactReader, type WorkspaceRowFilters } from './workspace-row-filters' -import { buildWorkspaceRowComparator, recentActivityWithCreateGrace } from './workspace-row-sort' +import { + buildWorkspaceRowComparator, + CREATE_GRACE_MS, + recentActivityWithCreateGrace +} from './workspace-row-sort' type Row = { id: string; folder?: boolean; facts?: Partial> } @@ -70,6 +74,11 @@ describe('buildWorkspaceRowComparator', () => { expect(recentActivityWithCreateGrace(10, Number.POSITIVE_INFINITY, 0)).toBe(10) }) + it('holds the create-grace floor until exactly createdAt + CREATE_GRACE_MS', () => { + expect(recentActivityWithCreateGrace(10, 0, CREATE_GRACE_MS - 1)).toBe(CREATE_GRACE_MS) + expect(recentActivityWithCreateGrace(10, 0, CREATE_GRACE_MS)).toBe(10) + }) + it('puts unranked rows after ranked ones under manual order, then by name', () => { const ranks: Record = { a: null, b: 1, c: 5, d: null } const compare = buildWorkspaceRowComparator('manual', {