mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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;
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
79
plan.md
@@ -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:
|
||||||
|
|
||||||
|
- 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<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 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user