From 8ff76d4ff6adc6121ae819f14e7100d22a25f12d Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 29 Jun 2026 18:55:32 +0800 Subject: [PATCH] perf(web-ui): shorten startup handoff and harden pet sync --- src/web-ui/index.html | 2 +- src/web-ui/src/app/App.tsx | 192 ++++++++++++++---- .../AgentCompanionDesktopPet.tsx | 55 ++++- .../src/app/startup/startupOverlay.test.ts | 2 +- src/web-ui/src/app/startup/startupOverlay.ts | 2 +- .../startupPerformanceContract.test.ts | 34 +++- .../AIExperienceConfigService.test.ts | 21 ++ .../services/AIExperienceConfigService.ts | 7 +- .../AgentCompanionWindowService.test.ts | 131 ++++++++++++ .../services/AgentCompanionWindowService.ts | 13 ++ .../scripts/perf-coverage-contract.test.mjs | 25 +++ .../run-long-session-interaction-matrix.mjs | 137 ++++++++++++- tests/e2e/scripts/run-startup-stability.mjs | 40 +++- .../performance/startup-session-perf.spec.ts | 30 +-- 14 files changed, 618 insertions(+), 73 deletions(-) create mode 100644 src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.test.ts diff --git a/src/web-ui/index.html b/src/web-ui/index.html index b944383ce1..ee56e9bce7 100644 --- a/src/web-ui/index.html +++ b/src/web-ui/index.html @@ -131,7 +131,7 @@ } #bitfun-startup-overlay.bitfun-startup-overlay--exiting { - animation: bitfun-startup-overlay-exit 0.32s ease-in-out both; + animation: bitfun-startup-overlay-exit 0.24s ease-in-out both; } .bitfun-startup-window-controls { diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index e3e00731e1..a1963d1a36 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -67,8 +67,8 @@ const LazyAppLayout = lazy(async () => { * - With a workspace: show workspace panels * - Header is always present; elements toggle by state */ -// Minimum time (ms) the splash is shown, so the animation is never a flash. -const MIN_SPLASH_MS = 900; +// Minimum time (ms) the splash is shown, so the handoff remains intentional without delaying a ready shell. +const MIN_SPLASH_MS = 650; // Keep hidden tray setup out of the first post-handoff interaction window. // Close-to-tray initializes it on demand if the user closes earlier. const DEFERRED_TRAY_INIT_DELAY_MS = 1500; @@ -160,9 +160,14 @@ function App() { useEffect(() => { if (workspaceLoading || !appLayoutReady) return; const elapsed = getStartupOverlayElapsedMs(); - const remaining = Math.max(0, MIN_SPLASH_MS - elapsed); + const scheduledDelayMs = Math.max(0, MIN_SPLASH_MS - elapsed); let cancelled = false; const timer = window.setTimeout(() => { + startupTrace.markPhase('startup_overlay_hide_start', { + elapsedMs: getStartupOverlayElapsedMs(), + minSplashMs: MIN_SPLASH_MS, + scheduledDelayMs, + }); void hideStartupOverlay().then(() => { if (!cancelled) { setStartupOverlayVisible(false); @@ -170,7 +175,7 @@ function App() { window.dispatchEvent(new CustomEvent(STARTUP_OVERLAY_HIDDEN_EVENT)); } }); - }, remaining); + }, scheduledDelayMs); return () => { cancelled = true; window.clearTimeout(timer); @@ -420,6 +425,7 @@ function App() { let disposed = false; let startupSyncHandle: { promise: Promise; cancel: () => void } | null = null; let removeSettingsListener: (() => void) | null = null; + let pendingActivityTimer: number | null = null; void (async () => { const [ @@ -440,54 +446,102 @@ function App() { return; } - const emitCurrentAgentCompanionActivity = () => { - if (disposed) { + let syncVersion = 0; + const cancelPendingAgentCompanionStartupSync = () => { + startupSyncHandle?.cancel(); + startupSyncHandle = null; + if (pendingActivityTimer !== null) { + window.clearTimeout(pendingActivityTimer); + pendingActivityTimer = null; + } + }; + const emitCurrentAgentCompanionActivity = (version: number) => { + if (disposed || version !== syncVersion) { return; } void emitAgentCompanionActivity(buildAgentCompanionActivity()); }; - - const settings = await aiExperienceConfigService.getSettingsAsync(); - if (disposed) { - return; - } - - startupTrace.markPhase('agent_companion_sync_scheduled', { - source: 'startup_idle', - }); - startupSyncHandle = backgroundTaskScheduler.schedule(async signal => { - if (signal.aborted || disposed) { + const scheduleFollowUpAgentCompanionActivity = (version: number) => { + if (pendingActivityTimer !== null) { + window.clearTimeout(pendingActivityTimer); + } + pendingActivityTimer = window.setTimeout(() => { + pendingActivityTimer = null; + emitCurrentAgentCompanionActivity(version); + }, 250); + }; + type AgentCompanionSettings = Awaited>; + + const runAgentCompanionSync = async ( + settings: AgentCompanionSettings, + version: number, + source: 'startup_idle' | 'settings_change', + signal?: AbortSignal, + ) => { + if (signal?.aborted || disposed || version !== syncVersion) { return; } - startupTrace.markPhase('agent_companion_sync_start', { - source: 'startup_idle', - }); + if (source === 'startup_idle') { + startupTrace.markPhase('agent_companion_sync_start', { + source, + }); + } await syncAgentCompanionDesktopWindow(settings); - if (signal.aborted || disposed) { + if (signal?.aborted || disposed || version !== syncVersion) { return; } - emitCurrentAgentCompanionActivity(); - window.setTimeout(emitCurrentAgentCompanionActivity, 250); - startupTrace.markPhase('agent_companion_sync_end', { - source: 'startup_idle', - }); - }, { - idle: true, - inFlightKey: 'agent-companion:startup-sync', - priority: 'low', - }); + emitCurrentAgentCompanionActivity(version); + scheduleFollowUpAgentCompanionActivity(version); + if (source === 'startup_idle') { + startupTrace.markPhase('agent_companion_sync_end', { + source, + }); + } + }; + const syncAgentCompanionSettings = ( + settings: AgentCompanionSettings | null, + source: 'startup_idle' | 'settings_change', + ) => { + const version = syncVersion += 1; + cancelPendingAgentCompanionStartupSync(); + if (source === 'startup_idle') { + startupTrace.markPhase('agent_companion_sync_scheduled', { + source, + }); + startupSyncHandle = backgroundTaskScheduler.schedule( + async signal => { + const latestSettings = await aiExperienceConfigService.getSettingsAsync({ forceRefresh: true }); + await runAgentCompanionSync(latestSettings, version, source, signal); + }, + { + idle: true, + inFlightKey: 'agent-companion:startup-sync', + priority: 'low', + }, + ); - startupSyncHandle.promise.catch(error => { - if (!disposed && !isBackgroundTaskCancelledError(error)) { - log.warn('Initial Agent companion sync task failed', error); + startupSyncHandle.promise.catch(error => { + if (!disposed && !isBackgroundTaskCancelledError(error)) { + log.warn('Initial Agent companion sync task failed', error); + } + }); + return; } - }); - removeSettingsListener = aiExperienceConfigService.addChangeListener(settings => { - void syncAgentCompanionDesktopWindow(settings).then(() => { - emitCurrentAgentCompanionActivity(); - window.setTimeout(emitCurrentAgentCompanionActivity, 250); + if (!settings) { + return; + } + void runAgentCompanionSync(settings, version, source).catch(error => { + if (!disposed) { + log.warn('Agent companion settings sync failed', error); + } }); + }; + + syncAgentCompanionSettings(null, 'startup_idle'); + + removeSettingsListener = aiExperienceConfigService.addChangeListener(settings => { + syncAgentCompanionSettings(settings, 'settings_change'); }); })().catch(error => { if (!disposed) { @@ -498,10 +552,70 @@ function App() { return () => { disposed = true; startupSyncHandle?.cancel(); + if (pendingActivityTimer !== null) { + window.clearTimeout(pendingActivityTimer); + } removeSettingsListener?.(); }; }, [interactiveShellReady]); + useEffect(() => { + if (!isTauriRuntime()) { + return; + } + + let disposed = false; + let unlisten: (() => void) | null = null; + + void import('@tauri-apps/api/event') + .then(({ emit, listen }) => listen( + 'agent-companion://ready', + async () => { + try { + const [ + { aiExperienceConfigService }, + { buildAgentCompanionActivity }, + { emitAgentCompanionActivity }, + ] = await Promise.all([ + import('@/infrastructure/config/services/AIExperienceConfigService'), + import('@/flow_chat/utils/agentCompanionActivity'), + import('@/flow_chat/services/AgentCompanionActivityBridge'), + ]); + const settings = await aiExperienceConfigService.getSettingsAsync({ forceRefresh: true }); + if (disposed) { + return; + } + await emit('agent-companion://settings-updated', settings); + if (disposed) { + return; + } + await emitAgentCompanionActivity(buildAgentCompanionActivity()); + } catch (error) { + if (!disposed) { + log.warn('Failed to synchronize Agent companion after ready event', error); + } + } + }, + )) + .then(removeListener => { + if (disposed) { + removeListener(); + return; + } + unlisten = removeListener; + }) + .catch(error => { + if (!disposed) { + log.warn('Failed to listen for Agent companion ready events', error); + } + }); + + return () => { + disposed = true; + unlisten?.(); + }; + }, []); + useEffect(() => { let disposed = false; let unsubscribe: (() => void) | null = null; diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index b92bb5c2c1..c2b5bb6bf4 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { listen } from '@tauri-apps/api/event'; +import { emit, listen } from '@tauri-apps/api/event'; import { cursorPosition, getCurrentWindow } from '@tauri-apps/api/window'; import { aiExperienceConfigService, type AgentCompanionPetSelection, type AIExperienceSettings } from '@/infrastructure/config/services/AIExperienceConfigService'; import { ChatInputPixelPet, type ChatInputPixelPetMood } from '@/flow_chat/components/ChatInputPixelPet'; @@ -83,29 +83,53 @@ export const AgentCompanionDesktopPet: React.FC = () => { : { width: DEFAULT_PET_SIZE, height: DEFAULT_PET_SIZE }; useEffect(() => { + let disposed = false; document.documentElement.classList.add('bitfun-agent-companion-window-root'); document.body.classList.add('bitfun-agent-companion-window-body'); + const hidePetWindowForInactiveSettings = () => { + void getCurrentWindow().hide().catch(error => { + log.warn('Failed to hide inactive Agent companion window', error); + }); + }; + const applySettings = (settings: AIExperienceSettings) => { setPet(settings.agent_companion_pet ?? null); setPetFrameSize(null); + if (!settings.enable_agent_companion || settings.agent_companion_display_mode !== 'desktop') { + hidePetWindowForInactiveSettings(); + } }; - void aiExperienceConfigService.getSettingsAsync().then(settings => { - applySettings(settings); - }); + void aiExperienceConfigService.getSettingsAsync() + .then(settings => { + if (!disposed) { + applySettings(settings); + } + }) + .catch(error => { + if (!disposed) { + log.warn('Failed to load Agent companion settings', error); + } + }); let removeTauriListener: (() => void) | null = null; - void listen('agent-companion://settings-updated', event => { + const settingsListenerReady = listen('agent-companion://settings-updated', event => { applySettings(event.payload); }).then(unlisten => { + if (disposed) { + unlisten(); + return false; + } removeTauriListener = unlisten; + return true; }).catch(error => { log.warn('Failed to listen for Agent companion settings updates', error); + return false; }); let removeActivityListener: (() => void) | null = null; - void listen('agent-companion://activity-updated', event => { + const activityListenerReady = listen('agent-companion://activity-updated', event => { const emittedAt = event.payload.emittedAt ?? 0; const sequence = event.payload.sequence ?? 0; if ( @@ -119,12 +143,31 @@ export const AgentCompanionDesktopPet: React.FC = () => { setMood(event.payload.mood); setTasks(event.payload.tasks); }).then(unlisten => { + if (disposed) { + unlisten(); + return false; + } removeActivityListener = unlisten; + return true; }).catch(error => { log.warn('Failed to listen for Agent companion activity updates', error); + return false; }); + void Promise.all([settingsListenerReady, activityListenerReady]) + .then(([settingsReady, activityReady]) => { + if (!disposed && settingsReady && activityReady) { + void emit('agent-companion://ready'); + } + }) + .catch(error => { + if (!disposed) { + log.warn('Failed to request Agent companion startup sync', error); + } + }); + return () => { + disposed = true; removeTauriListener?.(); removeActivityListener?.(); document.documentElement.classList.remove('bitfun-agent-companion-window-root'); diff --git a/src/web-ui/src/app/startup/startupOverlay.test.ts b/src/web-ui/src/app/startup/startupOverlay.test.ts index c2396fb56a..454b1725b7 100644 --- a/src/web-ui/src/app/startup/startupOverlay.test.ts +++ b/src/web-ui/src/app/startup/startupOverlay.test.ts @@ -37,7 +37,7 @@ describe('startupOverlay', () => { const hidden = hideStartupOverlay(); - await vi.advanceTimersByTimeAsync(449); + await vi.advanceTimersByTimeAsync(349); expect(isStartupOverlayPresent()).toBe(true); await vi.advanceTimersByTimeAsync(1); diff --git a/src/web-ui/src/app/startup/startupOverlay.ts b/src/web-ui/src/app/startup/startupOverlay.ts index 9a942361f0..30b2d8f409 100644 --- a/src/web-ui/src/app/startup/startupOverlay.ts +++ b/src/web-ui/src/app/startup/startupOverlay.ts @@ -1,6 +1,6 @@ const STARTUP_OVERLAY_ID = 'bitfun-startup-overlay'; const EXIT_CLASS = 'bitfun-startup-overlay--exiting'; -const EXIT_FALLBACK_MS = 450; +const EXIT_FALLBACK_MS = 350; declare global { interface Window { diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index cdd02d0673..21b28f3e86 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -57,8 +57,10 @@ describe('startup performance contract', () => { it('keeps the startup overlay exit short enough for a fast visual handoff', () => { const source = readSource('../../../index.html'); + const appSource = readSource('../App.tsx'); - expect(source).toContain('animation: bitfun-startup-overlay-exit 0.32s ease-in-out both;'); + expect(appSource).toContain('const MIN_SPLASH_MS = 650;'); + expect(source).toContain('animation: bitfun-startup-overlay-exit 0.24s ease-in-out both;'); }); it('keeps editor and tool infrastructure out of the first startup module', () => { @@ -702,8 +704,10 @@ describe('startup performance contract', () => { } }); - it('keeps Agent companion implementation modules out of the root startup bundle', () => { + it('keeps Agent companion startup bridge imports lazy in App', () => { const source = readSource('../App.tsx'); + const mainSource = readSource('../../main.tsx'); + const petSource = readSource('../components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx'); expect(source).not.toMatch(/from\s+['"]@\/flow_chat\/utils\/agentCompanionActivity['"]/); expect(source).not.toMatch(/from\s+['"]@\/flow_chat\/services\/AgentCompanionActivityBridge['"]/); @@ -712,5 +716,31 @@ describe('startup performance contract', () => { expect(source).toContain("import('@/flow_chat/utils/agentCompanionActivity')"); expect(source).toContain("import('@/flow_chat/services/AgentCompanionActivityBridge')"); expect(source).toContain("import('./services/openAgentCompanionSession')"); + expect(staticImportSpecifiers(mainSource)).toContain( + './app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet' + ); + expect(dynamicImportSpecifiers(mainSource)).not.toContain( + './app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet' + ); + expect(source).toContain("listen(\n 'agent-companion://ready'"); + expect(source).toContain("emit('agent-companion://settings-updated', settings)"); + expect(source).toContain('emitAgentCompanionActivity(buildAgentCompanionActivity())'); + expect(petSource).toContain("listen('agent-companion://settings-updated'"); + expect(petSource).toContain("listen('agent-companion://activity-updated'"); + expect(petSource).toContain("emit('agent-companion://ready')"); + expect(petSource).toContain("getCurrentWindow().hide()"); + }); + + it('cancels stale Agent companion startup sync when settings change', () => { + const source = readSource('../App.tsx'); + const changeListenerIndex = source.indexOf('removeSettingsListener = aiExperienceConfigService.addChangeListener'); + + expect(source).toContain('const cancelPendingAgentCompanionStartupSync = () => {'); + expect(source).toContain('startupSyncHandle?.cancel();'); + expect(source).toContain('version !== syncVersion'); + expect(source).toContain("syncAgentCompanionSettings(null, 'startup_idle')"); + expect(source).toContain("getSettingsAsync({ forceRefresh: true })"); + expect(changeListenerIndex).toBeGreaterThan(-1); + expect(source.slice(changeListenerIndex)).toContain("syncAgentCompanionSettings(settings, 'settings_change')"); }); }); diff --git a/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.test.ts b/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.test.ts index 81dac9ccbb..b7e0f33f95 100644 --- a/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.test.ts +++ b/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.test.ts @@ -6,10 +6,18 @@ const configManagerMock = vi.hoisted(() => ({ watch: vi.fn(), })); +const configApiMock = vi.hoisted(() => ({ + getConfig: vi.fn(), +})); + vi.mock('./ConfigManager', () => ({ configManager: configManagerMock, })); +vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ + configAPI: configApiMock, +})); + vi.mock('./AgentCompanionPetService', () => ({ DEFAULT_AGENT_COMPANION_PET: { id: 'default', @@ -55,4 +63,17 @@ describe('AIExperienceConfigService startup behavior', () => { expect(configManagerMock.getConfig).toHaveBeenCalledTimes(1); expect(configManagerMock.getConfig).toHaveBeenCalledWith('app.ai_experience'); }); + + it('can force refresh settings for cross-window lifecycle synchronization', async () => { + configApiMock.getConfig.mockResolvedValueOnce({ + enable_agent_companion: true, + agent_companion_display_mode: 'desktop', + }); + const { aiExperienceConfigService } = await import('./AIExperienceConfigService'); + + await aiExperienceConfigService.getSettingsAsync({ forceRefresh: true }); + + expect(configApiMock.getConfig).toHaveBeenCalledWith('app.ai_experience'); + expect(configManagerMock.getConfig).not.toHaveBeenCalled(); + }); }); diff --git a/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.ts b/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.ts index 801adb9e6c..73eaaaf144 100644 --- a/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.ts +++ b/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.ts @@ -2,6 +2,7 @@ import { configManager } from './ConfigManager'; import { DEFAULT_AGENT_COMPANION_PET } from './AgentCompanionPetService'; +import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('AIExperienceConfig'); @@ -130,10 +131,12 @@ export class AIExperienceConfigService { } - async getSettingsAsync(): Promise { + async getSettingsAsync(options?: { forceRefresh?: boolean }): Promise { this.ensureConfigWatcher(); try { - const settings = await configManager.getConfig(CONFIG_PATH); + const settings = options?.forceRefresh + ? await configAPI.getConfig(CONFIG_PATH) as AIExperienceSettings + : await configManager.getConfig(CONFIG_PATH); this.cachedSettings = normalizeSettings(settings); return this.cachedSettings; } catch (error) { diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.test.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.test.ts new file mode 100644 index 0000000000..537ea511e5 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AIExperienceSettings } from './AIExperienceConfigService'; + +const tauriCore = vi.hoisted(() => ({ + invoke: vi.fn(), +})); + +const tauriEvent = vi.hoisted(() => ({ + emit: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: tauriCore.invoke, +})); + +vi.mock('@tauri-apps/api/event', () => ({ + emit: tauriEvent.emit, +})); + +vi.mock('@/infrastructure/runtime', () => ({ + isTauriRuntime: () => true, +})); + +vi.mock('@/shared/utils/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + }), +})); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function settings( + enableAgentCompanion: boolean, + displayMode: AIExperienceSettings['agent_companion_display_mode'] = 'desktop', +): AIExperienceSettings { + return { + enable_session_title_generation: true, + enable_visual_mode: false, + enable_agent_companion: enableAgentCompanion, + agent_companion_display_mode: displayMode, + enable_workspace_search: false, + quick_actions: [], + }; +} + +describe('syncAgentCompanionDesktopWindow', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('skips stale queued show or hide requests so the latest config wins', async () => { + const firstInvoke = deferred(); + tauriCore.invoke + .mockReturnValueOnce(firstInvoke.promise) + .mockResolvedValue(undefined); + + const { syncAgentCompanionDesktopWindow } = await import('./AgentCompanionWindowService'); + + const first = syncAgentCompanionDesktopWindow(settings(true)); + await vi.waitFor(() => { + expect(tauriCore.invoke).toHaveBeenCalledTimes(1); + }); + + const staleHide = syncAgentCompanionDesktopWindow(settings(false)); + const latestShow = syncAgentCompanionDesktopWindow(settings(true)); + + firstInvoke.resolve(); + await Promise.all([first, staleHide, latestShow]); + + expect(tauriCore.invoke.mock.calls.map(call => call[0])).toEqual([ + 'show_agent_companion_desktop_pet', + 'show_agent_companion_desktop_pet', + ]); + expect(tauriEvent.emit).toHaveBeenCalledTimes(1); + expect(tauriEvent.emit).toHaveBeenCalledWith( + 'agent-companion://settings-updated', + expect.objectContaining({ + enable_agent_companion: true, + agent_companion_display_mode: 'desktop', + }), + ); + }); + + it('hides the desktop pet when the companion is disabled or moved to input mode', async () => { + const { syncAgentCompanionDesktopWindow } = await import('./AgentCompanionWindowService'); + + await syncAgentCompanionDesktopWindow(settings(false)); + await syncAgentCompanionDesktopWindow(settings(true, 'input')); + + expect(tauriCore.invoke.mock.calls.map(call => call[0])).toEqual([ + 'hide_agent_companion_desktop_pet', + 'hide_agent_companion_desktop_pet', + ]); + expect(tauriEvent.emit).not.toHaveBeenCalled(); + }); + + it('runs a latest hide after an in-flight stale show without emitting stale settings', async () => { + const firstInvoke = deferred(); + tauriCore.invoke + .mockReturnValueOnce(firstInvoke.promise) + .mockResolvedValue(undefined); + + const { syncAgentCompanionDesktopWindow } = await import('./AgentCompanionWindowService'); + + const staleShow = syncAgentCompanionDesktopWindow(settings(true)); + await vi.waitFor(() => { + expect(tauriCore.invoke).toHaveBeenCalledTimes(1); + }); + + const latestHide = syncAgentCompanionDesktopWindow(settings(false)); + + firstInvoke.resolve(); + await Promise.all([staleShow, latestHide]); + + expect(tauriCore.invoke.mock.calls.map(call => call[0])).toEqual([ + 'show_agent_companion_desktop_pet', + 'hide_agent_companion_desktop_pet', + ]); + expect(tauriEvent.emit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts index 942d9892f4..a5ec3657c9 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts @@ -8,11 +8,13 @@ const log = createLogger('AgentCompanionWindowService'); * Serialized `invoke`/`emit` so rapid settings toggles cannot interleave show/hide on the backend. */ let companionDesktopWindowSyncChain: Promise = Promise.resolve(); +let companionDesktopWindowSyncRequestId = 0; export async function syncAgentCompanionDesktopWindow( settings: AIExperienceSettings, ): Promise { if (!isTauriRuntime()) return; + const requestId = companionDesktopWindowSyncRequestId += 1; const run = async (): Promise => { const startedAt = performance.now(); @@ -21,6 +23,14 @@ export async function syncAgentCompanionDesktopWindow( ? 'show_agent_companion_desktop_pet' : 'hide_agent_companion_desktop_pet'; + if (requestId !== companionDesktopWindowSyncRequestId) { + log.debug('Skipped stale Agent companion desktop window sync', { + command, + displayMode: settings.agent_companion_display_mode, + }); + return; + } + try { log.debug('Agent companion desktop window sync started', { command, @@ -28,6 +38,9 @@ export async function syncAgentCompanionDesktopWindow( }); const { invoke } = await import('@tauri-apps/api/core'); await invoke(command); + if (requestId !== companionDesktopWindowSyncRequestId) { + return; + } if (command === 'show_agent_companion_desktop_pet') { const { emit } = await import('@tauri-apps/api/event'); await emit('agent-companion://settings-updated', settings); diff --git a/tests/e2e/scripts/perf-coverage-contract.test.mjs b/tests/e2e/scripts/perf-coverage-contract.test.mjs index 5decbb08be..7998b2a06a 100644 --- a/tests/e2e/scripts/perf-coverage-contract.test.mjs +++ b/tests/e2e/scripts/perf-coverage-contract.test.mjs @@ -28,6 +28,9 @@ test('performance scripts expose focused startup stability and interaction profi const startupRunner = readText('tests/e2e/scripts/run-startup-stability.mjs'); assert.match(startupRunner, /BITFUN_E2E_PERF_STARTUP_ITERATIONS/); + assert.match(startupRunner, /--samples/); + assert.match(startupRunner, /--iterations/); + assert.match(startupRunner, /readIntegerArgOrEnv/); assert.match(startupRunner, /BITFUN_E2E_PERF_STARTUP_MAX_INTERACTIVE_MS/); assert.match(startupRunner, /collects startup timing from the current build/); assert.match(startupRunner, /seenTraceIds/); @@ -65,3 +68,25 @@ test('long session required frame trace samples fail when trace phases are missi assert.match(startupSpec, /measurement\.traceWaitErrors\.length > 0/); assert.match(startupSpec, /measurement\.clickToPostHydrateUsableMs\)\.toBeGreaterThan\(0\)/); }); + +test('long session navigation lookup follows current session nav DOM contract', () => { + const startupSpec = readText('tests/e2e/specs/performance/startup-session-perf.spec.ts'); + + assert.match(startupSpec, /data-testid="nav-session-item"/); + assert.match(startupSpec, /data-testid="nav-session-list-toggle"/); + assert.match(startupSpec, /data-session-nav-toggle-action/); + assert.match(startupSpec, /data-session-id/); +}); + +test('long session interaction matrix isolates user data and avoids active-session preload bias', () => { + const interactionRunner = readText('tests/e2e/scripts/run-long-session-interaction-matrix.mjs'); + + assert.match(interactionRunner, /BITFUN_E2E_STORAGE_ROOT/); + assert.match(interactionRunner, /generate-long-session-fixture\.mjs/); + assert.match(interactionRunner, /BITFUN_E2E_PERF_SESSION_ID/); + assert.match(interactionRunner, /perf-long-session-001/); + assert.match(interactionRunner, /BITFUN_E2E_PERF_RAPID_SWITCH_SESSION_IDS/); + assert.match(interactionRunner, /perf-rapid-c-000/); + assert.match(interactionRunner, /pruneOldPerfRuns/); + assert.match(interactionRunner, /MAX_RETAINED_PERF_RUNS/); +}); diff --git a/tests/e2e/scripts/run-long-session-interaction-matrix.mjs b/tests/e2e/scripts/run-long-session-interaction-matrix.mjs index 9ba1ddbac2..00802a7fdf 100644 --- a/tests/e2e/scripts/run-long-session-interaction-matrix.mjs +++ b/tests/e2e/scripts/run-long-session-interaction-matrix.mjs @@ -6,6 +6,16 @@ import { fileURLToPath } from 'node:url'; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(SCRIPT_DIR, '..', '..', '..'); const REPORT_DIR = path.join(ROOT, 'tests', 'e2e', 'reports', 'performance'); +const PERF_RUN_ROOT = path.join(ROOT, 'tests', 'e2e', '.bitfun', 'perf-runs'); +const FIXTURE_SCRIPT = path.join(ROOT, 'tests', 'e2e', 'scripts', 'generate-long-session-fixture.mjs'); +const DEFAULT_LONG_SESSION_TARGET_ID = 'perf-long-session-001'; +const DEFAULT_RAPID_SWITCH_SESSION_IDS = [ + 'perf-rapid-a-000', + 'perf-rapid-b-000', + 'perf-rapid-c-000', +]; +const MAX_RETAINED_PERF_RUNS = 8; +const matrixRunId = `${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`; const scenarios = { 'first-open': { @@ -94,6 +104,14 @@ function runPnpm(args, options) { }); } +function runNode(args, options) { + return spawnSync(process.execPath, args, { + ...options, + shell: false, + encoding: 'utf8', + }); +} + function runnerStdioOptions() { if (process.env.BITFUN_E2E_PERF_RUNNER_STREAM_LOGS === '1') { return { stdio: 'inherit' }; @@ -132,6 +150,121 @@ function allowMissingReports() { ); } +function safePathSegment(value) { + return String(value).replace(/[^a-zA-Z0-9_.-]/g, '-'); +} + +function assertPathWithin(parent, candidate) { + const relative = path.relative(parent, candidate); + if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) { + return; + } + throw new Error(`Refusing to clean path outside performance run root: ${candidate}`); +} + +function pruneOldPerfRuns(maxRuns = MAX_RETAINED_PERF_RUNS) { + if (!fs.existsSync(PERF_RUN_ROOT)) { + return; + } + + const runs = fs + .readdirSync(PERF_RUN_ROOT, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => { + const fullPath = path.join(PERF_RUN_ROOT, entry.name); + const stat = fs.statSync(fullPath); + return { fullPath, mtimeMs: stat.mtimeMs }; + }) + .sort((left, right) => right.mtimeMs - left.mtimeMs); + + for (const run of runs.slice(maxRuns)) { + assertPathWithin(PERF_RUN_ROOT, run.fullPath); + fs.rmSync(run.fullPath, { recursive: true, force: true }); + } +} + +function runFixture(args, env) { + const result = runNode([FIXTURE_SCRIPT, ...args], { + cwd: ROOT, + env, + }); + if (result.status !== 0) { + throw new Error( + `Failed to generate long-session fixture.\n${outputTail(result)}`, + ); + } +} + +function prepareScenarioRuntime(name, baseEnv) { + const scenarioRoot = path.join(PERF_RUN_ROOT, matrixRunId, safePathSegment(name)); + assertPathWithin(PERF_RUN_ROOT, scenarioRoot); + fs.rmSync(scenarioRoot, { recursive: true, force: true }); + + const storageRoot = path.join(scenarioRoot, 'storage'); + const workspace = path.join(scenarioRoot, 'workspace'); + const homeRoot = path.join(storageRoot, 'home'); + const userRoot = path.join(storageRoot, 'user-root'); + const logRoot = path.join(storageRoot, 'logs'); + fs.mkdirSync(workspace, { recursive: true }); + fs.writeFileSync( + path.join(workspace, 'README.md'), + '# BitFun performance fixture workspace\n', + 'utf8', + ); + + const env = { + ...baseEnv, + BITFUN_E2E_STORAGE_ROOT: storageRoot, + BITFUN_E2E_HOME: homeRoot, + BITFUN_HOME: homeRoot, + BITFUN_E2E_USER_ROOT: userRoot, + BITFUN_USER_ROOT: userRoot, + BITFUN_E2E_LOG_DIR: logRoot, + E2E_TEST_WORKSPACE: workspace, + BITFUN_E2E_PERF_SESSION_ID: DEFAULT_LONG_SESSION_TARGET_ID, + BITFUN_E2E_PERF_RAPID_SWITCH_SESSION_IDS: DEFAULT_RAPID_SWITCH_SESSION_IDS.join(','), + }; + const timestampBase = Date.now(); + + runFixture([ + '--workspace', + workspace, + '--bitfun-home', + homeRoot, + '--bitfun-user-root', + userRoot, + '--session-prefix', + 'perf-long-session', + '--session-count', + '80', + '--long-session-index', + '1', + '--last-active-at-base', + String(timestampBase), + ], env); + + ['perf-rapid-a', 'perf-rapid-b', 'perf-rapid-c'].forEach((prefix, index) => { + runFixture([ + '--workspace', + workspace, + '--bitfun-home', + homeRoot, + '--bitfun-user-root', + userRoot, + '--session-prefix', + prefix, + '--session-count', + '1', + '--long-session-index', + '0', + '--last-active-at-base', + String(timestampBase - 10_000 - index * 1_000), + ], env); + }); + + return env; +} + function newestReport(prefix, startedAtMs) { if (!prefix || !fs.existsSync(REPORT_DIR)) { return null; @@ -203,7 +336,7 @@ function selectedScenarioNames() { function runScenario(name, baseEnv, options) { const scenario = scenarios[name]; const env = { - ...baseEnv, + ...prepareScenarioRuntime(name, baseEnv), ...(scenario.env ?? {}), }; const args = [ @@ -274,6 +407,8 @@ if (hasFlag('--dry-run')) { process.exit(0); } +pruneOldPerfRuns(Math.max(0, MAX_RETAINED_PERF_RUNS - 1)); + const results = names.map(name => runScenario(name, baseEnv, { allowMissingReports: missingReportsAllowed }), ); diff --git a/tests/e2e/scripts/run-startup-stability.mjs b/tests/e2e/scripts/run-startup-stability.mjs index 224e6e02d7..efe85f0258 100644 --- a/tests/e2e/scripts/run-startup-stability.mjs +++ b/tests/e2e/scripts/run-startup-stability.mjs @@ -12,11 +12,15 @@ function readFlag(name) { return process.argv.includes(name); } -function readNumberEnv(name, fallback) { - const raw = process.env[name]; - if (raw === undefined || raw === '') { - return fallback; +function readArgValue(name) { + const index = process.argv.indexOf(name); + if (index < 0) { + return undefined; } + return process.argv[index + 1]; +} + +function parseNonNegativeNumber(name, raw) { const value = Number(raw); if (!Number.isFinite(value) || value < 0) { throw new Error(`${name} must be a non-negative number, got ${raw}`); @@ -24,8 +28,26 @@ function readNumberEnv(name, fallback) { return value; } -function readIntegerEnv(name, fallback) { - return Math.max(1, Math.trunc(readNumberEnv(name, fallback))); +function readNumberEnv(name, fallback) { + const raw = process.env[name]; + if (raw === undefined || raw === '') { + return fallback; + } + return parseNonNegativeNumber(name, raw); +} + +function readNumberArgOrEnv(argNames, envName, fallback) { + for (const argName of argNames) { + const raw = readArgValue(argName); + if (raw !== undefined && raw !== '') { + return parseNonNegativeNumber(argName, raw); + } + } + return readNumberEnv(envName, fallback); +} + +function readIntegerArgOrEnv(argNames, envName, fallback) { + return Math.max(1, Math.trunc(readNumberArgOrEnv(argNames, envName, fallback))); } function shellQuote(value) { @@ -204,7 +226,11 @@ function runStartupIteration(index, total, env, thresholds, seenTraceIds) { }; } -const iterations = readIntegerEnv('BITFUN_E2E_PERF_STARTUP_ITERATIONS', 5); +const iterations = readIntegerArgOrEnv( + ['--samples', '--iterations'], + 'BITFUN_E2E_PERF_STARTUP_ITERATIONS', + 5, +); const thresholds = { interactiveShellReadyMs: readNumberEnv('BITFUN_E2E_PERF_STARTUP_MAX_INTERACTIVE_MS', 5000), firstScriptEvalMs: readNumberEnv('BITFUN_E2E_PERF_STARTUP_MAX_FIRST_SCRIPT_MS', 3000), diff --git a/tests/e2e/specs/performance/startup-session-perf.spec.ts b/tests/e2e/specs/performance/startup-session-perf.spec.ts index 54c06990a6..c3ad446081 100644 --- a/tests/e2e/specs/performance/startup-session-perf.spec.ts +++ b/tests/e2e/specs/performance/startup-session-perf.spec.ts @@ -36,6 +36,10 @@ const LONG_SESSION_RESIZE_BOTTOM_SETTLE_MAX_MS = 120; const LONG_SESSION_INPUT_MIN_TOP_RATIO = 0.65; const LONG_SESSION_INPUT_BOTTOM_TOLERANCE_PX = 96; const LONG_SESSION_MAX_LATEST_TEXT_DELAY_AFTER_VISIBLE_MS = 120; +const SESSION_NAV_ITEM_SELECTOR = + '[data-testid="session-nav-item"], [data-testid="nav-session-item"]'; +const SESSION_NAV_TOGGLE_SELECTOR = + '[data-testid="session-nav-show-more"], [data-testid="nav-session-list-toggle"]'; type LongSessionPostVisibleInteraction = | 'first-scroll' @@ -685,28 +689,28 @@ async function waitForOptionalTracePhaseForSessionSince( } } +function escapeCssAttributeValue(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + async function findSessionItem(sessionId: string): Promise | null> { + const targetSelector = + `[data-testid="session-nav-item"][data-session-id="${escapeCssAttributeValue(sessionId)}"], ` + + `[data-testid="nav-session-item"][data-session-id="${escapeCssAttributeValue(sessionId)}"]`; const readVisibleSessionIds = async (): Promise => - browser.execute(() => - Array.from(document.querySelectorAll( - '[data-testid="session-nav-item"], [data-testid="nav-session-item"]', - )) + browser.execute((selector) => + Array.from(document.querySelectorAll(selector)) .map(element => element.getAttribute('data-session-id') || '') - .filter(Boolean) - ); + .filter(Boolean), + SESSION_NAV_ITEM_SELECTOR); const findTarget = async (): Promise | null> => { - const item = await $( - `[data-testid="session-nav-item"][data-session-id="${sessionId}"], ` + - `[data-testid="nav-session-item"][data-session-id="${sessionId}"]`, - ); + const item = await $(targetSelector); return await item.isExisting() ? item : null; }; const findExpandableToggles = async (): Promise>> => { - const toggles = await browser.$$( - '[data-testid="session-nav-show-more"], [data-testid="nav-session-list-toggle"]', - ); + const toggles = await browser.$$(SESSION_NAV_TOGGLE_SELECTOR); const expandable: Array> = []; for (const toggle of toggles) { if (