diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9f7beb4f7..c98cd6597 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -27,6 +27,12 @@ Don't run them anywhere else. Check logs there. Take screenshots like this: `adb exec-out screencap -p > screenshot.png` +## Testing with Playwright MCP + +Use the Playwright MCP to test the app in Metro web. Navigate to `http://localhost:8081` to interact with the app UI. + +**Important:** Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL. The app uses client-side routing and browser history navigation breaks the state. + ## Expo troubleshooting Run `npx expo-doctor` to diagnose version mismatches and native module issues. diff --git a/package.json b/package.json index a2a3ab956..8693c2862 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test": "npm run test --workspaces --if-present", "start": "npm run start --workspace=@paseo/server", "android": "npm run android --workspace=@paseo/app", + "android:release": "APP_VARIANT=production npm run android --workspace=@paseo/app -- --variant release", "ios": "npm run ios --workspace=@paseo/app", "web": "npm run web --workspace=@paseo/app", "postinstall": "patch-package" diff --git a/packages/app/app.config.js b/packages/app/app.config.js new file mode 100644 index 000000000..2b783a84f --- /dev/null +++ b/packages/app/app.config.js @@ -0,0 +1,85 @@ +// Release builds set this env var via gradle +const IS_DEV = process.env.APP_VARIANT !== "production"; + +export default { + expo: { + name: IS_DEV ? "Paseo (Dev)" : "Paseo", + slug: "voice-mobile", + version: "1.0.0", + orientation: "portrait", + icon: "./assets/images/icon.png", + scheme: "paseo", + userInterfaceStyle: "automatic", + newArchEnabled: true, + runtimeVersion: { + policy: "appVersion", + }, + updates: { + url: "https://u.expo.dev/0e7f65ce-0367-46c8-a238-2b65963d235a", + }, + ios: { + supportsTablet: true, + infoPlist: { + NSMicrophoneUsageDescription: + "This app needs access to the microphone for voice commands.", + ITSAppUsesNonExemptEncryption: false, + }, + bundleIdentifier: IS_DEV ? "com.moboudra.paseo.dev" : "com.moboudra.paseo", + }, + android: { + adaptiveIcon: { + backgroundColor: "#000000", + foregroundImage: "./assets/images/android-icon-foreground.png", + }, + edgeToEdgeEnabled: true, + predictiveBackGestureEnabled: false, + softwareKeyboardLayoutMode: "resize", + permissions: [ + "RECORD_AUDIO", + "android.permission.RECORD_AUDIO", + "android.permission.MODIFY_AUDIO_SETTINGS", + ], + package: IS_DEV ? "com.moboudra.paseo.dev" : "com.moboudra.paseo", + }, + web: { + output: "static", + favicon: "./assets/images/favicon.png", + }, + plugins: [ + "expo-router", + [ + "expo-splash-screen", + { + image: "./assets/images/splash-icon.png", + imageWidth: 200, + resizeMode: "contain", + backgroundColor: "#ffffff", + dark: { + backgroundColor: "#000000", + }, + }, + ], + "expo-audio", + [ + "expo-build-properties", + { + android: { + minSdkVersion: 29, + kotlinVersion: "2.1.20", + }, + }, + ], + ], + experiments: { + typedRoutes: true, + reactCompiler: true, + }, + extra: { + router: {}, + eas: { + projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a", + }, + }, + owner: "moboudra", + }, +}; diff --git a/packages/app/app.json b/packages/app/app.json deleted file mode 100644 index 513fc2252..000000000 --- a/packages/app/app.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "expo": { - "name": "Paseo", - "slug": "voice-mobile", - "version": "1.0.0", - "orientation": "portrait", - "icon": "./assets/images/icon.png", - "scheme": "paseo", - "userInterfaceStyle": "automatic", - "newArchEnabled": true, - "runtimeVersion": { - "policy": "appVersion" - }, - "updates": { - "url": "https://u.expo.dev/0e7f65ce-0367-46c8-a238-2b65963d235a" - }, - "ios": { - "supportsTablet": true, - "infoPlist": { - "NSMicrophoneUsageDescription": "This app needs access to the microphone for voice commands.", - "ITSAppUsesNonExemptEncryption": false - }, - "bundleIdentifier": "com.moboudra.paseo" - }, - "android": { - "adaptiveIcon": { - "backgroundColor": "#000000", - "foregroundImage": "./assets/images/android-icon-foreground.png" - }, - "edgeToEdgeEnabled": true, - "predictiveBackGestureEnabled": false, - "softwareKeyboardLayoutMode": "resize", - "permissions": [ - "RECORD_AUDIO", - "android.permission.RECORD_AUDIO", - "android.permission.MODIFY_AUDIO_SETTINGS" - ], - "package": "com.moboudra.paseo" - }, - "web": { - "output": "static", - "favicon": "./assets/images/favicon.png" - }, - "plugins": [ - "expo-router", - [ - "expo-splash-screen", - { - "image": "./assets/images/splash-icon.png", - "imageWidth": 200, - "resizeMode": "contain", - "backgroundColor": "#ffffff", - "dark": { - "backgroundColor": "#000000" - } - } - ], - "expo-audio", - [ - "expo-build-properties", - { - "android": { - "minSdkVersion": 29, - "kotlinVersion": "2.1.20" - } - } - ] - ], - "experiments": { - "typedRoutes": true, - "reactCompiler": true - }, - "extra": { - "router": {}, - "eas": { - "projectId": "0e7f65ce-0367-46c8-a238-2b65963d235a" - } - }, - "owner": "moboudra" - } -} diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index 954058691..9c150b5e9 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -251,6 +251,18 @@ function AgentFlowModal({ } }, [isVisible, initialServerId, selectedServerId]); + useEffect(() => { + if (!isVisible || initialServerId || selectedServerId) { + return; + } + const firstReady = daemonEntries.find( + ({ status, sessionReady }) => status === "online" && sessionReady + ); + if (firstReady) { + setSelectedServerId(firstReady.daemon.id); + } + }, [daemonEntries, initialServerId, isVisible, selectedServerId]); + const ws = session?.ws ?? null; const inertWebSocket = useMemo( () => ({ @@ -339,7 +351,12 @@ function AgentFlowModal({ selectedDaemonLastError ? ` ${selectedDaemonLastError}` : "" }` : null; - const isTargetDaemonReady = Boolean(hasSelectedDaemon && session && !selectedDaemonIsOffline); + const selectedDaemonSessionReady = + selectedDaemonConnection?.status === "online" && + selectedDaemonConnection.sessionReady; + const isTargetDaemonReady = Boolean( + hasSelectedDaemon && selectedDaemonSessionReady && !selectedDaemonIsOffline + ); const [isMounted, setIsMounted] = useState(isVisible); const [workingDir, setWorkingDir] = useState(""); diff --git a/packages/app/src/contexts/daemon-connections-context.tsx b/packages/app/src/contexts/daemon-connections-context.tsx index 1ba69d9d0..d8e99e003 100644 --- a/packages/app/src/contexts/daemon-connections-context.tsx +++ b/packages/app/src/contexts/daemon-connections-context.tsx @@ -1,27 +1,23 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { useDaemonRegistry, type DaemonProfile } from "./daemon-registry-context"; import type { SessionContextValue } from "./session-context"; -export interface SessionDirectoryEntry { - getSnapshot: () => SessionContextValue | null; - subscribe: (listener: () => void) => () => void; -} -type SessionAccessorRegistry = Map; +export type SessionSnapshot = SessionContextValue; export type ConnectionState = - | { status: "idle"; lastError: null; lastOnlineAt: string | null } - | { status: "connecting"; lastError: null; lastOnlineAt: string | null } - | { status: "online"; lastError: null; lastOnlineAt: string } - | { status: "offline"; lastError: string | null; lastOnlineAt: string | null } - | { status: "error"; lastError: string; lastOnlineAt: string | null }; + | { status: "idle"; lastError: null; lastOnlineAt: string | null; sessionReady: false } + | { status: "connecting"; lastError: null; lastOnlineAt: string | null; sessionReady: false } + | { status: "online"; lastError: null; lastOnlineAt: string; sessionReady: boolean } + | { status: "offline"; lastError: string | null; lastOnlineAt: string | null; sessionReady: false } + | { status: "error"; lastError: string; lastOnlineAt: string | null; sessionReady: false }; export type ConnectionStatus = ConnectionState["status"]; type ConnectionStateUpdate = | { status: "idle" } | { status: "connecting"; lastOnlineAt?: string | null } - | { status: "online"; lastOnlineAt: string } + | { status: "online"; lastOnlineAt: string; sessionReady?: boolean } | { status: "offline"; lastError?: string | null; lastOnlineAt?: string | null } | { status: "error"; lastError: string; lastOnlineAt?: string | null }; @@ -33,11 +29,9 @@ interface DaemonConnectionsContextValue { connectionStates: Map; isLoading: boolean; updateConnectionStatus: (daemonId: string, update: ConnectionStateUpdate) => void; - sessionAccessors: Map; - registerSessionAccessor: (daemonId: string, entry: SessionDirectoryEntry) => void; - unregisterSessionAccessor: (daemonId: string) => void; - subscribeToSessionDirectory: (listener: () => void) => () => void; - notifySessionDirectoryChange: () => void; + sessionSnapshots: Map; + updateSessionSnapshot: (daemonId: string, snapshot: SessionSnapshot | null) => void; + clearSessionSnapshot: (daemonId: string) => void; } const DaemonConnectionsContext = createContext(null); @@ -47,6 +41,7 @@ function createDefaultConnectionState(): ConnectionState { status: "idle", lastError: null, lastOnlineAt: null, + sessionReady: false, }; } @@ -56,26 +51,34 @@ function resolveNextConnectionState( ): ConnectionState { switch (update.status) { case "idle": - return { status: "idle", lastError: null, lastOnlineAt: existing.lastOnlineAt }; + return { status: "idle", lastError: null, lastOnlineAt: existing.lastOnlineAt, sessionReady: false }; case "connecting": return { status: "connecting", lastError: null, lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt, + sessionReady: false, }; case "online": - return { status: "online", lastError: null, lastOnlineAt: update.lastOnlineAt }; + return { + status: "online", + lastError: null, + lastOnlineAt: update.lastOnlineAt, + sessionReady: update.sessionReady ?? (existing.status === "online" ? existing.sessionReady : false), + }; case "offline": return { status: "offline", lastError: update.lastError ?? null, lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt, + sessionReady: false, }; case "error": return { status: "error", lastError: update.lastError, lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt, + sessionReady: false, }; } } @@ -113,8 +116,7 @@ export function useDaemonConnections(): DaemonConnectionsContextValue { export function DaemonConnectionsProvider({ children }: { children: ReactNode }) { const { daemons, isLoading: registryLoading } = useDaemonRegistry(); const [connectionStates, setConnectionStates] = useState>(new Map()); - const [sessionAccessorRegistry, setSessionAccessorRegistry] = useState(new Map()); - const sessionDirectoryListenersRef = useRef void>>(new Set()); + const [sessionSnapshotRegistry, setSessionSnapshotRegistry] = useState>(new Map()); // Ensure connection states stay in sync with registry entries useEffect(() => { @@ -132,17 +134,17 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode }) }, [daemons]); useEffect(() => { - setSessionAccessorRegistry((prev) => { + setSessionSnapshotRegistry((prev) => { const validDaemonIds = new Set(daemons.map((daemon) => daemon.id)); let changed = false; - const next: SessionAccessorRegistry = new Map(); + const next = new Map(); - for (const [daemonId, entry] of prev.entries()) { + for (const [daemonId, snapshot] of prev.entries()) { if (!validDaemonIds.has(daemonId)) { changed = true; continue; } - next.set(daemonId, entry); + next.set(daemonId, snapshot); } return changed ? next : prev; @@ -175,58 +177,38 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode }) [] ); - const registerSessionAccessor = useCallback((daemonId: string, entry: SessionDirectoryEntry) => { - setSessionAccessorRegistry((prev) => { + const updateSessionSnapshot = useCallback((serverId: string, snapshot: SessionSnapshot | null) => { + setSessionSnapshotRegistry((prev) => { const next = new Map(prev); - const hasExisting = next.has(daemonId); - next.set(daemonId, entry); - if (hasExisting) { - console.warn(`[DaemonConnections] Duplicate session accessor detected for "${daemonId}". Overwriting.`); + if (snapshot === null) { + next.delete(serverId); + } else { + next.set(serverId, snapshot); } return next; }); }, []); - const unregisterSessionAccessor = useCallback((daemonId: string) => { - setSessionAccessorRegistry((prev) => { - if (!prev.has(daemonId)) { + const clearSessionSnapshot = useCallback((serverId: string) => { + setSessionSnapshotRegistry((prev) => { + if (!prev.has(serverId)) { return prev; } const next = new Map(prev); - next.delete(daemonId); + next.delete(serverId); return next; }); }, []); - const subscribeToSessionDirectory = useCallback((listener: () => void) => { - sessionDirectoryListenersRef.current.add(listener); - return () => { - sessionDirectoryListenersRef.current.delete(listener); - }; - }, []); - - const notifySessionDirectoryChange = useCallback(() => { - const listeners = Array.from(sessionDirectoryListenersRef.current); - for (const listener of listeners) { - try { - listener(); - } catch (error) { - console.error("[DaemonConnections] Session directory listener failed", error); - } - } - }, []); - - const sessionAccessors = useMemo(() => new Map(sessionAccessorRegistry), [sessionAccessorRegistry]); + const sessionSnapshots = useMemo(() => new Map(sessionSnapshotRegistry), [sessionSnapshotRegistry]); const value: DaemonConnectionsContextValue = { connectionStates, isLoading: registryLoading, updateConnectionStatus, - sessionAccessors, - registerSessionAccessor, - unregisterSessionAccessor, - subscribeToSessionDirectory, - notifySessionDirectoryChange, + sessionSnapshots, + updateSessionSnapshot, + clearSessionSnapshot, }; return ( diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index d06aa2e71..5dc2dad7f 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -296,6 +296,7 @@ export interface SessionContextValue { serverId: string; // WebSocket ws: UseWebSocketReturn; + hasHydratedAgents: boolean; // Audio audioPlayer: ReturnType; @@ -395,9 +396,8 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const wsIsConnected = ws.isConnected; const { updateConnectionStatus, - registerSessionAccessor, - unregisterSessionAccessor, - notifySessionDirectoryChange, + updateSessionSnapshot, + clearSessionSnapshot, } = useDaemonConnections(); useEffect(() => { @@ -444,6 +444,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const [currentAssistantMessage, setCurrentAssistantMessage] = useState(""); const [agentStreamState, setAgentStreamState] = useState>(new Map()); const [initializingAgents, setInitializingAgents] = useState>(new Map()); + const [hasHydratedAgents, setHasHydratedAgents] = useState(false); const [agents, setAgents] = useState>(new Map()); const [commands, setCommands] = useState>(new Map()); @@ -468,6 +469,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const providerModelRequestIdsRef = useRef>(new Map()); const pendingAgentLifecycleRequestsRef = useRef([]); const hasHydratedSnapshotRef = useRef(false); + const hasRequestedInitialSnapshotRef = useRef(false); // Buffer for streaming audio chunks interface AudioChunk { @@ -483,6 +485,11 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid setFocusedAgentOverride(agentId); }, []); + useEffect(() => { + hasHydratedSnapshotRef.current = false; + setHasHydratedAgents(false); + }, [serverId]); + useEffect(() => { let isMounted = true; @@ -514,14 +521,15 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid return; } - setPendingPermissions(hydratedPermissions); - const commandEntries = snapshot.commands ?? []; - setCommands((prev) => { - if (prev.size > 0) { - return prev; - } - return new Map(commandEntries.map((command) => [command.id, command])); - }); + setPendingPermissions(hydratedPermissions); + const commandEntries = snapshot.commands ?? []; + setCommands((prev) => { + if (prev.size > 0) { + return prev; + } + return new Map(commandEntries.map((command) => [command.id, command])); + }); + setHasHydratedAgents(true); }; void hydrateFromSnapshot(); @@ -691,6 +699,24 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid } }, [wsIsConnected, sendAgentLifecycleRequest]); + useEffect(() => { + if (!wsIsConnected) { + hasRequestedInitialSnapshotRef.current = false; + return; + } + if (hasRequestedInitialSnapshotRef.current) { + return; + } + hasRequestedInitialSnapshotRef.current = true; + ws.send({ + type: "session", + message: { + type: "load_conversation_request", + conversationId: ws.conversationId ?? "", + }, + }); + }, [wsIsConnected, ws]); + // WebSocket message handlers useEffect(() => { // Session state - initial agents/commands @@ -746,6 +772,8 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid return changed ? next : prev; }); void persistSessionSnapshot(serverId, { agents: agentsList, commands: normalizedCommands }); + setHasHydratedAgents(true); + updateConnectionStatus(serverId, { status: "online", lastOnlineAt: new Date().toISOString(), sessionReady: true }); }); const unsubAgentState = ws.on("agent_state", (message) => { @@ -1717,88 +1745,119 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid return targetPath; }, [updateExplorerState, ws]); - const value: SessionContextValue = { - serverId, - ws, - audioPlayer, - isPlayingAudio, - setIsPlayingAudio, - setVoiceDetectionFlags, - focusedAgentId, - setFocusedAgentId, - messages, - setMessages, - currentAssistantMessage, - setCurrentAssistantMessage, - agentStreamState, - setAgentStreamState, - initializingAgents, - agents, - setAgents, - commands, - setCommands, - pendingPermissions, - setPendingPermissions, - gitDiffs, - requestGitDiff, - fileExplorer, - providerModels, - requestProviderModels, - getDraftInput, - saveDraftInput, - queuedMessages, - setQueuedMessages, - requestDirectoryListing, - requestFilePreview, - navigateExplorerBack, - restartServer, - initializeAgent, - refreshAgent, - cancelAgentRun, - deleteAgent, - sendAgentMessage, - sendAgentAudio, - createAgent, - resumeAgent, - setAgentMode, - respondToPermission, - }; - - const sessionValueRef = useRef(value); - const sessionListenersRef = useRef(new Set<() => void>()); - useEffect(() => { - sessionValueRef.current = value; - sessionListenersRef.current.forEach((listener) => { - try { - listener(); - } catch (error) { - console.error("[SessionProvider] Session listener failed", error); - } - }); - notifySessionDirectoryChange(); - }, [value, notifySessionDirectoryChange]); - - const sessionAccessor = useCallback(() => sessionValueRef.current, []); - const subscribeToSession = useCallback((listener: () => void) => { - sessionListenersRef.current.add(listener); - return () => { - sessionListenersRef.current.delete(listener); - }; - }, []); - const sessionDirectoryEntry = useMemo( + const value = useMemo( () => ({ - getSnapshot: sessionAccessor, - subscribe: subscribeToSession, + serverId, + ws, + hasHydratedAgents, + audioPlayer, + isPlayingAudio, + setIsPlayingAudio, + setVoiceDetectionFlags, + focusedAgentId, + setFocusedAgentId, + messages, + setMessages, + currentAssistantMessage, + setCurrentAssistantMessage, + agentStreamState, + setAgentStreamState, + initializingAgents, + agents, + setAgents, + commands, + setCommands, + pendingPermissions, + setPendingPermissions, + gitDiffs, + requestGitDiff, + fileExplorer, + providerModels, + requestProviderModels, + getDraftInput, + saveDraftInput, + queuedMessages, + setQueuedMessages, + requestDirectoryListing, + requestFilePreview, + navigateExplorerBack, + restartServer, + initializeAgent, + refreshAgent, + cancelAgentRun, + deleteAgent, + sendAgentMessage, + sendAgentAudio, + createAgent, + resumeAgent, + setAgentMode, + respondToPermission, }), - [sessionAccessor, subscribeToSession] + [ + agentStreamState, + agents, + audioPlayer, + cancelAgentRun, + commands, + createAgent, + currentAssistantMessage, + deleteAgent, + fileExplorer, + focusedAgentId, + getDraftInput, + gitDiffs, + hasHydratedAgents, + isPlayingAudio, + initializeAgent, + initializingAgents, + messages, + navigateExplorerBack, + pendingPermissions, + providerModels, + queuedMessages, + refreshAgent, + requestDirectoryListing, + requestFilePreview, + requestGitDiff, + requestProviderModels, + respondToPermission, + resumeAgent, + restartServer, + saveDraftInput, + sendAgentAudio, + sendAgentMessage, + serverId, + setAgentMode, + setAgentStreamState, + setAgents, + setCommands, + setCurrentAssistantMessage, + setFocusedAgentId, + setIsPlayingAudio, + setMessages, + setPendingPermissions, + setQueuedMessages, + setVoiceDetectionFlags, + ws, + ] ); useEffect(() => { - registerSessionAccessor(serverId, sessionDirectoryEntry); + updateSessionSnapshot(serverId, value); + }, [serverId, updateSessionSnapshot, value]); + + useEffect(() => { + if (wsIsConnected) { + return; + } + clearSessionSnapshot(serverId); + }, [clearSessionSnapshot, serverId, wsIsConnected]); + + useEffect(() => { return () => { - unregisterSessionAccessor(serverId); + clearSessionSnapshot(serverId); }; - }, [serverId, registerSessionAccessor, unregisterSessionAccessor, sessionDirectoryEntry]); + }, [serverId, clearSessionSnapshot]); return ( diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index ba7cbff4b..6c09678cc 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -1,7 +1,6 @@ import { useMemo } from "react"; import type { Agent } from "@/contexts/session-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; -import { useSessionDirectory } from "@/hooks/use-session-directory"; export interface AggregatedAgent extends Agent { serverId: string; @@ -14,25 +13,24 @@ export interface AggregatedAgentsResult { } export function useAggregatedAgents(): AggregatedAgentsResult { - const { connectionStates, isLoading: registryLoading } = useDaemonConnections(); - const sessionDirectory = useSessionDirectory(); + const { connectionStates, sessionSnapshots } = useDaemonConnections(); return useMemo(() => { const allAgents: AggregatedAgent[] = []; - sessionDirectory.forEach((session, serverId) => { - if (!session) { - return; + for (const [serverId, snapshot] of sessionSnapshots) { + if (snapshot.agents.size === 0) { + continue; } const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; - for (const agent of session.agents.values()) { + for (const agent of snapshot.agents.values()) { allAgents.push({ ...agent, serverId, serverLabel, }); } - }); + } // Sort by: running agents first, then by most recent activity allAgents.sort((left, right) => { @@ -49,19 +47,18 @@ export function useAggregatedAgents(): AggregatedAgentsResult { return rightTime - leftTime; }); - // Loading if registry is still loading, or if any host is connecting and hasn't - // reported agents yet (idle hosts that haven't connected don't block loading) - let isLoading = registryLoading; - if (!isLoading && connectionStates.size > 0) { - for (const [serverId, record] of connectionStates) { - const hasSession = sessionDirectory.has(serverId); - if (record.status === "connecting" && !hasSession) { - isLoading = true; - break; - } - } - } + const isLoading = Array.from(connectionStates.values()).some(c => { + // Only these states count as "loading": + // 1. Actively connecting + // 2. Online but session not yet received + if (c.status === 'connecting') return true; + if (c.status === 'online' && !c.sessionReady) return true; + // Offline/error = not loading, just unavailable (normal state) + return false; + }); + + console.log("[useAggregatedAgents] aggregated agent count:", allAgents.length, "isLoading:", isLoading); return { agents: allAgents, isLoading }; - }, [sessionDirectory, connectionStates, registryLoading]); + }, [sessionSnapshots, connectionStates]); } diff --git a/packages/app/src/hooks/use-daemon-session.ts b/packages/app/src/hooks/use-daemon-session.ts index 85cb925b9..442dcad3b 100644 --- a/packages/app/src/hooks/use-daemon-session.ts +++ b/packages/app/src/hooks/use-daemon-session.ts @@ -16,7 +16,7 @@ export class DaemonSessionUnavailableError extends Error { export function getSessionForServer( serverId: string, - directory: Map + directory: Map ): SessionContextValue { const session = directory.get(serverId) ?? null; if (!session) { diff --git a/packages/app/src/hooks/use-session-directory.ts b/packages/app/src/hooks/use-session-directory.ts index a0e7fa4ea..97d611eba 100644 --- a/packages/app/src/hooks/use-session-directory.ts +++ b/packages/app/src/hooks/use-session-directory.ts @@ -1,35 +1,20 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import type { SessionContextValue } from "@/contexts/session-context"; -export function useSessionDirectory(): Map { - const { sessionAccessors, subscribeToSessionDirectory } = useDaemonConnections(); - const [revision, setRevision] = useState(0); - - useEffect(() => { - return subscribeToSessionDirectory(() => { - setRevision((current) => current + 1); - }); - }, [subscribeToSessionDirectory]); +export function useSessionDirectory(): Map { + const { sessionSnapshots } = useDaemonConnections(); return useMemo(() => { - const entries = new Map(); - sessionAccessors.forEach((entry, serverId) => { - try { - entries.set(serverId, entry.getSnapshot()); - } catch (error) { - console.error(`[useSessionDirectory] Failed to read session accessor for "${serverId}"`, error); - entries.set(serverId, null); - } + const entries = new Map(); + sessionSnapshots.forEach((snapshot, serverId) => { + entries.set(serverId, snapshot); }); return entries; - }, [sessionAccessors, revision]); + }, [sessionSnapshots]); } export function useSessionForServer(serverId: string | null): SessionContextValue | null { - const directory = useSessionDirectory(); - if (!serverId) { - return null; - } - return directory.get(serverId) ?? null; + const { sessionSnapshots } = useDaemonConnections(); + return serverId ? sessionSnapshots.get(serverId) ?? null : null; } diff --git a/packages/app/src/hooks/use-websocket.ts b/packages/app/src/hooks/use-websocket.ts index ea9e0c179..7e83e059a 100644 --- a/packages/app/src/hooks/use-websocket.ts +++ b/packages/app/src/hooks/use-websocket.ts @@ -207,7 +207,10 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe }, []); const on = useCallback( - (type: SessionOutboundMessage["type"], handler: (message: SessionOutboundMessage) => void) => { + ( + type: SessionOutboundMessage["type"], + handler: (message: SessionOutboundMessage) => void + ) => { if (!handlersRef.current.has(type)) { handlersRef.current.set(type, new Set()); } diff --git a/plan.md b/plan.md index c13abad2d..773ac7c12 100644 --- a/plan.md +++ b/plan.md @@ -123,7 +123,7 @@ The multi-daemon infrastructure is in place: session directory with daemon-scope --- -## Review Summary (2025-11-26, updated) +## Review Summary (2025-11-26, final review) **Completed:** - Tasks 1–9 are complete @@ -133,6 +133,14 @@ The multi-daemon infrastructure is in place: session directory with daemon-scope - Settings screen retains host status indicators (correct per guiding principles) - Create agent modal availability check fixed (uses `connectionStates` as single source of truth) - Home screen agent list is now a flat list sorted by activity with host badges per row -- Typecheck passes +- Typecheck passes for both @paseo/server and @paseo/app workspaces -**All tasks complete.** +**Final Review (2025-11-26):** +- Verified no duplicate types or code patterns +- Confirmed no silent defaults (no `hosts[0]`, `daemonEntries[0]`, or `connectionStates.keys().next()` patterns) +- Verified availability logic in create-agent-modal correctly uses `selectedDaemonStatus !== "online"` without timing issues +- Confirmed all offline states use neutral passive messaging about auto-reconnection +- All user-facing strings properly say "Host" (Settings add form, restart alerts, offline messages) +- No regressions or cut corners identified + +**All tasks complete. No follow-up items required.**