From 80df9aabf56bc980c14e6b73a79162f68656a0ea Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:37:13 -0700 Subject: [PATCH] Improve cmd-j palette ranking with token-count tiebreakers and identity Add containerOnlyTokenCount and recoveryTokenCount fields to distinguish entities when match quality is equal, enabling better ranking of results that rely on container fields or recovery mechanisms. Cache paletteIdentity in search results to avoid repeated encoding during sorting. Extract omnibox field filtering and open-tab capping into reusable functions. Optimize evidence-unit iteration to only process matched units. Strengthen worktree ambiguity checks to reject hostless tabs when IDs collide across hosts. --- .../cmd-j/palette-section-render-cap.ts | 10 +++- .../src/components/tab-bar/open-tab-search.ts | 23 +++++---- .../components/tab-bar/use-open-tab-search.ts | 33 +++++++----- .../use-worktree-jump-palette-controller.ts | 21 +------- .../use-worktree-jump-palette-sections.ts | 38 +++++++++++--- .../src/lib/browser-palette-search.ts | 30 +++++------ .../browser-workspace-tab-activation.test.ts | 14 +++++ .../lib/browser-workspace-tab-activation.ts | 13 ++--- .../src/lib/cmd-j-section-leadership.test.ts | 2 + .../src/lib/palette-match/match-document.ts | 27 ++++++---- .../palette-assignment-ranking.ts | 4 ++ .../src/lib/palette-match/palette-document.ts | 44 +++++++++++++++- .../palette-match-performance.test.ts | 51 ++++++++++++++++++- .../lib/palette-match/palette-ranking.test.ts | 2 + .../src/lib/palette-match/tab-match.ts | 4 ++ .../src/lib/simulator-palette-search.ts | 30 +++++------ .../simulator-tab-palette-activation.test.ts | 14 +++++ .../lib/simulator-tab-palette-activation.ts | 8 ++- .../src/lib/unified-tab-host-ownership.ts | 6 +++ .../workspace-tab-palette-activation.test.ts | 1 + .../lib/workspace-tab-palette-activation.ts | 7 ++- .../workspace-tab-palette-entry-builder.ts | 5 +- .../lib/workspace-tab-palette-results.test.ts | 29 ++++++++++- .../src/lib/workspace-tab-palette-results.ts | 47 ++++++++--------- .../lib/workspace-tab-palette-search.test.ts | 17 +++++++ 25 files changed, 344 insertions(+), 136 deletions(-) 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 97b150994c7..fd46bf3f02b 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 @@ -35,7 +35,15 @@ export function capPaletteSection( return { visible: items, overflowCount: 0 } } const visible = items.slice(0, cap) - const retained = retain ? items.slice(cap).find(retain) : undefined + let retained: T | undefined + if (retain) { + for (let index = cap; index < items.length; index += 1) { + if (retain(items[index])) { + retained = items[index] + break + } + } + } if (retained !== undefined && cap > 0) { visible.splice(cap - 1, 1, retained) } diff --git a/src/renderer/src/components/tab-bar/open-tab-search.ts b/src/renderer/src/components/tab-bar/open-tab-search.ts index 176dddc0eef..99a7c833b04 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search.ts @@ -85,10 +85,9 @@ export type OpenTabSearchInput = { type RankedResult = { result: OpenTabSearchResult - sourceRank: number matchRank: PaletteDocumentRank activity: PaletteActivityRank - score: number + position: readonly [number, number] identity: string } @@ -161,10 +160,9 @@ function rank( const converted = toResult(result) return [ { - sourceRank: SOURCE_RANK[source], matchRank: result.rank, activity: result.activity, - score: result.score, + position: [SOURCE_RANK[source], result.score], result: converted, identity: converted.id } @@ -172,7 +170,7 @@ function rank( }) } -function searchOpenTabCandidates({ +export function searchOpenTabCandidates({ workspaceTabs, browserPages, simulatorTabs, @@ -267,13 +265,13 @@ function searchOpenTabCandidates({ { rank: a.matchRank, activity: a.activity, - position: [a.sourceRank, a.score], + position: a.position, identity: a.identity }, { rank: b.matchRank, activity: b.activity, - position: [b.sourceRank, b.score], + position: b.position, identity: b.identity } ) @@ -282,10 +280,17 @@ function searchOpenTabCandidates({ } export function searchOpenTabs(input: OpenTabSearchInput): OpenTabSearchResult[] { + return capOpenTabSearchCandidates(searchOpenTabCandidates(input), input.retainedResultId) +} + +export function capOpenTabSearchCandidates( + candidates: readonly OpenTabSearchResult[], + retainedResultId?: string | null +): OpenTabSearchResult[] { const capped = capPaletteSection( - searchOpenTabCandidates(input), + candidates, OPEN_TAB_SEARCH_RESULT_LIMIT, - (result) => result.id === input.retainedResultId + (result) => result.id === retainedResultId ) return [...capped.visible] } diff --git a/src/renderer/src/components/tab-bar/use-open-tab-search.ts b/src/renderer/src/components/tab-bar/use-open-tab-search.ts index 53f398fb668..c1de1d4df96 100644 --- a/src/renderer/src/components/tab-bar/use-open-tab-search.ts +++ b/src/renderer/src/components/tab-bar/use-open-tab-search.ts @@ -9,7 +9,11 @@ import { selectOpenTabSearchEntryState, type OpenTabSearchEntries } from './open-tab-search-entries' -import { searchOpenTabs, type OpenTabSearchResult } from './open-tab-search' +import { + capOpenTabSearchCandidates, + searchOpenTabCandidates, + type OpenTabSearchResult +} from './open-tab-search' import { usePaletteSearchEvaluationContext } from '@/hooks/use-palette-search-evaluation-context' const EMPTY_RESULTS: OpenTabSearchResult[] = [] @@ -51,25 +55,26 @@ export function useOpenTabSearch({ [agentState, state] ) const deferredQuery = useDeferredValue(query) - const evaluationSnapshot = useMemo( - () => ({ deferredQuery, enabled, entries }), - [deferredQuery, enabled, entries] - ) + const evaluationSnapshot = useMemo(() => ({ deferredQuery, enabled }), [deferredQuery, enabled]) const context = usePaletteSearchEvaluationContext(evaluationSnapshot) + const candidates = useMemo( + () => + entries + ? searchOpenTabCandidates({ + ...entries, + query: deferredQuery, + context + }) + : EMPTY_RESULTS, + [context, deferredQuery, entries] + ) return useMemo( () => ({ query: deferredQuery, entries, - results: entries - ? searchOpenTabs({ - ...entries, - query: deferredQuery, - context, - retainedResultId - }) - : EMPTY_RESULTS + results: capOpenTabSearchCandidates(candidates, retainedResultId) }), - [context, deferredQuery, entries, retainedResultId] + [candidates, deferredQuery, entries, retainedResultId] ) } diff --git a/src/renderer/src/components/use-worktree-jump-palette-controller.ts b/src/renderer/src/components/use-worktree-jump-palette-controller.ts index 97d3391e011..f5cbb83b619 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-controller.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-controller.ts @@ -31,28 +31,9 @@ export function useWorktreeJumpPaletteController({ const paletteEvaluationSnapshot = useMemo( () => ({ query: localState.paletteSearchQuery, - agentStatus: storeState.agentStatusByPaneKey, - worktrees: storeState.allWorktrees, - browserPages: storeState.browserPagesByWorkspace, - browserWorkspaces: storeState.browserTabsByWorktree, - openFiles: storeState.openFiles, - retainedAgents: storeState.retainedAgentsByPaneKey, - sleepingAgents: storeState.sleepingAgentSessionsByPaneKey, - unifiedTabs: storeState.unifiedTabsByWorktree, visible }), - [ - localState.paletteSearchQuery, - storeState.agentStatusByPaneKey, - storeState.allWorktrees, - storeState.browserPagesByWorkspace, - storeState.browserTabsByWorktree, - storeState.openFiles, - storeState.retainedAgentsByPaneKey, - storeState.sleepingAgentSessionsByPaneKey, - storeState.unifiedTabsByWorktree, - visible - ] + [localState.paletteSearchQuery, visible] ) const paletteSearchContext = usePaletteSearchEvaluationContext(paletteEvaluationSnapshot) const evaluation = { paletteSearchContext } diff --git a/src/renderer/src/components/use-worktree-jump-palette-sections.ts b/src/renderer/src/components/use-worktree-jump-palette-sections.ts index 114cb32b84c..42555b2dda0 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-sections.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-sections.ts @@ -103,16 +103,35 @@ export function useWorktreeJumpPaletteSections({ [setExpandedSectionCaps] ) + const openTabsCap = PALETTE_SECTION_RENDER_CAP + (expandedSectionCaps['open-tabs'] ?? 0) + const typedWorktreeCap = PALETTE_SECTION_RENDER_CAP + (expandedSectionCaps.worktrees ?? 0) + const openTabIndexById = useMemo( + () => new Map(openTabItems.map((item, index) => [item.id, index])), + [openTabItems] + ) + const worktreeIndexById = useMemo( + () => new Map(worktreeItems.map((item, index) => [item.id, index])), + [worktreeItems] + ) + const retainedOpenTabId = + hasQuery && (openTabIndexById.get(selectedItemId ?? '') ?? -1) >= openTabsCap + ? selectedItemId + : null + const retainedWorktreeId = + hasQuery && (worktreeIndexById.get(selectedItemId ?? '') ?? -1) >= typedWorktreeCap + ? selectedItemId + : null + const paletteSections = useMemo(() => { - const openTabsCap = PALETTE_SECTION_RENDER_CAP + (expandedSectionCaps['open-tabs'] ?? 0) - const retainSelected = (item: { id: string }): boolean => item.id === selectedItemId + const retainOpenTab = (item: { id: string }): boolean => item.id === retainedOpenTabId + const retainWorktree = (item: { id: string }): boolean => item.id === retainedWorktreeId // Why: "See more" drops the above-the-fold trim outright instead of stepping 20 at a time, so one // click reveals the whole recent history the shared render cap allows. const recentTabsCap = expandedSectionCaps['open-tabs'] ? openTabsCap : EMPTY_QUERY_RECENT_TAB_CAP const openTabs = hasQuery - ? capPaletteSection(openTabItems, openTabsCap, retainSelected) + ? capPaletteSection(openTabItems, openTabsCap, retainOpenTab) : capPaletteSection(recentTabItems, recentTabsCap) const baseWorktreeCap = hasQuery ? Infinity @@ -121,10 +140,10 @@ export function useWorktreeJumpPaletteSections({ Math.max(1, EMPTY_QUERY_ROW_BUDGET - openTabs.visible.length) ) const worktreeCap = hasQuery - ? PALETTE_SECTION_RENDER_CAP + (expandedSectionCaps.worktrees ?? 0) + ? typedWorktreeCap : baseWorktreeCap + (expandedSectionCaps.worktrees ?? 0) const worktrees = hasQuery - ? capPaletteSection(worktreeItems, worktreeCap, retainSelected) + ? capPaletteSection(worktreeItems, worktreeCap, retainWorktree) : { visible: worktreeItems.slice(0, worktreeCap), overflowCount: Math.max(0, worktreeItems.length - worktreeCap) @@ -148,8 +167,8 @@ export function useWorktreeJumpPaletteSections({ (expandedSectionCaps[openTabsLeadSections ? 'open-tabs' : 'worktrees'] ?? 0), leadingHardCap: openTabsLeadSections ? openTabsCap : worktreeCap, trailingHardCap: openTabsLeadSections ? worktreeCap : openTabsCap, - leadingRetain: retainSelected, - trailingRetain: retainSelected + leadingRetain: openTabsLeadSections ? retainOpenTab : retainWorktree, + trailingRetain: openTabsLeadSections ? retainWorktree : retainOpenTab }) : null return { @@ -170,9 +189,12 @@ export function useWorktreeJumpPaletteSections({ middleItems, openTabItems, openTabsLeadSections, + openTabsCap, projectTargetItems, recentTabItems, - selectedItemId, + retainedOpenTabId, + retainedWorktreeId, + typedWorktreeCap, worktreeItems ]) diff --git a/src/renderer/src/lib/browser-palette-search.ts b/src/renderer/src/lib/browser-palette-search.ts index 848fb5ade8a..7b4e143fa0a 100644 --- a/src/renderer/src/lib/browser-palette-search.ts +++ b/src/renderer/src/lib/browser-palette-search.ts @@ -5,6 +5,7 @@ import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-te import { compareBaseSensitivityLocaleText } from './locale-text-collators' import { comparePaletteTabResults, + isOmniboxPaletteTabFieldAllowed, matchPaletteTabDocument, preparePaletteTabQuery } from './palette-match/tab-match' @@ -45,6 +46,7 @@ export type SearchableBrowserPage = { export type BrowserPaletteSearchResult = { /** Worktree ids collide across hosts; activation must not resolve by id alone. */ executionHostId?: ExecutionHostId + paletteIdentity: string pageId: string workspaceId: string worktreeId: string @@ -163,6 +165,13 @@ function baseResult( const activity = preparePaletteActivity(entry.lastActiveAt, context) return { ...(executionHostId ? { executionHostId } : {}), + paletteIdentity: encodePaletteIdentity([ + 'browser', + executionHostId ?? '', + entry.worktree.id, + entry.workspace.id, + entry.page.id + ]), pageId: entry.page.id, workspaceId: entry.workspace.id, worktreeId: entry.worktree.id, @@ -215,10 +224,7 @@ export function searchBrowserPages( const base = baseResult(entry, context) const secondaryTexts = browserPaletteSecondaryTexts(entry.page) const match = matchPaletteTabDocument(entry.document, prepared, { - isFieldAllowed: - options.fieldMode === 'omnibox' - ? (field) => field.id !== 'worktree' && field.id !== 'repo' - : undefined + isFieldAllowed: options.fieldMode === 'omnibox' ? isOmniboxPaletteTabFieldAllowed : undefined }) if (!match) { continue @@ -248,25 +254,13 @@ export function searchBrowserPages( { rank: a.rank, positionScore: a.score, - identity: encodePaletteIdentity([ - 'browser', - a.executionHostId ?? '', - a.worktreeId, - a.workspaceId, - a.pageId - ]), + identity: a.paletteIdentity, activity: a.activity }, { rank: b.rank, positionScore: b.score, - identity: encodePaletteIdentity([ - 'browser', - b.executionHostId ?? '', - b.worktreeId, - b.workspaceId, - b.pageId - ]), + identity: b.paletteIdentity, activity: b.activity } ) diff --git a/src/renderer/src/lib/browser-workspace-tab-activation.test.ts b/src/renderer/src/lib/browser-workspace-tab-activation.test.ts index f8e351e1170..9d3594e5924 100644 --- a/src/renderer/src/lib/browser-workspace-tab-activation.test.ts +++ b/src/renderer/src/lib/browser-workspace-tab-activation.test.ts @@ -69,6 +69,20 @@ it('refuses a hostless browser tab for a remote worktree whose ID also exists lo ).toBeNull() }) +it('refuses hostless activation when the caller omits a host for an ambiguous worktree id', () => { + seedState( + { + local: [makeWorktree({ id: 'wt' })], + remote: [makeWorktree({ id: 'wt', repoId: 'repo-remote', hostId: 'ssh:remote' })] + }, + { ...browserTab, executionHostId: 'ssh:remote' } + ) + + expect( + getActivatableBrowserWorkspaceTab({ worktreeId: 'wt', workspaceId: 'workspace' }) + ).toBeNull() +}) + it('accepts a hostless browser tab when the worktree ID is unambiguous', () => { seedState({ remote: [makeWorktree({ id: 'wt', hostId: 'ssh:remote' })] }, browserTab) diff --git a/src/renderer/src/lib/browser-workspace-tab-activation.ts b/src/renderer/src/lib/browser-workspace-tab-activation.ts index 3f5146877b4..0855e448961 100644 --- a/src/renderer/src/lib/browser-workspace-tab-activation.ts +++ b/src/renderer/src/lib/browser-workspace-tab-activation.ts @@ -13,16 +13,17 @@ type BrowserWorkspaceTabTarget = { export function getActivatableBrowserWorkspaceTab(params: BrowserWorkspaceTabTarget): Tab | null { const state = useAppStore.getState() - const worktree = params.executionHostId - ? state.getKnownWorktreeById(params.worktreeId, params.executionHostId) - : undefined - if (params.executionHostId && !worktree) { - return null - } // A hostless tab cannot be attributed when the same worktree ID exists on several hosts. const ambiguousWorktreeIds = findAmbiguousWorktreeIds( getIndexedAllWorktrees(state.worktreesByRepo) ) + if (!params.executionHostId && ambiguousWorktreeIds.has(params.worktreeId)) { + return null + } + const worktree = state.getKnownWorktreeById(params.worktreeId, params.executionHostId) + if (!worktree) { + return null + } // setActiveBrowserTab resolves its backing tab globally by workspace ID. const tabs = Object.values(state.unifiedTabsByWorktree).flat() const browserTabs = tabs.filter( diff --git a/src/renderer/src/lib/cmd-j-section-leadership.test.ts b/src/renderer/src/lib/cmd-j-section-leadership.test.ts index 2269f1de078..da41a7172e3 100644 --- a/src/renderer/src/lib/cmd-j-section-leadership.test.ts +++ b/src/renderer/src/lib/cmd-j-section-leadership.test.ts @@ -15,6 +15,8 @@ function rank(overrides: Partial = {}): PaletteDocumentRank recovery: 0, wordMatch: 0, coverage: 0, + containerOnlyTokenCount: 0, + recoveryTokenCount: 0, strength: 0, placement: 2, ...overrides diff --git a/src/renderer/src/lib/palette-match/match-document.ts b/src/renderer/src/lib/palette-match/match-document.ts index e1644b38191..953e1ed4206 100644 --- a/src/renderer/src/lib/palette-match/match-document.ts +++ b/src/renderer/src/lib/palette-match/match-document.ts @@ -2,7 +2,7 @@ import { matchPaletteField, type PaletteFieldMatch } from './match-field' import { resolvePaletteResultQualityClass, type PaletteMatchQuality } from './match-quality' import { createPaletteQueryToken, type PaletteQueryToken } from './palette-query' import { - comparePaletteDocumentRank, + comparePaletteProofRank, type PaletteDocument, type PaletteDocumentMatch, type PaletteTokenAssignment @@ -103,7 +103,7 @@ function toCandidate(hits: readonly FieldHit[]): TokenCandidate { function matchCompositePairs( document: PaletteDocument, token: PaletteQueryToken, - isFieldAllowed: (field: PaletteIndexedField) => boolean + isFieldAllowed?: (field: PaletteIndexedField) => boolean ): TokenCandidate[] { if (!token.repoBranch || !document.compositePairs.length) { return [] @@ -114,7 +114,11 @@ function matchCompositePairs( for (const pair of document.compositePairs) { const leftField = document.fieldById.get(pair.leftFieldId) const rightField = document.fieldById.get(pair.rightFieldId) - if (!leftField || !rightField || !isFieldAllowed(leftField) || !isFieldAllowed(rightField)) { + if ( + !leftField || + !rightField || + (isFieldAllowed && (!isFieldAllowed(leftField) || !isFieldAllowed(rightField))) + ) { continue } const leftMatch = matchPaletteField(leftField, left) @@ -134,7 +138,7 @@ function matchCompositePairs( function collectTokenCandidates( document: PaletteDocument, token: PaletteQueryToken, - isFieldAllowed: (field: PaletteIndexedField) => boolean + isFieldAllowed?: (field: PaletteIndexedField) => boolean ): TokenCandidates | null { const candidates: TokenCandidates = { visible: matchCompositePairs(document, token, isFieldAllowed), @@ -142,7 +146,7 @@ function collectTokenCandidates( } let found = candidates.visible.length > 0 for (const field of document.fields) { - if (!isFieldAllowed(field)) { + if (isFieldAllowed && !isFieldAllowed(field)) { continue } const match = matchPaletteField(field, token) @@ -207,10 +211,9 @@ export function matchPaletteDocument(args: { isFieldAllowed?: (field: PaletteIndexedField) => boolean diagnostics?: PaletteMatchDiagnostics }): PaletteDocumentMatch | null { - const isFieldAllowed = args.isFieldAllowed ?? (() => true) const candidates: TokenCandidates[] = [] for (const token of args.tokens) { - const collected = collectTokenCandidates(args.document, token, isFieldAllowed) + const collected = collectTokenCandidates(args.document, token, args.isFieldAllowed) if (!collected) { return null } @@ -244,7 +247,13 @@ export function matchPaletteDocument(args: { args.normalizedQuery, null ) - for (const evidenceId of args.document.evidenceUnits.keys()) { + const matchedEvidenceIds = new Set() + for (const candidate of candidates) { + for (const evidenceId of candidate.byEvidenceId.keys()) { + matchedEvidenceIds.add(evidenceId) + } + } + for (const evidenceId of matchedEvidenceIds) { ranked.push( ...collectScopeAssignments({ document: args.document, @@ -270,7 +279,7 @@ export function matchPaletteDocument(args: { return null } ranked.sort((a, b) => { - const rank = comparePaletteDocumentRank(a.rank, b.rank) + const rank = comparePaletteProofRank(a.rank, b.rank) if (rank !== 0) { return rank } diff --git a/src/renderer/src/lib/palette-match/palette-assignment-ranking.ts b/src/renderer/src/lib/palette-match/palette-assignment-ranking.ts index 290cd944875..62b95072c6d 100644 --- a/src/renderer/src/lib/palette-match/palette-assignment-ranking.ts +++ b/src/renderer/src/lib/palette-match/palette-assignment-ranking.ts @@ -108,6 +108,10 @@ function rankSelected( recovery: Math.max(...selected.map((candidate) => candidate.recovery)), wordMatch: Math.max(...selected.map((candidate) => candidate.wordMatch)), coverage: Math.max(...selected.map((candidate) => candidate.coverage)), + containerOnlyTokenCount: selected.filter((candidate) => + candidate.hits.every((hit) => hit.field.role === 'container') + ).length, + recoveryTokenCount: selected.filter((candidate) => candidate.recovery > 0).length, strength: Math.max(...selected.map((candidate) => candidate.strength)), placement } diff --git a/src/renderer/src/lib/palette-match/palette-document.ts b/src/renderer/src/lib/palette-match/palette-document.ts index 1d0b90ca7bd..7ebcba3e8e5 100644 --- a/src/renderer/src/lib/palette-match/palette-document.ts +++ b/src/renderer/src/lib/palette-match/palette-document.ts @@ -132,6 +132,10 @@ export type PaletteDocumentRank = { recovery: number wordMatch: number coverage: number + /** Tokens proved only by container fields; fewer preserves direct-match relevance. */ + containerOnlyTokenCount: number + /** Tokens that required compact or typo recovery; fewer breaks equal-severity ties. */ + recoveryTokenCount: number strength: number /** 0 prefix, 1 later word boundary, 2 distributed/other. */ placement: number @@ -146,6 +150,18 @@ export type PaletteDocumentMatch = { } const RANK_KEYS: readonly (keyof PaletteDocumentRank)[] = [ + 'destination', + 'recovery', + 'wordMatch', + 'coverage', + 'containerOnlyTokenCount', + 'recoveryTokenCount', + 'strength', + 'placement' +] + +// Aggregate token counts distinguish entities without replacing the best rendered proof. +const PROOF_RANK_KEYS: readonly (keyof PaletteDocumentRank)[] = [ 'destination', 'recovery', 'wordMatch', @@ -159,6 +175,8 @@ const SEMANTIC_RANK_KEYS: readonly (keyof PaletteDocumentRank)[] = [ 'recovery', 'wordMatch', 'coverage', + 'containerOnlyTokenCount', + 'recoveryTokenCount', 'strength' ] @@ -184,10 +202,32 @@ export function comparePaletteDocumentRank(a: PaletteDocumentRank, b: PaletteDoc return compareRankKeys(a, b, RANK_KEYS) } +export function comparePaletteProofRank(a: PaletteDocumentRank, b: PaletteDocumentRank): number { + return compareRankKeys(a, b, PROOF_RANK_KEYS) +} + export function createRecognizedPaletteRank(): PaletteDocumentRank { - return { destination: 0, recovery: 0, wordMatch: 0, coverage: 0, strength: 0, placement: 0 } + return { + destination: 0, + recovery: 0, + wordMatch: 0, + coverage: 0, + containerOnlyTokenCount: 0, + recoveryTokenCount: 0, + strength: 0, + placement: 0 + } } export function createPaletteFallbackRank(): PaletteDocumentRank { - return { destination: 3, recovery: 0, wordMatch: 0, coverage: 0, strength: 0, placement: 0 } + return { + destination: 3, + recovery: 0, + wordMatch: 0, + coverage: 0, + containerOnlyTokenCount: 0, + recoveryTokenCount: 0, + strength: 0, + placement: 0 + } } diff --git a/src/renderer/src/lib/palette-match/palette-match-performance.test.ts b/src/renderer/src/lib/palette-match/palette-match-performance.test.ts index dc5ba359830..8ce90a7e795 100644 --- a/src/renderer/src/lib/palette-match/palette-match-performance.test.ts +++ b/src/renderer/src/lib/palette-match/palette-match-performance.test.ts @@ -6,7 +6,7 @@ import { preparePaletteQuery } from './palette-query' import { buildWorktreePaletteDocuments } from '../worktree-palette-document' import { searchWorktreeDocuments } from '../worktree-palette-search' import { comparePaletteEntityRanks, createPaletteSearchContext } from './palette-ranking' -import type { PaletteDocument } from './palette-document' +import { buildPaletteDocument, type PaletteDocument } from './palette-document' import type { PaletteQueryToken } from './palette-query' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' @@ -203,6 +203,55 @@ describe('palette matcher performance budget', () => { ) }) + it('does not revisit an all-visible assignment for unmatched evidence units', () => { + const buildDocument = (evidenceCount: number): PaletteDocument => + buildPaletteDocument({ + id: `visible-${evidenceCount}`, + visibleFields: [ + { + id: 'title', + profile: 'structured-label', + text: 'atlas', + role: 'primary', + destinationEligible: true + } + ], + evidence: Array.from({ length: evidenceCount }, (_, index) => ({ + unit: { + id: `evidence-${index}`, + kind: 'comment', + text: `unrelated ${index}`, + accessibilityLabel: 'Comment' + }, + fields: [ + { + id: `evidence-field-${index}`, + profile: 'prose' as const, + text: `unrelated ${index}`, + evidenceId: `evidence-${index}`, + renderOffset: 0 + } + ] + })) + }) + const query = preparePaletteQuery('atlas') + if (query.state !== 'ready') { + throw new Error('Expected ready query') + } + const selectionVisits = (document: PaletteDocument): number => { + const diagnostics: PaletteMatchDiagnostics = { selectionCandidateVisits: 0 } + matchPaletteDocument({ + document, + tokens: query.tokens, + normalizedQuery: query.normalized, + diagnostics + }) + return diagnostics.selectionCandidateVisits + } + + expect(selectionVisits(buildDocument(100))).toBe(selectionVisits(buildDocument(0))) + }) + it('keeps retained match and range payload within budget', () => { const documents = buildWorktreePaletteDocuments(worktrees, sources) const matches = matchEveryDocument(documents) diff --git a/src/renderer/src/lib/palette-match/palette-ranking.test.ts b/src/renderer/src/lib/palette-match/palette-ranking.test.ts index 41500f90aaa..7dd6a026774 100644 --- a/src/renderer/src/lib/palette-match/palette-ranking.test.ts +++ b/src/renderer/src/lib/palette-match/palette-ranking.test.ts @@ -19,6 +19,8 @@ function rank(overrides: Partial = {}): PaletteDocumentRank recovery: 0, wordMatch: 0, coverage: 0, + containerOnlyTokenCount: 0, + recoveryTokenCount: 0, strength: 0, placement: 2, ...overrides diff --git a/src/renderer/src/lib/palette-match/tab-match.ts b/src/renderer/src/lib/palette-match/tab-match.ts index b00ddd179ae..32057054c12 100644 --- a/src/renderer/src/lib/palette-match/tab-match.ts +++ b/src/renderer/src/lib/palette-match/tab-match.ts @@ -18,6 +18,10 @@ import { comparePaletteEntityRanks, type PaletteActivityRank } from './palette-r const NO_RANGES: readonly MatchRange[] = [] +export function isOmniboxPaletteTabFieldAllowed(field: Pick): boolean { + return field.id !== PALETTE_TAB_WORKTREE_FIELD_ID && field.id !== PALETTE_TAB_REPO_FIELD_ID +} + export type PaletteTabIndexedMatch = { index: number; ranges: readonly MatchRange[] } export type PaletteTabMatch = { diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index 679dcaa28fb..e54c6d21b5c 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -8,6 +8,7 @@ import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-te import { compareBaseSensitivityLocaleText } from './locale-text-collators' import { comparePaletteTabResults, + isOmniboxPaletteTabFieldAllowed, matchPaletteTabDocument, preparePaletteTabQuery } from './palette-match/tab-match' @@ -49,6 +50,7 @@ export type SearchableSimulatorTab = { export type SimulatorPaletteSearchResult = { /** Worktree ids collide across hosts; activation must not resolve by id alone. */ executionHostId?: ExecutionHostId + paletteIdentity: string tabId: string worktreeId: string groupId: string @@ -150,12 +152,19 @@ function baseResult( entry: SearchableSimulatorTab, context: PaletteSearchContext ): SimulatorPaletteSearchResult { + const executionHostId = getUnifiedTabPaletteExecutionHostId(entry.tab, entry.worktree) const activity = preparePaletteActivity( maxValidPaletteActivityTimestamp([entry.tab.lastFocusedAt, entry.tab.createdAt]), context ) return { - executionHostId: getUnifiedTabPaletteExecutionHostId(entry.tab, entry.worktree), + ...(executionHostId ? { executionHostId } : {}), + paletteIdentity: encodePaletteIdentity([ + 'simulator', + executionHostId ?? '', + entry.worktree.id, + entry.tab.id + ]), tabId: entry.tab.id, worktreeId: entry.worktree.id, groupId: entry.tab.groupId, @@ -270,10 +279,7 @@ export function searchSimulatorTabs( const results: SimulatorPaletteSearchResult[] = [] for (const entry of entries) { const match = matchPaletteTabDocument(entry.document, prepared, { - isFieldAllowed: - options.fieldMode === 'omnibox' - ? (field) => field.id !== 'worktree' && field.id !== 'repo' - : undefined + isFieldAllowed: options.fieldMode === 'omnibox' ? isOmniboxPaletteTabFieldAllowed : undefined }) if (!match) { continue @@ -304,23 +310,13 @@ export function searchSimulatorTabs( { rank: a.rank, positionScore: a.score, - identity: encodePaletteIdentity([ - 'simulator', - a.executionHostId ?? '', - a.worktreeId, - a.tabId - ]), + identity: a.paletteIdentity, activity: a.activity }, { rank: b.rank, positionScore: b.score, - identity: encodePaletteIdentity([ - 'simulator', - b.executionHostId ?? '', - b.worktreeId, - b.tabId - ]), + identity: b.paletteIdentity, activity: b.activity } ) diff --git a/src/renderer/src/lib/simulator-tab-palette-activation.test.ts b/src/renderer/src/lib/simulator-tab-palette-activation.test.ts index abad93ae838..dc398524c0e 100644 --- a/src/renderer/src/lib/simulator-tab-palette-activation.test.ts +++ b/src/renderer/src/lib/simulator-tab-palette-activation.test.ts @@ -157,6 +157,20 @@ describe('activateSimulatorTabPaletteResult', () => { expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() }) + it('rejects a hostless tab when its worktree id exists on multiple hosts', () => { + seedStore({ + worktreesByRepo: { + local: [makeWorktree()], + remote: [makeWorktree({ repoId: 'repo-2', hostId: 'ssh:remote', path: '/tmp/remote' })] + } + }) + + expect(activateSimulatorTabPaletteResult({ ...target, executionHostId: 'ssh:remote' })).toEqual( + { status: 'failed', reason: 'missing-tab' } + ) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + }) + it('reports an unknown worktree without activating', () => { seedStore({ worktreesByRepo: {} }) diff --git a/src/renderer/src/lib/simulator-tab-palette-activation.ts b/src/renderer/src/lib/simulator-tab-palette-activation.ts index 388b57d126a..444bf2733bb 100644 --- a/src/renderer/src/lib/simulator-tab-palette-activation.ts +++ b/src/renderer/src/lib/simulator-tab-palette-activation.ts @@ -1,7 +1,8 @@ import { useAppStore } from '@/store' import type { ExecutionHostId } from '../../../shared/execution-host' import { activateAndRevealWorktree } from './worktree-activation' -import { isUnifiedTabOwnedByWorktree } from './unified-tab-host-ownership' +import { getIndexedAllWorktrees } from '@/store/worktree-repo-index' +import { findAmbiguousWorktreeIds, isUnifiedTabOwnedByWorktree } from './unified-tab-host-ownership' export type SimulatorTabPaletteActivationFailure = 'missing-tab' | 'missing-worktree' @@ -29,10 +30,13 @@ export function activateSimulatorTabPaletteResult({ (candidate) => candidate.id === tabId ) const tab = tabs[0] + const ambiguousWorktreeIds = findAmbiguousWorktreeIds( + getIndexedAllWorktrees(initialState.worktreesByRepo) + ) if ( tabs.length !== 1 || tab.contentType !== 'simulator' || - !isUnifiedTabOwnedByWorktree(tab, worktree, new Set()) + !isUnifiedTabOwnedByWorktree(tab, worktree, ambiguousWorktreeIds) ) { return { status: 'failed', reason: 'missing-tab' } } diff --git a/src/renderer/src/lib/unified-tab-host-ownership.ts b/src/renderer/src/lib/unified-tab-host-ownership.ts index bddcb8da1c5..69730b10a2d 100644 --- a/src/renderer/src/lib/unified-tab-host-ownership.ts +++ b/src/renderer/src/lib/unified-tab-host-ownership.ts @@ -75,6 +75,12 @@ export function isOpenFileOwnedByWorktree( return isExecutionHostAliasForWorktree(LOCAL_EXECUTION_HOST_ID, worktree) } +export function hasOpenFileExecutionHostEvidence( + file: Pick +): boolean { + return Boolean(file.operationProvenance || file.externalSshTargetId || file.runtimeEnvironmentId) +} + export function getUnifiedTabPaletteExecutionHostId( tab: Pick | undefined, worktree: Pick 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 5e28c3736f7..1660a13930b 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts @@ -120,6 +120,7 @@ function makeResult( score: 0, qualityClass: null, rank: null, + paletteIdentity: 'terminal\u0000wt-1\u0000group-1\u0000unified-terminal-1', lastActiveAt: null, activity: { ageBucket: null, timestamp: 0 }, ...overrides diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.ts b/src/renderer/src/lib/workspace-tab-palette-activation.ts index 810e5f13d4c..a64259d8ead 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.ts @@ -11,6 +11,7 @@ import { activateAndRevealWorktree } from './worktree-activation' import { getIndexedAllWorktrees } from '@/store/worktree-repo-index' import { findAmbiguousWorktreeIds, + hasOpenFileExecutionHostEvidence, isOpenFileOwnedByWorktree, isUnifiedTabOwnedByWorktree } from './unified-tab-host-ownership' @@ -84,11 +85,9 @@ function validateTarget( return 'missing-file' } const file = files[0] - const hasExplicitHost = Boolean( - file.operationProvenance || file.externalSshTargetId || file.runtimeEnvironmentId - ) // A hostless file falls back to local ownership, which only decides the match when IDs collide. - const requiresOwnershipCheck = hasExplicitHost || ambiguousWorktreeIds.has(worktree.id) + const requiresOwnershipCheck = + hasOpenFileExecutionHostEvidence(file) || ambiguousWorktreeIds.has(worktree.id) if (requiresOwnershipCheck && !isOpenFileOwnedByWorktree(file, worktree)) { return 'missing-file' } diff --git a/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts b/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts index 609bb2b68e6..2d339ff61c4 100644 --- a/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts +++ b/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts @@ -24,6 +24,7 @@ import type { import { findAmbiguousWorktreeIds, getUnifiedTabPaletteExecutionHostId, + hasOpenFileExecutionHostEvidence, isOpenFileOwnedByWorktree, isUnifiedTabOwnedByWorktree } from './unified-tab-host-ownership' @@ -264,7 +265,9 @@ export function buildSearchableWorkspaceTabEntries({ ?.find( (candidate) => candidate.worktreeId === worktree.id && - (!ambiguousWorktreeIds.has(worktree.id) || + (!( + hasOpenFileExecutionHostEvidence(candidate) || ambiguousWorktreeIds.has(worktree.id) + ) || isOpenFileOwnedByWorktree(candidate, worktree)) ) if (!file) { diff --git a/src/renderer/src/lib/workspace-tab-palette-results.test.ts b/src/renderer/src/lib/workspace-tab-palette-results.test.ts index b428a45a8aa..12c93319029 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.test.ts @@ -286,8 +286,35 @@ describe('searchWorkspaceTabs ranking', () => { const results = searchWorkspaceTabs([containerEntry, directEntry], 'auth aurora') - expect(results.map((result) => result.tabId)).toEqual(['container-tab', 'direct-tab']) + expect(results.map((result) => result.tabId)).toEqual(['direct-tab', 'container-tab']) expect(results.map((result) => result.rank?.coverage)).toEqual([2, 2]) + expect(results.map((result) => result.rank?.containerOnlyTokenCount)).toEqual([1, 2]) + }) + + it('ranks one recovered token above an otherwise-equal all-recovered match', () => { + const oneRecovery = makeEntry({ id: 'one-recovery' }) + const twoRecoveries = makeEntry({ id: 'two-recoveries' }) + oneRecovery.document = buildPaletteTabDocument({ + id: 'one-recovery', + title: 'alphx bravo', + secondaryTexts: [], + worktreeName: 'workspace', + branch: 'main', + repoName: 'repo' + }) + twoRecoveries.document = buildPaletteTabDocument({ + id: 'two-recoveries', + title: 'alphx bravx', + secondaryTexts: [], + worktreeName: 'workspace', + branch: 'main', + repoName: 'repo' + }) + + const results = searchWorkspaceTabs([twoRecoveries, oneRecovery], 'alpha bravo') + + expect(results.map((result) => result.tabId)).toEqual(['one-recovery', 'two-recoveries']) + expect(results.map((result) => result.rank?.recoveryTokenCount)).toEqual([1, 2]) }) it('ranks direct tab title matches ahead of container-only worktree matches', () => { diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts index 98e6b9e19e3..c4d576908a1 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.ts @@ -1,6 +1,7 @@ import { compareBaseSensitivityLocaleText } from './locale-text-collators' import { comparePaletteTabResults, + isOmniboxPaletteTabFieldAllowed, matchPaletteTabDocument, preparePaletteTabQuery, isPaletteTabQueryRejected @@ -35,6 +36,7 @@ const NO_RANGES: readonly MatchRange[] = [] export type WorkspaceTabPaletteSearchResult = { /** Worktree ids collide across hosts; activation must not resolve by id alone. */ executionHostId?: ExecutionHostId + paletteIdentity: string tabId: string entityId: string worktreeId: string @@ -113,6 +115,12 @@ function baseResult( const activity = preparePaletteActivity(resolveWorkspaceTabLastActiveAt(entry), context) return { ...(executionHostId ? { executionHostId } : {}), + paletteIdentity: encodePaletteIdentity([ + 'workspace', + executionHostId ?? '', + entry.worktree.id, + entry.tab.id + ]), tabId: entry.tab.id, entityId: entry.tab.entityId, worktreeId: entry.worktree.id, @@ -148,19 +156,11 @@ function matchEntry( context: PaletteSearchContext, fieldMode: 'all' | 'omnibox' ): WorkspaceTabPaletteSearchResult | null { - const isOmniboxFieldAllowed = (field: { id: string }): boolean => - field.id !== 'worktree' && field.id !== 'repo' - const match = matchPaletteTabDocument(entry.document, query, { - isFieldAllowed: fieldMode === 'omnibox' ? isOmniboxFieldAllowed : undefined - }) - if (!match) { + const unrestrictedMatch = matchPaletteTabDocument(entry.document, query) + if (!unrestrictedMatch) { // Why kept separate: agent text is not part of the structured field set, so it // never contributes to token coverage — it only recovers a row nothing else found. - const unrestrictedStructuredMatch = - fieldMode === 'omnibox' ? matchPaletteTabDocument(entry.document, query) : null - const snippet = unrestrictedStructuredMatch - ? null - : matchWorkspaceTabAgentSnippet(entry.agentMetadata, query) + const snippet = matchWorkspaceTabAgentSnippet(entry.agentMetadata, query) if (!snippet) { return null } @@ -173,6 +173,17 @@ function matchEntry( } } + const match = + fieldMode !== 'omnibox' || + (unrestrictedMatch.worktreeRanges.length === 0 && unrestrictedMatch.repoRanges.length === 0) + ? unrestrictedMatch + : matchPaletteTabDocument(entry.document, query, { + isFieldAllowed: isOmniboxPaletteTabFieldAllowed + }) + if (!match) { + return null + } + const secondaryText = match.secondary !== null ? (entry.secondarySearchTexts[match.secondary.index] ?? entry.secondaryText) @@ -235,23 +246,13 @@ export function searchWorkspaceTabs( { rank: a.rank, positionScore: a.score, - identity: encodePaletteIdentity([ - 'workspace', - a.executionHostId ?? '', - a.worktreeId, - a.tabId - ]), + identity: a.paletteIdentity, activity: a.activity }, { rank: b.rank, positionScore: b.score, - identity: encodePaletteIdentity([ - 'workspace', - b.executionHostId ?? '', - b.worktreeId, - b.tabId - ]), + identity: b.paletteIdentity, activity: b.activity } ) 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 8b196a13233..a80ba14f922 100644 --- a/src/renderer/src/lib/workspace-tab-palette-search.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-search.test.ts @@ -164,6 +164,23 @@ describe('workspace-tab-palette-search', () => { expect(entries.map((entry) => entry.tab.id)).toEqual(['unified-editor-dup']) expect(entries[0]?.secondaryText).toBe(SRC_APP_RELATIVE_PATH) }) + + it('omits an editor row whose explicit file host disagrees with its unique worktree', () => { + const remote = makeWorktree({ hostId: 'ssh:remote' }) + const editor = makeUnifiedTab({ + id: 'remote-editor', + entityId: SRC_APP_PATH, + contentType: 'editor', + executionHostId: 'ssh:remote' + }) + const entries = buildEntries({ + worktrees: [remote], + unifiedTabsByWorktree: { 'wt-1': [editor] }, + openFiles: [makeOpenFile({ externalSshTargetId: 'other-host' })] + }) + + expect(entries).toEqual([]) + }) it('emits one entry per tab id when a session persisted the same id twice', () => { // Why: the palette keys rows by tab id, and duplicated persisted records used // to render the row twice under one React key, stranding a ghost row.