Fix useDaemonSession to return combined state + APIs

Problem: After migrating to pure Zustand, useDaemonSession returned only
SessionState from the store, but components need both state AND imperative
APIs (like sendAgentMessage, createAgent, etc.).

Solution: Create DaemonSession type that combines:
- SessionState (from Zustand store)
- Imperative APIs (from SessionContext)
- Wrapped store actions (getDraftInput, setFocusedAgentId, etc.)

Changes:
- Added DaemonSession type to use-daemon-session.ts
- useDaemonSession now merges sessionState + context + store actions
- Wraps store actions to bind serverId automatically
- Updated prop types in components to use DaemonSession
- Fixed use-session-directory.ts to use SessionState

This restores component compatibility while keeping pure Zustand architecture.

Still TODO: Fix remaining components that use SessionContext directly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-12-02 14:27:57 +00:00
parent f5ec3bffb7
commit e8aa08159a
8 changed files with 161 additions and 19 deletions

View File

@@ -171,7 +171,7 @@ export default function AgentScreen() {
} }
type AgentScreenContentProps = { type AgentScreenContentProps = {
session: SessionContextValue; session: import("@/hooks/use-daemon-session").DaemonSession;
agentId?: string; agentId?: string;
routeServerId: string; routeServerId: string;
onBack: () => void; onBack: () => void;

View File

@@ -88,7 +88,7 @@ export default function FileExplorerScreen() {
} }
type FileExplorerContentProps = { type FileExplorerContentProps = {
session: SessionContextValue; session: import("@/hooks/use-daemon-session").DaemonSession;
agentId?: string; agentId?: string;
pathParamRaw?: string | string[]; pathParamRaw?: string | string[];
fileParamRaw?: string | string[]; fileParamRaw?: string | string[];

View File

@@ -95,7 +95,7 @@ function GitDiffContent({
agentId, agentId,
serverLabel, serverLabel,
}: { }: {
session: SessionContextValue; session: import("@/hooks/use-daemon-session").DaemonSession;
agentId?: string; agentId?: string;
serverLabel: string; serverLabel: string;
}) { }) {

View File

@@ -1095,7 +1095,6 @@ const styles = StyleSheet.create(((theme: any) => ({
container: { container: {
flexDirection: "column", flexDirection: "column",
position: "relative", position: "relative",
flex: 1,
}, },
borderSeparator: { borderSeparator: {
height: theme.borderWidth[1], height: theme.borderWidth[1],

View File

@@ -252,7 +252,7 @@ function AgentFlowModal({
}, [serverId, daemonEntries]); }, [serverId, daemonEntries]);
const [selectedServerId, setSelectedServerId] = useState<string | null>(initialServerId); const [selectedServerId, setSelectedServerId] = useState<string | null>(initialServerId);
const selectSessionSlice = useCallback( const selectSessionSlice = useCallback(
(session: SessionContextValue | null): CreateAgentSessionSlice | null => { (session: import("@/hooks/use-daemon-session").DaemonSession | null): CreateAgentSessionSlice | null => {
if (!session) { if (!session) {
return null; return null;
} }

View File

@@ -1,8 +1,8 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef, useContext } from "react";
import { Alert } from "react-native"; 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 { 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 { export class DaemonSessionUnavailableError extends Error {
serverId: string; serverId: string;
@@ -19,14 +19,57 @@ type UseDaemonSessionOptions = {
allowUnavailable?: 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;
cancelAgentRun: (agentId: string) => void;
sendAgentMessage: (
agentId: string,
message: string,
images?: Array<{ uri: string; mimeType?: string }>
) => Promise<void>;
sendAgentAudio: (
agentId: string,
audioBlob: Blob,
requestId?: string,
options?: { mode?: "transcribe_only" | "auto_run" }
) => Promise<void>;
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( export function useDaemonSession(
serverId?: string | null, serverId?: string | null,
options?: UseDaemonSessionOptions & { allowUnavailable?: false } options?: UseDaemonSessionOptions & { allowUnavailable?: false }
): SessionContextValue | null; ): DaemonSession | null;
export function useDaemonSession( export function useDaemonSession(
serverId: string | null | undefined, serverId: string | null | undefined,
options: UseDaemonSessionOptions & { allowUnavailable: true } options: UseDaemonSessionOptions & { allowUnavailable: true }
): SessionContextValue | null; ): DaemonSession | null;
export function useDaemonSession(serverId?: string | null, options?: UseDaemonSessionOptions) { export function useDaemonSession(serverId?: string | null, options?: UseDaemonSessionOptions) {
const selectSession = useCallback( const selectSession = useCallback(
(state: ReturnType<typeof useSessionStore.getState>) => { (state: ReturnType<typeof useSessionStore.getState>) => {
@@ -37,21 +80,42 @@ export function useDaemonSession(serverId?: string | null, options?: UseDaemonSe
}, },
[serverId] [serverId]
); );
const session = useSessionStore(selectSession); const sessionState = useSessionStore(selectSession);
const context = useContext(SessionContext);
const { connectionStates } = useDaemonConnections(); const { connectionStates } = useDaemonConnections();
const alertedDaemonsRef = useRef<Set<string>>(new Set()); const alertedDaemonsRef = useRef<Set<string>>(new Set());
const loggedDaemonsRef = useRef<Set<string>>(new Set()); const loggedDaemonsRef = useRef<Set<string>>(new Set());
const { suppressUnavailableAlert = false, allowUnavailable = false } = options ?? {}; 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) { if (!serverId) {
return null; return null;
} }
try { try {
if (!session) { if (!sessionState || !context) {
throw new DaemonSessionUnavailableError(serverId); 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) { } catch (error) {
if (error instanceof DaemonSessionUnavailableError) { if (error instanceof DaemonSessionUnavailableError) {
const connection = connectionStates.get(serverId); const connection = connectionStates.get(serverId);

View File

@@ -1,19 +1,19 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional"; 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<string, SessionData> { export function useSessionDirectory(): Map<string, SessionState> {
const sessions = useSessionStore((state) => state.sessions); const sessions = useSessionStore((state) => state.sessions);
return useMemo(() => { return useMemo(() => {
return new Map<string, SessionData>(Object.entries(sessions)); return new Map<string, SessionState>(Object.entries(sessions));
}, [sessions]); }, [sessions]);
} }
type SessionSelector<T> = (session: SessionData | null) => T; type SessionSelector<T> = (session: SessionState | null) => T;
type EqualityFn<T> = ((left: T, right: T) => boolean) | undefined; type EqualityFn<T> = ((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<T>( export function useSessionForServer<T>(
serverId: string | null, serverId: string | null,
selector: SessionSelector<T>, selector: SessionSelector<T>,
@@ -23,7 +23,7 @@ export function useSessionForServer<T>(
serverId: string | null, serverId: string | null,
selector?: SessionSelector<T>, selector?: SessionSelector<T>,
equalityFn?: EqualityFn<T> equalityFn?: EqualityFn<T>
): SessionData | null | T { ): SessionState | null | T {
const baseSelector = useCallback( const baseSelector = useCallback(
(state: ReturnType<typeof useSessionStore.getState>) => (state: ReturnType<typeof useSessionStore.getState>) =>
(serverId ? state.sessions[serverId] ?? null : null), (serverId ? state.sessions[serverId] ?? null : null),

79
plan.md
View File

@@ -144,3 +144,82 @@ The multi-daemon infrastructure is in place: session directory with daemon-scope
- No regressions or cut corners identified - No regressions or cut corners identified
**All tasks complete. No follow-up items required.** **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:
- 23s 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 whats 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 isnt 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 <300ms).
### 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<serverId, SessionSlice> }`, 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 wont 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 <300ms 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.