mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
85
packages/app/app.config.js
Normal file
85
packages/app/app.config.js
Normal 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",
|
||||
},
|
||||
};
|
||||
@@ -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]);
|
||||
|
||||
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("");
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
14
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.**
|
||||
|
||||
Reference in New Issue
Block a user