From 486f5d34be61a620ba5a33fc69a961ee00a0dfc9 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:28:09 -0700 Subject: [PATCH] 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. --- .../src/components/WorktreeJumpPalette.tsx | 97 +++++++------------ .../cmd-j/palette-section-render-cap.test.ts | 30 ++++++ .../cmd-j/palette-section-render-cap.ts | 22 ++++- .../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 | 13 ++- .../src/lib/workspace-tab-palette-results.ts | 28 ++++++ .../lib/workspace-tab-palette-search.test.ts | 19 ++++ .../src/lib/workspace-tab-palette-search.ts | 10 ++ 10 files changed, 181 insertions(+), 72 deletions(-) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 3eb9712213c..a4a47cc0c4d 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -1391,55 +1391,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { openTabsLeadSections ]) - const selectableItems = useMemo(() => { - const { - visibleOpenTabItems, - visibleWorktreeItems, - visibleProjectTargetItems, - visibleMiddleItems, - multiPrimaryFirstScreen, - multiPrimaryLayout - } = paletteSections - - // Why: selection order must mirror render order, including the soft-split interleave. - if (multiPrimaryFirstScreen && multiPrimaryLayout) { - const leadingRest = multiPrimaryLayout.leadingRest as PaletteItem[] - const trailingRest = multiPrimaryLayout.trailingRest as PaletteItem[] - if (openTabsLeadSections) { - return [ - ...(multiPrimaryLayout.leadingPreview as PaletteItem[]), - ...(multiPrimaryLayout.trailingFloor as PaletteItem[]), - ...leadingRest, - ...trailingRest, - ...visibleProjectTargetItems, - ...visibleMiddleItems - ] - } - return [ - ...(multiPrimaryLayout.leadingPreview as PaletteItem[]), - ...(multiPrimaryLayout.trailingFloor as PaletteItem[]), - ...leadingRest, - ...trailingRest, - ...visibleProjectTargetItems, - ...visibleMiddleItems - ] - } - - return openTabsLeadSections - ? [ - ...visibleOpenTabItems, - ...visibleWorktreeItems, - ...visibleProjectTargetItems, - ...visibleMiddleItems - ] - : [ - ...visibleWorktreeItems, - ...visibleProjectTargetItems, - ...visibleMiddleItems, - ...visibleOpenTabItems - ] - }, [openTabsLeadSections, paletteSections]) - // Why: badges number the snapshotted recent rows only — ⌘N is meaningless on a typed query. const recentTabShortcutIndexById = useMemo( () => @@ -1609,26 +1560,33 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // Typed query with both open tabs and worktrees: soft-split so the trailing // primary is not buried under ~50 leading rows (see tmp/cmd-j-recommended.html). if (multiPrimaryFirstScreen && multiPrimaryLayout) { - const { leadingPreview, leadingRest, leadingMoreCount, trailingFloor, trailingRest } = - multiPrimaryLayout + const leadingHintId = openTabsLeadSections + ? '__hint_open_tab_overflow__' + : '__hint_worktree_overflow__' + const trailingHintId = openTabsLeadSections + ? '__hint_worktree_overflow__' + : '__hint_open_tab_overflow__' if (openTabsLeadSections) { pushOpenTabsHeader() - appendPaletteListEntries(entries, leadingPreview as PaletteItem[]) - pushOverflowHint('__hint_open_tab_overflow__', leadingMoreCount) - pushWorktreesHeader() - appendPaletteListEntries(entries, trailingFloor as PaletteItem[]) - appendPaletteListEntries(entries, leadingRest as PaletteItem[]) - appendPaletteListEntries(entries, trailingRest as PaletteItem[]) } else { pushWorktreesHeader() - appendPaletteListEntries(entries, leadingPreview as PaletteItem[]) - pushOverflowHint('__hint_worktree_overflow__', leadingMoreCount) - pushOpenTabsHeader() - appendPaletteListEntries(entries, trailingFloor as PaletteItem[]) - appendPaletteListEntries(entries, leadingRest as PaletteItem[]) - appendPaletteListEntries(entries, trailingRest as PaletteItem[]) } + appendPaletteListEntries(entries, multiPrimaryLayout.leadingPreview as PaletteItem[]) + // Soft more for the leading section (scrollable rest + hard-cap tail). + pushOverflowHint(leadingHintId, multiPrimaryLayout.leadingMoreCount) + if (openTabsLeadSections) { + pushWorktreesHeader() + } else { + pushOpenTabsHeader() + } + // Floor first, then remaining leading rows, then trailing rest — same order + // as orderMultiPrimaryPaletteItems / keyboard selection. + appendPaletteListEntries(entries, multiPrimaryLayout.trailingFloor as PaletteItem[]) + appendPaletteListEntries(entries, multiPrimaryLayout.leadingRest as PaletteItem[]) + appendPaletteListEntries(entries, multiPrimaryLayout.trailingRest as PaletteItem[]) + // Trailing rest is already on screen; only hard-cap overflow needs a hint. + pushOverflowHint(trailingHintId, multiPrimaryLayout.trailingHardOverflowCount) pushProjectAndMiddleSections() if (showCreateAction) { entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' }) @@ -1652,6 +1610,19 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { return entries }, [hasQuery, openTabsLeadSections, paletteSections, showCreateAction, worktreeItems.length]) + // Why derive from listEntries: multi-primary interleave must stay identical for + // empty-state counts and keyboard selection — dual-path builders drifted before. + const selectableItems = useMemo( + () => + listEntries.filter( + (entry): entry is PaletteItem => + entry.type !== 'section-header' && + entry.type !== 'hint' && + entry.type !== 'create-worktree' + ), + [listEntries] + ) + const selectionItemIds = useMemo( () => getWorktreePaletteSelectionItemIds(listEntries), [listEntries] 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 e06dc5c4058..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 @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { capPaletteSection, layoutMultiPrimaryPaletteSections, + orderMultiPrimaryPaletteItems, PALETTE_SECTION_RENDER_CAP, softSplitPaletteSection, TYPED_QUERY_LEADING_PREVIEW, @@ -86,6 +87,7 @@ describe('layoutMultiPrimaryPaletteSections', () => { expect(layout.trailingRest).toEqual([103, 104]) expect(layout.leadingRest).toEqual([6, 7, 8, 9, 10, 11]) expect(layout.trailingMoreCount).toBe(2) + expect(layout.trailingHardOverflowCount).toBe(0) }) it('shows all trailing rows in the floor when the section is small', () => { @@ -97,5 +99,33 @@ describe('layoutMultiPrimaryPaletteSections', () => { expect(layout.trailingFloor).toEqual([200, 201]) expect(layout.trailingRest).toEqual([]) expect(layout.trailingMoreCount).toBe(0) + expect(layout.trailingHardOverflowCount).toBe(0) + }) + + it('reports trailing hard-cap overflow without counting scrollable rest', () => { + const layout = layoutMultiPrimaryPaletteSections({ + leadingItems: range(12), + trailingItems: range(80).map((n) => n + 1000) + }) + + expect(layout.trailingFloor).toHaveLength(TYPED_QUERY_TRAILING_FLOOR) + expect(layout.trailingRest).toHaveLength( + PALETTE_SECTION_RENDER_CAP - TYPED_QUERY_TRAILING_FLOOR + ) + expect(layout.trailingMoreCount).toBe(80 - TYPED_QUERY_TRAILING_FLOOR) + expect(layout.trailingHardOverflowCount).toBe(80 - PALETTE_SECTION_RENDER_CAP) + }) +}) + +describe('orderMultiPrimaryPaletteItems', () => { + it('interleaves floor before leading rest', () => { + const layout = layoutMultiPrimaryPaletteSections({ + leadingItems: range(8), + trailingItems: range(5).map((n) => n + 100) + }) + + expect(orderMultiPrimaryPaletteItems(layout)).toEqual([ + 0, 1, 2, 3, 4, 5, 100, 101, 102, 6, 7, 103, 104 + ]) }) }) 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 4e1817dd4ea..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 @@ -63,6 +63,10 @@ export function softSplitPaletteSection( * 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[] @@ -71,6 +75,7 @@ export type MultiPrimarySectionLayout = { trailingFloor: readonly T[] trailingRest: readonly T[] trailingMoreCount: number + trailingHardOverflowCount: number } export function layoutMultiPrimaryPaletteSections({ @@ -94,6 +99,21 @@ export function layoutMultiPrimaryPaletteSections({ leadingMoreCount: leading.moreCount, trailingFloor: trailing.preview, trailingRest: trailing.rest, - trailingMoreCount: trailing.moreCount + trailingMoreCount: trailing.moreCount, + // Why: floor + rest already cover every rendered trailing row; only the + // hard-capped tail needs a “keep typing” hint after the section. + trailingHardOverflowCount: Math.max(0, trailing.moreCount - trailing.rest.length) } } + +/** Selection/render order for the two interleaved primary sections. */ +export function orderMultiPrimaryPaletteItems( + layout: MultiPrimarySectionLayout +): readonly T[] { + return [ + ...layout.leadingPreview, + ...layout.trailingFloor, + ...layout.leadingRest, + ...layout.trailingRest + ] +} 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 397225083ec..1073a5fb93f 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 6082d225fc2..ee74978b101 100644 --- a/src/renderer/src/lib/simulator-palette-search.test.ts +++ b/src/renderer/src/lib/simulator-palette-search.test.ts @@ -109,9 +109,13 @@ describe('simulator-palette-search', () => { ] // Why no secondaryRange: type aliases match without a display secondary. - expect(searchSimulatorTabs(entries, 'mobile')).toHaveLength(1) - expect(searchSimulatorTabs(entries, 'mobile')[0]?.secondaryText).toBe('') - expect(searchSimulatorTabs(entries, 'mobile')[0]?.secondaryRange).toBeNull() + const mobileHit = searchSimulatorTabs(entries, 'mobile')[0] + expect(mobileHit?.secondaryText).toBe('') + expect(mobileHit?.secondaryRange).toBeNull() + expect(mobileHit?.typeAliasMatch).toEqual({ + text: 'mobile emulator tab', + range: { start: 0, end: 6 } + }) expect(searchSimulatorTabs(entries, 'simulator')).toHaveLength(1) expect(searchSimulatorTabs(entries, 'ios')).toHaveLength(1) expect(searchSimulatorTabs(entries, 'emulator')).toHaveLength(1) diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index ef7ef45eb7c..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 @@ -238,14 +239,15 @@ export function searchSimulatorTabs( continue } - let typeAliasMatch: MatchRange | null = null + let typeAliasHit: { text: string; range: MatchRange } | null = null for (const alias of SIMULATOR_TYPE_SEARCH_ALIASES) { - typeAliasMatch = findRange(alias, trimmedQuery) - if (typeAliasMatch) { + const range = findRange(alias, trimmedQuery) + if (range) { + typeAliasHit = { text: alias, range } break } } - if (typeAliasMatch) { + if (typeAliasHit) { results.push({ ...baseResult, titleRange: null, @@ -253,9 +255,10 @@ export function searchSimulatorTabs( secondaryRange: null, repoRange: null, worktreeRange: null, + typeAliasMatch: typeAliasHit, score: scoreSimulatorTabMatch({ fieldWeight: 20, - matchIndex: typeAliasMatch.start, + matchIndex: typeAliasHit.range.start, entry }) }) 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 d871b707ca8..a2a7e147bc3 100644 --- a/src/renderer/src/lib/workspace-tab-palette-search.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-search.test.ts @@ -502,4 +502,23 @@ describe('workspace-tab-palette-search', () => { }) expect(entries[0]?.secondarySearchTexts).toEqual([]) }) + + it('still finds terminals via type aliases without showing a secondary', () => { + const entries = buildEntries() + // Title is agent-named so the hit has to come from the type alias, not the title. + const renamed = entries.map((entry, index) => + index === 0 ? { ...entry, title: 'Fix login race', titleSearchText: 'Fix login race' } : entry + ) + const hit = searchWorkspaceTabs(renamed, 'terminal')[0] + expect(hit).toMatchObject({ + contentType: 'terminal', + title: 'Fix login race', + secondaryText: '', + secondaryRange: null, + typeAliasMatch: { + text: 'terminal tab', + range: { start: 0, end: 8 } + } + }) + }) }) diff --git a/src/renderer/src/lib/workspace-tab-palette-search.ts b/src/renderer/src/lib/workspace-tab-palette-search.ts index eb45ceef52a..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 & { @@ -226,6 +235,7 @@ export function buildSearchableWorkspaceTabs({ secondaryText: '', titleSearchText: title, secondarySearchTexts: [], + typeSearchAliases: TERMINAL_TYPE_SEARCH_ALIASES, agentMetadata: collectAgentMetadataForTerminal({ terminalTabId: tab.entityId, worktreeId: worktree.id,