From 21d43bc249c658efeddcc33a8c514275d2aa920f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Dec 2025 16:37:23 +0700 Subject: [PATCH] perf: eliminate cascading rerenders with granular Zustand selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete useDaemonSession hook that selected entire session object - Move drafts to top-level store (not nested in sessions) - Refactor all components to use granular useSessionStore selectors - Fix infinite loop bugs from selector fallbacks (use stable constants) Before: 15-20 component rerenders per keystroke After: ~1-2 rerenders per keystroke 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/app/agent/[serverId]/[agentId].tsx | 136 +++++++------ packages/app/src/app/file-explorer.tsx | 64 ++++--- packages/app/src/app/git-diff.tsx | 31 +-- packages/app/src/app/settings.tsx | 11 +- .../app/src/components/agent-input-area.tsx | 140 ++++++++------ packages/app/src/components/agent-list.tsx | 15 +- .../app/src/components/agent-status-bar.tsx | 30 +-- .../app/src/components/agent-stream-view.tsx | 33 ++-- .../app/src/components/create-agent-modal.tsx | 30 +-- packages/app/src/contexts/session-context.tsx | 2 +- packages/app/src/hooks/use-daemon-session.ts | 180 ------------------ 11 files changed, 280 insertions(+), 392 deletions(-) delete mode 100644 packages/app/src/hooks/use-daemon-session.ts diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index a6645bb75..46e1e6179 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -19,13 +19,14 @@ import { BackHeader } from "@/components/headers/back-header"; import { AgentStreamView } from "@/components/agent-stream-view"; import { AgentInputArea } from "@/components/agent-input-area"; import { CreateAgentModal, ImportAgentModal, type CreateAgentInitialValues } from "@/components/create-agent-modal"; -import type { Agent, SessionContextValue } from "@/contexts/session-context"; import { useFooterControls } from "@/contexts/footer-controls-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; import { formatConnectionStatus } from "@/utils/daemons"; -import { useDaemonSession } from "@/hooks/use-daemon-session"; +import { useSessionStore } from "@/stores/session-store"; +import type { Agent } from "@/stores/session-store"; import { useDaemonRequest } from "@/hooks/use-daemon-request"; +import type { StreamItem } from "@/types/stream"; import type { SessionOutboundMessage } from "@server/server/messages"; import { buildAgentNavigationKey, @@ -35,6 +36,7 @@ import { } from "@/utils/navigation-timing"; const DROPDOWN_WIDTH = 220; +const EMPTY_STREAM_ITEMS: StreamItem[] = []; type GitRepoInfoResponseMessage = Extract< SessionOutboundMessage, @@ -90,13 +92,11 @@ export default function AgentScreen() { const resolvedAgentId = typeof agentId === "string" ? agentId : undefined; const resolvedServerId = typeof serverId === "string" ? serverId : undefined; const { connectionStates } = useDaemonConnections(); - const hasConnectionEntry = resolvedServerId ? connectionStates.has(resolvedServerId) : false; - const session = useDaemonSession(resolvedServerId, { - suppressUnavailableAlert: true, - allowUnavailable: true, - }); - const sessionServerId = session?.serverId ?? null; + // Check if session exists for this serverId + const hasSession = useSessionStore((state) => + resolvedServerId ? !!state.sessions[resolvedServerId] : false + ); const connectionServerId = resolvedServerId ?? null; const connection = connectionServerId ? connectionStates.get(connectionServerId) : null; @@ -107,7 +107,7 @@ export default function AgentScreen() { const lastConnectionError = connection?.lastError ?? null; const handleBackToHome = useCallback(() => { - const targetServerId = resolvedServerId ?? sessionServerId; + const targetServerId = resolvedServerId; const targetAgentId = resolvedAgentId ?? null; if (targetServerId && targetAgentId) { startNavigationTiming(HOME_NAVIGATION_KEY, { @@ -127,10 +127,10 @@ export default function AgentScreen() { }); } router.replace("/"); - }, [resolvedAgentId, resolvedServerId, router, sessionServerId]); + }, [resolvedAgentId, resolvedServerId, router]); - const focusServerId = resolvedServerId ?? sessionServerId; - const navigationStatus = session ? "ready" : "session_unavailable"; + const focusServerId = resolvedServerId; + const navigationStatus = hasSession ? "ready" : "session_unavailable"; useFocusEffect( useCallback(() => { @@ -145,7 +145,7 @@ export default function AgentScreen() { }, [focusServerId, navigationStatus, resolvedAgentId]) ); - if (!session) { + if (!hasSession || !resolvedServerId) { return ( ); } type AgentScreenContentProps = { - session: import("@/hooks/use-daemon-session").DaemonSession; + serverId: string; agentId?: string; - routeServerId: string; onBack: () => void; }; -function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentScreenContentProps) { +function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const router = useRouter(); @@ -192,18 +188,53 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc const [createAgentInitialValues, setCreateAgentInitialValues] = useState(); - const { - agents, - agentStreamState, - initializingAgents, - pendingPermissions, - initializeAgent, - refreshAgent, - setFocusedAgentId, - ws, - } = session; const resolvedAgentId = agentId; + // Select only the specific agent + const agent = useSessionStore((state) => + resolvedAgentId ? state.sessions[serverId]?.agents?.get(resolvedAgentId) : undefined + ); + + // Select only the specific stream state - use stable empty array to avoid infinite loop + const streamItemsRaw = useSessionStore((state) => + resolvedAgentId ? state.sessions[serverId]?.agentStreamState?.get(resolvedAgentId) : undefined + ); + const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; + + // Select only the specific initializing state + const isInitializingFromMap = useSessionStore((state) => + resolvedAgentId ? (state.sessions[serverId]?.initializingAgents?.get(resolvedAgentId) ?? false) : false + ); + + // Select raw pending permissions - filter with useMemo to avoid new Map on every render + const allPendingPermissions = useSessionStore((state) => + state.sessions[serverId]?.pendingPermissions + ); + const pendingPermissions = useMemo(() => { + if (!allPendingPermissions || !resolvedAgentId) return new Map(); + const filtered = new Map(); + for (const [key, perm] of allPendingPermissions) { + if (perm.agentId === resolvedAgentId) { + filtered.set(key, perm); + } + } + return filtered; + }, [allPendingPermissions, resolvedAgentId]); + + // Get ws for connection status + const ws = useSessionStore((state) => state.sessions[serverId]?.ws); + + // Get methods + const methods = useSessionStore((state) => state.sessions[serverId]?.methods); + const initializeAgent = methods?.initializeAgent; + const refreshAgent = methods?.refreshAgent; + const setFocusedAgentId = useCallback( + (agentId: string | null) => { + useSessionStore.getState().setFocusedAgentId(serverId, agentId); + }, + [serverId] + ); + const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); const bottomInset = useSharedValue(insets.bottom); @@ -220,11 +251,6 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc }; }); - const agent = resolvedAgentId ? agents.get(resolvedAgentId) : undefined; - const streamItems = resolvedAgentId ? agentStreamState.get(resolvedAgentId) || [] : []; - const agentPermissions = new Map( - Array.from(pendingPermissions.entries()).filter(([_, perm]) => perm.agentId === resolvedAgentId) - ); const agentModel = extractAgentModel(agent); const modelDisplayValue = agentModel ?? "Unknown"; @@ -289,26 +315,28 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc }; }, [resolvedAgentId, setFocusedAgentId]); - const hasStreamState = resolvedAgentId ? agentStreamState.has(resolvedAgentId) : false; - const initializationState = resolvedAgentId ? initializingAgents.get(resolvedAgentId) : undefined; + const hasStreamState = useSessionStore((state) => + resolvedAgentId ? state.sessions[serverId]?.agentStreamState?.has(resolvedAgentId) ?? false : false + ); + const isInitializing = resolvedAgentId - ? initializationState !== undefined - ? initializationState + ? isInitializingFromMap !== false + ? isInitializingFromMap : !hasStreamState : false; useEffect(() => { - if (!resolvedAgentId) { + if (!resolvedAgentId || !initializeAgent) { return; } console.log("[AgentScreen] initializeAgent effect", { agentId: resolvedAgentId, hasStreamState, - initializationState, + isInitializingFromMap, }); - if (initializationState !== undefined) { + if (isInitializingFromMap !== false) { return; } @@ -317,12 +345,12 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc } initializeAgent({ agentId: resolvedAgentId }); - }, [resolvedAgentId, initializeAgent, initializationState, hasStreamState]); + }, [resolvedAgentId, initializeAgent, isInitializingFromMap, hasStreamState]); const agentControls = useMemo(() => { if (!resolvedAgentId) return null; - return ; - }, [resolvedAgentId, routeServerId]); + return ; + }, [resolvedAgentId, serverId]); useEffect(() => { if (!agentControls || !agent || isInitializing) { @@ -416,11 +444,11 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc pathname: "/git-diff", params: { agentId: resolvedAgentId, - serverId: routeServerId, + serverId: serverId, }, }); } - }, [resolvedAgentId, routeServerId, router, handleCloseMenu]); + }, [resolvedAgentId, serverId, router, handleCloseMenu]); const handleBrowseFiles = useCallback(() => { handleCloseMenu(); @@ -429,14 +457,14 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc pathname: "/file-explorer", params: { agentId: resolvedAgentId, - serverId: routeServerId, + serverId: serverId, }, }); } - }, [handleCloseMenu, resolvedAgentId, routeServerId, router]); + }, [handleCloseMenu, resolvedAgentId, serverId, router]); const handleRefreshAgent = useCallback(() => { - if (!resolvedAgentId) { + if (!resolvedAgentId || !refreshAgent) { return; } handleCloseMenu(); @@ -475,7 +503,7 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc isVisible={showCreateAgentModal} onClose={handleCloseCreateAgentModal} initialValues={createAgentInitialValues} - serverId={routeServerId} + serverId={serverId} /> ); @@ -483,7 +511,7 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc ); @@ -529,10 +557,10 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc ) : ( )} diff --git a/packages/app/src/app/file-explorer.tsx b/packages/app/src/app/file-explorer.tsx index 21b55bd4c..bb9b5533e 100644 --- a/packages/app/src/app/file-explorer.tsx +++ b/packages/app/src/app/file-explorer.tsx @@ -29,11 +29,11 @@ import { X, } from "lucide-react-native"; import { BackHeader } from "@/components/headers/back-header"; -import type { ExplorerEntry, SessionContextValue } from "@/contexts/session-context"; +import type { ExplorerEntry } from "@/stores/session-store"; import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { formatConnectionStatus } from "@/utils/daemons"; -import { useDaemonSession } from "@/hooks/use-daemon-session"; +import { useSessionStore } from "@/stores/session-store"; export default function FileExplorerScreen() { const { @@ -49,14 +49,14 @@ export default function FileExplorerScreen() { }>(); const resolvedServerId = typeof serverId === "string" ? serverId : undefined; const { connectionStates } = useDaemonConnections(); - const session = useDaemonSession(resolvedServerId, { - suppressUnavailableAlert: true, - allowUnavailable: true, - }); + + const session = useSessionStore((state) => + resolvedServerId ? state.sessions[resolvedServerId] : undefined + ); const connectionServerId = resolvedServerId ?? null; const connection = connectionServerId ? connectionStates.get(connectionServerId) : null; - const serverLabel = connection?.daemon.label ?? connectionServerId ?? session?.serverId ?? "Selected host"; + const serverLabel = connection?.daemon.label ?? connectionServerId ?? resolvedServerId ?? "Selected host"; const connectionStatus = connection?.status ?? "idle"; const connectionStatusLabel = formatConnectionStatus(connectionStatus); const lastError = connection?.lastError ?? null; @@ -78,21 +78,19 @@ export default function FileExplorerScreen() { return ( ); } type FileExplorerContentProps = { - session: import("@/hooks/use-daemon-session").DaemonSession; + serverId: string; agentId?: string; pathParamRaw?: string | string[]; fileParamRaw?: string | string[]; - routeServerId: string; }; type FileExplorerSessionUnavailableProps = { @@ -105,20 +103,29 @@ type FileExplorerSessionUnavailableProps = { }; function FileExplorerContent({ - session, + serverId, agentId, pathParamRaw, fileParamRaw, - routeServerId, }: FileExplorerContentProps) { const { theme } = useUnistyles(); - const { - agents, - fileExplorer, - requestDirectoryListing, - requestFilePreview, - navigateExplorerBack, - } = session; + + const agent = useSessionStore((state) => + agentId && state.sessions[serverId] + ? state.sessions[serverId]?.agents.get(agentId) + : undefined + ); + + const explorerState = useSessionStore((state) => + agentId && state.sessions[serverId] + ? state.sessions[serverId]?.fileExplorer.get(agentId) + : undefined + ); + + const methods = useSessionStore((state) => state.sessions[serverId]?.methods); + const requestDirectoryListing = methods?.requestDirectoryListing; + const requestFilePreview = methods?.requestFilePreview; + const navigateExplorerBack = methods?.navigateExplorerBack; const [viewMode, setViewMode] = useState<"list" | "grid">("list"); const [selectedEntryPath, setSelectedEntryPath] = useState(null); const pendingPathParamRef = useRef(null); @@ -136,9 +143,6 @@ function FileExplorerContent({ const derivedDirectoryFromFile = normalizedFileParam ? deriveDirectoryFromFile(normalizedFileParam) : null; - - const agent = agentId ? agents.get(agentId) : undefined; - const explorerState = agentId ? fileExplorer.get(agentId) : undefined; const history = explorerState?.history ?? []; const lastKnownDirectory = history[history.length - 1]; const rememberedDirectory = explorerState?.lastVisitedPath; @@ -237,7 +241,7 @@ function FileExplorerContent({ }, [activePath]); useEffect(() => { - if (!agentId || !initialTargetDirectory) { + if (!agentId || !initialTargetDirectory || !requestDirectoryListing) { pendingPathParamRef.current = null; return; } @@ -251,7 +255,7 @@ function FileExplorerContent({ }, [agentId, initialTargetDirectory, requestDirectoryListing]); useEffect(() => { - if (!agentId || !normalizedFileParam) { + if (!agentId || !normalizedFileParam || !requestFilePreview) { pendingFileParamRef.current = null; return; } @@ -281,7 +285,7 @@ function FileExplorerContent({ const handleEntryPress = useCallback( (entry: ExplorerEntry) => { - if (!agentId) { + if (!agentId || !requestDirectoryListing || !requestFilePreview) { return; } @@ -330,13 +334,13 @@ function FileExplorerContent({ if (agentId) { router.replace({ pathname: "/agent/[serverId]/[agentId]", - params: { serverId: routeServerId, agentId }, + params: { serverId, agentId }, }); return; } router.back(); - }, [agentId, routeServerId]); + }, [agentId, serverId]); const handleBackNavigation = useCallback(() => { if (!agentId) { @@ -349,7 +353,7 @@ function FileExplorerContent({ return true; } - if ((explorerState?.history?.length ?? 0) > 1) { + if ((explorerState?.history?.length ?? 0) > 1 && navigateExplorerBack) { navigateExplorerBack(agentId); return true; } @@ -472,7 +476,7 @@ function FileExplorerContent({ const handleViewableItemsChanged = useCallback( ({ viewableItems }: { viewableItems: Array }) => { - if (!agentId || viewMode !== "grid") { + if (!agentId || viewMode !== "grid" || !requestFilePreview) { return; } diff --git a/packages/app/src/app/git-diff.tsx b/packages/app/src/app/git-diff.tsx index 31db1209d..dd1901252 100644 --- a/packages/app/src/app/git-diff.tsx +++ b/packages/app/src/app/git-diff.tsx @@ -3,11 +3,10 @@ import { View, Text, ScrollView, ActivityIndicator } from "react-native"; import { useLocalSearchParams } from "expo-router"; import { StyleSheet } from "react-native-unistyles"; import { BackHeader } from "@/components/headers/back-header"; -import type { SessionContextValue } from "@/contexts/session-context"; import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { formatConnectionStatus } from "@/utils/daemons"; -import { useDaemonSession } from "@/hooks/use-daemon-session"; +import { useSessionStore } from "@/stores/session-store"; interface ParsedDiffFile { path: string; @@ -58,10 +57,8 @@ export default function GitDiffScreen() { const { agentId, serverId } = useLocalSearchParams<{ agentId: string; serverId?: string }>(); const resolvedServerId = typeof serverId === "string" ? serverId : undefined; const { connectionStates } = useDaemonConnections(); - const session = useDaemonSession(resolvedServerId, { - suppressUnavailableAlert: true, - allowUnavailable: true, - }); + + const session = useSessionStore((state) => resolvedServerId ? state.sessions[resolvedServerId] : undefined); const connectionServerId = resolvedServerId ?? null; const connection = connectionServerId ? connectionStates.get(connectionServerId) : null; @@ -83,7 +80,7 @@ export default function GitDiffScreen() { return ( @@ -91,22 +88,30 @@ export default function GitDiffScreen() { } function GitDiffContent({ - session, + serverId, agentId, serverLabel, }: { - session: import("@/hooks/use-daemon-session").DaemonSession; + serverId: string; agentId?: string; serverLabel: string; }) { - const { agents, gitDiffs, requestGitDiff } = session; const [isLoading, setIsLoading] = useState(true); - const agent = agentId ? agents.get(agentId) : undefined; - const diffText = agentId ? gitDiffs.get(agentId) : undefined; + const agent = useSessionStore((state) => + agentId && serverId ? state.sessions[serverId]?.agents?.get(agentId) : undefined + ); + + const diffText = useSessionStore((state) => + agentId && serverId ? state.sessions[serverId]?.gitDiffs?.get(agentId) : undefined + ); + + const requestGitDiff = useSessionStore((state) => + serverId ? state.sessions[serverId]?.methods?.requestGitDiff : undefined + ); useEffect(() => { - if (!agentId) { + if (!agentId || !requestGitDiff) { setIsLoading(false); return; } diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index 70840dd86..25403029c 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -19,8 +19,7 @@ import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-c import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { theme as defaultTheme } from "@/styles/theme"; import { BackHeader } from "@/components/headers/back-header"; -import { useSessionForServer } from "@/hooks/use-session-directory"; -import { useDaemonSession } from "@/hooks/use-daemon-session"; +import { useSessionStore } from "@/stores/session-store"; import type { UseWebSocketReturn } from "@/hooks/use-websocket"; const delay = (ms: number) => @@ -799,12 +798,8 @@ function DaemonCard({ : theme.colors.mutedForeground; const badgeText = statusLabel; const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; - const daemonWs = useSessionForServer( - daemon.id, - useCallback((session) => session?.ws ?? null, []) - ); - const fullSession = useDaemonSession(daemon.id, { allowUnavailable: true, suppressUnavailableAlert: true }); - const restartServerFn = fullSession?.restartServer ?? null; + const daemonWs = useSessionStore((state) => state.sessions[daemon.id]?.ws); + const restartServerFn = useSessionStore((state) => state.sessions[daemon.id]?.methods?.restartServer); const [isRestarting, setIsRestarting] = useState(false); const wsIsConnectedRef = useRef(daemonWs?.isConnected ?? false); const isTesting = testState?.status === "testing"; diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 2070415cf..829e39b7f 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -31,9 +31,8 @@ import { RealtimeControls } from "./realtime-controls"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug"; import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice"; -import { useDaemonSession } from "@/hooks/use-daemon-session"; +import { useSessionStore } from "@/stores/session-store"; import type { UseWebSocketReturn } from "@/hooks/use-websocket"; -import type { SessionContextValue, Agent } from "@/contexts/session-context"; import type { SessionOutboundMessage } from "@server/server/messages"; type QueuedMessage = { @@ -44,11 +43,12 @@ type QueuedMessage = { interface AgentInputAreaProps { agentId: string; - serverId?: string; + serverId: string; } const MIN_INPUT_HEIGHT = 50; const MAX_INPUT_HEIGHT = 160; +const EMPTY_ARRAY: readonly QueuedMessage[] = []; const MAX_INPUT_WIDTH = 960; const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2; // Android currently crashes inside ViewGroup.dispatchDraw when running Reanimated @@ -85,7 +85,38 @@ type DictationToastConfig = { export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { const { theme } = useUnistyles(); - const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true }); + + // [INVESTIGATION] Using granular selectors for WebSocket connection status + const ws = useSessionStore((state) => state.sessions[serverId]?.ws); + + // [INVESTIGATION] Select only the specific agent (not all agents) + const agent = useSessionStore((state) => + state.sessions[serverId]?.agents?.get(agentId) + ); + + // [INVESTIGATION] Get draft input - drafts are at TOP LEVEL (not in session) + const getDraftInput = useSessionStore((state) => state.getDraftInput); + const saveDraftInput = useSessionStore((state) => state.saveDraftInput); + + // [INVESTIGATION] Get queued messages for this agent - use stable empty array + const queuedMessagesRaw = useSessionStore((state) => + state.sessions[serverId]?.queuedMessages?.get(agentId) + ); + const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY; + + // [INVESTIGATION] Get methods + const methods = useSessionStore((state) => state.sessions[serverId]?.methods); + const sendAgentMessage = methods?.sendAgentMessage; + const cancelAgentRun = methods?.cancelAgentRun; + + // [INVESTIGATION] Use store action directly for setQueuedMessages + const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); + + // Noop fallback for sendAgentAudio (required by useDictation) + const noopSendAgentAudio = useCallback(async () => {}, []); + const sendAgentAudio = methods?.sendAgentAudio ?? noopSendAgentAudio; + + // Inert WebSocket fallback for when ws is undefined const inertWebSocket = useMemo( () => ({ isConnected: false, @@ -101,21 +132,8 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { }), [] ); - const noopSendAgentMessage = useCallback(async () => {}, []); - const noopSendAgentAudio = useCallback(async () => {}, []); - const noopCancelAgentRun = useCallback(() => {}, []); - const noopGetDraftInput = useCallback(() => undefined, []); - const noopSaveDraftInput = useCallback(() => {}, []); - const noopSetQueuedMessages = useCallback(() => {}, []); - const ws = session?.ws ?? inertWebSocket; - const sendAgentMessage = session?.sendAgentMessage ?? noopSendAgentMessage; - const sendAgentAudio = session?.sendAgentAudio ?? noopSendAgentAudio; - const agents = session?.agents ?? new Map(); - const cancelAgentRun = session?.cancelAgentRun ?? noopCancelAgentRun; - const getDraftInput = session?.getDraftInput ?? noopGetDraftInput; - const saveDraftInput = session?.saveDraftInput ?? noopSaveDraftInput; - const queuedMessagesByAgent = session?.queuedMessages ?? new Map(); - const setQueuedMessagesByAgent = session?.setQueuedMessages ?? noopSetQueuedMessages; + + const wsOrInert = ws ?? inertWebSocket; const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime(); const [userInput, setUserInput] = useState(""); @@ -125,7 +143,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { const [isCancellingAgent, setIsCancellingAgent] = useState(false); const [audioDebugInfo, setAudioDebugInfo] = useState(null); const [connectionStatus, setConnectionStatus] = useState(() => - ws.getConnectionState ? ws.getConnectionState() : { isConnected: ws.isConnected, isConnecting: ws.isConnecting } + wsOrInert.getConnectionState ? wsOrInert.getConnectionState() : { isConnected: wsOrInert.isConnected, isConnecting: wsOrInert.isConnecting } ); const [lastSuccessToastAt, setLastSuccessToastAt] = useState(null); @@ -180,7 +198,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { }, []); const canStartDictation = useCallback(() => { - const socketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected; + const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected; const allowed = !isRealtimeMode && socketConnected; console.log("[AgentInput] canStartDictation", { allowed, @@ -188,17 +206,17 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { wsConnected: socketConnected, }); return allowed; - }, [isRealtimeMode, ws]); + }, [isRealtimeMode, wsOrInert]); const canConfirmDictation = useCallback(() => { - const socketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected; + const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected; const allowed = socketConnected; console.log("[AgentInput] canConfirmDictation", { allowed, wsConnected: socketConnected, }); return allowed; - }, [ws]); + }, [wsOrInert]); const { isRecording: isDictating, @@ -220,7 +238,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { } = useDictation({ agentId, sendAgentAudio, - ws, + ws: wsOrInert, mode: "transcribe_only", onTranscript: handleDictationTranscript, onError: handleDictationError, @@ -235,13 +253,13 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { }, [dictationPendingRequestId]); useEffect(() => { - if (!ws.subscribeConnectionStatus) { + if (!wsOrInert.subscribeConnectionStatus) { return; } - return ws.subscribeConnectionStatus((status) => { + return wsOrInert.subscribeConnectionStatus((status) => { setConnectionStatus(status); }); - }, [ws]); + }, [wsOrInert]); useEffect(() => { if (dictationLastOutcome?.type === "success") { @@ -274,9 +292,9 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { const dictationToast = useMemo(() => { console.log('[AgentInputArea] Connection status:', { isConnected: connectionStatus.isConnected, - wsIsConnected: ws.isConnected, - wsIsConnecting: ws.isConnecting, - hasSession: !!session, + wsIsConnected: wsOrInert.isConnected, + wsIsConnecting: wsOrInert.isConnecting, + hasWs: !!ws, }); if (!connectionStatus.isConnected) { @@ -375,8 +393,8 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { }, [sendAgentMessage]); async function handleSendMessage() { - const socketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected; - if (!userInput.trim() || !socketConnected) return; + const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected; + if (!userInput.trim() || !socketConnected || !sendAgentMessage) return; const message = userInput.trim(); const imageAttachments = selectedImages.length > 0 ? selectedImages : undefined; @@ -416,7 +434,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { console.log("[AgentInput] handleVoicePress", { isDictating, isRealtimeMode, - wsConnected: ws.isConnected, + wsConnected: wsOrInert.isConnected, }); if (isDictating || isRealtimeMode) { return; @@ -480,7 +498,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { if (!shouldShowAudioDebug) { return; } - const unsubscribe = ws.on("transcription_result", (message: SessionOutboundMessage) => { + const unsubscribe = wsOrInert.on("transcription_result", (message: SessionOutboundMessage) => { if (message.type !== "transcription_result") { return; } @@ -506,7 +524,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { return () => { unsubscribe(); }; - }, [shouldShowAudioDebug, ws]); + }, [shouldShowAudioDebug, wsOrInert]); function isTextAreaLike(value: unknown): value is TextAreaHandle { if (!value || typeof value !== "object") { @@ -656,7 +674,6 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { }); } - const agent = agents.get(agentId); const isAgentRunning = agent?.status === "running"; agentStatusRef.current = agent?.status; const hasText = userInput.trim().length > 0; @@ -664,18 +681,17 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { const hasSendableContent = hasText || hasImages; const shouldShowSendButton = !isAgentRunning && hasSendableContent; const shouldShowVoiceControls = !hasSendableContent; - const queuedMessages = queuedMessagesByAgent.get(agentId) ?? []; const shouldHandleDesktopSubmit = IS_WEB; const updateQueue = useCallback( (updater: (current: QueuedMessage[]) => QueuedMessage[]) => { - setQueuedMessagesByAgent((prev: Map) => { + setQueuedMessages(serverId, (prev: Map) => { const next = new Map(prev); next.set(agentId, updater(prev.get(agentId) ?? [])); return next; }); }, - [agentId, setQueuedMessagesByAgent], + [agentId, serverId, setQueuedMessages], ); useEffect(() => { updateQueueRef.current = updateQueue; @@ -702,23 +718,23 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { } event.preventDefault(); if (isAgentRunning) { - if (!hasSendableContent || !ws.isConnected) { + if (!hasSendableContent || !wsOrInert.isConnected) { return; } handleQueueCurrentInput(); return; } - if (!shouldShowSendButton || isProcessing || !ws.isConnected) { + if (!shouldShowSendButton || isProcessing || !wsOrInert.isConnected) { return; } void handleSendMessage(); } useEffect(() => { - if (!isAgentRunning || !ws.isConnected) { + if (!isAgentRunning || !wsOrInert.isConnected) { setIsCancellingAgent(false); } - }, [isAgentRunning, ws.isConnected]); + }, [isAgentRunning, wsOrInert.isConnected]); useEffect(() => { if (!IS_WEB) { @@ -738,7 +754,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { return; } - if (!isRealtimeMode && shouldShowVoiceControls && ws.isConnected) { + if (!isRealtimeMode && shouldShowVoiceControls && wsOrInert.isConnected) { void handleVoicePress(); } return; @@ -759,7 +775,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { isDictating, isRealtimeMode, shouldShowVoiceControls, - ws, + wsOrInert, handleVoicePress, handleSendRecording, handleCancelRecording, @@ -820,7 +836,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { if (isRealtimeMode) { await stopRealtime(); } else { - if (!ws.isConnected || !serverId) { + if (!wsOrInert.isConnected || !serverId) { return; } await startRealtime(serverId); @@ -834,7 +850,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { if (!agent || agent.status !== "running" || isCancellingAgent) { return; } - if (!ws.isConnected) { + if (!wsOrInert.isConnected || !cancelAgentRun) { return; } setIsCancellingAgent(true); @@ -867,7 +883,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { async function handleSendQueuedNow(id: string) { const item = queuedMessages.find((q) => q.id === id); - if (!item || !ws.isConnected) return; + if (!item || !wsOrInert.isConnected || !sendAgentMessage) return; updateQueue((current) => current.filter((q) => q.id !== id)); @@ -879,11 +895,11 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { const realtimeButton = ( {isRealtimeMode ? ( @@ -897,10 +913,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { const voiceButton = ( @@ -991,7 +1007,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { multiline scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT} onContentSizeChange={handleContentSizeChange} - editable={!isDictating && ws.isConnected} + editable={!isDictating && wsOrInert.isConnected} onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined} /> @@ -1001,10 +1017,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { @@ -1020,12 +1036,12 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { {hasSendableContent && ( @@ -1034,12 +1050,12 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { {realtimeButton} {isCancellingAgent ? ( @@ -1052,10 +1068,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { ) : shouldShowSendButton ? ( diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 03b5ee47b..a6e3612b3 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -6,7 +6,7 @@ import { formatTimeAgo } from "@/utils/time"; import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status"; import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { useDaemonSession } from "@/hooks/use-daemon-session"; +import { useSessionStore } from "@/stores/session-store"; import { buildAgentNavigationKey, startNavigationTiming } from "@/utils/navigation-timing"; interface AgentListProps { @@ -18,14 +18,15 @@ interface AgentListProps { export function AgentList({ agents, isRefreshing = false, onRefresh }: AgentListProps) { const { theme } = useUnistyles(); const [actionAgent, setActionAgent] = useState(null); - const actionSession = useDaemonSession(actionAgent?.serverId, { - suppressUnavailableAlert: true, - allowUnavailable: true, - }); - const deleteAgent = actionSession?.deleteAgent; + // Get the methods for the specific server + const methods = useSessionStore((state) => + actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.methods : undefined + ); + const deleteAgent = methods?.deleteAgent; + const isActionSheetVisible = actionAgent !== null; - const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionSession); + const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !methods); const handleAgentPress = useCallback( (serverId: string, agentId: string) => { diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 0a3d6ffcd..a29006689 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -1,36 +1,42 @@ import { View, Text, Pressable } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronDown } from "lucide-react-native"; -import { useState, useCallback, useRef } from "react"; +import { useState, useRef } from "react"; import { ModeSelectorModal } from "./mode-selector-modal"; -import { useDaemonSession } from "@/hooks/use-daemon-session"; -import type { SessionContextValue, Agent } from "@/contexts/session-context"; +import { useSessionStore } from "@/stores/session-store"; interface AgentStatusBarProps { agentId: string; - serverId?: string; + serverId: string; } export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { // INVESTIGATION: Log rerenders const renderCountRef = useRef(0); renderCountRef.current++; - console.log(`[INVESTIGATION] AgentStatusBar render #${renderCountRef.current}`, { agentId }); + console.log(`[INVESTIGATION] AgentStatusBar render #${renderCountRef.current}`, { agentId, serverId }); const { theme } = useUnistyles(); - const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true }); - const noopSetAgentMode = useCallback(() => {}, []); - const agents = session?.agents ?? new Map(); - const setAgentMode = session?.setAgentMode ?? noopSetAgentMode; - const [showModeSelector, setShowModeSelector] = useState(false); - const agent = agents.get(agentId); + // Select only the specific agent (not all agents) + const agent = useSessionStore((state) => + state.sessions[serverId]?.agents?.get(agentId) + ); + + // Get the setAgentMode action (actions are stable, won't cause rerenders) + const setAgentMode = useSessionStore((state) => + state.sessions[serverId]?.methods?.setAgentMode + ); + + const [showModeSelector, setShowModeSelector] = useState(false); if (!agent) { return null; } function handleModeChange(modeId: string) { - setAgentMode(agentId, modeId); + if (setAgentMode) { + setAgentMode(agentId, modeId); + } } return ( diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 60a5757fe..1f7322cfa 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -32,7 +32,7 @@ import type { StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { Agent } from "@/contexts/session-context"; -import { useDaemonSession } from "@/hooks/use-daemon-session"; +import { useSessionStore } from "@/stores/session-store"; import { useDaemonRequest } from "@/hooks/use-daemon-request"; import type { UseWebSocketReturn } from "@/hooks/use-websocket"; import type { SessionOutboundMessage } from "@server/server/messages"; @@ -79,7 +79,19 @@ export function AgentStreamView({ const isNearBottomRef = useRef(true); const isUserScrollingRef = useRef(false); const router = useRouter(); - const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true }); + + // Get serverId (fallback to agent's serverId if not provided) + const resolvedServerId = serverId ?? agent.serverId ?? ""; + + // Get ws for connection status + const ws = useSessionStore((state) => state.sessions[resolvedServerId]?.ws); + + // Get methods for file operations + const methods = useSessionStore((state) => state.sessions[resolvedServerId]?.methods); + const requestDirectoryListing = methods?.requestDirectoryListing; + const requestFilePreview = methods?.requestFilePreview; + + // Create inert websocket fallback if ws is null const inertWebSocket = useMemo( () => ({ isConnected: false, @@ -93,10 +105,9 @@ export function AgentStreamView({ }), [] ); - const ws = session?.ws ?? inertWebSocket; - const requestDirectoryListing = session?.requestDirectoryListing ?? (() => {}); - const requestFilePreview = session?.requestFilePreview ?? (() => {}); - const resolvedServerId = serverId ?? session?.serverId ?? agent.serverId ?? ""; + const wsOrInert = ws ?? inertWebSocket; + const requestDirectoryListingOrInert = requestDirectoryListing ?? (() => {}); + const requestFilePreviewOrInert = requestFilePreview ?? (() => {}); // Keep entry/exit animations off on Android due to RN dispatchDraw crashes // tracked in react-native-reanimated#8422. const shouldDisableEntryExitAnimations = Platform.OS === "android"; @@ -120,9 +131,9 @@ export function AgentStreamView({ return; } - requestDirectoryListing(agentId, normalized.directory); + requestDirectoryListingOrInert(agentId, normalized.directory); if (normalized.file) { - requestFilePreview(agentId, normalized.file); + requestFilePreviewOrInert(agentId, normalized.file); } router.push({ @@ -141,7 +152,7 @@ export function AgentStreamView({ }, }); }, - [agentId, requestDirectoryListing, requestFilePreview, resolvedServerId, router] + [agentId, requestDirectoryListingOrInert, requestFilePreviewOrInert, resolvedServerId, router] ); const handleScroll = useCallback( @@ -350,7 +361,7 @@ export function AgentStreamView({ {pendingPermissionItems.length > 0 ? ( {pendingPermissionItems.map((permission) => ( - + ))} ) : null} @@ -363,7 +374,7 @@ export function AgentStreamView({ ); - }, [pendingPermissionItems, showWorkingIndicator, ws]); + }, [pendingPermissionItems, showWorkingIndicator, wsOrInert]); const flatListData = useMemo(() => { return [...streamItems].reverse(); diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index e5e3fc3d6..fba0afb55 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -65,7 +65,6 @@ import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/me import { formatConnectionStatus } from "@/utils/daemons"; import { trackAnalyticsEvent } from "@/utils/analytics"; import type { SessionContextValue } from "@/contexts/session-context"; -import { useDaemonSession } from "@/hooks/use-daemon-session"; import type { UseWebSocketReturn } from "@/hooks/use-websocket"; import { useSessionStore } from "@/stores/session-store"; @@ -263,26 +262,29 @@ function AgentFlowModal({ }, [serverId, daemonEntries]); const [selectedServerId, setSelectedServerId] = useState(initialServerId); - // Use useDaemonSession to get both state AND APIs - const fullSession = useDaemonSession(selectedServerId, { allowUnavailable: true, suppressUnavailableAlert: true }); + // [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 + ); // Extract only what we need for CreateAgentSessionSlice const session = useMemo(() => { - if (!fullSession) { + if (!selectedServerId || !sessionState || !sessionState.methods) { return null; } const slice: CreateAgentSessionSlice = { - serverId: fullSession.serverId, - ws: fullSession.ws, - createAgent: fullSession.createAgent, - resumeAgent: fullSession.resumeAgent, - sendAgentAudio: fullSession.sendAgentAudio, - agents: fullSession.agents, - providerModels: fullSession.providerModels, - requestProviderModels: fullSession.requestProviderModels, + serverId: selectedServerId, + ws: sessionState.ws, + createAgent: sessionState.methods.createAgent, + resumeAgent: sessionState.methods.resumeAgent, + sendAgentAudio: sessionState.methods.sendAgentAudio, + agents: sessionState.agents, + providerModels: sessionState.providerModels, + requestProviderModels: sessionState.methods.requestProviderModels, }; return slice; - }, [fullSession]); + }, [selectedServerId, sessionState]); // Helper to get session state for background operations // Note: This only returns state, not APIs. For calling methods, need to send WS messages directly. @@ -324,7 +326,6 @@ function AgentFlowModal({ } }, [daemonEntries, initialServerId, isVisible, selectedServerId]); - const ws = session?.ws ?? null; const inertWebSocket = useMemo( () => ({ isConnected: false, @@ -340,6 +341,7 @@ function AgentFlowModal({ }), [] ); + const ws = session?.ws ?? null; const effectiveWs: UseWebSocketReturn = ws ?? inertWebSocket; const createAgent = session?.createAgent; const resumeAgent = session?.resumeAgent; diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 06953de31..b49d0b3eb 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1662,7 +1662,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid ] ); - // Sync imperative methods to Zustand store so useDaemonSession can access them without React Context + // Sync imperative methods to Zustand store so components can access them via selectors // Memoize the methods object to avoid infinite re-renders (object reference must be stable) const setSessionMethods = useSessionStore((state) => state.setSessionMethods); const methods = useMemo(() => ({ diff --git a/packages/app/src/hooks/use-daemon-session.ts b/packages/app/src/hooks/use-daemon-session.ts deleted file mode 100644 index 51326cf88..000000000 --- a/packages/app/src/hooks/use-daemon-session.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { useCallback, useMemo, useRef } from "react"; -import { Alert } from "react-native"; -import { useDaemonConnections } from "@/contexts/daemon-connections-context"; -import { useSessionStore, type SessionState } from "@/stores/session-store"; - -export class DaemonSessionUnavailableError extends Error { - serverId: string; - - constructor(serverId: string) { - super(`Host session "${serverId}" is unavailable`); - this.name = "DaemonSessionUnavailableError"; - this.serverId = serverId; - } -} - -type UseDaemonSessionOptions = { - suppressUnavailableAlert?: boolean; - allowUnavailable?: boolean; -}; - -// Combined type: SessionState (from store) + imperative APIs (from context) -export type DaemonSession = SessionState & { - // Imperative APIs from context - setVoiceDetectionFlags: (isDetecting: boolean, isSpeaking: boolean) => void; - requestGitDiff: (agentId: string) => void; - requestDirectoryListing: (agentId: string, path: string, options?: { recordHistory?: boolean }) => void; - requestFilePreview: (agentId: string, path: string) => void; - navigateExplorerBack: (agentId: string) => string | null; - requestProviderModels: (provider: any, options?: { cwd?: string }) => void; - restartServer: (reason?: string) => void; - initializeAgent: (params: { agentId: string; requestId?: string }) => void; - refreshAgent: (params: { agentId: string; requestId?: string }) => void; - refreshSession: () => void; - cancelAgentRun: (agentId: string) => void; - sendAgentMessage: ( - agentId: string, - message: string, - images?: Array<{ uri: string; mimeType?: string }> - ) => Promise; - sendAgentAudio: ( - agentId: string, - audioBlob: Blob, - requestId?: string, - options?: { mode?: "transcribe_only" | "auto_run" } - ) => Promise; - deleteAgent: (agentId: string) => void; - createAgent: (options: { - config: any; - initialPrompt: string; - git?: any; - worktreeName?: string; - requestId?: string; - }) => void; - resumeAgent: (options: { handle: any; overrides?: any; requestId?: string }) => void; - setAgentMode: (agentId: string, modeId: string) => void; - respondToPermission: (agentId: string, requestId: string, response: any) => void; - // State getters/setters that components might use directly - getDraftInput: (agentId: string) => any; - saveDraftInput: (agentId: string, draft: any) => void; - setFocusedAgentId: (agentId: string | null) => void; - setMessages: (messages: any[] | ((prev: any[]) => any[])) => void; - setQueuedMessages: (value: any) => void; -}; - -export function useDaemonSession( - serverId?: string | null, - options?: UseDaemonSessionOptions & { allowUnavailable?: false } -): DaemonSession | null; -export function useDaemonSession( - serverId: string | null | undefined, - options: UseDaemonSessionOptions & { allowUnavailable: true } -): DaemonSession | null; -export function useDaemonSession(serverId?: string | null, options?: UseDaemonSessionOptions) { - - const selectSession = useCallback( - (state: ReturnType) => { - if (!serverId) { - return null; - } - return state.sessions[serverId] ?? null; - }, - [serverId] - ); - const sessionState = useSessionStore(selectSession); - const { connectionStates } = useDaemonConnections(); - const alertedDaemonsRef = useRef>(new Set()); - const loggedDaemonsRef = useRef>(new Set()); - const { suppressUnavailableAlert = false, allowUnavailable = false } = options ?? {}; - - // Get store actions (these are already stable from Zustand) - const getDraftInputAction = useSessionStore((state) => state.getDraftInput); - const saveDraftInputAction = useSessionStore((state) => state.saveDraftInput); - const setFocusedAgentIdAction = useSessionStore((state) => state.setFocusedAgentId); - const setMessagesAction = useSessionStore((state) => state.setMessages); - const setQueuedMessagesAction = useSessionStore((state) => state.setQueuedMessages); - - // Create stable wrapper functions (must be before conditional returns) - const boundGetDraftInput = useCallback( - (agentId: string) => getDraftInputAction(agentId), - [getDraftInputAction] - ); - const boundSaveDraftInput = useCallback( - (agentId: string, draft: any) => { saveDraftInputAction(agentId, draft); }, - [saveDraftInputAction] - ); - const boundSetFocusedAgentId = useCallback( - (agentId: string | null) => { if (serverId) setFocusedAgentIdAction(serverId, agentId); }, - [serverId, setFocusedAgentIdAction] - ); - const boundSetMessages = useCallback( - (messages: any) => { if (serverId) setMessagesAction(serverId, messages); }, - [serverId, setMessagesAction] - ); - const boundSetQueuedMessages = useCallback( - (value: any) => { if (serverId) setQueuedMessagesAction(serverId, value); }, - [serverId, setQueuedMessagesAction] - ); - - // Memoize the combined object to prevent infinite re-renders - const combined = useMemo(() => { - if (!serverId || !sessionState || !sessionState.methods) { - return null; - } - - return { - ...sessionState, - ...sessionState.methods, - getDraftInput: boundGetDraftInput, - saveDraftInput: boundSaveDraftInput, - setFocusedAgentId: boundSetFocusedAgentId, - setMessages: boundSetMessages, - setQueuedMessages: boundSetQueuedMessages, - }; - }, [ - serverId, - sessionState, - boundGetDraftInput, - boundSaveDraftInput, - boundSetFocusedAgentId, - boundSetMessages, - boundSetQueuedMessages, - ]); - - if (!serverId) { - return null; - } - - if (combined) { - console.log("[useDaemonSession] Debug", { - serverId, - hasSessionState: !!sessionState, - hasMethods: !!sessionState?.methods, - sessionStateKeys: sessionState ? Object.keys(sessionState).slice(0, 5) : [], - }); - return combined; - } - - // Handle unavailable session - const connection = connectionStates.get(serverId); - const label = connection?.daemon.label ?? serverId; - const status = connection?.status ?? "unknown"; - const lastError = connection?.lastError ? `\n${connection.lastError}` : ""; - const message = `${label} isn't connected yet (${status}). Paseo reconnects automatically and will enable actions once it's back.${lastError}`; - - if (!suppressUnavailableAlert && !alertedDaemonsRef.current.has(serverId)) { - alertedDaemonsRef.current.add(serverId); - Alert.alert("Host unavailable", message.trim()); - } - - if (!loggedDaemonsRef.current.has(serverId)) { - loggedDaemonsRef.current.add(serverId); - console.warn(`[useDaemonSession] Session unavailable for daemon "${label}" (${status}).`); - } - - if (allowUnavailable) { - return null; - } - - throw new DaemonSessionUnavailableError(serverId); -}