mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix agent history catch-up + avoid mobile background stream deltas (#18)
* Update files * Fix agent history sync + mobile stream gating
This commit is contained in:
@@ -84,6 +84,11 @@ 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({
|
||||
@@ -92,6 +97,7 @@ export function AgentStreamView({
|
||||
agent,
|
||||
streamItems,
|
||||
pendingPermissions,
|
||||
isSyncingHistory = false,
|
||||
}: AgentStreamViewProps) {
|
||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||
const { theme } = useUnistyles();
|
||||
@@ -590,6 +596,43 @@ export function AgentStreamView({
|
||||
return { marginBottom: tightGap };
|
||||
}, [listHeaderComponent, tightGap]);
|
||||
|
||||
const listEmptyComponent = useMemo(() => {
|
||||
const hasPermissions = pendingPermissionItems.length > 0;
|
||||
const hasHeadItems = (streamHead?.length ?? 0) > 0;
|
||||
if (hasPermissions || hasHeadItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldShowSyncing =
|
||||
isSyncingHistory || agent.status === "running";
|
||||
|
||||
if (shouldShowSyncing) {
|
||||
return (
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={stylesheet.emptyStateText}>Catching up…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
<Text style={stylesheet.emptyStateText}>
|
||||
Start chatting with this agent...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}, [
|
||||
agent.status,
|
||||
isSyncingHistory,
|
||||
pendingPermissionItems.length,
|
||||
streamHead,
|
||||
theme.colors.foregroundMuted,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ToolCallSheetProvider>
|
||||
<View style={stylesheet.container}>
|
||||
@@ -607,13 +650,7 @@ export function AgentStreamView({
|
||||
style={stylesheet.list}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
ListEmptyComponent={
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
<Text style={stylesheet.emptyStateText}>
|
||||
Start chatting with this agent...
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={listEmptyComponent}
|
||||
ListHeaderComponent={listHeaderComponent}
|
||||
extraData={flatListExtraData}
|
||||
maintainVisibleContentPosition={
|
||||
|
||||
@@ -915,16 +915,6 @@ export function SessionProvider({
|
||||
});
|
||||
}
|
||||
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const currentState = prev.get(agentId);
|
||||
if (currentState === false) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
|
||||
// NOTE: We don't update lastActivityAt on every stream event to prevent
|
||||
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
|
||||
// on status changes, which is sufficient for sorting and display purposes.
|
||||
@@ -956,7 +946,7 @@ export function SessionProvider({
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
if (prev.get(agentId) !== true) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
@@ -990,16 +980,6 @@ export function SessionProvider({
|
||||
requestId: (message.payload as any).requestId,
|
||||
});
|
||||
}
|
||||
if (status === "agent_initialized" && "agentId" in message.payload) {
|
||||
const agentId = (message.payload as any).agentId as string | undefined;
|
||||
if (agentId) {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const unsubPermissionRequest = client.on(
|
||||
@@ -1443,12 +1423,6 @@ export function SessionProvider({
|
||||
return next;
|
||||
});
|
||||
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, []);
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
if (!client) {
|
||||
console.warn("[Session] initializeAgent skipped: daemon unavailable");
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
@@ -1470,24 +1444,17 @@ export function SessionProvider({
|
||||
});
|
||||
});
|
||||
},
|
||||
[serverId, client, setAgentStreamTail, setInitializingAgents, clearAgentStreamHead]
|
||||
[serverId, client, setInitializingAgents]
|
||||
);
|
||||
|
||||
const refreshAgent = useCallback(
|
||||
({ agentId, requestId }: { agentId: string; requestId?: string }) => {
|
||||
({ agentId }: { agentId: string }) => {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, true);
|
||||
return next;
|
||||
});
|
||||
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, []);
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
|
||||
refreshAgentMutation
|
||||
.mutateAsync({ agentId })
|
||||
.catch((error) => {
|
||||
@@ -1499,13 +1466,7 @@ export function SessionProvider({
|
||||
});
|
||||
});
|
||||
},
|
||||
[
|
||||
serverId,
|
||||
refreshAgentMutation,
|
||||
setAgentStreamTail,
|
||||
setInitializingAgents,
|
||||
clearAgentStreamHead,
|
||||
]
|
||||
[serverId, refreshAgentMutation, setInitializingAgents]
|
||||
);
|
||||
|
||||
const sendAgentMessage = useCallback(
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { useCallback } from "react";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
attachInitTimeout,
|
||||
createInitDeferred,
|
||||
getInitDeferred,
|
||||
getInitKey,
|
||||
rejectInitDeferred,
|
||||
} from "@/utils/agent-initialization";
|
||||
|
||||
const INIT_TIMEOUT_MS = 10000;
|
||||
const INIT_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
export function useAgentInitialization(serverId: string) {
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
|
||||
|
||||
const ensureAgentIsInitialized = useCallback(
|
||||
(agentId: string): Promise<void> => {
|
||||
@@ -24,10 +23,23 @@ export function useAgentInitialization(serverId: string) {
|
||||
}
|
||||
|
||||
const deferred = createInitDeferred(key);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
rejectInitDeferred(key, new Error(`Agent initialization timed out after ${INIT_TIMEOUT_MS}ms`));
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
if (prev.get(agentId) !== true) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
rejectInitDeferred(
|
||||
key,
|
||||
new Error(
|
||||
`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`
|
||||
)
|
||||
);
|
||||
}, INIT_TIMEOUT_MS);
|
||||
attachInitTimeout(key, timeoutId);
|
||||
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -35,15 +47,7 @@ export function useAgentInitialization(serverId: string) {
|
||||
return next;
|
||||
});
|
||||
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, []);
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
|
||||
if (!client) {
|
||||
clearTimeout(timeoutId);
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
@@ -56,10 +60,10 @@ export function useAgentInitialization(serverId: string) {
|
||||
client
|
||||
.initializeAgent(agentId)
|
||||
.then(() => {
|
||||
clearTimeout(timeoutId);
|
||||
// No-op: the actual "timeline hydrated" signal is the `agent_stream_snapshot`
|
||||
// message, handled in SessionContext.
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId);
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
@@ -73,7 +77,7 @@ export function useAgentInitialization(serverId: string) {
|
||||
|
||||
return deferred.promise;
|
||||
},
|
||||
[clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents]
|
||||
[client, serverId, setInitializingAgents]
|
||||
);
|
||||
|
||||
const refreshAgent = useCallback(
|
||||
@@ -87,13 +91,6 @@ export function useAgentInitialization(serverId: string) {
|
||||
return next;
|
||||
});
|
||||
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, []);
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
|
||||
try {
|
||||
await client.refreshAgent(agentId);
|
||||
} catch (error) {
|
||||
@@ -105,9 +102,8 @@ export function useAgentInitialization(serverId: string) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents]
|
||||
[client, serverId, setInitializingAgents]
|
||||
);
|
||||
|
||||
return { ensureAgentIsInitialized, refreshAgent };
|
||||
}
|
||||
|
||||
|
||||
@@ -55,12 +55,13 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
||||
appVisibleRef.current = nextState === "active";
|
||||
if (nextState === "active") {
|
||||
recordUserActivity();
|
||||
maybeSendImmediateHeartbeat();
|
||||
}
|
||||
// Send immediately on visibility changes so the server can adapt streaming behavior.
|
||||
sendHeartbeat();
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [maybeSendImmediateHeartbeat, recordUserActivity]);
|
||||
}, [recordUserActivity, sendHeartbeat]);
|
||||
|
||||
// Track user activity on web for accurate staleness.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ScrollView,
|
||||
Platform,
|
||||
BackHandler,
|
||||
AppState,
|
||||
} from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
@@ -503,6 +504,46 @@ function AgentScreenContent({
|
||||
});
|
||||
}, [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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
return;
|
||||
@@ -723,28 +764,21 @@ function AgentScreenContent({
|
||||
<ReanimatedAnimated.View
|
||||
style={[styles.content, animatedKeyboardStyle]}
|
||||
>
|
||||
{isInitializing && !shouldUseOptimisticStream ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
<Text style={styles.loadingText}>Loading agent...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<AgentStreamView
|
||||
agentId={effectiveAgent.id}
|
||||
serverId={serverId}
|
||||
agent={effectiveAgent}
|
||||
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
|
||||
pendingPermissions={pendingPermissions}
|
||||
/>
|
||||
)}
|
||||
<AgentStreamView
|
||||
agentId={effectiveAgent.id}
|
||||
serverId={serverId}
|
||||
agent={effectiveAgent}
|
||||
streamItems={
|
||||
shouldUseOptimisticStream ? mergedStreamItems : streamItems
|
||||
}
|
||||
pendingPermissions={pendingPermissions}
|
||||
isSyncingHistory={isInitializing && !shouldUseOptimisticStream}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
|
||||
{/* Agent Input Area */}
|
||||
{!isInitializing && agent && resolvedAgentId && (
|
||||
{agent && resolvedAgentId && (
|
||||
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -46,25 +46,11 @@ function createUniqueTimelineId(
|
||||
timestamp: Date
|
||||
): string {
|
||||
const base = createTimelineId(prefix, text, timestamp);
|
||||
let suffixSeed = state.length;
|
||||
let candidate = `${base}_${suffixSeed.toString(36)}`;
|
||||
|
||||
// Fast path when the generated id hasn't been used yet
|
||||
const hasCollision = state.some((entry) => entry.id === candidate);
|
||||
if (!hasCollision) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// If the id already exists (e.g. prior items were pruned/replaced),
|
||||
// spin until we find an unused suffix. Building the lookup Set lazily keeps
|
||||
// the common case cheap while still guaranteeing uniqueness.
|
||||
const usedIds = new Set(state.map((entry) => entry.id));
|
||||
while (usedIds.has(candidate)) {
|
||||
suffixSeed += 1;
|
||||
candidate = `${base}_${suffixSeed.toString(36)}`;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
// We only ever append new timeline items, and we incorporate the current
|
||||
// length as a monotonic suffix, so uniqueness is guaranteed without an O(n)
|
||||
// collision scan (important for large hydration snapshots).
|
||||
const suffixSeed = state.length;
|
||||
return `${base}_${suffixSeed.toString(36)}`;
|
||||
}
|
||||
|
||||
export type StreamItem =
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface DeferredInit {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
const initPromises = new Map<string, DeferredInit>();
|
||||
@@ -23,14 +24,30 @@ export function createInitDeferred(key: string): DeferredInit {
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
const deferred: DeferredInit = { promise, resolve, reject };
|
||||
const deferred: DeferredInit = { promise, resolve, reject, timeoutId: null };
|
||||
initPromises.set(key, deferred);
|
||||
return deferred;
|
||||
}
|
||||
|
||||
export function attachInitTimeout(key: string, timeoutId: ReturnType<typeof setTimeout>): void {
|
||||
const deferred = initPromises.get(key);
|
||||
if (!deferred) {
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
deferred.timeoutId = timeoutId;
|
||||
}
|
||||
|
||||
export function resolveInitDeferred(key: string): void {
|
||||
const deferred = initPromises.get(key);
|
||||
deferred?.resolve();
|
||||
if (!deferred) {
|
||||
return;
|
||||
}
|
||||
if (deferred.timeoutId) {
|
||||
clearTimeout(deferred.timeoutId);
|
||||
}
|
||||
initPromises.delete(key);
|
||||
deferred.resolve();
|
||||
}
|
||||
|
||||
export function rejectInitDeferred(key: string, error: Error): void {
|
||||
@@ -38,7 +55,9 @@ export function rejectInitDeferred(key: string, error: Error): void {
|
||||
if (!deferred) {
|
||||
return;
|
||||
}
|
||||
if (deferred.timeoutId) {
|
||||
clearTimeout(deferred.timeoutId);
|
||||
}
|
||||
initPromises.delete(key);
|
||||
deferred.reject(error);
|
||||
}
|
||||
|
||||
|
||||
@@ -578,6 +578,24 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
agentId: event.agentId,
|
||||
event: serializeAgentStreamEvent(event.event),
|
||||
|
||||
Reference in New Issue
Block a user