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:
parent
5b377a9c0f
commit
8bd61feb97
|
|
@ -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 ⌘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<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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 ⌘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 (
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue