diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts index 9b3d9ee92..1cd2ff9f3 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -354,4 +354,31 @@ describe('buildWorktreeComparator', () => { expect(worktrees.map((worktree) => worktree.id)).toEqual(['active', 'background']) }) + + it('ranks a just-created worktree above shutdown worktrees with passive signals', () => { + const justCreated = makeWorktree({ + id: 'new', + displayName: 'New', + lastActivityAt: NOW + }) + // Shutdown worktree with max passive signals but no recent activity + const shutdown = makeWorktree({ + id: 'shutdown', + displayName: 'Shutdown', + isUnread: true, + linkedIssue: 42, + lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000 + }) + const prCache = { + '/tmp/repo-1::shutdown': { + data: { number: 17 }, + fetchedAt: NOW + } + } + const worktrees = [shutdown, justCreated] + + worktrees.sort(buildWorktreeComparator('recent', null, repoMap, prCache, NOW)) + + expect(worktrees.map((worktree) => worktree.id)).toEqual(['new', 'shutdown']) + }) }) diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index 7f8d0e60c..9dee56780 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -73,7 +73,12 @@ function computeSmartScoreFromSignals( const activityAge = now - (worktree.lastActivityAt || 0) if (worktree.lastActivityAt > 0) { const ONE_DAY = 24 * 60 * 60 * 1000 - score += 24 * Math.max(0, 1 - activityAge / ONE_DAY) + // Why 36: a just-created worktree has only this signal (no live tab yet, + // since the PTY spawns asynchronously after creation). Weight must exceed + // the max passive-signal combination for shutdown worktrees + // (isUnread 18 + PR 10 + issue 6 = 34) so brand-new worktrees always + // appear at the top of the "recent" sort immediately. + score += 36 * Math.max(0, 1 - activityAge / ONE_DAY) } return score @@ -161,12 +166,12 @@ export function buildWorktreeComparator( * * Scoring: * running AI job → +60 + * recent activity → +36 (decays over 24 hours) * needs attention → +35 * unread → +18 * open terminal → +12 * live branch PR → +10 * linked issue → +6 - * recent activity → +24 (decays over 24 hours) */ export function computeSmartScore( worktree: Worktree, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index c2d00da14..21c778569 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -231,12 +231,22 @@ export const createTerminalSlice: StateCreator next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, ptyId } : t)) } const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] + // Why: when a brand-new tab in the active worktree receives its first + // PTY, the live-tab signal (+12) flips on. bumpWorktreeActivity (below) + // intentionally skips sortEpoch for the active worktree to prevent the + // reorder-on-click bug (PR #209), but that means the sort never sees + // the new signal. Bump sortEpoch here so a just-created worktree + // immediately reflects its live-tab score instead of waiting for an + // unrelated event to trigger a re-sort. + const isFirstPty = existingPtyIds.length === 0 + const isActiveWorktree = worktreeId != null && s.activeWorktreeId === worktreeId return { tabsByWorktree: next, ptyIdsByTabId: { ...s.ptyIdsByTabId, [tabId]: existingPtyIds.includes(ptyId) ? existingPtyIds : [...existingPtyIds, ptyId] - } + }, + ...(isFirstPty && isActiveWorktree ? { sortEpoch: s.sortEpoch + 1 } : {}) } })