Merge branch 'main' of github.com:boudra/paseo

This commit is contained in:
Mohamed Boudra
2026-02-03 23:05:12 +07:00
7 changed files with 332 additions and 122 deletions

View File

@@ -1,5 +1,5 @@
import "@/styles/unistyles";
import { Stack, usePathname } from "expo-router";
import { Stack, usePathname, useRouter } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
@@ -15,9 +15,10 @@ import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-reg
import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context";
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, useEffect, type ReactNode, useMemo } from "react";
import { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
import { Platform } from "react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { SlidingSidebar } from "@/components/sliding-sidebar";
import { DownloadToast } from "@/components/download-toast";
import { ToastProvider } from "@/contexts/toast-context";
@@ -34,6 +35,63 @@ import {
import { getIsTauriMac } from "@/constants/layout";
import { useTrafficLightPadding } from "@/utils/tauri-window";
function PushNotificationRouter() {
const router = useRouter();
const lastHandledIdRef = useRef<string | null>(null);
useEffect(() => {
if (Platform.OS === "web") {
return;
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
// When the app is open, don't show OS banners.
shouldShowAlert: false,
shouldShowBanner: false,
shouldShowList: false,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
const openFromResponse = (response: Notifications.NotificationResponse) => {
const identifier = response.notification.request.identifier;
if (lastHandledIdRef.current === identifier) {
return;
}
lastHandledIdRef.current = identifier;
const data = response.notification.request.content.data as
| Record<string, unknown>
| undefined;
const agentId = typeof data?.agentId === "string" ? data.agentId : null;
if (agentId) {
// Legacy route resolves agent -> host once sessions reconnect.
router.push(`/agent/${agentId}` as any);
} else {
router.push("/agents" as any);
}
};
const subscription =
Notifications.addNotificationResponseReceivedListener(openFromResponse);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
openFromResponse(response);
}
});
return () => {
subscription.remove();
};
}, [router]);
return null;
}
function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
@@ -335,6 +393,7 @@ export default function RootLayout() {
<QueryProvider>
<DaemonRegistryProvider>
<DaemonConnectionsProvider>
<PushNotificationRouter />
<MultiDaemonSessionHost />
<ProvidersWrapper>
<SidebarAnimationProvider>

View File

@@ -13,6 +13,7 @@ import { useMutation } from "@tanstack/react-query";
import { useDaemonClient } from "@/hooks/use-daemon-client";
import { useAudioPlayer } from "@/hooks/use-audio-player";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import {
applyStreamEvent,
generateMessageId,
@@ -388,6 +389,7 @@ export function SessionProvider({
// Client activity tracking (heartbeat, push token registration)
useClientActivity({ client, focusedAgentId });
usePushTokenRegistration({ client, serverId });
// State for voice detection flags (will be set by RealtimeContext)
const isDetectingRef = useRef(false);

View File

@@ -3,6 +3,7 @@ import { AppState, Platform } from "react-native";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
const HEARTBEAT_INTERVAL_MS = 15_000;
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
interface ClientActivityOptions {
client: DaemonClientV2;
@@ -12,14 +13,15 @@ interface ClientActivityOptions {
/**
* Handles client activity reporting:
* - Heartbeat sending every 15 seconds
* - App visibility tracking (updates lastActivityAt on foreground)
* - Sends heartbeat immediately when focused agent changes
* - App visibility tracking
* - Records lastActivityAt only on real user activity (not on heartbeat)
*/
export function useClientActivity({ client, focusedAgentId }: ClientActivityOptions): void {
const lastActivityAtRef = useRef<Date>(new Date());
const appVisibleRef = useRef(AppState.currentState === "active");
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
const lastImmediateHeartbeatAtRef = useRef<number>(0);
const deviceType = Platform.OS === "web" ? "web" : "mobile";
@@ -28,17 +30,7 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
}, []);
const sendHeartbeat = useCallback(() => {
if (!client.isConnected) {
console.log("[ClientActivity] sendHeartbeat skipped - not connected");
return;
}
lastActivityAtRef.current = new Date();
console.log("[ClientActivity] sendHeartbeat", {
deviceType,
focusedAgentId,
lastActivityAt: lastActivityAtRef.current.toISOString(),
appVisible: appVisibleRef.current,
});
if (!client.isConnected) return;
client.sendHeartbeat({
deviceType,
focusedAgentId,
@@ -47,35 +39,77 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
});
}, [client, deviceType, focusedAgentId]);
const maybeSendImmediateHeartbeat = useCallback(() => {
if (!client.isConnected) return;
const now = Date.now();
if (now - lastImmediateHeartbeatAtRef.current < ACTIVITY_HEARTBEAT_THROTTLE_MS) {
return;
}
lastImmediateHeartbeatAtRef.current = now;
sendHeartbeat();
}, [client, sendHeartbeat]);
// Track app visibility
useEffect(() => {
console.log("[ClientActivity] AppState effect mounted, current:", AppState.currentState);
const subscription = AppState.addEventListener("change", (nextState) => {
console.log("[ClientActivity] AppState changed:", nextState);
appVisibleRef.current = nextState === "active";
if (nextState === "active") {
recordUserActivity();
maybeSendImmediateHeartbeat();
}
});
return () => subscription.remove();
}, [recordUserActivity]);
}, [maybeSendImmediateHeartbeat, recordUserActivity]);
// Track user activity on web for accurate staleness.
useEffect(() => {
if (Platform.OS !== "web") return;
if (typeof document === "undefined") return;
const handleUserActivity = () => {
recordUserActivity();
maybeSendImmediateHeartbeat();
};
const handleVisibilityChange = () => {
const visible = document.visibilityState === "visible";
appVisibleRef.current = visible;
if (visible) {
recordUserActivity();
maybeSendImmediateHeartbeat();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("focus", handleUserActivity);
window.addEventListener("pointerdown", handleUserActivity, { passive: true });
window.addEventListener("keydown", handleUserActivity);
window.addEventListener("wheel", handleUserActivity, { passive: true });
window.addEventListener("touchstart", handleUserActivity, { passive: true });
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("focus", handleUserActivity);
window.removeEventListener("pointerdown", handleUserActivity);
window.removeEventListener("keydown", handleUserActivity);
window.removeEventListener("wheel", handleUserActivity);
window.removeEventListener("touchstart", handleUserActivity);
};
}, [maybeSendImmediateHeartbeat, recordUserActivity]);
// Send heartbeat on focused agent change
useEffect(() => {
if (prevFocusedAgentIdRef.current !== focusedAgentId) {
console.log("[ClientActivity] focusedAgentId changed:", prevFocusedAgentIdRef.current, "->", focusedAgentId);
prevFocusedAgentIdRef.current = focusedAgentId;
recordUserActivity();
sendHeartbeat();
}
}, [focusedAgentId, sendHeartbeat]);
}, [focusedAgentId, recordUserActivity, sendHeartbeat]);
// Periodic heartbeat
useEffect(() => {
console.log("[ClientActivity] Heartbeat effect mounted, isConnected:", client.isConnected);
const startHeartbeat = () => {
console.log("[ClientActivity] startHeartbeat called");
if (heartbeatIntervalRef.current) {
clearInterval(heartbeatIntervalRef.current);
}
@@ -84,7 +118,6 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
};
const stopHeartbeat = () => {
console.log("[ClientActivity] stopHeartbeat called");
if (heartbeatIntervalRef.current) {
clearInterval(heartbeatIntervalRef.current);
heartbeatIntervalRef.current = null;
@@ -92,7 +125,6 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
};
const unsubscribe = client.subscribeConnectionStatus((state) => {
console.log("[ClientActivity] Connection status changed:", state.status);
if (state.status === "connected") {
startHeartbeat();
} else {

View File

@@ -0,0 +1,109 @@
import { useCallback, useEffect, useRef } from "react";
import { Platform } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
const STORAGE_PREFIX = "@paseo:expo-push-token:";
function getExpoProjectId(): string | null {
const fromEas = (Constants as any)?.easConfig?.projectId;
if (typeof fromEas === "string" && fromEas.trim()) return fromEas.trim();
const fromExtra = (Constants as any)?.expoConfig?.extra?.eas?.projectId;
if (typeof fromExtra === "string" && fromExtra.trim()) return fromExtra.trim();
return null;
}
async function ensurePushPermission(): Promise<boolean> {
const existing = await Notifications.getPermissionsAsync();
if (existing.status === "granted") return true;
if (!existing.canAskAgain) return false;
const requested = await Notifications.requestPermissionsAsync();
return requested.status === "granted";
}
export function usePushTokenRegistration(params: {
client: DaemonClientV2;
serverId: string;
}): void {
const { client, serverId } = params;
const tokenRef = useRef<string | null>(null);
const lastSentTokenRef = useRef<string | null>(null);
const registerIfPossible = useCallback(async () => {
if (Platform.OS === "web") return;
if (!client.isConnected) return;
const token = tokenRef.current;
if (!token) return;
if (lastSentTokenRef.current === token) return;
lastSentTokenRef.current = token;
client.registerPushToken(token);
}, [client]);
useEffect(() => {
if (Platform.OS === "web") return;
const storageKey = `${STORAGE_PREFIX}${serverId}`;
let cancelled = false;
const run = async () => {
const cached = await AsyncStorage.getItem(storageKey);
if (cancelled) return;
if (cached && typeof cached === "string") {
tokenRef.current = cached;
}
const granted = await ensurePushPermission();
if (!granted || cancelled) return;
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.DEFAULT,
});
}
const projectId = getExpoProjectId();
if (!projectId) {
console.warn("[PushToken] Missing EAS projectId; cannot fetch Expo push token");
return;
}
const result = await Notifications.getExpoPushTokenAsync({ projectId });
if (cancelled) return;
const token = result.data;
if (typeof token !== "string" || !token.trim()) return;
tokenRef.current = token;
await AsyncStorage.setItem(storageKey, token);
await registerIfPossible();
};
void run().catch((error) => {
console.warn("[PushToken] Failed to register push token", error);
});
return () => {
cancelled = true;
};
}, [registerIfPossible, serverId]);
useEffect(() => {
const unsubscribe = client.subscribeConnectionStatus((state) => {
if (state.status === "connected") {
void registerIfPossible();
} else {
// Re-register on the next successful connect.
lastSentTokenRef.current = null;
}
});
if (client.isConnected) {
void registerIfPossible();
}
return unsubscribe;
}, [client, registerIfPossible]);
}

View File

@@ -1,5 +1,4 @@
import { Platform } from "react-native";
import * as Notifications from "expo-notifications";
type OsNotificationPayload = {
title: string;
@@ -7,8 +6,6 @@ type OsNotificationPayload = {
data?: Record<string, unknown>;
};
let isNativeConfigured = false;
let permissionState: "unknown" | "granted" | "denied" = "unknown";
let permissionRequest: Promise<boolean> | null = null;
function getWebNotificationConstructor(): {
@@ -20,73 +17,25 @@ function getWebNotificationConstructor(): {
return NotificationConstructor ?? null;
}
async function configureNativeNotifications(): Promise<void> {
if (isNativeConfigured || Platform.OS === "web") {
return;
}
isNativeConfigured = true;
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
}
async function ensureNotificationPermission(): Promise<boolean> {
if (Platform.OS === "web") {
const NotificationConstructor = getWebNotificationConstructor();
if (!NotificationConstructor) {
return false;
}
if (NotificationConstructor.permission === "granted") {
return true;
}
if (NotificationConstructor.permission === "denied") {
return false;
}
if (permissionRequest) {
return permissionRequest;
}
permissionRequest = Promise.resolve(
NotificationConstructor.requestPermission
? NotificationConstructor.requestPermission()
: "denied"
).then((permission) => permission === "granted");
const result = await permissionRequest;
permissionRequest = null;
return result;
const NotificationConstructor = getWebNotificationConstructor();
if (!NotificationConstructor) {
return false;
}
if (permissionState === "granted") {
if (NotificationConstructor.permission === "granted") {
return true;
}
if (permissionState === "denied") {
if (NotificationConstructor.permission === "denied") {
return false;
}
if (permissionRequest) {
return permissionRequest;
}
permissionRequest = (async () => {
const existing = await Notifications.getPermissionsAsync();
if (existing.status === "granted") {
permissionState = "granted";
return true;
}
if (!existing.canAskAgain) {
permissionState = "denied";
return false;
}
const requested = await Notifications.requestPermissionsAsync();
permissionState = requested.status === "granted" ? "granted" : "denied";
return permissionState === "granted";
})();
permissionRequest = Promise.resolve(
NotificationConstructor.requestPermission
? NotificationConstructor.requestPermission()
: "denied"
).then((permission) => permission === "granted");
const result = await permissionRequest;
permissionRequest = null;
return result;
@@ -95,36 +44,22 @@ async function ensureNotificationPermission(): Promise<boolean> {
export async function sendOsNotification(
payload: OsNotificationPayload
): Promise<boolean> {
if (Platform.OS === "web") {
const NotificationConstructor = getWebNotificationConstructor();
if (!NotificationConstructor) {
return false;
}
const granted = await ensureNotificationPermission();
if (!granted) {
return false;
}
new NotificationConstructor(payload.title, {
body: payload.body,
data: payload.data,
});
return true;
// Mobile/native notifications should be remote push only.
if (Platform.OS !== "web") {
return false;
}
await configureNativeNotifications();
const NotificationConstructor = getWebNotificationConstructor();
if (!NotificationConstructor) {
return false;
}
const granted = await ensureNotificationPermission();
if (!granted) {
return false;
}
await Notifications.scheduleNotificationAsync({
content: {
title: payload.title,
body: payload.body,
data: payload.data,
},
trigger: null,
new NotificationConstructor(payload.title, {
body: payload.body,
data: payload.data,
});
return true;
}

View File

@@ -1,25 +1,38 @@
import type pino from "pino";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
/**
* Simple in-memory store for Expo push tokens.
* Tokens are used to send push notifications when all clients are stale.
* Store for Expo push tokens.
*
* Tokens are persisted to disk so pushes still work after daemon restarts.
*/
export class PushTokenStore {
private readonly logger: pino.Logger;
private tokens: Set<string> = new Set();
private readonly filePath: string;
constructor(logger: pino.Logger) {
constructor(logger: pino.Logger, filePath: string) {
this.logger = logger.child({ component: "token-store" });
this.filePath = filePath;
this.loadFromDisk();
}
addToken(token: string): void {
this.tokens.add(token);
const normalized = token.trim();
if (!normalized) return;
if (this.tokens.has(normalized)) return;
this.tokens.add(normalized);
this.persist();
this.logger.debug({ total: this.tokens.size }, "Added token");
}
removeToken(token: string): void {
const deleted = this.tokens.delete(token);
const normalized = token.trim();
if (!normalized) return;
const deleted = this.tokens.delete(normalized);
if (deleted) {
this.persist();
this.logger.debug({ total: this.tokens.size }, "Removed token");
}
}
@@ -27,4 +40,35 @@ export class PushTokenStore {
getAllTokens(): string[] {
return Array.from(this.tokens);
}
private loadFromDisk(): void {
try {
if (!existsSync(this.filePath)) {
return;
}
const raw = readFileSync(this.filePath, "utf-8");
const parsed = JSON.parse(raw) as { tokens?: unknown };
const tokens = Array.isArray(parsed.tokens)
? parsed.tokens.filter((t): t is string => typeof t === "string" && t.trim().length > 0)
: [];
this.tokens = new Set(tokens.map((t) => t.trim()));
this.logger.info({ total: this.tokens.size }, "Loaded push tokens");
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn({ err }, "Failed to load push tokens");
}
}
private persist(): void {
try {
mkdirSync(dirname(this.filePath), { recursive: true });
const tmpPath = `${this.filePath}.tmp`;
const payload = JSON.stringify({ tokens: Array.from(this.tokens) }, null, 2) + "\n";
writeFileSync(tmpPath, payload);
renameSync(tmpPath, this.filePath);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn({ err }, "Failed to persist push tokens");
}
}
}

View File

@@ -71,7 +71,10 @@ export class WebSocketSessionBridge {
this.dictation = dictation ?? null;
const pushLogger = this.logger.child({ module: "push" });
this.pushTokenStore = new PushTokenStore(pushLogger);
this.pushTokenStore = new PushTokenStore(
pushLogger,
join(paseoHome, "push-tokens.json")
);
this.pushService = new PushService(pushLogger, this.pushTokenStore);
this.agentManager.setAgentAttentionCallback((params) => {
@@ -391,16 +394,42 @@ export class WebSocketSessionBridge {
"broadcastAgentAttention"
);
const allClientsStale = allStates.every((state) => state.isStale);
this.logger.debug({ allClientsStale }, "Client staleness check");
if (allClientsStale) {
const hasActiveWebClient = allStates.some(
(state) => state.deviceType === "web" && !state.isStale
);
const hasActiveMobileForegroundClient = allStates.some(
(state) => state.deviceType === "mobile" && state.appVisible && !state.isStale
);
// Push is only a fallback when the user is away from their desktop/web.
// Also suppress push if they're actively using the mobile app.
const shouldSendPush =
params.reason !== "error" &&
!hasActiveWebClient &&
!hasActiveMobileForegroundClient;
this.logger.debug(
{ hasActiveWebClient, hasActiveMobileForegroundClient, shouldSendPush },
"Push gating check"
);
if (shouldSendPush) {
const tokens = this.pushTokenStore.getAllTokens();
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
if (tokens.length > 0) {
const agent = this.agentManager.getAgent(params.agentId);
const agentTitle = agent?.config?.title ?? agent?.cwd ?? params.agentId;
const title =
params.reason === "permission" ? "Agent needs permission" : "Agent finished";
const body =
params.reason === "permission"
? `Permission requested: ${agentTitle}`
: `Finished: ${agentTitle}`;
void this.pushService.sendPush(tokens, {
title: "Agent needs attention",
body: `Reason: ${params.reason}`,
data: { agentId: params.agentId },
title,
body,
data: { agentId: params.agentId, reason: params.reason },
});
}
}