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
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const BUBBLE_MIN_WIDTH = 132;
const BUBBLE_MAX_WIDTH = 252;
const WINDOW_EDGE_BUFFER = 4;
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;

export const AgentCompanionDesktopPet: React.FC = () => {
const { t } = useTranslation('flow-chat');
Expand All @@ -37,6 +39,12 @@ export const AgentCompanionDesktopPet: React.FC = () => {
const bubblesRef = useRef<HTMLDivElement>(null);
const lastActivitySequenceRef = useRef(0);
const lastActivityEmittedAtRef = useRef(0);
const petPointerSessionRef = useRef<{
pointerId: number;
startX: number;
startY: number;
dragStarted: boolean;
} | null>(null);
const displayTasks = [...tasks].reverse();
const activePetSize = pet && petFrameSize
? petFrameSize
Expand Down Expand Up @@ -237,11 +245,56 @@ export const AgentCompanionDesktopPet: React.FC = () => {
};
}, []);

const startDrag = (event: React.PointerEvent<HTMLDivElement>) => {
const showMainWindowFromPet = useCallback(async () => {
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('show_main_window');
} catch (error) {
log.warn('Failed to show main window from Agent companion pet', error);
}
}, []);

const clearPetPointerSession = (target: HTMLDivElement, pointerId: number) => {
const session = petPointerSessionRef.current;
if (!session || session.pointerId !== pointerId) {
return;
}
petPointerSessionRef.current = null;
try {
target.releasePointerCapture(pointerId);
} catch {
/* already released */
}
};

const onPetPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
petPointerSessionRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
dragStarted: false,
};
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
/* ignore */
}
};

const onPetPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const session = petPointerSessionRef.current;
if (!session || event.pointerId !== session.pointerId || session.dragStarted) {
return;
}
const dx = event.clientX - session.startX;
const dy = event.clientY - session.startY;
if (dx * dx + dy * dy < PET_DRAG_THRESHOLD_PX * PET_DRAG_THRESHOLD_PX) {
return;
}
session.dragStarted = true;
event.preventDefault();
setIsDraggingPet(true);
void getCurrentWindow().startDragging()
Expand All @@ -253,6 +306,26 @@ export const AgentCompanionDesktopPet: React.FC = () => {
});
};

const onPetPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
const session = petPointerSessionRef.current;
if (!session || event.pointerId !== session.pointerId) {
return;
}
const shouldShowMain = !session.dragStarted;
clearPetPointerSession(event.currentTarget, event.pointerId);
if (shouldShowMain) {
void showMainWindowFromPet();
}
};

const onPetPointerCancel = (event: React.PointerEvent<HTMLDivElement>) => {
const session = petPointerSessionRef.current;
if (!session || event.pointerId !== session.pointerId) {
return;
}
clearPetPointerSession(event.currentTarget, event.pointerId);
};

const displayMood: ChatInputPixelPetMood = isDraggingPet
? 'dragging'
: isHoveringPet
Expand Down Expand Up @@ -322,7 +395,10 @@ export const AgentCompanionDesktopPet: React.FC = () => {
className="bitfun-agent-companion-window__pet-hitbox"
onPointerEnter={() => setIsHoveringPet(true)}
onPointerLeave={() => setIsHoveringPet(false)}
onPointerDown={startDrag}
onPointerDown={onPetPointerDown}
onPointerMove={onPetPointerMove}
onPointerUp={onPetPointerUp}
onPointerCancel={onPetPointerCancel}
>
<ChatInputPixelPet
mood={displayMood}
Expand Down
29 changes: 19 additions & 10 deletions src/web-ui/src/app/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,10 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
t,
]);

// Save in-progress conversations before the native window is closed/hidden.
// When the user hides the main window (tray / macOS dock), the app keeps running.
// `saveAllInProgressTurns` settles in-flight dialog turns for disk persistence, which
// clears Agent companion desktop bubbles until the next chat update—so only run it
// immediately before we actually exit the process.
React.useEffect(() => {
let unlistenFn: (() => void) | null = null;
let handlingClose = false;
Expand All @@ -312,22 +315,23 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {

try {
// Both macOS and Windows/Linux: Rust intercepts the native close request
// and emits this event. We save turns then decide what to do.
// and emits this event. We decide hide vs quit; persist interrupted turns only on quit.
const [{ listen }, { invoke }] = await Promise.all([
import('@tauri-apps/api/event'),
import('@tauri-apps/api/core'),
]);

unlistenFn = await listen('bitfun_main_window_close_requested', async () => {
if (handlingClose) return;
handlingClose = true;

const persistInterruptedTurnsForExit = async () => {
try {
const flowChatManager = FlowChatManager.getInstance();
await flowChatManager.saveAllInProgressTurns();
await FlowChatManager.getInstance().saveAllInProgressTurns();
} catch (error) {
log.error('Failed to save conversations before close', error);
log.error('Failed to save conversations before quit', error);
}
};

unlistenFn = await listen('bitfun_main_window_close_requested', async () => {
if (handlingClose) return;
handlingClose = true;

if (isMacOS) {
// macOS always hides to keep the app alive in the dock.
Expand Down Expand Up @@ -360,17 +364,22 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
showCancel: true,
});
if (shouldQuit) {
await persistInterruptedTurnsForExit();
await systemAPI.quitApp();
} else {
await systemAPI.minimizeToTray();
}
} else {
// quit
await persistInterruptedTurnsForExit();
await systemAPI.quitApp();
}
} catch (error) {
log.error('Failed to handle close request', { behavior, error });
try { await systemAPI.quitApp(); } catch { /* ignore */ }
try {
await persistInterruptedTurnsForExit();
await systemAPI.quitApp();
} catch { /* ignore */ }
} finally {
handlingClose = false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,9 @@ async function performSaveDialogTurnToDisk(
}

/**
* Save all in-progress dialog turns
* Used when closing the window to persist unfinished session turns
* Save all in-progress dialog turns by settling them for persistence.
* Call only when the app process is about to exit; hiding to tray/dock keeps the
* app alive and settling here would clear in-memory "active" state (e.g. desktop pet bubbles).
*/
export async function saveAllInProgressTurns(context: FlowChatContext): Promise<void> {
const state = context.flowChatStore.getState();
Expand Down
Loading