mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Stabilize agent history sync after app backgrounding
This commit is contained in:
@@ -85,11 +85,6 @@ export interface AgentStreamViewProps {
|
||||
agent: Agent;
|
||||
streamItems: StreamItem[];
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
/**
|
||||
* True when we expect a history snapshot to arrive shortly (e.g. after opening an agent
|
||||
* or resuming after connectivity changes) and should avoid showing the "empty" state.
|
||||
*/
|
||||
isSyncingHistory?: boolean;
|
||||
}
|
||||
|
||||
export function AgentStreamView({
|
||||
@@ -98,7 +93,6 @@ export function AgentStreamView({
|
||||
agent,
|
||||
streamItems,
|
||||
pendingPermissions,
|
||||
isSyncingHistory = false,
|
||||
}: AgentStreamViewProps) {
|
||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||
const { theme } = useUnistyles();
|
||||
@@ -521,9 +515,8 @@ export function AgentStreamView({
|
||||
});
|
||||
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
|
||||
|
||||
const showSyncingIndicator = isSyncingHistory;
|
||||
const showWorkingIndicator = agent.status === "running";
|
||||
const showBottomBar = showSyncingIndicator || showWorkingIndicator || isVoiceMode;
|
||||
const showBottomBar = showWorkingIndicator || isVoiceMode;
|
||||
|
||||
const listHeaderComponent = useMemo(() => {
|
||||
const hasPermissions = pendingPermissionItems.length > 0;
|
||||
@@ -533,11 +526,7 @@ export function AgentStreamView({
|
||||
return null;
|
||||
}
|
||||
|
||||
const leftContent = showSyncingIndicator
|
||||
? <SyncingIndicator />
|
||||
: showWorkingIndicator
|
||||
? <WorkingIndicator />
|
||||
: null;
|
||||
const leftContent = showWorkingIndicator ? <WorkingIndicator /> : null;
|
||||
|
||||
return (
|
||||
<View style={stylesheet.contentWrapper}>
|
||||
@@ -587,7 +576,6 @@ export function AgentStreamView({
|
||||
);
|
||||
}, [
|
||||
pendingPermissionItems,
|
||||
showSyncingIndicator,
|
||||
showWorkingIndicator,
|
||||
client,
|
||||
streamHead,
|
||||
@@ -629,17 +617,16 @@ export function AgentStreamView({
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldShowSyncing =
|
||||
isSyncingHistory || agent.status === "running";
|
||||
const shouldShowWorking = agent.status === "running";
|
||||
|
||||
if (shouldShowSyncing) {
|
||||
if (shouldShowWorking) {
|
||||
return (
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={stylesheet.emptyStateText}>Catching up…</Text>
|
||||
<Text style={stylesheet.emptyStateText}>Working…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -653,7 +640,6 @@ export function AgentStreamView({
|
||||
);
|
||||
}, [
|
||||
agent.status,
|
||||
isSyncingHistory,
|
||||
pendingPermissionItems.length,
|
||||
streamHead,
|
||||
theme.colors.foregroundMuted,
|
||||
@@ -870,15 +856,6 @@ function WorkingIndicator() {
|
||||
);
|
||||
}
|
||||
|
||||
function SyncingIndicator() {
|
||||
return (
|
||||
<View style={stylesheet.syncingIndicator}>
|
||||
<ActivityIndicator size="small" color={stylesheet.syncingIndicatorText.color} />
|
||||
<Text style={stylesheet.syncingIndicatorText}>Catching up…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Permission Request Card Component
|
||||
function PermissionRequestCard({
|
||||
permission,
|
||||
|
||||
@@ -66,6 +66,7 @@ const derivePendingPermissionKey = (
|
||||
};
|
||||
|
||||
const NOTIFICATION_PREVIEW_LIMIT = 220;
|
||||
const HISTORY_STALE_AFTER_MS = 60_000;
|
||||
|
||||
const normalizeNotificationText = (text: string): string =>
|
||||
text.replace(/\s+/g, " ").trim();
|
||||
@@ -304,6 +305,12 @@ export function SessionProvider({
|
||||
const setInitializingAgents = useSessionStore(
|
||||
(state) => state.setInitializingAgents
|
||||
);
|
||||
const bumpHistorySyncGeneration = useSessionStore(
|
||||
(state) => state.bumpHistorySyncGeneration
|
||||
);
|
||||
const markAgentHistorySynchronized = useSessionStore(
|
||||
(state) => state.markAgentHistorySynchronized
|
||||
);
|
||||
const setHasHydratedAgents = useSessionStore(
|
||||
(state) => state.setHasHydratedAgents
|
||||
);
|
||||
@@ -329,8 +336,18 @@ export function SessionProvider({
|
||||
(state) => state.sessions[serverId]?.focusedAgentId ?? null
|
||||
);
|
||||
|
||||
const handleAppResumed = useCallback(
|
||||
(awayMs: number) => {
|
||||
if (awayMs < HISTORY_STALE_AFTER_MS) {
|
||||
return;
|
||||
}
|
||||
bumpHistorySyncGeneration(serverId);
|
||||
},
|
||||
[bumpHistorySyncGeneration, serverId]
|
||||
);
|
||||
|
||||
// Client activity tracking (heartbeat, push token registration)
|
||||
useClientActivity({ client, focusedAgentId });
|
||||
useClientActivity({ client, focusedAgentId, onAppResumed: handleAppResumed });
|
||||
usePushTokenRegistration({ client, serverId });
|
||||
|
||||
// State for voice detection flags (will be set by RealtimeContext)
|
||||
@@ -458,6 +475,15 @@ export function SessionProvider({
|
||||
return unsubscribe;
|
||||
}, [client]);
|
||||
|
||||
const wasConnectedRef = useRef(client.isConnected);
|
||||
useEffect(() => {
|
||||
const wasConnected = wasConnectedRef.current;
|
||||
if (!wasConnected && connectionSnapshot.isConnected) {
|
||||
bumpHistorySyncGeneration(serverId);
|
||||
}
|
||||
wasConnectedRef.current = connectionSnapshot.isConnected;
|
||||
}, [serverId, connectionSnapshot.isConnected, bumpHistorySyncGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
updateSessionConnection(serverId, connectionSnapshot);
|
||||
}, [serverId, connectionSnapshot, updateSessionConnection]);
|
||||
@@ -994,6 +1020,7 @@ export function SessionProvider({
|
||||
|
||||
// Resolve the initialization promise (even for empty history)
|
||||
resolveInitDeferred(initKey);
|
||||
markAgentHistorySynchronized(serverId, agentId);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1436,6 +1463,7 @@ export function SessionProvider({
|
||||
clearDraftInput,
|
||||
notifyAgentAttention,
|
||||
applyAgentUpdatePayload,
|
||||
markAgentHistorySynchronized,
|
||||
]);
|
||||
|
||||
const initializeAgent = useCallback(
|
||||
|
||||
@@ -8,6 +8,7 @@ const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
|
||||
interface ClientActivityOptions {
|
||||
client: DaemonClient;
|
||||
focusedAgentId: string | null;
|
||||
onAppResumed?: (awayMs: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,9 +17,17 @@ interface ClientActivityOptions {
|
||||
* - App visibility tracking
|
||||
* - Records lastActivityAt only on real user activity (not on heartbeat)
|
||||
*/
|
||||
export function useClientActivity({ client, focusedAgentId }: ClientActivityOptions): void {
|
||||
export function useClientActivity({
|
||||
client,
|
||||
focusedAgentId,
|
||||
onAppResumed,
|
||||
}: ClientActivityOptions): void {
|
||||
const lastActivityAtRef = useRef<Date>(new Date());
|
||||
const appVisibleRef = useRef(AppState.currentState === "active");
|
||||
const appVisibilityChangedAtRef = useRef<Date>(new Date());
|
||||
const backgroundedAtMsRef = useRef<number | null>(
|
||||
AppState.currentState === "active" ? null : Date.now()
|
||||
);
|
||||
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
|
||||
const lastImmediateHeartbeatAtRef = useRef<number>(0);
|
||||
@@ -36,9 +45,34 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
||||
focusedAgentId,
|
||||
lastActivityAt: lastActivityAtRef.current.toISOString(),
|
||||
appVisible: appVisibleRef.current,
|
||||
appVisibilityChangedAt: appVisibilityChangedAtRef.current.toISOString(),
|
||||
});
|
||||
}, [client, deviceType, focusedAgentId]);
|
||||
|
||||
const setAppVisible = useCallback(
|
||||
(nextVisible: boolean) => {
|
||||
const previousVisible = appVisibleRef.current;
|
||||
if (previousVisible === nextVisible) {
|
||||
return;
|
||||
}
|
||||
appVisibleRef.current = nextVisible;
|
||||
appVisibilityChangedAtRef.current = new Date();
|
||||
|
||||
if (!nextVisible) {
|
||||
backgroundedAtMsRef.current = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const backgroundedAt = backgroundedAtMsRef.current;
|
||||
backgroundedAtMsRef.current = null;
|
||||
if (backgroundedAt !== null) {
|
||||
onAppResumed?.(Math.max(0, Date.now() - backgroundedAt));
|
||||
}
|
||||
recordUserActivity();
|
||||
},
|
||||
[onAppResumed, recordUserActivity]
|
||||
);
|
||||
|
||||
const maybeSendImmediateHeartbeat = useCallback(() => {
|
||||
if (!client.isConnected) return;
|
||||
const now = Date.now();
|
||||
@@ -52,16 +86,13 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
||||
// Track app visibility
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||
appVisibleRef.current = nextState === "active";
|
||||
if (nextState === "active") {
|
||||
recordUserActivity();
|
||||
}
|
||||
setAppVisible(nextState === "active");
|
||||
// Send immediately on visibility changes so the server can adapt streaming behavior.
|
||||
sendHeartbeat();
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [recordUserActivity, sendHeartbeat]);
|
||||
}, [sendHeartbeat, setAppVisible]);
|
||||
|
||||
// Track user activity on web for accurate staleness.
|
||||
useEffect(() => {
|
||||
@@ -75,9 +106,8 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
const visible = document.visibilityState === "visible";
|
||||
appVisibleRef.current = visible;
|
||||
setAppVisible(visible);
|
||||
if (visible) {
|
||||
recordUserActivity();
|
||||
maybeSendImmediateHeartbeat();
|
||||
}
|
||||
};
|
||||
@@ -97,7 +127,7 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
||||
window.removeEventListener("wheel", handleUserActivity);
|
||||
window.removeEventListener("touchstart", handleUserActivity);
|
||||
};
|
||||
}, [maybeSendImmediateHeartbeat, recordUserActivity]);
|
||||
}, [maybeSendImmediateHeartbeat, recordUserActivity, setAppVisible]);
|
||||
|
||||
// Send heartbeat on focused agent change
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
ScrollView,
|
||||
Platform,
|
||||
BackHandler,
|
||||
AppState,
|
||||
} from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
@@ -333,6 +332,14 @@ function AgentScreenContent({
|
||||
? state.sessions[serverId]?.initializingAgents?.get(resolvedAgentId) ?? false
|
||||
: false
|
||||
);
|
||||
const historySyncGeneration = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.historySyncGeneration ?? 0
|
||||
);
|
||||
const agentHistorySyncGeneration = useSessionStore((state) =>
|
||||
resolvedAgentId
|
||||
? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1
|
||||
: -1
|
||||
);
|
||||
|
||||
// Select raw pending permissions - filter with useMemo to avoid new Map on every render
|
||||
const allPendingPermissions = useSessionStore(
|
||||
@@ -409,6 +416,12 @@ function AgentScreenContent({
|
||||
const initKey = getInitKey(serverId, resolvedAgentId);
|
||||
return Boolean(getInitDeferred(initKey));
|
||||
}, [resolvedAgentId, isInitializing, serverId]);
|
||||
const needsAuthoritativeSync = useMemo(() => {
|
||||
if (!resolvedAgentId) {
|
||||
return false;
|
||||
}
|
||||
return agentHistorySyncGeneration < historySyncGeneration;
|
||||
}, [agentHistorySyncGeneration, historySyncGeneration, resolvedAgentId]);
|
||||
|
||||
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
|
||||
if (!isPendingCreateForRoute || !pendingCreate) {
|
||||
@@ -439,6 +452,7 @@ function AgentScreenContent({
|
||||
}, [optimisticStreamItems, streamItems]);
|
||||
|
||||
const shouldUseOptimisticStream = isPendingCreateForRoute && optimisticStreamItems.length > 0;
|
||||
const shouldBlockForHistorySync = !shouldUseOptimisticStream && (needsAuthoritativeSync || isHistorySyncing);
|
||||
|
||||
const placeholderAgent: Agent | null = useMemo(() => {
|
||||
if (!shouldUseOptimisticStream || !resolvedAgentId) {
|
||||
@@ -515,60 +529,20 @@ function AgentScreenContent({
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if not connected - will re-run when connection is established
|
||||
if (!isConnected) {
|
||||
return;
|
||||
}
|
||||
if (!needsAuthoritativeSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
}, [resolvedAgentId, ensureAgentIsInitialized, isConnected]);
|
||||
|
||||
// When the app comes back to the foreground, re-sync history for the focused agent.
|
||||
// This covers cases where the OS/backgrounding caused us to miss stream events.
|
||||
const lastAppStateRef = useRef(AppState.currentState);
|
||||
const lastResumeSyncAtRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||
const prev = lastAppStateRef.current;
|
||||
lastAppStateRef.current = nextState;
|
||||
|
||||
if (nextState !== "active" || prev === "active") {
|
||||
return;
|
||||
}
|
||||
if (!isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
// Avoid accidental double-syncs on rapid transitions.
|
||||
if (now - lastResumeSyncAtRef.current < 2000) {
|
||||
return;
|
||||
}
|
||||
lastResumeSyncAtRef.current = now;
|
||||
|
||||
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
|
||||
console.warn("[AgentScreen] Agent initialization failed on resume", {
|
||||
agentId: resolvedAgentId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [ensureAgentIsInitialized, isConnected, resolvedAgentId]);
|
||||
}, [resolvedAgentId, ensureAgentIsInitialized, isConnected, needsAuthoritativeSync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
@@ -638,7 +612,7 @@ function AgentScreenContent({
|
||||
|
||||
const mainContent = (
|
||||
<View style={styles.outerContainer}>
|
||||
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isInitializing}>
|
||||
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isInitializing || shouldBlockForHistorySync}>
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<MenuHeader
|
||||
@@ -815,7 +789,7 @@ function AgentScreenContent({
|
||||
|
||||
<DropdownMenuItem
|
||||
leading={<RotateCcw size={16} color={theme.colors.foreground} />}
|
||||
disabled={isInitializing}
|
||||
disabled={isInitializing || shouldBlockForHistorySync}
|
||||
trailing={
|
||||
isInitializing ? (
|
||||
<ActivityIndicator
|
||||
@@ -837,25 +811,36 @@ function AgentScreenContent({
|
||||
|
||||
{/* Content Area with Keyboard Animation */}
|
||||
<View style={styles.contentContainer}>
|
||||
<ReanimatedAnimated.View
|
||||
style={[styles.content, animatedKeyboardStyle]}
|
||||
>
|
||||
<AgentStreamView
|
||||
agentId={effectiveAgent.id}
|
||||
serverId={serverId}
|
||||
agent={effectiveAgent}
|
||||
streamItems={
|
||||
shouldUseOptimisticStream ? mergedStreamItems : streamItems
|
||||
}
|
||||
pendingPermissions={pendingPermissions}
|
||||
isSyncingHistory={isHistorySyncing && !shouldUseOptimisticStream}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
{shouldBlockForHistorySync ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
<Text style={styles.loadingText}>Loading agent...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ReanimatedAnimated.View
|
||||
style={[styles.content, animatedKeyboardStyle]}
|
||||
>
|
||||
<AgentStreamView
|
||||
agentId={effectiveAgent.id}
|
||||
serverId={serverId}
|
||||
agent={effectiveAgent}
|
||||
streamItems={
|
||||
shouldUseOptimisticStream ? mergedStreamItems : streamItems
|
||||
}
|
||||
pendingPermissions={pendingPermissions}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Agent Input Area */}
|
||||
{agent && resolvedAgentId && (
|
||||
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
|
||||
{agent && resolvedAgentId && !shouldBlockForHistorySync && (
|
||||
<AgentInputArea
|
||||
agentId={resolvedAgentId}
|
||||
serverId={serverId}
|
||||
autoFocus
|
||||
onAddImages={handleAddImagesCallback}
|
||||
/>
|
||||
)}
|
||||
|
||||
</View>
|
||||
|
||||
@@ -184,6 +184,8 @@ export interface SessionState {
|
||||
// Stream state (head/tail model)
|
||||
agentStreamTail: Map<string, StreamItem[]>;
|
||||
agentStreamHead: Map<string, StreamItem[]>;
|
||||
historySyncGeneration: number;
|
||||
agentHistorySyncGeneration: Map<string, number>;
|
||||
|
||||
// Initializing agents (used for UI loading state)
|
||||
initializingAgents: Map<string, boolean>;
|
||||
@@ -236,6 +238,8 @@ interface SessionStoreActions {
|
||||
setAgentStreamTail: (serverId: string, state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
|
||||
setAgentStreamHead: (serverId: string, state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
|
||||
clearAgentStreamHead: (serverId: string, agentId: string) => void;
|
||||
bumpHistorySyncGeneration: (serverId: string) => void;
|
||||
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (serverId: string, state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>)) => void;
|
||||
@@ -318,6 +322,8 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
|
||||
currentAssistantMessage: "",
|
||||
agentStreamTail: new Map(),
|
||||
agentStreamHead: new Map(),
|
||||
historySyncGeneration: 0,
|
||||
agentHistorySyncGeneration: new Map(),
|
||||
initializingAgents: new Map(),
|
||||
agents: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
@@ -599,6 +605,59 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
bumpHistorySyncGeneration: (serverId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const nextGeneration = session.historySyncGeneration + 1;
|
||||
logSessionStoreUpdate("bumpHistorySyncGeneration", serverId, {
|
||||
generation: nextGeneration,
|
||||
});
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
historySyncGeneration: nextGeneration,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
markAgentHistorySynchronized: (serverId, agentId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const currentGeneration = session.historySyncGeneration;
|
||||
const previousGeneration = session.agentHistorySyncGeneration.get(agentId);
|
||||
if (previousGeneration === currentGeneration) {
|
||||
return prev;
|
||||
}
|
||||
const nextMap = new Map(session.agentHistorySyncGeneration);
|
||||
nextMap.set(agentId, currentGeneration);
|
||||
logSessionStoreUpdate("markAgentHistorySynchronized", serverId, {
|
||||
agentId,
|
||||
generation: currentGeneration,
|
||||
});
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentHistorySyncGeneration: nextMap,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (serverId, state) => {
|
||||
set((prev) => {
|
||||
|
||||
@@ -772,6 +772,7 @@ export class DaemonClient {
|
||||
focusedAgentId: string | null;
|
||||
lastActivityAt: string;
|
||||
appVisible: boolean;
|
||||
appVisibilityChangedAt?: string;
|
||||
}): void {
|
||||
this.sendSessionMessage({
|
||||
type: "client_heartbeat",
|
||||
@@ -779,6 +780,7 @@ export class DaemonClient {
|
||||
focusedAgentId: params.focusedAgentId,
|
||||
lastActivityAt: params.lastActivityAt,
|
||||
appVisible: params.appVisible,
|
||||
appVisibilityChangedAt: params.appVisibilityChangedAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -375,7 +375,9 @@ export class Session {
|
||||
focusedAgentId: string | null;
|
||||
lastActivityAt: Date;
|
||||
appVisible: boolean;
|
||||
appVisibilityChangedAt: Date;
|
||||
} | null = null;
|
||||
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private terminalSubscriptions: Map<string, () => void> = new Map();
|
||||
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
|
||||
@@ -474,6 +476,7 @@ export class Session {
|
||||
focusedAgentId: string | null;
|
||||
lastActivityAt: Date;
|
||||
appVisible: boolean;
|
||||
appVisibilityChangedAt: Date;
|
||||
} | null {
|
||||
return this.clientActivity;
|
||||
}
|
||||
@@ -646,21 +649,24 @@ export class Session {
|
||||
}
|
||||
|
||||
// Reduce bandwidth/CPU on mobile: only forward high-frequency agent stream events
|
||||
// while the app is visible and the user is focused on that agent.
|
||||
// for the focused agent, with a short grace window while backgrounded.
|
||||
//
|
||||
// History catch-up is handled via explicit `initialize_agent_request` which emits a
|
||||
// batched `agent_stream_snapshot`.
|
||||
const activity = this.clientActivity;
|
||||
if (activity?.deviceType === "mobile") {
|
||||
if (!activity.appVisible) {
|
||||
return;
|
||||
}
|
||||
if (!activity.focusedAgentId) {
|
||||
return;
|
||||
}
|
||||
if (activity.focusedAgentId !== event.agentId) {
|
||||
return;
|
||||
}
|
||||
if (!activity.appVisible) {
|
||||
const hiddenForMs = Date.now() - activity.appVisibilityChangedAt.getTime();
|
||||
if (hiddenForMs >= this.MOBILE_BACKGROUND_STREAM_GRACE_MS) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serializedEvent = serializeAgentStreamEvent(event.event);
|
||||
@@ -2643,12 +2649,17 @@ export class Session {
|
||||
focusedAgentId: string | null;
|
||||
lastActivityAt: string;
|
||||
appVisible: boolean;
|
||||
appVisibilityChangedAt?: string;
|
||||
}): void {
|
||||
const appVisibilityChangedAt = msg.appVisibilityChangedAt
|
||||
? new Date(msg.appVisibilityChangedAt)
|
||||
: new Date(msg.lastActivityAt);
|
||||
this.clientActivity = {
|
||||
deviceType: msg.deviceType,
|
||||
focusedAgentId: msg.focusedAgentId,
|
||||
lastActivityAt: new Date(msg.lastActivityAt),
|
||||
appVisible: msg.appVisible,
|
||||
appVisibilityChangedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -864,6 +864,7 @@ export const ClientHeartbeatMessageSchema = z.object({
|
||||
focusedAgentId: z.string().nullable(),
|
||||
lastActivityAt: z.string(),
|
||||
appVisible: z.boolean(),
|
||||
appVisibilityChangedAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const PingMessageSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user