diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 2a7188bf1..f96aa0b6d 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron' +import { app, ipcMain } from 'electron' import { join } from 'node:path' import { scanAiVaultSessions } from '../ai-vault/session-scanner' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' @@ -68,6 +68,13 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) => listAiVaultSessions(args) ) + // DOM focus/visibility events don't fire in the renderer on macOS app + // activation, so refresh-on-refocus needs this main-process signal. + app.on('browser-window-focus', (_event, window) => { + if (!window.isDestroyed()) { + window.webContents.send('aiVault:windowFocused') + } + }) } async function getAiVaultWslHomeDirs(): Promise { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 139474b25..3f31cc560 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -751,6 +751,8 @@ export type OpenCodeUsageApi = { export type AiVaultApi = { listSessions: (args?: AiVaultListArgs) => Promise + /** Fires when any app window regains OS focus; returns an unsubscribe. */ + onWindowFocused: (callback: () => void) => () => void } export type NativeChatReadSessionResult = { messages: NativeChatMessage[] } | { error: string } diff --git a/src/preload/index.ts b/src/preload/index.ts index e705586d4..9a162aa54 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3612,7 +3612,12 @@ const api = { aiVault: { listSessions: (args?: AiVaultListArgs): Promise => - ipcRenderer.invoke('aiVault:listSessions', args) + ipcRenderer.invoke('aiVault:listSessions', args), + onWindowFocused: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('aiVault:windowFocused', listener) + return () => ipcRenderer.removeListener('aiVault:windowFocused', listener) + } }, nativeChat: { 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 new file mode 100644 index 000000000..cdff27604 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -0,0 +1,304 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { AiVaultListResult } from '../../../../shared/ai-vault-types' +import { useAppStore } from '@/store' +import { + resetAiVaultForcedRescanThrottleForTest, + useAiVaultSessionRefresh +} from './ai-vault-session-refresh' + +const EMPTY_RESULT: AiVaultListResult = { + sessions: [], + issues: [], + scannedAt: '2026-07-01T00:00:00.000Z' +} + +const THROTTLE_MS = 5_000 + +const listSessionsMock = vi.fn<(args: unknown) => Promise>() + +// Captures the hook's subscription to the main-process window-focus push. +let windowFocusCallback: (() => void) | null = null +const onWindowFocusedMock = vi.fn((callback: () => void) => { + windowFocusCallback = callback + return () => { + windowFocusCallback = null + } +}) + +async function fireWindowFocused(): Promise { + await act(async () => { + windowFocusCallback?.() + }) + await flushMicrotasks() +} + +const initialAppState = useAppStore.getInitialState() + +const roots: Root[] = [] +let latest: ReturnType | null = null + +function HookProbe(props: { scopePaths: readonly string[] }): null { + latest = useAiVaultSessionRefresh(props.scopePaths) + return null +} + +async function renderHook(scopePaths: readonly string[] = []): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render(createElement(HookProbe, { scopePaths })) + }) +} + +async function flushMicrotasks(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function dispatch(target: EventTarget, type: string): Promise { + await act(async () => { + target.dispatchEvent(new Event(type)) + }) + await flushMicrotasks() +} + +async function advance(ms: number): Promise { + await act(async () => { + vi.advanceTimersByTime(ms) + }) + await flushMicrotasks() +} + +function makeAgentEntry(sessionId: string, state = 'working'): AgentStatusEntry { + return { + state, + prompt: '', + updatedAt: 0, + stateStartedAt: 0, + paneKey: `tab-${sessionId}:leaf-${sessionId}`, + stateHistory: [], + providerSession: { key: 'session_id', id: sessionId } + } as AgentStatusEntry +} + +async function setAgentStatuses(entries: Record): Promise { + await act(async () => { + useAppStore.setState({ agentStatusByPaneKey: entries }) + }) + await flushMicrotasks() +} + +function lastCallArgs(): unknown { + return listSessionsMock.mock.calls.at(-1)?.[0] +} + +beforeEach(() => { + vi.useFakeTimers() + listSessionsMock.mockReset().mockResolvedValue(EMPTY_RESULT) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only window.api shim + ;(window as any).api = { + aiVault: { listSessions: listSessionsMock, onWindowFocused: onWindowFocusedMock } + } + resetAiVaultForcedRescanThrottleForTest() + useAppStore.setState(initialAppState, true) +}) + +afterEach(() => { + roots.splice(0).forEach((root) => act(() => root.unmount())) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('useAiVaultSessionRefresh refocus behavior', () => { + it('bypasses the scan cache on mount so panel entry shows new sessions', async () => { + await renderHook() + await flushMicrotasks() + + expect(listSessionsMock).toHaveBeenCalledTimes(1) + expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ force: true }) + }) + + it('force re-scans on refocus once the throttle allows it', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('defers a refocus inside the throttle window to a trailing forced scan', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + // Within the throttle window nothing runs yet — the event must not be + // dropped, so it lands as one trailing scan when the throttle frees up. + await fireWindowFocused() + await dispatch(document, 'visibilitychange') + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await advance(THROTTLE_MS + 1) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('ignores focus/visibility events while the document is hidden', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden') + await advance(THROTTLE_MS + 1) + await dispatch(document, 'visibilitychange') + await fireWindowFocused() + await advance(THROTTLE_MS + 1) + + expect(listSessionsMock).toHaveBeenCalledTimes(1) + }) + + it('stops listening and cancels trailing scans after unmount', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + // Queue a trailing forced scan, then unmount before it fires. + await fireWindowFocused() + roots.splice(0).forEach((root) => act(() => root.unmount())) + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + await dispatch(document, 'visibilitychange') + + expect(listSessionsMock).toHaveBeenCalledTimes(1) + }) + + it('does not raise the loading flag for refocus refreshes', async () => { + await renderHook() + await flushMicrotasks() + await advance(THROTTLE_MS + 1) + + let resolveScan: ((result: AiVaultListResult) => void) | null = null + listSessionsMock.mockImplementationOnce( + () => new Promise((resolve) => (resolveScan = resolve)) + ) + await fireWindowFocused() + + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(latest?.loading).toBe(false) + + await act(async () => { + resolveScan?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:01.000Z' }) + }) + await flushMicrotasks() + expect(latest?.loading).toBe(false) + }) + + it('skips state updates when a refresh returns the applied snapshot', async () => { + await renderHook() + await flushMicrotasks() + const firstResult = latest?.scanResult + + // Same scannedAt = the snapshot already on screen was replayed. + listSessionsMock.mockResolvedValueOnce({ ...EMPTY_RESULT }) + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + expect(latest?.scanResult).toBe(firstResult) + + listSessionsMock.mockResolvedValueOnce({ + ...EMPTY_RESULT, + scannedAt: '2026-07-01T00:00:02.000Z' + }) + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + expect(latest?.scanResult).not.toBe(firstResult) + }) + + it('keeps the manual refresh button forcing a cache bypass', async () => { + await renderHook() + await flushMicrotasks() + + await act(async () => { + await latest?.refresh({ force: true }) + }) + + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('counts a manual force refresh against the rescan throttle', async () => { + await renderHook() + await flushMicrotasks() + + await advance(THROTTLE_MS + 1) + await act(async () => { + await latest?.refresh({ force: true }) + }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + // The button just scanned; an immediate refocus defers to trailing. + await fireWindowFocused() + expect(listSessionsMock).toHaveBeenCalledTimes(2) + }) +}) + +describe('useAiVaultSessionRefresh in-app agent session behavior', () => { + it('force re-scans when an agent session starts inside Orca', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await advance(THROTTLE_MS + 1) + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1') }) + + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('defers an in-throttle session start to a trailing forced scan', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1') }) + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await advance(THROTTLE_MS + 1) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('ignores agent activity on already-known sessions', async () => { + await renderHook() + await flushMicrotasks() + await advance(THROTTLE_MS + 1) + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1', 'working') }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + // Message/tool pings and state transitions on a known session must not + // re-trigger — only a session id we haven't seen does. + await advance(THROTTLE_MS + 1) + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1', 'done') }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + // A closed pane re-opening the same session is not a new session either. + await setAgentStatuses({}) + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1', 'working') }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + await setAgentStatuses({ 'pane-2': makeAgentEntry('sess-2', 'working') }) + expect(listSessionsMock).toHaveBeenCalledTimes(3) + }) +}) 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 8d9a7fdfd..98539f858 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 @@ -1,12 +1,35 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' +import { useAppStore } from '@/store' const SESSION_LIMIT = 500 +// Panel entry and window refocus must show sessions started since the last +// scan, so they bypass the main process's 15s cache — but a full scan parses +// up to ~1000 transcripts, so bound forced scans to one per interval. Module +// scope so the throttle survives panel remounts (the panel unmounts per tab). +const FORCED_RESCAN_MIN_INTERVAL_MS = 5_000 +let lastForcedRescanAt = 0 + +function consumeForcedRescanBudget(): boolean { + const now = Date.now() + if (now - lastForcedRescanAt < FORCED_RESCAN_MIN_INTERVAL_MS) { + return false + } + lastForcedRescanAt = now + return true +} + +export function resetAiVaultForcedRescanThrottleForTest(): void { + lastForcedRescanAt = 0 +} + +type AiVaultRefreshArgs = { force?: boolean; background?: boolean } + export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { error: string | null loading: boolean - refresh: (args?: { force?: boolean }) => Promise + refresh: (args?: AiVaultRefreshArgs) => Promise scanResult: AiVaultListResult | null sessions: AiVaultSession[] } { @@ -18,25 +41,39 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { const refreshInFlightRef = useRef(false) const pendingRefreshRef = useRef(false) const pendingForceRef = useRef(false) + const pendingBackgroundRef = useRef(true) + const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null) const mountedRef = useRef(true) const scopePathsKey = useMemo(() => scopePaths.join('\n'), [scopePaths]) const scopePathsRef = useRef(scopePaths) scopePathsRef.current = scopePaths - const refresh = useCallback(async (args: { force?: boolean } = {}): Promise => { + const refresh = useCallback(async (args: AiVaultRefreshArgs = {}): Promise => { // A scope change during an in-flight scan must not be dropped; queue one more // scan so the current scoped view is refreshed after the older scan settles. if (refreshInFlightRef.current) { pendingRefreshRef.current = true pendingForceRef.current ||= args.force === true + pendingBackgroundRef.current &&= args.background === true return } refreshInFlightRef.current = true const refreshId = refreshIdRef.current + 1 refreshIdRef.current = refreshId - setLoading(true) + // A manual force scan counts against the throttle so an auto rescan right + // after the button press doesn't trigger a second full scan. + if (args.force === true) { + lastForcedRescanAt = Date.now() + } + // Background (refocus) refreshes usually resolve from the main-process + // cache; suppressing the loading flag avoids a spinner flash on every + // return to the app. + if (args.background !== true) { + setLoading(true) + } setError(null) + const scopeKey = scopePathsRef.current.join('\n') try { const result = await window.api.aiVault.listSessions({ limit: SESSION_LIMIT, @@ -46,6 +83,15 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { if (!mountedRef.current || refreshIdRef.current !== refreshId) { return } + // A cache hit returns the snapshot already on screen; skip the state + // updates so refocus flips don't force pointless re-renders. + if ( + lastAppliedScanRef.current?.scopeKey === scopeKey && + lastAppliedScanRef.current.scannedAt === result.scannedAt + ) { + return + } + lastAppliedScanRef.current = { scopeKey, scannedAt: result.scannedAt } setScanResult(result) setSessions(result.sessions) } catch (err) { @@ -60,28 +106,114 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { if (pendingRefreshRef.current && mountedRef.current) { pendingRefreshRef.current = false const force = pendingForceRef.current + // The queued refresh is background-only if every queued caller was. + const background = pendingBackgroundRef.current pendingForceRef.current = false - void refresh({ force }) + pendingBackgroundRef.current = true + void refresh({ force, background }) } } // Deps are intentionally empty: refresh reads changing values through refs // and recurses on itself, so its identity must stay stable. }, []) + // Forced rescans triggered by events (refocus, agent-session starts) run + // immediately when the throttle allows, otherwise once as soon as it frees + // up — dropping the event would leave a just-started session invisible + // until some unrelated later trigger. + const forcedRescanTimerRef = useRef | null>(null) + const requestForcedRescan = useCallback(() => { + const waitMs = lastForcedRescanAt + FORCED_RESCAN_MIN_INTERVAL_MS - Date.now() + if (waitMs <= 0) { + lastForcedRescanAt = Date.now() + void refresh({ background: true, force: true }) + return + } + if (forcedRescanTimerRef.current !== null) { + return + } + forcedRescanTimerRef.current = setTimeout(() => { + forcedRescanTimerRef.current = null + lastForcedRescanAt = Date.now() + void refresh({ background: true, force: true }) + }, waitMs) + }, [refresh]) + useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false refreshIdRef.current += 1 refreshInFlightRef.current = false + if (forcedRescanTimerRef.current !== null) { + clearTimeout(forcedRescanTimerRef.current) + forcedRescanTimerRef.current = null + } } }, []) // Re-scan on mount and whenever the active scope changes, since the scanner - // tailors its in-scope results to scopePaths. + // tailors its in-scope results to scopePaths. Force (throttled) so + // re-entering the panel shows sessions newer than the 15s cache; when the + // throttle denies it, paint from cache now and catch up once it frees. useEffect(() => { - void refresh() - }, [refresh, scopePathsKey]) + const force = consumeForcedRescanBudget() + void refresh({ force }) + if (!force) { + requestForcedRescan() + } + }, [refresh, requestForcedRescan, scopePathsKey]) + + // Sessions started while the app was backgrounded should appear when the + // user returns, so refocus also bypasses the scan cache (throttled). OS + // refocus arrives via the main process — renderer DOM focus events don't + // fire on macOS app activation; visibilitychange covers minimize-restore. + useEffect(() => { + const onRefocus = (): void => { + if (document.visibilityState !== 'visible') { + return + } + requestForcedRescan() + } + const unsubscribeWindowFocus = window.api.aiVault.onWindowFocused?.(onRefocus) + document.addEventListener('visibilitychange', onRefocus) + return () => { + unsubscribeWindowFocus?.() + document.removeEventListener('visibilitychange', onRefocus) + } + }, [requestForcedRescan]) + + // Sessions started inside Orca never blur the window, so refocus alone + // can't surface them. Agent hooks already report provider sessions; re-scan + // only when a session id we haven't seen appears — state transitions are + // deliberately ignored, they fire constantly while agents work. + const agentSessionIdsKey = useAppStore((s) => { + const ids: string[] = [] + for (const entry of Object.values(s.agentStatusByPaneKey)) { + if (entry.providerSession?.id) { + ids.push(entry.providerSession.id) + } + } + return ids.sort().join('\n') + }) + const seenAgentSessionIdsRef = useRef | null>(null) + useEffect(() => { + const ids = agentSessionIdsKey === '' ? [] : agentSessionIdsKey.split('\n') + // The mount refresh already covers sessions live at mount time. + if (seenAgentSessionIdsRef.current === null) { + seenAgentSessionIdsRef.current = new Set(ids) + return + } + const seen = seenAgentSessionIdsRef.current + const freshIds = ids.filter((id) => !seen.has(id)) + if (freshIds.length === 0) { + return + } + for (const id of freshIds) { + seen.add(id) + } + requestForcedRescan() + }, [agentSessionIdsKey, requestForcedRescan]) return { error, loading, refresh, scanResult, sessions } } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index c7c472123..1da70b789 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -632,7 +632,8 @@ function createWebPreloadApi(): Partial { sessions: [], issues: [], scannedAt: new Date().toISOString() - }) + }), + onWindowFocused: () => () => {} }, preflight: createPreflightApi(), notifications: createNotificationsApi(),