mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user