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.