From a37b3fca15a97824b596a3afe2ff6782e4197a92 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:41:22 -0700 Subject: [PATCH] Improve cmd+j search ranking with direct fields and recency Prioritize results matching direct fields (titles, content) over container fields (worktree, branch, repo). Use tab focus time to break ranking ties. Makes search more useful for quick navigation. --- .../src/components/WorktreeJumpPalette.tsx | 16 ++++- .../src/lib/browser-palette-search.ts | 15 +++- .../src/lib/cmd-j-section-leadership.test.ts | 13 +++- .../src/lib/cmd-j-section-leadership.ts | 11 ++- .../src/lib/palette-match/indexed-field.ts | 6 +- .../src/lib/palette-match/match-document.ts | 18 ++++- .../src/lib/palette-match/match-quality.ts | 6 +- .../src/lib/palette-match/palette-document.ts | 9 ++- .../palette-match/palette-match-core.test.ts | 32 +++++++++ .../src/lib/palette-match/tab-document.ts | 24 +++++-- .../src/lib/palette-match/tab-match.ts | 31 ++++---- .../src/lib/simulator-palette-search.ts | 15 +++- .../lib/workspace-tab-agent-snippet-match.ts | 1 + .../workspace-tab-palette-entry-builder.ts | 5 +- .../lib/workspace-tab-palette-results.test.ts | 70 +++++++++++++++++++ .../src/lib/workspace-tab-palette-results.ts | 21 ++++-- .../lib/worktree-palette-task-url-result.ts | 1 + src/renderer/src/store/slices/tabs.ts | 20 +++--- src/shared/tab-types.ts | 2 + 19 files changed, 267 insertions(+), 49 deletions(-) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index fb0487a2704..6304e1fa099 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -138,7 +138,7 @@ import { } from '@/components/browser-pane/host-guest/browser-focus' import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' import { buildSidebarHostOptions } from '@/components/sidebar/sidebar-host-options' -import { getPaletteHostBadge, type PaletteHostBadge } from '@/components/cmd-j/palette-host-badge' +import { getPaletteHostBadge } from '@/components/cmd-j/palette-host-badge' import { composeWorktreeHostIdentity, getWorktreeHostIdentity @@ -1385,8 +1385,18 @@ function WorktreeJumpPaletteContent({ // An empty query leaves every rank null, so ordering falls through to those scores. return items.sort((a, b) => comparePaletteRankedItems( - { rank: a.result.rank, order: a.result.score, id: a.id }, - { rank: b.result.rank, order: b.result.score, id: b.id } + { + rank: a.result.rank, + order: a.result.score, + id: a.id, + lastActiveAt: a.result.lastActiveAt ?? undefined + }, + { + rank: b.result.rank, + order: b.result.score, + id: b.id, + lastActiveAt: b.result.lastActiveAt ?? undefined + } ) ) }, [browserItems, simulatorItems, workspaceTabItems]) diff --git a/src/renderer/src/lib/browser-palette-search.ts b/src/renderer/src/lib/browser-palette-search.ts index ec1adeda66e..5072d0ee87d 100644 --- a/src/renderer/src/lib/browser-palette-search.ts +++ b/src/renderer/src/lib/browser-palette-search.ts @@ -55,6 +55,7 @@ export type BrowserPaletteSearchResult = { score: number qualityClass: PaletteResultQualityClass | null rank: PaletteDocumentRank | null + lastActiveAt?: number | null } export const BROWSER_PALETTE_QUERY_MAX_BYTES = 2 * 1024 @@ -208,8 +209,18 @@ export function searchBrowserPages( return results.sort((a, b) => a.rank && b.rank ? comparePaletteTabResults( - { rank: a.rank, positionScore: a.score, id: a.pageId }, - { rank: b.rank, positionScore: b.score, id: b.pageId } + { + rank: a.rank, + positionScore: a.score, + id: a.pageId, + lastActiveAt: a.lastActiveAt ?? undefined + }, + { + rank: b.rank, + positionScore: b.score, + id: b.pageId, + lastActiveAt: b.lastActiveAt ?? undefined + } ) : compareEmptyQueryResults(a, b) ) 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 bf0d082a501..620f32869a5 100644 --- a/src/renderer/src/lib/cmd-j-section-leadership.test.ts +++ b/src/renderer/src/lib/cmd-j-section-leadership.test.ts @@ -12,6 +12,7 @@ import type { PaletteDocumentRank } from './palette-match/palette-document' function rank(overrides: Partial = {}): PaletteDocumentRank { return { exactIntent: 1, + matchedDirectField: 0, wholeQuery: 3, worstQuality: 5, usesSupportingEvidence: 0, @@ -114,9 +115,15 @@ describe('ranked item comparison', () => { expect(comparePaletteRankedItems(strong, weak)).toBeLessThan(0) }) - it('falls back to the section order when match rank ties', () => { - const first = { rank: rank(), order: 1, id: 'z' } - const second = { rank: rank(), order: 2, id: 'a' } + it('prefers recently active item when match rank ties', () => { + const recent = { rank: rank(), order: 10, id: 'z', lastActiveAt: 2000 } + const older = { rank: rank(), order: 0, id: 'a', lastActiveAt: 1000 } + expect(comparePaletteRankedItems(recent, older)).toBeLessThan(0) + }) + + it('falls back to the section order when match rank and recency tie', () => { + const first = { rank: rank(), order: 1, id: 'z', lastActiveAt: 1000 } + const second = { rank: rank(), order: 2, id: 'a', lastActiveAt: 1000 } expect(comparePaletteRankedItems(first, second)).toBeLessThan(0) }) diff --git a/src/renderer/src/lib/cmd-j-section-leadership.ts b/src/renderer/src/lib/cmd-j-section-leadership.ts index 372eb2aebf6..093cacce567 100644 --- a/src/renderer/src/lib/cmd-j-section-leadership.ts +++ b/src/renderer/src/lib/cmd-j-section-leadership.ts @@ -31,9 +31,11 @@ export type PaletteRankedItem = { /** Existing smart-recency / list position, used only after match rank ties. */ order: number id: string + /** Timestamp of most recent activity (focus or agent interaction). */ + lastActiveAt?: number } -/** Match rank first, then the section's own recency order, then a stable id. */ +/** Match rank first, then recent activity, then positional order, then stable id. */ export function comparePaletteRankedItems(a: PaletteRankedItem, b: PaletteRankedItem): number { if (a.rank && b.rank) { const byRank = comparePaletteDocumentRank(a.rank, b.rank) @@ -43,6 +45,13 @@ export function comparePaletteRankedItems(a: PaletteRankedItem, b: PaletteRanked } else if (a.rank !== b.rank) { return a.rank ? -1 : 1 } + if (a.lastActiveAt !== b.lastActiveAt) { + const aTime = a.lastActiveAt ?? 0 + const bTime = b.lastActiveAt ?? 0 + if (aTime !== bTime) { + return bTime - aTime + } + } if (a.order !== b.order) { return a.order - b.order } diff --git a/src/renderer/src/lib/palette-match/indexed-field.ts b/src/renderer/src/lib/palette-match/indexed-field.ts index 5d87776bf47..0e87ae37895 100644 --- a/src/renderer/src/lib/palette-match/indexed-field.ts +++ b/src/renderer/src/lib/palette-match/indexed-field.ts @@ -30,6 +30,8 @@ export type PaletteFieldSource = { /** null marks a visible identity field; identity fields combine freely. */ evidenceId?: string | null identifier?: PaletteIdentifierOptions + /** Container-level fields (e.g. worktree/branch for tabs) demote when matched alone. */ + isContainer?: boolean } export type PaletteIndexedField = { @@ -40,6 +42,7 @@ export type PaletteIndexedField = { words: readonly PaletteWord[] evidenceId: string | null identifier: PaletteIdentifierOptions | null + isContainer: boolean } const IDENTIFIER_PREFIX_KINDS: ReadonlySet = new Set([ @@ -125,7 +128,8 @@ export function indexPaletteField(source: PaletteFieldSource): PaletteIndexedFie atoms: segments.atoms, words: segments.words, evidenceId: source.evidenceId ?? null, - identifier: source.identifier ?? null + identifier: source.identifier ?? null, + isContainer: Boolean(source.isContainer) } } diff --git a/src/renderer/src/lib/palette-match/match-document.ts b/src/renderer/src/lib/palette-match/match-document.ts index b5f7647ef28..a17116ad96f 100644 --- a/src/renderer/src/lib/palette-match/match-document.ts +++ b/src/renderer/src/lib/palette-match/match-document.ts @@ -154,14 +154,16 @@ function buildAssignments( } function rankAssignments(args: { + document: PaletteDocument assignments: readonly PaletteTokenAssignment[] usesEvidence: boolean wholeQuery: number exactIntent: boolean -}): { rank: PaletteDocumentRank; worstQuality: PaletteMatchQuality } { +}): { rank: PaletteDocumentRank; worstQuality: PaletteMatchQuality; isContainerOnly: boolean } { let worstQuality: PaletteMatchQuality = 'field-exact' let fuzzyTokenCount = 0 const fields = new Set() + let hasDirectHit = args.usesEvidence for (const assignment of args.assignments) { if (paletteMatchQualityRank(assignment.quality) > paletteMatchQualityRank(worstQuality)) { @@ -171,12 +173,20 @@ function rankAssignments(args: { fuzzyTokenCount += 1 } fields.add(assignment.fieldId) + const field = args.document.fieldById.get(assignment.fieldId) + if (field && !field.isContainer) { + hasDirectHit = true + } } + const isContainerOnly = !hasDirectHit + return { worstQuality, + isContainerOnly, rank: { exactIntent: args.exactIntent ? 0 : 1, + matchedDirectField: isContainerOnly ? 1 : 0, wholeQuery: args.wholeQuery, worstQuality: paletteMatchQualityRank(worstQuality), usesSupportingEvidence: args.usesEvidence ? 1 : 0, @@ -274,7 +284,8 @@ export function matchPaletteDocument(args: { continue } const usedEvidenceId = built.usesEvidence ? evidenceId : null - const { rank, worstQuality } = rankAssignments({ + const { rank, worstQuality, isContainerOnly } = rankAssignments({ + document, assignments: built.assignments, usesEvidence: built.usesEvidence, wholeQuery, @@ -288,7 +299,8 @@ export function matchPaletteDocument(args: { ? 'exact-intent' : resolvePaletteResultQualityClass({ worstQuality, - usesSupportingEvidence: built.usesEvidence + usesSupportingEvidence: built.usesEvidence, + isContainerOnly }), rank, assignments: built.assignments, diff --git a/src/renderer/src/lib/palette-match/match-quality.ts b/src/renderer/src/lib/palette-match/match-quality.ts index 77f3c7aa8df..d400489b6be 100644 --- a/src/renderer/src/lib/palette-match/match-quality.ts +++ b/src/renderer/src/lib/palette-match/match-quality.ts @@ -59,11 +59,15 @@ export function paletteResultQualityClassRank(value: PaletteResultQualityClass): export function resolvePaletteResultQualityClass(args: { worstQuality: PaletteMatchQuality usesSupportingEvidence: boolean + isContainerOnly?: boolean }): PaletteResultQualityClass { - const { worstQuality, usesSupportingEvidence } = args + const { worstQuality, usesSupportingEvidence, isContainerOnly } = args if (isFuzzyPaletteMatchQuality(worstQuality)) { return 'fuzzy-evidence' } + if (isContainerOnly) { + return isExactPaletteMatchQuality(worstQuality) ? 'exact-evidence' : 'partial-evidence' + } if (usesSupportingEvidence) { return isExactPaletteMatchQuality(worstQuality) ? 'exact-evidence' : 'partial-evidence' } diff --git a/src/renderer/src/lib/palette-match/palette-document.ts b/src/renderer/src/lib/palette-match/palette-document.ts index 0e92875d493..2400921f6be 100644 --- a/src/renderer/src/lib/palette-match/palette-document.ts +++ b/src/renderer/src/lib/palette-match/palette-document.ts @@ -40,6 +40,7 @@ export type PaletteDocument = { /** Visible identity fields, cached because they carry the whole-query check. */ visibleFields: readonly PaletteIndexedField[] fieldsByEvidenceId: ReadonlyMap + fieldById: ReadonlyMap } export type PaletteDocumentInput = { @@ -80,7 +81,9 @@ export function buildPaletteDocument(input: PaletteDocumentInput): PaletteDocume const fields = indexPaletteFields([...input.visibleFields, ...evidenceSources]) const fieldsByEvidenceId = new Map() const visibleFields: PaletteIndexedField[] = [] + const fieldById = new Map() for (const field of fields) { + fieldById.set(field.id, field) if (!field.evidenceId) { visibleFields.push(field) continue @@ -103,7 +106,8 @@ export function buildPaletteDocument(input: PaletteDocumentInput): PaletteDocume evidenceUnits, renderOffsetByFieldId, visibleFields, - fieldsByEvidenceId + fieldsByEvidenceId, + fieldById } } @@ -125,6 +129,8 @@ export type PaletteSupportingEvidence = { export type PaletteDocumentRank = { /** 0 when a recognized exact intent (such as a task URL) produced this row. */ exactIntent: number + /** 0 when query matched at least one direct field; 1 when all matched tokens are container-only. */ + matchedDirectField: number /** 0 equality, 1 prefix, 2 word boundary, 3 none — whole query in visible text. */ wholeQuery: number worstQuality: number @@ -144,6 +150,7 @@ export type PaletteDocumentMatch = { const RANK_KEYS: readonly (keyof PaletteDocumentRank)[] = [ 'exactIntent', + 'matchedDirectField', 'wholeQuery', 'worstQuality', 'usesSupportingEvidence', diff --git a/src/renderer/src/lib/palette-match/palette-match-core.test.ts b/src/renderer/src/lib/palette-match/palette-match-core.test.ts index 6fe7878513f..8862165bc79 100644 --- a/src/renderer/src/lib/palette-match/palette-match-core.test.ts +++ b/src/renderer/src/lib/palette-match/palette-match-core.test.ts @@ -381,3 +381,35 @@ describe('typo distance', () => { expect(isWithinOnePaletteEdit(a, b)).toBe(expected) }) }) + +describe('container field matching', () => { + it('marks matchedDirectField as 1 and demotes quality class when all tokens land on container fields', () => { + const tabDoc: PaletteDocumentInput = { + id: 'tab-1', + visibleFields: [ + { id: 'title', profile: 'structured-label', text: 'README.md' }, + { id: 'worktree', profile: 'structured-label', text: 'STA-4360-feature', isContainer: true } + ], + evidence: [] + } + const match = run(tabDoc, '4360') + expect(match).not.toBeNull() + expect(match?.rank.matchedDirectField).toBe(1) + expect(match?.qualityClass).toBe('exact-evidence') + }) + + it('marks matchedDirectField as 0 when at least one token lands on a direct field', () => { + const tabDoc: PaletteDocumentInput = { + id: 'tab-1', + visibleFields: [ + { id: 'title', profile: 'structured-label', text: 'wsl-transcript-4360.ts' }, + { id: 'worktree', profile: 'structured-label', text: 'STA-4360-feature', isContainer: true } + ], + evidence: [] + } + const match = run(tabDoc, '4360') + expect(match).not.toBeNull() + expect(match?.rank.matchedDirectField).toBe(0) + expect(match?.qualityClass).toBe('exact-visible') + }) +}) diff --git a/src/renderer/src/lib/palette-match/tab-document.ts b/src/renderer/src/lib/palette-match/tab-document.ts index d03c09a7706..8c289931afc 100644 --- a/src/renderer/src/lib/palette-match/tab-document.ts +++ b/src/renderer/src/lib/palette-match/tab-document.ts @@ -72,13 +72,29 @@ function dedupeSecondaryTexts( export function buildPaletteTabDocument(input: PaletteTabDocumentInput): PaletteDocument { const fields: PaletteFieldSource[] = [ { id: PALETTE_TAB_TITLE_FIELD_ID, profile: 'structured-label', text: input.title }, - { id: PALETTE_TAB_WORKTREE_FIELD_ID, profile: 'structured-label', text: input.worktreeName }, - { id: PALETTE_TAB_BRANCH_FIELD_ID, profile: 'structured-label', text: input.branch }, - { id: PALETTE_TAB_REPO_FIELD_ID, profile: 'structured-label', text: input.repoName }, + { + id: PALETTE_TAB_WORKTREE_FIELD_ID, + profile: 'structured-label', + text: input.worktreeName, + isContainer: true + }, + { + id: PALETTE_TAB_BRANCH_FIELD_ID, + profile: 'structured-label', + text: input.branch, + isContainer: true + }, + { + id: PALETTE_TAB_REPO_FIELD_ID, + profile: 'structured-label', + text: input.repoName, + isContainer: true + }, { id: PALETTE_TAB_WORKSPACE_FIELD_ID, profile: 'structured-label', - text: input.workspaceLabel ?? '' + text: input.workspaceLabel ?? '', + isContainer: true } ] diff --git a/src/renderer/src/lib/palette-match/tab-match.ts b/src/renderer/src/lib/palette-match/tab-match.ts index d9c65000fa0..f40dd6de03d 100644 --- a/src/renderer/src/lib/palette-match/tab-match.ts +++ b/src/renderer/src/lib/palette-match/tab-match.ts @@ -12,7 +12,11 @@ import { } from './tab-document' import type { MatchRange } from './normalized-text' import type { PaletteResultQualityClass } from './match-quality' -import type { PaletteDocument, PaletteDocumentRank } from './palette-document' +import { + comparePaletteDocumentRank, + type PaletteDocument, + type PaletteDocumentRank +} from './palette-document' const NO_RANGES: readonly MatchRange[] = [] @@ -93,22 +97,21 @@ export type PaletteTabRankInputs = { /** Existing positional score: current tab, current worktree, then list order. */ positionScore: number id: string + /** Timestamp of most recent activity (focus or agent interaction). */ + lastActiveAt?: number } -const RANK_KEYS: readonly (keyof PaletteDocumentRank)[] = [ - 'exactIntent', - 'wholeQuery', - 'worstQuality', - 'usesSupportingEvidence', - 'fuzzyTokenCount', - 'fieldHopCount' -] - -/** Lexicographic match rank first, then the section's existing recency order. */ +/** Lexicographic match rank first, then recent activity, then positional order. */ export function comparePaletteTabResults(a: PaletteTabRankInputs, b: PaletteTabRankInputs): number { - for (const key of RANK_KEYS) { - if (a.rank[key] !== b.rank[key]) { - return a.rank[key] - b.rank[key] + const byRank = comparePaletteDocumentRank(a.rank, b.rank) + if (byRank !== 0) { + return byRank + } + if (a.lastActiveAt !== b.lastActiveAt) { + const aTime = a.lastActiveAt ?? 0 + const bTime = b.lastActiveAt ?? 0 + if (aTime !== bTime) { + return bTime - aTime } } if (a.positionScore !== b.positionScore) { diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index f5d1faecde1..43196fd72ab 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -54,6 +54,7 @@ export type SimulatorPaletteSearchResult = { score: number qualityClass: PaletteResultQualityClass | null rank: PaletteDocumentRank | null + lastActiveAt?: number | null } type SimulatorPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simulator' @@ -290,8 +291,18 @@ export function searchSimulatorTabs( return results.sort((a, b) => a.rank && b.rank ? comparePaletteTabResults( - { rank: a.rank, positionScore: a.score, id: a.tabId }, - { rank: b.rank, positionScore: b.score, id: b.tabId } + { + rank: a.rank, + positionScore: a.score, + id: a.tabId, + lastActiveAt: a.lastActiveAt ?? undefined + }, + { + rank: b.rank, + positionScore: b.score, + id: b.tabId, + lastActiveAt: b.lastActiveAt ?? undefined + } ) : compareEmptyQueryResults(a, b) ) diff --git a/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts b/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts index f5b47aa1871..3a67ed6a11b 100644 --- a/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts +++ b/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts @@ -21,6 +21,7 @@ import type { AgentMetadata } from './workspace-tab-agent-metadata' */ const AGENT_SNIPPET_RANK: PaletteDocumentRank = { exactIntent: 1, + matchedDirectField: 0, wholeQuery: 3, worstQuality: paletteMatchQualityRank(PALETTE_MATCH_QUALITIES.at(-1) as PaletteMatchQuality) + 1, usesSupportingEvidence: 1, 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 2b70345904d..4eea1c3b9d6 100644 --- a/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts +++ b/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts @@ -92,9 +92,12 @@ function isCurrentWorkspaceTab({ ) { return false } + if (activeUnifiedTabId) { + return activeUnifiedTabId === tab.id + } const visibleType = tab.contentType === 'terminal' ? 'terminal' : 'editor' const storedType = activeTabTypeByWorktree[tab.worktreeId] ?? activeTabType - if (storedType !== visibleType || activeUnifiedTabId !== tab.id) { + if (storedType !== visibleType) { return false } return visibleType === 'terminal' 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 f0d52346ae4..a62c6870ae8 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.test.ts @@ -68,6 +68,7 @@ function makeEntry({ worktreeSortIndex: 0, groupSortIndex: 0, tabSortIndex: 0, + occupantAgent: null, title, secondaryText: '', titleSearchText: title, @@ -137,4 +138,73 @@ describe('searchWorkspaceTabs lastActiveAt', () => { const [result] = searchWorkspaceTabs([entry], '') expect(result.lastActiveAt).toBe(4000) }) + + it('uses tab lastFocusedAt when no agent metadata is present', () => { + const entry = makeEntry({ + id: 'tab-focused', + createdAt: 1000 + }) + entry.tab.lastFocusedAt = 6000 + const [result] = searchWorkspaceTabs([entry], '') + expect(result.lastActiveAt).toBe(6000) + }) +}) + +describe('searchWorkspaceTabs ranking', () => { + it('ranks direct tab title matches ahead of container-only worktree matches', () => { + const directEntry = makeEntry({ id: 'README-4360' }) + const containerEntry = makeEntry({ id: 'unrelated-file' }) + // Both are in WORKTREE_NAME 'Aurora Workspace', but suppose worktree has 4360 + directEntry.document = buildPaletteTabDocument({ + id: 'tab-direct', + title: 'STA-4360-fix.ts', + secondaryTexts: [], + worktreeName: 'STA-4360', + branch: 'main', + repoName: 'repo' + }) + containerEntry.document = buildPaletteTabDocument({ + id: 'tab-container', + title: 'other-file.ts', + secondaryTexts: [], + worktreeName: 'STA-4360', + branch: 'main', + repoName: 'repo' + }) + + const results = searchWorkspaceTabs([containerEntry, directEntry], '4360') + expect(results).toHaveLength(2) + expect(results[0].tabId).toBe('README-4360') + expect(results[0].rank?.matchedDirectField).toBe(0) + expect(results[1].tabId).toBe('unrelated-file') + expect(results[1].rank?.matchedDirectField).toBe(1) + }) + + it('breaks tie between two container-matching tabs using lastActiveAt recency', () => { + const olderTab = makeEntry({ id: 'older-tab' }) + const newerTab = makeEntry({ id: 'newer-tab' }) + olderTab.document = buildPaletteTabDocument({ + id: 'older', + title: 'file-a.ts', + secondaryTexts: [], + worktreeName: 'STA-4360', + branch: 'main', + repoName: 'repo' + }) + newerTab.document = buildPaletteTabDocument({ + id: 'newer', + title: 'file-b.ts', + secondaryTexts: [], + worktreeName: 'STA-4360', + branch: 'main', + repoName: 'repo' + }) + olderTab.tab.lastFocusedAt = 1000 + newerTab.tab.lastFocusedAt = 5000 + + const results = searchWorkspaceTabs([olderTab, newerTab], '4360') + expect(results).toHaveLength(2) + expect(results[0].tabId).toBe('newer-tab') + expect(results[1].tabId).toBe('older-tab') + }) }) diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts index 56b7db32cad..8e6989b7754 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.ts @@ -86,10 +86,11 @@ function positionScore(entry: SearchableWorkspaceTab): number { } function resolveWorkspaceTabLastActiveAt(entry: SearchableWorkspaceTab): number | null { - // Agent activity wins when present; otherwise fall back to the worktree's PTY - // activity, which covers idle terminals and editor tabs alike. + // Agent activity wins when present; then explicit tab focus; otherwise fall back to worktree. const candidate = - maxAgentActivityAt(entry.agentMetadata) ?? (entry.worktree.lastActivityAt || null) + maxAgentActivityAt(entry.agentMetadata) ?? + entry.tab.lastFocusedAt ?? + (entry.worktree.lastActivityAt || null) if (candidate == null) { return null } @@ -193,8 +194,18 @@ export function searchWorkspaceTabs( return results.sort((a, b) => a.rank && b.rank ? comparePaletteTabResults( - { rank: a.rank, positionScore: a.score, id: a.tabId }, - { rank: b.rank, positionScore: b.score, id: b.tabId } + { + rank: a.rank, + positionScore: a.score, + id: a.tabId, + lastActiveAt: a.lastActiveAt ?? undefined + }, + { + rank: b.rank, + positionScore: b.score, + id: b.tabId, + lastActiveAt: b.lastActiveAt ?? undefined + } ) : compareEmptyQueryResults(a, b) ) diff --git a/src/renderer/src/lib/worktree-palette-task-url-result.ts b/src/renderer/src/lib/worktree-palette-task-url-result.ts index 54c6e265c2d..303083dff92 100644 --- a/src/renderer/src/lib/worktree-palette-task-url-result.ts +++ b/src/renderer/src/lib/worktree-palette-task-url-result.ts @@ -38,6 +38,7 @@ export function buildWorktreePaletteTaskUrlResult(args: { qualityClass: 'exact-intent', rank: { exactIntent: 0, + matchedDirectField: 0, wholeQuery: 0, worstQuality: 0, usesSupportingEvidence: 1, diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index b1f1392d82f..54e2248556b 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -1059,14 +1059,18 @@ export const createTabsSlice: StateCreator = (set, })() : state.unreadTerminalTabs return { - unifiedTabsByWorktree: opts?.preservePreview - ? state.unifiedTabsByWorktree - : { - ...state.unifiedTabsByWorktree, - [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((item) => - item.id === tabId ? { ...item, isPreview: false } : item - ) - }, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((item) => + item.id === tabId + ? { + ...item, + isPreview: opts?.preservePreview ? item.isPreview : false, + lastFocusedAt: Date.now() + } + : item + ) + }, groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: (state.groupsByWorktree[worktreeId] ?? []).map((group) => diff --git a/src/shared/tab-types.ts b/src/shared/tab-types.ts index e456c1df412..2211668e615 100644 --- a/src/shared/tab-types.ts +++ b/src/shared/tab-types.ts @@ -58,6 +58,8 @@ export type Tab = { * underneath; `'terminal'` (the default for legacy/missing) shows the raw * xterm. Optional so sessions persisted before this field hydrate cleanly. */ viewMode?: 'terminal' | 'chat' + /** Timestamp when the tab was last focused / activated by the user. */ + lastFocusedAt?: number } export type TabGroup = {