From 0d29497f8279e2d4c2d26ffc8f3fb68cee2756a7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:32:40 -0700 Subject: [PATCH] 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. --- .../src/components/WorktreeJumpPalette.tsx | 498 +++++++++++------- .../cmd-j/palette-section-render-cap.test.ts | 94 +++- .../cmd-j/palette-section-render-cap.ts | 95 ++++ .../tab-bar/open-tab-search.test.ts | 4 +- .../src/lib/cmd-j-match-relevance.test.ts | 13 + src/renderer/src/lib/cmd-j-match-relevance.ts | 11 + .../src/lib/simulator-palette-search.test.ts | 10 +- .../src/lib/simulator-palette-search.ts | 49 +- .../workspace-tab-palette-activation.test.ts | 2 +- .../src/lib/workspace-tab-palette-results.ts | 28 + .../lib/workspace-tab-palette-search.test.ts | 30 ++ .../src/lib/workspace-tab-palette-search.ts | 16 +- 12 files changed, 624 insertions(+), 226 deletions(-) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index e7d23aaea68..309fd949db8 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -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 ( +
+ + + + {leadingBadges} + {showSecondary ? ( + <> + · + + + + + ) : null} + {showWorktree ? ( + <> + · + + + + + ) : null} +
+ ) +} + function PaletteState({ title, subtitle }: { title: string; subtitle: string }): React.JSX.Element { return (
@@ -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} @@ -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({ + 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( - () => - // 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( + () => + 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 { } /> - + {isLoading && selectableItems.length === 0 && !showCreateAction ? (
-
+
-
-
- - - - {result.isCurrentTab && ( - - {translate( - 'auto.components.WorktreeJumpPalette.52404f8096', - 'Current Tab' +
+ + {result.isCurrentTab && ( + + {translate( + 'auto.components.WorktreeJumpPalette.52404f8096', + 'Current Tab' + )} + )} - - )} - {!result.isCurrentTab && result.isCurrentWorktree && ( - - {translate( - 'auto.components.WorktreeJumpPalette.c5081f2814', - 'Current Worktree' + {!result.isCurrentTab && result.isCurrentWorktree && ( + + {translate( + 'auto.components.WorktreeJumpPalette.c5081f2814', + 'Current Worktree' + )} + )} - - )} - · - - - - · - - - -
+ + } + />
@@ -2642,44 +2756,37 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
-
+
-
-
- - - - {result.isCurrentTab && ( - - {translate( - 'auto.components.WorktreeJumpPalette.52404f8096', - 'Current Tab' +
+ + {result.isCurrentTab && ( + + {translate( + 'auto.components.WorktreeJumpPalette.52404f8096', + 'Current Tab' + )} + )} - - )} - {!result.isCurrentTab && result.isCurrentWorktree && ( - - {translate( - 'auto.components.WorktreeJumpPalette.c5081f2814', - 'Current Worktree' + {!result.isCurrentTab && result.isCurrentWorktree && ( + + {translate( + 'auto.components.WorktreeJumpPalette.c5081f2814', + 'Current Worktree' + )} + )} - - )} - · - - - - · - - - -
+ + } + />
@@ -2728,44 +2835,37 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
-
+
-
-
- - - - {result.isCurrentPage && ( - - {translate( - 'auto.components.WorktreeJumpPalette.52404f8096', - 'Current Tab' +
+ + {result.isCurrentPage && ( + + {translate( + 'auto.components.WorktreeJumpPalette.52404f8096', + 'Current Tab' + )} + )} - - )} - {!result.isCurrentPage && result.isCurrentWorktree && ( - - {translate( - 'auto.components.WorktreeJumpPalette.c5081f2814', - 'Current Worktree' + {!result.isCurrentPage && result.isCurrentWorktree && ( + + {translate( + 'auto.components.WorktreeJumpPalette.c5081f2814', + 'Current Worktree' + )} + )} - - )} - · - - - - · - - - -
+ + } + />
diff --git a/src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts b/src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts index 810c44b07f0..eb19e89b99f 100644 --- a/src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts +++ b/src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts @@ -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 + ]) + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-section-render-cap.ts b/src/renderer/src/components/cmd-j/palette-section-render-cap.ts index 1e2f8efc1f9..334e2c8c1ff 100644 --- a/src/renderer/src/components/cmd-j/palette-section-render-cap.ts +++ b/src/renderer/src/components/cmd-j/palette-section-render-cap.ts @@ -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 = { visible: readonly T[] overflowCount: number @@ -22,3 +32,88 @@ export function capPaletteSection( } 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 = { + preview: readonly T[] + rest: readonly T[] + moreCount: number +} + +export function softSplitPaletteSection( + items: readonly T[], + previewCount: number, + hardCap: number = PALETTE_SECTION_RENDER_CAP +): SoftSplitSection { + 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 = { + leadingPreview: readonly T[] + leadingRest: readonly T[] + leadingMoreCount: number + trailingFloor: readonly T[] + trailingRest: readonly T[] + trailingMoreCount: number + trailingHardOverflowCount: number +} + +export function layoutMultiPrimaryPaletteSections({ + 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 { + 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( + layout: MultiPrimarySectionLayout +): readonly T[] { + return [ + ...layout.leadingPreview, + ...layout.trailingFloor, + ...layout.leadingRest, + ...layout.trailingRest + ] +} diff --git a/src/renderer/src/components/tab-bar/open-tab-search.test.ts b/src/renderer/src/components/tab-bar/open-tab-search.test.ts index 6a27884d96b..1476bdaa3bb 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search.test.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search.test.ts @@ -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 }] : [], diff --git a/src/renderer/src/lib/cmd-j-match-relevance.test.ts b/src/renderer/src/lib/cmd-j-match-relevance.test.ts index 36da3f66d0d..07711ac97bc 100644 --- a/src/renderer/src/lib/cmd-j-match-relevance.test.ts +++ b/src/renderer/src/lib/cmd-j-match-relevance.test.ts @@ -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) + }) }) diff --git a/src/renderer/src/lib/cmd-j-match-relevance.ts b/src/renderer/src/lib/cmd-j-match-relevance.ts index cfbc95163eb..44015d0d4f1 100644 --- a/src/renderer/src/lib/cmd-j-match-relevance.ts +++ b/src/renderer/src/lib/cmd-j-match-relevance.ts @@ -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, diff --git a/src/renderer/src/lib/simulator-palette-search.test.ts b/src/renderer/src/lib/simulator-palette-search.test.ts index cfc8aa4a720..ee74978b101 100644 --- a/src/renderer/src/lib/simulator-palette-search.test.ts +++ b/src/renderer/src/lib/simulator-palette-search.test.ts @@ -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', () => { diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index aded934662e..e458f62ae7f 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -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 } diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts index 101841524a4..5d0def8b8ba 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts @@ -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, diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts index 9d829355a43..4d0e5dc8379 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.ts @@ -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({ diff --git a/src/renderer/src/lib/workspace-tab-palette-search.test.ts b/src/renderer/src/lib/workspace-tab-palette-search.test.ts index 54bc1b26ecb..a2a7e147bc3 100644 --- a/src/renderer/src/lib/workspace-tab-palette-search.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-search.test.ts @@ -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 } + } + }) + }) }) diff --git a/src/renderer/src/lib/workspace-tab-palette-search.ts b/src/renderer/src/lib/workspace-tab-palette-search.ts index b4f2c407274..1d037335fb4 100644 --- a/src/renderer/src/lib/workspace-tab-palette-search.ts +++ b/src/renderer/src/lib/workspace-tab-palette-search.ts @@ -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,