Refresh AI Vault session list on window refocus (#7075)

* Refresh AI Vault session list when the window regains focus

Sessions started after the panel mounted never appeared until a manual
refresh, since the hook only scanned on mount and scope changes. Listen
for window focus and visibilitychange (to visible) while the panel is
mounted and trigger a non-force re-scan, letting the main process's 15s
scan cache rate-limit rapid focus flips. The manual refresh button keeps
its force (cache-bypassing) behavior.

Co-authored-by: Orca <help@stably.ai>

* Keep AI Vault refocus refresh render-free on cache hits

Refocus refreshes now run as background: the loading flag stays down (no
spinner flash on every alt-tab back), and when the main process replays
the cached snapshot (same scope key + scannedAt) the state updates are
skipped entirely so nothing re-renders. Fresh scans and the manual force
refresh apply results exactly as before.

Co-authored-by: Orca <help@stably.ai>

* Bypass AI Vault scan cache on panel entry and refocus, throttled

Non-force refreshes were served the 15s-old cached snapshot, so a
session started right before re-entering the panel or refocusing the
window still didn't appear — only the manual force refresh showed it.
Panel entry and refocus now request a force scan, throttled in module
scope to one forced scan per 5s (surviving panel remounts), so rapid
tab/focus flips still resolve from the main-process cache. Manual force
refreshes count against the throttle to avoid back-to-back full scans.

Co-authored-by: Orca <help@stably.ai>

* Deliver AI Vault refocus via main-process signal; calm in-app triggers

Renderer DOM focus/visibility events never fire on macOS app activation
(verified live: document.hasFocus() stays true and no focus/blur event
lands when the window loses/gains OS focus), so the refocus refresh was
inert. Main now broadcasts browser-window-focus to the renderer as
aiVault:windowFocused and the hook subscribes to that instead.

Sessions started inside Orca (window never blurs) get their own trigger
from agent hooks: re-scan only when an unseen provider session id
appears in agent status. State transitions and message pings on known
sessions are deliberately ignored — keying on them made the panel churn
on every AI message. Event-driven rescans that land inside the 5s
throttle window defer to one trailing scan instead of being dropped.

Verified end-to-end against a dev instance: a headless claude session
appeared in the panel on OS-level refocus and another on panel
re-entry, without the manual refresh button.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing 2026-07-02 01:45:03 -07:00 committed by GitHub
parent 366746ad2b
commit 6142ec1a06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 461 additions and 10 deletions

View File

@ -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<string[]> {

View File

@ -751,6 +751,8 @@ export type OpenCodeUsageApi = {
export type AiVaultApi = {
listSessions: (args?: AiVaultListArgs) => Promise<AiVaultListResult>
/** Fires when any app window regains OS focus; returns an unsubscribe. */
onWindowFocused: (callback: () => void) => () => void
}
export type NativeChatReadSessionResult = { messages: NativeChatMessage[] } | { error: string }

View File

@ -3612,7 +3612,12 @@ const api = {
aiVault: {
listSessions: (args?: AiVaultListArgs): Promise<unknown> =>
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: {

View File

@ -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<AiVaultListResult>>()
// 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<void> {
await act(async () => {
windowFocusCallback?.()
})
await flushMicrotasks()
}
const initialAppState = useAppStore.getInitialState()
const roots: Root[] = []
let latest: ReturnType<typeof useAiVaultSessionRefresh> | null = null
function HookProbe(props: { scopePaths: readonly string[] }): null {
latest = useAiVaultSessionRefresh(props.scopePaths)
return null
}
async function renderHook(scopePaths: readonly string[] = []): Promise<void> {
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<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
async function dispatch(target: EventTarget, type: string): Promise<void> {
await act(async () => {
target.dispatchEvent(new Event(type))
})
await flushMicrotasks()
}
async function advance(ms: number): Promise<void> {
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<string, AgentStatusEntry>): Promise<void> {
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<AiVaultListResult>((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)
})
})

View File

@ -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<void>
refresh: (args?: AiVaultRefreshArgs) => Promise<void>
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<readonly string[]>(scopePaths)
scopePathsRef.current = scopePaths
const refresh = useCallback(async (args: { force?: boolean } = {}): Promise<void> => {
const refresh = useCallback(async (args: AiVaultRefreshArgs = {}): Promise<void> => {
// 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<ReturnType<typeof setTimeout> | 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<Set<string> | 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 }
}

View File

@ -632,7 +632,8 @@ function createWebPreloadApi(): Partial<PreloadApi> {
sessions: [],
issues: [],
scannedAt: new Date().toISOString()
})
}),
onWindowFocused: () => () => {}
},
preflight: createPreflightApi(),
notifications: createNotificationsApi(),