Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/web-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
192 changes: 153 additions & 39 deletions src/web-ui/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -160,17 +160,22 @@ 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);
startupTrace.markPhase('startup_overlay_hidden');
window.dispatchEvent(new CustomEvent(STARTUP_OVERLAY_HIDDEN_EVENT));
}
});
}, remaining);
}, scheduledDelayMs);
return () => {
cancelled = true;
window.clearTimeout(timer);
Expand Down Expand Up @@ -420,6 +425,7 @@ function App() {
let disposed = false;
let startupSyncHandle: { promise: Promise<void>; cancel: () => void } | null = null;
let removeSettingsListener: (() => void) | null = null;
let pendingActivityTimer: number | null = null;

void (async () => {
const [
Expand All @@ -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<ReturnType<typeof aiExperienceConfigService.getSettingsAsync>>;

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) {
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<AIExperienceSettings>('agent-companion://settings-updated', event => {
const settingsListenerReady = listen<AIExperienceSettings>('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<AgentCompanionActivityPayload>('agent-companion://activity-updated', event => {
const activityListenerReady = listen<AgentCompanionActivityPayload>('agent-companion://activity-updated', event => {
const emittedAt = event.payload.emittedAt ?? 0;
const sequence = event.payload.sequence ?? 0;
if (
Expand All @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion src/web-ui/src/app/startup/startupOverlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/web-ui/src/app/startup/startupOverlay.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Loading
Loading