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:
parent
366746ad2b
commit
6142ec1a06
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -632,7 +632,8 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
|||
sessions: [],
|
||||
issues: [],
|
||||
scannedAt: new Date().toISOString()
|
||||
})
|
||||
}),
|
||||
onWindowFocused: () => () => {}
|
||||
},
|
||||
preflight: createPreflightApi(),
|
||||
notifications: createNotificationsApi(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue