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 = {
|
||||
session: SessionContextValue;
|
||||
session: import("@/hooks/use-daemon-session").DaemonSession;
|
||||
agentId?: string;
|
||||
routeServerId: string;
|
||||
onBack: () => void;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -95,7 +95,7 @@ function GitDiffContent({
|
||||
agentId,
|
||||
serverLabel,
|
||||
}: {
|
||||
session: SessionContextValue;
|
||||
session: import("@/hooks/use-daemon-session").DaemonSession;
|
||||
agentId?: string;
|
||||
serverLabel: string;
|
||||
}) {
|
||||
|
||||
@@ -1095,7 +1095,6 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
container: {
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
},
|
||||
borderSeparator: {
|
||||
height: theme.borderWidth[1],
|
||||
|
||||
@@ -252,7 +252,7 @@ function AgentFlowModal({
|
||||
}, [serverId, daemonEntries]);
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(initialServerId);
|
||||
const selectSessionSlice = useCallback(
|
||||
(session: SessionContextValue | null): CreateAgentSessionSlice | null => {
|
||||
(session: import("@/hooks/use-daemon-session").DaemonSession | null): CreateAgentSessionSlice | null => {
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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<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(
|
||||
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<typeof useSessionStore.getState>) => {
|
||||
@@ -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<Set<string>>(new Set());
|
||||
const loggedDaemonsRef = useRef<Set<string>>(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);
|
||||
|
||||
@@ -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<string, SessionData> {
|
||||
export function useSessionDirectory(): Map<string, SessionState> {
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
|
||||
return useMemo(() => {
|
||||
return new Map<string, SessionData>(Object.entries(sessions));
|
||||
return new Map<string, SessionState>(Object.entries(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;
|
||||
|
||||
export function useSessionForServer(serverId: string | null): SessionData | null;
|
||||
export function useSessionForServer(serverId: string | null): SessionState | null;
|
||||
export function useSessionForServer<T>(
|
||||
serverId: string | null,
|
||||
selector: SessionSelector<T>,
|
||||
@@ -23,7 +23,7 @@ export function useSessionForServer<T>(
|
||||
serverId: string | null,
|
||||
selector?: SessionSelector<T>,
|
||||
equalityFn?: EqualityFn<T>
|
||||
): SessionData | null | T {
|
||||
): SessionState | null | T {
|
||||
const baseSelector = useCallback(
|
||||
(state: ReturnType<typeof useSessionStore.getState>) =>
|
||||
(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
|
||||
|
||||
**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