Skip to content
Open
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 scripts/dev.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ function getDesktopBinaryPath() {
const binaryName = `bitfun-desktop${suffix}`;

if (process.platform === 'darwin') {
return path.join(ROOT_DIR, 'target', 'debug', 'BitFun.app', 'Contents', 'MacOS', 'BitFun');
return path.join(ROOT_DIR, 'target', 'debug', 'BitFun.app', 'Contents', 'MacOS', 'bitfun-desktop');
}

return path.join(ROOT_DIR, 'target', 'debug', binaryName);
Expand Down
4 changes: 2 additions & 2 deletions src/crates/assembly/core/src/service/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ pub struct AgentCompanionPetSelection {
fn default_agent_companion_pet() -> Option<AgentCompanionPetSelection> {
Some(AgentCompanionPetSelection {
id: "bitfun".to_string(),
display_name: "Bitfun".to_string(),
display_name: "BitFun".to_string(),
description: Some(
"BitFun's mascot — Bifang, a figure from Chinese mythology said to live on Mount Zhang'e. In the Classic of Mountains and Seas (Shan Hai Jing · Western Mountains), Bifang is described as crane-like with one foot, blue feathers marked with red, and a white beak.".to_string(),
),
Expand Down Expand Up @@ -1899,7 +1899,7 @@ mod tests {
.as_ref()
.expect("default companion pet should be present");
assert_eq!(pet.id, "bitfun");
assert_eq!(pet.display_name, "Bitfun");
assert_eq!(pet.display_name, "BitFun");
assert_eq!(pet.package_path, "/agent-companion-pets/bitfun");
assert_eq!(
pet.spritesheet_path,
Expand Down
2 changes: 1 addition & 1 deletion src/web-ui/public/agent-companion-pets/bitfun/pet.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"id": "bitfun",
"displayName": "Bitfun",
"displayName": "BitFun",
"description": "BitFun's mascot — Bifang (毕方), a figure from Chinese mythology said to live on Mount Zhang'e. In the Classic of Mountains and Seas (Shan Hai Jing · Western Mountains), Bifang is described as crane-like with one foot, blue feathers marked with red, and a white beak.",
"spritesheetPath": "spritesheet.webp"
}
97 changes: 97 additions & 0 deletions src/web-ui/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
isStartupOverlayPresent,
} from './startup/startupOverlay';
import { ToolbarModeProvider } from '../flow_chat/components/toolbar-mode/ToolbarModeProvider';
import { FlowChatStore } from '@/flow_chat/store/FlowChatStore';

const log = createLogger('App');

Expand Down Expand Up @@ -510,6 +511,102 @@ function App() {
};
}, []);

// Listen for the Agent Companion pet window requesting completion dismissal.
// The pet window cannot call FlowChatStore directly because it runs in a
// separate WebView with its own JavaScript context (separate singleton).
useEffect(() => {
let unlisten: (() => void) | null = null;
void import('@tauri-apps/api/event')
.then(({ listen }) => listen<{ sessionId?: string }>(
'agent-companion://dismiss-completion',
event => {
const sessionId = event.payload?.sessionId;
if (!sessionId) return;
FlowChatStore.getInstance().clearSessionUnreadCompletion(sessionId);
},
))
.then(removeListener => {
unlisten = removeListener;
})
.catch(error => {
log.warn('Failed to listen for Agent companion dismiss-completion events', error);
});

return () => {
unlisten?.();
};
}, []);

// Auto-dismiss Agent Companion completion bubbles after 5 seconds.
// Subscribes directly to FlowChatStore (not the activity bridge) to avoid
// any timing issues in the buildAgentCompanionActivity → emit chain.
// Timers live in the main window so they aren't throttled by the WebView
// engine (the pet window is a small unfocused overlay window).
useEffect(() => {
const AUTO_DISMISS_DELAY_MS = 5000;
const store = FlowChatStore.getInstance();
const timers = new Map<string, ReturnType<typeof setTimeout>>();
const scheduled = new Set<string>();

const checkAndSchedule = () => {
const state = store.getState();
const completionKinds: ReadonlySet<string | undefined> = new Set([
'completed',
'error',
'interrupted',
]);

state.sessions.forEach(session => {
const sessionId = session.sessionId;
const hasCompletion = completionKinds.has(session.hasUnreadCompletion);

if (hasCompletion && !scheduled.has(sessionId)) {
scheduled.add(sessionId);
log.debug('[AgentCompanion auto-dismiss] Scheduling timer', { sessionId, completionKind: session.hasUnreadCompletion });
const timerId = setTimeout(() => {
log.debug('[AgentCompanion auto-dismiss] Timer fired, clearing unread completion', { sessionId });
store.clearSessionUnreadCompletion(sessionId);
timers.delete(sessionId);
scheduled.delete(sessionId);
}, AUTO_DISMISS_DELAY_MS);
timers.set(sessionId, timerId);
} else if (!hasCompletion && scheduled.has(sessionId)) {
const timerId = timers.get(sessionId);
if (timerId) {
clearTimeout(timerId);
timers.delete(sessionId);
}
scheduled.delete(sessionId);
log.debug('[AgentCompanion auto-dismiss] Cancelled timer (completion cleared)', { sessionId });
}
});

// Clean up timers for sessions that no longer exist.
const existingIds = new Set(
Array.from(state.sessions.values()).map(s => s.sessionId),
);
timers.forEach((timerId, sessionId) => {
if (!existingIds.has(sessionId)) {
clearTimeout(timerId);
timers.delete(sessionId);
scheduled.delete(sessionId);
log.debug('[AgentCompanion auto-dismiss] Cancelled timer (session removed)', { sessionId });
}
});
};

log.debug('[AgentCompanion auto-dismiss] Subscribing to FlowChatStore');
const unsubscribe = store.subscribe(checkAndSchedule);
// Run immediately for any pre-existing completions (e.g. restored sessions).
checkAndSchedule();

return () => {
log.debug('[AgentCompanion auto-dismiss] Unsubscribing, clearing timers');
unsubscribe();
timers.forEach(timerId => clearTimeout(timerId));
};
}, []);

// Block browser-native Ctrl+F (find bar) and Ctrl+R (hard reload).
// On macOS the equivalent modifiers are Cmd+F / Cmd+R.
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { listen } from '@tauri-apps/api/event';
import { listen, emit } 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';
import type { ChatInputPetMood } from '@/flow_chat/utils/chatInputPetMood';
import type { AgentCompanionActivityPayload, AgentCompanionTaskStatus } from '@/flow_chat/utils/agentCompanionActivity';
import type { AgentCompanionActivityPayload, AgentCompanionTaskState, AgentCompanionTaskStatus } from '@/flow_chat/utils/agentCompanionActivity';
import { createLogger } from '@/shared/utils/logger';
import './AgentCompanionDesktopPet.scss';

Expand All @@ -25,6 +25,12 @@ const POINTER_HOVER_POLL_INTERVAL_MS = 120;
/** Clicks shorter/smaller than this use `show_main_window`; beyond it we start a native drag. */
const PET_DRAG_THRESHOLD_PX = 8;
const IS_WINDOWS_WEBVIEW = /\bWindows\b/i.test(window.navigator.userAgent);
/** Tasks in these states are completion bubbles that can be dismissed. */
const COMPLETION_TASK_STATES: ReadonlySet<AgentCompanionTaskState> = new Set([
'completed',
'error',
'interrupted',
]);

interface TypewriterOutputState {
target: string;
Expand Down Expand Up @@ -343,6 +349,28 @@ export const AgentCompanionDesktopPet: React.FC = () => {
}
}, []);

/** Dismiss all completed/error/interrupted task bubbles immediately
* by emitting a cross-window Tauri event. The main window listens
* for this event and clears the unread-completion flag in its own
* FlowChatStore, which triggers a store subscription that re-emits
* the activity payload (now without the completion task). */
const dismissCompletedTasks = useCallback(async () => {
const completionSessionIds = tasks
.filter(t => COMPLETION_TASK_STATES.has(t.state))
.map(t => t.sessionId);

if (completionSessionIds.length === 0) return;

try {
await Promise.all(completionSessionIds.map(sessionId =>
emit('agent-companion://dismiss-completion', { sessionId }),
));
log.info('Dismissed completed task bubbles via user click');
} catch (error) {
log.warn('Failed to dismiss completion tasks via cross-window event', error);
}
}, [tasks]);

const onContextMenu = useCallback((event: React.MouseEvent) => {
event.preventDefault();
}, []);
Expand Down Expand Up @@ -407,6 +435,11 @@ export const AgentCompanionDesktopPet: React.FC = () => {
const shouldShowMain = !session.dragStarted;
clearPetPointerSession(event.currentTarget, event.pointerId);
if (shouldShowMain) {
// Dismiss completed task bubbles when user clicks the pet.
const hasCompletedTasks = tasks.some(t => COMPLETION_TASK_STATES.has(t.state));
if (hasCompletedTasks) {
void dismissCompletedTasks();
}
void showMainWindowFromPet();
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -628,3 +628,26 @@ describe('handleDialogTurnComplete', () => {
expect(turn?.hasFinalResponse).toBe(false);
});
});

describe('shouldMarkUnreadCompletion', () => {
const { shouldMarkUnreadCompletion } = __test_only__;

it('always returns true regardless of session id', () => {
expect(shouldMarkUnreadCompletion('active-session')).toBe(true);
expect(shouldMarkUnreadCompletion('background-session')).toBe(true);
expect(shouldMarkUnreadCompletion('')).toBe(true);
});

it('returns true for any string input — all completions are marked so the Agent Companion pet can show completion bubbles', () => {
const sessions = ['session-1', 'session-2', 'session-3'];
sessions.forEach(sessionId => {
expect(shouldMarkUnreadCompletion(sessionId)).toBe(true);
});
});

it('returns true even when the input is undefined or null (defensive)', () => {
// Parameter is typed as `string` but callers may pass unexpected values.
expect(shouldMarkUnreadCompletion(undefined as unknown as string)).toBe(true);
expect(shouldMarkUnreadCompletion(null as unknown as string)).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,17 @@ function mergeParamsPartialEventData(
export const __test_only__ = {
resolveDialogTurnDisplayContent,
mergeParamsPartialEventData,
shouldMarkUnreadCompletion,
};

function shouldMarkUnreadCompletion(sessionId: string): boolean {
const activeSessionId = FlowChatStore.getInstance().getState().activeSessionId;
return sessionId !== activeSessionId || !isAppWindowFocused();
function shouldMarkUnreadCompletion(_sessionId: string): boolean {
// Always mark completions so the Agent Companion pet can show a completion
// bubble regardless of which session is active. The main window auto-dismisses
// the unread flag after 5 seconds (except for the active focused session,
// where the user sees the completion directly in the chat UI), so there is
// no risk of stale indicators. This trades an extra persistence write per
// completion for reliable cross-window pet notifications.
return true;
}

function logDroppedDataEvent(
Expand Down
Loading