mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
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.
This commit is contained in:
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { PaletteDocumentRank } from './palette-match/palette-document'
|
||||
function rank(overrides: Partial<PaletteDocumentRank> = {}): 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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<PaletteIdentifierKind> = new Set<PaletteIdentifierKind>([
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>()
|
||||
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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export type PaletteDocument = {
|
||||
/** Visible identity fields, cached because they carry the whole-query check. */
|
||||
visibleFields: readonly PaletteIndexedField[]
|
||||
fieldsByEvidenceId: ReadonlyMap<string, readonly PaletteIndexedField[]>
|
||||
fieldById: ReadonlyMap<string, PaletteIndexedField>
|
||||
}
|
||||
|
||||
export type PaletteDocumentInput = {
|
||||
@@ -80,7 +81,9 @@ export function buildPaletteDocument(input: PaletteDocumentInput): PaletteDocume
|
||||
const fields = indexPaletteFields([...input.visibleFields, ...evidenceSources])
|
||||
const fieldsByEvidenceId = new Map<string, PaletteIndexedField[]>()
|
||||
const visibleFields: PaletteIndexedField[] = []
|
||||
const fieldById = new Map<string, PaletteIndexedField>()
|
||||
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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ export function buildWorktreePaletteTaskUrlResult(args: {
|
||||
qualityClass: 'exact-intent',
|
||||
rank: {
|
||||
exactIntent: 0,
|
||||
matchedDirectField: 0,
|
||||
wholeQuery: 0,
|
||||
worstQuality: 0,
|
||||
usesSupportingEvidence: 1,
|
||||
|
||||
@@ -1059,14 +1059,18 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (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) =>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user