Add type-alias search and fix multi-primary palette ordering

Add searchable type aliases (e.g. "terminal tab", "mobile emulator") so users can find items by type without cluttering the row display. Refactor multi-primary palette layout into orderMultiPrimaryPaletteItems to prevent selection/render order drift, simplify selectableItems derivation, and track trailing hard-overflow count separately from scrollable rest.
This commit is contained in:
Jinjing
2026-08-07 21:28:09 -07:00
parent deda156a2f
commit 486f5d34be
10 changed files with 181 additions and 72 deletions
@@ -1391,55 +1391,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
openTabsLeadSections
])
const selectableItems = useMemo<PaletteItem[]>(() => {
const {
visibleOpenTabItems,
visibleWorktreeItems,
visibleProjectTargetItems,
visibleMiddleItems,
multiPrimaryFirstScreen,
multiPrimaryLayout
} = paletteSections
// Why: selection order must mirror render order, including the soft-split interleave.
if (multiPrimaryFirstScreen && multiPrimaryLayout) {
const leadingRest = multiPrimaryLayout.leadingRest as PaletteItem[]
const trailingRest = multiPrimaryLayout.trailingRest as PaletteItem[]
if (openTabsLeadSections) {
return [
...(multiPrimaryLayout.leadingPreview as PaletteItem[]),
...(multiPrimaryLayout.trailingFloor as PaletteItem[]),
...leadingRest,
...trailingRest,
...visibleProjectTargetItems,
...visibleMiddleItems
]
}
return [
...(multiPrimaryLayout.leadingPreview as PaletteItem[]),
...(multiPrimaryLayout.trailingFloor as PaletteItem[]),
...leadingRest,
...trailingRest,
...visibleProjectTargetItems,
...visibleMiddleItems
]
}
return openTabsLeadSections
? [
...visibleOpenTabItems,
...visibleWorktreeItems,
...visibleProjectTargetItems,
...visibleMiddleItems
]
: [
...visibleWorktreeItems,
...visibleProjectTargetItems,
...visibleMiddleItems,
...visibleOpenTabItems
]
}, [openTabsLeadSections, paletteSections])
// Why: badges number the snapshotted recent rows only — ⌘N is meaningless on a typed query.
const recentTabShortcutIndexById = useMemo(
() =>
@@ -1609,26 +1560,33 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
// Typed query with both open tabs and worktrees: soft-split so the trailing
// primary is not buried under ~50 leading rows (see tmp/cmd-j-recommended.html).
if (multiPrimaryFirstScreen && multiPrimaryLayout) {
const { leadingPreview, leadingRest, leadingMoreCount, trailingFloor, trailingRest } =
multiPrimaryLayout
const leadingHintId = openTabsLeadSections
? '__hint_open_tab_overflow__'
: '__hint_worktree_overflow__'
const trailingHintId = openTabsLeadSections
? '__hint_worktree_overflow__'
: '__hint_open_tab_overflow__'
if (openTabsLeadSections) {
pushOpenTabsHeader()
appendPaletteListEntries(entries, leadingPreview as PaletteItem[])
pushOverflowHint('__hint_open_tab_overflow__', leadingMoreCount)
pushWorktreesHeader()
appendPaletteListEntries(entries, trailingFloor as PaletteItem[])
appendPaletteListEntries(entries, leadingRest as PaletteItem[])
appendPaletteListEntries(entries, trailingRest as PaletteItem[])
} else {
pushWorktreesHeader()
appendPaletteListEntries(entries, leadingPreview as PaletteItem[])
pushOverflowHint('__hint_worktree_overflow__', leadingMoreCount)
pushOpenTabsHeader()
appendPaletteListEntries(entries, trailingFloor as PaletteItem[])
appendPaletteListEntries(entries, leadingRest as PaletteItem[])
appendPaletteListEntries(entries, trailingRest as PaletteItem[])
}
appendPaletteListEntries(entries, multiPrimaryLayout.leadingPreview as PaletteItem[])
// Soft more for the leading section (scrollable rest + hard-cap tail).
pushOverflowHint(leadingHintId, multiPrimaryLayout.leadingMoreCount)
if (openTabsLeadSections) {
pushWorktreesHeader()
} else {
pushOpenTabsHeader()
}
// Floor first, then remaining leading rows, then trailing rest — same order
// as orderMultiPrimaryPaletteItems / keyboard selection.
appendPaletteListEntries(entries, multiPrimaryLayout.trailingFloor as PaletteItem[])
appendPaletteListEntries(entries, multiPrimaryLayout.leadingRest as PaletteItem[])
appendPaletteListEntries(entries, multiPrimaryLayout.trailingRest as PaletteItem[])
// Trailing rest is already on screen; only hard-cap overflow needs a hint.
pushOverflowHint(trailingHintId, multiPrimaryLayout.trailingHardOverflowCount)
pushProjectAndMiddleSections()
if (showCreateAction) {
entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' })
@@ -1652,6 +1610,19 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
return entries
}, [hasQuery, openTabsLeadSections, paletteSections, showCreateAction, worktreeItems.length])
// Why derive from listEntries: multi-primary interleave must stay identical for
// empty-state counts and keyboard selection — dual-path builders drifted before.
const selectableItems = useMemo<PaletteItem[]>(
() =>
listEntries.filter(
(entry): entry is PaletteItem =>
entry.type !== 'section-header' &&
entry.type !== 'hint' &&
entry.type !== 'create-worktree'
),
[listEntries]
)
const selectionItemIds = useMemo(
() => getWorktreePaletteSelectionItemIds(listEntries),
[listEntries]
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
capPaletteSection,
layoutMultiPrimaryPaletteSections,
orderMultiPrimaryPaletteItems,
PALETTE_SECTION_RENDER_CAP,
softSplitPaletteSection,
TYPED_QUERY_LEADING_PREVIEW,
@@ -86,6 +87,7 @@ describe('layoutMultiPrimaryPaletteSections', () => {
expect(layout.trailingRest).toEqual([103, 104])
expect(layout.leadingRest).toEqual([6, 7, 8, 9, 10, 11])
expect(layout.trailingMoreCount).toBe(2)
expect(layout.trailingHardOverflowCount).toBe(0)
})
it('shows all trailing rows in the floor when the section is small', () => {
@@ -97,5 +99,33 @@ describe('layoutMultiPrimaryPaletteSections', () => {
expect(layout.trailingFloor).toEqual([200, 201])
expect(layout.trailingRest).toEqual([])
expect(layout.trailingMoreCount).toBe(0)
expect(layout.trailingHardOverflowCount).toBe(0)
})
it('reports trailing hard-cap overflow without counting scrollable rest', () => {
const layout = layoutMultiPrimaryPaletteSections({
leadingItems: range(12),
trailingItems: range(80).map((n) => n + 1000)
})
expect(layout.trailingFloor).toHaveLength(TYPED_QUERY_TRAILING_FLOOR)
expect(layout.trailingRest).toHaveLength(
PALETTE_SECTION_RENDER_CAP - TYPED_QUERY_TRAILING_FLOOR
)
expect(layout.trailingMoreCount).toBe(80 - TYPED_QUERY_TRAILING_FLOOR)
expect(layout.trailingHardOverflowCount).toBe(80 - PALETTE_SECTION_RENDER_CAP)
})
})
describe('orderMultiPrimaryPaletteItems', () => {
it('interleaves floor before leading rest', () => {
const layout = layoutMultiPrimaryPaletteSections({
leadingItems: range(8),
trailingItems: range(5).map((n) => n + 100)
})
expect(orderMultiPrimaryPaletteItems(layout)).toEqual([
0, 1, 2, 3, 4, 5, 100, 101, 102, 6, 7, 103, 104
])
})
})
@@ -63,6 +63,10 @@ export function softSplitPaletteSection<T>(
* Leading section gets a soft preview; trailing primary gets an early floor so
* it is not buried under ~50 leading rows. Remaining rows of both sections
* follow (still under the hard cap). Projects/middle stay after both primaries.
*
* `leadingMoreCount` is the mid-list soft hint (rest + hard-cap overflow).
* `trailingHardOverflowCount` is only rows past the hard cap — trailing rest is
* already rendered, so a soft “more” would double-count scrollable rows.
*/
export type MultiPrimarySectionLayout<T> = {
leadingPreview: readonly T[]
@@ -71,6 +75,7 @@ export type MultiPrimarySectionLayout<T> = {
trailingFloor: readonly T[]
trailingRest: readonly T[]
trailingMoreCount: number
trailingHardOverflowCount: number
}
export function layoutMultiPrimaryPaletteSections<T>({
@@ -94,6 +99,21 @@ export function layoutMultiPrimaryPaletteSections<T>({
leadingMoreCount: leading.moreCount,
trailingFloor: trailing.preview,
trailingRest: trailing.rest,
trailingMoreCount: trailing.moreCount
trailingMoreCount: trailing.moreCount,
// Why: floor + rest already cover every rendered trailing row; only the
// hard-capped tail needs a “keep typing” hint after the section.
trailingHardOverflowCount: Math.max(0, trailing.moreCount - trailing.rest.length)
}
}
/** Selection/render order for the two interleaved primary sections. */
export function orderMultiPrimaryPaletteItems<T>(
layout: MultiPrimarySectionLayout<T>
): readonly T[] {
return [
...layout.leadingPreview,
...layout.trailingFloor,
...layout.leadingRest,
...layout.trailingRest
]
}
@@ -219,4 +219,17 @@ describe('getOpenTabMatchRelevance', () => {
)
expect(secondary).toBeLessThan(workspace)
})
it('ranks search-only type aliases as secondary-tier hits', () => {
const typeAlias = getOpenTabMatchRelevance(
makeOpenTab({
typeAliasMatch: { text: 'terminal tab', range: { start: 0, end: 8 } }
})
)
const titlePrefix = getOpenTabMatchRelevance(makeOpenTab({ titleRange: { start: 0, end: 4 } }))
const ambient = getOpenTabMatchRelevance(makeOpenTab({ worktreeRange: { start: 0, end: 4 } }))
expect(typeAlias).toBeLessThan(NO_MATCH_RELEVANCE)
expect(titlePrefix).toBeLessThan(typeAlias)
expect(typeAlias).toBeLessThan(ambient)
})
})
@@ -81,12 +81,23 @@ export type OpenTabRelevanceInput = {
repoRange: MatchRange | null
workspaceLabel?: string | null
workspaceRange?: MatchRange | null
/**
* Search-only type label match (e.g. "terminal tab" / "mobile emulator").
* Not rendered on the row — still needs a relevance field so section leadership
* and open-tab sort don't treat the hit as unmatched.
*/
typeAliasMatch?: { text: string; range: MatchRange } | null
}
export function getOpenTabMatchRelevance(result: OpenTabRelevanceInput): number {
return scorePaletteRelevance([
{ text: result.title, range: result.titleRange, tier: 0 },
{ text: result.secondaryText, range: result.secondaryRange, tier: 1 },
{
text: result.typeAliasMatch?.text ?? '',
range: result.typeAliasMatch?.range ?? null,
tier: 1
},
{
text: result.workspaceLabel ?? '',
range: result.workspaceRange ?? null,
@@ -109,9 +109,13 @@ describe('simulator-palette-search', () => {
]
// Why no secondaryRange: type aliases match without a display secondary.
expect(searchSimulatorTabs(entries, 'mobile')).toHaveLength(1)
expect(searchSimulatorTabs(entries, 'mobile')[0]?.secondaryText).toBe('')
expect(searchSimulatorTabs(entries, 'mobile')[0]?.secondaryRange).toBeNull()
const mobileHit = searchSimulatorTabs(entries, 'mobile')[0]
expect(mobileHit?.secondaryText).toBe('')
expect(mobileHit?.secondaryRange).toBeNull()
expect(mobileHit?.typeAliasMatch).toEqual({
text: 'mobile emulator tab',
range: { start: 0, end: 6 }
})
expect(searchSimulatorTabs(entries, 'simulator')).toHaveLength(1)
expect(searchSimulatorTabs(entries, 'ios')).toHaveLength(1)
expect(searchSimulatorTabs(entries, 'emulator')).toHaveLength(1)
@@ -24,6 +24,7 @@ export type SimulatorPaletteSearchResult = {
secondaryRange: MatchRange | null
repoRange: MatchRange | null
worktreeRange: MatchRange | null
typeAliasMatch?: { text: string; range: MatchRange } | null
isCurrentTab: boolean
isCurrentWorktree: boolean
score: number
@@ -238,14 +239,15 @@ export function searchSimulatorTabs(
continue
}
let typeAliasMatch: MatchRange | null = null
let typeAliasHit: { text: string; range: MatchRange } | null = null
for (const alias of SIMULATOR_TYPE_SEARCH_ALIASES) {
typeAliasMatch = findRange(alias, trimmedQuery)
if (typeAliasMatch) {
const range = findRange(alias, trimmedQuery)
if (range) {
typeAliasHit = { text: alias, range }
break
}
}
if (typeAliasMatch) {
if (typeAliasHit) {
results.push({
...baseResult,
titleRange: null,
@@ -253,9 +255,10 @@ export function searchSimulatorTabs(
secondaryRange: null,
repoRange: null,
worktreeRange: null,
typeAliasMatch: typeAliasHit,
score: scoreSimulatorTabMatch({
fieldWeight: 20,
matchIndex: typeAliasMatch.start,
matchIndex: typeAliasHit.range.start,
entry
})
})
@@ -19,6 +19,7 @@ export type WorkspaceTabPaletteSearchResult = {
secondaryRange: MatchRange | null
repoRange: MatchRange | null
worktreeRange: MatchRange | null
typeAliasMatch?: { text: string; range: MatchRange } | null
isCurrentTab: boolean
isCurrentWorktree: boolean
score: number
@@ -185,6 +186,33 @@ export function searchWorkspaceTabs(
continue
}
// Why after display secondaries: path/file matches should beat bare type labels.
let typeAliasHit: { text: string; range: MatchRange } | null = null
for (const alias of entry.typeSearchAliases ?? []) {
const range = findRange(alias, trimmedQuery)
if (range) {
typeAliasHit = { text: alias, range }
break
}
}
if (typeAliasHit) {
results.push({
...baseResult,
titleRange: null,
// Why null: aliases are search keys only — nothing to highlight in the row.
secondaryRange: null,
repoRange: null,
worktreeRange: null,
typeAliasMatch: typeAliasHit,
score: scoreWorkspaceTabMatch({
fieldWeight: 25,
matchIndex: typeAliasHit.range.start,
entry
})
})
continue
}
const agentMatch = getBestAgentSnippet(entry, trimmedQuery)
if (agentMatch) {
results.push({
@@ -502,4 +502,23 @@ describe('workspace-tab-palette-search', () => {
})
expect(entries[0]?.secondarySearchTexts).toEqual([])
})
it('still finds terminals via type aliases without showing a secondary', () => {
const entries = buildEntries()
// Title is agent-named so the hit has to come from the type alias, not the title.
const renamed = entries.map((entry, index) =>
index === 0 ? { ...entry, title: 'Fix login race', titleSearchText: 'Fix login race' } : entry
)
const hit = searchWorkspaceTabs(renamed, 'terminal')[0]
expect(hit).toMatchObject({
contentType: 'terminal',
title: 'Fix login race',
secondaryText: '',
secondaryRange: null,
typeAliasMatch: {
text: 'terminal tab',
range: { start: 0, end: 8 }
}
})
})
})
@@ -33,11 +33,20 @@ export type SearchableWorkspaceTab = {
secondaryText: string
titleSearchText: string
secondarySearchTexts: string[]
/**
* Search-only type labels (e.g. "terminal tab"). Matched without writing into
* the row secondary — the content icon already conveys type.
*/
typeSearchAliases?: readonly string[]
agentMetadata: AgentMetadata[]
isCurrentTab: boolean
isCurrentWorktree: boolean
}
// Why search-only: the status/content icon already says "terminal"; a fixed
// secondary crowds the row. Keep these matchable so typing "terminal" still finds them.
export const TERMINAL_TYPE_SEARCH_ALIASES = ['terminal tab', 'terminal'] as const
type WorkspaceTabPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simulator'
export type BuildSearchableWorkspaceTabsOptions = WorkspaceTabAgentMetadataState & {
@@ -226,6 +235,7 @@ export function buildSearchableWorkspaceTabs({
secondaryText: '',
titleSearchText: title,
secondarySearchTexts: [],
typeSearchAliases: TERMINAL_TYPE_SEARCH_ALIASES,
agentMetadata: collectAgentMetadataForTerminal({
terminalTabId: tab.entityId,
worktreeId: worktree.id,