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