Expand Cmd+J and interleave open tabs with worktrees on search (#13120)

* Expand Cmd+J palette and interleave tabs/worktrees on query

Increase palette dimensions (900x600) and remove redundant secondary
labels ("Terminal tab", "Mobile Emulator tab") that crowded rows. When
a typed query matches both open tabs and worktrees, use a soft-split
layout: leading section preview followed by trailing section floor so
neither primary is buried under ~50 rows. Trailing section no longer
truncates to hard cap when paired with a larger leading section.

* 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.

* fix(cmd-j): pin multi-primary layout generic for mixed item types

Typecheck failed because the ternary lead/trail arrays inferred a
WorktreePaletteItem[] | OpenTabPaletteItem[] union that could not
satisfy layoutMultiPrimaryPaletteSections' single T parameter.
This commit is contained in:
Jinjing
2026-08-07 21:32:40 -07:00
committed by GitHub
parent f968583e95
commit 0d29497f82
12 changed files with 624 additions and 226 deletions
@@ -130,7 +130,10 @@ import {
reconcilePaletteFilter,
type PaletteFilterState
} from '@/components/cmd-j/palette-filter'
import { capPaletteSection } from '@/components/cmd-j/palette-section-render-cap'
import {
capPaletteSection,
layoutMultiPrimaryPaletteSections
} from '@/components/cmd-j/palette-section-render-cap'
import { useSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata'
import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow'
import {
@@ -334,6 +337,55 @@ function HighlightedText({
)
}
function PaletteOpenTabPrimaryLine({
title,
titleRange,
secondaryText,
secondaryRange,
worktreeName,
worktreeRange,
leadingBadges
}: {
title: string
titleRange: MatchRange | null
secondaryText: string
secondaryRange: MatchRange | null
worktreeName: string
worktreeRange: MatchRange | null
leadingBadges?: React.ReactNode
}): React.JSX.Element {
// Why gate on non-empty: empty secondaries (terminals/simulators) used to still
// render two "·" separators, which read as a double mark and stole width from
// the worktree name until it collided with host/repo badges.
const showSecondary = secondaryText.trim().length > 0
const showWorktree = worktreeName.trim().length > 0
return (
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
<span className="min-w-0 max-w-[42%] shrink-0 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
<HighlightedText text={title} matchRange={titleRange} />
</span>
{leadingBadges}
{showSecondary ? (
<>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92">
<HighlightedText text={secondaryText} matchRange={secondaryRange} />
</span>
</>
) : null}
{showWorktree ? (
<>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92">
<HighlightedText text={worktreeName} matchRange={worktreeRange} />
</span>
</>
) : null}
</div>
)
}
function PaletteState({ title, subtitle }: { title: string; subtitle: string }): React.JSX.Element {
return (
<div className="px-5 py-8 text-center">
@@ -367,7 +419,9 @@ function PaletteHostBadgeChip({
'Host: {{value0}}',
{ value0: badge.label }
)}
className="max-w-[140px] truncate rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88"
// Why solid background: left-column text used to paint through translucent
// host chips when a long worktree name overflowed under the badge column.
className="max-w-[140px] truncate rounded-[6px] border border-border/60 bg-background px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88"
>
{badge.label}
</span>
@@ -1296,6 +1350,24 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
[actionResults, deferredQuery, quickActionContext, settingsResults]
)
// Why: both lists are relevance-sorted, so their heads carry each section's best hit. The stronger
// one leads; ties go to open tabs, matching the empty-query view and favouring a tab already open
// over a workspace the user would have to switch to.
const openTabsLeadSections = useMemo(() => {
if (!hasQuery) {
return true
}
const bestWorktree = worktreeItems[0]
const bestWorktreeRelevance = bestWorktree
? (worktreeRelevanceById.get(bestWorktree.worktree.id) ?? NO_MATCH_RELEVANCE)
: NO_MATCH_RELEVANCE
const bestOpenTab = openTabItems[0]
const bestOpenTabRelevance = bestOpenTab
? getOpenTabMatchRelevance(bestOpenTab.result)
: NO_MATCH_RELEVANCE
return bestOpenTabRelevance <= bestWorktreeRelevance
}, [hasQuery, openTabItems, worktreeItems, worktreeRelevanceById])
const paletteSections = useMemo(() => {
const openTabs = hasQuery
? capPaletteSection(openTabItems)
@@ -1318,6 +1390,16 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const projectTargets = capPaletteSection(hasQuery ? projectTargetItems : [])
const middle = capPaletteSection(hasQuery ? middleItems : [])
const showWorktreeHint = !hasQuery && worktreeItems.length > worktreeCap
// Why: only interleave when both primaries have hits — a lone section keeps the
// full hard-capped list with no floor (empty headers / wasted slots).
const multiPrimaryFirstScreen =
hasQuery && openTabs.visible.length > 0 && worktrees.visible.length > 0
const multiPrimaryLayout = multiPrimaryFirstScreen
? layoutMultiPrimaryPaletteSections<WorktreePaletteItem | OpenTabPaletteItem>({
leadingItems: openTabsLeadSections ? openTabItems : worktreeItems,
trailingItems: openTabsLeadSections ? worktreeItems : openTabItems
})
: null
return {
visibleWorktreeItems: worktrees.visible as PaletteItem[],
@@ -1328,46 +1410,19 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
middleOverflowCount: middle.overflowCount,
visibleOpenTabItems: openTabs.visible as PaletteItem[],
openTabOverflowCount: openTabs.overflowCount,
showWorktreeHint
showWorktreeHint,
multiPrimaryFirstScreen,
multiPrimaryLayout
}
}, [worktreeItems, projectTargetItems, middleItems, openTabItems, recentTabItems, hasQuery])
// Why: both lists are relevance-sorted, so their heads carry each section's best hit. The stronger
// one leads; ties go to open tabs, matching the empty-query view and favouring a tab already open
// over a workspace the user would have to switch to.
const openTabsLeadSections = useMemo(() => {
if (!hasQuery) {
return true
}
const bestWorktree = worktreeItems[0]
const bestWorktreeRelevance = bestWorktree
? (worktreeRelevanceById.get(bestWorktree.worktree.id) ?? NO_MATCH_RELEVANCE)
: NO_MATCH_RELEVANCE
const bestOpenTab = openTabItems[0]
const bestOpenTabRelevance = bestOpenTab
? getOpenTabMatchRelevance(bestOpenTab.result)
: NO_MATCH_RELEVANCE
return bestOpenTabRelevance <= bestWorktreeRelevance
}, [hasQuery, openTabItems, worktreeItems, worktreeRelevanceById])
const selectableItems = useMemo<PaletteItem[]>(
() =>
// Why: mirrors render order, which leads with whichever section holds the strongest match.
openTabsLeadSections
? [
...paletteSections.visibleOpenTabItems,
...paletteSections.visibleWorktreeItems,
...paletteSections.visibleProjectTargetItems,
...paletteSections.visibleMiddleItems
]
: [
...paletteSections.visibleWorktreeItems,
...paletteSections.visibleProjectTargetItems,
...paletteSections.visibleMiddleItems,
...paletteSections.visibleOpenTabItems
],
[openTabsLeadSections, paletteSections]
)
}, [
worktreeItems,
projectTargetItems,
middleItems,
openTabItems,
recentTabItems,
hasQuery,
openTabsLeadSections
])
// Why: badges number the snapshotted recent rows only — ⌘N is meaningless on a typed query.
const recentTabShortcutIndexById = useMemo(
@@ -1402,7 +1457,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
worktreeOverflowCount,
projectTargetOverflowCount,
middleOverflowCount,
openTabOverflowCount
openTabOverflowCount,
multiPrimaryFirstScreen,
multiPrimaryLayout
} = paletteSections
const pushOverflowHint = (id: string, overflowCount: number): void => {
if (overflowCount > 0) {
@@ -1411,7 +1468,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
type: 'hint',
label: translate(
'worktreeJumpPalette.renderCapOverflow',
'{{value0}} more - keep typing or add a filter to narrow',
'{{value0}} more — scroll or keep typing to narrow',
{ value0: overflowCount }
)
})
@@ -1437,22 +1494,43 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
hasQuery && visibleProjectTargetItems.length > 0 && populatedSectionCount > 1
const showMiddleHeader = hasQuery && visibleMiddleItems.length > 0 && populatedSectionCount > 1
const pushOpenTabsHeader = (): void => {
if (!showOpenTabsHeader) {
return
}
entries.push({
id: '__header_open_tabs__',
type: 'section-header',
label: hasQuery
? translate('auto.components.WorktreeJumpPalette.50a1d11d5b', 'Open Tabs')
: translate(
'auto.components.WorktreeJumpPalette.recentChatsTerminalsHeader',
'Recent Chats & Terminals'
)
})
}
const pushWorktreesHeader = (): void => {
if (!showWorktreeHeader) {
return
}
entries.push({
id: '__header_worktrees__',
type: 'section-header',
label: hasQuery
? translate('auto.components.WorktreeJumpPalette.worktreesHeader', 'Worktrees')
: translate(
'auto.components.WorktreeJumpPalette.recentWorktreesHeader',
'Recent Worktrees'
)
})
}
const pushWorktreeSection = (): void => {
if (visibleWorktreeItems.length === 0) {
return
}
if (showWorktreeHeader) {
entries.push({
id: '__header_worktrees__',
type: 'section-header',
label: hasQuery
? translate('auto.components.WorktreeJumpPalette.worktreesHeader', 'Worktrees')
: translate(
'auto.components.WorktreeJumpPalette.recentWorktreesHeader',
'Recent Worktrees'
)
})
}
pushWorktreesHeader()
appendPaletteListEntries(entries, visibleWorktreeItems)
if (showWorktreeHint) {
entries.push({
@@ -1472,22 +1550,39 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
if (visibleOpenTabItems.length === 0) {
return
}
if (showOpenTabsHeader) {
entries.push({
id: '__header_open_tabs__',
type: 'section-header',
label: hasQuery
? translate('auto.components.WorktreeJumpPalette.50a1d11d5b', 'Open Tabs')
: translate(
'auto.components.WorktreeJumpPalette.recentChatsTerminalsHeader',
'Recent Chats & Terminals'
)
})
}
pushOpenTabsHeader()
appendPaletteListEntries(entries, visibleOpenTabItems)
pushOverflowHint('__hint_open_tab_overflow__', openTabOverflowCount)
}
const pushProjectAndMiddleSections = (): void => {
if (visibleProjectTargetItems.length > 0) {
if (showProjectTargetHeader) {
entries.push({
id: '__header_projects_groups__',
type: 'section-header',
label: translate(
'auto.components.WorktreeJumpPalette.projectsGroupsHeader',
'Projects & Groups'
)
})
}
appendPaletteListEntries(entries, visibleProjectTargetItems)
pushOverflowHint('__hint_project_overflow__', projectTargetOverflowCount)
}
if (visibleMiddleItems.length > 0) {
if (showMiddleHeader) {
entries.push({
id: '__header_actions_settings__',
type: 'section-header',
label: translate('auto.components.WorktreeJumpPalette.088d66d980', 'Actions & Settings')
})
}
appendPaletteListEntries(entries, visibleMiddleItems)
pushOverflowHint('__hint_middle_overflow__', middleOverflowCount)
}
}
if (!hasQuery) {
// Why: the recent section leads the empty-query view; nothing else in this branch is populated.
pushOpenTabSection()
@@ -1495,35 +1590,48 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
return entries
}
// 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 leadingHintId = openTabsLeadSections
? '__hint_open_tab_overflow__'
: '__hint_worktree_overflow__'
const trailingHintId = openTabsLeadSections
? '__hint_worktree_overflow__'
: '__hint_open_tab_overflow__'
if (openTabsLeadSections) {
pushOpenTabsHeader()
} else {
pushWorktreesHeader()
}
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' })
}
return entries
}
if (openTabsLeadSections) {
pushOpenTabSection()
}
pushWorktreeSection()
if (visibleProjectTargetItems.length > 0) {
if (showProjectTargetHeader) {
entries.push({
id: '__header_projects_groups__',
type: 'section-header',
label: translate(
'auto.components.WorktreeJumpPalette.projectsGroupsHeader',
'Projects & Groups'
)
})
}
appendPaletteListEntries(entries, visibleProjectTargetItems)
pushOverflowHint('__hint_project_overflow__', projectTargetOverflowCount)
}
if (visibleMiddleItems.length > 0) {
if (showMiddleHeader) {
entries.push({
id: '__header_actions_settings__',
type: 'section-header',
label: translate('auto.components.WorktreeJumpPalette.088d66d980', 'Actions & Settings')
})
}
appendPaletteListEntries(entries, visibleMiddleItems)
pushOverflowHint('__hint_middle_overflow__', middleOverflowCount)
}
pushProjectAndMiddleSections()
if (!openTabsLeadSections) {
pushOpenTabSection()
}
@@ -1535,6 +1643,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]
@@ -2194,7 +2315,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
'Search chats, terminals, worktrees, settings, and actions'
)}
overlayClassName="bg-black/55 backdrop-blur-[2px]"
contentClassName="top-[13%] w-[736px] max-w-[94vw] overflow-hidden rounded-xl border border-border/70 bg-background/96 shadow-[0_26px_84px_rgba(0,0,0,0.32)] backdrop-blur-xl"
contentClassName="top-[10%] w-[900px] max-w-[96vw] overflow-hidden rounded-xl border border-border/70 bg-background/96 shadow-[0_26px_84px_rgba(0,0,0,0.32)] backdrop-blur-xl"
commandProps={{
loop: true,
value: commandSelectedItemId,
@@ -2226,7 +2347,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
}
/>
<PaletteFilterChips model={filterModel} filter={filter} onFilterChange={setRawFilter} />
<CommandList ref={listRef} className="max-h-[min(460px,62vh)] px-2.5 pb-2.5 pt-2">
<CommandList ref={listRef} className="max-h-[min(600px,72vh)] px-2.5 pb-2.5 pt-2">
{isLoading && selectableItems.length === 0 && !showCreateAction ? (
<PaletteState
title={translate(
@@ -2553,44 +2674,37 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
fallback={<WorkspaceTabIcon className="size-3.5" aria-hidden="true" />}
/>
</div>
<div className="min-w-0 flex-1">
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="max-w-[40%] shrink-0 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
<HighlightedText text={result.title} matchRange={result.titleRange} />
</span>
{result.isCurrentTab && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.52404f8096',
'Current Tab'
<div className="min-w-0 flex-1 overflow-hidden">
<PaletteOpenTabPrimaryLine
title={result.title}
titleRange={result.titleRange}
secondaryText={result.secondaryText}
secondaryRange={result.secondaryRange}
worktreeName={result.worktreeName}
worktreeRange={result.worktreeRange}
leadingBadges={
<>
{result.isCurrentTab && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.52404f8096',
'Current Tab'
)}
</span>
)}
</span>
)}
{!result.isCurrentTab && result.isCurrentWorktree && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.c5081f2814',
'Current Worktree'
{!result.isCurrentTab && result.isCurrentWorktree && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.c5081f2814',
'Current Worktree'
)}
</span>
)}
</span>
)}
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92">
<HighlightedText
text={result.secondaryText}
matchRange={result.secondaryRange}
/>
</span>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="shrink-0 text-[12px] font-medium text-muted-foreground/92">
<HighlightedText
text={result.worktreeName}
matchRange={result.worktreeRange}
/>
</span>
</div>
</>
}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<PaletteHostBadgeChip badge={workspaceTabHostBadge} />
@@ -2642,44 +2756,37 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
<div className="flex h-5 w-4 shrink-0 items-center justify-center self-start text-muted-foreground/85">
<Smartphone className="size-3.5" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="max-w-[40%] shrink-0 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
<HighlightedText text={result.title} matchRange={result.titleRange} />
</span>
{result.isCurrentTab && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.52404f8096',
'Current Tab'
<div className="min-w-0 flex-1 overflow-hidden">
<PaletteOpenTabPrimaryLine
title={result.title}
titleRange={result.titleRange}
secondaryText={result.secondaryText}
secondaryRange={result.secondaryRange}
worktreeName={result.worktreeName}
worktreeRange={result.worktreeRange}
leadingBadges={
<>
{result.isCurrentTab && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.52404f8096',
'Current Tab'
)}
</span>
)}
</span>
)}
{!result.isCurrentTab && result.isCurrentWorktree && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.c5081f2814',
'Current Worktree'
{!result.isCurrentTab && result.isCurrentWorktree && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.c5081f2814',
'Current Worktree'
)}
</span>
)}
</span>
)}
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92">
<HighlightedText
text={result.secondaryText}
matchRange={result.secondaryRange}
/>
</span>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="shrink-0 text-[12px] font-medium text-muted-foreground/92">
<HighlightedText
text={result.worktreeName}
matchRange={result.worktreeRange}
/>
</span>
</div>
</>
}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<PaletteHostBadgeChip badge={simulatorHostBadge} />
@@ -2728,44 +2835,37 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
<div className="flex h-5 w-4 shrink-0 items-center justify-center self-start text-muted-foreground/85">
<Globe className="size-3.5" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="max-w-[40%] shrink-0 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
<HighlightedText text={result.title} matchRange={result.titleRange} />
</span>
{result.isCurrentPage && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.52404f8096',
'Current Tab'
<div className="min-w-0 flex-1 overflow-hidden">
<PaletteOpenTabPrimaryLine
title={result.title}
titleRange={result.titleRange}
secondaryText={result.secondaryText}
secondaryRange={result.secondaryRange}
worktreeName={result.worktreeName}
worktreeRange={result.worktreeRange}
leadingBadges={
<>
{result.isCurrentPage && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.52404f8096',
'Current Tab'
)}
</span>
)}
</span>
)}
{!result.isCurrentPage && result.isCurrentWorktree && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.c5081f2814',
'Current Worktree'
{!result.isCurrentPage && result.isCurrentWorktree && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.c5081f2814',
'Current Worktree'
)}
</span>
)}
</span>
)}
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92">
<HighlightedText
text={result.secondaryText}
matchRange={result.secondaryRange}
/>
</span>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="shrink-0 text-[12px] font-medium text-muted-foreground/92">
<HighlightedText
text={result.worktreeName}
matchRange={result.worktreeRange}
/>
</span>
</div>
</>
}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<PaletteHostBadgeChip badge={browserHostBadge} />
@@ -1,5 +1,13 @@
import { describe, expect, it } from 'vitest'
import { capPaletteSection, PALETTE_SECTION_RENDER_CAP } from './palette-section-render-cap'
import {
capPaletteSection,
layoutMultiPrimaryPaletteSections,
orderMultiPrimaryPaletteItems,
PALETTE_SECTION_RENDER_CAP,
softSplitPaletteSection,
TYPED_QUERY_LEADING_PREVIEW,
TYPED_QUERY_TRAILING_FLOOR
} from './palette-section-render-cap'
const range = (count: number): number[] => Array.from({ length: count }, (_, index) => index)
@@ -37,3 +45,87 @@ describe('capPaletteSection', () => {
expect(capPaletteSection(range(3), 0)).toEqual({ visible: [], overflowCount: 3 })
})
})
describe('softSplitPaletteSection', () => {
it('splits under the hard cap and reports everything after the preview', () => {
const split = softSplitPaletteSection(range(12), TYPED_QUERY_LEADING_PREVIEW)
expect(split.preview).toEqual(range(6))
expect(split.rest).toEqual([6, 7, 8, 9, 10, 11])
expect(split.moreCount).toBe(6)
})
it('includes hard-cap overflow in moreCount without rendering it', () => {
const split = softSplitPaletteSection(range(80), TYPED_QUERY_LEADING_PREVIEW)
expect(split.preview).toHaveLength(TYPED_QUERY_LEADING_PREVIEW)
expect(split.rest).toHaveLength(PALETTE_SECTION_RENDER_CAP - TYPED_QUERY_LEADING_PREVIEW)
expect(split.moreCount).toBe(80 - TYPED_QUERY_LEADING_PREVIEW)
})
it('keeps short sections intact with no remainder', () => {
const items = range(3)
const split = softSplitPaletteSection(items, TYPED_QUERY_LEADING_PREVIEW)
expect(split.preview).toEqual(items)
expect(split.rest).toEqual([])
expect(split.moreCount).toBe(0)
})
})
describe('layoutMultiPrimaryPaletteSections', () => {
it('puts trailing floor before leading rest so worktrees stay early', () => {
const layout = layoutMultiPrimaryPaletteSections({
leadingItems: range(12),
trailingItems: range(5).map((n) => n + 100)
})
expect(layout.leadingPreview).toEqual(range(TYPED_QUERY_LEADING_PREVIEW))
expect(layout.leadingMoreCount).toBe(6)
expect(layout.trailingFloor).toEqual([100, 101, 102])
expect(layout.trailingFloor).toHaveLength(TYPED_QUERY_TRAILING_FLOOR)
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', () => {
const layout = layoutMultiPrimaryPaletteSections({
leadingItems: range(10),
trailingItems: [200, 201]
})
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
])
})
})
@@ -8,6 +8,16 @@
*/
export const PALETTE_SECTION_RENDER_CAP = 50
/**
* First-screen soft split for typed queries when open tabs and worktrees both
* match. Leading section shows this many rows, then a non-selectable hint, then
* the trailing section floor so worktrees (or tabs) stay above the fold.
*/
export const TYPED_QUERY_LEADING_PREVIEW = 6
/** Min rows for the non-leading primary section (tabs ↔ worktrees) in the first screen. */
export const TYPED_QUERY_TRAILING_FLOOR = 3
export type CappedPaletteSection<T> = {
visible: readonly T[]
overflowCount: number
@@ -22,3 +32,88 @@ export function capPaletteSection<T>(
}
return { visible: items.slice(0, cap), overflowCount: items.length - cap }
}
/**
* Soft-split a hard-capped section into first-screen preview + remainder.
* `moreCount` is everything after the preview (including rows past the hard cap)
* so one overflow hint covers both scrollable rest and “keep typing” remainder.
*/
export type SoftSplitSection<T> = {
preview: readonly T[]
rest: readonly T[]
moreCount: number
}
export function softSplitPaletteSection<T>(
items: readonly T[],
previewCount: number,
hardCap: number = PALETTE_SECTION_RENDER_CAP
): SoftSplitSection<T> {
const capped = capPaletteSection(items, hardCap)
const previewSize = Math.max(0, Math.min(previewCount, capped.visible.length))
return {
preview: capped.visible.slice(0, previewSize),
rest: capped.visible.slice(previewSize),
moreCount: Math.max(0, items.length - previewSize)
}
}
/**
* Layout when a typed query hits both open tabs and worktrees.
* 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[]
leadingRest: readonly T[]
leadingMoreCount: number
trailingFloor: readonly T[]
trailingRest: readonly T[]
trailingMoreCount: number
trailingHardOverflowCount: number
}
export function layoutMultiPrimaryPaletteSections<T>({
leadingItems,
trailingItems,
leadingPreviewCount = TYPED_QUERY_LEADING_PREVIEW,
trailingFloorCount = TYPED_QUERY_TRAILING_FLOOR,
hardCap = PALETTE_SECTION_RENDER_CAP
}: {
leadingItems: readonly T[]
trailingItems: readonly T[]
leadingPreviewCount?: number
trailingFloorCount?: number
hardCap?: number
}): MultiPrimarySectionLayout<T> {
const leading = softSplitPaletteSection(leadingItems, leadingPreviewCount, hardCap)
const trailing = softSplitPaletteSection(trailingItems, trailingFloorCount, hardCap)
return {
leadingPreview: leading.preview,
leadingRest: leading.rest,
leadingMoreCount: leading.moreCount,
trailingFloor: trailing.preview,
trailingRest: trailing.rest,
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
]
}
@@ -58,7 +58,7 @@ function makeWorkspaceTab({
id,
title,
contentType = 'terminal',
secondaryText = 'Terminal tab',
secondaryText = '',
secondarySearchTexts,
agentSnippets = [],
tabSortIndex = 0,
@@ -85,7 +85,7 @@ function makeWorkspaceTab({
title,
secondaryText,
titleSearchText: title,
secondarySearchTexts: secondarySearchTexts ?? [secondaryText],
secondarySearchTexts: secondarySearchTexts ?? (secondaryText ? [secondaryText] : []),
agentMetadata: agentSnippets.length
? [{ paneKey: `${id}-pane`, textParts: [], snippetCandidates: agentSnippets }]
: [],
@@ -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,
@@ -108,9 +108,17 @@ describe('simulator-palette-search', () => {
}
]
expect(searchSimulatorTabs(entries, 'mobile')[0]?.secondaryRange).toEqual({ start: 0, end: 6 })
// Why no secondaryRange: type aliases match without a display secondary.
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)
})
it('searches worktree and repo metadata', () => {
@@ -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
@@ -33,6 +34,16 @@ type SimulatorPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simula
export const SIMULATOR_PALETTE_QUERY_MAX_BYTES = 2 * 1024
// Why search-only: the row icon already says "emulator"; a fixed secondary label
// crowds Cmd+J the same way "Terminal tab" did. Keep these strings matchable so
// typing "mobile" / "simulator" still finds emulator tabs.
const SIMULATOR_TYPE_SEARCH_ALIASES = [
'mobile emulator tab',
'mobile emulator',
'ios simulator',
'emulator'
] as const
export function isSimulatorPaletteQueryTooLarge(
query: string,
maxBytes = SIMULATOR_PALETTE_QUERY_MAX_BYTES
@@ -180,7 +191,9 @@ export function searchSimulatorTabs(
for (const entry of entries) {
const title = entry.tab.label || 'Mobile Emulator'
const secondaryText = 'Mobile Emulator tab'
// Why: type is already clear from the smartphone icon; a fixed label only
// crowds the row (and used to leave a bare "· ·" under width pressure).
const secondaryText = ''
// Why: a cleared display name leaves this undefined at runtime; findRange would throw.
const worktreeName = resolveWorktreeDisplayName(entry.worktree)
const baseResult = {
@@ -226,32 +239,28 @@ export function searchSimulatorTabs(
continue
}
const secondaryRange = findRange(secondaryText, trimmedQuery)
if (secondaryRange) {
results.push({
...baseResult,
titleRange: null,
secondaryRange,
repoRange: null,
worktreeRange: null,
score: scoreSimulatorTabMatch({
fieldWeight: 20,
matchIndex: secondaryRange.start,
entry
})
})
continue
let typeAliasHit: { text: string; range: MatchRange } | null = null
for (const alias of SIMULATOR_TYPE_SEARCH_ALIASES) {
const range = findRange(alias, trimmedQuery)
if (range) {
typeAliasHit = { text: alias, range }
break
}
}
const aliasRange = findRange('ios simulator', trimmedQuery)
if (aliasRange) {
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,
score: scoreSimulatorTabMatch({ fieldWeight: 24, matchIndex: aliasRange.start, entry })
typeAliasMatch: typeAliasHit,
score: scoreSimulatorTabMatch({
fieldWeight: 20,
matchIndex: typeAliasHit.range.start,
entry
})
})
continue
}
@@ -103,7 +103,7 @@ function makeResult(
groupId: 'group-1',
contentType: 'terminal',
title: 'Terminal',
secondaryText: 'Terminal tab',
secondaryText: '',
repoName: 'repo/orca',
worktreeName: 'Palette Worktree',
titleRange: null,
@@ -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({
@@ -491,4 +491,34 @@ describe('workspace-tab-palette-search', () => {
worktreeRange: null
})
})
it('omits the fixed Terminal tab secondary on terminals', () => {
const entries = buildEntries()
const emptyQuery = searchWorkspaceTabs(entries, '')[0]
expect(emptyQuery).toMatchObject({
contentType: 'terminal',
secondaryText: '',
secondaryRange: null
})
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 & {
@@ -221,9 +230,12 @@ export function buildSearchableWorkspaceTabs({
entries.push({
...baseEntry,
title,
secondaryText: 'Terminal tab',
// Why: type is already clear from the status/content icon; a fixed
// "Terminal tab" label only crowds the row (and used to leave a bare "· ·").
secondaryText: '',
titleSearchText: title,
secondarySearchTexts: ['Terminal tab'],
secondarySearchTexts: [],
typeSearchAliases: TERMINAL_TYPE_SEARCH_ALIASES,
agentMetadata: collectAgentMetadataForTerminal({
terminalTabId: tab.entityId,
worktreeId: worktree.id,