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