diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 653bd7086..26b1c3d72 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -171,7 +171,7 @@ export default function AgentScreen() { } type AgentScreenContentProps = { - session: SessionContextValue; + session: import("@/hooks/use-daemon-session").DaemonSession; agentId?: string; routeServerId: string; onBack: () => void; diff --git a/packages/app/src/app/file-explorer.tsx b/packages/app/src/app/file-explorer.tsx index 0a6c6cde0..21b55bd4c 100644 --- a/packages/app/src/app/file-explorer.tsx +++ b/packages/app/src/app/file-explorer.tsx @@ -88,7 +88,7 @@ export default function FileExplorerScreen() { } type FileExplorerContentProps = { - session: SessionContextValue; + session: import("@/hooks/use-daemon-session").DaemonSession; agentId?: string; pathParamRaw?: string | string[]; fileParamRaw?: string | string[]; diff --git a/packages/app/src/app/git-diff.tsx b/packages/app/src/app/git-diff.tsx index 06cec3c88..31db1209d 100644 --- a/packages/app/src/app/git-diff.tsx +++ b/packages/app/src/app/git-diff.tsx @@ -95,7 +95,7 @@ function GitDiffContent({ agentId, serverLabel, }: { - session: SessionContextValue; + session: import("@/hooks/use-daemon-session").DaemonSession; agentId?: string; serverLabel: string; }) { diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 5f259f62c..9918bcf7c 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -1095,7 +1095,6 @@ const styles = StyleSheet.create(((theme: any) => ({ container: { flexDirection: "column", position: "relative", - flex: 1, }, borderSeparator: { height: theme.borderWidth[1], diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index 262763f36..3e068dc73 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -252,7 +252,7 @@ function AgentFlowModal({ }, [serverId, daemonEntries]); const [selectedServerId, setSelectedServerId] = useState(initialServerId); const selectSessionSlice = useCallback( - (session: SessionContextValue | null): CreateAgentSessionSlice | null => { + (session: import("@/hooks/use-daemon-session").DaemonSession | null): CreateAgentSessionSlice | null => { if (!session) { return null; } diff --git a/packages/app/src/hooks/use-daemon-session.ts b/packages/app/src/hooks/use-daemon-session.ts index 9bfcf9b07..45a6329db 100644 --- a/packages/app/src/hooks/use-daemon-session.ts +++ b/packages/app/src/hooks/use-daemon-session.ts @@ -1,8 +1,8 @@ -import { useCallback, useRef } from "react"; +import { useCallback, useRef, useContext } from "react"; import { Alert } from "react-native"; -import type { SessionContextValue } from "@/contexts/session-context"; +import { SessionContext } from "@/contexts/session-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; -import { useSessionStore } from "@/stores/session-store"; +import { useSessionStore, type SessionState } from "@/stores/session-store"; export class DaemonSessionUnavailableError extends Error { serverId: string; @@ -19,14 +19,57 @@ type UseDaemonSessionOptions = { 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; + 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 } -): SessionContextValue | null; +): DaemonSession | null; export function useDaemonSession( serverId: string | null | undefined, options: UseDaemonSessionOptions & { allowUnavailable: true } -): SessionContextValue | null; +): DaemonSession | null; export function useDaemonSession(serverId?: string | null, options?: UseDaemonSessionOptions) { const selectSession = useCallback( (state: ReturnType) => { @@ -37,21 +80,42 @@ export function useDaemonSession(serverId?: string | null, options?: UseDaemonSe }, [serverId] ); - const session = useSessionStore(selectSession); + const sessionState = useSessionStore(selectSession); + const context = useContext(SessionContext); const { connectionStates } = useDaemonConnections(); const alertedDaemonsRef = useRef>(new Set()); const loggedDaemonsRef = useRef>(new Set()); const { suppressUnavailableAlert = false, allowUnavailable = false } = options ?? {}; + // Get store actions + const getDraftInput = useSessionStore((state) => state.getDraftInput); + const saveDraftInput = useSessionStore((state) => state.saveDraftInput); + const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId); + const setMessages = useSessionStore((state) => state.setMessages); + const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); + if (!serverId) { return null; } try { - if (!session) { + if (!sessionState || !context) { throw new DaemonSessionUnavailableError(serverId); } - return session; + + // Combine session state with imperative APIs from context and store actions + const combined: DaemonSession = { + ...sessionState, + ...context, + // Wrap store actions to bind serverId + getDraftInput: (agentId: string) => getDraftInput(serverId, agentId), + saveDraftInput: (agentId: string, draft: any) => saveDraftInput(serverId, agentId, draft), + setFocusedAgentId: (agentId: string | null) => setFocusedAgentId(serverId, agentId), + setMessages: (messages: any) => setMessages(serverId, messages), + setQueuedMessages: (value: any) => setQueuedMessages(serverId, value), + }; + + return combined; } catch (error) { if (error instanceof DaemonSessionUnavailableError) { const connection = connectionStates.get(serverId); diff --git a/packages/app/src/hooks/use-session-directory.ts b/packages/app/src/hooks/use-session-directory.ts index 8f41bcc33..9139dd34d 100644 --- a/packages/app/src/hooks/use-session-directory.ts +++ b/packages/app/src/hooks/use-session-directory.ts @@ -1,19 +1,19 @@ import { useCallback, useMemo } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; -import { useSessionStore, type SessionData } from "@/stores/session-store"; +import { useSessionStore, type SessionState } from "@/stores/session-store"; -export function useSessionDirectory(): Map { +export function useSessionDirectory(): Map { const sessions = useSessionStore((state) => state.sessions); return useMemo(() => { - return new Map(Object.entries(sessions)); + return new Map(Object.entries(sessions)); }, [sessions]); } -type SessionSelector = (session: SessionData | null) => T; +type SessionSelector = (session: SessionState | null) => T; type EqualityFn = ((left: T, right: T) => boolean) | undefined; -export function useSessionForServer(serverId: string | null): SessionData | null; +export function useSessionForServer(serverId: string | null): SessionState | null; export function useSessionForServer( serverId: string | null, selector: SessionSelector, @@ -23,7 +23,7 @@ export function useSessionForServer( serverId: string | null, selector?: SessionSelector, equalityFn?: EqualityFn -): SessionData | null | T { +): SessionState | null | T { const baseSelector = useCallback( (state: ReturnType) => (serverId ? state.sessions[serverId] ?? null : null), diff --git a/plan.md b/plan.md index 773ac7c12..1392ec079 100644 --- a/plan.md +++ b/plan.md @@ -144,3 +144,82 @@ The multi-daemon infrastructure is in place: session directory with daemon-scope - No regressions or cut corners identified **All tasks complete. No follow-up items required.** + +--- + +# Session State Performance Plan (2025-12-02) + +## Background + +The Expo client exposes each daemon **session** (one per host) through `SessionProvider`, which builds a massive `SessionContextValue` object containing agents, stream timelines, messages, git/file explorer caches, audio flags, and all websocket helpers. Every render of that provider pushes the entire object into a hand-rolled global store (`useSessionStore`) that mimics Zustand but always replaces `state.sessions[serverId]` wholesale. Home, Settings, and the global footer subscribe to the whole `sessions` map, so **any websocket tick** forces those screens to recompute their derived data. On-device this manifests as: + +- 2–3 s delays when navigating back from the agent view because Home must rebuild the aggregated agent list while streaming updates continue. +- Perceived jank on every button press (Settings, Import, etc.) due to large, synchronous object copies on the JS thread. +- Memory growth / eventual OOM because `messages`, `agentStreamState`, and explorer caches never trim yet remain resident in the shared store even when no UI needs them. + +## Goals + +1. Make basic navigation (Home ⇄ Agent ⇄ Settings) feel instant by eliminating unnecessary re-renders. +2. Adopt real Zustand so we can update fine-grained slices instead of copying the entire session payload. +3. Keep heavy per-agent data inside `SessionProvider` unless a screen explicitly opts in, reducing memory retention and serialization overhead. +4. Preserve multi-host support and current feature set (agent list, realtime, file explorer) while refactoring. + +## Non-Goals + +- Changing server APIs or backend session semantics. +- Rewriting the React Navigation / expo-router structure. +- Shipping UI redesigns beyond what’s needed to validate the perf fix. + +## Current Pain Points + +- `SessionProvider`’s `useMemo(value)` depends on nearly every hook; each streamed token creates a brand-new object which is immediately spread into the store (`updateSession(serverId, payload)` at `src/contexts/session-context.tsx:1731-1741`). +- `useSessionStore` lacks structural sharing—selectors always receive fresh references, so memoization never kicks in. +- `useAggregatedAgents` recomputes the flattened list on every store update even when Home isn’t visible; JSX renders block navigation events. +- Maps/Sets stored in Context mutate in-place, so cloning them for the store creates additional GC churn and prevents fine-grained equality checks. +- AsyncStorage persistence saves the full snapshot per host, so restoring a single host involves parsing huge JSON blobs regardless of what the user plans to view. + +## Proposed Approach + +### Phase 0 – Instrument & Baseline (0.5 day) + +1. Add lightweight logging around `useSessionStore` updates (count + payload size) and record navigation timings (press → screen transition) using the RN profiler. +2. Validate assumptions on both an Android dev build and iOS simulator to set measurable targets (e.g., back navigation <300 ms). + +### Phase 1 – Extract Lightweight Agent Directory (0.5 day) + +1. Inside `SessionProvider`, derive a minimal `{id, serverId, title, status, lastActivityAt, cwd, provider}` array. +2. Publish only that array into the shared store (or a new `AgentDirectory` context) so Home/Settings can read it without touching the heavy session object. +3. Update `useAggregatedAgents` and any other consumers to rely on the minimal shape. This alone should make Home navigation responsive and provides a safety net while the full Zustand migration happens. + +### Phase 2 – Introduce Real Zustand Store (1 day) + +1. Replace `src/stores/session-store.ts` with `create()` from `zustand` plus the `subscribeWithSelector` middleware for fine-grained updates. +2. Model state as `{ sessions: Record }`, where each `SessionSlice` only includes data that needs to be globally observable (agents map, connection flags, optional metadata). Keep heavyweight transient data (messages, stream buffers) inside `SessionProvider`. +3. Refactor `SessionProvider` mutators (`setAgents`, `setPendingPermissions`, etc.) to call the Zustand setters directly instead of copying entire maps. +4. Update hooks (`useSessionDirectory`, `useDaemonSession`, footer) to select slices via `useSessionStore((state) => state.sessions[serverId]?.agents)` so re-renders are scoped to the relevant server. + +### Phase 3 – Trim Heavy Session Data (0.5 day) + +1. Cap `messages` and `agentStreamState` lengths per agent (e.g., last 200 entries) and keep them local to the agent screen unless explicitly required elsewhere. +2. Gate AsyncStorage snapshots to only persist the lightweight agent metadata that Home needs. Provide an opt-in debug toggle to persist everything for dev builds. +3. Validate memory footprint before/after on a real device (Android Studio profiler or Xcode Instruments). + +### Phase 4 – Polish & Guardrails (0.5 day) + +1. Add React Profiler/Flipper traces to CI or docs so regressions are easier to catch. +2. Document the new store architecture in `docs/perf-session-store.md` and note best practices (never spread the entire slice into other contexts, prefer selectors, etc.). +3. Audit remaining contexts (Realtime, footer) for similar anti-patterns and queue follow-up issues if needed. + +## Risks & Mitigations + +- **Map/Set mutability:** Zustand selectors won’t detect deep mutations on Maps. Mitigation: convert to plain objects or always replace the Map when updating a slice (e.g., `set(state => ({ ...state, agents: new Map(state.agents) }))`). +- **AsyncStorage compatibility:** Persisted snapshots may need migration logic to handle the new lightweight format. Solution: version the payload and fall back to a fresh session when parsing fails. +- **Time overruns:** If Phase 2 proves longer than expected, we can still ship Phase 1 (lightweight directory) to immediately unblock users, then continue iterating. + +## Success Metrics + +- Back navigation from `/agent/[serverId]/[agentId]` to `/` completes in <300 ms on Android dev build (measure via `Performance.now()` or React Profiler). +- `useSessionStore` updates drop by >80% when idle (no active runs), confirmed via instrumentation logs. +- Heap usage on Android no longer grows unbounded during a 10-minute session that opens/closes multiple agents. + +Meeting these targets should eliminate the “3 second back press” symptom and provide a scalable foundation for future multi-host features.