From 25fe6cbccfde9106ffdeef333fb42a770d7d915d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:26:48 -0700 Subject: [PATCH] Fix Cmd+J recent order when terminal entities hydrate late Unified tabs can appear before tabsByWorktree entities on restore, which latched an all-IDLE ranking and buried blocked chats until reopen. Keep a provisional freeze, then re-capture once entities arrive. --- .../components/WorktreeJumpPalette.test.tsx | 30 +++++++++++++++++ .../src/components/WorktreeJumpPalette.tsx | 32 ++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index 23af48e6364..5b692ee5909 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -767,6 +767,36 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { expect(getCommandValue()).toBe(movedTo) }) + it('re-ranks once when terminal entities hydrate after unified tabs', async () => { + // Why split hydration: unified tabs can land before tabsByWorktree; without a re-capture every + // row ranks IDLE and the blocked chat stays under focus recency until reopen. + const hydrated = makeRecentTabState({ + agentStatusByPaneKey: { + [`term-alpha:${LEAF_ID}`]: makeAgentEntry('term-alpha', 'blocked', Date.now()) + }, + lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } + }) + await renderPalette({ ...hydrated, tabsByWorktree: {} }) + expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) + + await act(async () => { + useAppStore.setState({ tabsByWorktree: hydrated.tabsByWorktree } as Partial) + }) + await flushEffects() + expect(getTabRowIds()).toEqual(['tab-alpha', 'tab-beta']) + + // Why: incomplete→complete re-rank is one-shot — later status churn still freezes positions. + await act(async () => { + useAppStore.setState({ + agentStatusByPaneKey: { + [`term-beta:${LEAF_ID}`]: makeAgentEntry('term-beta', 'blocked', Date.now()) + } + } as Partial) + }) + await flushEffects() + expect(getTabRowIds()).toEqual(['tab-alpha', 'tab-beta']) + }) + it('ranks a blocked agent above a more recently visited idle tab', async () => { await renderPalette( makeRecentTabState({ diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index c3ff5aae2e8..4a66f6eb2c7 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -1046,20 +1046,48 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // send ⌘3 to the wrong row; dots keep updating, positions don't. const [recentTabOrder, setRecentTabOrder] = useState(EMPTY_RECENT_TAB_ORDER) const recentTabOrderCapturedRef = useRef(false) + // Why: unified tabs can land before tabsByWorktree entities. A capture then ranks every chat as + // IDLE; allow one re-capture when entities arrive, then freeze for good. + const recentTabOrderAttentionReadyRef = useRef(false) + // Terminal rows without a tabsByWorktree entity can't resolve attention yet (see orderRecent…). + const recentOrderAttentionIncomplete = useMemo(() => { + for (const item of openTabItems) { + if (item.type !== 'workspace-tab' || item.result.contentType !== 'terminal') { + continue + } + const worktree = worktreeMap.get(item.result.worktreeId) + if (!worktree || worktree.isArchived || isCurrentOpenTabItem(item)) { + continue + } + if (!terminalTabsById.has(item.result.entityId)) { + return true + } + } + return false + }, [openTabItems, terminalTabsById, worktreeMap]) // Why layout, not passive: a post-paint capture shows one frame of worktrees-only, which flashes // the list, renumbers ⌘1–6 under the user, and lets cmdk latch a worktree as the Enter target. useLayoutEffect(() => { if (!visible) { recentTabOrderCapturedRef.current = false + recentTabOrderAttentionReadyRef.current = false autoSelectedItemIdRef.current = null setRecentTabOrder(EMPTY_RECENT_TAB_ORDER) return } // Why: the query and filter are cleared by the open effect below, which runs after this one — // capturing before that lands would freeze the *previous* session's filtered subset for good. - if (recentTabOrderCapturedRef.current || hasQuery || query.length > 0 || filterActive) { + if (hasQuery || query.length > 0 || filterActive) { return } + // Fully frozen after an attention-ready capture; provisional freeze while entities still pending + // so agent-status churn can't reshuffle under the cursor before the one-shot re-rank. + if (recentTabOrderCapturedRef.current) { + if (recentTabOrderAttentionReadyRef.current || recentOrderAttentionIncomplete) { + return + } + // Incomplete → complete: fall through and re-capture with real attention ranks. + } const order = orderRecentWorkspaceTabs({ rows: recentTabRows, paneSources: recentTabPaneSources, @@ -1075,6 +1103,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { return } recentTabOrderCapturedRef.current = true + recentTabOrderAttentionReadyRef.current = !recentOrderAttentionIncomplete setRecentTabOrder(order) // Why: recents render above the worktrees, so a row auto-selected before they arrived is no // longer the list head — hand Enter back to the top, matching ⌘1. Untouched selections only: @@ -1089,6 +1118,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { hasQuery, lastVisitedAtByWorktreeId, query.length, + recentOrderAttentionIncomplete, recentTabPaneSources, recentTabRows, visible