feat: add smart notification routing with device-aware heartbeats and push support
This commit is contained in:
@@ -353,7 +353,6 @@ function AgentScreenContent({
|
||||
|
||||
// Get methods
|
||||
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
|
||||
const initializeAgent = methods?.initializeAgent;
|
||||
const refreshAgent = methods?.refreshAgent;
|
||||
const setFocusedAgentId = useCallback(
|
||||
(agentId: string | null) => {
|
||||
@@ -436,11 +435,11 @@ function AgentScreenContent({
|
||||
? isInitializingFromMap !== false
|
||||
: false;
|
||||
|
||||
// Track which agent we've already triggered initialization for
|
||||
const initializedAgentRef = useRef<string | null>(null);
|
||||
// Get ensureAgentIsInitialized from methods
|
||||
const ensureAgentIsInitialized = methods?.ensureAgentIsInitialized;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId || !initializeAgent) {
|
||||
if (!resolvedAgentId || !ensureAgentIsInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -449,28 +448,15 @@ function AgentScreenContent({
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already initializing
|
||||
if (isInitializingFromMap === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if we already triggered initialization for this agent
|
||||
if (initializedAgentRef.current === resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark this agent as initialized
|
||||
initializedAgentRef.current = resolvedAgentId;
|
||||
|
||||
// Always fetch timeline when opening agent screen
|
||||
// Server returns full snapshot, client replaces cache
|
||||
// This ensures we always have complete history, even after reconnection
|
||||
console.log("[AgentScreen] Fetching agent timeline", {
|
||||
agentId: resolvedAgentId,
|
||||
// ensureAgentIsInitialized handles deduplication via module-level promises map
|
||||
// If already initialized or in-flight, returns resolved/pending promise immediately
|
||||
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
|
||||
console.warn("[AgentScreen] Agent initialization failed", {
|
||||
agentId: resolvedAgentId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
initializeAgent({ agentId: resolvedAgentId });
|
||||
}, [resolvedAgentId, initializeAgent, isInitializingFromMap, isConnected]);
|
||||
}, [resolvedAgentId, ensureAgentIsInitialized, isConnected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
|
||||
@@ -12,6 +12,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
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 {
|
||||
applyStreamEvent,
|
||||
generateMessageId,
|
||||
@@ -194,6 +195,20 @@ type FileDownloadTokenPayload = Extract<
|
||||
|
||||
const SESSION_SNAPSHOT_STORAGE_PREFIX = "@paseo:session-snapshot:";
|
||||
|
||||
// Module-level map for agent initialization promises
|
||||
// Key: `${serverId}:${agentId}`, Value: { promise, resolve, reject }
|
||||
// This survives Fast Refresh because it's outside React component tree
|
||||
interface DeferredInit {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
const agentInitializationPromises = new Map<string, DeferredInit>();
|
||||
|
||||
function getInitKey(serverId: string, agentId: string): string {
|
||||
return `${serverId}:${agentId}`;
|
||||
}
|
||||
|
||||
type PersistedSessionSnapshot = {
|
||||
agents: AgentSnapshotPayload[];
|
||||
savedAt: string;
|
||||
@@ -356,6 +371,7 @@ export interface SessionContextValue {
|
||||
requestId: string,
|
||||
response: any
|
||||
) => void;
|
||||
ensureAgentIsInitialized: (agentId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
@@ -421,6 +437,14 @@ export function SessionProvider({
|
||||
(state) => state.updateSessionConnection
|
||||
);
|
||||
|
||||
// Track focused agent for heartbeat
|
||||
const focusedAgentId = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.focusedAgentId ?? null
|
||||
);
|
||||
|
||||
// Client activity tracking (heartbeat, push token registration)
|
||||
useClientActivity({ client, focusedAgentId });
|
||||
|
||||
// State for voice detection flags (will be set by RealtimeContext)
|
||||
const isDetectingRef = useRef(false);
|
||||
const isSpeakingRef = useRef(false);
|
||||
@@ -1009,11 +1033,14 @@ export function SessionProvider({
|
||||
console.log("[Session] agent_stream", { agentId, event, timestamp });
|
||||
|
||||
if (event.type === "attention_required") {
|
||||
notifyAgentAttention({
|
||||
agentId,
|
||||
reason: event.reason,
|
||||
timestamp: event.timestamp,
|
||||
});
|
||||
console.log("[Session] attention_required", { agentId, shouldNotify: event.shouldNotify, reason: event.reason });
|
||||
if (event.shouldNotify) {
|
||||
notifyAgentAttention({
|
||||
agentId,
|
||||
reason: event.reason,
|
||||
timestamp: event.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
@@ -1094,6 +1121,14 @@ export function SessionProvider({
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Resolve the initialization promise (even for empty history)
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const deferred = agentInitializationPromises.get(initKey);
|
||||
if (deferred) {
|
||||
deferred.resolve();
|
||||
// Keep the promise in the map so subsequent calls return immediately
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1563,7 +1598,6 @@ export function SessionProvider({
|
||||
|
||||
const initializeAgent = useCallback(
|
||||
({ agentId, requestId }: { agentId: string; requestId?: string }) => {
|
||||
console.log("[Session] initializeAgent called", { agentId, requestId });
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, true);
|
||||
@@ -1635,6 +1669,94 @@ export function SessionProvider({
|
||||
]
|
||||
);
|
||||
|
||||
const INIT_TIMEOUT_MS = 10000;
|
||||
|
||||
const ensureAgentIsInitialized = useCallback(
|
||||
(agentId: string): Promise<void> => {
|
||||
const key = getInitKey(serverId, agentId);
|
||||
|
||||
// If we already have a promise (resolved or in-flight), return it
|
||||
const existing = agentInitializationPromises.get(key);
|
||||
if (existing) {
|
||||
return existing.promise;
|
||||
}
|
||||
|
||||
// Create a deferred promise
|
||||
let resolve: () => void;
|
||||
let reject: (error: Error) => void;
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
const deferred: DeferredInit = {
|
||||
promise,
|
||||
resolve: resolve!,
|
||||
reject: reject!,
|
||||
};
|
||||
agentInitializationPromises.set(key, deferred);
|
||||
|
||||
// Set up timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
const entry = agentInitializationPromises.get(key);
|
||||
if (entry === deferred) {
|
||||
agentInitializationPromises.delete(key);
|
||||
deferred.reject(new Error(`Agent initialization timed out after ${INIT_TIMEOUT_MS}ms`));
|
||||
}
|
||||
}, INIT_TIMEOUT_MS);
|
||||
|
||||
// Set UI loading state
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, true);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Clear existing stream state
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, []);
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
|
||||
if (!client) {
|
||||
console.warn("[Session] ensureAgentIsInitialized skipped: daemon unavailable");
|
||||
clearTimeout(timeoutId);
|
||||
agentInitializationPromises.delete(key);
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
deferred.reject(new Error("Daemon unavailable"));
|
||||
return promise;
|
||||
}
|
||||
|
||||
client
|
||||
.initializeAgent(agentId)
|
||||
.then(() => {
|
||||
// Note: We don't resolve here - we wait for agent_stream_snapshot
|
||||
// The snapshot handler will call deferred.resolve()
|
||||
clearTimeout(timeoutId);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Session] ensureAgentIsInitialized failed", { agentId, error });
|
||||
clearTimeout(timeoutId);
|
||||
agentInitializationPromises.delete(key);
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
deferred.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
|
||||
return promise;
|
||||
},
|
||||
[serverId, client, setAgentStreamTail, setInitializingAgents, clearAgentStreamHead]
|
||||
);
|
||||
|
||||
const requestProviderModels = useCallback(
|
||||
(provider: any, options?: { cwd?: string }) => {
|
||||
const requestId = generateMessageId();
|
||||
@@ -2231,6 +2353,7 @@ export function SessionProvider({
|
||||
createAgent,
|
||||
setAgentMode,
|
||||
respondToPermission,
|
||||
ensureAgentIsInitialized,
|
||||
}),
|
||||
[
|
||||
serverId,
|
||||
@@ -2254,6 +2377,7 @@ export function SessionProvider({
|
||||
createAgent,
|
||||
setAgentMode,
|
||||
respondToPermission,
|
||||
ensureAgentIsInitialized,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -2280,6 +2404,7 @@ export function SessionProvider({
|
||||
createAgent,
|
||||
setAgentMode,
|
||||
respondToPermission,
|
||||
ensureAgentIsInitialized,
|
||||
}),
|
||||
[
|
||||
setVoiceDetectionFlags,
|
||||
@@ -2300,6 +2425,7 @@ export function SessionProvider({
|
||||
createAgent,
|
||||
setAgentMode,
|
||||
respondToPermission,
|
||||
ensureAgentIsInitialized,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
|
||||
interface ClientActivityOptions {
|
||||
client: DaemonClientV2;
|
||||
focusedAgentId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles client activity reporting:
|
||||
* - Heartbeat sending every 15 seconds
|
||||
* - App visibility tracking (updates lastActivityAt on foreground)
|
||||
* - Sends heartbeat immediately when focused agent changes
|
||||
*/
|
||||
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 deviceType = Platform.OS === "web" ? "web" : "mobile";
|
||||
|
||||
const recordUserActivity = useCallback(() => {
|
||||
lastActivityAtRef.current = new Date();
|
||||
}, []);
|
||||
|
||||
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,
|
||||
});
|
||||
client.sendHeartbeat({
|
||||
deviceType,
|
||||
focusedAgentId,
|
||||
lastActivityAt: lastActivityAtRef.current.toISOString(),
|
||||
appVisible: appVisibleRef.current,
|
||||
});
|
||||
}, [client, deviceType, focusedAgentId]);
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [recordUserActivity]);
|
||||
|
||||
// Send heartbeat on focused agent change
|
||||
useEffect(() => {
|
||||
if (prevFocusedAgentIdRef.current !== focusedAgentId) {
|
||||
console.log("[ClientActivity] focusedAgentId changed:", prevFocusedAgentIdRef.current, "->", focusedAgentId);
|
||||
prevFocusedAgentIdRef.current = focusedAgentId;
|
||||
sendHeartbeat();
|
||||
}
|
||||
}, [focusedAgentId, 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);
|
||||
}
|
||||
sendHeartbeat();
|
||||
heartbeatIntervalRef.current = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const stopHeartbeat = () => {
|
||||
console.log("[ClientActivity] stopHeartbeat called");
|
||||
if (heartbeatIntervalRef.current) {
|
||||
clearInterval(heartbeatIntervalRef.current);
|
||||
heartbeatIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = client.subscribeConnectionStatus((state) => {
|
||||
console.log("[ClientActivity] Connection status changed:", state.status);
|
||||
if (state.status === "connected") {
|
||||
startHeartbeat();
|
||||
} else {
|
||||
stopHeartbeat();
|
||||
}
|
||||
});
|
||||
|
||||
if (client.isConnected) {
|
||||
startHeartbeat();
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
stopHeartbeat();
|
||||
};
|
||||
}, [client, sendHeartbeat]);
|
||||
}
|
||||
@@ -204,6 +204,7 @@ export interface SessionState {
|
||||
}) => Promise<void>;
|
||||
setAgentMode: (agentId: string, modeId: string) => void;
|
||||
respondToPermission: (agentId: string, requestId: string, response: any) => void;
|
||||
ensureAgentIsInitialized: (agentId: string) => Promise<void>;
|
||||
} | null;
|
||||
|
||||
// Hydration status
|
||||
@@ -223,7 +224,7 @@ export interface SessionState {
|
||||
agentStreamTail: Map<string, StreamItem[]>;
|
||||
agentStreamHead: Map<string, StreamItem[]>;
|
||||
|
||||
// Initializing agents
|
||||
// Initializing agents (used for UI loading state)
|
||||
initializingAgents: Map<string, boolean>;
|
||||
|
||||
// Agents
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
/**
|
||||
* Simple hash function for deterministic ID generation
|
||||
*/
|
||||
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i += 1) {
|
||||
|
||||
Reference in New Issue
Block a user