refactor(app): simplify session directory with direct snapshots and add sessionReady state

Replace subscription-based session accessor pattern with direct snapshot updates in daemon connections context. Add sessionReady flag to connection state to track when initial agent list has been received. Auto-select first ready host in create agent modal. Migrate from app.json to app.config.js. Add android:release script and Playwright testing docs.
This commit is contained in:
Mohamed Boudra
2025-11-27 21:06:48 +01:00
parent a01c3f9b73
commit bf58e81ce8
12 changed files with 339 additions and 277 deletions
+6
View File
@@ -27,6 +27,12 @@ Don't run them anywhere else. Check logs there.
Take screenshots like this: `adb exec-out screencap -p > screenshot.png` 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 ## Expo troubleshooting
Run `npx expo-doctor` to diagnose version mismatches and native module issues. Run `npx expo-doctor` to diagnose version mismatches and native module issues.
+1
View File
@@ -14,6 +14,7 @@
"test": "npm run test --workspaces --if-present", "test": "npm run test --workspaces --if-present",
"start": "npm run start --workspace=@paseo/server", "start": "npm run start --workspace=@paseo/server",
"android": "npm run android --workspace=@paseo/app", "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", "ios": "npm run ios --workspace=@paseo/app",
"web": "npm run web --workspace=@paseo/app", "web": "npm run web --workspace=@paseo/app",
"postinstall": "patch-package" "postinstall": "patch-package"
+85
View File
@@ -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",
},
};
-81
View File
@@ -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"
}
}
@@ -251,6 +251,18 @@ function AgentFlowModal({
} }
}, [isVisible, initialServerId, selectedServerId]); }, [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 ws = session?.ws ?? null;
const inertWebSocket = useMemo<UseWebSocketReturn>( const inertWebSocket = useMemo<UseWebSocketReturn>(
() => ({ () => ({
@@ -339,7 +351,12 @@ function AgentFlowModal({
selectedDaemonLastError ? ` ${selectedDaemonLastError}` : "" selectedDaemonLastError ? ` ${selectedDaemonLastError}` : ""
}` }`
: null; : 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 [isMounted, setIsMounted] = useState(isVisible);
const [workingDir, setWorkingDir] = useState(""); const [workingDir, setWorkingDir] = useState("");
@@ -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 type { ReactNode } from "react";
import { useDaemonRegistry, type DaemonProfile } from "./daemon-registry-context"; import { useDaemonRegistry, type DaemonProfile } from "./daemon-registry-context";
import type { SessionContextValue } from "./session-context"; import type { SessionContextValue } from "./session-context";
export interface SessionDirectoryEntry { export type SessionSnapshot = SessionContextValue;
getSnapshot: () => SessionContextValue | null;
subscribe: (listener: () => void) => () => void;
}
type SessionAccessorRegistry = Map<string, SessionDirectoryEntry>;
export type ConnectionState = export type ConnectionState =
| { status: "idle"; lastError: null; lastOnlineAt: string | null } | { status: "idle"; lastError: null; lastOnlineAt: string | null; sessionReady: false }
| { status: "connecting"; lastError: null; lastOnlineAt: string | null } | { status: "connecting"; lastError: null; lastOnlineAt: string | null; sessionReady: false }
| { status: "online"; lastError: null; lastOnlineAt: string } | { status: "online"; lastError: null; lastOnlineAt: string; sessionReady: boolean }
| { status: "offline"; lastError: string | null; lastOnlineAt: string | null } | { status: "offline"; lastError: string | null; lastOnlineAt: string | null; sessionReady: false }
| { status: "error"; lastError: string; lastOnlineAt: string | null }; | { status: "error"; lastError: string; lastOnlineAt: string | null; sessionReady: false };
export type ConnectionStatus = ConnectionState["status"]; export type ConnectionStatus = ConnectionState["status"];
type ConnectionStateUpdate = type ConnectionStateUpdate =
| { status: "idle" } | { status: "idle" }
| { status: "connecting"; lastOnlineAt?: string | null } | { status: "connecting"; lastOnlineAt?: string | null }
| { status: "online"; lastOnlineAt: string } | { status: "online"; lastOnlineAt: string; sessionReady?: boolean }
| { status: "offline"; lastError?: string | null; lastOnlineAt?: string | null } | { status: "offline"; lastError?: string | null; lastOnlineAt?: string | null }
| { status: "error"; lastError: string; lastOnlineAt?: string | null }; | { status: "error"; lastError: string; lastOnlineAt?: string | null };
@@ -33,11 +29,9 @@ interface DaemonConnectionsContextValue {
connectionStates: Map<string, DaemonConnectionRecord>; connectionStates: Map<string, DaemonConnectionRecord>;
isLoading: boolean; isLoading: boolean;
updateConnectionStatus: (daemonId: string, update: ConnectionStateUpdate) => void; updateConnectionStatus: (daemonId: string, update: ConnectionStateUpdate) => void;
sessionAccessors: Map<string, SessionDirectoryEntry>; sessionSnapshots: Map<string, SessionSnapshot>;
registerSessionAccessor: (daemonId: string, entry: SessionDirectoryEntry) => void; updateSessionSnapshot: (daemonId: string, snapshot: SessionSnapshot | null) => void;
unregisterSessionAccessor: (daemonId: string) => void; clearSessionSnapshot: (daemonId: string) => void;
subscribeToSessionDirectory: (listener: () => void) => () => void;
notifySessionDirectoryChange: () => void;
} }
const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(null); const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(null);
@@ -47,6 +41,7 @@ function createDefaultConnectionState(): ConnectionState {
status: "idle", status: "idle",
lastError: null, lastError: null,
lastOnlineAt: null, lastOnlineAt: null,
sessionReady: false,
}; };
} }
@@ -56,26 +51,34 @@ function resolveNextConnectionState(
): ConnectionState { ): ConnectionState {
switch (update.status) { switch (update.status) {
case "idle": case "idle":
return { status: "idle", lastError: null, lastOnlineAt: existing.lastOnlineAt }; return { status: "idle", lastError: null, lastOnlineAt: existing.lastOnlineAt, sessionReady: false };
case "connecting": case "connecting":
return { return {
status: "connecting", status: "connecting",
lastError: null, lastError: null,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt, lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
sessionReady: false,
}; };
case "online": 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": case "offline":
return { return {
status: "offline", status: "offline",
lastError: update.lastError ?? null, lastError: update.lastError ?? null,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt, lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
sessionReady: false,
}; };
case "error": case "error":
return { return {
status: "error", status: "error",
lastError: update.lastError, lastError: update.lastError,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt, lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
sessionReady: false,
}; };
} }
} }
@@ -113,8 +116,7 @@ export function useDaemonConnections(): DaemonConnectionsContextValue {
export function DaemonConnectionsProvider({ children }: { children: ReactNode }) { export function DaemonConnectionsProvider({ children }: { children: ReactNode }) {
const { daemons, isLoading: registryLoading } = useDaemonRegistry(); const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const [connectionStates, setConnectionStates] = useState<Map<string, DaemonConnectionRecord>>(new Map()); const [connectionStates, setConnectionStates] = useState<Map<string, DaemonConnectionRecord>>(new Map());
const [sessionAccessorRegistry, setSessionAccessorRegistry] = useState<SessionAccessorRegistry>(new Map()); const [sessionSnapshotRegistry, setSessionSnapshotRegistry] = useState<Map<string, SessionSnapshot>>(new Map());
const sessionDirectoryListenersRef = useRef<Set<() => void>>(new Set());
// Ensure connection states stay in sync with registry entries // Ensure connection states stay in sync with registry entries
useEffect(() => { useEffect(() => {
@@ -132,17 +134,17 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
}, [daemons]); }, [daemons]);
useEffect(() => { useEffect(() => {
setSessionAccessorRegistry((prev) => { setSessionSnapshotRegistry((prev) => {
const validDaemonIds = new Set(daemons.map((daemon) => daemon.id)); const validDaemonIds = new Set(daemons.map((daemon) => daemon.id));
let changed = false; let changed = false;
const next: SessionAccessorRegistry = new Map(); const next = new Map<string, SessionSnapshot>();
for (const [daemonId, entry] of prev.entries()) { for (const [daemonId, snapshot] of prev.entries()) {
if (!validDaemonIds.has(daemonId)) { if (!validDaemonIds.has(daemonId)) {
changed = true; changed = true;
continue; continue;
} }
next.set(daemonId, entry); next.set(daemonId, snapshot);
} }
return changed ? next : prev; return changed ? next : prev;
@@ -175,58 +177,38 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
[] []
); );
const registerSessionAccessor = useCallback((daemonId: string, entry: SessionDirectoryEntry) => { const updateSessionSnapshot = useCallback((serverId: string, snapshot: SessionSnapshot | null) => {
setSessionAccessorRegistry((prev) => { setSessionSnapshotRegistry((prev) => {
const next = new Map(prev); const next = new Map(prev);
const hasExisting = next.has(daemonId); if (snapshot === null) {
next.set(daemonId, entry); next.delete(serverId);
if (hasExisting) { } else {
console.warn(`[DaemonConnections] Duplicate session accessor detected for "${daemonId}". Overwriting.`); next.set(serverId, snapshot);
} }
return next; return next;
}); });
}, []); }, []);
const unregisterSessionAccessor = useCallback((daemonId: string) => { const clearSessionSnapshot = useCallback((serverId: string) => {
setSessionAccessorRegistry((prev) => { setSessionSnapshotRegistry((prev) => {
if (!prev.has(daemonId)) { if (!prev.has(serverId)) {
return prev; return prev;
} }
const next = new Map(prev); const next = new Map(prev);
next.delete(daemonId); next.delete(serverId);
return next; return next;
}); });
}, []); }, []);
const subscribeToSessionDirectory = useCallback((listener: () => void) => { const sessionSnapshots = useMemo(() => new Map(sessionSnapshotRegistry), [sessionSnapshotRegistry]);
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 value: DaemonConnectionsContextValue = { const value: DaemonConnectionsContextValue = {
connectionStates, connectionStates,
isLoading: registryLoading, isLoading: registryLoading,
updateConnectionStatus, updateConnectionStatus,
sessionAccessors, sessionSnapshots,
registerSessionAccessor, updateSessionSnapshot,
unregisterSessionAccessor, clearSessionSnapshot,
subscribeToSessionDirectory,
notifySessionDirectoryChange,
}; };
return ( return (
+145 -86
View File
@@ -296,6 +296,7 @@ export interface SessionContextValue {
serverId: string; serverId: string;
// WebSocket // WebSocket
ws: UseWebSocketReturn; ws: UseWebSocketReturn;
hasHydratedAgents: boolean;
// Audio // Audio
audioPlayer: ReturnType<typeof useAudioPlayer>; audioPlayer: ReturnType<typeof useAudioPlayer>;
@@ -395,9 +396,8 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
const wsIsConnected = ws.isConnected; const wsIsConnected = ws.isConnected;
const { const {
updateConnectionStatus, updateConnectionStatus,
registerSessionAccessor, updateSessionSnapshot,
unregisterSessionAccessor, clearSessionSnapshot,
notifySessionDirectoryChange,
} = useDaemonConnections(); } = useDaemonConnections();
useEffect(() => { useEffect(() => {
@@ -444,6 +444,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
const [currentAssistantMessage, setCurrentAssistantMessage] = useState(""); const [currentAssistantMessage, setCurrentAssistantMessage] = useState("");
const [agentStreamState, setAgentStreamState] = useState<Map<string, StreamItem[]>>(new Map()); const [agentStreamState, setAgentStreamState] = useState<Map<string, StreamItem[]>>(new Map());
const [initializingAgents, setInitializingAgents] = useState<Map<string, boolean>>(new Map()); const [initializingAgents, setInitializingAgents] = useState<Map<string, boolean>>(new Map());
const [hasHydratedAgents, setHasHydratedAgents] = useState(false);
const [agents, setAgents] = useState<Map<string, Agent>>(new Map()); const [agents, setAgents] = useState<Map<string, Agent>>(new Map());
const [commands, setCommands] = useState<Map<string, Command>>(new Map()); const [commands, setCommands] = useState<Map<string, Command>>(new Map());
@@ -468,6 +469,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
const providerModelRequestIdsRef = useRef<Map<AgentProvider, string>>(new Map()); const providerModelRequestIdsRef = useRef<Map<AgentProvider, string>>(new Map());
const pendingAgentLifecycleRequestsRef = useRef<PendingAgentLifecycleRequest[]>([]); const pendingAgentLifecycleRequestsRef = useRef<PendingAgentLifecycleRequest[]>([]);
const hasHydratedSnapshotRef = useRef(false); const hasHydratedSnapshotRef = useRef(false);
const hasRequestedInitialSnapshotRef = useRef(false);
// Buffer for streaming audio chunks // Buffer for streaming audio chunks
interface AudioChunk { interface AudioChunk {
@@ -483,6 +485,11 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
setFocusedAgentOverride(agentId); setFocusedAgentOverride(agentId);
}, []); }, []);
useEffect(() => {
hasHydratedSnapshotRef.current = false;
setHasHydratedAgents(false);
}, [serverId]);
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -514,14 +521,15 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
return; return;
} }
setPendingPermissions(hydratedPermissions); setPendingPermissions(hydratedPermissions);
const commandEntries = snapshot.commands ?? []; const commandEntries = snapshot.commands ?? [];
setCommands((prev) => { setCommands((prev) => {
if (prev.size > 0) { if (prev.size > 0) {
return prev; return prev;
} }
return new Map(commandEntries.map((command) => [command.id, command])); return new Map(commandEntries.map((command) => [command.id, command]));
}); });
setHasHydratedAgents(true);
}; };
void hydrateFromSnapshot(); void hydrateFromSnapshot();
@@ -691,6 +699,24 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
} }
}, [wsIsConnected, sendAgentLifecycleRequest]); }, [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 // WebSocket message handlers
useEffect(() => { useEffect(() => {
// Session state - initial agents/commands // Session state - initial agents/commands
@@ -746,6 +772,8 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
return changed ? next : prev; return changed ? next : prev;
}); });
void persistSessionSnapshot(serverId, { agents: agentsList, commands: normalizedCommands }); 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) => { const unsubAgentState = ws.on("agent_state", (message) => {
@@ -1717,88 +1745,119 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
return targetPath; return targetPath;
}, [updateExplorerState, ws]); }, [updateExplorerState, ws]);
const value: SessionContextValue = { const value = useMemo<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<SessionContextValue>(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(
() => ({ () => ({
getSnapshot: sessionAccessor, serverId,
subscribe: subscribeToSession, 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(() => { useEffect(() => {
registerSessionAccessor(serverId, sessionDirectoryEntry); updateSessionSnapshot(serverId, value);
}, [serverId, updateSessionSnapshot, value]);
useEffect(() => {
if (wsIsConnected) {
return;
}
clearSessionSnapshot(serverId);
}, [clearSessionSnapshot, serverId, wsIsConnected]);
useEffect(() => {
return () => { return () => {
unregisterSessionAccessor(serverId); clearSessionSnapshot(serverId);
}; };
}, [serverId, registerSessionAccessor, unregisterSessionAccessor, sessionDirectoryEntry]); }, [serverId, clearSessionSnapshot]);
return ( return (
<SessionContext.Provider value={value}> <SessionContext.Provider value={value}>
+18 -21
View File
@@ -1,7 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { Agent } from "@/contexts/session-context"; import type { Agent } from "@/contexts/session-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionDirectory } from "@/hooks/use-session-directory";
export interface AggregatedAgent extends Agent { export interface AggregatedAgent extends Agent {
serverId: string; serverId: string;
@@ -14,25 +13,24 @@ export interface AggregatedAgentsResult {
} }
export function useAggregatedAgents(): AggregatedAgentsResult { export function useAggregatedAgents(): AggregatedAgentsResult {
const { connectionStates, isLoading: registryLoading } = useDaemonConnections(); const { connectionStates, sessionSnapshots } = useDaemonConnections();
const sessionDirectory = useSessionDirectory();
return useMemo(() => { return useMemo(() => {
const allAgents: AggregatedAgent[] = []; const allAgents: AggregatedAgent[] = [];
sessionDirectory.forEach((session, serverId) => { for (const [serverId, snapshot] of sessionSnapshots) {
if (!session) { if (snapshot.agents.size === 0) {
return; continue;
} }
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
for (const agent of session.agents.values()) { for (const agent of snapshot.agents.values()) {
allAgents.push({ allAgents.push({
...agent, ...agent,
serverId, serverId,
serverLabel, serverLabel,
}); });
} }
}); }
// Sort by: running agents first, then by most recent activity // Sort by: running agents first, then by most recent activity
allAgents.sort((left, right) => { allAgents.sort((left, right) => {
@@ -49,19 +47,18 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
return rightTime - leftTime; return rightTime - leftTime;
}); });
// Loading if registry is still loading, or if any host is connecting and hasn't const isLoading = Array.from(connectionStates.values()).some(c => {
// reported agents yet (idle hosts that haven't connected don't block loading) // Only these states count as "loading":
let isLoading = registryLoading; // 1. Actively connecting
if (!isLoading && connectionStates.size > 0) { // 2. Online but session not yet received
for (const [serverId, record] of connectionStates) { if (c.status === 'connecting') return true;
const hasSession = sessionDirectory.has(serverId); if (c.status === 'online' && !c.sessionReady) return true;
if (record.status === "connecting" && !hasSession) {
isLoading = true;
break;
}
}
}
// Offline/error = not loading, just unavailable (normal state)
return false;
});
console.log("[useAggregatedAgents] aggregated agent count:", allAgents.length, "isLoading:", isLoading);
return { agents: allAgents, isLoading }; return { agents: allAgents, isLoading };
}, [sessionDirectory, connectionStates, registryLoading]); }, [sessionSnapshots, connectionStates]);
} }
+1 -1
View File
@@ -16,7 +16,7 @@ export class DaemonSessionUnavailableError extends Error {
export function getSessionForServer( export function getSessionForServer(
serverId: string, serverId: string,
directory: Map<string, SessionContextValue | null> directory: Map<string, SessionContextValue>
): SessionContextValue { ): SessionContextValue {
const session = directory.get(serverId) ?? null; const session = directory.get(serverId) ?? null;
if (!session) { if (!session) {
@@ -1,35 +1,20 @@
import { useEffect, useMemo, useState } from "react"; import { useMemo } from "react";
import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { SessionContextValue } from "@/contexts/session-context"; import type { SessionContextValue } from "@/contexts/session-context";
export function useSessionDirectory(): Map<string, SessionContextValue | null> { export function useSessionDirectory(): Map<string, SessionContextValue> {
const { sessionAccessors, subscribeToSessionDirectory } = useDaemonConnections(); const { sessionSnapshots } = useDaemonConnections();
const [revision, setRevision] = useState(0);
useEffect(() => {
return subscribeToSessionDirectory(() => {
setRevision((current) => current + 1);
});
}, [subscribeToSessionDirectory]);
return useMemo(() => { return useMemo(() => {
const entries = new Map<string, SessionContextValue | null>(); const entries = new Map<string, SessionContextValue>();
sessionAccessors.forEach((entry, serverId) => { sessionSnapshots.forEach((snapshot, serverId) => {
try { entries.set(serverId, snapshot);
entries.set(serverId, entry.getSnapshot());
} catch (error) {
console.error(`[useSessionDirectory] Failed to read session accessor for "${serverId}"`, error);
entries.set(serverId, null);
}
}); });
return entries; return entries;
}, [sessionAccessors, revision]); }, [sessionSnapshots]);
} }
export function useSessionForServer(serverId: string | null): SessionContextValue | null { export function useSessionForServer(serverId: string | null): SessionContextValue | null {
const directory = useSessionDirectory(); const { sessionSnapshots } = useDaemonConnections();
if (!serverId) { return serverId ? sessionSnapshots.get(serverId) ?? null : null;
return null;
}
return directory.get(serverId) ?? null;
} }
+4 -1
View File
@@ -207,7 +207,10 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
}, []); }, []);
const on = useCallback( const on = useCallback(
(type: SessionOutboundMessage["type"], handler: (message: SessionOutboundMessage) => void) => { (
type: SessionOutboundMessage["type"],
handler: (message: SessionOutboundMessage) => void
) => {
if (!handlersRef.current.has(type)) { if (!handlersRef.current.has(type)) {
handlersRef.current.set(type, new Set()); handlersRef.current.set(type, new Set());
} }
+11 -3
View File
@@ -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:** **Completed:**
- Tasks 1–9 are complete - 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) - Settings screen retains host status indicators (correct per guiding principles)
- Create agent modal availability check fixed (uses `connectionStates` as single source of truth) - 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 - 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.**