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 (