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.
This commit is contained in:
Jinjing
2026-08-07 18:26:48 -07:00
parent 2c865cda66
commit 25fe6cbccf
2 changed files with 61 additions and 1 deletions
@@ -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<AppState>)
})
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<AppState>)
})
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({
@@ -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<readonly string[]>(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