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

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`
## 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.

View File

@@ -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"

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",
},
};

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"
}
}

View File

@@ -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<UseWebSocketReturn>(
() => ({
@@ -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("");

View File

@@ -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<string, SessionDirectoryEntry>;
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<string, DaemonConnectionRecord>;
isLoading: boolean;
updateConnectionStatus: (daemonId: string, update: ConnectionStateUpdate) => void;
sessionAccessors: Map<string, SessionDirectoryEntry>;
registerSessionAccessor: (daemonId: string, entry: SessionDirectoryEntry) => void;
unregisterSessionAccessor: (daemonId: string) => void;
subscribeToSessionDirectory: (listener: () => void) => () => void;
notifySessionDirectoryChange: () => void;
sessionSnapshots: Map<string, SessionSnapshot>;
updateSessionSnapshot: (daemonId: string, snapshot: SessionSnapshot | null) => void;
clearSessionSnapshot: (daemonId: string) => void;
}
const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(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<Map<string, DaemonConnectionRecord>>(new Map());
const [sessionAccessorRegistry, setSessionAccessorRegistry] = useState<SessionAccessorRegistry>(new Map());
const sessionDirectoryListenersRef = useRef<Set<() => void>>(new Set());
const [sessionSnapshotRegistry, setSessionSnapshotRegistry] = useState<Map<string, SessionSnapshot>>(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<string, SessionSnapshot>();
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 (

View File

@@ -296,6 +296,7 @@ export interface SessionContextValue {
serverId: string;
// WebSocket
ws: UseWebSocketReturn;
hasHydratedAgents: boolean;
// Audio
audioPlayer: ReturnType<typeof useAudioPlayer>;
@@ -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<Map<string, StreamItem[]>>(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 [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 pendingAgentLifecycleRequestsRef = useRef<PendingAgentLifecycleRequest[]>([]);
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<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(
const value = useMemo<SessionContextValue>(
() => ({
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 (
<SessionContext.Provider value={value}>

View File

@@ -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]);
}

View File

@@ -16,7 +16,7 @@ export class DaemonSessionUnavailableError extends Error {
export function getSessionForServer(
serverId: string,
directory: Map<string, SessionContextValue | null>
directory: Map<string, SessionContextValue>
): SessionContextValue {
const session = directory.get(serverId) ?? null;
if (!session) {

View File

@@ -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<string, SessionContextValue | null> {
const { sessionAccessors, subscribeToSessionDirectory } = useDaemonConnections();
const [revision, setRevision] = useState(0);
useEffect(() => {
return subscribeToSessionDirectory(() => {
setRevision((current) => current + 1);
});
}, [subscribeToSessionDirectory]);
export function useSessionDirectory(): Map<string, SessionContextValue> {
const { sessionSnapshots } = useDaemonConnections();
return useMemo(() => {
const entries = new Map<string, SessionContextValue | null>();
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<string, SessionContextValue>();
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;
}

View File

@@ -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());
}

14
plan.md
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:**
- Tasks 19 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.**