Exclude idle current tabs from Cmd+J recent; add live attention badges (#13299)

* 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 082cc31703)
This commit is contained in:
Jinjing 2026-08-09 12:18:14 -07:00
parent 5b377a9c0f
commit 8bd61feb97
12 changed files with 1687 additions and 693 deletions

View File

@ -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<typeof ReactI18Next>()
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: () => <span data-status-indicator="true" />
}))
vi.mock('@/components/repo/RepoBadgeLabel', () => ({
RepoBadgeMark: () => <span data-repo-badge-mark="true" />
}))
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 ? (
<div data-command-dialog="true" data-command-value={commandProps?.value ?? ''}>
{children}
</div>
) : null
},
CommandInput: ({
value,
onValueChange,
placeholder
}: {
value?: string
onValueChange?: (next: string) => void
placeholder?: string
}) => {
setCommandQuery = onValueChange ?? null
return (
<input
data-command-input="true"
placeholder={placeholder}
value={value}
onChange={(event) => onValueChange?.(event.currentTarget.value)}
/>
)
},
CommandList: React.forwardRef(function CommandList(
{ children }: { children: React.ReactNode },
ref: React.ForwardedRef<HTMLDivElement>
) {
return (
<div ref={ref} data-command-list="true">
{children}
</div>
)
}),
CommandEmpty: ({ children }: { children: React.ReactNode }) => (
<div data-command-empty="true">{children}</div>
),
CommandItem: ({
children,
onSelect,
value
}: {
children: React.ReactNode
onSelect?: (value: string) => void
value?: string
}) => (
<button data-command-item={value ?? ''} onClick={() => onSelect?.(value ?? '')} type="button">
{children}
</button>
)
}
})
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<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
async function renderPalette(overrides: Partial<AppState>): Promise<void> {
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<AppState>)
await act(async () => {
testRoot.render(<WorktreeJumpPalette />)
})
await flushEffects()
}
function getWorktreeRows(): string[] {
return [...testContainer.querySelectorAll<HTMLElement>('[data-command-item^="worktree:"]')].map(
(node) => node.textContent ?? ''
)
}
function getRenderedRowIds(): string[] {
return [...testContainer.querySelectorAll<HTMLElement>('[data-command-item]')].map(
(node) => node.dataset.commandItem ?? ''
)
}
/** The id cmdk would activate on Enter. */
function getCommandValue(): string {
return (
testContainer.querySelector<HTMLElement>('[data-command-dialog]')?.dataset.commandValue ?? ''
)
}
function getTabRowIds(): string[] {
return [...testContainer.querySelectorAll<HTMLElement>('[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<AppState>)
})
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<AppState> {
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<AppState>)
})
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<AppState>)
})
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<AppState>)
})
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<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({
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<AppState>)
})
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<AppState>)
})
await flushEffects()
await act(async () => {
useAppStore.setState({
activeModal: 'worktree-palette'
} as Partial<AppState>)
})
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<AppState>)
})
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 ⌘16. 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<AppState>)
})
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<AppState>)
})
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')
})
})

View File

@ -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<typeof ReactI18Next>()
@ -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 ? (
<div data-command-dialog="true" data-command-value={commandProps?.value ?? ''}>
{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> = {}
): 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<void> {
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<AppState> = {}): Partial<AppState> {
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<AppState> {
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<HTMLElement>('[data-command-item]')].map(
(node) => node.dataset.commandItem ?? ''
)
}
/** The id cmdk would activate on Enter. */
function getCommandValue(): string {
return (
testContainer.querySelector<HTMLElement>('[data-command-dialog]')?.dataset.commandValue ?? ''
)
}
function getTabRowIds(): string[] {
return [...testContainer.querySelectorAll<HTMLElement>('[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<AppState>)
})
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<AppState> {
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<AppState>)
})
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<AppState>)
})
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<AppState>)
})
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<AppState>)
})
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<AppState>)
})
await flushEffects()
await act(async () => {
useAppStore.setState({
activeModal: 'worktree-palette'
} as Partial<AppState>)
})
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')
})
})

View File

@ -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<string, true | boolean | undefined>
unreadAgentCompletionPanes: Record<string, true | boolean | undefined>
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<RecentWorkspaceTabRow[]>(() => {
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<OpenTabRecentRow[]>(() => {
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<RecentWorkspaceTabRow[]>(() => {
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<readonly string[]>(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 ⌘16 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 (

View File

@ -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(
<PaletteLiveStatusProvider active>
<FrozenBody>
<PaletteRecentTabStatusDot
row={{
id: 'workspace-tab:tab-a',
worktreeId: 'wt-a',
unifiedTabId: 'tab-a',
terminalTab: { id: 'term-a', title: 'Chat' },
worktreeLastActivityAt: 0
}}
fallback={<span data-fallback="true" />}
/>
</FrozenBody>
</PaletteLiveStatusProvider>
)
})
const rendersAfterMount = bodyRenderCount
await act(async () => {
useAppStore.setState({ unreadTerminalTabs: { 'term-a': true } } as Partial<AppState>)
})
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(
<PaletteLiveStatusProvider active>
<PaletteRecentTabStatusDot
row={{
id: 'workspace-tab:tab-a',
worktreeId: 'wt-a',
unifiedTabId: 'tab-a',
terminalTab: { id: 'term-a', title: 'Chat' },
worktreeLastActivityAt: 0
}}
fallback={<span data-fallback="true" />}
/>
</PaletteLiveStatusProvider>
)
})
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<AppState>)
await act(async () => {
testRoot.render(
<PaletteLiveStatusProvider active>
<PaletteRecentTabStatusDot
row={{
id: 'workspace-tab:tab-a',
worktreeId: 'wt-a',
unifiedTabId: 'tab-a',
terminalTab: { id: 'term-a', title: 'Chat' },
worktreeLastActivityAt: 0
}}
fallback={<span data-fallback="true" />}
/>
</PaletteLiveStatusProvider>
)
})
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<AppState>)
await act(async () => {
testRoot.render(
<PaletteLiveStatusProvider active>
<PaletteRecentTabStatusDot
row={{
id: 'workspace-tab:tab-a',
worktreeId: 'wt-a',
unifiedTabId: 'tab-a',
terminalTab: { id: 'term-a', title: 'Chat' },
worktreeLastActivityAt: 0
}}
fallback={<span data-fallback="true" />}
/>
</PaletteLiveStatusProvider>
)
})
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<AppState>)
await act(async () => {
testRoot.render(
<PaletteLiveStatusProvider active>
<PaletteRecentTabStatusDot
row={{
id: 'workspace-tab:tab-a',
worktreeId: 'wt-a',
unifiedTabId: 'tab-a',
terminalTab: { id: 'term-a', title: 'Chat' },
worktreeLastActivityAt: 0
}}
fallback={<span data-fallback="true" />}
/>
</PaletteLiveStatusProvider>
)
})
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(
<PaletteLiveStatusProvider active>
<PaletteRecentTabStatusDot
row={{
id: 'workspace-tab:tab-a',
worktreeId: 'wt-a',
unifiedTabId: 'tab-a',
terminalTab: { id: 'term-a', title: 'Chat' },
worktreeLastActivityAt: 0
}}
fallback={<span data-fallback="true" />}
/>
</PaletteLiveStatusProvider>
)
})
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<AppState>)
await act(async () => {
testRoot.render(
<PaletteLiveStatusProvider active>
<PaletteRecentTabStatusDot
row={{
id: 'workspace-tab:tab-a',
worktreeId: 'wt-a',
unifiedTabId: 'tab-a',
terminalTab: { id: 'term-a', title: 'Chat' },
worktreeLastActivityAt: 0
}}
fallback={<span data-fallback="true" />}
/>
</PaletteLiveStatusProvider>
)
})
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<AppState>)
await act(async () => {
testRoot.render(
<PaletteLiveStatusProvider active>
<PaletteRecentTabStatusDot
row={{
id: 'workspace-tab:tab-a',
worktreeId: 'wt-a',
unifiedTabId: 'tab-a',
terminalTab: { id: 'term-a', title: 'Chat' },
worktreeLastActivityAt: 0
}}
fallback={<span data-fallback="true" />}
/>
</PaletteLiveStatusProvider>
)
})
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', () => {
</PaletteLiveStatusProvider>
)
})
expect(dotLabels()).toEqual(['Working'])
await act(async () => {
setAgentState('blocked')
})
expect(dotLabels()).toEqual(['Needs permission'])
const pip = testContainer.querySelector<HTMLElement>('[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')
})
})

View File

@ -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<string, TerminalTab[]>
browserTabsByWorktree: Record<string, BrowserWorkspace[]>
unreadTerminalTabs: Record<string, true>
unreadAgentCompletionPanes: Record<string, true>
/** 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 (
<>
<StatusIndicator status={status} aria-hidden="true" />
<span className="sr-only">{getWorktreeStatusLabel(status)}</span>
</>
<span
className="relative inline-flex size-3.5 shrink-0 items-center justify-center"
title={statusLabel}
>
{fallback}
<span
className={cn(
// Why popover, not background: the dialog surface is --popover (#171717 in dark), while
// --background is the app canvas (#0a0a0a) — using it punched a dark halo through every
// dark-mode row. Selected rows swap to accent so the cutout stays invisible there too.
'pointer-events-none absolute -right-0.5 -bottom-0.5 flex items-center justify-center rounded-full',
'bg-popover ring-2 ring-popover',
'group-data-[selected=true]:bg-accent group-data-[selected=true]:ring-accent'
)}
aria-hidden="true"
>
<RecentTabAttentionBadgeGlyph badge={badge} />
</span>
<span className="sr-only">{statusLabel}</span>
</span>
)
}
/** Renders the shared attention glyph — AgentStateDot for agent states, bell for unread. */
function RecentTabAttentionBadgeGlyph({
badge
}: {
badge: TerminalTabAttentionBadge
}): React.JSX.Element {
if (badge === 'unread') {
return <FilledBellIcon className="size-2.5 text-amber-500 drop-shadow-sm" />
}
// Why: AgentStateDot owns working/permission/done glyphs app-wide (spinner / ? / check).
return <AgentStateDot state={badge} size="sm" />
}

View File

@ -24,9 +24,9 @@ import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon'
import {
hasUnreadAgentCompletionForTerminalTab,
isTerminalTabActivityLive,
resolveTerminalTabActivityStatus
resolveTerminalTabActivityStatus,
terminalTabHasUnreadActivity
} from './terminal-tab-activity-status'
type SortableTabProps = {
@ -88,10 +88,12 @@ export default function SortableTab({
onToggleViewMode
}: SortableTabProps): React.JSX.Element {
// Why: agent-completion unread exists even with terminal-attention off; collapse both sources to one primitive so unrelated tabs don't re-render.
const hasUnreadActivity = useAppStore(
(s) =>
s.unreadTerminalTabs[tab.id] === true ||
hasUnreadAgentCompletionForTerminalTab(s.unreadAgentCompletionPanes, tab.id)
const hasUnreadActivity = useAppStore((s) =>
terminalTabHasUnreadActivity({
terminalTabId: tab.id,
unreadTerminalTabs: s.unreadTerminalTabs,
unreadAgentCompletionPanes: s.unreadAgentCompletionPanes
})
)
// Why: resolver returns a primitive so unrelated agent updates can't repaint this tab (pane bucketing memoized per snapshot).
const activityStatus = useAppStore((s) =>

View File

@ -1,10 +1,13 @@
import { AgentStateDot, type AgentDotState } from '@/components/AgentStateDot'
import { AgentStateDot } from '@/components/AgentStateDot'
import { AgentIcon } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import type { TerminalTab, TuiAgent } from '../../../../shared/types'
import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers'
import { ShellIcon } from './shell-icons'
import type { TerminalTabActivityStatus } from './terminal-tab-activity-status'
import {
terminalTabActivityToAgentDotState,
type TerminalTabActivityStatus
} from './terminal-tab-activity-status'
import { translate } from '@/i18n/i18n'
type TerminalTabLeadingIconProps = {
@ -21,27 +24,6 @@ type TerminalTabAgentIdentityIconProps = {
className?: string
}
/**
* Map the container status to the shared state-dot vocabulary. `active` and
* `inactive` carry no activity glyph the tab falls through to its agent or
* shell identity icon instead. Uses the same WorktreeStatus vocabulary as the
* sidebar so live states read identically (tabs intentionally omit the card's
* retained-done promotion, so a stale green check can differ after cleanup).
*/
function activityDotState(status: TerminalTabActivityStatus): AgentDotState | null {
switch (status) {
case 'working':
return 'working'
case 'permission':
return 'permission'
case 'done':
return 'done'
case 'active':
case 'inactive':
return null
}
}
/** Keep the provider glyph treatment identical across every terminal-tab state. */
function TerminalTabAgentIdentityIcon({
agent,
@ -83,7 +65,9 @@ export function TerminalTabLeadingIcon({
)
}
const dotState = activityDotState(activityStatus)
// Why: shared mapper with Cmd+J recent badges — working/permission/done only; active/inactive
// fall through to agent/shell identity.
const dotState = terminalTabActivityToAgentDotState(activityStatus)
if (dotState) {
return (
<span

View File

@ -4,7 +4,10 @@ import type { TerminalTab } from '../../../../shared/types'
import {
hasUnreadAgentCompletionForTerminalTab,
resetTerminalTabActivityFlagsCacheForTest,
resolveTerminalTabActivityStatus
resolveTerminalTabActivityStatus,
resolveTerminalTabAttentionBadge,
terminalTabActivityToAgentDotState,
terminalTabHasUnreadActivity
} from './terminal-tab-activity-status'
const TAB_ID = 'tab-1'
@ -228,4 +231,52 @@ describe('hasUnreadAgentCompletionForTerminalTab', () => {
hasUnreadAgentCompletionForTerminalTab({ [`tab-2:${SECOND_LEAF_ID}`]: true }, TAB_ID)
).toBe(false)
})
// Why: the param accepts boolean maps, so a cleared-to-`false` marker must not read as unread.
it('ignores a falsy marker left on the owning tab', () => {
expect(
hasUnreadAgentCompletionForTerminalTab({ [`${TAB_ID}:${FIRST_LEAF_ID}`]: false }, TAB_ID)
).toBe(false)
})
})
describe('resolveTerminalTabAttentionBadge', () => {
it('prefers working, then permission, then unread, then done', () => {
expect(resolveTerminalTabAttentionBadge({ status: 'working', hasUnread: true })).toBe('working')
expect(resolveTerminalTabAttentionBadge({ status: 'permission', hasUnread: true })).toBe(
'permission'
)
expect(resolveTerminalTabAttentionBadge({ status: 'done', hasUnread: true })).toBe('unread')
expect(resolveTerminalTabAttentionBadge({ status: 'done', hasUnread: false })).toBe('done')
expect(resolveTerminalTabAttentionBadge({ status: 'active', hasUnread: false })).toBeNull()
})
})
describe('terminalTabHasUnreadActivity', () => {
it('is true for a tab bell or completion pane', () => {
expect(
terminalTabHasUnreadActivity({
terminalTabId: TAB_ID,
unreadTerminalTabs: { [TAB_ID]: true },
unreadAgentCompletionPanes: {}
})
).toBe(true)
expect(
terminalTabHasUnreadActivity({
terminalTabId: TAB_ID,
unreadTerminalTabs: {},
unreadAgentCompletionPanes: { [`${TAB_ID}:${FIRST_LEAF_ID}`]: true }
})
).toBe(true)
})
})
describe('terminalTabActivityToAgentDotState', () => {
it('maps glyph statuses and drops quiet ones', () => {
expect(terminalTabActivityToAgentDotState('working')).toBe('working')
expect(terminalTabActivityToAgentDotState('permission')).toBe('permission')
expect(terminalTabActivityToAgentDotState('done')).toBe('done')
expect(terminalTabActivityToAgentDotState('active')).toBeNull()
expect(terminalTabActivityToAgentDotState('inactive')).toBeNull()
})
})

View File

@ -169,12 +169,79 @@ export function isTerminalTabActivityLive(status: TerminalTabActivityStatus): bo
return status === 'working' || status === 'permission'
}
/**
* Glyph-bearing attention states for a terminal tab (tab bar + Cmd+J recent chats).
* Quiet active/inactive map to null so identity icons stay clean.
*/
export type TerminalTabAttentionBadge = 'working' | 'permission' | 'unread' | 'done'
/**
* Single priority ladder shared by the tab strip and Cmd+J recent rows:
* in-turn (working / permission) unread bell freshly done check.
*/
export function resolveTerminalTabAttentionBadge({
status,
hasUnread
}: {
status: WorktreeStatus | null | undefined
hasUnread: boolean
}): TerminalTabAttentionBadge | null {
if (status === 'working') {
return 'working'
}
if (status === 'permission') {
return 'permission'
}
if (hasUnread) {
return 'unread'
}
if (status === 'done') {
return 'done'
}
return null
}
/** Map a container activity status onto AgentStateDot's vocabulary (no unread — that's a bell). */
export function terminalTabActivityToAgentDotState(
status: TerminalTabActivityStatus
): 'working' | 'permission' | 'done' | null {
switch (status) {
case 'working':
case 'permission':
case 'done':
return status
case 'active':
case 'inactive':
return null
}
}
/** Bell or unacked agent completion — same sources the tab strip and floating launcher use. */
export function terminalTabHasUnreadActivity({
terminalTabId,
unreadTerminalTabs,
unreadAgentCompletionPanes
}: {
terminalTabId: string
unreadTerminalTabs: Record<string, true | boolean | undefined>
unreadAgentCompletionPanes: Record<string, true | boolean | undefined>
}): boolean {
return (
unreadTerminalTabs[terminalTabId] === true ||
hasUnreadAgentCompletionForTerminalTab(unreadAgentCompletionPanes, terminalTabId)
)
}
/** Match pane-level unread completion markers to their owning terminal tab. */
export function hasUnreadAgentCompletionForTerminalTab(
unreadAgentCompletionPanes: Record<string, true> | undefined,
unreadAgentCompletionPanes: Record<string, true | boolean | undefined> | undefined,
tabId: string
): boolean {
for (const paneKey of Object.keys(unreadAgentCompletionPanes ?? {})) {
for (const [paneKey, unread] of Object.entries(unreadAgentCompletionPanes ?? {})) {
// Why entries, not keys: the widened value type lets a cleared marker linger as `false`.
if (!unread) {
continue
}
// paneKey is `${tabId}:${leafId}` and tab ids never contain ":", so the
// prefix up to the first ":" is the owning tab id (see
// selectFloatingWorkspaceHasUnread). Prefix-match to keep legacy keys.

View File

@ -12,7 +12,9 @@ const BASE: PaletteStatusInputsState = {
runtimePaneTitlesByTabId: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {},
tabsByWorktree: {}
tabsByWorktree: {},
unreadTerminalTabs: {},
unreadAgentCompletionPanes: {}
}
describe('selectPaletteStatusInputs', () => {
@ -78,6 +80,29 @@ describe('selectPaletteIndexStatusSnapshot', () => {
expect(snapshot.agentStatusByPaneKey).toBe(statuses)
})
// Why here and not subscribed: recent-section membership reads these, and the row order is frozen
// on open — a live unread write would change membership the frozen order can no longer honour.
it('snapshots the unread maps alongside the status maps', () => {
const unreadTabs = { 'term-1': true } as const
const unreadPanes = { 'term-1:leaf-1': true } as const
const snapshot = selectPaletteIndexStatusSnapshot(
{ ...BASE, unreadTerminalTabs: unreadTabs, unreadAgentCompletionPanes: unreadPanes },
true
)
expect(snapshot.unreadTerminalTabs).toBe(unreadTabs)
expect(snapshot.unreadAgentCompletionPanes).toBe(unreadPanes)
})
it('keeps unread churn out of the subscribed bundle', () => {
const r1 = selectPaletteStatusInputs(BASE, true)
const churned: PaletteStatusInputsState = {
...BASE,
unreadTerminalTabs: { 'term-1': true },
unreadAgentCompletionPanes: { 'term-1:leaf-1': true }
}
expect(shallow(r1, selectPaletteStatusInputs(churned, true))).toBe(true)
})
it('drops its hold on the live maps once inactive', () => {
const titles = { 'tab-1': { 0: 'claude' } }
const inactive = selectPaletteIndexStatusSnapshot(

View File

@ -7,6 +7,8 @@ export type PaletteStatusInputsState = Pick<
| 'ptyIdsByTabId'
| 'terminalLayoutsByTabId'
| 'tabsByWorktree'
| 'unreadTerminalTabs'
| 'unreadAgentCompletionPanes'
>
export type PaletteStatusInputs = Pick<
@ -14,21 +16,27 @@ export type PaletteStatusInputs = Pick<
'ptyIdsByTabId' | 'terminalLayoutsByTabId' | 'tabsByWorktree'
>
/** The two hottest maps, read as a snapshot rather than subscribed. See `selectPaletteIndexStatusSnapshot`. */
/** The hottest maps, read as a snapshot rather than subscribed. See `selectPaletteIndexStatusSnapshot`. */
export type PaletteIndexStatusSnapshot = Pick<
PaletteStatusInputsState,
'agentStatusByPaneKey' | 'runtimePaneTitlesByTabId'
| 'agentStatusByPaneKey'
| 'runtimePaneTitlesByTabId'
| 'unreadTerminalTabs'
| 'unreadAgentCompletionPanes'
>
const EMPTY_PALETTE_INDEX_STATUS: PaletteIndexStatusSnapshot = Object.freeze({
agentStatusByPaneKey: {},
runtimePaneTitlesByTabId: {}
runtimePaneTitlesByTabId: {},
unreadTerminalTabs: {},
unreadAgentCompletionPanes: {}
})
/**
* The agent-status and pane-title maps as of *now*, for the palette's index, ordering and filters.
* Snapshotted rather than subscribed because the dots own that churn (`PaletteLiveStatusProvider`),
* leaving the index free to freeze on open.
* The agent-status, pane-title and unread maps as of *now*, for the palette's index, ordering,
* filters and recent-section membership. Snapshotted rather than subscribed because the dots own
* that churn (`PaletteLiveStatusProvider`), leaving the index free to freeze on open membership
* and row order then agree on one open-time reading instead of half-live, half-frozen.
*/
export function selectPaletteIndexStatusSnapshot(
s: PaletteStatusInputsState,
@ -39,7 +47,9 @@ export function selectPaletteIndexStatusSnapshot(
}
return {
agentStatusByPaneKey: s.agentStatusByPaneKey,
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId,
unreadTerminalTabs: s.unreadTerminalTabs,
unreadAgentCompletionPanes: s.unreadAgentCompletionPanes
}
}

View File

@ -0,0 +1,164 @@
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 type { AppState } from '@/store/types'
// Store fixtures shared by the Cmd+J palette suites (worktree list + recent chats & terminals).
export function makeRepo(): Repo {
return {
id: 'repo-1',
path: '/repos/repo-1',
displayName: 'Repo 1',
badgeColor: '#000000',
addedAt: 0
}
}
export function makeWorktree(
id: string,
displayName: string,
overrides: Partial<Worktree> = {}
): 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
}
}
export function makeTerminalTab(id: string, worktreeId: string, title: string): TerminalTab {
return {
id,
ptyId: `pty-${id}`,
worktreeId,
title,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}
export 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
}
}
export function makeGroup(worktreeId: string, tabIds: string[]): TabGroup {
return {
id: `group-${worktreeId}`,
worktreeId,
activeTabId: tabIds[0] ?? null,
tabOrder: tabIds,
recentTabIds: tabIds
}
}
export const LEAF_ID = '11111111-2222-4333-8444-555555555555'
export 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. */
export function makeRecentTabState(overrides: Partial<AppState> = {}): Partial<AppState> {
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. */
export function makeManyTabState(count: number): Partial<AppState> {
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' }
}
}