From 8bd61feb97440b7a17c563ea5d247b84de84b678 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:18:14 -0700 Subject: [PATCH] Exclude idle current tabs from Cmd+J recent; add live attention badges (#13299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Show attention badges on recent chats in Cmd+J palette Keep current tabs visible only when they have a scannable badge (working, permission, unread, done). Snapshot unread maps alongside status maps to freeze recent-section membership on open instead of churning with live updates. Unify badge logic across tab strip and palette, and extract test fixtures for reuse between suites. * Exclude idle current tabs from Cmd+J recent; add live attention badges Current tabs no longer appear in Recent Chats when idle—only working, blocked, or unread agents keep the current slot visible. `done` no longer admits current tabs; the user watched it complete on screen, so that slot goes elsewhere. Add live attention badges to recent rows (working/permission/unread/done), matching the tab-bar ladder. Freeze recent-section membership at open-time to keep row order and inclusion synchronized instead of changing live. * minor fix (cherry picked from commit 082cc31703b523373088a6d7e8a73b87ef4549d9) --- .../WorktreeJumpPalette.recent-tabs.test.tsx | 898 ++++++++++++++++++ .../components/WorktreeJumpPalette.test.tsx | 605 +----------- .../src/components/WorktreeJumpPalette.tsx | 158 ++- .../cmd-j/palette-live-status.test.tsx | 241 ++++- .../components/cmd-j/palette-live-status.tsx | 93 +- .../src/components/tab-bar/SortableTab.tsx | 14 +- .../tab-bar/TerminalTabLeadingIcon.tsx | 32 +- .../terminal-tab-activity-status.test.ts | 53 +- .../tab-bar/terminal-tab-activity-status.ts | 71 +- ...orktree-jump-palette-status-inputs.test.ts | 27 +- .../worktree-jump-palette-status-inputs.ts | 24 +- .../worktree-jump-palette-test-fixtures.ts | 164 ++++ 12 files changed, 1687 insertions(+), 693 deletions(-) create mode 100644 src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx create mode 100644 src/renderer/src/components/worktree-jump-palette-test-fixtures.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx new file mode 100644 index 000000000..68de13176 --- /dev/null +++ b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx @@ -0,0 +1,898 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactI18Next from 'react-i18next' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { emitCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump' +import WorktreeJumpPalette from './WorktreeJumpPalette' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { + LEAF_ID, + makeAgentEntry, + makeGroup, + makeManyTabState, + makeRecentTabState, + makeRepo, + makeTerminalTab, + makeUnifiedTab, + makeWorktree +} from './worktree-jump-palette-test-fixtures' + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key + }) + } +}) + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/hooks/useSettingsNavigationMetadata', () => ({ + useSettingsNavigationMetadata: () => [] +})) + +vi.mock('@/components/sidebar/StatusIndicator', () => ({ + default: () => +})) + +vi.mock('@/components/repo/RepoBadgeLabel', () => ({ + RepoBadgeMark: () => +})) + +vi.mock('@/components/cmd-j/palette-host-badge', () => ({ + getPaletteHostBadge: () => null +})) + +// Why: activation reaches into window.api and the whole worktree-reveal path; the palette's own +// contract is which result it hands over, so stub the boundary and assert on that. +const { activateWorkspaceTabPaletteResult } = vi.hoisted(() => ({ + activateWorkspaceTabPaletteResult: vi.fn((_result: unknown) => ({ status: 'activated' }) as const) +})) +vi.mock('@/lib/workspace-tab-palette-activation', () => ({ + activateWorkspaceTabPaletteResult: (result: unknown) => activateWorkspaceTabPaletteResult(result) +})) + +vi.mock('@/components/ui/command', async () => { + const React = await import('react') + return { + // Why the commandProps passthrough: cmdk resolves Enter against its `value`, so the controlled + // value is the only honest stand-in for "what would Enter activate" without mounting real cmdk. + CommandDialog: ({ + children, + open, + commandProps + }: { + children: React.ReactNode + open?: boolean + commandProps?: { value?: string; onValueChange?: (next: string) => void } + }) => { + setCommandSelection = commandProps?.onValueChange ?? null + return open ? ( +
+ {children} +
+ ) : null + }, + CommandInput: ({ + value, + onValueChange, + placeholder + }: { + value?: string + onValueChange?: (next: string) => void + placeholder?: string + }) => { + setCommandQuery = onValueChange ?? null + return ( + onValueChange?.(event.currentTarget.value)} + /> + ) + }, + CommandList: React.forwardRef(function CommandList( + { children }: { children: React.ReactNode }, + ref: React.ForwardedRef + ) { + return ( +
+ {children} +
+ ) + }), + CommandEmpty: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + CommandItem: ({ + children, + onSelect, + value + }: { + children: React.ReactNode + onSelect?: (value: string) => void + value?: string + }) => ( + + ) + } +}) + +const initialAppState = useAppStore.getInitialState() +let testRoot: Root +let testContainer: HTMLDivElement +let setCommandQuery: ((next: string) => void) | null = null +let setCommandSelection: ((next: string) => void) | null = null + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function renderPalette(overrides: Partial): Promise { + useAppStore.setState({ + activeModal: 'worktree-palette', + activeWorktreeId: null, + repos: [makeRepo()], + tabsByWorktree: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + unifiedTabsByWorktree: {}, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + // Why explicit: the sweep exemption is what these cases probe, so it must + // not ride on whatever the store default happens to be. + alwaysShowDefaultBranchWorkspace: true, + lastVisitedAtByWorktreeId: {}, + ...overrides + } as Partial) + + await act(async () => { + testRoot.render() + }) + await flushEffects() +} + +function getWorktreeRows(): string[] { + return [...testContainer.querySelectorAll('[data-command-item^="worktree:"]')].map( + (node) => node.textContent ?? '' + ) +} + +function getRenderedRowIds(): string[] { + return [...testContainer.querySelectorAll('[data-command-item]')].map( + (node) => node.dataset.commandItem ?? '' + ) +} + +/** The id cmdk would activate on Enter. */ +function getCommandValue(): string { + return ( + testContainer.querySelector('[data-command-dialog]')?.dataset.commandValue ?? '' + ) +} + +function getTabRowIds(): string[] { + return [...testContainer.querySelectorAll('[data-command-item^="workspace-tab:"]')] + .map((node) => node.dataset.commandItem ?? '') + .map((id) => id.replace('workspace-tab:', '')) +} + +describe('WorktreeJumpPalette recent chats & terminals', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + setCommandQuery = null + setCommandSelection = null + activateWorkspaceTabPaletteResult.mockClear() + useAppStore.setState(initialAppState, true) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => { + testRoot.unmount() + }) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + }) + + it('leads the empty-query list with the recent section', async () => { + await renderPalette(makeRecentTabState()) + + const rows = getRenderedRowIds().filter((id) => id.length > 0) + expect(rows[0]).toMatch(/^workspace-tab:/) + expect(rows.some((id) => id.startsWith('worktree:'))).toBe(true) + expect(testContainer.textContent).toContain('Recent Chats & Terminals') + expect(testContainer.textContent).toContain('Recent Worktrees') + }) + + it('caps the recent section so the worktree header stays above the fold', async () => { + await renderPalette(makeManyTabState(12)) + + expect(getTabRowIds()).toHaveLength(6) + expect(testContainer.textContent).toContain('Recent Worktrees') + // Why: the worktree section shrinks against the recent rows so the list holds at 10 total — + // it must never uncap, not even for the frame before the order snapshot lands. + expect(getWorktreeRows().length).toBeLessThanOrEqual(4) + }) + + it('backfills past the cap when rows drop out of the frozen order', async () => { + await renderPalette(makeManyTabState(12)) + const before = getTabRowIds() + + // Why: closing the whole first page stands in for any mid-open narrowing (a filter chip does the + // same thing) — the section must fall through to the next ranked rows, not render empty. + await act(async () => { + useAppStore.setState({ + unifiedTabsByWorktree: { + 'wt-many': (useAppStore.getState().unifiedTabsByWorktree['wt-many'] ?? []).filter( + (tab) => !before.includes(tab.id) + ) + } + } as Partial) + }) + await flushEffects() + + const after = getTabRowIds() + expect(after).toHaveLength(6) + expect(after.some((id) => before.includes(id))).toBe(false) + }) + + /** A tab whose title starts with the query, against worktrees that only match mid-name. */ + function makeTypedRelevanceState(): Partial { + const weak = makeWorktree('wt-weak', 'improve-agent-dashboard-performance') + const host = makeWorktree('wt-host', 'docs-update') + return { + worktreesByRepo: { 'repo-1': [weak, host] }, + showSleepingWorkspaces: true, + ptyIdsByTabId: { 'term-host': ['pty-term-host'] }, + tabsByWorktree: { + 'wt-host': [makeTerminalTab('term-host', 'wt-host', 'Performance Review Main Daemon')] + }, + unifiedTabsByWorktree: { + 'wt-host': [ + makeUnifiedTab('tab-host', 'wt-host', 'term-host', 'Performance Review Main Daemon') + ] + }, + groupsByWorktree: { 'wt-host': [makeGroup('wt-host', ['tab-host'])] }, + activeGroupIdByWorktree: { 'wt-host': 'group-wt-host' } + } + } + + it('leads a typed query with the tab section when it holds the stronger match', async () => { + await renderPalette(makeTypedRelevanceState()) + + await act(async () => { + setCommandQuery?.('perf') + }) + await flushEffects() + + const rows = getRenderedRowIds().filter((id) => id.length > 0) + expect(rows[0]).toBe('workspace-tab:tab-host') + expect(rows).toContain('worktree:wt-weak') + expect(getCommandValue()).toBe('workspace-tab:tab-host') + }) + + it('selects the new first result when cmdk reports the deferred list selection', async () => { + await renderPalette(makeTypedRelevanceState()) + + await act(async () => { + setCommandQuery?.('improve') + }) + await flushEffects() + expect(getCommandValue()).toBe('worktree:wt-weak') + + await act(async () => { + setCommandQuery?.('perf') + setCommandSelection?.('worktree:wt-weak') + }) + await flushEffects() + + expect(getRenderedRowIds().find((id) => id.length > 0)).toBe('workspace-tab:tab-host') + expect(getCommandValue()).toBe('workspace-tab:tab-host') + }) + + it('keeps worktrees ahead of tabs when a worktree holds the stronger match', async () => { + await renderPalette({ + ...makeTypedRelevanceState(), + worktreesByRepo: { + 'repo-1': [ + makeWorktree('wt-strong', 'perf-diff-tighten'), + makeWorktree('wt-host', 'docs-update') + ] + } + }) + + await act(async () => { + setCommandQuery?.('perf-d') + }) + await flushEffects() + + const firstRow = getRenderedRowIds().find((id) => id.length > 0) + expect(firstRow).toBe('worktree:wt-strong') + }) + + it('ranks a typed query by match position inside the worktree section', async () => { + await renderPalette({ + worktreesByRepo: { + 'repo-1': [ + // Why this order: smart sort keeps the input order here, so a promoted prefix hit can only + // come from relevance re-ranking. + makeWorktree('wt-word-a', 'improve-agent-dashboard-performance'), + makeWorktree('wt-word-b', 'rc-perf-update-channels'), + makeWorktree('wt-prefix', 'perf-diff-tighten') + ] + }, + showSleepingWorkspaces: true + }) + + await act(async () => { + setCommandQuery?.('perf') + }) + await flushEffects() + + // Why the two word-start rows keep their input order: relevance ranks by where the match sits + // relative to a word boundary, not by raw offset — equal hits still defer to smart sort. + expect(getRenderedRowIds().filter((id) => id.startsWith('worktree:'))).toEqual([ + 'worktree:wt-prefix', + 'worktree:wt-word-a', + 'worktree:wt-word-b' + ]) + }) + + it('budget-caps the worktree section when nothing fills the recent one', async () => { + await renderPalette({ + worktreesByRepo: { + 'repo-1': Array.from({ length: 14 }, (_, index) => + makeWorktree(`wt-${index}`, `Spare workspace ${index}`) + ) + }, + showSleepingWorkspaces: true + }) + + // Why this shape: a filter chip that drops every open tab lands here too, and uncapping used to + // mount one row per workspace. + expect(getTabRowIds()).toEqual([]) + expect(getWorktreeRows()).toHaveLength(10) + expect(testContainer.textContent).toContain('Type to see all 14 worktrees') + }) + + it('captures the order when tabs hydrate after the palette is already open', async () => { + const hydrated = makeRecentTabState() + await renderPalette({ + ...hydrated, + tabsByWorktree: {}, + unifiedTabsByWorktree: {} + }) + + expect(getTabRowIds()).toEqual([]) + // Why: cmdk claims the first row it sees, which before hydration is a worktree. + const firstWorktreeId = getRenderedRowIds().find((id) => id.startsWith('worktree:')) + expect(firstWorktreeId).toBeDefined() + await act(async () => { + setCommandSelection?.(firstWorktreeId ?? '') + }) + await flushEffects() + + await act(async () => { + useAppStore.setState({ + tabsByWorktree: hydrated.tabsByWorktree, + unifiedTabsByWorktree: hydrated.unifiedTabsByWorktree + } as Partial) + }) + await flushEffects() + + const [topRowId] = getTabRowIds() + expect(getTabRowIds()).toHaveLength(2) + // Enter has to follow the rows up: ⌘1 already points at the first recent chat. + expect(getCommandValue()).toBe(`workspace-tab:${topRowId}`) + + // Why here: an empty snapshot also left the digit chords addressing nothing until reopen. + await act(async () => { + emitCmdJRowIndexJump(0) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( + expect.objectContaining({ tabId: topRowId }) + ) + }) + + it('leaves a deliberately moved selection alone when recents land late', async () => { + const hydrated = makeRecentTabState() + await renderPalette({ + ...hydrated, + tabsByWorktree: {}, + unifiedTabsByWorktree: {} + }) + + const worktreeIds = getRenderedRowIds().filter((id) => id.startsWith('worktree:')) + expect(worktreeIds.length).toBeGreaterThan(1) + // Why the second row: only a selection that differs from the auto-picked head proves the user moved it. + const movedTo = worktreeIds[1] + await act(async () => { + setCommandSelection?.(movedTo) + }) + await flushEffects() + + await act(async () => { + useAppStore.setState({ + tabsByWorktree: hydrated.tabsByWorktree, + unifiedTabsByWorktree: hydrated.unifiedTabsByWorktree + } as Partial) + }) + await flushEffects() + + expect(getTabRowIds()).toHaveLength(2) + 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. A deliberate second-row highlight must survive that one re-rank. + const hydrated = makeRecentTabState({ + agentStatusByPaneKey: { + [makePaneKey('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']) + const movedTo = `workspace-tab:${getTabRowIds()[1]}` + await act(async () => { + setCommandSelection?.(movedTo) + }) + await flushEffects() + await act(async () => { + useAppStore.setState({ tabsByWorktree: hydrated.tabsByWorktree } as Partial) + }) + await flushEffects() + expect(getTabRowIds()).toEqual(['tab-alpha', 'tab-beta']) + expect(getCommandValue()).toBe(movedTo) + }) + + it('admits a high-signal current tab whose terminal entity hydrates late', async () => { + // Why: with no tabsByWorktree entity the current tab's badge is unknowable, so membership is + // too — an attention-ready capture there would freeze it out of Recent for the whole open. + const hydrated = makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'blocked', Date.now()) + } + }) + await renderPalette({ ...hydrated, tabsByWorktree: {} }) + expect(getTabRowIds()).toEqual(['tab-beta']) + + await act(async () => { + useAppStore.setState({ tabsByWorktree: hydrated.tabsByWorktree } 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({ + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'blocked', Date.now()) + }, + lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-alpha', 'tab-beta']) + }) + + it('freezes the order captured on open while statuses keep changing', async () => { + await renderPalette( + makeRecentTabState({ + lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) + + await act(async () => { + useAppStore.setState({ + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'blocked', Date.now()) + } + } as Partial) + }) + await flushEffects() + + expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) + }) + + it('captures the unfiltered order when reopened after a search', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + setCommandQuery?.('Alpha') + }) + await flushEffects() + + // Why closed-then-reopened: the palette stays mounted, and the open effect clears the query one + // commit after the snapshot effect — so a naive capture would freeze the Alpha-only subset. + await act(async () => { + useAppStore.setState({ activeModal: undefined } as Partial) + }) + await flushEffects() + await act(async () => { + useAppStore.setState({ + activeModal: 'worktree-palette' + } as Partial) + }) + await flushEffects() + + expect(getTabRowIds()).toHaveLength(2) + }) + + it('excludes the idle current tab from the recent section', async () => { + await renderPalette( + makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-beta']) + }) + + it('keeps the current tab in recent when its agent needs permission', async () => { + await renderPalette( + makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'blocked', Date.now()) + }, + lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } + }) + ) + + // Why: high-signal current tabs stay scannable (ask-question / permission badge) even though + // idle "where you are" rows are still dropped. + expect(getTabRowIds()).toEqual(['tab-alpha', 'tab-beta']) + expect(testContainer.textContent).toContain('Current Tab') + }) + + it('keeps the current tab in recent when its agent is working', async () => { + await renderPalette( + makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'working', Date.now()) + } + }) + ) + + expect(getTabRowIds()).toContain('tab-alpha') + }) + + it('keeps the current tab in recent when it has unread activity', async () => { + await renderPalette( + makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, + unreadTerminalTabs: { 'term-alpha': true } + }) + ) + + expect(getTabRowIds()).toContain('tab-alpha') + }) + + it('excludes the current tab when its agent is merely done', async () => { + await renderPalette( + makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'done', Date.now()) + } + }) + ) + + // Why: a completion you watched land needs no row — `done` outlives the unread auto-ack by the + // whole 30m staleness window, so the slot goes to a workspace off screen instead. + expect(getTabRowIds()).toEqual(['tab-beta']) + }) + + it('still lists a non-current tab whose agent is done', async () => { + await renderPalette( + makeRecentTabState({ + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'done', Date.now()) + } + }) + ) + + // Why: `done` only stops earning *entry* for the tab on screen — elsewhere it is still news. + expect(getTabRowIds()).toContain('tab-alpha') + }) + + it('keeps the current tab in recent on a pane-only unread completion marker', async () => { + await renderPalette( + makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, + // Why pane-keyed only: the narrower marker (unacked completion in one pane) is its own + // inclusion input — unreadTerminalTabs stays empty here. + unreadAgentCompletionPanes: { [makePaneKey('term-alpha', LEAF_ID)]: true } + }) + ) + + expect(getTabRowIds()).toContain('tab-alpha') + }) + + it('excludes the current editor tab — no agent ladder can lift it out of "you are here"', async () => { + const fileId = '/repo/wt-alpha/notes.ts' + const state = makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'editor', + activeTabTypeByWorktree: { 'wt-alpha': 'editor' }, + activeFileId: fileId, + activeFileIdByWorktree: { 'wt-alpha': fileId }, + openFiles: [ + { + id: fileId, + filePath: fileId, + relativePath: 'notes.ts', + worktreeId: 'wt-alpha', + language: 'typescript', + isDirty: false, + mode: 'edit' + } + ] + }) + await renderPalette({ + ...state, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + 'wt-alpha': [ + { + ...makeUnifiedTab('tab-alpha-file', 'wt-alpha', fileId, 'notes.ts'), + contentType: 'editor' + }, + ...(state.unifiedTabsByWorktree?.['wt-alpha'] ?? []) + ] + }, + groupsByWorktree: { + ...state.groupsByWorktree, + 'wt-alpha': [makeGroup('wt-alpha', ['tab-alpha-file', 'tab-alpha'])] + } + }) + + expect(getTabRowIds()).not.toContain('tab-alpha-file') + expect(getTabRowIds()).toContain('tab-alpha') + + // Proves the exclusion is the current-tab rule, not a missing index entry: search still finds it. + await act(async () => { + setCommandQuery?.('notes') + }) + await flushEffects() + expect(getTabRowIds()).toContain('tab-alpha-file') + }) + + it('excludes an archived worktree tab even with a blocked agent', async () => { + const alpha = makeWorktree('wt-alpha', 'Alpha workspace', { isArchived: true }) + const beta = makeWorktree('wt-beta', 'Beta workspace') + await renderPalette( + makeRecentTabState({ + worktreesByRepo: { 'repo-1': [alpha, beta] }, + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'blocked', Date.now()) + } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-beta']) + }) + + it('does not admit the current tab mid-open when it goes unread', async () => { + await renderPalette( + makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-beta']) + + await act(async () => { + useAppStore.setState({ unreadTerminalTabs: { 'term-alpha': true } } as Partial) + }) + await flushEffects() + + // Why frozen: membership shares the open-time snapshot with the row order, so a late arrival + // can't insert a row under the cursor and renumber ⌘1–6. It joins on the next open. + expect(getTabRowIds()).toEqual(['tab-beta']) + }) + + it('keeps a frozen current row listed after it quiets mid-open', async () => { + await renderPalette( + makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, + unreadTerminalTabs: { 'term-alpha': true } + }) + ) + + expect(getTabRowIds()).toContain('tab-alpha') + + await act(async () => { + useAppStore.setState({ + unreadTerminalTabs: {}, + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'working', Date.now()) + } + } as Partial) + }) + await flushEffects() + + // Why: the row stays where the frozen order put it, and its badge must keep resolving — row + // data covers every open tab, so inclusion dropping it can't blank the pip mid-open. + expect(getTabRowIds()).toContain('tab-alpha') + expect(testContainer.textContent).toContain('Alpha chat') + expect(testContainer.querySelector('[title="Working"]')).not.toBeNull() + }) + + it('keeps a frozen current row listed when its agent finishes mid-open', async () => { + await renderPalette( + makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'working', Date.now()) + } + }) + ) + + expect(getTabRowIds()).toContain('tab-alpha') + + await act(async () => { + useAppStore.setState({ + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'done', Date.now()) + } + } as Partial) + }) + await flushEffects() + + // Why: `done` gates entry, not rendering — a row already in the frozen order keeps its slot and + // flips to the completed check rather than blanking under the cursor. + expect(getTabRowIds()).toContain('tab-alpha') + expect(testContainer.querySelector('[title="Done"]')).not.toBeNull() + }) + + it('activates the row a digit chord addresses while open', async () => { + await renderPalette( + makeRecentTabState({ + lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) + + await act(async () => { + emitCmdJRowIndexJump(1) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-alpha' }) + ) + }) + + it('ignores a digit chord beyond the rendered recent rows', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + emitCmdJRowIndexJump(8) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).not.toHaveBeenCalled() + }) + + it('stops routing digit chords once a query is typed', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + setCommandQuery?.('Alpha') + }) + await flushEffects() + + await act(async () => { + emitCmdJRowIndexJump(0) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).not.toHaveBeenCalled() + }) + + it('keeps create-worktree below the matches it would otherwise outrank', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + setCommandQuery?.('Alpha') + }) + await flushEffects() + + const rows = getRenderedRowIds().filter((id) => id.length > 0) + expect(rows.at(-1)).toBe('__create_worktree__') + expect(rows.length).toBeGreaterThan(1) + }) + + it('labels a folder workspace row with its display name, not a branch', async () => { + await renderPalette( + makeRecentTabState({ + worktreesByRepo: { + 'repo-1': [ + makeWorktree('wt-alpha', 'Alpha workspace', { + isMainWorktree: true, + branch: '' + }), + makeWorktree('wt-beta', 'Beta workspace') + ] + } + }) + ) + + expect(testContainer.textContent).toContain('Alpha workspace') + }) +}) diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index 8378690f5..cd1149f72 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -4,13 +4,10 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as ReactI18Next from 'react-i18next' -import type { Repo, Tab, TabGroup, TerminalTab, Worktree } from '../../../shared/types' -import type { AgentStatusEntry, AgentStatusState } from '../../../shared/agent-status-types' -import { makePaneKey } from '../../../shared/stable-pane-id' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' -import { emitCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump' import WorktreeJumpPalette from './WorktreeJumpPalette' +import { makeRepo, makeWorktree } from './worktree-jump-palette-test-fixtures' vi.mock('react-i18next', async (importOriginal) => { const actual = await importOriginal() @@ -71,7 +68,6 @@ vi.mock('@/components/ui/command', async () => { open?: boolean commandProps?: { value?: string; onValueChange?: (next: string) => void } }) => { - setCommandSelection = commandProps?.onValueChange ?? null return open ? (
{children} @@ -130,44 +126,6 @@ const initialAppState = useAppStore.getInitialState() let testRoot: Root let testContainer: HTMLDivElement let setCommandQuery: ((next: string) => void) | null = null -let setCommandSelection: ((next: string) => void) | null = null - -function makeRepo(): Repo { - return { - id: 'repo-1', - path: '/repos/repo-1', - displayName: 'Repo 1', - badgeColor: '#000000', - addedAt: 0 - } -} - -function makeWorktree( - id: string, - displayName: string, - overrides: Partial = {} -): Worktree { - return { - id, - repoId: 'repo-1', - path: `/tmp/${id}`, - head: 'abc123', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: false, - displayName, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - ...overrides - } -} async function flushEffects(): Promise { await act(async () => { @@ -210,7 +168,6 @@ describe('WorktreeJumpPalette', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true setCommandQuery = null - setCommandSelection = null useAppStore.setState(initialAppState, true) testContainer = document.createElement('div') document.body.appendChild(testContainer) @@ -381,563 +338,3 @@ describe('WorktreeJumpPalette', () => { expect(testContainer.textContent).toContain('Feature workspace') }) }) - -function makeTerminalTab(id: string, worktreeId: string, title: string): TerminalTab { - return { - id, - ptyId: `pty-${id}`, - worktreeId, - title, - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 0 - } -} - -function makeUnifiedTab(id: string, worktreeId: string, entityId: string, label: string): Tab { - return { - id, - entityId, - groupId: `group-${worktreeId}`, - worktreeId, - contentType: 'terminal', - label, - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 0 - } -} - -function makeGroup(worktreeId: string, tabIds: string[]): TabGroup { - return { - id: `group-${worktreeId}`, - worktreeId, - activeTabId: tabIds[0] ?? null, - tabOrder: tabIds, - recentTabIds: tabIds - } -} - -const LEAF_ID = '11111111-2222-4333-8444-555555555555' - -function makeAgentEntry( - tabId: string, - state: AgentStatusState, - stateStartedAt: number -): AgentStatusEntry { - return { - state, - prompt: '', - updatedAt: stateStartedAt, - stateStartedAt, - paneKey: makePaneKey(tabId, LEAF_ID), - stateHistory: [] - } -} - -/** Two worktrees, one terminal tab each, none of them current. */ -function makeRecentTabState(overrides: Partial = {}): Partial { - const alpha = makeWorktree('wt-alpha', 'Alpha workspace') - const beta = makeWorktree('wt-beta', 'Beta workspace') - return { - worktreesByRepo: { 'repo-1': [alpha, beta] }, - showSleepingWorkspaces: true, - ptyIdsByTabId: { - 'term-alpha': ['pty-term-alpha'], - 'term-beta': ['pty-term-beta'] - }, - tabsByWorktree: { - 'wt-alpha': [makeTerminalTab('term-alpha', 'wt-alpha', 'Alpha chat')], - 'wt-beta': [makeTerminalTab('term-beta', 'wt-beta', 'Beta chat')] - }, - unifiedTabsByWorktree: { - 'wt-alpha': [makeUnifiedTab('tab-alpha', 'wt-alpha', 'term-alpha', 'Alpha chat')], - 'wt-beta': [makeUnifiedTab('tab-beta', 'wt-beta', 'term-beta', 'Beta chat')] - }, - groupsByWorktree: { - 'wt-alpha': [makeGroup('wt-alpha', ['tab-alpha'])], - 'wt-beta': [makeGroup('wt-beta', ['tab-beta'])] - }, - activeGroupIdByWorktree: { - 'wt-alpha': 'group-wt-alpha', - 'wt-beta': 'group-wt-beta' - }, - ...overrides - } -} - -/** One tab-heavy worktree plus `count` bare ones, so both sections overflow their caps. */ -function makeManyTabState(count: number): Partial { - const ids = Array.from({ length: count }, (_, index) => `${index}`) - return { - worktreesByRepo: { - 'repo-1': [ - makeWorktree('wt-many', 'Many workspace'), - ...ids.map((id) => makeWorktree(`wt-${id}`, `Spare workspace ${id}`)) - ] - }, - showSleepingWorkspaces: true, - ptyIdsByTabId: Object.fromEntries(ids.map((id) => [`term-${id}`, [`pty-${id}`]])), - tabsByWorktree: { - 'wt-many': ids.map((id) => makeTerminalTab(`term-${id}`, 'wt-many', `Chat ${id}`)) - }, - unifiedTabsByWorktree: { - 'wt-many': ids.map((id) => makeUnifiedTab(`tab-${id}`, 'wt-many', `term-${id}`, `Chat ${id}`)) - }, - groupsByWorktree: { - 'wt-many': [ - makeGroup( - 'wt-many', - ids.map((id) => `tab-${id}`) - ) - ] - }, - activeGroupIdByWorktree: { 'wt-many': 'group-wt-many' } - } -} - -function getRenderedRowIds(): string[] { - return [...testContainer.querySelectorAll('[data-command-item]')].map( - (node) => node.dataset.commandItem ?? '' - ) -} - -/** The id cmdk would activate on Enter. */ -function getCommandValue(): string { - return ( - testContainer.querySelector('[data-command-dialog]')?.dataset.commandValue ?? '' - ) -} - -function getTabRowIds(): string[] { - return [...testContainer.querySelectorAll('[data-command-item^="workspace-tab:"]')] - .map((node) => node.dataset.commandItem ?? '') - .map((id) => id.replace('workspace-tab:', '')) -} - -describe('WorktreeJumpPalette recent chats & terminals', () => { - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - setCommandQuery = null - setCommandSelection = null - activateWorkspaceTabPaletteResult.mockClear() - useAppStore.setState(initialAppState, true) - testContainer = document.createElement('div') - document.body.appendChild(testContainer) - testRoot = createRoot(testContainer) - }) - - afterEach(async () => { - await act(async () => { - testRoot.unmount() - }) - document.body.replaceChildren() - useAppStore.setState(initialAppState, true) - }) - - it('leads the empty-query list with the recent section', async () => { - await renderPalette(makeRecentTabState()) - - const rows = getRenderedRowIds().filter((id) => id.length > 0) - expect(rows[0]).toMatch(/^workspace-tab:/) - expect(rows.some((id) => id.startsWith('worktree:'))).toBe(true) - expect(testContainer.textContent).toContain('Recent Chats & Terminals') - expect(testContainer.textContent).toContain('Recent Worktrees') - }) - - it('caps the recent section so the worktree header stays above the fold', async () => { - await renderPalette(makeManyTabState(12)) - - expect(getTabRowIds()).toHaveLength(6) - expect(testContainer.textContent).toContain('Recent Worktrees') - // Why: the worktree section shrinks against the recent rows so the list holds at 10 total — - // it must never uncap, not even for the frame before the order snapshot lands. - expect(getWorktreeRows().length).toBeLessThanOrEqual(4) - }) - - it('backfills past the cap when rows drop out of the frozen order', async () => { - await renderPalette(makeManyTabState(12)) - const before = getTabRowIds() - - // Why: closing the whole first page stands in for any mid-open narrowing (a filter chip does the - // same thing) — the section must fall through to the next ranked rows, not render empty. - await act(async () => { - useAppStore.setState({ - unifiedTabsByWorktree: { - 'wt-many': (useAppStore.getState().unifiedTabsByWorktree['wt-many'] ?? []).filter( - (tab) => !before.includes(tab.id) - ) - } - } as Partial) - }) - await flushEffects() - - const after = getTabRowIds() - expect(after).toHaveLength(6) - expect(after.some((id) => before.includes(id))).toBe(false) - }) - - /** A tab whose title starts with the query, against worktrees that only match mid-name. */ - function makeTypedRelevanceState(): Partial { - const weak = makeWorktree('wt-weak', 'improve-agent-dashboard-performance') - const host = makeWorktree('wt-host', 'docs-update') - return { - worktreesByRepo: { 'repo-1': [weak, host] }, - showSleepingWorkspaces: true, - ptyIdsByTabId: { 'term-host': ['pty-term-host'] }, - tabsByWorktree: { - 'wt-host': [makeTerminalTab('term-host', 'wt-host', 'Performance Review Main Daemon')] - }, - unifiedTabsByWorktree: { - 'wt-host': [ - makeUnifiedTab('tab-host', 'wt-host', 'term-host', 'Performance Review Main Daemon') - ] - }, - groupsByWorktree: { 'wt-host': [makeGroup('wt-host', ['tab-host'])] }, - activeGroupIdByWorktree: { 'wt-host': 'group-wt-host' } - } - } - - it('leads a typed query with the tab section when it holds the stronger match', async () => { - await renderPalette(makeTypedRelevanceState()) - - await act(async () => { - setCommandQuery?.('perf') - }) - await flushEffects() - - const rows = getRenderedRowIds().filter((id) => id.length > 0) - expect(rows[0]).toBe('workspace-tab:tab-host') - expect(rows).toContain('worktree:wt-weak') - expect(getCommandValue()).toBe('workspace-tab:tab-host') - }) - - it('selects the new first result when cmdk reports the deferred list selection', async () => { - await renderPalette(makeTypedRelevanceState()) - - await act(async () => { - setCommandQuery?.('improve') - }) - await flushEffects() - expect(getCommandValue()).toBe('worktree:wt-weak') - - await act(async () => { - setCommandQuery?.('perf') - setCommandSelection?.('worktree:wt-weak') - }) - await flushEffects() - - expect(getRenderedRowIds().find((id) => id.length > 0)).toBe('workspace-tab:tab-host') - expect(getCommandValue()).toBe('workspace-tab:tab-host') - }) - - it('keeps worktrees ahead of tabs when a worktree holds the stronger match', async () => { - await renderPalette({ - ...makeTypedRelevanceState(), - worktreesByRepo: { - 'repo-1': [ - makeWorktree('wt-strong', 'perf-diff-tighten'), - makeWorktree('wt-host', 'docs-update') - ] - } - }) - - await act(async () => { - setCommandQuery?.('perf-d') - }) - await flushEffects() - - const firstRow = getRenderedRowIds().find((id) => id.length > 0) - expect(firstRow).toBe('worktree:wt-strong') - }) - - it('ranks a typed query by match position inside the worktree section', async () => { - await renderPalette({ - worktreesByRepo: { - 'repo-1': [ - // Why this order: smart sort keeps the input order here, so a promoted prefix hit can only - // come from relevance re-ranking. - makeWorktree('wt-word-a', 'improve-agent-dashboard-performance'), - makeWorktree('wt-word-b', 'rc-perf-update-channels'), - makeWorktree('wt-prefix', 'perf-diff-tighten') - ] - }, - showSleepingWorkspaces: true - }) - - await act(async () => { - setCommandQuery?.('perf') - }) - await flushEffects() - - // Why the two word-start rows keep their input order: relevance ranks by where the match sits - // relative to a word boundary, not by raw offset — equal hits still defer to smart sort. - expect(getRenderedRowIds().filter((id) => id.startsWith('worktree:'))).toEqual([ - 'worktree:wt-prefix', - 'worktree:wt-word-a', - 'worktree:wt-word-b' - ]) - }) - - it('budget-caps the worktree section when nothing fills the recent one', async () => { - await renderPalette({ - worktreesByRepo: { - 'repo-1': Array.from({ length: 14 }, (_, index) => - makeWorktree(`wt-${index}`, `Spare workspace ${index}`) - ) - }, - showSleepingWorkspaces: true - }) - - // Why this shape: a filter chip that drops every open tab lands here too, and uncapping used to - // mount one row per workspace. - expect(getTabRowIds()).toEqual([]) - expect(getWorktreeRows()).toHaveLength(10) - expect(testContainer.textContent).toContain('Type to see all 14 worktrees') - }) - - it('captures the order when tabs hydrate after the palette is already open', async () => { - const hydrated = makeRecentTabState() - await renderPalette({ - ...hydrated, - tabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - - expect(getTabRowIds()).toEqual([]) - // Why: cmdk claims the first row it sees, which before hydration is a worktree. - const firstWorktreeId = getRenderedRowIds().find((id) => id.startsWith('worktree:')) - expect(firstWorktreeId).toBeDefined() - await act(async () => { - setCommandSelection?.(firstWorktreeId ?? '') - }) - await flushEffects() - - await act(async () => { - useAppStore.setState({ - tabsByWorktree: hydrated.tabsByWorktree, - unifiedTabsByWorktree: hydrated.unifiedTabsByWorktree - } as Partial) - }) - await flushEffects() - - const [topRowId] = getTabRowIds() - expect(getTabRowIds()).toHaveLength(2) - // Enter has to follow the rows up: ⌘1 already points at the first recent chat. - expect(getCommandValue()).toBe(`workspace-tab:${topRowId}`) - - // Why here: an empty snapshot also left the digit chords addressing nothing until reopen. - await act(async () => { - emitCmdJRowIndexJump(0) - }) - await flushEffects() - - expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( - expect.objectContaining({ tabId: topRowId }) - ) - }) - - it('leaves a deliberately moved selection alone when recents land late', async () => { - const hydrated = makeRecentTabState() - await renderPalette({ - ...hydrated, - tabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - - const worktreeIds = getRenderedRowIds().filter((id) => id.startsWith('worktree:')) - expect(worktreeIds.length).toBeGreaterThan(1) - // Why the second row: only a selection that differs from the auto-picked head proves the user moved it. - const movedTo = worktreeIds[1] - await act(async () => { - setCommandSelection?.(movedTo) - }) - await flushEffects() - - await act(async () => { - useAppStore.setState({ - tabsByWorktree: hydrated.tabsByWorktree, - unifiedTabsByWorktree: hydrated.unifiedTabsByWorktree - } as Partial) - }) - await flushEffects() - - expect(getTabRowIds()).toHaveLength(2) - 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. A deliberate second-row highlight must survive that one re-rank. - const hydrated = makeRecentTabState({ - agentStatusByPaneKey: { - [makePaneKey('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']) - const movedTo = `workspace-tab:${getTabRowIds()[1]}` - await act(async () => { - setCommandSelection?.(movedTo) - }) - await flushEffects() - await act(async () => { - useAppStore.setState({ tabsByWorktree: hydrated.tabsByWorktree } as Partial) - }) - await flushEffects() - expect(getTabRowIds()).toEqual(['tab-alpha', 'tab-beta']) - expect(getCommandValue()).toBe(movedTo) - }) - - it('ranks a blocked agent above a more recently visited idle tab', async () => { - await renderPalette( - makeRecentTabState({ - agentStatusByPaneKey: { - [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'blocked', Date.now()) - }, - lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } - }) - ) - - expect(getTabRowIds()).toEqual(['tab-alpha', 'tab-beta']) - }) - - it('freezes the order captured on open while statuses keep changing', async () => { - await renderPalette( - makeRecentTabState({ - lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } - }) - ) - - expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) - - await act(async () => { - useAppStore.setState({ - agentStatusByPaneKey: { - [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'blocked', Date.now()) - } - } as Partial) - }) - await flushEffects() - - expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) - }) - - it('captures the unfiltered order when reopened after a search', async () => { - await renderPalette(makeRecentTabState()) - - await act(async () => { - setCommandQuery?.('Alpha') - }) - await flushEffects() - - // Why closed-then-reopened: the palette stays mounted, and the open effect clears the query one - // commit after the snapshot effect — so a naive capture would freeze the Alpha-only subset. - await act(async () => { - useAppStore.setState({ activeModal: undefined } as Partial) - }) - await flushEffects() - await act(async () => { - useAppStore.setState({ - activeModal: 'worktree-palette' - } as Partial) - }) - await flushEffects() - - expect(getTabRowIds()).toHaveLength(2) - }) - - it('excludes the current tab from the recent section', async () => { - await renderPalette( - makeRecentTabState({ - activeWorktreeId: 'wt-alpha', - activeTabType: 'terminal', - activeTabId: 'term-alpha', - activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, - activeTabTypeByWorktree: { 'wt-alpha': 'terminal' } - }) - ) - - expect(getTabRowIds()).toEqual(['tab-beta']) - }) - - it('activates the row a digit chord addresses while open', async () => { - await renderPalette( - makeRecentTabState({ - lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } - }) - ) - - expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) - - await act(async () => { - emitCmdJRowIndexJump(1) - }) - await flushEffects() - - expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( - expect.objectContaining({ tabId: 'tab-alpha' }) - ) - }) - - it('ignores a digit chord beyond the rendered recent rows', async () => { - await renderPalette(makeRecentTabState()) - - await act(async () => { - emitCmdJRowIndexJump(8) - }) - await flushEffects() - - expect(activateWorkspaceTabPaletteResult).not.toHaveBeenCalled() - }) - - it('stops routing digit chords once a query is typed', async () => { - await renderPalette(makeRecentTabState()) - - await act(async () => { - setCommandQuery?.('Alpha') - }) - await flushEffects() - - await act(async () => { - emitCmdJRowIndexJump(0) - }) - await flushEffects() - - expect(activateWorkspaceTabPaletteResult).not.toHaveBeenCalled() - }) - - it('keeps create-worktree below the matches it would otherwise outrank', async () => { - await renderPalette(makeRecentTabState()) - - await act(async () => { - setCommandQuery?.('Alpha') - }) - await flushEffects() - - const rows = getRenderedRowIds().filter((id) => id.length > 0) - expect(rows.at(-1)).toBe('__create_worktree__') - expect(rows.length).toBeGreaterThan(1) - }) - - it('labels a folder workspace row with its display name, not a branch', async () => { - await renderPalette( - makeRecentTabState({ - worktreesByRepo: { - 'repo-1': [ - makeWorktree('wt-alpha', 'Alpha workspace', { - isMainWorktree: true, - branch: '' - }), - makeWorktree('wt-beta', 'Beta workspace') - ] - } - }) - ) - - expect(testContainer.textContent).toContain('Alpha workspace') - }) -}) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 84ef8a394..89068eb48 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -32,6 +32,10 @@ import { PaletteRecentTabStatusDot, PaletteWorktreeStatusDot } from './cmd-j/palette-live-status' +import { + resolveTerminalTabAttentionBadge, + terminalTabHasUnreadActivity +} from '@/components/tab-bar/terminal-tab-activity-status' import { CommandDialog, CommandInput, @@ -106,6 +110,7 @@ import { import { buildFocusedGroupTabRecency, orderRecentWorkspaceTabs, + resolveRecentWorkspaceTabStatus, type RecentWorkspaceTabRow } from '@/lib/recent-workspace-tab-rows' import { subscribeCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump' @@ -279,6 +284,61 @@ function isCurrentOpenTabItem(item: OpenTabPaletteItem): boolean { return item.type === 'browser-page' ? item.result.isCurrentPage : item.result.isCurrentTab } +/** An open tab's recent-section row plus the inputs inclusion needs. */ +type OpenTabRecentRow = { + item: OpenTabPaletteItem + worktree: Worktree + row: RecentWorkspaceTabRow +} + +/** + * Empty-query recent section: skip idle "where you already are" rows, but keep the current tab when + * it still wants something from you (working, permission, unread). Decided from the open-time status + * snapshot, so membership matches the frozen row order for the whole session — a current tab that + * goes high-signal mid-open joins Recent on the next open, not under the cursor. + */ +function shouldIncludeOpenTabInRecentSection({ + item, + worktree, + row, + paneSources, + unreadTerminalTabs, + unreadAgentCompletionPanes, + now +}: { + item: OpenTabPaletteItem + worktree: Worktree + row: RecentWorkspaceTabRow + paneSources: TabPaneInputSources + unreadTerminalTabs: Record + unreadAgentCompletionPanes: Record + now: number +}): boolean { + if (worktree.isArchived) { + return false + } + if (!isCurrentOpenTabItem(item)) { + return true + } + // Current browser/editor rows have no attention ladder to escape "you're already here". + if (!row.terminalTab) { + return false + } + // Why the ladder minus `done`: the badge rungs decide entry, but a completion you watched land on + // screen (unread auto-acks on the focused tab) is news to nobody, and `done` lingers for the full + // 30m staleness window — that slot belongs to a workspace you can't already see. Rows admitted + // while working keep their frozen slot and flip to the check. + const badge = resolveTerminalTabAttentionBadge({ + status: resolveRecentWorkspaceTabStatus(row, paneSources, now), + hasUnread: terminalTabHasUnreadActivity({ + terminalTabId: row.terminalTab.id, + unreadTerminalTabs, + unreadAgentCompletionPanes + }) + }) + return badge != null && badge !== 'done' +} + function PaletteRowShortcutBadge({ index, modifierKeys @@ -509,7 +569,15 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // oxlint-disable-next-line react-hooks/exhaustive-deps -- these deps ARE the refresh policy, not reads: re-snapshot when the palette opens or the tab set moves under it, never on the agent churn the snapshot exists to ignore. [paletteStatusInputsActive, tabsByWorktree, unifiedTabsByWorktree] ) - const { agentStatusByPaneKey, runtimePaneTitlesByTabId } = paletteIndexStatus + // Why the unread maps ride the same snapshot: recent-section membership is decided once, with the + // same open-time reading the frozen row order uses. Subscribing here would re-render the whole + // palette on app-wide unread churn to change membership the frozen order can no longer honour. + const { + agentStatusByPaneKey, + runtimePaneTitlesByTabId, + unreadTerminalTabs, + unreadAgentCompletionPanes + } = paletteIndexStatus const openFiles = useAppStore((s) => s.openFiles) const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree) const groupsByWorktree = useAppStore((s) => s.groupsByWorktree) @@ -1070,34 +1138,62 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ] ) - // Why: the recent section excludes the current tab (the top slot is never "where you are") and - // archived worktrees, both of which the typed-query index still surfaces. - const recentTabRows = useMemo(() => { - const rows: RecentWorkspaceTabRow[] = [] + // Why unfiltered: a row already frozen into the recent order must keep resolving its badge even + // once inclusion would drop it (a current tab that quiets down), or the pip blanks mid-open. + const openTabRecentRows = useMemo(() => { + const entries: OpenTabRecentRow[] = [] for (const item of openTabItems) { const worktree = worktreeMap.get(item.result.worktreeId) - if (!worktree || worktree.isArchived || isCurrentOpenTabItem(item)) { + if (!worktree) { continue } - rows.push({ - id: item.id, - worktreeId: worktree.id, - unifiedTabId: item.type === 'browser-page' ? null : item.result.tabId, - terminalTab: - item.type === 'workspace-tab' && item.result.contentType === 'terminal' - ? (terminalTabsById.get(item.result.entityId) ?? null) - : null, - worktreeLastActivityAt: worktree.lastActivityAt + entries.push({ + item, + worktree, + row: { + id: item.id, + worktreeId: worktree.id, + unifiedTabId: item.type === 'browser-page' ? null : item.result.tabId, + terminalTab: + item.type === 'workspace-tab' && item.result.contentType === 'terminal' + ? (terminalTabsById.get(item.result.entityId) ?? null) + : null, + worktreeLastActivityAt: worktree.lastActivityAt + } }) } - return rows + return entries }, [openTabItems, terminalTabsById, worktreeMap]) const recentTabRowById = useMemo( - () => new Map(recentTabRows.map((row) => [row.id, row])), - [recentTabRows] + () => new Map(openTabRecentRows.map(({ row }) => [row.id, row])), + [openTabRecentRows] ) + // Why: empty-query recent skips idle current tabs ("you're already there") and archived + // worktrees; high-signal current agents still surface so working / ask-question / unread + // badges stay visible. Typed-query still indexes every open tab. + const recentTabRows = useMemo(() => { + const now = Date.now() + const rows: RecentWorkspaceTabRow[] = [] + for (const { item, worktree, row } of openTabRecentRows) { + if ( + shouldIncludeOpenTabInRecentSection({ + item, + worktree, + row, + paneSources: recentTabPaneSources, + unreadTerminalTabs, + unreadAgentCompletionPanes, + now + }) + ) { + rows.push(row) + } + } + return rows + }, [openTabRecentRows, recentTabPaneSources, unreadAgentCompletionPanes, unreadTerminalTabs]) + // Why: ordering is captured once on open. Live re-ranking would move rows under the cursor and // send ⌘3 to the wrong row; dots keep updating, positions don't. const [recentTabOrder, setRecentTabOrder] = useState(EMPTY_RECENT_TAB_ORDER) @@ -1106,21 +1202,23 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // 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…). + // Why current tabs count too: the missing entity is also what decides whether a current tab has a + // badge worth listing, so a capture now would freeze it out for the whole open. Archived is the + // one exclusion that needs no entity. const recentOrderAttentionIncomplete = useMemo(() => { - for (const item of openTabItems) { - if (item.type !== 'workspace-tab' || item.result.contentType !== 'terminal') { + for (const { item, worktree, row } of openTabRecentRows) { + if ( + item.type !== 'workspace-tab' || + item.result.contentType !== 'terminal' || + row.terminalTab || + worktree.isArchived + ) { continue } - const worktree = worktreeMap.get(item.result.worktreeId) - if (!worktree || worktree.isArchived || isCurrentOpenTabItem(item)) { - continue - } - if (!terminalTabsById.has(item.result.entityId)) { - return true - } + return true } return false - }, [openTabItems, terminalTabsById, worktreeMap]) + }, [openTabRecentRows]) // 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(() => { @@ -2684,8 +2782,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) const WorkspaceTabIcon = result.contentType === 'terminal' ? SquareTerminal : FileText - // Why null on a typed query: the dot belongs to the frozen recent section — the - // Open Tabs results a search returns show their content icon instead. + // Why null on a typed query: live corner pips belong to the frozen recent section — + // Open Tabs search results stay content-icon only (no agent status overlay). const recentRow = hasQuery ? null : (recentTabRowById.get(entry.id) ?? null) return ( diff --git a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx index 9e6daf7f7..2fa01fffc 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx @@ -133,6 +133,37 @@ describe('palette live status', () => { expect(bodyRenderCount).toBe(rendersAfterMount) }) + // Why: unread writes are app-wide chatter. The pip owns them here so the palette body can read + // its unread from the open-time snapshot instead of re-rendering the whole list on every bell. + it('re-renders the unread pip without re-rendering the frozen body around it', async () => { + await act(async () => { + testRoot.render( + + + } + /> + + + ) + }) + const rendersAfterMount = bodyRenderCount + + await act(async () => { + useAppStore.setState({ unreadTerminalTabs: { 'term-a': true } } as Partial) + }) + + expect(dotLabels()).toEqual(['Unread agent completion']) + expect(bodyRenderCount).toBe(rendersAfterMount) + }) + it('goes inert while the palette is closed', async () => { setAgentState('working') await render(false) @@ -162,7 +193,200 @@ describe('palette live status', () => { expect(dotLabels()).toEqual([]) }) - it('resolves a live dot for a recent row backed by a terminal', async () => { + it('badges only working and permission — not quiet active — on terminal-backed rows', async () => { + // Live PTY, no agent activity → active, but quiet chats stay icon-only (no emerald pip). + await act(async () => { + testRoot.render( + + } + /> + + ) + }) + expect(testContainer.querySelector('[data-fallback]')).not.toBeNull() + expect(dotLabels()).toEqual([]) + expect(testContainer.querySelector('[data-spinner]')).toBeNull() + + await act(async () => { + setAgentState('working') + }) + // Why: A2 — category identity stays on the content icon; only high-signal agent states get a pip. + expect(testContainer.querySelector('[data-fallback]')).not.toBeNull() + expect(testContainer.querySelector('[data-spinner]')).not.toBeNull() + expect(dotLabels()).toEqual(['Working']) + // Hover tooltip on the outer hit target (badge is pointer-events-none). + expect(testContainer.querySelector('[title="Working"]')).not.toBeNull() + + await act(async () => { + setAgentState('blocked') + }) + expect(testContainer.querySelector('[data-fallback]')).not.toBeNull() + expect(testContainer.querySelector('[data-spinner]')).toBeNull() + expect(dotLabels()).toEqual(['Needs permission']) + expect(testContainer.querySelector('[title="Needs permission"]')).not.toBeNull() + }) + + it('shows only the content icon when a terminal-backed row is inactive', async () => { + useAppStore.setState({ + ptyIdsByTabId: {} + } as Partial) + await act(async () => { + testRoot.render( + + } + /> + + ) + }) + expect(testContainer.querySelector('[data-fallback]')).not.toBeNull() + expect(dotLabels()).toEqual([]) + expect(testContainer.querySelector('[data-spinner]')).toBeNull() + }) + + it('badges unread agent completion when the agent is quiet', async () => { + useAppStore.setState({ + unreadAgentCompletionPanes: { + [makePaneKey('term-a', LEAF)]: true + } + } as Partial) + await act(async () => { + testRoot.render( + + } + /> + + ) + }) + expect(testContainer.querySelector('[data-fallback]')).not.toBeNull() + expect(testContainer.querySelector('[data-spinner]')).toBeNull() + expect(dotLabels()).toEqual(['Unread agent completion']) + expect(testContainer.querySelector('[title="Unread agent completion"]')).not.toBeNull() + }) + + it('prefers working over unread on the same row', async () => { + setAgentState('working') + useAppStore.setState({ + unreadTerminalTabs: { 'term-a': true } + } as Partial) + await act(async () => { + testRoot.render( + + } + /> + + ) + }) + expect(testContainer.querySelector('[data-spinner]')).not.toBeNull() + expect(dotLabels()).toEqual(['Working']) + }) + + it('badges freshly done with a check when quiet and not unread', async () => { + setAgentState('done') + await act(async () => { + testRoot.render( + + } + /> + + ) + }) + expect(testContainer.querySelector('[data-fallback]')).not.toBeNull() + expect(dotLabels()).toEqual(['Done']) + expect(testContainer.querySelector('[title="Done"]')).not.toBeNull() + // lucide CircleCheck class marker + expect(testContainer.innerHTML).toContain('lucide-circle-check') + }) + + it('prefers unread over freshly done on the same row', async () => { + setAgentState('done') + useAppStore.setState({ + unreadTerminalTabs: { 'term-a': true } + } as Partial) + await act(async () => { + testRoot.render( + + } + /> + + ) + }) + expect(dotLabels()).toEqual(['Unread agent completion']) + expect(testContainer.innerHTML).not.toContain('lucide-circle-check') + }) + + it('prefers permission over unread on the same row', async () => { + setAgentState('blocked') + useAppStore.setState({ + unreadTerminalTabs: { 'term-a': true } + } as Partial) + await act(async () => { + testRoot.render( + + } + /> + + ) + }) + expect(dotLabels()).toEqual(['Needs permission']) + }) + + it('cuts the pip out of the dialog surface, and out of accent when selected', async () => { setAgentState('working') await act(async () => { testRoot.render( @@ -180,11 +404,14 @@ describe('palette live status', () => { ) }) - expect(dotLabels()).toEqual(['Working']) - - await act(async () => { - setAgentState('blocked') - }) - expect(dotLabels()).toEqual(['Needs permission']) + const pip = testContainer.querySelector('[aria-hidden="true"].rounded-full') + expect(pip).not.toBeNull() + // Why popover and not background: the CommandDialog surface is --popover (#171717 dark), while + // --background is the app canvas (#0a0a0a) — the mismatch punched a dark halo through each row. + expect(pip?.className).toContain('bg-popover') + expect(pip?.className).toContain('ring-popover') + expect(pip?.className).not.toContain('bg-background') + expect(pip?.className).toContain('group-data-[selected=true]:bg-accent') + expect(pip?.className).toContain('group-data-[selected=true]:ring-accent') }) }) diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx index 5e75a82bf..4dcfab7e2 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -1,11 +1,14 @@ import React, { createContext, useContext, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' +import { AgentStateDot } from '@/components/AgentStateDot' import StatusIndicator from '@/components/sidebar/StatusIndicator' +import { FilledBellIcon } from '@/components/sidebar/WorktreeCardHelpers' import { buildExplicitEntriesByTabId, type TabPaneInputSources } from '@/components/sidebar/smart-attention' +import { cn } from '@/lib/utils' import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state' import { getWorktreeStatus, @@ -16,6 +19,12 @@ import { resolveRecentWorkspaceTabStatus, type RecentWorkspaceTabRow } from '@/lib/recent-workspace-tab-rows' +import { + resolveTerminalTabAttentionBadge, + terminalTabHasUnreadActivity, + type TerminalTabAttentionBadge +} from '@/components/tab-bar/terminal-tab-activity-status' +import { translate } from '@/i18n/i18n' import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state' import type { BrowserWorkspace, TerminalTab, Worktree } from '../../../../shared/types' @@ -25,6 +34,8 @@ type PaletteLiveStatus = { paneSources: TabPaneInputSources tabsByWorktree: Record browserTabsByWorktree: Record + unreadTerminalTabs: Record + unreadAgentCompletionPanes: Record /** Bumped with the maps so consumers re-resolve `now`-sensitive freshness on the same tick. */ statusEpoch: number } @@ -46,7 +57,9 @@ export function PaletteLiveStatusProvider({ terminalLayoutsByTabId, tabsByWorktree, browserTabsByWorktree, - migrationUnsupportedByPtyId + migrationUnsupportedByPtyId, + unreadTerminalTabs, + unreadAgentCompletionPanes } = useAppStore( useShallow((s) => active @@ -57,7 +70,9 @@ export function PaletteLiveStatusProvider({ terminalLayoutsByTabId: s.terminalLayoutsByTabId, tabsByWorktree: s.tabsByWorktree, browserTabsByWorktree: s.browserTabsByWorktree, - migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId + migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId, + unreadTerminalTabs: s.unreadTerminalTabs, + unreadAgentCompletionPanes: s.unreadAgentCompletionPanes } : EMPTY_LIVE_INPUTS ) @@ -85,6 +100,8 @@ export function PaletteLiveStatusProvider({ }, tabsByWorktree, browserTabsByWorktree, + unreadTerminalTabs, + unreadAgentCompletionPanes, statusEpoch } }, [ @@ -95,7 +112,9 @@ export function PaletteLiveStatusProvider({ runtimePaneTitlesByTabId, statusEpoch, tabsByWorktree, - terminalLayoutsByTabId + terminalLayoutsByTabId, + unreadAgentCompletionPanes, + unreadTerminalTabs ]) return ( @@ -110,7 +129,9 @@ const EMPTY_LIVE_INPUTS = Object.freeze({ terminalLayoutsByTabId: {}, tabsByWorktree: {}, browserTabsByWorktree: {}, - migrationUnsupportedByPtyId: {} + migrationUnsupportedByPtyId: {}, + unreadTerminalTabs: {}, + unreadAgentCompletionPanes: {} }) function useLiveStatus(): PaletteLiveStatus | null { @@ -146,8 +167,8 @@ export function PaletteWorktreeStatusDot({ } /** - * Live dot for a recent chat/terminal row, falling back to the row's content icon when the row has - * no agent-bearing terminal behind it. + * Leading slot for a recent chat/terminal row: content icon + shared attention badge + * (resolveTerminalTabAttentionBadge — same ladder as the tab strip). */ export function PaletteRecentTabStatusDot({ row, @@ -157,17 +178,67 @@ export function PaletteRecentTabStatusDot({ fallback: React.ReactNode }): React.JSX.Element { const live = useLiveStatus() + const terminalTabId = row?.terminalTab?.id const status: WorktreeStatus | null = live && row?.terminalTab ? resolveRecentWorkspaceTabStatus(row, live.paneSources, Date.now()) : null - if (!status) { + const hasUnread = + live != null && + terminalTabId != null && + terminalTabHasUnreadActivity({ + terminalTabId, + unreadTerminalTabs: live.unreadTerminalTabs, + unreadAgentCompletionPanes: live.unreadAgentCompletionPanes + }) + const badge = resolveTerminalTabAttentionBadge({ status, hasUnread }) + if (badge == null) { return <>{fallback} } + const statusLabel = + badge === 'unread' + ? // Why the tab-bar key: same bell, same sentence — a fresh key here would ship untranslated + // in every non-English locale for the sake of a namespace. + translate( + 'auto.components.tab.bar.TerminalTabLeadingIcon.7ab2964bea', + 'Unread agent completion' + ) + : getWorktreeStatusLabel(badge) + // Why: title on the outer hit target (not the pointer-events-none pip) so hover still reveals + // status — matches StatusIndicator's tooltip placement. return ( - <> -