From 644c654274d64c0a4d00c1b29840de395a390743 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Dec 2025 17:34:07 +0700 Subject: [PATCH] perf: prevent HomeScreen rerenders during agent streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add top-level agentLastActivity Map to store (prevents cascade rerenders) - Remove lastActivityAt updates from agent_stream handler (only status changes update timestamps) - useAggregatedAgents now uses agent.lastActivityAt directly - Remove investigation logging from previous debug session The root cause was that every stream event updated agent.lastActivityAt, causing useAggregatedAgents to recalculate, which caused HomeScreen to rerender even when not visible. Now timestamps only update on meaningful events (agent status changes), not individual stream tokens. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../app/src/components/agent-stream-view.tsx | 8 ---- .../app/src/components/create-agent-modal.tsx | 5 -- packages/app/src/contexts/session-context.tsx | 47 ++++++++++++++----- .../app/src/hooks/use-aggregated-agents.ts | 1 + packages/app/src/stores/session-store.ts | 30 +++++++++++- packages/server/src/server/session.ts | 6 ++- 6 files changed, 70 insertions(+), 27 deletions(-) diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 1f7322cfa..cdd494f42 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -62,14 +62,6 @@ export function AgentStreamView({ streamItems, pendingPermissions, }: AgentStreamViewProps) { - // INVESTIGATION: Log rerenders - const renderCountRef = useRef(0); - renderCountRef.current++; - console.log(`[INVESTIGATION] AgentStreamView render #${renderCountRef.current}`, { - agentId, - streamItemsLength: streamItems.length, - pendingPermissionsSize: pendingPermissions.size, - }); const flatListRef = useRef>(null); const insets = useSafeAreaInsets(); const [isNearBottom, setIsNearBottom] = useState(true); diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index fba0afb55..1a395c436 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -262,8 +262,6 @@ function AgentFlowModal({ }, [serverId, daemonEntries]); const [selectedServerId, setSelectedServerId] = useState(initialServerId); - // [INVESTIGATION] Using useSessionStore directly with granular selectors - // Get the complete session state for the selected server const sessionState = useSessionStore((state) => selectedServerId ? state.sessions[selectedServerId] : undefined ); @@ -2155,9 +2153,6 @@ function AgentFlowModal({ value={initialPrompt} isLoading={isLoading} onChange={(text) => { - console.log('[INVESTIGATION] CreateModal onChange called', { - textLength: text.length, - }); setInitialPrompt(text); setErrorMessage(""); }} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index b49d0b3eb..bd04588d0 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -223,6 +223,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); const setHasHydratedAgents = useSessionStore((state) => state.setHasHydratedAgents); const setAgents = useSessionStore((state) => state.setAgents); + const setAgentLastActivity = useSessionStore((state) => state.setAgentLastActivity); const setCommands = useSessionStore((state) => state.setCommands); const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); const setGitDiffs = useSessionStore((state) => state.setGitDiffs); @@ -324,10 +325,12 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const agents = new Map(); const pendingPermissions = new Map(); + const agentLastActivity = new Map(); for (const agentSnapshot of snapshot.agents) { const agent = normalizeAgentSnapshot(agentSnapshot, serverId); agents.set(agent.id, agent); + agentLastActivity.set(agent.id, agent.lastActivityAt); for (const request of agent.pendingPermissions) { const key = derivePendingPermissionKey(agent.id, request); pendingPermissions.set(key, { key, agentId: agent.id, request }); @@ -341,6 +344,11 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid return agents; }); + // Initialize agentLastActivity slice (top-level) + for (const [agentId, timestamp] of agentLastActivity.entries()) { + setAgentLastActivity(agentId, timestamp); + } + setPendingPermissions(serverId, pendingPermissions); const commandEntries = snapshot.commands ?? []; setCommands(serverId, (prev) => { @@ -579,10 +587,12 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const agents = new Map(); const pendingPermissions = new Map(); + const agentLastActivity = new Map(); for (const agentSnapshot of agentsList) { const agent = normalizeAgentSnapshot(agentSnapshot, serverId); agents.set(agent.id, agent); + agentLastActivity.set(agent.id, agent.lastActivityAt); for (const request of agent.pendingPermissions) { const key = derivePendingPermissionKey(agent.id, request); pendingPermissions.set(key, { key, agentId: agent.id, request }); @@ -592,6 +602,12 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const normalizedCommands = commandsList.map((command) => command); setAgents(serverId, agents); + + // Initialize agentLastActivity slice (top-level) + for (const [agentId, timestamp] of agentLastActivity.entries()) { + setAgentLastActivity(agentId, timestamp); + } + setPendingPermissions(serverId, pendingPermissions); setCommands(serverId, new Map(normalizedCommands.map((command: any) => [command.id, command]))); setAgentStreamState(serverId, (prev) => { @@ -648,6 +664,9 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid return next; }); + // Update agentLastActivity slice (top-level) + setAgentLastActivity(agent.id, agent.lastActivityAt); + setPendingPermissions(serverId, (prev) => { const next = new Map(prev); for (const [key, pending] of Array.from(next.entries())) { @@ -702,16 +721,9 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid return next; }); - // Update agent's lastActivityAt to track stream activity - setAgents(serverId, (prev) => { - const agent = prev.get(agentId); - if (!agent) { - return prev; - } - const next = new Map(prev); - next.set(agentId, { ...agent, lastActivityAt: parsedTimestamp }); - return next; - }); + // NOTE: We don't update lastActivityAt on every stream event to prevent + // cascading rerenders. The agent_state handler updates agent.lastActivityAt + // on status changes, which is sufficient for sorting and display purposes. }); const unsubAgentStreamSnapshot = ws.on("agent_stream_snapshot", (message) => { @@ -1079,6 +1091,19 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid return next; }); + // Remove from agentLastActivity slice (top-level) + useSessionStore.setState((state) => { + if (!state.agentLastActivity.has(agentId)) { + return state; + } + const nextActivity = new Map(state.agentLastActivity); + nextActivity.delete(agentId); + return { + ...state, + agentLastActivity: nextActivity, + }; + }); + setAgentStreamState(serverId, (prev) => { if (!prev.has(agentId)) { return prev; @@ -1146,7 +1171,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid unsubProviderModels(); unsubAgentDeleted(); }; - }, [ws, audioPlayer, serverId, setIsPlayingAudio, setMessages, setCurrentAssistantMessage, setAgentStreamState, setInitializingAgents, setAgents, setCommands, setPendingPermissions, setGitDiffs, setFileExplorer, setProviderModels, setHasHydratedAgents, updateConnectionStatus, getSession, saveDraftInput]); + }, [ws, audioPlayer, serverId, setIsPlayingAudio, setMessages, setCurrentAssistantMessage, setAgentStreamState, setInitializingAgents, setAgents, setAgentLastActivity, setCommands, setPendingPermissions, setGitDiffs, setFileExplorer, setProviderModels, setHasHydratedAgents, updateConnectionStatus, getSession, saveDraftInput]); // Auto-flush queued messages when agent transitions from running -> not running useEffect(() => { diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index be5cf791a..5c35cdf0e 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -40,6 +40,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult { } const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; for (const agent of session.agents.values()) { + // Use agent's own lastActivityAt field directly allAgents.push({ id: agent.id, serverId, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 5cedaee25..17a907566 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -243,6 +243,9 @@ interface SessionStoreState { // Single draft for the create agent modal createModalDraft: DraftInput | null; + + // Agent activity timestamps (top-level, keyed by agentId to prevent cascade rerenders) + agentLastActivity: Map; } // Action types @@ -272,6 +275,9 @@ interface SessionStoreActions { // Agents setAgents: (serverId: string, agents: Map | ((prev: Map) => Map)) => void; + // Agent activity timestamps + setAgentLastActivity: (agentId: string, timestamp: Date) => void; + // Commands setCommands: (serverId: string, commands: Map | ((prev: Map) => Map)) => void; @@ -363,6 +369,7 @@ export const useSessionStore = create()( sessions: {}, drafts: new Map(), createModalDraft: null, + agentLastActivity: new Map(), // Session management initializeSession: (serverId, ws, audioPlayer) => { @@ -573,6 +580,22 @@ export const useSessionStore = create()( }); }, + // Agent activity timestamps (top-level, does NOT mutate session object) + setAgentLastActivity: (agentId, timestamp) => { + set((prev) => { + const currentTimestamp = prev.agentLastActivity.get(agentId); + if (currentTimestamp && currentTimestamp.getTime() === timestamp.getTime()) { + return prev; + } + const nextActivity = new Map(prev.agentLastActivity); + nextActivity.set(agentId, timestamp); + return { + ...prev, + agentLastActivity: nextActivity, + }; + }); + }, + // Commands setCommands: (serverId, commands) => { set((prev) => { @@ -775,19 +798,22 @@ export const useSessionStore = create()( // Agent directory - derived from agents (computed on-demand) getAgentDirectory: (serverId) => { - const session = get().sessions[serverId]; + const state = get(); + const session = state.sessions[serverId]; if (!session) { return undefined; } const entries: AgentDirectoryEntry[] = []; for (const agent of session.agents.values()) { + // Get lastActivityAt from top-level slice, fallback to agent.lastActivityAt + const lastActivityAt = state.agentLastActivity.get(agent.id) ?? agent.lastActivityAt; entries.push({ id: agent.id, serverId, title: agent.title ?? null, status: agent.status, - lastActivityAt: agent.lastActivityAt, + lastActivityAt, cwd: agent.cwd, provider: agent.provider, }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3e4dc2713..d6d78e1b7 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -514,7 +514,11 @@ export class Session { timestamp: new Date().toISOString(), } as const; - console.log(`[SERVER AGENT_STREAM] timestamp=${Date.now()} agentId=${event.agentId} eventType=${event.event.type}`); + const itemType = event.event.type === "timeline" ? event.event.item.type : undefined; + const logMessage = itemType + ? `[SERVER AGENT_STREAM] timestamp=${Date.now()} agentId=${event.agentId} eventType=${event.event.type} itemType=${itemType}` + : `[SERVER AGENT_STREAM] timestamp=${Date.now()} agentId=${event.agentId} eventType=${event.event.type}`; + console.log(logMessage); this.emit({ type: "agent_stream", payload,