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
@@ -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 (
+145 -86
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}>