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 620f32869a5..410bf676e0d 100644 --- a/src/renderer/src/lib/cmd-j-section-leadership.test.ts +++ b/src/renderer/src/lib/cmd-j-section-leadership.test.ts @@ -12,7 +12,7 @@ import type { PaletteDocumentRank } from './palette-match/palette-document' function rank(overrides: Partial = {}): PaletteDocumentRank { return { exactIntent: 1, - matchedDirectField: 0, + containerOnlyTokenCount: 0, wholeQuery: 3, worstQuality: 5, usesSupportingEvidence: 0, diff --git a/src/renderer/src/lib/palette-match/match-document.ts b/src/renderer/src/lib/palette-match/match-document.ts index 9d19fe38220..7b2363c1e16 100644 --- a/src/renderer/src/lib/palette-match/match-document.ts +++ b/src/renderer/src/lib/palette-match/match-document.ts @@ -163,8 +163,10 @@ function rankAssignments(args: { let worstQuality: PaletteMatchQuality = 'field-exact' let fuzzyTokenCount = 0 const fields = new Set() - // Derive from field metadata only: an evidence field can itself be a container. - let hasDirectHit = false + let containerOnlyTokenCount = 0 + let tokenIndex = -1 + let tokenHasDirectField = false + let matchedTokenCount = 0 for (const assignment of args.assignments) { if (paletteMatchQualityRank(assignment.quality) > paletteMatchQualityRank(worstQuality)) { @@ -174,20 +176,32 @@ function rankAssignments(args: { fuzzyTokenCount += 1 } fields.add(assignment.fieldId) + if (assignment.tokenIndex !== tokenIndex) { + if (tokenIndex !== -1 && !tokenHasDirectField) { + containerOnlyTokenCount += 1 + } + tokenIndex = assignment.tokenIndex + tokenHasDirectField = false + matchedTokenCount += 1 + } const field = args.document.fieldById.get(assignment.fieldId) if (field && !field.isContainer) { - hasDirectHit = true + tokenHasDirectField = true } } - const isContainerOnly = !hasDirectHit + if (tokenIndex !== -1 && !tokenHasDirectField) { + containerOnlyTokenCount += 1 + } + const isContainerOnly = + containerOnlyTokenCount > 0 && containerOnlyTokenCount === matchedTokenCount return { worstQuality, isContainerOnly, rank: { exactIntent: args.exactIntent ? 0 : 1, - matchedDirectField: isContainerOnly ? 1 : 0, + containerOnlyTokenCount, wholeQuery: args.wholeQuery, worstQuality: paletteMatchQualityRank(worstQuality), usesSupportingEvidence: args.usesEvidence ? 1 : 0, diff --git a/src/renderer/src/lib/palette-match/palette-document.ts b/src/renderer/src/lib/palette-match/palette-document.ts index 2400921f6be..0934ff2cf41 100644 --- a/src/renderer/src/lib/palette-match/palette-document.ts +++ b/src/renderer/src/lib/palette-match/palette-document.ts @@ -129,8 +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 + /** Tokens whose chosen assignment only matched container fields. */ + containerOnlyTokenCount: number /** 0 equality, 1 prefix, 2 word boundary, 3 none — whole query in visible text. */ wholeQuery: number worstQuality: number @@ -150,7 +150,7 @@ export type PaletteDocumentMatch = { const RANK_KEYS: readonly (keyof PaletteDocumentRank)[] = [ 'exactIntent', - 'matchedDirectField', + 'containerOnlyTokenCount', '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 8862165bc79..de7bd7696c7 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 @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest' import { matchPaletteDocument } from './match-document' import { mapNormalizedRange, normalizePaletteText } from './normalized-text' import { preparePaletteQuery, PALETTE_QUERY_MAX_TOKENS } from './palette-query' -import { buildPaletteDocument, type PaletteDocumentInput } from './palette-document' +import { + buildPaletteDocument, + comparePaletteDocumentRank, + type PaletteDocumentInput +} from './palette-document' import { segmentPaletteText } from './text-segments' import { isWithinOnePaletteEdit } from './typo-distance' @@ -383,7 +387,7 @@ describe('typo distance', () => { }) describe('container field matching', () => { - it('marks matchedDirectField as 1 and demotes quality class when all tokens land on container fields', () => { + it('counts a container-only token and demotes quality class when every token lands on containers', () => { const tabDoc: PaletteDocumentInput = { id: 'tab-1', visibleFields: [ @@ -394,11 +398,11 @@ describe('container field matching', () => { } const match = run(tabDoc, '4360') expect(match).not.toBeNull() - expect(match?.rank.matchedDirectField).toBe(1) + expect(match?.rank.containerOnlyTokenCount).toBe(1) expect(match?.qualityClass).toBe('exact-evidence') }) - it('marks matchedDirectField as 0 when at least one token lands on a direct field', () => { + it('does not count a token that lands on a direct field', () => { const tabDoc: PaletteDocumentInput = { id: 'tab-1', visibleFields: [ @@ -409,7 +413,40 @@ describe('container field matching', () => { } const match = run(tabDoc, '4360') expect(match).not.toBeNull() - expect(match?.rank.matchedDirectField).toBe(0) + expect(match?.rank.containerOnlyTokenCount).toBe(0) expect(match?.qualityClass).toBe('exact-visible') }) + + it('ranks an all-direct multi-token match ahead of a mixed direct and container match', () => { + const direct = run( + { + id: 'direct', + visibleFields: [ + { id: 'title', profile: 'structured-label', text: 'alpha' }, + { id: 'path', profile: 'structured-label', text: 'beta' } + ], + evidence: [] + }, + 'alpha beta' + ) + const mixed = run( + { + id: 'mixed', + visibleFields: [ + { id: 'title', profile: 'structured-label', text: 'alpha' }, + { id: 'worktree', profile: 'structured-label', text: 'beta', isContainer: true } + ], + evidence: [] + }, + 'alpha beta' + ) + + expect(direct).not.toBeNull() + expect(mixed).not.toBeNull() + expect(direct?.rank.containerOnlyTokenCount).toBe(0) + expect(mixed?.rank.containerOnlyTokenCount).toBe(1) + if (direct && mixed) { + expect(comparePaletteDocumentRank(direct.rank, mixed.rank)).toBeLessThan(0) + } + }) }) diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts index c36a48dc615..5f344214e92 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts @@ -179,6 +179,24 @@ describe('orderRecentWorkspaceTabs', () => { ).toEqual(['alpha-2', 'alpha-1', 'beta-1']) }) + it('keeps an interleaved worktree block together before applying its focused-group MRU', () => { + const rows = [ + row('alpha-old', { worktreeId: 'wt-alpha', unifiedTabId: 'unified-alpha-old' }), + row('beta', { worktreeId: 'wt-beta', unifiedTabId: 'unified-beta' }), + row('alpha-new', { worktreeId: 'wt-alpha', unifiedTabId: 'unified-alpha-new' }) + ] + + expect( + order(rows, sources([]), { + focusedGroupTabRecency: new Map([ + ['unified-alpha-old', 0], + ['unified-alpha-new', 1], + ['unified-beta', 0] + ]) + }) + ).toEqual(['alpha-new', 'alpha-old', 'beta']) + }) + it('keeps input (positional) order when nothing else separates two rows', () => { const rows = [row('a', { worktreeId: 'wt-1' }), row('b', { worktreeId: 'wt-1' })] diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.ts b/src/renderer/src/lib/recent-workspace-tab-rows.ts index 5005030b81a..e0d0d7751f5 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.ts @@ -45,6 +45,7 @@ type RankedRow = { visitedAt: number | undefined focusOrdinal: number worktreeId: string + worktreeOrder: number } /** Classes 1 (blocked/waiting) and 2 (freshly done) are the rows that want the user. */ @@ -127,10 +128,8 @@ function compareRankedRows(a: RankedRow, b: RankedRow): number { } return b.visitedAt - a.visitedAt } - // Why: focusOrdinal is a per-worktree sequence, so comparing it across worktrees interleaves - // them arbitrarily. Keep input (positional) order instead. - if (a.worktreeId !== b.worktreeId) { - return 0 + if (a.worktreeOrder !== b.worktreeOrder) { + return a.worktreeOrder - b.worktreeOrder } return b.focusOrdinal - a.focusOrdinal } @@ -139,10 +138,16 @@ function compareRankedRows(a: RankedRow, b: RankedRow): number { * Rank rows into ids, most-wanted first: * tier 1 — needs attention: class 1 (blocked/waiting) then 2 (fresh done), newest first * tier 2 — everything else: worktree focus recency, then focused-group MRU - * Full ties keep input order, which callers pass in positional (worktree/group/tab) order. + * Equal worktree tiers preserve first-seen worktree order, then use that worktree's MRU. */ export function orderRecentWorkspaceTabs(inputs: RecentWorkspaceTabOrderInputs): string[] { const { rows, paneSources, now, lastVisitedAtByWorktreeId, focusedGroupTabRecency } = inputs + const worktreeOrder = new Map() + for (const row of rows) { + if (!worktreeOrder.has(row.worktreeId)) { + worktreeOrder.set(row.worktreeId, worktreeOrder.size) + } + } return rows .map((row): RankedRow => { const attention = resolveRecentWorkspaceTabAttention(row, paneSources, now) @@ -153,6 +158,7 @@ export function orderRecentWorkspaceTabs(inputs: RecentWorkspaceTabOrderInputs): attentionTimestamp: attention.attentionTimestamp, visitedAt: lastVisitedAtByWorktreeId[row.worktreeId], worktreeId: row.worktreeId, + worktreeOrder: worktreeOrder.get(row.worktreeId) ?? Number.MAX_SAFE_INTEGER, focusOrdinal: row.unifiedTabId === null ? NO_FOCUS_ORDINAL 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 3a67ed6a11b..ae8b0d497c2 100644 --- a/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts +++ b/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts @@ -21,7 +21,7 @@ import type { AgentMetadata } from './workspace-tab-agent-metadata' */ const AGENT_SNIPPET_RANK: PaletteDocumentRank = { exactIntent: 1, - matchedDirectField: 0, + containerOnlyTokenCount: Number.MAX_SAFE_INTEGER, wholeQuery: 3, worstQuality: paletteMatchQualityRank(PALETTE_MATCH_QUALITIES.at(-1) as PaletteMatchQuality) + 1, usesSupportingEvidence: 1, 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 a62c6870ae8..8c34265d3a1 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.test.ts @@ -99,13 +99,14 @@ function makeEntry({ describe('searchWorkspaceTabs lastActiveAt', () => { it('is null when neither agent activity nor worktree activity is known', () => { - const [result] = searchWorkspaceTabs([makeEntry()], '') + const [result] = searchWorkspaceTabs([makeEntry({ createdAt: 4000 })], '') expect(result.lastActiveAt).toBeNull() }) it('falls back to worktree PTY activity for editor tabs with no agent metadata', () => { const entry = makeEntry({ contentType: 'editor', + createdAt: 1000, worktree: makeWorktree({ lastActivityAt: 5000 }) }) const [result] = searchWorkspaceTabs([entry], '') @@ -148,9 +149,61 @@ describe('searchWorkspaceTabs lastActiveAt', () => { const [result] = searchWorkspaceTabs([entry], '') expect(result.lastActiveAt).toBe(6000) }) + + it('uses a newer tab focus instead of stale agent activity', () => { + const entry = makeEntry({ + id: 'tab-refocused', + createdAt: 1000, + agentLastActivityAt: 3000 + }) + entry.tab.lastFocusedAt = 8000 + + const [result] = searchWorkspaceTabs([entry], '') + + expect(result.lastActiveAt).toBe(8000) + }) + + it('uses newer agent activity instead of an older tab focus', () => { + const entry = makeEntry({ + id: 'tab-agent-active', + createdAt: 1000, + agentLastActivityAt: 8000 + }) + entry.tab.lastFocusedAt = 3000 + + const [result] = searchWorkspaceTabs([entry], '') + + expect(result.lastActiveAt).toBe(8000) + }) }) describe('searchWorkspaceTabs ranking', () => { + it('ranks a multi-token direct-plus-container hit above a container-only whole-query hit', () => { + const directEntry = makeEntry({ id: 'direct-tab' }) + const containerEntry = makeEntry({ id: 'container-tab' }) + directEntry.document = buildPaletteTabDocument({ + id: 'direct', + title: 'Auth notes', + secondaryTexts: [], + worktreeName: 'Aurora migration', + branch: 'main', + repoName: 'repo' + }) + containerEntry.document = buildPaletteTabDocument({ + id: 'container', + title: 'Unrelated notes', + secondaryTexts: [], + worktreeName: 'Auth Aurora', + branch: 'main', + repoName: 'repo' + }) + + const results = searchWorkspaceTabs([containerEntry, directEntry], 'auth aurora') + + expect(results.map((result) => result.tabId)).toEqual(['direct-tab', 'container-tab']) + expect(results.map((result) => result.rank?.containerOnlyTokenCount)).toEqual([1, 2]) + }) + it('ranks direct tab title matches ahead of container-only worktree matches', () => { const directEntry = makeEntry({ id: 'README-4360' }) const containerEntry = makeEntry({ id: 'unrelated-file' }) @@ -175,9 +228,9 @@ describe('searchWorkspaceTabs ranking', () => { 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[0].rank?.containerOnlyTokenCount).toBe(0) expect(results[1].tabId).toBe('unrelated-file') - expect(results[1].rank?.matchedDirectField).toBe(1) + expect(results[1].rank?.containerOnlyTokenCount).toBe(1) }) it('breaks tie between two container-matching tabs using lastActiveAt recency', () => { diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts index 67d77bec921..9eeeff525f6 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.ts @@ -87,15 +87,13 @@ function positionScore(entry: SearchableWorkspaceTab): number { } function resolveWorkspaceTabLastActiveAt(entry: SearchableWorkspaceTab): number | null { - // Agent activity wins when present; then explicit tab focus; otherwise fall back to worktree. - const candidate = - maxAgentActivityAt(entry.agentMetadata) ?? - entry.tab.lastFocusedAt ?? - (entry.worktree.lastActivityAt || null) + // Why: explicit tab activity outranks the worktree fallback; creation only clamps stale signals. + const tabLocalActivity = + Math.max(maxAgentActivityAt(entry.agentMetadata) ?? 0, entry.tab.lastFocusedAt ?? 0) || null + const candidate = tabLocalActivity || entry.worktree.lastActivityAt || null if (candidate == null) { return null } - // Never report activity older than the tab itself (e.g. a stale worktree signal). return Math.max(candidate, entry.tab.createdAt) } 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 303083dff92..27f06757387 100644 --- a/src/renderer/src/lib/worktree-palette-task-url-result.ts +++ b/src/renderer/src/lib/worktree-palette-task-url-result.ts @@ -38,7 +38,7 @@ export function buildWorktreePaletteTaskUrlResult(args: { qualityClass: 'exact-intent', rank: { exactIntent: 0, - matchedDirectField: 0, + containerOnlyTokenCount: 0, wholeQuery: 0, worstQuality: 0, usesSupportingEvidence: 1, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index d9aba06ac9d..6420f9e1e60 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -147,6 +147,8 @@ const tabSchema = z.object({ color: z.string().nullable(), sortOrder: z.number(), createdAt: z.number(), + // Why: corrupt optional recency must not discard the whole persisted tab. + lastFocusedAt: z.number().finite().nonnegative().optional().catch(undefined), isPreview: z.boolean().optional(), isPinned: z.boolean().optional(), // Why: persist the per-tab native-chat view mode so 'chat' survives reload / diff --git a/src/shared/workspace-session-tab-focus-schema.test.ts b/src/shared/workspace-session-tab-focus-schema.test.ts new file mode 100644 index 00000000000..9e2167d5082 --- /dev/null +++ b/src/shared/workspace-session-tab-focus-schema.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { parseWorkspaceSession } from './workspace-session-schema' + +function sessionWithLastFocusedAt(lastFocusedAt: unknown): Record { + return { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { + wt: [ + { + id: 'tab-1', + entityId: 'terminal-1', + groupId: 'group-1', + worktreeId: 'wt', + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + lastFocusedAt + } + ] + } + } +} + +describe('workspace session unified-tab focus timestamp', () => { + it('preserves a finite nonnegative lastFocusedAt', () => { + const result = parseWorkspaceSession(sessionWithLastFocusedAt(1_700_000_000_123)) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.unifiedTabs?.wt[0]?.lastFocusedAt).toBe(1_700_000_000_123) + } + }) + + it.each([-1, Number.NaN, Number.POSITIVE_INFINITY])( + 'drops an invalid lastFocusedAt without dropping its tab (%s)', + (lastFocusedAt) => { + const result = parseWorkspaceSession(sessionWithLastFocusedAt(lastFocusedAt)) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.unifiedTabs?.wt).toHaveLength(1) + expect(result.value.unifiedTabs?.wt[0]?.lastFocusedAt).toBeUndefined() + } + } + ) +})