diff --git a/src/main/ai-vault/session-list-results.test.ts b/src/main/ai-vault/session-list-results.test.ts index ef0c578f662..f9d0f9308ff 100644 --- a/src/main/ai-vault/session-list-results.test.ts +++ b/src/main/ai-vault/session-list-results.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AiVaultListResult, AiVaultScanIssue, @@ -46,6 +46,51 @@ const SCOPE_TRUNCATION: AiVaultScanIssue = { } describe('mergeAiVaultListResults', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('keeps the latest input scannedAt instead of restamping the merge', () => { + const merged = mergeAiVaultListResults( + [ + { ...listResult([]), scannedAt: '2026-08-02T00:00:00.000Z' }, + { ...listResult([]), scannedAt: '2026-08-02T00:00:05.000Z' } + ], + undefined + ) + + expect(merged.scannedAt).toBe('2026-08-02T00:00:05.000Z') + }) + + it('ignores a future or unparsable remote stamp so the merge cannot pin the renderer guard', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-02T00:00:10.000Z')) + + expect( + mergeAiVaultListResults( + [ + { ...listResult([]), scannedAt: '2026-08-02T00:00:20.000Z' }, + { ...listResult([]), scannedAt: '2026-08-02T00:00:05.000Z' } + ], + undefined + ).scannedAt + ).toBe('2026-08-02T00:00:05.000Z') + + expect( + mergeAiVaultListResults( + [ + { ...listResult([]), scannedAt: 'scan-A' }, + { ...listResult([]), scannedAt: '2026-08-02T00:00:04.000Z' } + ], + undefined + ).scannedAt + ).toBe('2026-08-02T00:00:04.000Z') + + expect( + mergeAiVaultListResults([{ ...listResult([]), scannedAt: 'scan-A' }], undefined).scannedAt + ).toBe('2026-08-02T00:00:10.000Z') + }) + it('does not cap an Unlimited all-host merge', () => { const sessions = Array.from({ length: 1001 }, (_, index) => session(index)) const merged = mergeAiVaultListResults([{ ...listResult([]), sessions }], undefined, true) diff --git a/src/main/ai-vault/session-list-results.ts b/src/main/ai-vault/session-list-results.ts index 54e2fff7d5a..05203b9a4b4 100644 --- a/src/main/ai-vault/session-list-results.ts +++ b/src/main/ai-vault/session-list-results.ts @@ -75,6 +75,27 @@ export function mergeAiVaultListResults( .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) .slice(0, limit), issues, - scannedAt: new Date().toISOString() + // Why: a merge is not a new scan. Reminting here made every all-host cache + // hit look fresh to the renderer, which only skipped apply when scannedAt + // matched. Keep the latest input stamp so identical legs stay a no-op. + scannedAt: latestAiVaultScannedAt(results) } } + +function latestAiVaultScannedAt(results: readonly AiVaultListResult[]): string { + const now = new Date().toISOString() + let latest: string | undefined + for (const result of results) { + const stamp = result.scannedAt + // Remote legs carry their own clock and only `z.string()` validation. An + // unparsable or future stamp would pin the merged stamp above every local + // rescan and silently freeze the renderer's scannedAt equality guard. + if (Number.isNaN(Date.parse(stamp)) || stamp > now) { + continue + } + if (latest === undefined || stamp > latest) { + latest = stamp + } + } + return latest ?? now +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-identity.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-identity.test.ts new file mode 100644 index 00000000000..8bcee4aa67f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-identity.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' +import { reuseAiVaultListResult } from './ai-vault-session-identity' + +// Why: production rows carry nested previewMessages + subagent. A scalar +// {id, title} fixture reconciles even when the walker is broken, which is how +// a scannedAt-only or Object.is fix stays green. +function makeProductionSession(index: number, title = `session-${index}`): AiVaultSession { + const id = `local:codex:session-${index}:/sessions/session-${index}.jsonl` + const timestamp = new Date(Date.UTC(2026, 6, 1, 0, 0, index)).toISOString() + return { + id, + executionHostId: 'local', + executionHostPlatform: 'darwin', + agent: 'codex', + sessionId: `session-${index}`, + title, + cwd: '/Users/ada/orca', + branch: 'nwparker/ai-vault-session-list-identity', + model: 'gpt-5', + filePath: `/sessions/session-${index}.jsonl`, + codexHome: '/Users/ada/.codex', + createdAt: timestamp, + updatedAt: timestamp, + modifiedAt: timestamp, + messageCount: 4, + totalTokens: 1800, + previewMessages: [ + { + role: 'user', + text: 'keep session list identity on reminted scannedAt', + timestamp + }, + { + role: 'assistant', + text: 'reuse previous row refs when the transcript did not change', + timestamp + } + ], + previewMessagesTruncated: true, + lastUserPrompt: 'keep session list identity on reminted scannedAt', + queuedMessageCount: 0, + subagentTranscriptCount: 1, + resumeCommand: `codex resume session-${index}`, + subagent: { + parentSessionId: `session-${index}`, + agentType: 'Explore', + status: 'completed' + } + } +} + +function cloneResult(result: AiVaultListResult, scannedAt: string): AiVaultListResult { + const cloned = structuredClone(result) + cloned.scannedAt = scannedAt + return cloned +} + +describe('reuseAiVaultListResult', () => { + it('keeps the result, rows, and nested previewMessages across independently cloned remints', () => { + const current: AiVaultListResult = { + sessions: [makeProductionSession(1), makeProductionSession(2)], + issues: [ + { + executionHostId: 'ssh:dev-box', + agent: 'codex', + kind: 'scope', + path: '/home/ada', + message: 'Only the first 64 project paths were scanned.' + } + ], + scannedAt: '2026-07-01T00:00:00.000Z' + } + const incoming = cloneResult(current, '2026-07-01T00:00:15.000Z') + expect(incoming).not.toBe(current) + expect(incoming.sessions).not.toBe(current.sessions) + expect(incoming.sessions[0]).not.toBe(current.sessions[0]) + expect(incoming.sessions[0]?.previewMessages).not.toBe(current.sessions[0]?.previewMessages) + expect(incoming.sessions[0]?.subagent).not.toBe(current.sessions[0]?.subagent) + expect(incoming.issues).not.toBe(current.issues) + + const reused = reuseAiVaultListResult(current, incoming) + expect(reused).toBe(current) + expect(reused.sessions).toBe(current.sessions) + expect(reused.sessions[0]).toBe(current.sessions[0]) + expect(reused.sessions[0]?.previewMessages).toBe(current.sessions[0]?.previewMessages) + expect(reused.issues).toBe(current.issues) + }) + + it('replaces a changed row but reuses the unchanged sibling', () => { + const current: AiVaultListResult = { + sessions: [makeProductionSession(1), makeProductionSession(2)], + issues: [], + scannedAt: '2026-07-01T00:00:00.000Z' + } + const incoming = cloneResult(current, '2026-07-01T00:00:15.000Z') + const changed = incoming.sessions[1] + if (!changed?.previewMessages[0]) { + throw new Error('expected a nested preview message') + } + changed.previewMessages[0] = { ...changed.previewMessages[0], text: 'a new user turn' } + + const reused = reuseAiVaultListResult(current, incoming) + expect(reused).not.toBe(current) + expect(reused).not.toBe(incoming) + expect(reused.sessions[0]).toBe(current.sessions[0]) + expect(reused.sessions[1]).not.toBe(current.sessions[1]) + expect(reused.sessions[1]?.previewMessages[0]?.text).toBe('a new user turn') + expect(reused.scannedAt).toBe('2026-07-01T00:00:15.000Z') + }) + + it('replaces issues when sessions are unchanged', () => { + const hostIssue = { + executionHostId: 'ssh:dev-box' as const, + agent: 'codex' as const, + kind: 'host' as const, + path: 'dev-box', + message: 'Remote connection dropped.' + } + const current: AiVaultListResult = { + sessions: [makeProductionSession(1)], + issues: [hostIssue], + scannedAt: '2026-07-01T00:00:00.000Z' + } + const incoming = cloneResult(current, '2026-07-01T00:00:15.000Z') + incoming.issues = [] + + const reused = reuseAiVaultListResult(current, incoming) + expect(reused).not.toBe(current) + expect(reused.sessions).toBe(current.sessions) + expect(reused.issues).toEqual([]) + expect(reused.issues).toBe(incoming.issues) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-identity.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-identity.ts new file mode 100644 index 00000000000..9798318b632 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-identity.ts @@ -0,0 +1,44 @@ +import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' +import { areValuesEqual } from '@/store/slices/repo-identity-reconcile' +import { reuseEqualCatalogRows } from '@/store/slices/worktree-catalog-reconciliation' + +export const EMPTY_AI_VAULT_SESSIONS: AiVaultSession[] = [] + +// Why: listSessions always structured-clones nested session rows (previewMessages, +// subagent). A TTL miss remints scannedAt even when the disk contents did not +// change, and all-host merge used to remint it even on cache-hit legs. The panel +// only skipped apply when scannedAt matched, so alt-tab after 15s rebuilt +// sessionProjectById + the worktree path map for every row. Reuse previous row +// and result identity when the payload is structurally unchanged so those memos +// stay cold. Reference compare is inert here — IPC clones never match. +export function reuseAiVaultListResult( + current: AiVaultListResult | null, + incoming: AiVaultListResult +): AiVaultListResult { + if (current === incoming) { + return current + } + if (!current) { + return incoming + } + const sessions = reuseEqualCatalogRows(current.sessions, incoming.sessions) + const issues = areValuesEqual(current.issues, incoming.issues) ? current.issues : incoming.issues + if ( + sessions === current.sessions && + issues === current.issues && + current.cancelled === incoming.cancelled + ) { + return current + } + if (sessions === incoming.sessions && issues === incoming.issues) { + return incoming + } + return { ...incoming, sessions, issues } +} + +export function applyPublishedAiVaultList( + published: AiVaultListResult, + setScanResult: (updater: (prev: AiVaultListResult | null) => AiVaultListResult) => void +): void { + setScanResult((prev) => reuseAiVaultListResult(prev, published)) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index b1e9b819ac0..18931768d15 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -431,13 +431,140 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { await fireWindowFocused() expect(latest?.scanResult).toBe(firstResult) + // A reminted stamp with the same empty body is still the snapshot on screen. listSessionsMock.mockResolvedValueOnce({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:02.000Z' }) await advance(THROTTLE_MS + 1) await fireWindowFocused() - expect(latest?.scanResult).not.toBe(firstResult) + expect(latest?.scanResult).toBe(firstResult) + }) + + it('keeps session row identity when a reminted scan is a structuredClone of the same nested rows', async () => { + const session = makeVaultSession(1) + const first: AiVaultListResult = { + sessions: [ + { + ...session, + previewMessages: [ + { + role: 'user', + text: 'keep session list identity on reminted scannedAt', + timestamp: session.modifiedAt + }, + { + role: 'assistant', + text: 'reuse previous row refs when the transcript did not change', + timestamp: session.modifiedAt + } + ], + previewMessagesTruncated: true, + lastUserPrompt: 'keep session list identity on reminted scannedAt', + subagent: { + parentSessionId: session.sessionId, + agentType: 'Explore', + status: 'completed' + } + } + ], + issues: [], + scannedAt: '2026-07-01T00:00:00.000Z' + } + listSessionsMock.mockResolvedValueOnce(first) + await renderHook() + await flushMicrotasks() + const appliedSessions = latest?.sessions + const appliedResult = latest?.scanResult + expect(appliedSessions?.[0]?.previewMessages).toHaveLength(2) + + const reminted = structuredClone(first) + reminted.scannedAt = '2026-07-01T00:00:15.000Z' + expect(reminted.sessions).not.toBe(first.sessions) + expect(reminted.sessions[0]).not.toBe(first.sessions[0]) + expect(reminted.sessions[0]?.previewMessages).not.toBe(first.sessions[0]?.previewMessages) + + listSessionsMock.mockResolvedValueOnce(reminted) + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + + expect(latest?.sessions).toBe(appliedSessions) + expect(latest?.sessions[0]).toBe(appliedSessions?.[0]) + expect(latest?.sessions[0]?.previewMessages).toBe(appliedSessions?.[0]?.previewMessages) + expect(latest?.scanResult).toBe(appliedResult) + }) + + it('replaces the changed row when a reminted scan edits nested preview text', async () => { + const session = makeVaultSession(1) + const sibling = makeVaultSession(2) + const first: AiVaultListResult = { + sessions: [ + { + ...session, + previewMessages: [ + { role: 'user', text: 'original ask', timestamp: session.modifiedAt } + ] + }, + sibling + ], + issues: [], + scannedAt: '2026-07-01T00:00:00.000Z' + } + listSessionsMock.mockResolvedValueOnce(first) + await renderHook() + await flushMicrotasks() + const appliedSessions = latest?.sessions + expect(appliedSessions).toHaveLength(2) + + const reminted = structuredClone(first) + reminted.scannedAt = '2026-07-01T00:00:15.000Z' + const changed = reminted.sessions[0] + const preview = changed?.previewMessages[0] + if (!changed || !preview) { + throw new Error('expected a nested preview message') + } + reminted.sessions[0] = { + ...changed, + previewMessages: [{ ...preview, text: 'follow-up ask' }] + } + + listSessionsMock.mockResolvedValueOnce(reminted) + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + + expect(latest?.sessions).not.toBe(appliedSessions) + expect(latest?.sessions[0]).not.toBe(appliedSessions?.[0]) + expect(latest?.sessions[0]?.previewMessages[0]?.text).toBe('follow-up ask') + expect(latest?.sessions[1]).toBe(appliedSessions?.[1]) + }) + + it('appends a new session on refocus and keeps the surviving row identity', async () => { + const first: AiVaultListResult = { + sessions: [makeVaultSession(1)], + issues: [], + scannedAt: '2026-07-01T00:00:00.000Z' + } + listSessionsMock.mockResolvedValueOnce(first) + await renderHook() + await flushMicrotasks() + const surviving = latest?.sessions[0] + const previous = first.sessions[0] + expect(surviving?.id).toBe('session-1') + if (!previous) { + throw new Error('expected the first scan to include a session') + } + + listSessionsMock.mockResolvedValueOnce({ + sessions: [structuredClone(previous), makeVaultSession(2)], + issues: [], + scannedAt: '2026-07-01T00:00:15.000Z' + }) + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + + expect(latest?.sessions).toHaveLength(2) + expect(latest?.sessions[0]).toBe(surviving) + expect(latest?.sessions[1]?.id).toBe('session-2') }) it('keeps the current list when a superseded scan resolves cancelled', async () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index e0d9eea9b3e..e18c26290f9 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -8,6 +8,7 @@ import type { ExecutionHostScope } from '../../../../shared/execution-host' import { useAppStore } from '@/store' import type { AiVaultSessionLimit } from './ai-vault-session-limit' import { AiVaultSessionPublicationGate } from './ai-vault-session-publication-gate' +import { applyPublishedAiVaultList, EMPTY_AI_VAULT_SESSIONS } from './ai-vault-session-identity' import { aiVaultSessionResultCacheKey, cacheAiVaultSessionResult, @@ -42,8 +43,8 @@ export function useAiVaultSessionRefresh( scanResult: AiVaultListResult | null sessions: AiVaultSession[] } { - const [sessions, setSessions] = useState([]) const [scanResult, setScanResult] = useState(null) + const sessions = scanResult?.sessions ?? EMPTY_AI_VAULT_SESSIONS const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const requestTokenRef = useRef(crypto.randomUUID()) @@ -92,8 +93,7 @@ export function useAiVaultSessionRefresh( lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: cachedResult.scannedAt } setError(null) publicationGateRef.current.publish(cachedResult, (published) => { - setScanResult(published) - setSessions(published.sessions) + applyPublishedAiVaultList(published, setScanResult) }) setLoading(false) return @@ -161,8 +161,7 @@ export function useAiVaultSessionRefresh( }) publicationGateRef.current.publish(result, (published) => { if (mountedRef.current && scanKey === currentScanScopeKey()) { - setScanResult(published) - setSessions(published.sessions) + applyPublishedAiVaultList(published, setScanResult) } }) } catch (err) {