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`
|
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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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]);
|
}, [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 (
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.**
|
||||||
|
|||||||
Reference in New Issue
Block a user