feat: add multi-daemon support with session routing and connection management

Introduce DaemonRegistry and DaemonConnections contexts for managing multiple server connections. Add session directory for aggregating agents across daemons, with automatic routing based on agent/server context.

Key changes:
- Add daemon profile management (add/edit/remove/set default)
- Implement connection state tracking with reconnection and exponential backoff
- Create useAggregatedAgents hook for unified agent list across daemons
- Add useDaemonSession and useDaemonRequest hooks for daemon-scoped operations
- Update agent routes to include serverId for proper session routing
- Add connection health indicators on home screen and settings
- Persist session snapshots per daemon for faster hydration on reconnect
- Guard screens gracefully when target daemon is offline
- Add React Query for consistent loading/error state management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-11-26 08:25:02 +01:00
parent 7d8979e125
commit 4ab79b1a50
33 changed files with 4795 additions and 1481 deletions

View File

@@ -1 +0,0 @@
patch

28
package-lock.json generated
View File

@@ -7,6 +7,7 @@
"": {
"name": "paseo",
"version": "1.0.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
"packages/server",
@@ -6938,6 +6939,32 @@
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@tanstack/query-core": {
"version": "5.90.11",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz",
"integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.11",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz",
"integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
@@ -21010,6 +21037,7 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"buffer": "^6.0.3",
"expo": "^54.0.18",
"expo-audio": "~1.0.13",

View File

@@ -20,6 +20,7 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"buffer": "^6.0.3",
"expo": "^54.0.18",
"expo-audio": "~1.0.13",

View File

@@ -5,13 +5,37 @@ import { GestureHandlerRootView } from "react-native-gesture-handler";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { SessionProvider } from "@/contexts/session-context";
import { RealtimeProvider } from "@/contexts/realtime-context";
import { useSettings } from "@/hooks/use-settings";
import { View, ActivityIndicator } from "react-native";
import { useAppSettings } from "@/hooks/use-settings";
import { View, ActivityIndicator, Text } from "react-native";
import { GlobalFooter } from "@/components/global-footer";
import { useUnistyles } from "react-native-unistyles";
import { FooterControlsProvider } from "@/contexts/footer-controls-context";
import { DaemonRegistryProvider } from "@/contexts/daemon-registry-context";
import { DaemonConnectionsProvider, useDaemonConnections } from "@/contexts/daemon-connections-context";
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
function AppContainer({ children }: { children: React.ReactNode }) {
function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
},
})
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
function AppContainer({ children }: { children: ReactNode }) {
const { theme } = useUnistyles();
return (
@@ -21,61 +45,107 @@ function AppContainer({ children }: { children: React.ReactNode }) {
);
}
function ProvidersWrapper({ children }: { children: React.ReactNode }) {
const { settings, isLoading } = useSettings();
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const { activeDaemon, isLoading: connectionsLoading } = useDaemonConnections();
if (isLoading) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#09090b",
}}
>
<ActivityIndicator size="large" color="#fafafa" />
</View>
);
if (settingsLoading || connectionsLoading) {
return <LoadingView />;
}
if (!activeDaemon) {
return <MissingDaemonView />;
}
return (
<SessionProvider serverUrl={settings.serverUrl}>
<SessionProvider
key={activeDaemon.id}
serverUrl={activeDaemon.wsUrl}
serverId={activeDaemon.id}
>
<RealtimeProvider>{children}</RealtimeProvider>
</SessionProvider>
);
}
function LoadingView() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#09090b",
}}
>
<ActivityIndicator size="large" color="#fafafa" />
</View>
);
}
function MissingDaemonView() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
backgroundColor: "#09090b",
}}
>
<ActivityIndicator size="small" color="#fafafa" />
<Text
style={{
color: "#fafafa",
marginTop: 16,
textAlign: "center",
}}
>
No daemon configured. Open Settings to add a server URL.
</Text>
</View>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<KeyboardProvider>
<BottomSheetModalProvider>
<ProvidersWrapper>
<FooterControlsProvider>
<AppContainer>
<Stack
screenOptions={{
headerShown: false,
animation: "none",
gestureEnabled: true,
gestureDirection: "horizontal",
fullScreenGestureEnabled: true,
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="orchestrator" />
<Stack.Screen name="agent/[id]" />
<Stack.Screen name="settings" />
<Stack.Screen name="audio-test" />
<Stack.Screen name="git-diff" />
<Stack.Screen name="file-explorer" />
</Stack>
<GlobalFooter />
</AppContainer>
</FooterControlsProvider>
</ProvidersWrapper>
<QueryProvider>
<DaemonRegistryProvider>
<DaemonConnectionsProvider>
<MultiDaemonSessionHost />
<ProvidersWrapper>
<FooterControlsProvider>
<AppContainer>
<Stack
screenOptions={{
headerShown: false,
animation: "none",
gestureEnabled: true,
gestureDirection: "horizontal",
fullScreenGestureEnabled: true,
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="orchestrator" />
<Stack.Screen name="agent/[id]" />
<Stack.Screen name="agent/[serverId]/[agentId]" />
<Stack.Screen name="settings" />
<Stack.Screen name="audio-test" />
<Stack.Screen name="git-diff" />
<Stack.Screen name="file-explorer" />
</Stack>
<GlobalFooter />
</AppContainer>
</FooterControlsProvider>
</ProvidersWrapper>
</DaemonConnectionsProvider>
</DaemonRegistryProvider>
</QueryProvider>
</BottomSheetModalProvider>
</KeyboardProvider>
</SafeAreaProvider>

View File

@@ -1,560 +1,148 @@
import { useEffect, useMemo, useRef, useCallback, useState } from "react";
import {
View,
Text,
ActivityIndicator,
Pressable,
Modal,
useWindowDimensions,
LayoutChangeEvent,
} from "react-native";
import { useCallback, useEffect, useMemo } from "react";
import type { ReactNode } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MoreVertical, GitBranch, Folder, RotateCcw, PlusCircle } from "lucide-react-native";
import { BackHeader } from "@/components/headers/back-header";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
import { CreateAgentModal, type CreateAgentInitialValues } from "@/components/create-agent-modal";
import { useSession } from "@/contexts/session-context";
import { useSessionDirectory } from "@/hooks/use-session-directory";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { Agent } from "@/contexts/session-context";
import { useFooterControls } from "@/contexts/footer-controls-context";
import { generateMessageId } from "@/types/stream";
const DROPDOWN_WIDTH = 220;
type AgentMatch = {
serverId: string;
serverLabel: string;
agent: Agent;
};
type BranchStatus = "idle" | "loading" | "ready" | "error";
function extractAgentModel(agent?: Agent | null): string | null {
if (!agent) {
return null;
}
const directModel = typeof agent.model === "string" ? agent.model.trim() : "";
if (directModel.length > 0) {
return directModel;
}
const metadata = agent.persistence?.metadata;
if (!metadata || typeof metadata !== "object") {
return null;
}
const persistedModel = (metadata as Record<string, unknown>).model;
if (typeof persistedModel === "string" && persistedModel.trim().length > 0) {
return persistedModel.trim();
}
const extra = (metadata as Record<string, unknown>).extra;
if (!extra || typeof extra !== "object") {
return null;
}
const getModelFrom = (source: unknown) => {
if (!source || typeof source !== "object") {
return null;
}
const candidate = (source as Record<string, unknown>).model;
return typeof candidate === "string" && candidate.trim().length > 0
? candidate.trim()
: null;
};
return (
getModelFrom((extra as Record<string, unknown>).codex) ??
getModelFrom((extra as Record<string, unknown>).claude)
);
}
export default function AgentScreen() {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const router = useRouter();
export default function LegacyAgentRedirectScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const {
agents,
agentStreamState,
initializingAgents,
pendingPermissions,
respondToPermission,
initializeAgent,
refreshAgent,
setFocusedAgentId,
ws,
} = useSession();
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const [menuVisible, setMenuVisible] = useState(false);
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
const [menuContentHeight, setMenuContentHeight] = useState(0);
const menuButtonRef = useRef<View>(null);
const [branchStatus, setBranchStatus] = useState<BranchStatus>("idle");
const [branchLabel, setBranchLabel] = useState<string | null>(null);
const [branchError, setBranchError] = useState<string | null>(null);
const [showCreateAgentModal, setShowCreateAgentModal] = useState(false);
const [createAgentInitialValues, setCreateAgentInitialValues] =
useState<CreateAgentInitialValues | undefined>();
const repoInfoRequestIdRef = useRef<string | null>(null);
const hasPendingRepoRequest = repoInfoRequestIdRef.current !== null;
const branchDisplayValue =
branchStatus === "error"
? branchError ?? "Unavailable"
: branchLabel ?? "Unknown";
const shouldListenForBranchInfo = menuVisible || hasPendingRepoRequest;
const router = useRouter();
const { theme } = useUnistyles();
const { connectionStates } = useDaemonConnections();
const sessionDirectory = useSessionDirectory();
const agentId = typeof id === "string" ? id.trim() : undefined;
// Keyboard animation
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
useEffect(() => {
bottomInset.value = insets.bottom;
}, [insets.bottom, bottomInset]);
const animatedKeyboardStyle = useAnimatedStyle(() => {
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = Math.max(0, absoluteHeight - bottomInset.value);
return {
transform: [{ translateY: -shift }],
};
});
const agent = id ? agents.get(id) : undefined;
const streamItems = id ? agentStreamState.get(id) || [] : [];
const agentPermissions = new Map(
Array.from(pendingPermissions.entries()).filter(([_, perm]) => perm.agentId === id)
);
const agentModel = extractAgentModel(agent);
const modelDisplayValue = agentModel ?? "Unknown";
const resetBranchState = useCallback(() => {
repoInfoRequestIdRef.current = null;
setBranchStatus("idle");
setBranchLabel(null);
setBranchError(null);
}, []);
const sendGitRepoInfoRequest = useCallback(
(cwd: string) => {
if (!cwd) {
resetBranchState();
return;
}
const requestId = generateMessageId();
repoInfoRequestIdRef.current = requestId;
setBranchStatus("loading");
setBranchLabel(null);
setBranchError(null);
ws.send({
type: "session",
message: {
type: "git_repo_info_request",
cwd,
requestId,
},
});
},
[resetBranchState, ws]
);
useEffect(() => {
if (!agent?.cwd) {
resetBranchState();
return;
const matches = useMemo<AgentMatch[]>(() => {
if (!agentId) {
return [];
}
sendGitRepoInfoRequest(agent.cwd);
}, [agent?.cwd, resetBranchState, sendGitRepoInfoRequest]);
useEffect(() => {
if (!shouldListenForBranchInfo) {
return;
}
const unsubscribe = ws.on("git_repo_info_response", (message) => {
if (message.type !== "git_repo_info_response") {
const results: AgentMatch[] = [];
sessionDirectory.forEach((session, serverId) => {
if (!session) {
return;
}
if (
repoInfoRequestIdRef.current &&
message.payload.requestId &&
message.payload.requestId !== repoInfoRequestIdRef.current
) {
const agent = session.agents.get(agentId);
if (!agent) {
return;
}
if (agent?.cwd && message.payload.cwd && message.payload.cwd !== agent.cwd) {
return;
}
repoInfoRequestIdRef.current = null;
if (message.payload.error) {
setBranchStatus("error");
setBranchError(message.payload.error);
setBranchLabel(null);
return;
}
setBranchStatus("ready");
setBranchError(null);
setBranchLabel(message.payload.currentBranch ?? null);
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
results.push({ serverId, serverLabel, agent });
});
return () => {
unsubscribe();
};
}, [agent?.cwd, shouldListenForBranchInfo, ws]);
return results;
}, [agentId, sessionDirectory, connectionStates]);
const hasSessions = sessionDirectory.size > 0;
const isRedirecting = Boolean(agentId && matches.length === 1);
useEffect(() => {
if (!id) {
setFocusedAgentId(null);
if (!isRedirecting) {
return;
}
setFocusedAgentId(id);
return () => {
setFocusedAgentId(null);
};
}, [id, setFocusedAgentId]);
const hasStreamState = id ? agentStreamState.has(id) : false;
const initializationState = id ? initializingAgents.get(id) : undefined;
const isInitializing = id
? initializationState !== undefined
? initializationState
: !hasStreamState
: false;
useEffect(() => {
if (!id) {
return;
}
if (initializationState !== undefined) {
return;
}
if (hasStreamState) {
return;
}
initializeAgent({ agentId: id });
}, [id, initializeAgent, initializationState, hasStreamState]);
const agentControls = useMemo(() => {
if (!id) return null;
return <AgentInputArea agentId={id} />;
}, [id]);
useEffect(() => {
if (!agentControls || !agent || isInitializing) {
unregisterFooterControls();
return;
}
registerFooterControls(agentControls);
return () => {
unregisterFooterControls();
};
}, [agentControls, agent, isInitializing, registerFooterControls, unregisterFooterControls]);
const recalculateMenuPosition = useCallback(
(onMeasured?: () => void) => {
requestAnimationFrame(() => {
const anchor = menuButtonRef.current;
if (!anchor) {
if (onMeasured) {
onMeasured();
}
return;
}
anchor.measureInWindow((x, y, width, height) => {
const verticalOffset = 8;
const horizontalMargin = 16;
const desiredLeft = x + width - DROPDOWN_WIDTH;
const maxLeft = windowWidth - DROPDOWN_WIDTH - horizontalMargin;
const clampedLeft = Math.min(Math.max(desiredLeft, horizontalMargin), maxLeft);
// Position menu below button - add insets.top to account for status bar
const buttonBottom = y + height + insets.top;
const top = buttonBottom + verticalOffset;
// If menu would go off screen, clamp to visible area
const bottomEdge = top + menuContentHeight;
const maxBottom = windowHeight - horizontalMargin;
const clampedTop = bottomEdge > maxBottom
? Math.max(verticalOffset, maxBottom - menuContentHeight)
: top;
console.log('[Menu] Button position:', { x, y, width, height, insetsTop: insets.top });
console.log('[Menu] Calculated position:', { buttonBottom, top, clampedTop, left: clampedLeft });
setMenuPosition({
top: clampedTop,
left: clampedLeft,
});
if (onMeasured) {
onMeasured();
}
});
});
},
[menuContentHeight, windowHeight, windowWidth]
);
const handleOpenMenu = useCallback(() => {
if (agent?.cwd) {
sendGitRepoInfoRequest(agent.cwd);
}
recalculateMenuPosition(() => {
setMenuVisible(true);
const match = matches[0];
router.replace({
pathname: "/agent/[serverId]/[agentId]",
params: {
serverId: match.serverId,
agentId: match.agent.id,
},
});
}, [agent?.cwd, recalculateMenuPosition, sendGitRepoInfoRequest]);
}, [isRedirecting, matches, router]);
const handleCloseMenu = useCallback(() => {
setMenuVisible(false);
setMenuContentHeight(0);
}, []);
useEffect(() => {
if (!menuVisible) {
return;
}
recalculateMenuPosition();
}, [menuVisible, recalculateMenuPosition]);
const handleMenuLayout = useCallback((event: LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout;
setMenuContentHeight((current) => (current === height ? current : height));
}, []);
const handleBackToHome = useCallback(() => {
const handleGoHome = useCallback(() => {
router.replace("/");
}, [router]);
const handleViewChanges = useCallback(() => {
handleCloseMenu();
if (id) {
router.push(`/git-diff?agentId=${id}`);
}
}, [id, router, handleCloseMenu]);
const handleBrowseFiles = useCallback(() => {
handleCloseMenu();
if (id) {
router.push(`/file-explorer?agentId=${id}`);
}
}, [handleCloseMenu, id, router]);
const handleRefreshAgent = useCallback(() => {
if (!id) {
return;
}
handleCloseMenu();
refreshAgent({ agentId: id });
}, [handleCloseMenu, id, refreshAgent]);
const handleCreateNewAgent = useCallback(() => {
if (!agent) {
return;
}
handleCloseMenu();
setCreateAgentInitialValues({
workingDir: agent.cwd,
provider: agent.provider,
modeId: agent.currentModeId,
model: agentModel ?? undefined,
});
setShowCreateAgentModal(true);
}, [agent, agentModel, handleCloseMenu]);
const handleCloseCreateAgentModal = useCallback(() => {
setShowCreateAgentModal(false);
}, []);
const createAgentModal = (
<CreateAgentModal
isVisible={showCreateAgentModal}
onClose={handleCloseCreateAgentModal}
initialValues={createAgentInitialValues}
/>
const handleSelectMatch = useCallback(
(match: AgentMatch) => {
router.replace({
pathname: "/agent/[serverId]/[agentId]",
params: {
serverId: match.serverId,
agentId: match.agent.id,
},
});
},
[router]
);
let body: ReactNode = null;
if (!agent) {
return (
<>
<View style={styles.container}>
<BackHeader onBack={handleBackToHome} />
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
if (!agentId) {
body = (
<View style={styles.centerState}>
<Text style={styles.title}>Missing agent</Text>
<Text style={styles.subtitle}>
This link is missing an agent id. Go back to the Agents screen to pick one.
</Text>
<Pressable style={styles.primaryButton} onPress={handleGoHome}>
<Text style={styles.primaryButtonText}>Go Home</Text>
</Pressable>
</View>
);
} else if (!hasSessions || isRedirecting) {
body = (
<View style={styles.centerState}>
<ActivityIndicator size="large" color={theme.colors.primary} />
<Text style={styles.subtitle}>
{isRedirecting ? "Opening your agent..." : "Looking for your agent..."}
</Text>
</View>
);
} else if (matches.length === 0) {
body = (
<View style={styles.centerState}>
<Text style={styles.title}>Agent not found</Text>
<Text style={styles.subtitle}>
We couldn't find "{agentId}" on any connected daemon. Make sure the daemon is online
or reopen the agent from the Home screen after it reconnects.
</Text>
<Pressable style={styles.primaryButton} onPress={handleGoHome}>
<Text style={styles.primaryButtonText}>Go Home</Text>
</Pressable>
</View>
);
} else if (matches.length > 1) {
body = (
<View style={styles.centerState}>
<Text style={styles.title}>Pick a daemon</Text>
<Text style={styles.subtitle}>
Multiple daemons have an agent with this id. Choose the one you intended to open.
</Text>
<View style={styles.matchList}>
{matches.map((match) => (
<Pressable
key={`${match.serverId}:${match.agent.id}`}
style={styles.matchButton}
onPress={() => handleSelectMatch(match)}
>
<Text style={styles.matchLabel}>{match.serverLabel}</Text>
<Text style={styles.matchMeta} numberOfLines={1}>
{match.agent.cwd}
</Text>
</Pressable>
))}
</View>
{createAgentModal}
</>
</View>
);
}
return (
<>
<View style={styles.container}>
{/* Header */}
<BackHeader
title={agent.title || "Agent"}
onBack={handleBackToHome}
rightContent={
<View ref={menuButtonRef} collapsable={false}>
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
<MoreVertical size={20} color={theme.colors.foreground} />
</Pressable>
</View>
}
/>
{/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}>
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
{isInitializing ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={theme.colors.primary} />
<Text style={styles.loadingText}>Loading agent...</Text>
</View>
) : (
<AgentStreamView
agentId={id!}
agent={agent}
streamItems={streamItems}
pendingPermissions={agentPermissions}
onPermissionResponse={(agentId, requestId, response) =>
respondToPermission(agentId, requestId, response)
}
/>
)}
</ReanimatedAnimated.View>
</View>
{/* Dropdown Menu */}
<Modal
visible={menuVisible}
animationType="fade"
transparent={true}
onRequestClose={handleCloseMenu}
>
<View style={styles.menuOverlay}>
<Pressable style={styles.menuBackdrop} onPress={handleCloseMenu} />
<View
style={[
styles.dropdownMenu,
{
position: "absolute",
top: menuPosition.top,
left: menuPosition.left,
width: DROPDOWN_WIDTH,
},
]}
onLayout={handleMenuLayout}
>
<View style={styles.menuMetaContainer}>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Directory</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={2}
ellipsizeMode="middle"
>
{agent.cwd}
</Text>
</View>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Model</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={1}
ellipsizeMode="middle"
>
{modelDisplayValue}
</Text>
</View>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Branch</Text>
<View style={styles.menuMetaValueRow}>
{branchStatus === "loading" ? (
<>
<ActivityIndicator
size="small"
color={theme.colors.mutedForeground}
/>
<Text style={styles.menuMetaPendingText}>Fetching</Text>
</>
) : (
<Text
style={[
styles.menuMetaValue,
branchStatus === "error" ? styles.menuMetaValueError : null,
]}
numberOfLines={1}
ellipsizeMode="middle"
>
{branchDisplayValue}
</Text>
)}
</View>
</View>
</View>
<View style={styles.menuDivider} />
<Pressable onPress={handleViewChanges} style={styles.menuItem}>
<GitBranch size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>View Changes</Text>
</Pressable>
<Pressable onPress={handleBrowseFiles} style={styles.menuItem}>
<Folder size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>Browse Files</Text>
</Pressable>
<Pressable onPress={handleCreateNewAgent} style={styles.menuItem}>
<PlusCircle size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>New Agent</Text>
</Pressable>
<Pressable
onPress={handleRefreshAgent}
style={[
styles.menuItem,
isInitializing ? styles.menuItemDisabled : null,
]}
disabled={isInitializing}
>
<RotateCcw size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>
{isInitializing ? "Refreshing..." : "Refresh"}
</Text>
{isInitializing && (
<ActivityIndicator
size="small"
color={theme.colors.primary}
style={styles.menuItemSpinner}
/>
)}
</Pressable>
</View>
</View>
</Modal>
</View>
{createAgentModal}
</>
<View style={styles.container}>
<BackHeader title="Agent" onBack={handleGoHome} />
<View style={styles.content}>{body}</View>
</View>
);
}
@@ -563,103 +151,59 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.background,
},
contentContainer: {
flex: 1,
overflow: "hidden",
},
content: {
flex: 1,
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[8],
},
loadingContainer: {
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 16,
},
loadingText: {
fontSize: theme.fontSize.base,
color: theme.colors.mutedForeground,
},
errorContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[4],
},
errorText: {
fontSize: theme.fontSize.lg,
color: theme.colors.mutedForeground,
},
menuButton: {
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
menuOverlay: {
flex: 1,
},
menuBackdrop: {
...StyleSheet.absoluteFillObject,
},
dropdownMenu: {
backgroundColor: theme.colors.popover,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[2],
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 5,
},
menuMetaContainer: {
gap: theme.spacing[2],
marginBottom: theme.spacing[2],
},
menuMetaRow: {
gap: theme.spacing[1],
},
menuMetaLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.mutedForeground,
letterSpacing: 0.5,
textTransform: "uppercase",
},
menuMetaValue: {
fontSize: theme.fontSize.sm,
title: {
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
menuMetaValueRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
menuMetaPendingText: {
subtitle: {
fontSize: theme.fontSize.sm,
color: theme.colors.mutedForeground,
textAlign: "center",
},
menuMetaValueError: {
color: theme.colors.destructive,
},
menuDivider: {
height: StyleSheet.hairlineWidth,
backgroundColor: theme.colors.border,
marginVertical: theme.spacing[2],
},
menuItem: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
primaryButton: {
marginTop: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.primary,
},
menuItemDisabled: {
opacity: 0.6,
primaryButtonText: {
color: theme.colors.primaryForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
menuItemSpinner: {
marginLeft: "auto",
matchList: {
width: "100%",
gap: theme.spacing[3],
},
menuItemText: {
matchButton: {
width: "100%",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.muted,
},
matchLabel: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
matchMeta: {
marginTop: theme.spacing[1],
fontSize: theme.fontSize.sm,
color: theme.colors.mutedForeground,
},
}));

View File

@@ -0,0 +1,769 @@
import { useEffect, useMemo, useRef, useCallback, useState } from "react";
import {
View,
Text,
ActivityIndicator,
Pressable,
Modal,
useWindowDimensions,
LayoutChangeEvent,
} from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MoreVertical, GitBranch, Folder, RotateCcw, PlusCircle, Download } from "lucide-react-native";
import { BackHeader } from "@/components/headers/back-header";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
import { CreateAgentModal, ImportAgentModal, type CreateAgentInitialValues } from "@/components/create-agent-modal";
import type { Agent, SessionContextValue } from "@/contexts/session-context";
import { useFooterControls } from "@/contexts/footer-controls-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
import { useDaemonRequest } from "@/hooks/use-daemon-request";
import type { SessionOutboundMessage } from "@server/server/messages";
const DROPDOWN_WIDTH = 220;
type GitRepoInfoResponseMessage = Extract<
SessionOutboundMessage,
{ type: "git_repo_info_response" }
>;
type BranchStatus = "idle" | "loading" | "ready" | "error";
function extractAgentModel(agent?: Agent | null): string | null {
if (!agent) {
return null;
}
const directModel = typeof agent.model === "string" ? agent.model.trim() : "";
if (directModel.length > 0) {
return directModel;
}
const metadata = agent.persistence?.metadata;
if (!metadata || typeof metadata !== "object") {
return null;
}
const persistedModel = (metadata as Record<string, unknown>).model;
if (typeof persistedModel === "string" && persistedModel.trim().length > 0) {
return persistedModel.trim();
}
const extra = (metadata as Record<string, unknown>).extra;
if (!extra || typeof extra !== "object") {
return null;
}
const getModelFrom = (source: unknown) => {
if (!source || typeof source !== "object") {
return null;
}
const candidate = (source as Record<string, unknown>).model;
return typeof candidate === "string" && candidate.trim().length > 0
? candidate.trim()
: null;
};
return (
getModelFrom((extra as Record<string, unknown>).codex) ??
getModelFrom((extra as Record<string, unknown>).claude)
);
}
export default function AgentScreen() {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const router = useRouter();
const { serverId, agentId } = useLocalSearchParams<{ serverId: string; agentId: string }>();
const resolvedAgentId = typeof agentId === "string" ? agentId : undefined;
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
const handleBackToHome = useCallback(() => {
router.replace("/");
}, [router]);
const { connectionStates, activeDaemonId, setActiveDaemonId } = useDaemonConnections();
let session: SessionContextValue | null = null;
useEffect(() => {
if (!resolvedServerId) {
return;
}
if (activeDaemonId === resolvedServerId) {
return;
}
setActiveDaemonId(resolvedServerId);
}, [resolvedServerId, activeDaemonId, setActiveDaemonId]);
try {
session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true });
} catch (error) {
if (error instanceof DaemonSessionUnavailableError) {
session = null;
} else {
throw error;
}
}
const connectionServerId = resolvedServerId ?? null;
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
const serverLabel = connection?.daemon.label ?? connectionServerId ?? "Selected daemon";
const connectionStatus = connection?.status ?? "idle";
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
const lastConnectionError = connection?.lastError ?? null;
if (!session) {
return (
<AgentSessionUnavailableState
onBack={handleBackToHome}
serverLabel={serverLabel}
connectionStatus={connectionStatus}
connectionStatusLabel={connectionStatusLabel}
lastError={lastConnectionError}
/>
);
}
const {
agents,
agentStreamState,
initializingAgents,
pendingPermissions,
initializeAgent,
refreshAgent,
setFocusedAgentId,
ws,
} = session;
const routeServerId = resolvedServerId ?? session.serverId;
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const [menuVisible, setMenuVisible] = useState(false);
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
const [menuContentHeight, setMenuContentHeight] = useState(0);
const menuButtonRef = useRef<View>(null);
const [showCreateAgentModal, setShowCreateAgentModal] = useState(false);
const [showImportAgentModal, setShowImportAgentModal] = useState(false);
const [createAgentInitialValues, setCreateAgentInitialValues] =
useState<CreateAgentInitialValues | undefined>();
// Keyboard animation
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
useEffect(() => {
bottomInset.value = insets.bottom;
}, [insets.bottom, bottomInset]);
const animatedKeyboardStyle = useAnimatedStyle(() => {
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = Math.max(0, absoluteHeight - bottomInset.value);
return {
transform: [{ translateY: -shift }],
};
});
const agent = resolvedAgentId ? agents.get(resolvedAgentId) : undefined;
const streamItems = resolvedAgentId ? agentStreamState.get(resolvedAgentId) || [] : [];
const agentPermissions = new Map(
Array.from(pendingPermissions.entries()).filter(([_, perm]) => perm.agentId === resolvedAgentId)
);
const agentModel = extractAgentModel(agent);
const modelDisplayValue = agentModel ?? "Unknown";
const gitRepoInfoRequest = useDaemonRequest<
{ cwd: string },
{ cwd: string; currentBranch: string | null },
GitRepoInfoResponseMessage
>({
ws,
responseType: "git_repo_info_response",
buildRequest: ({ params, requestId }) => ({
type: "session",
message: {
type: "git_repo_info_request",
cwd: params?.cwd ?? ".",
requestId,
},
}),
getRequestKey: (params) => params?.cwd ?? "default",
selectData: (message) => ({
cwd: message.payload.cwd,
currentBranch: message.payload.currentBranch ?? null,
}),
extractError: (message) =>
message.payload.error ? new Error(message.payload.error) : null,
keepPreviousData: false,
});
const { execute: fetchGitRepoInfo, reset: resetGitRepoInfo } = gitRepoInfoRequest;
const branchStatus: BranchStatus = !agent?.cwd
? "idle"
: gitRepoInfoRequest.status === "loading"
? "loading"
: gitRepoInfoRequest.status === "error"
? "error"
: gitRepoInfoRequest.status === "success"
? "ready"
: "idle";
const branchLabel = gitRepoInfoRequest.data?.currentBranch ?? null;
const branchError = gitRepoInfoRequest.error?.message ?? null;
const branchDisplayValue =
branchStatus === "error"
? branchError ?? "Unavailable"
: branchLabel ?? "Unknown";
useEffect(() => {
if (!agent?.cwd) {
resetGitRepoInfo();
return;
}
fetchGitRepoInfo({ cwd: agent.cwd }).catch(() => {});
}, [agent?.cwd, fetchGitRepoInfo, resetGitRepoInfo]);
useEffect(() => {
if (!resolvedAgentId) {
setFocusedAgentId(null);
return;
}
setFocusedAgentId(resolvedAgentId);
return () => {
setFocusedAgentId(null);
};
}, [resolvedAgentId, setFocusedAgentId]);
const hasStreamState = resolvedAgentId ? agentStreamState.has(resolvedAgentId) : false;
const initializationState = resolvedAgentId ? initializingAgents.get(resolvedAgentId) : undefined;
const isInitializing = resolvedAgentId
? initializationState !== undefined
? initializationState
: !hasStreamState
: false;
useEffect(() => {
if (!resolvedAgentId) {
return;
}
if (initializationState !== undefined) {
return;
}
if (hasStreamState) {
return;
}
initializeAgent({ agentId: resolvedAgentId });
}, [resolvedAgentId, initializeAgent, initializationState, hasStreamState]);
const agentControls = useMemo(() => {
if (!resolvedAgentId) return null;
return <AgentInputArea agentId={resolvedAgentId} serverId={routeServerId} />;
}, [resolvedAgentId, routeServerId]);
useEffect(() => {
if (!agentControls || !agent || isInitializing) {
unregisterFooterControls();
return;
}
registerFooterControls(agentControls);
return () => {
unregisterFooterControls();
};
}, [agentControls, agent, isInitializing, registerFooterControls, unregisterFooterControls]);
const recalculateMenuPosition = useCallback(
(onMeasured?: () => void) => {
requestAnimationFrame(() => {
const anchor = menuButtonRef.current;
if (!anchor) {
if (onMeasured) {
onMeasured();
}
return;
}
anchor.measureInWindow((x, y, width, height) => {
const verticalOffset = 8;
const horizontalMargin = 16;
const desiredLeft = x + width - DROPDOWN_WIDTH;
const maxLeft = windowWidth - DROPDOWN_WIDTH - horizontalMargin;
const clampedLeft = Math.min(Math.max(desiredLeft, horizontalMargin), maxLeft);
// Position menu below button using the raw coordinates from measureInWindow
const buttonBottom = y + height;
const top = buttonBottom + verticalOffset;
// If menu would go off screen, clamp to visible area
const bottomEdge = top + menuContentHeight;
const maxBottom = windowHeight - horizontalMargin;
const clampedTop = bottomEdge > maxBottom
? Math.max(verticalOffset, maxBottom - menuContentHeight)
: top;
setMenuPosition({
top: clampedTop,
left: clampedLeft,
});
if (onMeasured) {
onMeasured();
}
});
});
},
[menuContentHeight, windowHeight, windowWidth]
);
const handleOpenMenu = useCallback(() => {
if (agent?.cwd) {
fetchGitRepoInfo({ cwd: agent.cwd }).catch(() => {});
}
recalculateMenuPosition(() => {
setMenuVisible(true);
});
}, [agent?.cwd, fetchGitRepoInfo, recalculateMenuPosition]);
const handleCloseMenu = useCallback(() => {
setMenuVisible(false);
setMenuContentHeight(0);
}, []);
useEffect(() => {
if (!menuVisible) {
return;
}
recalculateMenuPosition();
}, [menuVisible, recalculateMenuPosition]);
const handleMenuLayout = useCallback((event: LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout;
setMenuContentHeight((current) => (current === height ? current : height));
}, []);
const handleViewChanges = useCallback(() => {
handleCloseMenu();
if (resolvedAgentId) {
router.push({
pathname: "/git-diff",
params: {
agentId: resolvedAgentId,
serverId: routeServerId,
},
});
}
}, [resolvedAgentId, routeServerId, router, handleCloseMenu]);
const handleBrowseFiles = useCallback(() => {
handleCloseMenu();
if (resolvedAgentId) {
router.push({
pathname: "/file-explorer",
params: {
agentId: resolvedAgentId,
serverId: routeServerId,
},
});
}
}, [handleCloseMenu, resolvedAgentId, routeServerId, router]);
const handleRefreshAgent = useCallback(() => {
if (!resolvedAgentId) {
return;
}
handleCloseMenu();
refreshAgent({ agentId: resolvedAgentId });
}, [handleCloseMenu, resolvedAgentId, refreshAgent]);
const handleCreateNewAgent = useCallback(() => {
if (!agent) {
return;
}
handleCloseMenu();
setCreateAgentInitialValues({
workingDir: agent.cwd,
provider: agent.provider,
modeId: agent.currentModeId,
model: agentModel ?? undefined,
});
setShowCreateAgentModal(true);
}, [agent, agentModel, handleCloseMenu]);
const handleCloseCreateAgentModal = useCallback(() => {
setShowCreateAgentModal(false);
}, []);
const handleImportAgent = useCallback(() => {
handleCloseMenu();
setShowImportAgentModal(true);
}, [handleCloseMenu]);
const handleCloseImportAgentModal = useCallback(() => {
setShowImportAgentModal(false);
}, []);
const createAgentModal = (
<CreateAgentModal
isVisible={showCreateAgentModal}
onClose={handleCloseCreateAgentModal}
initialValues={createAgentInitialValues}
serverId={routeServerId}
/>
);
const importAgentModal = (
<ImportAgentModal
isVisible={showImportAgentModal}
onClose={handleCloseImportAgentModal}
serverId={routeServerId}
/>
);
if (!agent) {
return (
<>
<View style={styles.container}>
<BackHeader onBack={handleBackToHome} />
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
</View>
{createAgentModal}
{importAgentModal}
</>
);
}
return (
<>
<View style={styles.container}>
{/* Header */}
<BackHeader
title={agent.title || "Agent"}
onBack={handleBackToHome}
rightContent={
<View ref={menuButtonRef} collapsable={false}>
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
<MoreVertical size={20} color={theme.colors.foreground} />
</Pressable>
</View>
}
/>
{/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}>
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
{isInitializing ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={theme.colors.primary} />
<Text style={styles.loadingText}>Loading agent...</Text>
</View>
) : (
<AgentStreamView
agentId={agent.id}
serverId={routeServerId}
agent={agent}
streamItems={streamItems}
pendingPermissions={agentPermissions}
/>
)}
</ReanimatedAnimated.View>
</View>
{/* Dropdown Menu */}
<Modal
visible={menuVisible}
animationType="fade"
transparent={true}
onRequestClose={handleCloseMenu}
>
<View style={styles.menuOverlay}>
<Pressable style={styles.menuBackdrop} onPress={handleCloseMenu} />
<View
style={[
styles.dropdownMenu,
{
position: "absolute",
top: menuPosition.top,
left: menuPosition.left,
width: DROPDOWN_WIDTH,
},
]}
onLayout={handleMenuLayout}
>
<View style={styles.menuMetaContainer}>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Directory</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={2}
ellipsizeMode="middle"
>
{agent.cwd}
</Text>
</View>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Model</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={1}
ellipsizeMode="middle"
>
{modelDisplayValue}
</Text>
</View>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Branch</Text>
<View style={styles.menuMetaValueRow}>
{branchStatus === "loading" ? (
<>
<ActivityIndicator
size="small"
color={theme.colors.mutedForeground}
/>
<Text style={styles.menuMetaPendingText}>Fetching</Text>
</>
) : (
<Text
style={[
styles.menuMetaValue,
branchStatus === "error" ? styles.menuMetaValueError : null,
]}
numberOfLines={1}
ellipsizeMode="middle"
>
{branchDisplayValue}
</Text>
)}
</View>
</View>
</View>
<View style={styles.menuDivider} />
<Pressable onPress={handleViewChanges} style={styles.menuItem}>
<GitBranch size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>View Changes</Text>
</Pressable>
<Pressable onPress={handleBrowseFiles} style={styles.menuItem}>
<Folder size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>Browse Files</Text>
</Pressable>
<Pressable onPress={handleImportAgent} style={styles.menuItem}>
<Download size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>Import Agent</Text>
</Pressable>
<Pressable onPress={handleCreateNewAgent} style={styles.menuItem}>
<PlusCircle size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>New Agent</Text>
</Pressable>
<Pressable
onPress={handleRefreshAgent}
style={[
styles.menuItem,
isInitializing ? styles.menuItemDisabled : null,
]}
disabled={isInitializing}
>
<RotateCcw size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>
{isInitializing ? "Refreshing..." : "Refresh"}
</Text>
{isInitializing && (
<ActivityIndicator
size="small"
color={theme.colors.primary}
style={styles.menuItemSpinner}
/>
)}
</Pressable>
</View>
</View>
</Modal>
</View>
{createAgentModal}
{importAgentModal}
</>
);
}
function AgentSessionUnavailableState({
onBack,
serverLabel,
connectionStatus,
connectionStatusLabel,
lastError,
}: {
onBack: () => void;
serverLabel: string;
connectionStatus: ConnectionStatus;
connectionStatusLabel: string;
lastError: string | null;
}) {
const isConnecting = connectionStatus === "connecting";
return (
<View style={styles.container}>
<BackHeader title="Agent" onBack={onBack} />
<View style={styles.centerState}>
{isConnecting ? (
<>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>Connecting to {serverLabel}...</Text>
<Text style={styles.statusText}>We'll show this agent once the daemon is online.</Text>
</>
) : (
<>
<Text style={styles.errorText}>
Can't open this agent while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
</Text>
<Text style={styles.statusText}>
Connect this daemon or switch to another one to continue.
</Text>
{lastError ? <Text style={styles.errorDetails}>{lastError}</Text> : null}
</>
)}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
contentContainer: {
flex: 1,
overflow: "hidden",
},
content: {
flex: 1,
},
loadingContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 16,
},
loadingText: {
fontSize: theme.fontSize.base,
color: theme.colors.mutedForeground,
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[6],
gap: theme.spacing[3],
},
errorContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
errorText: {
fontSize: theme.fontSize.lg,
color: theme.colors.mutedForeground,
textAlign: "center",
},
statusText: {
marginTop: theme.spacing[2],
textAlign: "center",
fontSize: theme.fontSize.sm,
color: theme.colors.mutedForeground,
},
errorDetails: {
marginTop: theme.spacing[1],
textAlign: "center",
fontSize: theme.fontSize.xs,
color: theme.colors.mutedForeground,
},
menuButton: {
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
menuOverlay: {
flex: 1,
},
menuBackdrop: {
...StyleSheet.absoluteFillObject,
},
dropdownMenu: {
backgroundColor: theme.colors.popover,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[2],
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 5,
},
menuMetaContainer: {
gap: theme.spacing[2],
marginBottom: theme.spacing[2],
},
menuMetaRow: {
gap: theme.spacing[1],
},
menuMetaLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.mutedForeground,
letterSpacing: 0.5,
textTransform: "uppercase",
},
menuMetaValue: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
menuMetaValueRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
menuMetaPendingText: {
fontSize: theme.fontSize.sm,
color: theme.colors.mutedForeground,
},
menuMetaValueError: {
color: theme.colors.destructive,
},
menuDivider: {
height: StyleSheet.hairlineWidth,
backgroundColor: theme.colors.border,
marginVertical: theme.spacing[2],
},
menuItem: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
menuItemDisabled: {
opacity: 0.6,
},
menuItemSpinner: {
marginLeft: "auto",
},
menuItemText: {
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
}));

View File

@@ -29,26 +29,104 @@ import {
X,
} from "lucide-react-native";
import { BackHeader } from "@/components/headers/back-header";
import { useSession, type ExplorerEntry } from "@/contexts/session-context";
import type { ExplorerEntry, SessionContextValue } from "@/contexts/session-context";
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
export default function FileExplorerScreen() {
const { theme } = useUnistyles();
const {
agentId,
path: pathParamRaw,
file: fileParamRaw,
serverId,
} = useLocalSearchParams<{
agentId: string;
path?: string | string[];
file?: string | string[];
serverId?: string;
}>();
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
const { connectionStates } = useDaemonConnections();
let session: SessionContextValue | null = null;
try {
session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true });
} catch (error) {
if (error instanceof DaemonSessionUnavailableError) {
session = null;
} else {
throw error;
}
}
const connectionServerId = resolvedServerId ?? null;
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
const serverLabel = connection?.daemon.label ?? connectionServerId ?? session?.serverId ?? "Selected daemon";
const connectionStatus = connection?.status ?? "idle";
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
const lastError = connection?.lastError ?? null;
if (!session) {
return (
<FileExplorerSessionUnavailable
agentId={agentId}
serverId={resolvedServerId}
serverLabel={serverLabel}
connectionStatus={connectionStatus}
connectionStatusLabel={connectionStatusLabel}
lastError={lastError}
/>
);
}
const routeServerId = resolvedServerId ?? session.serverId;
return (
<FileExplorerContent
session={session}
agentId={agentId}
pathParamRaw={pathParamRaw}
fileParamRaw={fileParamRaw}
routeServerId={routeServerId}
/>
);
}
type FileExplorerContentProps = {
session: SessionContextValue;
agentId?: string;
pathParamRaw?: string | string[];
fileParamRaw?: string | string[];
routeServerId: string;
};
type FileExplorerSessionUnavailableProps = {
agentId?: string;
serverId?: string;
serverLabel: string;
connectionStatus: ConnectionStatus;
connectionStatusLabel: string;
lastError: string | null;
};
function FileExplorerContent({
session,
agentId,
pathParamRaw,
fileParamRaw,
routeServerId,
}: FileExplorerContentProps) {
const { theme } = useUnistyles();
const {
agents,
fileExplorer,
requestDirectoryListing,
requestFilePreview,
navigateExplorerBack,
} = useSession();
} = session;
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const [selectedEntryPath, setSelectedEntryPath] = useState<string | null>(null);
const pendingPathParamRef = useRef<string | null>(null);
@@ -258,12 +336,15 @@ export default function FileExplorerScreen() {
const handleCloseExplorer = useCallback(() => {
if (agentId) {
router.replace({ pathname: "/agent/[id]", params: { id: agentId } });
router.replace({
pathname: "/agent/[serverId]/[agentId]",
params: { serverId: routeServerId, agentId },
});
return;
}
router.back();
}, [agentId]);
}, [agentId, routeServerId]);
const handleBackNavigation = useCallback(() => {
if (!agentId) {
@@ -576,6 +657,61 @@ export default function FileExplorerScreen() {
);
}
function FileExplorerSessionUnavailable({
agentId,
serverId,
serverLabel,
connectionStatus,
connectionStatusLabel,
lastError,
}: FileExplorerSessionUnavailableProps) {
const { theme } = useUnistyles();
const handleClose = useCallback(() => {
if (agentId && serverId) {
router.replace({
pathname: "/agent/[serverId]/[agentId]",
params: { serverId, agentId },
});
return;
}
router.back();
}, [agentId, serverId]);
const isConnecting = connectionStatus === "connecting";
return (
<View style={styles.container}>
<BackHeader
title="Files"
onBack={handleClose}
rightContent={
<Pressable style={styles.closeButton} onPress={handleClose}>
<X size={18} color={theme.colors.foreground} />
</Pressable>
}
/>
<View style={styles.centerState}>
{isConnecting ? (
<>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Connecting to {serverLabel}...</Text>
<Text style={styles.statusText}>We'll load files once this daemon is online.</Text>
</>
) : (
<>
<Text style={styles.errorText}>
Can't open files while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
</Text>
<Text style={styles.statusText}>Connect this daemon and try again.</Text>
{lastError ? <Text style={styles.errorDetails}>{lastError}</Text> : null}
</>
)}
</View>
</View>
);
}
function ViewToggle({
viewMode,
onChange,
@@ -828,6 +964,16 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
textAlign: "center",
},
statusText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
errorDetails: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
textAlign: "center",
},
emptyText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.base,

View File

@@ -3,7 +3,11 @@ import { View, Text, ScrollView, ActivityIndicator } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { StyleSheet } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { useSession } from "@/contexts/session-context";
import type { SessionContextValue } from "@/contexts/session-context";
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
interface ParsedDiffFile {
path: string;
@@ -51,8 +55,55 @@ function parseDiff(diffText: string): ParsedDiffFile[] {
}
export default function GitDiffScreen() {
const { agentId } = useLocalSearchParams<{ agentId: string }>();
const { agents, gitDiffs, requestGitDiff } = useSession();
const { agentId, serverId } = useLocalSearchParams<{ agentId: string; serverId?: string }>();
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
const { connectionStates } = useDaemonConnections();
let session: SessionContextValue | null = null;
try {
session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true });
} catch (error) {
if (error instanceof DaemonSessionUnavailableError) {
session = null;
} else {
throw error;
}
}
const connectionServerId = resolvedServerId ?? null;
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
const serverLabel = connection?.daemon.label ?? connectionServerId ?? session?.serverId ?? "Active daemon";
const connectionStatus = connection?.status ?? "idle";
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
const lastError = connection?.lastError ?? null;
if (!session) {
return (
<SessionUnavailableState
serverLabel={serverLabel}
connectionStatus={connectionStatus}
connectionStatusLabel={connectionStatusLabel}
lastError={lastError}
/>
);
}
const routeServerId = resolvedServerId ?? session.serverId;
return <GitDiffContent session={session} agentId={agentId} routeServerId={routeServerId} />;
}
function GitDiffContent({
session,
agentId,
routeServerId,
}: {
session: SessionContextValue;
agentId?: string;
routeServerId: string;
}) {
const { agents, gitDiffs, requestGitDiff } = session;
const [isLoading, setIsLoading] = useState(true);
const agent = agentId ? agents.get(agentId) : undefined;
@@ -64,7 +115,6 @@ export default function GitDiffScreen() {
return;
}
// Always request fresh diff when screen mounts
setIsLoading(true);
requestGitDiff(agentId);
@@ -85,6 +135,7 @@ export default function GitDiffScreen() {
return (
<View style={styles.container}>
<BackHeader title="Changes" />
<Text style={styles.metaText}>Server: {routeServerId}</Text>
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
@@ -99,6 +150,7 @@ export default function GitDiffScreen() {
return (
<View style={styles.container}>
<BackHeader title="Changes" />
<Text style={styles.metaText}>Server: {routeServerId}</Text>
<ScrollView style={styles.scrollView} contentContainerStyle={styles.contentContainer}>
{isLoading ? (
@@ -156,11 +208,55 @@ export default function GitDiffScreen() {
);
}
function SessionUnavailableState({
serverLabel,
connectionStatus,
connectionStatusLabel,
lastError,
}: {
serverLabel: string;
connectionStatus: ConnectionStatus;
connectionStatusLabel: string;
lastError: string | null;
}) {
const isConnecting = connectionStatus === "connecting";
return (
<View style={styles.container}>
<BackHeader title="Changes" />
<Text style={styles.metaText}>Server: {serverLabel}</Text>
<View style={styles.errorContainer}>
{isConnecting ? (
<>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>Connecting to {serverLabel}...</Text>
<Text style={styles.statusText}>We'll show changes once this session is online.</Text>
</>
) : (
<>
<Text style={styles.errorText}>
Can't load changes while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
</Text>
<Text style={styles.statusText}>Connect this daemon or switch to another one to continue.</Text>
{lastError ? <Text style={styles.errorDetails}>{lastError}</Text> : null}
</>
)}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
metaText: {
paddingHorizontal: theme.spacing[6],
marginBottom: theme.spacing[2],
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
},
scrollView: {
flex: 1,
},
@@ -191,6 +287,18 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.destructive,
textAlign: "center",
},
statusText: {
marginTop: theme.spacing[3],
textAlign: "center",
fontSize: theme.fontSize.sm,
color: theme.colors.mutedForeground,
},
errorDetails: {
marginTop: theme.spacing[2],
textAlign: "center",
fontSize: theme.fontSize.xs,
color: theme.colors.mutedForeground,
},
emptyContainer: {
flex: 1,
alignItems: "center",

View File

@@ -1,5 +1,5 @@
import { View } from "react-native";
import { useState, useCallback } from "react";
import { View, Text } from "react-native";
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated";
@@ -9,15 +9,29 @@ import { EmptyState } from "@/components/empty-state";
import { AgentList } from "@/components/agent-list";
import { CreateAgentModal, ImportAgentModal } from "@/components/create-agent-modal";
import { useSession } from "@/contexts/session-context";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus, getConnectionStatusTone, type ConnectionStatusTone } from "@/utils/daemons";
import { useLocalSearchParams } from "expo-router";
export default function HomeScreen() {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const { agents } = useSession();
const { activeDaemonId, connectionStates } = useDaemonConnections();
const aggregatedAgents = useAggregatedAgents(agents);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [createModalMounted, setCreateModalMounted] = useState(false);
const [importModalMounted, setImportModalMounted] = useState(false);
const [pendingImportServerId, setPendingImportServerId] = useState<string | null>(null);
const { modal, flow, action, serverId: serverIdParam } = useLocalSearchParams<{
modal?: string;
flow?: string;
action?: string;
serverId?: string;
}>();
const deepLinkHandledRef = useRef<string | null>(null);
// Keyboard animation
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
@@ -30,17 +44,38 @@ export default function HomeScreen() {
};
});
const hasAgents = agents.size > 0;
const aggregatedCount = aggregatedAgents.reduce((count, group) => count + group.agents.length, 0);
const hasAgents = aggregatedCount > 0;
const connectionIssues = useMemo(() => {
return Array.from(connectionStates.values()).filter(
(entry) => entry.status === "connecting" || entry.status === "offline" || entry.status === "error"
);
}, [connectionStates]);
const statusColors: Record<ConnectionStatusTone, string> = {
success: theme.colors.palette.green[400],
warning: theme.colors.palette.amber[500],
error: theme.colors.destructive,
muted: theme.colors.mutedForeground,
};
const handleCreateAgent = useCallback(() => {
setCreateModalMounted(true);
setShowCreateModal(true);
}, []);
const openImportModal = useCallback(
(serverIdOverride?: string | null) => {
const resolvedServerId = serverIdOverride ?? activeDaemonId ?? null;
setPendingImportServerId(resolvedServerId);
setImportModalMounted(true);
setShowImportModal(true);
},
[activeDaemonId]
);
const handleImportAgent = useCallback(() => {
setImportModalMounted(true);
setShowImportModal(true);
}, []);
openImportModal();
}, [openImportModal]);
const handleCloseCreateModal = useCallback(() => {
setShowCreateModal(false);
@@ -48,8 +83,40 @@ export default function HomeScreen() {
const handleCloseImportModal = useCallback(() => {
setShowImportModal(false);
setPendingImportServerId(null);
}, []);
const wantsImportDeepLink = useMemo(() => {
const values = [modal, flow, action];
return values.some(
(value) => typeof value === "string" && value.trim().toLowerCase() === "import"
);
}, [action, flow, modal]);
const deepLinkServerId = typeof serverIdParam === "string" ? serverIdParam : null;
const deepLinkKey = useMemo(() => {
if (!wantsImportDeepLink) {
return null;
}
return JSON.stringify({
action: action ?? null,
flow: flow ?? null,
modal: modal ?? null,
serverId: deepLinkServerId,
});
}, [action, flow, modal, deepLinkServerId, wantsImportDeepLink]);
useEffect(() => {
if (!wantsImportDeepLink || !deepLinkKey) {
deepLinkHandledRef.current = null;
return;
}
if (deepLinkHandledRef.current === deepLinkKey) {
return;
}
deepLinkHandledRef.current = deepLinkKey;
openImportModal(deepLinkServerId);
}, [deepLinkKey, deepLinkServerId, openImportModal, wantsImportDeepLink]);
return (
<View style={styles.container}>
{/* Header */}
@@ -58,21 +125,56 @@ export default function HomeScreen() {
onImportAgent={handleImportAgent}
/>
{connectionIssues.length > 0 ? (
<View style={styles.connectionBanner}>
{connectionIssues.map((entry) => {
const tone = getConnectionStatusTone(entry.status);
const statusColor = statusColors[tone];
return (
<View key={entry.daemon.id} style={styles.connectionRow}>
<View style={styles.connectionHeader}>
<View style={[styles.connectionDot, { backgroundColor: statusColor }]} />
<Text style={styles.connectionLabel}>
{entry.daemon.label} · {formatConnectionStatus(entry.status)}
</Text>
</View>
{entry.lastError ? (
<Text style={styles.connectionError} numberOfLines={2}>
{entry.lastError}
</Text>
) : null}
</View>
);
})}
</View>
) : null}
{/* Content Area with Keyboard Animation */}
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
{hasAgents ? (
<AgentList agents={agents} />
<AgentList agentGroups={aggregatedAgents} />
) : (
<EmptyState onCreateAgent={handleCreateAgent} />
<EmptyState
onCreateAgent={handleCreateAgent}
onImportAgent={handleImportAgent}
/>
)}
</ReanimatedAnimated.View>
{/* Create Agent Modal */}
{createModalMounted ? (
<CreateAgentModal isVisible={showCreateModal} onClose={handleCloseCreateModal} />
<CreateAgentModal
isVisible={showCreateModal}
onClose={handleCloseCreateModal}
serverId={activeDaemonId}
/>
) : null}
{importModalMounted ? (
<ImportAgentModal isVisible={showImportModal} onClose={handleCloseImportModal} />
<ImportAgentModal
isVisible={showImportModal}
onClose={handleCloseImportModal}
serverId={pendingImportServerId ?? activeDaemonId}
/>
) : null}
</View>
);
@@ -83,6 +185,39 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.background,
},
connectionBanner: {
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[4],
gap: theme.spacing[2],
},
connectionRow: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
gap: theme.spacing[1],
},
connectionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
connectionDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
connectionLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
connectionError: {
color: theme.colors.destructive,
fontSize: theme.fontSize.xs,
},
content: {
flex: 1,
},

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { MutableRefObject } from "react";
import {
View,
Text,
@@ -11,11 +12,14 @@ import {
Platform,
} from "react-native";
import { router } from "expo-router";
import { StyleSheet } from "react-native-unistyles";
import { useSettings } from "@/hooks/use-settings";
import { useSession } from "@/contexts/session-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useAppSettings } from "@/hooks/use-settings";
import { useDaemonRegistry, type DaemonProfile } from "@/contexts/daemon-registry-context";
import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { theme as defaultTheme } from "@/styles/theme";
import { BackHeader } from "@/components/headers/back-header";
import { useSessionForServer } from "@/hooks/use-session-directory";
const delay = (ms: number) =>
new Promise<void>((resolve) => {
@@ -67,6 +71,9 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
},
inputDisabled: {
opacity: theme.opacity[50],
},
helperText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
@@ -115,11 +122,23 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[4],
marginBottom: theme.spacing[3],
},
daemonCard: {
gap: theme.spacing[2],
},
daemonCardActive: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.palette.blue[500],
},
settingRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
daemonHeaderRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
settingContent: {
flex: 1,
},
@@ -132,6 +151,87 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
connectionStatusBadge: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
connectionStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
connectionStatusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
connectionErrorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
daemonActionsRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
daemonActionButton: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
daemonActionText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
daemonActionDestructive: {
borderColor: theme.colors.destructive,
},
daemonActionDestructiveText: {
color: theme.colors.destructive,
},
daemonFormActionsRow: {
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
marginTop: theme.spacing[3],
},
daemonActionPrimary: {
backgroundColor: theme.colors.palette.blue[500],
borderColor: theme.colors.palette.blue[500],
},
daemonActionPrimaryText: {
color: theme.colors.palette.white,
},
daemonActionDisabled: {
opacity: theme.opacity[50],
},
testResultSuccessText: {
color: theme.colors.palette.green[400],
fontSize: theme.fontSize.xs,
},
testResultErrorText: {
color: theme.colors.palette.red[200],
fontSize: theme.fontSize.xs,
},
testResultInfoText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
},
addButton: {
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
},
addButtonText: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
},
themeCardDisabled: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
@@ -202,24 +302,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
restartButton: {
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
marginTop: theme.spacing[3],
backgroundColor: theme.colors.destructive,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
restartButtonDisabled: {
opacity: theme.opacity[50],
},
restartButtonText: {
color: theme.colors.destructiveForeground,
textAlign: "center",
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
footer: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
@@ -238,11 +320,20 @@ const styles = StyleSheet.create((theme) => ({
},
}));
export default function SettingsScreen() {
const { settings, isLoading, updateSettings, resetSettings } = useSettings();
const { restartServer, ws } = useSession();
type DaemonTestState = {
status: "idle" | "testing" | "success" | "error";
message?: string;
};
const [serverUrl, setServerUrl] = useState(settings.serverUrl);
export default function SettingsScreen() {
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
const { daemons, isLoading: daemonLoading, addDaemon, updateDaemon, removeDaemon, setDefaultDaemon } = useDaemonRegistry();
const { activeDaemon, activeDaemonId, setActiveDaemonId, connectionStates, updateConnectionStatus } = useDaemonConnections();
const activeDaemonSession = useSessionForServer(activeDaemonId ?? null);
const activeDaemonConnection = activeDaemonId ? connectionStates.get(activeDaemonId) : null;
const activeDaemonStatusLabel = activeDaemonConnection ? formatConnectionStatus(activeDaemonConnection.status) : null;
const [serverUrl, setServerUrl] = useState(activeDaemon?.wsUrl ?? "");
const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker);
const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn);
const [theme, setTheme] = useState<"dark" | "light" | "auto">(settings.theme);
@@ -252,9 +343,24 @@ export default function SettingsScreen() {
success: boolean;
message: string;
} | null>(null);
const [isRestarting, setIsRestarting] = useState(false);
const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false);
const [daemonForm, setDaemonForm] = useState<{ id: string | null; label: string; wsUrl: string; autoConnect: boolean }>(
{ id: null, label: "", wsUrl: "", autoConnect: true }
);
const [isSavingDaemon, setIsSavingDaemon] = useState(false);
const [daemonTestStates, setDaemonTestStates] = useState<Map<string, DaemonTestState>>(() => new Map());
const isLoading = settingsLoading || daemonLoading;
const baselineServerUrl = activeDaemon?.wsUrl ?? "";
const isMountedRef = useRef(true);
const wsIsConnectedRef = useRef(ws.isConnected);
const isServerConfigLocked = Boolean(activeDaemon && !activeDaemonSession);
const serverDescriptionText = activeDaemon
? `${activeDaemon.label}${activeDaemonStatusLabel ? ` - ${activeDaemonStatusLabel}` : ""}${
isServerConfigLocked ? " - Session unavailable" : ""
}`
: "No active daemon selected.";
const serverHelperText = isServerConfigLocked
? `Connect to ${activeDaemon?.label ?? "this daemon"} to edit its server URL.`
: "Must be a valid WebSocket URL (ws:// or wss://)";
useEffect(() => {
return () => {
@@ -262,10 +368,6 @@ export default function SettingsScreen() {
};
}, []);
useEffect(() => {
wsIsConnectedRef.current = ws.isConnected;
}, [ws.isConnected]);
const waitForCondition = useCallback(
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
const deadline = Date.now() + timeoutMs;
@@ -336,71 +438,147 @@ export default function SettingsScreen() {
});
}, []);
const waitForServerRestart = useCallback(async () => {
const maxAttempts = 12;
const retryDelayMs = 2500;
const disconnectTimeoutMs = 7000;
const reconnectTimeoutMs = 10000;
const handleOpenDaemonForm = useCallback((profile?: DaemonProfile) => {
if (profile) {
setDaemonForm({
id: profile.id,
label: profile.label,
wsUrl: profile.wsUrl,
autoConnect: profile.autoConnect,
});
} else {
setDaemonForm({ id: null, label: "", wsUrl: "", autoConnect: true });
}
setIsDaemonFormVisible(true);
}, []);
if (wsIsConnectedRef.current) {
await waitForCondition(() => !wsIsConnectedRef.current, disconnectTimeoutMs);
const handleCloseDaemonForm = useCallback(() => {
setIsDaemonFormVisible(false);
setDaemonForm({ id: null, label: "", wsUrl: "", autoConnect: true });
}, []);
const handleSubmitDaemonForm = useCallback(async () => {
if (!daemonForm.label.trim()) {
Alert.alert("Label required", "Please enter a label for the daemon.");
return;
}
if (!validateServerUrl(daemonForm.wsUrl)) {
Alert.alert("Invalid URL", "Daemon URL must be ws:// or wss://");
return;
}
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await testServerConnection(settings.serverUrl);
const reconnected = await waitForCondition(
() => wsIsConnectedRef.current,
reconnectTimeoutMs
);
if (isMountedRef.current) {
setIsRestarting(false);
if (!reconnected) {
Alert.alert(
"Server reachable",
"The server came back online but the app has not reconnected yet."
);
}
}
return;
} catch (error) {
console.warn(
`[Settings] Restart poll attempt ${attempt}/${maxAttempts} failed`,
error
);
if (attempt === maxAttempts) {
if (isMountedRef.current) {
setIsRestarting(false);
Alert.alert(
"Unable to reconnect",
"The server did not come back online. Please verify the daemon restarted."
);
}
return;
}
await delay(retryDelayMs);
try {
setIsSavingDaemon(true);
const payload = {
label: daemonForm.label.trim(),
wsUrl: daemonForm.wsUrl.trim(),
autoConnect: daemonForm.autoConnect,
};
if (daemonForm.id) {
await updateDaemon(daemonForm.id, payload);
setActiveDaemonId(daemonForm.id);
} else {
const created = await addDaemon(payload);
setActiveDaemonId(created.id);
}
handleCloseDaemonForm();
} catch (error) {
console.error("[Settings] Failed to save daemon", error);
Alert.alert("Error", "Unable to save daemon");
} finally {
setIsSavingDaemon(false);
}
}, [settings.serverUrl, testServerConnection, waitForCondition]);
}, [daemonForm, addDaemon, updateDaemon, handleCloseDaemonForm, setActiveDaemonId]);
const handleRemoveDaemon = useCallback(
(profile: DaemonProfile) => {
Alert.alert(
"Remove Daemon",
`Remove ${profile.label}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Remove",
style: "destructive",
onPress: async () => {
try {
await removeDaemon(profile.id);
} catch (error) {
console.error("[Settings] Failed to remove daemon", error);
Alert.alert("Error", "Unable to remove daemon");
}
},
},
]
);
},
[removeDaemon]
);
const handleSetDefaultDaemon = useCallback(
(profile: DaemonProfile) => {
void setDefaultDaemon(profile.id);
},
[setDefaultDaemon]
);
const handleSetActiveDaemon = useCallback(
(profile: DaemonProfile) => {
setActiveDaemonId(profile.id);
},
[setActiveDaemonId]
);
const updateDaemonTestState = useCallback((daemonId: string, state: { status: "idle" | "testing" | "success" | "error"; message?: string }) => {
setDaemonTestStates((prev) => {
const next = new Map(prev);
next.set(daemonId, state);
return next;
});
}, []);
const handleTestDaemonConnection = useCallback(
async (profile: DaemonProfile) => {
const url = profile.wsUrl;
if (!validateServerUrl(url)) {
Alert.alert("Invalid URL", "Daemon URL must be ws:// or wss://");
return;
}
updateDaemonTestState(profile.id, { status: "testing" });
updateConnectionStatus(profile.id, "connecting");
try {
await testServerConnection(url, 4000);
updateDaemonTestState(profile.id, { status: "success", message: "Reachable" });
updateConnectionStatus(profile.id, "online", { lastOnlineAt: new Date().toISOString() });
} catch (error) {
const message = error instanceof Error ? error.message : "Connection failed";
updateDaemonTestState(profile.id, { status: "error", message });
updateConnectionStatus(profile.id, "offline", { lastError: message });
}
},
[testServerConnection, updateConnectionStatus, updateDaemonTestState]
);
// Update local state when settings load
useEffect(() => {
setServerUrl(settings.serverUrl);
setUseSpeaker(settings.useSpeaker);
setKeepScreenOn(settings.keepScreenOn);
setTheme(settings.theme);
}, [settings]);
useEffect(() => {
setServerUrl(activeDaemon?.wsUrl ?? "");
}, [activeDaemon?.wsUrl]);
// Track changes
useEffect(() => {
const changed =
serverUrl !== settings.serverUrl ||
serverUrl !== baselineServerUrl ||
useSpeaker !== settings.useSpeaker ||
keepScreenOn !== settings.keepScreenOn ||
theme !== settings.theme;
setHasChanges(changed);
}, [serverUrl, useSpeaker, keepScreenOn, theme, settings]);
}, [serverUrl, baselineServerUrl, useSpeaker, keepScreenOn, theme, settings]);
function validateServerUrl(url: string): boolean {
try {
@@ -411,6 +589,15 @@ export default function SettingsScreen() {
}
}
function deriveDaemonLabel(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname || "Daemon";
} catch {
return "Daemon";
}
}
async function handleSave() {
// Validate server URL
if (!validateServerUrl(serverUrl)) {
@@ -423,13 +610,28 @@ export default function SettingsScreen() {
}
try {
const trimmedUrl = serverUrl.trim();
await updateSettings({
serverUrl,
useSpeaker,
keepScreenOn,
theme,
});
if (activeDaemon) {
await updateDaemon(activeDaemon.id, {
wsUrl: trimmedUrl,
label: activeDaemon.label || deriveDaemonLabel(trimmedUrl),
});
} else {
await addDaemon({
label: deriveDaemonLabel(trimmedUrl),
wsUrl: trimmedUrl,
autoConnect: true,
isDefault: true,
});
}
Alert.alert(
"Settings Saved",
"Your settings have been saved successfully.",
@@ -478,58 +680,16 @@ export default function SettingsScreen() {
const restartConfirmationMessage =
"This will immediately stop the Voice Dev backend process. The app will disconnect until it restarts.";
const beginServerRestart = useCallback(() => {
if (!wsIsConnectedRef.current) {
Alert.alert(
"Not Connected",
"Connect to the server before attempting a restart."
);
return;
}
setIsRestarting(true);
try {
restartServer("settings_screen_restart");
} catch (error) {
setIsRestarting(false);
Alert.alert(
"Error",
"Failed to send restart request. Please ensure you are connected to the server."
);
return;
}
void waitForServerRestart();
}, [restartServer, waitForServerRestart]);
function handleRestartServer() {
if (Platform.OS === "web") {
const hasBrowserConfirm =
typeof globalThis !== "undefined" &&
typeof (globalThis as any).confirm === "function";
const confirmed = hasBrowserConfirm
? (globalThis as any).confirm(restartConfirmationMessage)
: true;
if (confirmed) {
beginServerRestart();
}
return;
}
Alert.alert("Restart Server", restartConfirmationMessage, [
{ text: "Cancel", style: "cancel" },
{
text: "Restart",
style: "destructive",
onPress: beginServerRestart,
},
]);
}
async function handleTestConnection() {
if (isServerConfigLocked) {
Alert.alert(
"Session unavailable",
`${activeDaemon?.label ?? "This daemon"} is not connected. Connect to it before testing the URL.`
);
return;
}
if (!validateServerUrl(serverUrl)) {
Alert.alert(
"Invalid URL",
@@ -579,10 +739,11 @@ export default function SettingsScreen() {
{/* Server Configuration */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Server Configuration</Text>
<Text style={styles.helperText}>{serverDescriptionText}</Text>
<Text style={styles.label}>WebSocket URL</Text>
<TextInput
style={styles.input}
style={[styles.input, isServerConfigLocked && styles.inputDisabled]}
placeholder="wss://example.com/ws"
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={serverUrl}
@@ -593,9 +754,11 @@ export default function SettingsScreen() {
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isServerConfigLocked}
selectTextOnFocus={!isServerConfigLocked}
/>
<Text style={styles.helperText}>
Must be a valid WebSocket URL (ws:// or wss://)
{serverHelperText}
</Text>
{/* Test Connection Button */}
@@ -604,7 +767,7 @@ export default function SettingsScreen() {
disabled={isTesting || !validateServerUrl(serverUrl)}
style={[
styles.testButton,
(isTesting || !validateServerUrl(serverUrl)) &&
(isTesting || !validateServerUrl(serverUrl) || isServerConfigLocked) &&
styles.testButtonDisabled,
]}
>
@@ -640,6 +803,101 @@ export default function SettingsScreen() {
)}
</View>
{/* Daemon Management */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Daemons</Text>
{daemons.length === 0 ? (
<View style={styles.settingCard}>
<Text style={styles.settingDescription}>No daemons configured.</Text>
</View>
) : (
daemons.map((daemon) => {
const connection = connectionStates.get(daemon.id);
const connectionStatus = connection?.status ?? "idle";
const lastConnectionError = connection?.lastError ?? null;
const testState = daemonTestStates.get(daemon.id);
return (
<DaemonCard
key={daemon.id}
daemon={daemon}
isActive={daemon.id === activeDaemonId}
connectionStatus={connectionStatus}
lastError={lastConnectionError}
testState={testState}
onSetActive={handleSetActiveDaemon}
onSetDefault={handleSetDefaultDaemon}
onTestConnection={handleTestDaemonConnection}
onEdit={handleOpenDaemonForm}
onRemove={handleRemoveDaemon}
restartConfirmationMessage={restartConfirmationMessage}
waitForCondition={waitForCondition}
testServerConnection={testServerConnection}
isScreenMountedRef={isMountedRef}
/>
);
})
)}
{isDaemonFormVisible ? (
<View style={styles.settingCard}>
<Text style={styles.settingTitle}>{daemonForm.id ? "Edit Daemon" : "Add Daemon"}</Text>
<Text style={styles.label}>Label</Text>
<TextInput
style={styles.input}
value={daemonForm.label}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, label: text }))}
placeholder="My Server"
placeholderTextColor={defaultTheme.colors.mutedForeground}
/>
<Text style={styles.label}>WebSocket URL</Text>
<TextInput
style={styles.input}
value={daemonForm.wsUrl}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, wsUrl: text }))}
placeholder="wss://example.com/ws"
placeholderTextColor={defaultTheme.colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<View style={styles.settingRow}>
<View style={styles.settingContent}>
<Text style={styles.settingTitle}>Auto-connect</Text>
<Text style={styles.settingDescription}>Connect automatically when Paseo launches.</Text>
</View>
<Switch
value={daemonForm.autoConnect}
onValueChange={(value) => setDaemonForm((prev) => ({ ...prev, autoConnect: value }))}
trackColor={{ false: defaultTheme.colors.palette.gray[700], true: defaultTheme.colors.palette.blue[500] }}
thumbColor={daemonForm.autoConnect ? defaultTheme.colors.palette.blue[400] : defaultTheme.colors.palette.gray[300]}
/>
</View>
<View style={styles.daemonFormActionsRow}>
<Pressable style={[styles.daemonActionButton, styles.daemonActionDestructive]} onPress={handleCloseDaemonForm}>
<Text style={[styles.daemonActionText, styles.daemonActionDestructiveText]}>Cancel</Text>
</Pressable>
<Pressable
style={[styles.daemonActionButton, styles.daemonActionPrimary, isSavingDaemon && styles.daemonActionDisabled]}
onPress={handleSubmitDaemonForm}
disabled={isSavingDaemon}
>
<Text style={[styles.daemonActionText, styles.daemonActionPrimaryText]}>
{isSavingDaemon ? "Saving..." : daemonForm.id ? "Save Daemon" : "Add Daemon"}
</Text>
</Pressable>
</View>
</View>
) : (
<Pressable style={styles.addButton} onPress={() => handleOpenDaemonForm()}>
<Text style={styles.addButtonText}>Add Daemon</Text>
</Pressable>
)}
</View>
{/* Audio Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Audio</Text>
@@ -728,26 +986,6 @@ export default function SettingsScreen() {
<Pressable style={styles.resetButton} onPress={handleReset}>
<Text style={styles.resetButtonText}>Reset to Defaults</Text>
</Pressable>
<Pressable
style={[
styles.restartButton,
isRestarting && styles.restartButtonDisabled,
]}
onPress={handleRestartServer}
disabled={isRestarting}
>
{isRestarting && (
<ActivityIndicator
size="small"
color={defaultTheme.colors.destructiveForeground}
style={{ marginRight: defaultTheme.spacing[2] }}
/>
)}
<Text style={styles.restartButtonText}>
{isRestarting ? "Restarting..." : "Restart Server"}
</Text>
</Pressable>
</View>
{/* App Info */}
@@ -760,3 +998,243 @@ export default function SettingsScreen() {
</View>
);
}
interface DaemonCardProps {
daemon: DaemonProfile;
isActive: boolean;
connectionStatus: ConnectionStatus;
lastError: string | null;
testState?: DaemonTestState;
onSetActive: (daemon: DaemonProfile) => void;
onSetDefault: (daemon: DaemonProfile) => void;
onTestConnection: (daemon: DaemonProfile) => void;
onEdit: (daemon: DaemonProfile) => void;
onRemove: (daemon: DaemonProfile) => void;
restartConfirmationMessage: string;
waitForCondition: (predicate: () => boolean, timeoutMs: number, intervalMs?: number) => Promise<boolean>;
testServerConnection: (url: string, timeoutMs?: number) => Promise<void>;
isScreenMountedRef: MutableRefObject<boolean>;
}
function DaemonCard({
daemon,
isActive,
connectionStatus,
lastError,
testState,
onSetActive,
onSetDefault,
onTestConnection,
onEdit,
onRemove,
restartConfirmationMessage,
waitForCondition,
testServerConnection,
isScreenMountedRef,
}: DaemonCardProps) {
const { theme } = useUnistyles();
const statusLabel = formatConnectionStatus(connectionStatus);
const statusTone = getConnectionStatusTone(connectionStatus);
const statusColor =
statusTone === "success"
? theme.colors.palette.green[400]
: statusTone === "warning"
? theme.colors.palette.amber[500]
: statusTone === "error"
? theme.colors.destructive
: theme.colors.mutedForeground;
const badgeText = isActive ? `Active · ${statusLabel}` : statusLabel;
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
const daemonSession = useSessionForServer(daemon.id);
const [isRestarting, setIsRestarting] = useState(false);
const wsIsConnectedRef = useRef(daemonSession?.ws.isConnected ?? false);
const isTesting = testState?.status === "testing";
const sessionIsConnected = daemonSession?.ws.isConnected ?? false;
useEffect(() => {
wsIsConnectedRef.current = sessionIsConnected;
}, [sessionIsConnected]);
const waitForDaemonRestart = useCallback(async () => {
const maxAttempts = 12;
const retryDelayMs = 2500;
const disconnectTimeoutMs = 7000;
const reconnectTimeoutMs = 10000;
if (wsIsConnectedRef.current) {
await waitForCondition(() => !wsIsConnectedRef.current, disconnectTimeoutMs);
}
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await testServerConnection(daemon.wsUrl);
const reconnected = await waitForCondition(() => wsIsConnectedRef.current, reconnectTimeoutMs);
if (isScreenMountedRef.current) {
setIsRestarting(false);
if (!reconnected) {
Alert.alert(
"Server reachable",
`${daemon.label} came back online but Paseo has not reconnected yet.`
);
}
}
return;
} catch (error) {
console.warn(
`[Settings] Restart poll attempt ${attempt}/${maxAttempts} failed for ${daemon.label}`,
error
);
if (attempt === maxAttempts) {
if (isScreenMountedRef.current) {
setIsRestarting(false);
Alert.alert(
"Unable to reconnect",
`${daemon.label} did not come back online. Please verify it restarted.`
);
}
return;
}
await delay(retryDelayMs);
}
}
}, [daemon.label, daemon.wsUrl, isScreenMountedRef, testServerConnection, waitForCondition]);
const beginServerRestart = useCallback(() => {
if (!daemonSession) {
Alert.alert(
"Daemon unavailable",
`${daemon.label} is not connected. Select it to connect before restarting.`
);
return;
}
if (!wsIsConnectedRef.current) {
Alert.alert("Not Connected", "Connect to the server before attempting a restart.");
return;
}
setIsRestarting(true);
try {
daemonSession.restartServer(`settings_daemon_restart_${daemon.id}`);
} catch (error) {
console.error(`[Settings] Failed to restart daemon ${daemon.label}`, error);
setIsRestarting(false);
Alert.alert(
"Error",
"Failed to send restart request. Please ensure you are connected to the server."
);
return;
}
void waitForDaemonRestart();
}, [daemon.id, daemon.label, daemonSession, waitForDaemonRestart]);
const handleRestartPress = useCallback(() => {
if (!daemonSession) {
Alert.alert(
"Daemon unavailable",
`${daemon.label} is not connected. Select it to connect before restarting.`
);
return;
}
if (Platform.OS === "web") {
const hasBrowserConfirm =
typeof globalThis !== "undefined" &&
typeof (globalThis as any).confirm === "function";
const confirmed = hasBrowserConfirm
? (globalThis as any).confirm(`Restart ${daemon.label}? ${restartConfirmationMessage}`)
: true;
if (confirmed) {
beginServerRestart();
}
return;
}
Alert.alert(`Restart ${daemon.label}`, restartConfirmationMessage, [
{ text: "Cancel", style: "cancel" },
{
text: "Restart",
style: "destructive",
onPress: beginServerRestart,
},
]);
}, [beginServerRestart, daemon.label, daemonSession, restartConfirmationMessage]);
return (
<View style={[styles.settingCard, styles.daemonCard, isActive && styles.daemonCardActive]}>
<View style={styles.daemonHeaderRow}>
<Text style={styles.settingTitle}>{daemon.label}</Text>
<View style={styles.connectionStatusBadge}>
<View style={[styles.connectionStatusDot, { backgroundColor: statusColor }]} />
<Text style={[styles.connectionStatusText, { color: statusColor }]}>{badgeText}</Text>
</View>
</View>
<Text style={styles.settingDescription}>{daemon.wsUrl}</Text>
{connectionError ? <Text style={styles.connectionErrorText}>{connectionError}</Text> : null}
{testState && testState.status !== "idle" ? (
<Text
style={
testState.status === "success"
? styles.testResultSuccessText
: testState.status === "error"
? styles.testResultErrorText
: styles.testResultInfoText
}
>
{testState.message ?? (testState.status === "success" ? "Reachable" : "Testing...")}
</Text>
) : null}
<View style={styles.daemonActionsRow}>
{!isActive ? (
<Pressable style={styles.daemonActionButton} onPress={() => onSetActive(daemon)}>
<Text style={styles.daemonActionText}>Set Active</Text>
</Pressable>
) : null}
<Pressable style={styles.daemonActionButton} onPress={() => onSetDefault(daemon)}>
<Text style={styles.daemonActionText}>{daemon.isDefault ? "Default" : "Make Default"}</Text>
</Pressable>
<Pressable
style={[
styles.daemonActionButton,
styles.daemonActionPrimary,
isTesting && styles.daemonActionDisabled,
]}
onPress={() => onTestConnection(daemon)}
disabled={isTesting}
>
<Text style={[styles.daemonActionText, styles.daemonActionPrimaryText]}>
{isTesting ? "Testing..." : "Test"}
</Text>
</Pressable>
<Pressable
style={[
styles.daemonActionButton,
styles.daemonActionDestructive,
isRestarting && styles.daemonActionDisabled,
]}
onPress={handleRestartPress}
disabled={isRestarting}
>
{isRestarting ? (
<ActivityIndicator size="small" color={defaultTheme.colors.destructive} />
) : (
<Text style={[styles.daemonActionText, styles.daemonActionDestructiveText]}>Restart</Text>
)}
</Pressable>
<Pressable style={styles.daemonActionButton} onPress={() => onEdit(daemon)}>
<Text style={styles.daemonActionText}>Edit</Text>
</Pressable>
<Pressable
style={[styles.daemonActionButton, styles.daemonActionDestructive]}
onPress={() => onRemove(daemon)}
>
<Text style={[styles.daemonActionText, styles.daemonActionDestructiveText]}>Remove</Text>
</Pressable>
</View>
</View>
);
}

View File

@@ -14,7 +14,7 @@ export interface ActiveProcessesProps {
}>;
viewMode: "orchestrator" | "agent";
activeAgentId: string | null;
onSelectAgent: (id: string) => void;
onSelectAgent: (serverId: string, id: string) => void;
onSelectOrchestrator: () => void;
}
@@ -174,7 +174,7 @@ export function ActiveProcesses({
return (
<Pressable
key={`agent-${agent.id}`}
onPress={() => onSelectAgent(agent.id)}
onPress={() => onSelectAgent(agent.serverId, agent.id)}
style={({ pressed }) => [
styles.processItem,
isActive ? styles.processItemActive : styles.processItemInactive,

View File

@@ -20,7 +20,6 @@ import Animated, {
FadeIn,
FadeOut,
} from "react-native-reanimated";
import { useSession } from "@/contexts/session-context";
import { useRealtime } from "@/contexts/realtime-context";
import { useAudioRecorder } from "@/hooks/use-audio-recorder";
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
@@ -31,6 +30,7 @@ import { RealtimeControls } from "./realtime-controls";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug";
import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice";
import { useDaemonSession } from "@/hooks/use-daemon-session";
type QueuedMessage = {
id: string;
@@ -40,6 +40,7 @@ type QueuedMessage = {
interface AgentInputAreaProps {
agentId: string;
serverId?: string;
}
const MIN_INPUT_HEIGHT = 50;
@@ -68,7 +69,7 @@ type TextAreaHandle = {
} & Record<string, unknown>;
};
export function AgentInputArea({ agentId }: AgentInputAreaProps) {
export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
const { theme } = useUnistyles();
const {
ws,
@@ -80,7 +81,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
saveDraftInput,
queuedMessages: queuedMessagesByAgent,
setQueuedMessages: setQueuedMessagesByAgent,
} = useSession();
} = useDaemonSession(serverId);
const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime();
const [userInput, setUserInput] = useState("");
@@ -137,7 +138,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
if (!userInput.trim() || !ws.isConnected) return;
const message = userInput.trim();
const imageUris = selectedImages.length > 0 ? selectedImages.map(img => img.uri) : undefined;
const imageAttachments = selectedImages.length > 0 ? selectedImages : undefined;
setUserInput("");
setSelectedImages([]);
@@ -145,7 +146,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
setIsProcessing(true);
try {
await sendAgentMessage(agentId, message, imageUris);
await sendAgentMessage(agentId, message, imageAttachments);
} catch (error) {
console.error("[AgentInput] Failed to send message:", error);
} finally {
@@ -710,7 +711,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
// Cancels current agent run before sending queued prompt
handleCancelAgent();
await sendAgentMessage(agentId, item.text, item.images?.map((img) => img.uri));
await sendAgentMessage(agentId, item.text, item.images);
}
const realtimeButton = (
@@ -846,7 +847,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
>
<Paperclip size={20} color={theme.colors.foreground} />
</Pressable>
<AgentStatusBar agentId={agentId} />
<AgentStatusBar agentId={agentId} serverId={serverId} />
</View>
{/* Right button group */}

View File

@@ -1,131 +1,143 @@
import { View, Text, Pressable, ScrollView, Modal } from "react-native";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { Agent } from "@/contexts/session-context";
import { useSession } from "@/contexts/session-context";
import { formatTimeAgo } from "@/utils/time";
import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { type AggregatedAgentGroup } from "@/hooks/use-aggregated-agents";
import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
interface AgentListProps {
agents: Map<string, Agent>;
agentGroups: AggregatedAgentGroup[];
}
export function AgentList({ agents }: AgentListProps) {
export function AgentList({ agentGroups }: AgentListProps) {
const { theme } = useUnistyles();
const { deleteAgent } = useSession();
const { setActiveDaemonId } = useDaemonConnections();
const [actionAgent, setActionAgent] = useState<Agent | null>(null);
const [actionAgentServerId, setActionAgentServerId] = useState<string | null>(null);
let actionSession = null;
let actionSessionUnavailable = false;
try {
actionSession = useDaemonSession(actionAgentServerId ?? undefined, { suppressUnavailableAlert: true });
} catch (error) {
if (error instanceof DaemonSessionUnavailableError) {
actionSessionUnavailable = true;
actionSession = null;
} else {
throw error;
}
}
const deleteAgent = actionSession?.deleteAgent;
const isActionSheetVisible = actionAgent !== null;
// Sort agents by status so running agents stay at the top, then fall back to the
// persisted timestamp of the last user message, and finally their last activity
const agentArray = useMemo(() => {
return Array.from(agents.values()).sort((a, b) => {
const aIsRunning = a.status === "running";
const bIsRunning = b.status === "running";
const isActionDaemonUnavailable = Boolean(actionAgentServerId && actionSessionUnavailable);
if (aIsRunning && !bIsRunning) {
return -1;
}
if (!aIsRunning && bIsRunning) {
return 1;
}
const sortA = (a.lastUserMessageAt ?? a.lastActivityAt).getTime();
const sortB = (b.lastUserMessageAt ?? b.lastActivityAt).getTime();
return sortB - sortA;
});
}, [agents]);
const handleAgentPress = useCallback((agentId: string) => {
const handleAgentPress = useCallback((serverId: string, agentId: string) => {
if (isActionSheetVisible) {
return;
}
router.push(`/agent/${agentId}`);
}, [isActionSheetVisible]);
setActiveDaemonId(serverId);
router.push({
pathname: "/agent/[serverId]/[agentId]",
params: {
serverId,
agentId,
},
});
}, [isActionSheetVisible, setActiveDaemonId]);
const handleAgentLongPress = useCallback((agent: Agent) => {
const handleAgentLongPress = useCallback((serverId: string, agent: Agent) => {
setActionAgentServerId(serverId);
setActionAgent(agent);
}, []);
const handleCloseActionSheet = useCallback(() => {
setActionAgent(null);
setActionAgentServerId(null);
}, []);
const handleDeleteAgent = useCallback(() => {
if (!actionAgent) {
if (!actionAgent || !deleteAgent) {
return;
}
deleteAgent(actionAgent.id);
setActionAgent(null);
setActionAgentServerId(null);
}, [actionAgent, deleteAgent]);
return (
<>
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
{agentArray.map((agent) => {
const statusColor = getAgentStatusColor(agent.status);
const statusLabel = getAgentStatusLabel(agent.status);
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const providerLabel = getAgentProviderDefinition(agent.provider).label;
{agentGroups.map(({ serverId, serverLabel, agents }) => (
<View key={serverId} style={styles.section}>
<Text style={styles.sectionLabel}>{serverLabel}</Text>
{agents.map((agent) => {
const statusColor = getAgentStatusColor(agent.status);
const statusLabel = getAgentStatusLabel(agent.status);
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const providerLabel = getAgentProviderDefinition(agent.provider).label;
const rowServerId = agent.serverId ?? serverId;
return (
<Pressable
key={agent.id}
style={({ pressed }) => [
styles.agentItem,
pressed && styles.agentItemPressed,
]}
onPress={() => handleAgentPress(agent.id)}
onLongPress={() => handleAgentLongPress(agent)}
>
<View style={styles.agentContent}>
<Text
style={styles.agentTitle}
numberOfLines={1}
return (
<Pressable
key={`${rowServerId}:${agent.id}`}
style={({ pressed }) => [
styles.agentItem,
pressed && styles.agentItemPressed,
]}
onPress={() => handleAgentPress(rowServerId, agent.id)}
onLongPress={() => handleAgentLongPress(rowServerId, agent)}
>
{agent.title || "New Agent"}
</Text>
<Text
style={styles.agentDirectory}
numberOfLines={1}
>
{agent.cwd}
</Text>
<View style={styles.statusRow}>
<View style={styles.statusGroup}>
<View
style={[styles.providerBadge, { backgroundColor: theme.colors.muted }]}
<View style={styles.agentContent}>
<Text
style={styles.agentTitle}
numberOfLines={1}
>
<Text
style={[styles.providerText, { color: theme.colors.mutedForeground }]}
numberOfLines={1}
>
{providerLabel}
</Text>
</View>
{agent.title || "New Agent"}
</Text>
<View style={styles.statusBadge}>
<View
style={[styles.statusDot, { backgroundColor: statusColor }]}
/>
<Text style={[styles.statusText, { color: statusColor }]}>
{statusLabel}
<Text style={styles.agentDirectory} numberOfLines={1}>
{agent.cwd}
</Text>
<View style={styles.statusRow}>
<View style={styles.statusGroup}>
<View
style={[styles.providerBadge, { backgroundColor: theme.colors.muted }]}
>
<Text
style={[styles.providerText, { color: theme.colors.mutedForeground }]}
numberOfLines={1}
>
{providerLabel}
</Text>
</View>
<View style={styles.statusBadge}>
<View
style={[styles.statusDot, { backgroundColor: statusColor }]}
/>
<Text style={[styles.statusText, { color: statusColor }]}>
{statusLabel}
</Text>
</View>
</View>
<Text style={styles.timeAgo}>
{timeAgo}
</Text>
</View>
</View>
<Text style={styles.timeAgo}>
{timeAgo}
</Text>
</View>
</View>
</Pressable>
);
})}
</Pressable>
);
})}
</View>
))}
</ScrollView>
<Modal
@@ -142,13 +154,23 @@ export function AgentList({ agents }: AgentListProps) {
{actionAgent?.title || "Delete agent"}
</Text>
<Text style={styles.sheetSubtitle}>
Removing this agent only deletes it from Paseo. Claude/Codex keeps the original project.
{isActionDaemonUnavailable
? "Connect this daemon before managing its agents."
: "Removing this agent only deletes it from Paseo. Claude/Codex keeps the original project."}
</Text>
<Pressable
disabled={!deleteAgent || isActionDaemonUnavailable}
style={[styles.sheetButton, styles.sheetDeleteButton]}
onPress={handleDeleteAgent}
>
<Text style={styles.sheetDeleteText}>Delete agent</Text>
<Text
style={[
styles.sheetDeleteText,
(!deleteAgent || isActionDaemonUnavailable) && styles.sheetDeleteTextDisabled,
]}
>
{isActionDaemonUnavailable ? "Daemon offline" : "Delete agent"}
</Text>
</Pressable>
<Pressable
style={[styles.sheetButton, styles.sheetCancelButton]}
@@ -169,6 +191,17 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[4],
},
section: {
marginBottom: theme.spacing[4],
},
sectionLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.5,
marginBottom: theme.spacing[2],
},
agentItem: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
@@ -281,6 +314,9 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.destructiveForeground,
fontWeight: theme.fontWeight.semibold,
},
sheetDeleteTextDisabled: {
opacity: 0.5,
},
sheetCancelButton: {
backgroundColor: theme.colors.muted,
},

View File

@@ -22,7 +22,7 @@ interface AgentSidebarProps {
agents: Agent[];
activeAgentId: string | null;
onClose: () => void;
onSelectAgent: (agentId: string) => void;
onSelectAgent: (serverId: string, agentId: string) => void;
onNewAgent: () => void;
edgeSwipeTranslateX?: SharedValue<number> | null;
}
@@ -72,8 +72,8 @@ export function AgentSidebar({
}
}, [isOpen]);
function handleAgentSelect(agentId: string) {
onSelectAgent(agentId);
function handleAgentSelect(agentId: string, serverId: string) {
onSelectAgent(serverId, agentId);
onClose();
}
@@ -189,7 +189,7 @@ export function AgentSidebar({
styles.agentItem,
{ backgroundColor: isActive ? theme.colors.secondary : "transparent" },
]}
onPress={() => handleAgentSelect(agent.id)}
onPress={() => handleAgentSelect(agent.id, agent.serverId)}
>
<View style={styles.agentContent}>
<Text

View File

@@ -1,17 +1,18 @@
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import { useSession } from "@/contexts/session-context";
import { useState } from "react";
import { ModeSelectorModal } from "./mode-selector-modal";
import { useDaemonSession } from "@/hooks/use-daemon-session";
interface AgentStatusBarProps {
agentId: string;
serverId?: string;
}
export function AgentStatusBar({ agentId }: AgentStatusBarProps) {
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const { theme } = useUnistyles();
const { agents, setAgentMode } = useSession();
const { agents, setAgentMode } = useDaemonSession(serverId);
const [showModeSelector, setShowModeSelector] = useState(false);
const agent = agents.get(agentId);

View File

@@ -10,6 +10,7 @@ import {
NativeSyntheticEvent,
InteractionManager,
Platform,
ActivityIndicator,
} from "react-native";
import Markdown from "react-native-markdown-display";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -31,7 +32,10 @@ import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import type { Agent } from "@/contexts/session-context";
import { useSession } from "@/contexts/session-context";
import { useDaemonSession } from "@/hooks/use-daemon-session";
import { useDaemonRequest } from "@/hooks/use-daemon-request";
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
import type { SessionOutboundMessage } from "@server/server/messages";
import {
extractCommandDetails,
extractEditEntries,
@@ -39,20 +43,24 @@ import {
} from "@/utils/tool-call-parsers";
const MAX_CHAT_WIDTH = 960;
type PermissionResolvedMessage = Extract<
SessionOutboundMessage,
{ type: "agent_permission_resolved" }
>;
export interface AgentStreamViewProps {
agentId: string;
serverId?: string;
agent: Agent;
streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
onPermissionResponse: (agentId: string, requestId: string, response: AgentPermissionResponse) => void;
}
export function AgentStreamView({
agentId,
serverId,
agent,
streamItems,
pendingPermissions,
onPermissionResponse,
}: AgentStreamViewProps) {
const flatListRef = useRef<FlatList<StreamItem>>(null);
const insets = useSafeAreaInsets();
@@ -63,7 +71,9 @@ export function AgentStreamView({
const isNearBottomRef = useRef(true);
const isUserScrollingRef = useRef(false);
const router = useRouter();
const { requestDirectoryListing, requestFilePreview } = useSession();
const session = useDaemonSession(serverId);
const { requestDirectoryListing, requestFilePreview, ws } = session;
const resolvedServerId = serverId ?? session.serverId;
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -97,6 +107,7 @@ export function AgentStreamView({
params: {
agentId,
path: normalized.directory,
serverId: resolvedServerId,
...(normalized.file ? { file: normalized.file } : {}),
...(target.lineStart !== undefined
? { lineStart: String(target.lineStart) }
@@ -107,7 +118,7 @@ export function AgentStreamView({
},
});
},
[agentId, requestDirectoryListing, requestFilePreview, router]
[agentId, requestDirectoryListing, requestFilePreview, resolvedServerId, router]
);
const handleScroll = useCallback(
@@ -316,11 +327,7 @@ export function AgentStreamView({
{pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard
key={permission.key}
permission={permission}
onResponse={onPermissionResponse}
/>
<PermissionRequestCard key={permission.key} permission={permission} ws={ws} />
))}
</View>
) : null}
@@ -333,7 +340,7 @@ export function AgentStreamView({
</View>
</View>
);
}, [onPermissionResponse, pendingPermissionItems, showWorkingIndicator]);
}, [pendingPermissionItems, showWorkingIndicator, ws]);
const flatListData = useMemo(() => {
return [...streamItems].reverse();
@@ -512,10 +519,10 @@ function WorkingIndicator() {
// Permission Request Card Component
function PermissionRequestCard({
permission,
onResponse,
ws,
}: {
permission: PendingPermission;
onResponse: (agentId: string, requestId: string, response: AgentPermissionResponse) => void;
ws: UseWebSocketReturn;
}) {
const { theme } = useUnistyles();
@@ -617,6 +624,50 @@ function PermissionRequestCard({
[theme]
);
const permissionResponse = useDaemonRequest<
{ agentId: string; requestId: string; response: AgentPermissionResponse },
{ agentId: string; requestId: string },
PermissionResolvedMessage
>({
ws,
responseType: "agent_permission_resolved",
buildRequest: ({ params }) => ({
type: "session",
message: {
type: "agent_permission_response",
agentId: params?.agentId ?? "",
requestId: params?.requestId ?? "",
response: params?.response ?? { behavior: "deny" },
},
}),
matchResponse: (message, context) =>
message.payload.agentId === context.params?.agentId &&
message.payload.requestId === context.params?.requestId,
getRequestKey: (params) =>
params ? `${params.agentId}:${params.requestId}` : "default",
selectData: (message) => ({
agentId: message.payload.agentId,
requestId: message.payload.requestId,
}),
timeoutMs: 15000,
keepPreviousData: false,
});
const isResponding = permissionResponse.isLoading;
const handleResponse = useCallback(
(response: AgentPermissionResponse) => {
permissionResponse
.execute({
agentId: permission.agentId,
requestId: permission.request.id,
response,
})
.catch((error) => {
console.error("[PermissionRequestCard] Failed to respond to permission:", error);
});
},
[permission.agentId, permission.request.id, permissionResponse]
);
return (
<View
style={[
@@ -778,18 +829,21 @@ function PermissionRequestCard({
permissionStyles.optionButton,
{ backgroundColor: theme.colors.primary },
]}
onPress={() =>
onResponse(permission.agentId, request.id, { behavior: "allow" })
}
onPress={() => handleResponse({ behavior: "allow" })}
disabled={isResponding}
>
<Text
style={[
permissionStyles.optionText,
{ color: theme.colors.primaryForeground },
]}
>
Allow
</Text>
{isResponding ? (
<ActivityIndicator size="small" color={theme.colors.primaryForeground} />
) : (
<Text
style={[
permissionStyles.optionText,
{ color: theme.colors.primaryForeground },
]}
>
Allow
</Text>
)}
</Pressable>
<Pressable
style={[
@@ -797,20 +851,25 @@ function PermissionRequestCard({
{ backgroundColor: theme.colors.destructive },
]}
onPress={() =>
onResponse(permission.agentId, request.id, {
handleResponse({
behavior: "deny",
message: "Denied by user",
})
}
disabled={isResponding}
>
<Text
style={[
permissionStyles.optionText,
{ color: theme.colors.primaryForeground },
]}
>
Deny
</Text>
{isResponding ? (
<ActivityIndicator size="small" color={theme.colors.primaryForeground} />
) : (
<Text
style={[
permissionStyles.optionText,
{ color: theme.colors.primaryForeground },
]}
>
Deny
</Text>
)}
</Pressable>
</View>
</View>

View File

@@ -39,7 +39,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, Check, X, ChevronDown } from "lucide-react-native";
import { theme as defaultTheme } from "@/styles/theme";
import { useRecentPaths } from "@/hooks/use-recent-paths";
import { useSession } from "@/contexts/session-context";
import { useRouter } from "expo-router";
import { generateMessageId } from "@/types/stream";
import { useAudioRecorder } from "@/hooks/use-audio-recorder";
@@ -50,6 +49,7 @@ import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type {
AgentProvider,
AgentMode,
@@ -58,7 +58,12 @@ import type {
AgentTimelineItem,
AgentModelDefinition,
} from "@server/server/agent/agent-sdk-types";
import type { WSInboundMessage } from "@server/server/messages";
import { useDaemonRequest } from "@/hooks/use-daemon-request";
import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/messages";
import { formatConnectionStatus } from "@/utils/daemons";
import { useSession } from "@/contexts/session-context";
import { useSessionForServer } from "@/hooks/use-session-directory";
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
export type CreateAgentInitialValues = {
workingDir?: string;
@@ -72,12 +77,14 @@ interface AgentFlowModalProps {
onClose: () => void;
flow: "create" | "import";
initialValues?: CreateAgentInitialValues;
serverId?: string | null;
}
interface ModalWrapperProps {
isVisible: boolean;
onClose: () => void;
initialValues?: CreateAgentInitialValues;
serverId?: string | null;
}
const providerDefinitions = AGENT_PROVIDER_DEFINITIONS;
@@ -150,6 +157,11 @@ type RepoInfoState = {
isDirty: boolean;
};
type GitRepoInfoResponseMessage = Extract<
SessionOutboundMessage,
{ type: "git_repo_info_response" }
>;
function formatRelativeTime(date: Date): string {
const now = Date.now();
const diffMs = now - date.getTime();
@@ -188,6 +200,7 @@ function AgentFlowModal({
onClose,
flow,
initialValues,
serverId,
}: AgentFlowModalProps) {
const insets = useSafeAreaInsets();
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
@@ -201,17 +214,121 @@ function AgentFlowModal({
const isCreateFlow = !isImportFlow;
const { recentPaths, addRecentPath } = useRecentPaths();
const { connectionStates, activeDaemonId, setActiveDaemonId } = useDaemonConnections();
const daemonEntries = useMemo(() => Array.from(connectionStates.values()), [connectionStates]);
const initialServerId = useMemo(() => {
if (serverId) {
return serverId;
}
if (activeDaemonId) {
return activeDaemonId;
}
return daemonEntries[0]?.daemon.id ?? null;
}, [serverId, activeDaemonId, daemonEntries]);
const [selectedServerId, setSelectedServerId] = useState<string | null>(initialServerId);
const activeSession = useSession();
const selectedSession = useSessionForServer(selectedServerId);
const session =
selectedServerId === null ? activeSession : selectedSession ?? null;
useEffect(() => {
if (!selectedServerId) {
setSelectedServerId(initialServerId);
return;
}
const exists = daemonEntries.some((entry) => entry.daemon.id === selectedServerId);
if (!exists) {
setSelectedServerId(initialServerId);
}
}, [daemonEntries, selectedServerId, initialServerId]);
useEffect(() => {
if (isVisible && initialServerId && selectedServerId !== initialServerId) {
setSelectedServerId(initialServerId);
}
}, [isVisible, initialServerId]);
const ws = session?.ws ?? null;
const inertWebSocket = useMemo<UseWebSocketReturn>(
() => ({
isConnected: false,
isConnecting: false,
conversationId: null,
lastError: null,
send: () => {},
on: () => () => {},
sendPing: () => {},
sendUserMessage: () => {},
}),
[]
);
const effectiveWs = ws ?? inertWebSocket;
const createAgent = session?.createAgent;
const resumeAgent = session?.resumeAgent;
const sendAgentAudio = session?.sendAgentAudio;
const agents = session?.agents;
const providerModels = session?.providerModels;
const requestProviderModels = session?.requestProviderModels;
const gitRepoInfoRequest = useDaemonRequest<
{ cwd: string },
RepoInfoState,
GitRepoInfoResponseMessage
>({
ws: effectiveWs,
responseType: "git_repo_info_response",
buildRequest: ({ params, requestId }) => ({
type: "session",
message: {
type: "git_repo_info_request",
cwd: params?.cwd ?? ".",
requestId,
},
}),
getRequestKey: (params) => params?.cwd ?? "default",
selectData: (message) => ({
cwd: message.payload.cwd,
repoRoot: message.payload.repoRoot,
branches: message.payload.branches ?? [],
currentBranch: message.payload.currentBranch ?? null,
isDirty: Boolean(message.payload.isDirty),
}),
extractError: (message) =>
message.payload.error ? new Error(message.payload.error) : null,
keepPreviousData: false,
});
const {
ws,
createAgent,
resumeAgent,
sendAgentAudio,
agents,
providerModels,
requestProviderModels,
} = useSession();
const isWsConnected = ws.isConnected;
status: repoRequestStatus,
data: repoInfo,
error: repoRequestError,
execute: inspectRepoInfo,
reset: resetRepoInfo,
cancel: cancelRepoInfo,
} = gitRepoInfoRequest;
const isWsConnected = ws?.isConnected ?? false;
const router = useRouter();
const activeServerId = session?.serverId ?? null;
const selectedDaemonConnection = selectedServerId
? connectionStates.get(selectedServerId)
: null;
const selectedDaemonLabel =
selectedDaemonConnection?.daemon.label ??
selectedDaemonConnection?.daemon.wsUrl ??
selectedServerId ??
"Selected daemon";
const selectedDaemonStatusLabel = selectedDaemonConnection
? formatConnectionStatus(selectedDaemonConnection.status)
: "offline";
const selectedDaemonIsUnavailable = Boolean(
selectedServerId &&
(!session || selectedDaemonConnection?.status !== "online")
);
const selectedDaemonLastError = selectedDaemonConnection?.lastError?.trim();
const daemonAvailabilityError = selectedDaemonIsUnavailable
? `${selectedDaemonLabel} is ${selectedDaemonStatusLabel}. Connect to it before creating or importing agents.${
selectedDaemonLastError ? ` ${selectedDaemonLastError}` : ""
}`
: null;
const isTargetDaemonReady = Boolean(session && !selectedDaemonIsUnavailable);
const [isMounted, setIsMounted] = useState(isVisible);
const [workingDir, setWorkingDir] = useState("");
@@ -245,13 +362,7 @@ function AgentFlowModal({
const [dictationVolume, setDictationVolume] = useState(0);
const [dictationDebugInfo, setDictationDebugInfo] = useState<AudioDebugInfo | null>(null);
const [openDropdown, setOpenDropdown] = useState<DropdownKey | null>(null);
const [repoInfo, setRepoInfo] = useState<RepoInfoState | null>(null);
const [repoInfoStatus, setRepoInfoStatus] = useState<
"idle" | "loading" | "ready" | "error"
>("idle");
const [repoInfoError, setRepoInfoError] = useState<string | null>(null);
const pendingRequestIdRef = useRef<string | null>(null);
const repoInfoRequestIdRef = useRef<string | null>(null);
const shouldSyncBaseBranchRef = useRef(true);
const promptInputRef = useRef<
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
@@ -353,6 +464,9 @@ function AgentFlowModal({
}, [applyInitialValues, isVisible]);
const refreshProviderModels = useCallback(() => {
if (!requestProviderModels) {
return;
}
const trimmed = workingDir.trim();
requestProviderModels(selectedProvider, {
cwd: trimmed.length > 0 ? trimmed : undefined,
@@ -466,13 +580,13 @@ function AgentFlowModal({
);
const agentDefinition = providerDefinitionMap.get(selectedProvider);
const modeOptions = agentDefinition?.modes ?? [];
const modelState = providerModels.get(selectedProvider);
const modelState = providerModels?.get(selectedProvider);
const availableModels = modelState?.models ?? [];
const isModelLoading = modelState?.isLoading ?? false;
const modelError = modelState?.error ?? null;
useEffect(() => {
if (!isVisible) {
if (!isVisible || !providerModels || !requestProviderModels || !isTargetDaemonReady) {
return;
}
const trimmed = workingDir.trim();
@@ -483,7 +597,7 @@ function AgentFlowModal({
requestProviderModels(selectedProvider, {
cwd: trimmed.length > 0 ? trimmed : undefined,
});
}, [isVisible, providerModels, requestProviderModels, selectedProvider, workingDir]);
}, [isVisible, providerModels, requestProviderModels, selectedProvider, workingDir, isTargetDaemonReady]);
const setPromptHeight = useCallback((nextHeight: number) => {
const bounded = Math.min(
PROMPT_MAX_HEIGHT,
@@ -599,6 +713,9 @@ function AgentFlowModal({
}, [shouldAutoFocusPrompt]);
const activeSessionIds = useMemo(() => {
const ids = new Set<string>();
if (!agents) {
return ids;
}
agents.forEach((agent) => {
if (agent.sessionId) {
ids.add(agent.sessionId);
@@ -687,12 +804,9 @@ function AgentFlowModal({
setWorktreeSlugEdited(false);
setErrorMessage("");
setIsLoading(false);
setRepoInfo(null);
setRepoInfoStatus("idle");
setRepoInfoError(null);
resetRepoInfo();
setOpenDropdown(null);
pendingRequestIdRef.current = null;
repoInfoRequestIdRef.current = null;
shouldSyncBaseBranchRef.current = true;
dictationRequestIdRef.current = null;
setIsDictating(false);
@@ -702,7 +816,8 @@ function AgentFlowModal({
if (dictationRecorder.isRecording?.()) {
void dictationRecorder.stop().catch(() => {});
}
}, [setPromptHeight]);
cancelRepoInfo();
}, [cancelRepoInfo, resetRepoInfo, setPromptHeight]);
const handleDictationStart = useCallback(async () => {
if (!isCreateFlow || isLoading || !isWsConnected || isDictating || isDictationProcessing) {
@@ -739,6 +854,13 @@ function AgentFlowModal({
if (!isDictating || isDictationProcessing) {
return;
}
if (!sendAgentAudio) {
setErrorMessage(
daemonAvailabilityError ??
"Connect to the selected daemon before using dictation."
);
return;
}
try {
const audioData = await dictationRecorder.stop();
setIsDictating(false);
@@ -760,19 +882,38 @@ function AgentFlowModal({
dictationRequestIdRef.current = null;
setIsDictationProcessing(false);
}
}, [dictationRecorder, isDictating, isDictationProcessing, sendAgentAudio]);
}, [
daemonAvailabilityError,
dictationRecorder,
isDictating,
isDictationProcessing,
sendAgentAudio,
]);
const navigateToAgentIfNeeded = useCallback(() => {
const agentId = pendingNavigationAgentIdRef.current;
if (!agentId) {
if (!agentId || !activeServerId) {
return;
}
pendingNavigationAgentIdRef.current = null;
if (activeDaemonId !== activeServerId) {
setActiveDaemonId(activeServerId);
}
InteractionManager.runAfterInteractions(() => {
router.push(`/agent/${agentId}`);
router.push({
pathname: "/agent/[serverId]/[agentId]",
params: {
serverId: activeServerId,
agentId,
},
});
});
}, [router]);
}, [activeDaemonId, activeServerId, router, setActiveDaemonId]);
const handleSelectServer = useCallback((serverId: string) => {
setSelectedServerId(serverId);
}, []);
const handleCloseAnimationComplete = useCallback(() => {
console.log("[CreateAgentModal] close animation complete resetting form");
@@ -783,6 +924,14 @@ function AgentFlowModal({
const requestImportCandidates = useCallback(
(provider?: AgentProvider) => {
if (!isTargetDaemonReady || !ws || !ws.isConnected) {
setIsImportLoading(false);
setImportError(
daemonAvailabilityError ??
"Connect to the selected daemon to load import candidates."
);
return;
}
setIsImportLoading(true);
setImportError(null);
const msg: WSInboundMessage = {
@@ -804,7 +953,7 @@ function AgentFlowModal({
setImportError("Unable to load agents to import. Please try again.");
}
},
[ws]
[daemonAvailabilityError, isTargetDaemonReady, ws]
);
useEffect(() => {
@@ -973,34 +1122,6 @@ function AgentFlowModal({
slugifyWorktreeName,
]);
const requestRepoInfo = useCallback(
(path: string) => {
const trimmed = path.trim();
if (!trimmed) {
setRepoInfo(null);
setRepoInfoStatus("idle");
setRepoInfoError(null);
repoInfoRequestIdRef.current = null;
return;
}
const requestId = generateMessageId();
repoInfoRequestIdRef.current = requestId;
setRepoInfoStatus("loading");
setRepoInfoError(null);
ws.send({
type: "session",
message: {
type: "git_repo_info_request",
cwd: trimmed,
requestId,
},
});
},
[ws]
);
useEffect(() => {
if (!isCreateFlow || !isVisible) {
return;
@@ -1008,62 +1129,61 @@ function AgentFlowModal({
shouldSyncBaseBranchRef.current = true;
}, [isCreateFlow, isVisible, workingDir]);
useEffect(() => {
if (!isCreateFlow || !isVisible) {
return;
}
requestRepoInfo(workingDir);
}, [isCreateFlow, isVisible, requestRepoInfo, workingDir]);
const trimmedWorkingDir = workingDir.trim();
const shouldInspectRepo = isCreateFlow && isVisible && trimmedWorkingDir.length > 0;
const repoAvailabilityError = shouldInspectRepo && (!isTargetDaemonReady || !isWsConnected)
? daemonAvailabilityError ?? "Connect to the selected daemon to inspect the repository."
: null;
const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo
? "idle"
: repoAvailabilityError
? "error"
: repoRequestStatus === "loading"
? "loading"
: repoRequestStatus === "error"
? "error"
: repoRequestStatus === "success"
? "ready"
: "idle";
const repoInfoError = repoAvailabilityError ?? repoRequestError?.message ?? null;
useEffect(() => {
if (!isCreateFlow || !isVisible) {
if (!shouldInspectRepo) {
cancelRepoInfo();
resetRepoInfo();
return;
}
const unsubscribe = ws.on("git_repo_info_response", (message) => {
if (message.type !== "git_repo_info_response") {
return;
}
if (
repoInfoRequestIdRef.current &&
message.payload.requestId &&
message.payload.requestId !== repoInfoRequestIdRef.current
) {
return;
}
if (message.payload.error) {
setRepoInfo(null);
setRepoInfoStatus("error");
setRepoInfoError(message.payload.error);
return;
}
setRepoInfo({
cwd: message.payload.cwd,
repoRoot: message.payload.repoRoot,
branches: message.payload.branches ?? [],
currentBranch: message.payload.currentBranch ?? null,
isDirty: Boolean(message.payload.isDirty),
});
setRepoInfoStatus("ready");
setRepoInfoError(null);
setBaseBranch((prev) => {
if (
shouldSyncBaseBranchRef.current ||
prev.trim().length === 0
) {
shouldSyncBaseBranchRef.current = false;
return message.payload.currentBranch ?? "";
}
return prev;
});
});
if (repoAvailabilityError) {
cancelRepoInfo();
return;
}
inspectRepoInfo({ cwd: trimmedWorkingDir }).catch(() => {});
return () => {
unsubscribe();
cancelRepoInfo();
};
}, [isCreateFlow, isVisible, ws]);
}, [
cancelRepoInfo,
inspectRepoInfo,
repoAvailabilityError,
resetRepoInfo,
shouldInspectRepo,
trimmedWorkingDir,
]);
useEffect(() => {
if (!repoInfo) {
return;
}
setBaseBranch((prev) => {
if (shouldSyncBaseBranchRef.current || prev.trim().length === 0) {
shouldSyncBaseBranchRef.current = false;
return repoInfo.currentBranch ?? "";
}
return prev;
});
}, [repoInfo]);
const handleCreate = useCallback(async () => {
const trimmedPath = workingDir.trim();
@@ -1087,6 +1207,14 @@ function AgentFlowModal({
return;
}
if (!createAgent || !isTargetDaemonReady) {
setErrorMessage(
daemonAvailabilityError ??
"Connect to the selected daemon before creating an agent."
);
return;
}
const trimmedBaseBranch = baseBranch.trim();
try {
@@ -1162,6 +1290,8 @@ function AgentFlowModal({
validateWorktreeName,
addRecentPath,
createAgent,
daemonAvailabilityError,
isTargetDaemonReady,
]);
const handleImportCandidatePress = useCallback(
@@ -1169,6 +1299,13 @@ function AgentFlowModal({
if (isLoading) {
return;
}
if (!resumeAgent || !isTargetDaemonReady) {
setImportError(
daemonAvailabilityError ??
"Connect to the selected daemon before importing an agent."
);
return;
}
setErrorMessage("");
const requestId = generateMessageId();
pendingRequestIdRef.current = requestId;
@@ -1178,14 +1315,20 @@ function AgentFlowModal({
requestId,
});
},
[isLoading, resumeAgent]
[
daemonAvailabilityError,
isLoading,
isTargetDaemonReady,
resumeAgent,
setImportError,
]
);
const renderImportItem = useCallback<ListRenderItem<ImportCandidate>>(
({ item }) => (
<Pressable
onPress={() => handleImportCandidatePress(item)}
disabled={isLoading}
disabled={isLoading || !isTargetDaemonReady}
style={styles.resumeItem}
>
<View style={styles.resumeItemHeader}>
@@ -1209,11 +1352,11 @@ function AgentFlowModal({
</View>
</Pressable>
),
[getProviderLabel, handleImportCandidatePress, isLoading]
[getProviderLabel, handleImportCandidatePress, isLoading, isTargetDaemonReady]
);
useEffect(() => {
if (!isImportFlow || !isVisible) {
if (!isImportFlow || !isVisible || !ws) {
return;
}
const unsubscribe = ws.on("list_persisted_agents_response", (message) => {
@@ -1240,7 +1383,7 @@ function AgentFlowModal({
}, [isImportFlow, isVisible, ws]);
useEffect(() => {
if (!shouldListenForStatus) {
if (!shouldListenForStatus || !ws) {
return;
}
const unsubscribe = ws.on("status", (message) => {
@@ -1295,7 +1438,7 @@ function AgentFlowModal({
}, [handleClose, shouldListenForStatus, ws]);
useEffect(() => {
if (!shouldListenForDictation) {
if (!shouldListenForDictation || !ws) {
return;
}
const unsubscribe = ws.on("transcription_result", (message) => {
@@ -1360,7 +1503,7 @@ function AgentFlowModal({
<PromptDictationControls
isRecording={isDictating}
isProcessing={isDictationProcessing}
disabled={isLoading || !isWsConnected}
disabled={isLoading || !isWsConnected || !isTargetDaemonReady}
volume={dictationVolume}
onStart={handleDictationStart}
onCancel={handleDictationCancel}
@@ -1426,7 +1569,8 @@ function AgentFlowModal({
workingDirIsEmpty ||
promptIsEmpty ||
Boolean(gitBlockingError) ||
isLoading;
isLoading ||
!isTargetDaemonReady;
const handlePromptDesktopSubmitKeyPress = useCallback(
(event: WebTextInputKeyPressEvent) => {
if (!shouldHandlePromptDesktopSubmit) {
@@ -1517,6 +1661,54 @@ function AgentFlowModal({
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={styles.daemonSelectorSection}>
<Text style={styles.daemonSelectorLabel}>Target Daemon</Text>
{daemonEntries.length === 0 ? (
<Text style={styles.daemonChipText}>No daemons available</Text>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.daemonSelectorChips}
>
{daemonEntries.map(({ daemon, status }) => {
const isSelected = daemon.id === selectedServerId;
const label = daemon.label || daemon.wsUrl;
return (
<Pressable
key={daemon.id}
onPress={() => handleSelectServer(daemon.id)}
style={[styles.daemonChip, isSelected && styles.daemonChipSelected]}
>
<Text
style={[
styles.daemonChipText,
isSelected && styles.daemonChipTextSelected,
]}
numberOfLines={1}
>
{label}
</Text>
<Text
style={[
styles.daemonChipStatus,
isSelected && styles.daemonChipTextSelected,
]}
>
{formatConnectionStatus(status)}
</Text>
</Pressable>
);
})}
</ScrollView>
)}
{daemonAvailabilityError ? (
<Text style={styles.daemonAvailabilityText}>
{daemonAvailabilityError}
</Text>
) : null}
</View>
<PromptSection
value={initialPrompt}
isLoading={isLoading}
@@ -1700,6 +1892,11 @@ function AgentFlowModal({
},
]}
>
{daemonAvailabilityError ? (
<Text style={styles.daemonAvailabilityText}>
{daemonAvailabilityError}
</Text>
) : null}
<View style={styles.resumeFilters}>
<View style={styles.providerFilterRow}>
{providerFilterOptions.map((option) => {
@@ -1736,7 +1933,7 @@ function AgentFlowModal({
<Pressable
style={styles.refreshButton}
onPress={refreshImportList}
disabled={isImportLoading}
disabled={isImportLoading || !isTargetDaemonReady}
>
<Text style={styles.refreshButtonText}>Refresh</Text>
</Pressable>
@@ -1764,6 +1961,7 @@ function AgentFlowModal({
<Pressable
style={styles.refreshButtonAlt}
onPress={refreshImportList}
disabled={isImportLoading || !isTargetDaemonReady}
>
<Text style={styles.refreshButtonAltText}>Try Again</Text>
</Pressable>
@@ -2956,6 +3154,50 @@ const styles = StyleSheet.create(((theme: any) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
daemonSelectorSection: {
marginBottom: theme.spacing[6],
},
daemonSelectorLabel: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
textTransform: "uppercase",
letterSpacing: 0.5,
marginBottom: theme.spacing[2],
},
daemonSelectorChips: {
flexDirection: "row",
gap: theme.spacing[2],
},
daemonChip: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
marginRight: theme.spacing[2],
},
daemonChipSelected: {
backgroundColor: theme.colors.palette.blue[900],
borderColor: theme.colors.palette.blue[500],
},
daemonChipText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
daemonChipTextSelected: {
color: theme.colors.palette.white,
},
daemonChipStatus: {
fontSize: theme.fontSize.xs,
color: theme.colors.mutedForeground,
},
daemonAvailabilityText: {
marginTop: theme.spacing[2],
marginBottom: theme.spacing[2],
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
footer: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,

View File

@@ -1,22 +1,35 @@
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Plus } from "lucide-react-native";
import { Plus, Download } from "lucide-react-native";
interface EmptyStateProps {
onCreateAgent: () => void;
onImportAgent?: () => void;
}
export function EmptyState({ onCreateAgent }: EmptyStateProps) {
export function EmptyState({ onCreateAgent, onImportAgent }: EmptyStateProps) {
const { theme } = useUnistyles();
const hasImportCta = typeof onImportAgent === "function";
return (
<View style={styles.container}>
<Text style={styles.title}>Hammock</Text>
<Text style={styles.subtitle}>What would you like to work on?</Text>
<Pressable onPress={onCreateAgent} style={styles.button}>
<Plus size={20} color={styles.buttonText.color} />
<Text style={styles.buttonText}>New agent</Text>
</Pressable>
<View style={styles.buttonGroup}>
<Pressable onPress={onCreateAgent} style={[styles.button, styles.primaryButton]}>
<Plus size={20} color={styles.primaryButtonText.color} />
<Text style={styles.primaryButtonText}>New agent</Text>
</Pressable>
{hasImportCta ? (
<Pressable
onPress={onImportAgent}
style={[styles.button, styles.secondaryButton]}
>
<Download size={20} color={styles.secondaryButtonText.color} />
<Text style={styles.secondaryButtonText}>Import agent</Text>
</Pressable>
) : null}
</View>
</View>
);
}
@@ -40,18 +53,35 @@ const styles = StyleSheet.create((theme) => ({
textAlign: "center",
marginBottom: theme.spacing[8],
},
buttonGroup: {
width: "100%",
gap: theme.spacing[3],
},
button: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
backgroundColor: theme.colors.primary,
borderRadius: theme.borderRadius.lg,
justifyContent: "center",
},
buttonText: {
primaryButton: {
backgroundColor: theme.colors.primary,
},
primaryButtonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.primaryForeground,
},
secondaryButton: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.muted,
},
secondaryButtonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
},
}));

View File

@@ -3,12 +3,13 @@ import { View, Pressable, Text, Platform } from "react-native";
import { usePathname, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AudioLines, Users, Plus } from "lucide-react-native";
import { AudioLines, Users, Plus, Download } from "lucide-react-native";
import { useRealtime } from "@/contexts/realtime-context";
import { useSession } from "@/contexts/session-context";
import { useFooterControls, FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { RealtimeControls } from "./realtime-controls";
import { CreateAgentModal } from "./create-agent-modal";
import { CreateAgentModal, ImportAgentModal } from "./create-agent-modal";
import Animated, {
FadeIn,
FadeOut,
@@ -23,8 +24,10 @@ export function GlobalFooter() {
const router = useRouter();
const { isRealtimeMode, startRealtime } = useRealtime();
const { ws } = useSession();
const { activeDaemonId } = useDaemonConnections();
const { controls } = useFooterControls();
const [showCreateModal, setShowCreateModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
// Guard Reanimated entry/exit transitions on Android to avoid ViewGroup.dispatchDraw crashes
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -72,7 +75,7 @@ export function GlobalFooter() {
return null;
}
// For home and orchestrator screens, show three buttons with realtime stacked on top
// For home and orchestrator screens, show action buttons with realtime stacked on top
const nonAgentFooterHeight = isRealtimeMode
? FOOTER_HEIGHT * 2 + insets.bottom
: FOOTER_HEIGHT + insets.bottom;
@@ -104,8 +107,8 @@ export function GlobalFooter() {
</Animated.View>
)}
{/* Three button menu - always visible */}
<View style={styles.threeButtonContainer}>
{/* Action menu */}
<View style={styles.actionButtonContainer}>
<Pressable
onPress={() => router.push("/")}
style={({ pressed }) => [
@@ -123,6 +126,23 @@ export function GlobalFooter() {
<Text style={styles.footerButtonText}>Agents</Text>
</Pressable>
<Pressable
onPress={() => setShowImportModal(true)}
style={({ pressed }) => [
styles.footerButton,
pressed && styles.buttonPressed,
]}
>
<View style={styles.footerIconWrapper}>
<Download
size={iconSize}
color={theme.colors.foreground}
style={iconStyle}
/>
</View>
<Text style={styles.footerButtonText}>Import</Text>
</Pressable>
<Pressable
onPress={() => {
console.log("[GlobalFooter] New Agent button pressed");
@@ -168,6 +188,12 @@ export function GlobalFooter() {
<CreateAgentModal
isVisible={showCreateModal}
onClose={() => setShowCreateModal(false)}
serverId={activeDaemonId}
/>
<ImportAgentModal
isVisible={showImportModal}
onClose={() => setShowImportModal(false)}
serverId={activeDaemonId}
/>
</>
);
@@ -187,7 +213,7 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
threeButtonContainer: {
actionButtonContainer: {
flexDirection: "row",
padding: theme.spacing[4],
gap: theme.spacing[3],

View File

@@ -1,9 +1,8 @@
import { useCallback, useState } from "react";
import { Modal, Pressable, Text, View } from "react-native";
import { useCallback } from "react";
import { Pressable } from "react-native";
import { router } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Settings, MessageSquare, MoreVertical, Plus } from "lucide-react-native";
import { Settings, MessageSquare, Download, Plus } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
interface HomeHeaderProps {
@@ -13,69 +12,40 @@ interface HomeHeaderProps {
export function HomeHeader({ onCreateAgent, onImportAgent }: HomeHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const [isMenuVisible, setIsMenuVisible] = useState(false);
const openMenu = useCallback(() => setIsMenuVisible(true), []);
const closeMenu = useCallback(() => setIsMenuVisible(false), []);
const handleCreatePress = useCallback(() => {
onCreateAgent();
}, [onCreateAgent]);
const handleImportPress = useCallback(() => {
closeMenu();
onImportAgent();
}, [closeMenu, onImportAgent]);
}, [onImportAgent]);
return (
<>
<ScreenHeader
left={
<ScreenHeader
left={
<Pressable
onPress={() => router.push("/settings")}
style={styles.iconButton}
>
<Settings size={20} color={theme.colors.foreground} />
</Pressable>
}
right={
<>
<Pressable
onPress={() => router.push("/settings")}
onPress={() => router.push("/orchestrator")}
style={styles.iconButton}
>
<Settings size={20} color={theme.colors.foreground} />
<MessageSquare size={20} color={theme.colors.foreground} />
</Pressable>
}
right={
<>
<Pressable
onPress={() => router.push("/orchestrator")}
style={styles.iconButton}
>
<MessageSquare size={20} color={theme.colors.foreground} />
</Pressable>
<Pressable onPress={onCreateAgent} style={styles.iconButton}>
<Plus size={20} color={theme.colors.foreground} />
</Pressable>
<Pressable onPress={openMenu} style={styles.iconButton}>
<MoreVertical size={20} color={theme.colors.foreground} />
</Pressable>
</>
}
/>
<Modal
visible={isMenuVisible}
transparent
animationType="fade"
onRequestClose={closeMenu}
>
<View style={styles.menuOverlay}>
<Pressable style={styles.menuBackdrop} onPress={closeMenu} />
<View
style={[
styles.menuContainer,
{
top: insets.top + theme.spacing[4],
right: theme.spacing[3],
},
]}
>
<Pressable style={styles.menuItem} onPress={handleImportPress}>
<Text style={styles.menuItemText}>Import Agent</Text>
</Pressable>
</View>
</View>
</Modal>
</>
<Pressable onPress={handleImportPress} style={styles.iconButton}>
<Download size={20} color={theme.colors.foreground} />
</Pressable>
<Pressable onPress={handleCreatePress} style={styles.iconButton}>
<Plus size={20} color={theme.colors.foreground} />
</Pressable>
</>
}
/>
);
}
@@ -84,32 +54,4 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
menuOverlay: {
flex: 1,
},
menuBackdrop: {
...StyleSheet.absoluteFillObject,
},
menuContainer: {
position: "absolute",
minWidth: 180,
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.background,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
shadowColor: "#000",
shadowOpacity: 0.15,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
menuItem: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
menuItemText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
}));

View File

@@ -0,0 +1,45 @@
import { useMemo } from "react";
import { SessionProvider } from "@/contexts/session-context";
import { useDaemonRegistry, type DaemonProfile } from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
export function MultiDaemonSessionHost() {
const { daemons } = useDaemonRegistry();
const { activeDaemonId } = useDaemonConnections();
const backgroundDaemons = useMemo<DaemonProfile[]>(() => {
const shouldConnect = new Map<string, DaemonProfile>();
for (const daemon of daemons) {
if (daemon.autoConnect) {
shouldConnect.set(daemon.id, daemon);
}
}
if (activeDaemonId) {
const activeDaemon = daemons.find((daemon) => daemon.id === activeDaemonId);
if (activeDaemon) {
shouldConnect.set(activeDaemon.id, activeDaemon);
}
// The active daemon already wraps the UI tree via ProvidersWrapper,
// so we skip rendering it here to avoid duplicate websocket connections.
shouldConnect.delete(activeDaemonId);
}
return Array.from(shouldConnect.values());
}, [daemons, activeDaemonId]);
if (backgroundDaemons.length === 0) {
return null;
}
return (
<>
{backgroundDaemons.map((daemon) => (
<SessionProvider key={daemon.id} serverUrl={daemon.wsUrl} serverId={daemon.id}>
{null}
</SessionProvider>
))}
</>
);
}

View File

@@ -0,0 +1,239 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDaemonRegistry, type DaemonProfile } from "./daemon-registry-context";
import type { SessionContextValue } from "./session-context";
export interface SessionDirectoryEntry {
getSnapshot: () => SessionContextValue | null;
subscribe: (listener: () => void) => () => void;
}
export type ConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export interface DaemonConnectionRecord {
daemon: DaemonProfile;
status: ConnectionStatus;
lastOnlineAt?: string;
lastError?: string | null;
}
interface DaemonConnectionsContextValue {
activeDaemonId: string | null;
activeDaemon: DaemonProfile | null;
connectionStates: Map<string, DaemonConnectionRecord>;
isLoading: boolean;
setActiveDaemonId: (daemonId: string) => void;
updateConnectionStatus: (
daemonId: string,
status: ConnectionStatus,
extras?: { lastError?: string | null; lastOnlineAt?: string }
) => void;
sessionAccessors: Map<string, SessionDirectoryEntry>;
registerSessionAccessor: (daemonId: string, entry: SessionDirectoryEntry) => void;
unregisterSessionAccessor: (daemonId: string) => void;
subscribeToSessionDirectory: (listener: () => void) => () => void;
notifySessionDirectoryChange: () => void;
}
const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(null);
const ACTIVE_DAEMON_STORAGE_KEY = "@paseo:active-daemon-id";
const ACTIVE_DAEMON_QUERY_KEY = ["active-daemon-id"];
export function useDaemonConnections(): DaemonConnectionsContextValue {
const ctx = useContext(DaemonConnectionsContext);
if (!ctx) {
throw new Error("useDaemonConnections must be used within DaemonConnectionsProvider");
}
return ctx;
}
export function DaemonConnectionsProvider({ children }: { children: ReactNode }) {
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const [activeDaemonId, setActiveDaemonIdState] = useState<string | null>(null);
const [connectionStates, setConnectionStates] = useState<Map<string, DaemonConnectionRecord>>(new Map());
const [sessionAccessors, setSessionAccessors] = useState<Map<string, SessionDirectoryEntry>>(new Map());
const queryClient = useQueryClient();
const activeDaemonPreference = useQuery({
queryKey: ACTIVE_DAEMON_QUERY_KEY,
queryFn: loadActiveDaemonPreference,
staleTime: Infinity,
gcTime: Infinity,
});
const sessionDirectoryListenersRef = useRef<Set<() => void>>(new Set());
useEffect(() => {
if (activeDaemonPreference.isPending) {
return;
}
setActiveDaemonIdState(activeDaemonPreference.data ?? null);
}, [activeDaemonPreference.data, activeDaemonPreference.isPending]);
// Ensure connection states stay in sync with registry entries
useEffect(() => {
setConnectionStates((prev) => {
const next = new Map<string, DaemonConnectionRecord>();
for (const daemon of daemons) {
const existing = prev.get(daemon.id);
next.set(daemon.id, {
daemon,
status: existing?.status ?? "idle",
lastOnlineAt: existing?.lastOnlineAt,
lastError: existing?.lastError,
});
}
return next;
});
}, [daemons]);
useEffect(() => {
setSessionAccessors((prev) => {
const next = new Map(prev);
for (const key of Array.from(next.keys())) {
if (!daemons.some((daemon) => daemon.id === key)) {
next.delete(key);
}
}
return next;
});
}, [daemons]);
// Keep active daemon aligned with registry default
const persistActiveDaemonId = useCallback(async (daemonId: string | null) => {
queryClient.setQueryData<string | null>(ACTIVE_DAEMON_QUERY_KEY, daemonId);
try {
if (daemonId) {
await AsyncStorage.setItem(ACTIVE_DAEMON_STORAGE_KEY, daemonId);
} else {
await AsyncStorage.removeItem(ACTIVE_DAEMON_STORAGE_KEY);
}
} catch (error) {
console.error("[DaemonConnections] Failed to persist active daemon", error);
}
}, [queryClient]);
const setActiveDaemonId = useCallback(
(daemonId: string) => {
setActiveDaemonIdState(daemonId);
void persistActiveDaemonId(daemonId);
},
[persistActiveDaemonId]
);
useEffect(() => {
if (activeDaemonPreference.isPending) {
return;
}
if (daemons.length === 0) {
setActiveDaemonIdState(null);
void persistActiveDaemonId(null);
return;
}
if (activeDaemonId && daemons.some((daemon) => daemon.id === activeDaemonId)) {
return;
}
const fallback = daemons.find((daemon) => daemon.isDefault) ?? daemons[0];
setActiveDaemonId(fallback.id);
}, [daemons, activeDaemonId, activeDaemonPreference.isPending, persistActiveDaemonId, setActiveDaemonId]);
const activeDaemon = useMemo(() => {
if (!activeDaemonId) {
return null;
}
return daemons.find((daemon) => daemon.id === activeDaemonId) ?? null;
}, [activeDaemonId, daemons]);
const updateConnectionStatus = useCallback(
(
daemonId: string,
status: ConnectionStatus,
extras?: { lastError?: string | null; lastOnlineAt?: string }
) => {
setConnectionStates((prev) => {
const next = new Map(prev);
const existing = next.get(daemonId);
if (!existing) {
return prev;
}
const hasExplicitLastError = Boolean(extras && Object.prototype.hasOwnProperty.call(extras, "lastError"));
next.set(daemonId, {
...existing,
status,
lastError: hasExplicitLastError ? (extras!.lastError ?? null) : existing.lastError ?? null,
lastOnlineAt: extras?.lastOnlineAt ?? existing.lastOnlineAt,
});
return next;
});
},
[]
);
const registerSessionAccessor = useCallback((daemonId: string, entry: SessionDirectoryEntry) => {
setSessionAccessors((prev) => {
const next = new Map(prev);
next.set(daemonId, entry);
return next;
});
}, []);
const unregisterSessionAccessor = useCallback((daemonId: string) => {
setSessionAccessors((prev) => {
const next = new Map(prev);
next.delete(daemonId);
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 value: DaemonConnectionsContextValue = {
activeDaemonId,
activeDaemon,
connectionStates,
isLoading: registryLoading || activeDaemonPreference.isPending,
setActiveDaemonId,
updateConnectionStatus,
sessionAccessors,
registerSessionAccessor,
unregisterSessionAccessor,
subscribeToSessionDirectory,
notifySessionDirectoryChange,
};
return (
<DaemonConnectionsContext.Provider value={value}>
{children}
</DaemonConnectionsContext.Provider>
);
}
async function loadActiveDaemonPreference(): Promise<string | null> {
try {
const stored = await AsyncStorage.getItem(ACTIVE_DAEMON_STORAGE_KEY);
return stored ?? null;
} catch (error) {
console.error("[DaemonConnections] Failed to read active daemon preference", error);
throw error;
}
}

View File

@@ -0,0 +1,218 @@
import { createContext, useCallback, useContext, useMemo } from "react";
import type { ReactNode } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
const FALLBACK_DAEMON_URL = "ws://localhost:6767/ws";
const DAEMON_REGISTRY_QUERY_KEY = ["daemon-registry"];
export type DaemonProfile = {
id: string;
label: string;
wsUrl: string;
restUrl?: string | null;
autoConnect: boolean;
isDefault: boolean;
createdAt: string;
updatedAt: string;
metadata?: Record<string, unknown> | null;
};
type CreateDaemonInput = {
label: string;
wsUrl: string;
restUrl?: string | null;
autoConnect?: boolean;
isDefault?: boolean;
};
type UpdateDaemonInput = Partial<Omit<DaemonProfile, "id" | "createdAt">>;
interface DaemonRegistryContextValue {
daemons: DaemonProfile[];
isLoading: boolean;
error: unknown | null;
defaultDaemon: DaemonProfile | null;
addDaemon: (input: CreateDaemonInput) => Promise<DaemonProfile>;
updateDaemon: (id: string, updates: UpdateDaemonInput) => Promise<void>;
removeDaemon: (id: string) => Promise<void>;
setDefaultDaemon: (id: string) => Promise<void>;
}
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null);
export function useDaemonRegistry(): DaemonRegistryContextValue {
const ctx = useContext(DaemonRegistryContext);
if (!ctx) {
throw new Error("useDaemonRegistry must be used within DaemonRegistryProvider");
}
return ctx;
}
export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const { data: daemons = [], isPending, error } = useQuery({
queryKey: DAEMON_REGISTRY_QUERY_KEY,
queryFn: loadDaemonRegistryFromStorage,
staleTime: Infinity,
gcTime: Infinity,
});
const persist = useCallback(
async (profiles: DaemonProfile[]) => {
queryClient.setQueryData<DaemonProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles);
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles));
},
[queryClient]
);
const readDaemons = useCallback(() => {
return queryClient.getQueryData<DaemonProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons;
}, [queryClient, daemons]);
const addDaemon = useCallback(async (input: CreateDaemonInput) => {
const existing = readDaemons();
const timestamp = new Date().toISOString();
const profile: DaemonProfile = {
id: generateDaemonId(),
label: input.label.trim() || deriveLabelFromUrl(input.wsUrl),
wsUrl: input.wsUrl,
restUrl: input.restUrl ?? null,
autoConnect: input.autoConnect ?? true,
isDefault: input.isDefault ?? existing.length === 0,
createdAt: timestamp,
updatedAt: timestamp,
metadata: null,
};
const next = normalizeDefaults([...existing, profile]);
await persist(next);
return profile;
}, [persist, readDaemons]);
const updateDaemon = useCallback(async (id: string, updates: UpdateDaemonInput) => {
const next = readDaemons().map((daemon) =>
daemon.id === id
? {
...daemon,
...updates,
updatedAt: new Date().toISOString(),
}
: daemon
);
await persist(next);
}, [persist, readDaemons]);
const removeDaemon = useCallback(async (id: string) => {
const remaining = readDaemons().filter((daemon) => daemon.id !== id);
const normalized = normalizeDefaults(remaining);
await persist(normalized.length > 0 ? normalized : [createProfile("Local Daemon", FALLBACK_DAEMON_URL, true)]);
}, [persist, readDaemons]);
const setDefaultDaemon = useCallback(async (id: string) => {
const next = readDaemons().map((daemon) => ({
...daemon,
isDefault: daemon.id === id,
updatedAt: daemon.id === id ? new Date().toISOString() : daemon.updatedAt,
}));
await persist(next);
}, [persist, readDaemons]);
const defaultDaemon = useMemo(() => {
if (daemons.length === 0) {
return null;
}
const explicit = daemons.find((daemon) => daemon.isDefault);
return explicit ?? daemons[0];
}, [daemons]);
const value: DaemonRegistryContextValue = {
daemons,
isLoading: isPending,
error: error ?? null,
defaultDaemon,
addDaemon,
updateDaemon,
removeDaemon,
setDefaultDaemon,
};
return (
<DaemonRegistryContext.Provider value={value}>
{children}
</DaemonRegistryContext.Provider>
);
}
function generateDaemonId(): string {
const random = Math.random().toString(36).slice(2, 8);
return `daemon_${Date.now().toString(36)}_${random}`;
}
function deriveLabelFromUrl(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname || "Unnamed Daemon";
} catch {
return "Unnamed Daemon";
}
}
function createProfile(label: string, wsUrl: string, isDefault: boolean): DaemonProfile {
const timestamp = new Date().toISOString();
return {
id: generateDaemonId(),
label,
wsUrl,
restUrl: null,
autoConnect: true,
isDefault,
createdAt: timestamp,
updatedAt: timestamp,
metadata: null,
};
}
function normalizeDefaults(profiles: DaemonProfile[]): DaemonProfile[] {
if (profiles.length === 0) {
return profiles;
}
const hasDefault = profiles.some((daemon) => daemon.isDefault);
if (hasDefault) {
return profiles;
}
const [first, ...rest] = profiles;
return [
{ ...first, isDefault: true },
...rest,
];
}
async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
try {
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
if (stored) {
return JSON.parse(stored) as DaemonProfile[];
}
const legacy = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
if (legacy) {
const legacyParsed = JSON.parse(legacy) as Record<string, unknown>;
const legacyUrl = typeof legacyParsed.serverUrl === "string" ? legacyParsed.serverUrl : null;
if (legacyUrl) {
const migrated = [createProfile("Primary Daemon", legacyUrl, true)];
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated));
return migrated;
}
}
const fallback = [createProfile("Local Daemon", FALLBACK_DAEMON_URL, true)];
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(fallback));
return fallback;
} catch (error) {
console.error("[DaemonRegistry] Failed to load daemon registry", error);
throw error;
}
}

View File

@@ -1,5 +1,7 @@
import { createContext, useContext, useState, useRef, ReactNode, useCallback, useEffect } from "react";
import { createContext, useContext, useState, useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useWebSocket, type UseWebSocketReturn } from "@/hooks/use-websocket";
import { useDaemonRequest } from "@/hooks/use-daemon-request";
import { useAudioPlayer } from "@/hooks/use-audio-player";
import { reduceStreamUpdate, generateMessageId, hydrateStreamState, type StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
@@ -9,6 +11,7 @@ import type {
AgentStreamEventPayload,
WSInboundMessage,
GitSetupOptions,
SessionOutboundMessage,
} from "@server/server/messages";
import type {
AgentLifecycleStatus,
@@ -26,6 +29,7 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import { ScrollView } from "react-native";
import * as FileSystem from 'expo-file-system';
import { useDaemonConnections } from "./daemon-connections-context";
const derivePendingPermissionKey = (agentId: string, request: AgentPermissionRequest) => {
const fallbackId =
@@ -43,6 +47,16 @@ type DraftInput = {
images: Array<{ uri: string; mimeType: string }>;
};
type GitDiffResponseMessage = Extract<
SessionOutboundMessage,
{ type: "git_diff_response" }
>;
type FileExplorerResponseMessage = Extract<
SessionOutboundMessage,
{ type: "file_explorer_response" }
>;
export type MessageEntry =
| {
type: "user";
@@ -91,6 +105,7 @@ type ProviderModelState = {
};
export interface Agent {
serverId: string;
id: string;
provider: AgentProvider;
status: AgentLifecycleStatus;
@@ -183,6 +198,48 @@ const pushHistory = (history: string[], path: string): string[] => {
return [...normalizedHistory, path];
};
const SESSION_SNAPSHOT_STORAGE_PREFIX = "@paseo:session-snapshot:";
type PersistedSessionSnapshot = {
agents: AgentSnapshotPayload[];
commands: Command[];
savedAt: string;
};
const getSessionSnapshotStorageKey = (serverId: string): string => {
return `${SESSION_SNAPSHOT_STORAGE_PREFIX}${serverId}`;
};
async function loadPersistedSessionSnapshot(serverId: string): Promise<PersistedSessionSnapshot | null> {
try {
const raw = await AsyncStorage.getItem(getSessionSnapshotStorageKey(serverId));
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PersistedSessionSnapshot;
if (!Array.isArray(parsed?.agents) || !Array.isArray(parsed?.commands)) {
return null;
}
return parsed;
} catch (error) {
console.error(`[Session] Failed to load persisted snapshot for ${serverId}`, error);
return null;
}
}
async function persistSessionSnapshot(serverId: string, snapshot: { agents: AgentSnapshotPayload[]; commands: Command[] }) {
try {
const payload: PersistedSessionSnapshot = {
agents: snapshot.agents,
commands: snapshot.commands,
savedAt: new Date().toISOString(),
};
await AsyncStorage.setItem(getSessionSnapshotStorageKey(serverId), JSON.stringify(payload));
} catch (error) {
console.error(`[Session] Failed to persist snapshot for ${serverId}`, error);
}
}
type PendingAgentLifecycleRequest = {
kind: "initialize" | "refresh";
params: {
@@ -191,11 +248,12 @@ type PendingAgentLifecycleRequest = {
};
};
function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload): Agent {
function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string): Agent {
const createdAt = new Date(snapshot.createdAt);
const updatedAt = new Date(snapshot.updatedAt);
const lastUserMessageAt = snapshot.lastUserMessageAt ? new Date(snapshot.lastUserMessageAt) : null;
return {
serverId,
id: snapshot.id,
provider: snapshot.provider,
status: snapshot.status as AgentLifecycleStatus,
@@ -217,8 +275,25 @@ function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload): Agent {
};
}
function buildSessionStateFromSnapshots(serverId: string, snapshots: AgentSnapshotPayload[]) {
const agents = new Map<string, Agent>();
const pendingPermissions = new Map<string, PendingPermission>();
interface SessionContextValue {
for (const snapshot of snapshots) {
const agent = normalizeAgentSnapshot(snapshot, serverId);
agents.set(agent.id, agent);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
pendingPermissions.set(key, { key, agentId: agent.id, request });
}
}
return { agents, pendingPermissions };
}
export interface SessionContextValue {
serverId: string;
// WebSocket
ws: UseWebSocketReturn;
@@ -275,7 +350,11 @@ interface SessionContextValue {
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
refreshAgent: (params: { agentId: string; requestId?: string }) => void;
cancelAgentRun: (agentId: string) => void;
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
sendAgentMessage: (
agentId: string,
message: string,
images?: Array<{ uri: string; mimeType?: string }>
) => Promise<void>;
sendAgentAudio: (
agentId: string,
audioBlob: Blob,
@@ -308,11 +387,46 @@ export function useSession() {
interface SessionProviderProps {
children: ReactNode;
serverUrl: string;
serverId: string;
}
export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
export function SessionProvider({ children, serverUrl, serverId }: SessionProviderProps) {
const ws = useWebSocket(serverUrl);
const wsIsConnected = ws.isConnected;
const {
updateConnectionStatus,
registerSessionAccessor,
unregisterSessionAccessor,
notifySessionDirectoryChange,
} = useDaemonConnections();
useEffect(() => {
if (ws.isConnected) {
updateConnectionStatus(serverId, "online", {
lastOnlineAt: new Date().toISOString(),
lastError: null,
});
return;
}
if (ws.isConnecting) {
updateConnectionStatus(serverId, "connecting");
return;
}
if (ws.lastError) {
updateConnectionStatus(serverId, "error", { lastError: ws.lastError });
return;
}
updateConnectionStatus(serverId, "offline");
}, [serverId, updateConnectionStatus, ws.isConnected, ws.isConnecting, ws.lastError]);
useEffect(() => {
return () => {
updateConnectionStatus(serverId, "offline", { lastError: null });
};
}, [serverId, updateConnectionStatus]);
// State for voice detection flags (will be set by RealtimeContext)
const isDetectingRef = useRef(false);
@@ -353,6 +467,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
const providerModelRequestIdsRef = useRef<Map<AgentProvider, string>>(new Map());
const pendingAgentLifecycleRequestsRef = useRef<PendingAgentLifecycleRequest[]>([]);
const hasHydratedSnapshotRef = useRef(false);
// Buffer for streaming audio chunks
interface AudioChunk {
@@ -368,6 +483,54 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
setFocusedAgentOverride(agentId);
}, []);
useEffect(() => {
let isMounted = true;
const hydrateFromSnapshot = async () => {
if (hasHydratedSnapshotRef.current) {
return;
}
hasHydratedSnapshotRef.current = true;
const snapshot = await loadPersistedSessionSnapshot(serverId);
if (!snapshot || !isMounted) {
return;
}
const { agents: hydratedAgents, pendingPermissions: hydratedPermissions } = buildSessionStateFromSnapshots(
serverId,
snapshot.agents
);
let applied = false;
setAgents((prev) => {
if (prev.size > 0) {
return prev;
}
applied = true;
return hydratedAgents;
});
if (!applied) {
return;
}
setPendingPermissions(hydratedPermissions);
const commandEntries = snapshot.commands ?? [];
setCommands((prev) => {
if (prev.size > 0) {
return prev;
}
return new Map(commandEntries.map((command) => [command.id, command]));
});
};
void hydrateFromSnapshot();
return () => {
isMounted = false;
};
}, [serverId]);
useEffect(() => {
if (focusedAgentOverride) {
if (orchestratorFocusedAgentId !== null) {
@@ -408,6 +571,91 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
[]
);
const gitDiffRequest = useDaemonRequest<
{ agentId: string },
{ agentId: string; diff: string },
GitDiffResponseMessage
>({
ws,
responseType: "git_diff_response",
buildRequest: ({ params }) => ({
type: "session",
message: {
type: "git_diff_request",
agentId: params?.agentId ?? "",
},
}),
matchResponse: (message, context) =>
message.payload.agentId === context.params?.agentId,
getRequestKey: (params) => params?.agentId ?? "default",
selectData: (message) => ({
agentId: message.payload.agentId,
diff: message.payload.diff ?? "",
}),
extractError: (message) =>
message.payload.error ? new Error(message.payload.error) : null,
timeoutMs: 10000,
keepPreviousData: false,
});
const directoryListingRequest = useDaemonRequest<
{ agentId: string; path: string },
FileExplorerResponseMessage["payload"],
FileExplorerResponseMessage
>({
ws,
responseType: "file_explorer_response",
buildRequest: ({ params }) => ({
type: "session",
message: {
type: "file_explorer_request",
agentId: params?.agentId ?? "",
path: params?.path,
mode: "list",
},
}),
matchResponse: (message, context) =>
message.payload.mode === "list" &&
message.payload.agentId === context.params?.agentId &&
message.payload.path === context.params?.path,
getRequestKey: (params) =>
params ? `${params.agentId}:list:${params.path}` : "default",
selectData: (message) => message.payload,
extractError: (message) =>
message.payload.error ? new Error(message.payload.error) : null,
timeoutMs: 10000,
keepPreviousData: false,
});
const filePreviewRequest = useDaemonRequest<
{ agentId: string; path: string },
FileExplorerResponseMessage["payload"],
FileExplorerResponseMessage
>({
ws,
responseType: "file_explorer_response",
buildRequest: ({ params }) => ({
type: "session",
message: {
type: "file_explorer_request",
agentId: params?.agentId ?? "",
path: params?.path,
mode: "file",
},
}),
matchResponse: (message, context) =>
message.payload.mode === "file" &&
message.payload.agentId === context.params?.agentId &&
message.payload.path === context.params?.path,
getRequestKey: (params) =>
params ? `${params.agentId}:file:${params.path}` : "default",
selectData: (message) => message.payload,
extractError: (message) =>
message.payload.error ? new Error(message.payload.error) : null,
timeoutMs: 10000,
keepPreviousData: false,
});
const sendAgentLifecycleRequest = useCallback(
(request: PendingAgentLifecycleRequest) => {
const { kind, params } = request;
@@ -452,21 +700,15 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
console.log("[Session] Session state:", agentsList.length, "agents,", commandsList.length, "commands");
const nextAgents = new Map<string, Agent>();
const nextPermissions = new Map<string, PendingPermission>();
const { agents: hydratedAgents, pendingPermissions: hydratedPermissions } = buildSessionStateFromSnapshots(
serverId,
agentsList
);
const normalizedCommands = commandsList.map((command) => command as Command);
for (const snapshot of agentsList) {
const agent = normalizeAgentSnapshot(snapshot);
nextAgents.set(agent.id, agent);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
nextPermissions.set(key, { key, agentId: agent.id, request });
}
}
setAgents(nextAgents);
setPendingPermissions(nextPermissions);
setCommands(new Map(commandsList.map((c) => [c.id, c as Command])));
setAgents(hydratedAgents);
setPendingPermissions(hydratedPermissions);
setCommands(new Map(normalizedCommands.map((command) => [command.id, command])));
setAgentStreamState((prev) => {
if (prev.size === 0) {
return prev;
@@ -503,12 +745,13 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
return changed ? next : prev;
});
void persistSessionSnapshot(serverId, { agents: agentsList, commands: normalizedCommands });
});
const unsubAgentState = ws.on("agent_state", (message) => {
if (message.type !== "agent_state") return;
const snapshot = message.payload;
const agent = normalizeAgentSnapshot(snapshot);
const agent = normalizeAgentSnapshot(snapshot, serverId);
console.log("[Session] Agent state update:", agent.id, agent.status);
@@ -912,62 +1155,6 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
}
});
// Git diff response handler
const unsubGitDiff = ws.on("git_diff_response", (message) => {
if (message.type !== "git_diff_response") return;
const { agentId, diff, error } = message.payload;
console.log("[Session] Git diff response for agent:", agentId, error ? `(error: ${error})` : "");
if (error) {
console.error("[Session] Git diff error:", error);
setGitDiffs((prev) => new Map(prev).set(agentId, `Error: ${error}`));
} else {
setGitDiffs((prev) => new Map(prev).set(agentId, diff || ""));
}
});
const unsubFileExplorer = ws.on("file_explorer_response", (message) => {
if (message.type !== "file_explorer_response") {
return;
}
const { agentId, directory, file, mode, error } = message.payload;
console.log(
"[Session] File explorer response for agent:",
agentId,
mode,
error ? `(error: ${error})` : ""
);
updateExplorerState(agentId, (state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: error ?? null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
if (!error) {
if (mode === "list" && directory) {
const directories = new Map(state.directories);
directories.set(directory.path, directory);
nextState.directories = directories;
}
if (mode === "file" && file) {
const files = new Map(state.files);
files.set(file.path, file);
nextState.files = files;
}
}
return nextState;
});
});
const unsubProviderModels = ws.on(
"list_provider_models_response",
(message) => {
@@ -1074,8 +1261,6 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
unsubActivity();
unsubChunk();
unsubTranscription();
unsubGitDiff();
unsubFileExplorer();
unsubProviderModels();
unsubAgentDeleted();
};
@@ -1166,7 +1351,11 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg);
}, [ws]);
const sendAgentMessage = useCallback(async (agentId: string, message: string, imageUris?: string[]) => {
const sendAgentMessage = useCallback(async (
agentId: string,
message: string,
images?: Array<{ uri: string; mimeType?: string }>
) => {
// Generate unique message ID for deduplication
const messageId = generateMessageId();
@@ -1186,24 +1375,28 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
// Convert images to base64 if provided
let imagesData: Array<{ data: string; mimeType: string }> | undefined;
if (imageUris && imageUris.length > 0) {
imagesData = [];
for (const imageUri of imageUris) {
try {
// Use FileSystem.File to read the image as base64
const file = new FileSystem.File(imageUri);
const base64 = file.base64Sync();
// Get MIME type from the file
const mimeType = file.type || 'image/jpeg';
imagesData.push({
data: base64,
mimeType,
});
} catch (error) {
console.error('[Session] Failed to convert image:', error);
}
if (images && images.length > 0) {
const encodedImages = await Promise.all(
images.map(async ({ uri, mimeType }) => {
try {
const data = await FileSystem.readAsStringAsync(uri, {
encoding: "base64",
});
return {
data,
mimeType: mimeType ?? "image/jpeg",
};
} catch (error) {
console.error("[Session] Failed to convert image:", error);
return null;
}
})
);
const validImages = encodedImages.filter(
(entry): entry is { data: string; mimeType: string } => entry !== null
);
if (validImages.length > 0) {
imagesData = validImages;
}
}
@@ -1229,7 +1422,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
const queue = queuedMessages.get(agentId);
if (queue && queue.length > 0) {
const [next, ...rest] = queue;
void sendAgentMessage(agentId, next.text, next.images?.map((img) => img.uri));
void sendAgentMessage(agentId, next.text, next.images);
setQueuedMessages((prev) => {
const updated = new Map(prev);
updated.set(agentId, rest);
@@ -1389,15 +1582,15 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
}, []);
const requestGitDiff = useCallback((agentId: string) => {
const msg: WSInboundMessage = {
type: "session",
message: {
type: "git_diff_request",
agentId,
},
};
ws.send(msg);
}, [ws]);
gitDiffRequest
.execute({ agentId })
.then((result) => {
setGitDiffs((prev) => new Map(prev).set(result.agentId, result.diff));
})
.catch((error) => {
setGitDiffs((prev) => new Map(prev).set(agentId, `Error: ${error.message}`));
});
}, [gitDiffRequest, setGitDiffs]);
const requestDirectoryListing = useCallback((agentId: string, path: string, options?: { recordHistory?: boolean }) => {
const normalizedPath = path && path.length > 0 ? path : ".";
@@ -1413,17 +1606,37 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
lastVisitedPath: normalizedPath,
}));
const msg: WSInboundMessage = {
type: "session",
message: {
type: "file_explorer_request",
agentId,
path: normalizedPath,
mode: "list",
},
};
ws.send(msg);
}, [updateExplorerState, ws]);
directoryListingRequest
.execute({ agentId, path: normalizedPath })
.then((payload) => {
updateExplorerState(agentId, (state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: payload.error ?? null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
if (!payload.error && payload.directory) {
const directories = new Map(state.directories);
directories.set(payload.directory.path, payload.directory);
nextState.directories = directories;
}
return nextState;
});
})
.catch((error) => {
updateExplorerState(agentId, (state) => ({
...state,
isLoading: false,
lastError: error.message,
pendingRequest: null,
}));
});
}, [directoryListingRequest, updateExplorerState]);
const requestFilePreview = useCallback((agentId: string, path: string) => {
const normalizedPath = path && path.length > 0 ? path : ".";
@@ -1434,17 +1647,37 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
pendingRequest: { path: normalizedPath, mode: "file" },
}));
const msg: WSInboundMessage = {
type: "session",
message: {
type: "file_explorer_request",
agentId,
path: normalizedPath,
mode: "file",
},
};
ws.send(msg);
}, [updateExplorerState, ws]);
filePreviewRequest
.execute({ agentId, path: normalizedPath })
.then((payload) => {
updateExplorerState(agentId, (state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: payload.error ?? null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
if (!payload.error && payload.file) {
const files = new Map(state.files);
files.set(payload.file.path, payload.file);
nextState.files = files;
}
return nextState;
});
})
.catch((error) => {
updateExplorerState(agentId, (state) => ({
...state,
isLoading: false,
lastError: error.message,
pendingRequest: null,
}));
});
}, [filePreviewRequest, updateExplorerState]);
const navigateExplorerBack = useCallback((agentId: string) => {
let targetPath: string | null = null;
@@ -1485,6 +1718,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
}, [updateExplorerState, ws]);
const value: SessionContextValue = {
serverId,
ws,
audioPlayer,
isPlayingAudio,
@@ -1530,6 +1764,42 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
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,
subscribe: subscribeToSession,
}),
[sessionAccessor, subscribeToSession]
);
useEffect(() => {
registerSessionAccessor(serverId, sessionDirectoryEntry);
return () => {
unregisterSessionAccessor(serverId);
};
}, [serverId, registerSessionAccessor, unregisterSessionAccessor, sessionDirectoryEntry]);
return (
<SessionContext.Provider value={value}>
{children}

View File

@@ -0,0 +1,90 @@
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 AggregatedAgentGroup {
serverId: string;
serverLabel: string;
agents: Agent[];
}
const sortAgents = (agents: Agent[]): Agent[] => {
return [...agents].sort((left, right) => {
const leftRunning = left.status === "running";
const rightRunning = right.status === "running";
if (leftRunning && !rightRunning) {
return -1;
}
if (!leftRunning && rightRunning) {
return 1;
}
const leftTime = (left.lastUserMessageAt ?? left.lastActivityAt).getTime();
const rightTime = (right.lastUserMessageAt ?? right.lastActivityAt).getTime();
return rightTime - leftTime;
});
};
export function useAggregatedAgents(fallbackAgents: Map<string, Agent>): AggregatedAgentGroup[] {
const { activeDaemonId, connectionStates } = useDaemonConnections();
const sessionDirectory = useSessionDirectory();
const activeServerId = activeDaemonId ?? "default";
return useMemo(() => {
const groups = new Map<string, { serverLabel: string; agents: Agent[] }>();
const daemonOrder = new Map<string, number>();
Array.from(connectionStates.keys()).forEach((serverId, index) => {
daemonOrder.set(serverId, index);
});
if (sessionDirectory.size > 0) {
sessionDirectory.forEach((session, serverId) => {
if (!session) {
return;
}
const label = connectionStates.get(serverId)?.daemon.label ?? serverId;
const sessionAgents = Array.from(session.agents.values());
if (sessionAgents.length === 0) {
return;
}
const existing = groups.get(serverId);
if (existing) {
existing.agents.push(...sessionAgents);
} else {
groups.set(serverId, { serverLabel: label, agents: sessionAgents });
}
});
}
if (groups.size === 0) {
const label = connectionStates.get(activeServerId)?.daemon.label ?? activeServerId;
const fallbackList = Array.from(fallbackAgents.values());
if (fallbackList.length > 0) {
groups.set(activeServerId, { serverLabel: label, agents: fallbackList });
}
}
const aggregatedGroups = Array.from(groups.entries()).map(([serverId, { serverLabel, agents }]) => ({
serverId,
serverLabel,
agents: sortAgents(agents),
}));
aggregatedGroups.sort((left, right) => {
const leftIndex = daemonOrder.get(left.serverId);
const rightIndex = daemonOrder.get(right.serverId);
if (leftIndex !== undefined && rightIndex !== undefined && leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
if (leftIndex !== undefined) {
return -1;
}
if (rightIndex !== undefined) {
return 1;
}
return left.serverLabel.localeCompare(right.serverLabel);
});
return aggregatedGroups;
}, [sessionDirectory, fallbackAgents, activeServerId, connectionStates]);
}

View File

@@ -0,0 +1,548 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { SessionOutboundMessage, WSInboundMessage } from "@server/server/messages";
import type { UseWebSocketReturn } from "./use-websocket";
import { generateMessageId } from "@/types/stream";
type RequestStatus = "idle" | "loading" | "success" | "error";
type MaybeUndefined<T> = T | undefined;
type ExecuteParams<TParams> = TParams extends void ? void | undefined : TParams;
export interface RequestContext<TParams> {
params: MaybeUndefined<TParams>;
requestId: string;
key: string;
attempt: number;
}
type RetryDelay =
| number
| ((attempt: number) => number);
export interface UseDaemonRequestOptions<
TParams = void,
TData = unknown,
TMessage extends SessionOutboundMessage = SessionOutboundMessage
> {
ws: UseWebSocketReturn;
/**
* Session message type to subscribe to for responses.
*/
responseType: TMessage["type"];
/**
* Builder that receives params + the generated requestId and returns the outbound WS message.
*/
buildRequest: (context: { params: MaybeUndefined<TParams>; requestId: string }) => WSInboundMessage;
/**
* Extracts the typed data from the inbound response.
*/
selectData: (message: TMessage, context: RequestContext<TParams>) => TData;
/**
* Override response matching behavior. Defaults to comparing payload.requestId when available.
*/
matchResponse?: (message: TMessage, context: RequestContext<TParams>) => boolean;
/**
* Returns the request key for dedupe. Defaults to JSON.stringify(params) or "default".
*/
getRequestKey?: (params: MaybeUndefined<TParams>) => string;
/**
* Returns an error (string or Error) when the payload represents a failure.
*/
extractError?: (message: TMessage, context: RequestContext<TParams>) => string | Error | null;
/**
* Milliseconds before a request automatically times out. Pass null to disable.
*/
timeoutMs?: number | null;
/**
* Number of retry attempts after the initial send.
*/
retryCount?: number;
/**
* Delay between retries (ms) or function that maps attempt -> delay.
*/
retryDelayMs?: RetryDelay;
/**
* Keep previously resolved data while loading.
*/
keepPreviousData?: boolean;
/**
* Provide initial data before running the request.
*/
initialData?: TData | null;
/**
* Disable deduplication (send every execute call even if one is in-flight).
*/
dedupe?: boolean;
}
export interface ExecuteRequestOptions {
timeoutMs?: number | null;
retryCount?: number;
retryDelayMs?: RetryDelay;
dedupe?: boolean;
requestKeyOverride?: string;
}
export interface UseDaemonRequestResult<TParams, TData> {
status: RequestStatus;
data: TData | null;
error: Error | null;
isIdle: boolean;
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
requestId: string | null;
updatedAt: number | null;
execute: (params?: ExecuteParams<TParams>, options?: ExecuteRequestOptions) => Promise<TData>;
reset: () => void;
cancel: (reason?: string) => void;
}
interface ActiveRequest<TParams, TData, TMessage extends SessionOutboundMessage> {
key: string;
params: MaybeUndefined<TParams>;
requestId: string;
attempt: number;
promise: Promise<TData>;
resolve: (data: TData) => void;
reject: (error: Error) => void;
timeoutHandle: ReturnType<typeof setTimeout> | null;
retryHandle: ReturnType<typeof setTimeout> | null;
aborted: boolean;
options: ResolvedBehavior;
}
interface ResolvedBehavior {
timeoutMs: number | null;
retryCount: number;
retryDelayMs: RetryDelay;
dedupe: boolean;
}
const DEFAULT_TIMEOUT_MS = 15000;
const DEFAULT_RETRY_DELAY_MS = 750;
function useLatest<T>(value: T) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
function resolveKey(params: unknown): string {
if (params === undefined || params === null) {
return "default";
}
if (typeof params === "string" || typeof params === "number" || typeof params === "boolean") {
return String(params);
}
try {
return JSON.stringify(params);
} catch {
return "default";
}
}
function ensureError(error: unknown): Error {
if (error instanceof Error) {
return error;
}
return new Error(typeof error === "string" ? error : "Unknown request error");
}
export function useDaemonRequest<
TParams = void,
TData = unknown,
TMessage extends SessionOutboundMessage = SessionOutboundMessage
>(options: UseDaemonRequestOptions<TParams, TData, TMessage>): UseDaemonRequestResult<TParams, TData> {
const {
ws,
responseType,
buildRequest,
selectData,
matchResponse,
getRequestKey,
extractError,
timeoutMs = DEFAULT_TIMEOUT_MS,
retryCount = 0,
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
keepPreviousData = true,
initialData = null,
dedupe = true,
} = options;
const buildRequestRef = useLatest(buildRequest);
const selectDataRef = useLatest(selectData);
const matchResponseRef = useLatest(matchResponse);
const extractErrorRef = useLatest(extractError);
const getRequestKeyRef = useLatest(getRequestKey);
const [state, setState] = useState<{
status: RequestStatus;
data: TData | null;
error: Error | null;
requestId: string | null;
updatedAt: number | null;
}>(() => ({
status: initialData === null ? "idle" : "success",
data: initialData,
error: null,
requestId: null,
updatedAt: initialData === null ? null : Date.now(),
}));
const activeRequestRef = useRef<ActiveRequest<TParams, TData, TMessage> | null>(null);
const isMountedRef = useRef(true);
useEffect(() => {
return () => {
isMountedRef.current = false;
const active = activeRequestRef.current;
if (active) {
if (active.timeoutHandle) {
clearTimeout(active.timeoutHandle);
}
if (active.retryHandle) {
clearTimeout(active.retryHandle);
}
activeRequestRef.current = null;
}
};
}, []);
const cleanupActiveRequest = useCallback(() => {
const current = activeRequestRef.current;
if (!current) {
return;
}
if (current.timeoutHandle) {
clearTimeout(current.timeoutHandle);
}
if (current.retryHandle) {
clearTimeout(current.retryHandle);
}
activeRequestRef.current = null;
}, []);
const applyState = useCallback(
(updater: (prev: typeof state) => typeof state) => {
if (!isMountedRef.current) {
return;
}
setState(updater);
},
[]
);
const getMatchFn = useCallback(
(context: RequestContext<TParams>) => {
const userMatch = matchResponseRef.current;
if (userMatch) {
return (message: TMessage) => userMatch(message, context);
}
return (message: TMessage) => {
const payload = (message as { payload?: { requestId?: unknown } }).payload;
if (
payload &&
typeof payload === "object" &&
"requestId" in payload &&
typeof (payload as { requestId?: unknown }).requestId === "string"
) {
return (payload as { requestId?: string }).requestId === context.requestId;
}
return true;
};
},
[matchResponseRef]
);
const resolvedBehavior = useCallback(
(overrides?: ExecuteRequestOptions): ResolvedBehavior => ({
timeoutMs:
overrides?.timeoutMs !== undefined
? overrides.timeoutMs
: timeoutMs ?? null,
retryCount: overrides?.retryCount ?? retryCount,
retryDelayMs: overrides?.retryDelayMs ?? retryDelayMs,
dedupe: overrides?.dedupe ?? dedupe,
}),
[timeoutMs, retryCount, retryDelayMs, dedupe]
);
const sendAttempt = useCallback(
(request: ActiveRequest<TParams, TData, TMessage>) => {
if (request.aborted) {
return;
}
request.attempt += 1;
if (request.timeoutHandle) {
clearTimeout(request.timeoutHandle);
}
try {
const message = buildRequestRef.current({
params: request.params,
requestId: request.requestId,
});
ws.send(message);
} catch (error) {
const err = ensureError(error);
request.reject(err);
cleanupActiveRequest();
applyState((prev) => ({
status: "error",
data: keepPreviousData ? prev.data : null,
error: err,
requestId: null,
updatedAt: Date.now(),
}));
return;
}
const timeoutDuration =
typeof request.options.timeoutMs === "number"
? request.options.timeoutMs
: null;
if (timeoutDuration && timeoutDuration > 0) {
request.timeoutHandle = setTimeout(() => {
if (request.aborted) {
return;
}
const timeoutError = new Error(
`Request timed out after ${timeoutDuration}ms`
);
const maxAttempts = request.options.retryCount + 1;
if (request.attempt >= maxAttempts) {
cleanupActiveRequest();
request.reject(timeoutError);
applyState((prev) => ({
status: "error",
data: keepPreviousData ? prev.data : null,
error: timeoutError,
requestId: null,
updatedAt: Date.now(),
}));
return;
}
const delay =
typeof request.options.retryDelayMs === "function"
? request.options.retryDelayMs(request.attempt)
: request.options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
request.retryHandle = setTimeout(() => {
sendAttempt(request);
}, Math.max(0, delay));
}, timeoutDuration);
}
},
[applyState, buildRequestRef, cleanupActiveRequest, keepPreviousData, ws]
);
const startRequest = useCallback(
(params?: ExecuteParams<TParams>, overrides?: ExecuteRequestOptions) => {
const normalizedParams = params as MaybeUndefined<TParams>;
const behavior = resolvedBehavior(overrides);
const computedKey =
overrides?.requestKeyOverride ??
getRequestKeyRef.current?.(normalizedParams) ??
resolveKey(normalizedParams);
const active = activeRequestRef.current;
if (behavior.dedupe && active && active.key === computedKey) {
return active.promise;
}
let resolveFn!: (value: TData) => void;
let rejectFn!: (error: Error) => void;
const promise = new Promise<TData>((resolve, reject) => {
resolveFn = resolve;
rejectFn = reject;
});
const request: ActiveRequest<TParams, TData, TMessage> = {
key: computedKey,
params: normalizedParams,
requestId: generateMessageId(),
attempt: 0,
promise,
resolve: resolveFn,
reject: rejectFn,
timeoutHandle: null,
retryHandle: null,
aborted: false,
options: behavior,
};
activeRequestRef.current = request;
applyState((prev) => ({
status: "loading",
data: keepPreviousData ? prev.data : null,
error: null,
requestId: request.requestId,
updatedAt: prev.updatedAt,
}));
sendAttempt(request);
return promise;
},
[applyState, getRequestKeyRef, keepPreviousData, resolvedBehavior, sendAttempt]
);
useEffect(() => {
const unsubscribe = ws.on(responseType, (message) => {
const active = activeRequestRef.current;
if (!active) {
return;
}
const castedMessage = message as TMessage;
const context: RequestContext<TParams> = {
params: active.params,
requestId: active.requestId,
key: active.key,
attempt: active.attempt,
};
const matcher = getMatchFn(context);
if (!matcher(castedMessage)) {
return;
}
if (active.timeoutHandle) {
clearTimeout(active.timeoutHandle);
active.timeoutHandle = null;
}
if (active.retryHandle) {
clearTimeout(active.retryHandle);
active.retryHandle = null;
}
const maybeError = extractErrorRef.current
? extractErrorRef.current(castedMessage, context)
: null;
if (maybeError) {
const error = ensureError(maybeError);
const maxAttempts = active.options.retryCount + 1;
if (active.attempt >= maxAttempts) {
cleanupActiveRequest();
active.reject(error);
applyState((prev) => ({
status: "error",
data: keepPreviousData ? prev.data : null,
error,
requestId: null,
updatedAt: Date.now(),
}));
return;
}
const delay =
typeof active.options.retryDelayMs === "function"
? active.options.retryDelayMs(active.attempt)
: active.options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
active.retryHandle = setTimeout(() => {
sendAttempt(active);
}, Math.max(0, delay));
return;
}
let data: TData;
try {
data = selectDataRef.current(castedMessage, context);
} catch (error) {
const err = ensureError(error);
cleanupActiveRequest();
active.reject(err);
applyState((prev) => ({
status: "error",
data: keepPreviousData ? prev.data : null,
error: err,
requestId: null,
updatedAt: Date.now(),
}));
return;
}
cleanupActiveRequest();
active.resolve(data);
applyState(() => ({
status: "success",
data,
error: null,
requestId: context.requestId,
updatedAt: Date.now(),
}));
});
return unsubscribe;
}, [
applyState,
cleanupActiveRequest,
extractErrorRef,
getMatchFn,
keepPreviousData,
responseType,
selectDataRef,
sendAttempt,
ws,
]);
const execute = useCallback(
(params?: ExecuteParams<TParams>, overrides?: ExecuteRequestOptions) => startRequest(params, overrides),
[startRequest]
);
const reset = useCallback(() => {
cleanupActiveRequest();
applyState(() => ({
status: "idle",
data: initialData,
error: null,
requestId: null,
updatedAt: null,
}));
}, [applyState, cleanupActiveRequest, initialData]);
const cancel = useCallback(
(reason?: string) => {
const active = activeRequestRef.current;
if (!active) {
return;
}
active.aborted = true;
cleanupActiveRequest();
const error = new Error(reason ?? "Request cancelled");
active.reject(error);
applyState((prev) => ({
status: "idle",
data: keepPreviousData ? prev.data : null,
error: null,
requestId: null,
updatedAt: prev.updatedAt,
}));
},
[applyState, cleanupActiveRequest, keepPreviousData]
);
return useMemo(
() => ({
status: state.status,
data: state.data,
error: state.error,
isIdle: state.status === "idle",
isLoading: state.status === "loading",
isSuccess: state.status === "success",
isError: state.status === "error",
requestId: state.requestId,
updatedAt: state.updatedAt,
execute,
reset,
cancel,
}),
[execute, reset, cancel, state]
);
}

View File

@@ -0,0 +1,70 @@
import { useRef } from "react";
import { Alert } from "react-native";
import type { SessionContextValue } from "@/contexts/session-context";
import { useSession } from "@/contexts/session-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionDirectory } from "./use-session-directory";
export class DaemonSessionUnavailableError extends Error {
serverId: string;
constructor(serverId: string) {
super(`Daemon session "${serverId}" is unavailable`);
this.name = "DaemonSessionUnavailableError";
this.serverId = serverId;
}
}
export function getSessionForServer(
serverId: string,
directory: Map<string, SessionContextValue | null>
): SessionContextValue {
const session = directory.get(serverId) ?? null;
if (!session) {
throw new DaemonSessionUnavailableError(serverId);
}
return session;
}
type UseDaemonSessionOptions = {
suppressUnavailableAlert?: boolean;
};
export function useDaemonSession(serverId?: string, options?: UseDaemonSessionOptions) {
const activeSession = useSession();
const sessionDirectory = useSessionDirectory();
const { connectionStates } = useDaemonConnections();
const alertedDaemonsRef = useRef<Set<string>>(new Set());
const loggedDaemonsRef = useRef<Set<string>>(new Set());
const { suppressUnavailableAlert = false } = options ?? {};
const targetServerId = serverId ?? activeSession.serverId;
const isActiveSession = targetServerId === activeSession.serverId;
if (isActiveSession || !targetServerId) {
return activeSession;
}
try {
return getSessionForServer(targetServerId, sessionDirectory);
} catch (error) {
if (error instanceof DaemonSessionUnavailableError) {
const connection = connectionStates.get(targetServerId);
const label = connection?.daemon.label ?? targetServerId;
const status = connection?.status ?? "unknown";
const lastError = connection?.lastError ? `\n${connection.lastError}` : "";
const message = `${label} isn't connected yet (${status}). Switch to it or enable auto-connect so Paseo can reach it.${lastError}`;
if (!suppressUnavailableAlert && !alertedDaemonsRef.current.has(targetServerId)) {
alertedDaemonsRef.current.add(targetServerId);
Alert.alert("Daemon unavailable", message.trim());
}
if (!loggedDaemonsRef.current.has(targetServerId)) {
loggedDaemonsRef.current.add(targetServerId);
console.warn(`[useDaemonSession] Session unavailable for daemon "${label}" (${status}).`);
}
}
throw error;
}
}

View File

@@ -0,0 +1,35 @@
import { useEffect, useMemo, useState } 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]);
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);
}
});
return entries;
}, [sessionAccessors, revision]);
}
export function useSessionForServer(serverId: string | null): SessionContextValue | null {
const directory = useSessionDirectory();
if (!serverId) {
return null;
}
return directory.get(serverId) ?? null;
}

View File

@@ -1,78 +1,114 @@
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
const STORAGE_KEY = '@paseo:settings';
const APP_SETTINGS_KEY = "@paseo:app-settings";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
export interface Settings {
serverUrl: string;
export interface AppSettings {
useSpeaker: boolean;
keepScreenOn: boolean;
theme: 'dark' | 'light' | 'auto';
theme: "dark" | "light" | "auto";
}
const DEFAULT_SETTINGS: Settings = {
serverUrl: 'wss://mohameds-macbook-pro.tail8fe838.ts.net/ws',
const DEFAULT_APP_SETTINGS: AppSettings = {
useSpeaker: true,
keepScreenOn: true,
theme: 'dark',
theme: "dark",
};
export interface UseSettingsReturn {
settings: Settings;
export interface UseAppSettingsReturn {
settings: AppSettings;
isLoading: boolean;
updateSettings: (updates: Partial<Settings>) => Promise<void>;
error: unknown | null;
updateSettings: (updates: Partial<AppSettings>) => Promise<void>;
resetSettings: () => Promise<void>;
}
export function useSettings(): UseSettingsReturn {
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
const [isLoading, setIsLoading] = useState(true);
export function useAppSettings(): UseAppSettingsReturn {
const queryClient = useQueryClient();
const { data, isPending, error } = useQuery({
queryKey: APP_SETTINGS_QUERY_KEY,
queryFn: loadSettingsFromStorage,
staleTime: Infinity,
gcTime: Infinity,
});
// Load settings from AsyncStorage on mount
useEffect(() => {
loadSettings();
}, []);
async function loadSettings() {
try {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<Settings>;
setSettings({ ...DEFAULT_SETTINGS, ...parsed });
const updateSettings = useCallback(
async (updates: Partial<AppSettings>) => {
try {
const prev = queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_APP_SETTINGS;
const next = { ...prev, ...updates };
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
} catch (err) {
console.error("[AppSettings] Failed to save settings:", err);
throw err;
}
} catch (error) {
console.error('[Settings] Failed to load settings:', error);
// Continue with default settings
} finally {
setIsLoading(false);
}
}
const updateSettings = useCallback(async (updates: Partial<Settings>) => {
try {
const newSettings = { ...settings, ...updates };
setSettings(newSettings);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newSettings));
} catch (error) {
console.error('[Settings] Failed to save settings:', error);
throw error;
}
}, [settings]);
},
[queryClient]
);
const resetSettings = useCallback(async () => {
try {
setSettings(DEFAULT_SETTINGS);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_SETTINGS));
} catch (error) {
console.error('[Settings] Failed to reset settings:', error);
throw error;
const next = { ...DEFAULT_APP_SETTINGS };
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
} catch (err) {
console.error("[AppSettings] Failed to reset settings:", err);
throw err;
}
}, []);
}, [queryClient]);
return {
settings,
isLoading,
settings: data ?? DEFAULT_APP_SETTINGS,
isLoading: isPending,
error: error ?? null,
updateSettings,
resetSettings,
};
}
async function loadSettingsFromStorage(): Promise<AppSettings> {
try {
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<AppSettings>;
return { ...DEFAULT_APP_SETTINGS, ...parsed };
}
const legacyStored = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
if (legacyStored) {
const legacyParsed = JSON.parse(legacyStored) as Record<string, unknown>;
const next = {
...DEFAULT_APP_SETTINGS,
...pickAppSettingsFromLegacy(legacyParsed),
} satisfies AppSettings;
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
return next;
}
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_APP_SETTINGS));
return DEFAULT_APP_SETTINGS;
} catch (error) {
console.error("[AppSettings] Failed to load settings:", error);
throw error;
}
}
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
if (typeof legacy.useSpeaker === "boolean") {
result.useSpeaker = legacy.useSpeaker;
}
if (typeof legacy.keepScreenOn === "boolean") {
result.keepScreenOn = legacy.keepScreenOn;
}
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
result.theme = legacy.theme;
}
return result;
}
export const useSettings = useAppSettings;

View File

@@ -1,54 +1,123 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import type {
WSInboundMessage,
WSOutboundMessage,
SessionOutboundMessage
} from '@server/server/messages';
SessionOutboundMessage,
} from "@server/server/messages";
export interface UseWebSocketReturn {
isConnected: boolean;
isConnecting: boolean;
conversationId: string | null;
lastError: string | null;
send: (message: WSInboundMessage) => void;
on: (type: SessionOutboundMessage['type'], handler: (message: SessionOutboundMessage) => void) => () => void;
on: (
type: SessionOutboundMessage["type"],
handler: (message: SessionOutboundMessage) => void
) => () => void;
sendPing: () => void;
sendUserMessage: (message: string) => void;
}
const RECONNECT_BASE_DELAY_MS = 1500;
const RECONNECT_MAX_DELAY_MS = 30000;
export function useWebSocket(url: string, conversationId?: string | null): UseWebSocketReturn {
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(true);
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
const [lastError, setLastError] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const handlersRef = useRef<Map<SessionOutboundMessage['type'], Set<(message: SessionOutboundMessage) => void>>>(new Map());
const handlersRef =
useRef<Map<SessionOutboundMessage["type"], Set<(message: SessionOutboundMessage) => void>>>(new Map());
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const reconnectAttemptRef = useRef(0);
const shouldReconnectRef = useRef(true);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
return;
}
let scheduledReconnect = false;
const scheduleReconnect = (reason?: string) => {
if (scheduledReconnect || !shouldReconnectRef.current) {
return;
}
scheduledReconnect = true;
if (wsRef.current) {
try {
wsRef.current.close();
} catch {
// no-op
}
wsRef.current = null;
}
if (typeof reason === "string" && reason.trim().length > 0) {
setLastError(reason.trim());
}
setIsConnected(false);
setIsConnecting(false);
const attempt = reconnectAttemptRef.current;
const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** attempt, RECONNECT_MAX_DELAY_MS);
reconnectAttemptRef.current = attempt + 1;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
reconnectTimeoutRef.current = setTimeout(() => {
reconnectTimeoutRef.current = undefined;
if (!shouldReconnectRef.current) {
return;
}
setIsConnecting(true);
connect();
}, delay);
};
try {
// Add conversation ID to URL if provided
const wsUrl = conversationId ? `${url}?conversationId=${conversationId}` : url;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
setIsConnecting(true);
ws.onopen = () => {
console.log('[WS] Connected to server');
console.log("[WS] Connected to server");
setIsConnected(true);
setIsConnecting(false);
setLastError(null);
reconnectAttemptRef.current = 0;
};
ws.onclose = () => {
console.log('[WS] Disconnected from server');
ws.onclose = (event) => {
console.log("[WS] Disconnected from server");
const reason =
typeof event?.reason === "string" && event.reason.trim().length > 0
? event.reason.trim()
: `Socket closed (code ${event?.code ?? "unknown"})`;
setIsConnected(false);
// Attempt to reconnect after 3 seconds
reconnectTimeoutRef.current = setTimeout(() => {
console.log('[WS] Attempting to reconnect...');
connect();
}, 3000);
scheduleReconnect(reason);
};
ws.onerror = (error) => {
console.warn('[WS] Error:', error);
ws.onerror = (errorEvent) => {
let reason = "WebSocket error";
if (
errorEvent &&
typeof errorEvent === "object" &&
"message" in errorEvent &&
typeof (errorEvent as { message: unknown }).message === "string"
) {
const message = ((errorEvent as { message: string }).message || "").trim();
reason = message.length > 0 ? message : reason;
}
console.warn("[WS] Error:", errorEvent);
scheduleReconnect(reason);
};
ws.onmessage = (event) => {
@@ -56,12 +125,12 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
const wsMessage: WSOutboundMessage = JSON.parse(event.data);
// Only session messages trigger handlers
if (wsMessage.type === 'session') {
if (wsMessage.type === "session") {
const sessionMessage = wsMessage.message;
console.log(`[WS] Received session message type: ${sessionMessage.type}`);
// Track conversation ID when loaded
if (sessionMessage.type === 'conversation_loaded') {
if (sessionMessage.type === "conversation_loaded") {
setCurrentConversationId(sessionMessage.payload.conversationId);
}
@@ -81,20 +150,23 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
console.log(`[WS] Received ${wsMessage.type}`);
}
} catch (err) {
console.error('[WS] Failed to parse message:', err);
console.error("[WS] Failed to parse message:", err);
}
};
wsRef.current = ws;
} catch (err) {
console.warn('[WS] Failed to create WebSocket:', err);
console.warn("[WS] Failed to create WebSocket:", err);
const reason = err instanceof Error ? err.message : "Failed to create WebSocket";
scheduleReconnect(reason);
}
}, [url, conversationId]);
useEffect(() => {
shouldReconnectRef.current = true;
setIsConnecting(true);
connect();
return () => {
shouldReconnectRef.current = false;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
@@ -108,41 +180,41 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
console.warn('[WS] Cannot send message - not connected');
console.warn("[WS] Cannot send message - not connected");
}
}, []);
const on = useCallback((
type: SessionOutboundMessage['type'],
handler: (message: SessionOutboundMessage) => void
) => {
if (!handlersRef.current.has(type)) {
handlersRef.current.set(type, new Set());
}
handlersRef.current.get(type)!.add(handler);
// Return cleanup function
return () => {
const handlers = handlersRef.current.get(type);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
handlersRef.current.delete(type);
}
const on = useCallback(
(type: SessionOutboundMessage["type"], handler: (message: SessionOutboundMessage) => void) => {
if (!handlersRef.current.has(type)) {
handlersRef.current.set(type, new Set());
}
};
}, []);
handlersRef.current.get(type)!.add(handler);
// Return cleanup function
return () => {
const handlers = handlersRef.current.get(type);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
handlersRef.current.delete(type);
}
}
};
},
[]
);
const sendPing = useCallback(() => {
send({ type: 'ping' });
send({ type: "ping" });
}, [send]);
const sendUserMessage = useCallback(
(message: string) => {
send({
type: 'session',
type: "session",
message: {
type: 'user_text',
type: "user_text",
text: message,
},
});
@@ -153,12 +225,14 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
return useMemo(
() => ({
isConnected,
isConnecting,
conversationId: currentConversationId,
lastError,
send,
on,
sendPing,
sendUserMessage,
}),
[isConnected, currentConversationId, send, on, sendPing, sendUserMessage]
[isConnected, isConnecting, currentConversationId, lastError, send, on, sendPing, sendUserMessage]
);
}

View File

@@ -0,0 +1,31 @@
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
export function formatConnectionStatus(status: ConnectionStatus): string {
switch (status) {
case "online":
return "Online";
case "connecting":
return "Connecting";
case "offline":
return "Offline";
case "error":
return "Error";
default:
return "Idle";
}
}
export type ConnectionStatusTone = "success" | "warning" | "error" | "muted";
export function getConnectionStatusTone(status: ConnectionStatus): ConnectionStatusTone {
switch (status) {
case "online":
return "success";
case "connecting":
return "warning";
case "error":
return "error";
default:
return "muted";
}
}

168
plan.md
View File

@@ -1,92 +1,92 @@
# Guidelines
# Multi-Daemon Production Rollout Plan
- Implement the first available task, top down
- Only do a single task and exit, the other agents will implement the other tasks
- Commit after each task with a descriptive commit message.
- Add context after each task completion, indented under the task, to help the reviewer understand the changes.
## Guiding Principles
- The app always reflects the union of every connected daemon—no manual switching to “see” data.
- Actions (create/resume agent, browse files, realtime tools) automatically route to the correct daemon based on the agent/workspace context.
- Connection health, loading states, and mutations are observable and resilient (React Query or equivalent).
# Tasks
## Workstreams & Tasks
- [x] Claude hydration regression must be proven with a real end-to-end test: spin up an actual Claude agent (no mocks), ask it to run tool calls that create/edit/read a temp project, verify the live stream shows the tool results, shut the agent down, hydrate from disk (`~/.claude/projects/...`), and assert the exact diff/read/command output replays in the UI. Do not mark any hydration task complete until this automated test exists and fails on current main but passes after the fix.
- Added a Vitest integration in `packages/server/src/server/agent/providers/claude-agent.test.ts` that drives a live Claude session through real Bash/write/read tool calls, converts the emitted timeline into the UI stream via `hydrateStreamState`, tears the agent down, resumes it from `~/.claude/projects/...`, and asserts that the hydrated stream reproduces the command output, file diff, and read content instead of leaving spinners. The helper waits for the persisted `.jsonl` file (covering `/var``/private` realpaths), cleans up temp state, and the run is wired into `npm run test --workspace=@voice/server -- src/server/agent/providers/claude-agent.test.ts` (passes locally).
- [x] Hydrated Claude tool calls still show the spinner forever after refreshing a chat; Claude CLI already persists the tool payloads under `~/.claude/projects/<conversation>`, but our hydrate path isnt loading them. Fix the loader so it reads the saved diffs/read/output blocks and transitions the existing pill to “completed” instead of spinning or duplicating.
- Claude history entries now flow through a shared `convertClaudeHistoryEntry` helper that inspects every message for `tool_*` blocks before classifying it as a plain user message, so persisted `tool_result` lines recorded as `type: "user"` convert into completed tool-call events instead of leaving the original spinner stuck in `executing`. Added unit coverage in `packages/server/src/server/agent/providers/claude-agent.test.ts` proving user tool results hydrate correctly and ran `npm run test --workspace=@voice/server -- src/server/agent/providers/claude-agent.test.ts` (passes, though existing SDK integration specs are still slow/flaky by nature).
- [x] We still see duplicate tool call pills (loading + completed/failed) in both Codex and Claude sessions, live and hydrated. Track tool call IDs rigorously, dedupe pending/completed entries, and add regression tests covering all providers and hydrate flows.
- Hardened the timeline reducer (`packages/app/src/types/stream.ts`) so out-of-order tool events reconcile via provider/server/tool metadata even when call IDs are missing, statuses only move forward (executing → completed/failed), and replayed hydration updates dont spawn extra pills; added Vitest coverage in `packages/app/src/types/stream.test.ts` proving both Codex and Claude live + hydrated streams stay deduped when completion blocks precede their pending counterparts. Tests run via `npx vitest packages/app/src/types/stream.test.ts`.
- [x] Permission request cards in the stream header are emitting duplicate key warnings—derive stable per-agent keys (for example `${agentId}:${request.id}` with fallbacks) so React stops complaining.
- Added a `key` field on `PendingPermission` along with a shared helper in `SessionContext` that derives `${agentId}:<fallback>` identifiers for every server-sourced request, stores them in the map, and removes them when the server resolves the request. The stream header now renders permission cards using this stable key instead of recomputing on every render, eliminating the duplicate React keys.
- [x] The so-called tests are still fake: convert the stream harness into real Vitest suites co-located with the code, make `npm test` (Vitest) the single entrypoint, and ensure those tests fail today because hydrated tool-call results are missing.
- Added `packages/app/src/types/stream.harness.test.ts`, which codifies the manual stream harness into Vitest and captures the regression: the live sequence populates tool diffs/reads/command output, but the hydrated snapshot lacks them, so `npm run test --workspace=@voice/app` now fails on the second assertion until the hydration loader replays tool payloads from disk.
- [x] Hydrated sessions continue to drop user messages; capture this in a failing test for both Codex and Claude (hydrate snapshot + live replay) and only mark complete once the test passes.
- Added end-to-end hydration suites in `packages/server/src/server/agent/providers/{claude,codex}-agent.test.ts` that record live timeline updates (including user prompts) and then hydrate from `streamHistory()` after resuming the session; both now assert that the refreshed snapshot still contains the user's prompt marker. Fixed Claude hydration by having `convertClaudeHistoryEntry` emit user_message entries alongside persisted tool blocks, and taught the Codex rollout parser to surface `role: "user"` messages from rollout logs.
- [x] Audit the recent “Vitest migration” claim: confirm `npm test` actually runs the new suites end to end, add coverage that specifically asserts tool call results render after hydration, and keep the task open until CI proves it.
- Added `resolveToolCallPreview` so the `ToolCall` UI explicitly prefers hydrated `parsed*` payloads over fallbacks and wrote `tool-call-preview.test.ts` to cover hydration-vs-fallback behavior.
- Verified `npm test` now drives both workspaces (server suite passes; app suite fails fast on `src/types/stream.harness.test.ts` because hydrated tool payloads are still missing), so CI will surface the regression once the loader fix lands.
- [x] AgentInput image picker crashes with "Attempting to launch an unregistered ActivityResultLauncher" when calling Expo ImagePicker; register the launcher properly (or switch to the new async hook) so picking images doesnt throw and we can attach screenshots again.
- Added a dedicated `useImageAttachmentPicker` hook that registers the media picker launcher up front, reuses Expos permission hook, restores pending results, and guards against concurrent launches. AgentInput now consumes this hook so tapping the attachment button opens the picker without crashing on Android; `npm run typecheck --workspace=@voice/app` currently fails upstream in `stream.test.ts` before our change, noted in the summary.
- [x] Do not mark the Claude hydration fix complete until theres an end-to-end test that (1) runs Claude, (2) executes tool calls, (3) persists the conversation under `~/.claude/projects/...`, and (4) hydrates from disk verifying the tool call results render. No test, no checkbox.
- Strengthened `packages/server/src/server/agent/providers/claude-agent.test.ts` so the existing e2e hydration suite now asserts the live stream parses command/edit/read payloads and that the resumed stream reproduces those parsed diffs/reads/command outputs from `~/.claude/projects`, guaranteeing the UI pills render identical results after hydration.
- [x] ERROR Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version. .$tool_1763217454926_uyr9rm
### 1. Session Directory & Data Consistency
- [x] Rebuild `useSessionDirectory` so it stays in sync with realtime session state (agents, permissions, stream updates) instead of caching an accessor forever.
- Added session-level subscriptions so the directory re-renders on daemon updates and ran `npm run typecheck` to verify.
- [x] Expose a lightweight subscription/API for background `SessionProvider`s so any change invalidates aggregated consumers without forcing rerenders of the main tree.
- Added central session-directory listeners in the daemon connections context, had SessionProviders emit invalidations, updated `useSessionDirectory` to use the new API, and ran `npm run typecheck`.
- [x] Audit every consumer that still reaches for `useSession()` directly (AgentStreamView, AgentInputArea, file explorers, realtime, etc.) and ensure they receive the session instance that corresponds to the agent/daemon theyre operating on.
- Agent detail UI now pulls daemon-scoped sessions via `useDaemonSession`, so sending messages, toggling modes, and inline file explorers all operate against the routes `serverId` (see `packages/app/src/components/agent-input-area.tsx:72`, `packages/app/src/components/agent-stream-view.tsx:31`, `packages/app/src/components/agent-status-bar.tsx:6`, `packages/app/src/app/agent/[serverId]/[agentId].tsx:256`) and `npm run typecheck` passes.
- `Stream` now generates fallback tool ids via `createUniqueTimelineId`, ensuring hydrated/past tool calls without callIds never reuse the same key even when their metadata matches; added `testFallbackToolCallIdsStayUnique` in `packages/app/src/types/stream.test.ts` to cover the regression and verified via `npx vitest packages/app/src/types/stream.test.ts`.
### 2. Aggregated Agent Experience
- [x] Replace the home screens `agents.size` gate with `useAggregatedAgents`, render the merged list (grouped/sorted by daemon), and remove the daemon picker UI from the header.
- Home now builds grouped daemon sections via `useAggregatedAgents`, `AgentList` renders each section with the correct serverId routing, the header exposes dedicated import/create buttons without manual daemon switching, and `npm run typecheck` passes.
- When closing the agent action sheet we now clear the stored `serverId` so `useDaemonSession` falls back to the active daemon and the Home screen no longer crashes if that background daemon disconnects after a long-press.
- [x] Ensure all agent rows carry their `serverId` through navigation (Agent screen, diff viewer, file explorer, orchestrator) so every deep link includes `/agent/[serverId]/[agentId]`.
- Annotated every agent snapshot with its daemon `serverId`, updated shared row components (`AgentList`, `AgentSidebar`, `ActiveProcesses`) to read it when navigating/deleting, and re-ran `npm run typecheck`.
- [x] Update stream detail routes (git diff, file explorer) to validate the daemon session from params and gracefully show status/loading/error states if the background session is unavailable.
- Wrapped both routes in session guards that render connection-aware placeholders when the target daemon is offline/unavailable, moved the existing logic into gated child components, and re-ran `npm run typecheck` to verify.
- [x] Guard the main agent route when the requested daemon session is unavailable so deep links or quick daemon switches don't crash the screen.
- Added a connection-aware guard around `/agent/[serverId]/[agentId]` that renders a friendly placeholder when the session is offline/unavailable and re-ran `npm run typecheck`.
- [x] Fix the agent screen dropdown positioning so we don't double-apply the safe-area offset and remove the leaked debug logging.
- `packages/app/src/app/agent/[serverId]/[agentId].tsx:320-339` adds `insets.top` to `measureInWindow` coordinates (which already include the status bar) and emits `[Menu]` console logs on every open, so the action menu renders ~40px too low on notched devices and spams the JS console.
- Removed the extra `insets.top` offset, cleaned up the `[Menu]` debug logs, and re-ran `npm run typecheck`.
- [x] Make inline file path navigation pick up the correct daemon id even when two daemons generate the same `agentId`.
- Added `resolvedServerId` to the `handleInlinePathPress` dependency list in `packages/app/src/components/agent-stream-view.tsx:83-116`, so navigating after switching daemons now routes to the correct file explorer target; re-ran `npm run typecheck`.
- [x] Sync the active daemon context with the agent route so realtime/audio flows hit the same websocket as the rendered agent, even when arriving from a deep link.
- `AgentScreen` looks up the requested session via `useDaemonSession` but never calls `setActiveDaemonId`, so opening `/agent/[serverB]/[agentId]` while daemon A is active leaves the global `SessionProvider`/`RealtimeProvider` pointed at A (see `packages/app/src/app/agent/[serverId]/[agentId].tsx:74-138`).
- `AgentInputArea` forwards realtime/voice interactions through `useRealtime()` and the active `ws` (`packages/app/src/components/agent-input-area.tsx:656-744`), so with the mismatch above those commands get sent to daemon A instead of the daemon hosting the open agent.
- Added a route-aware effect in `packages/app/src/app/agent/[serverId]/[agentId].tsx` that synchronizes `setActiveDaemonId` with the screen's `serverId` param so the root Session/Realtime providers follow deep links, and ran `npm run typecheck --workspace=@paseo/app`.
- [x] Restore the legacy `/agent/[id]` route as a compatibility shim so old deep links (without a daemon id) keep working while we transition the UI.
- Added `packages/app/src/app/agent/[id].tsx`, which scans the session directory for the requested agent, auto-redirects when theres a single match, and lets the user choose when multiple daemons share that id; registered the screen in `_layout.tsx` and re-ran `npm run typecheck --workspace=@paseo/app`.
- [x] Keep background agent actions from swapping the active daemon just to open the action sheet or delete.
- `AgentList` still called `setActiveDaemonId` on long-press and before deletion (`packages/app/src/components/agent-list.tsx:19-63`), so managing a background daemons agent on Home would tear down the active websocket/realtime session. Removed those calls and rely on `useDaemonSession` so the aggregated view no longer hijacks the global session for contextual actions.
- [x] Allow guarded screens to opt out of the `useDaemonSession` alert so offline placeholders dont trigger duplicate system popups.
- Added a `suppressUnavailableAlert` option to `useDaemonSession`, had Agent, Git diff, and File Explorer guards opt in so they only render their inline placeholders, and ran `npm run typecheck --workspace=@paseo/app`.
- [x] Force the root `SessionProvider` to remount whenever the active daemon changes so session state never leaks between servers.
- Added `key={activeDaemon.id}` in `_layout.tsx`, ensuring each daemon gets a fresh session tree so cached agents/permissions dont show up under the wrong daemon while switching routes.
```tsx
Code: agent-stream-view.tsx
317 | return (
318 | <View style={stylesheet.container}>
> 319 | <FlatList
| ^
320 | ref={flatListRef}
321 | data={flatListData}
322 | renderItem={renderStreamItem}
```
### 3. Agent Creation & Lifecycle Actions
- [x] Remove the server selector from the home header; inside the Create/Import modal replace the current “session swap” approach with an explicit `serverId` prop that simply determines which daemon receives the mutation.
- Create/Import modals now accept a `serverId` prop, stop mutating the active daemon, and every caller (home screen, footer, agent view) passes the daemon id theyre operating against; ran `npm run typecheck`.
- [x] Prevent `useDaemonSession` from throwing during modal render when a daemon is offline—surface that state inside the UI (disabled create button + inline error) and keep the chip selection responsive.
- Currently selecting a daemon chip whose session isnt connected (e.g., auto-connect disabled or still initializing) crashes `CreateAgentModal` because `useDaemonSession` rethrows immediately; we need to gate the selection and show an inline “connect first” state instead of exploding.
- Confirmed this is still happening: `CreateAgentModal` passes the selected `serverId` straight into `useDaemonSession` (`packages/app/src/components/create-agent-modal.tsx:233`), so tapping an offline daemon chip kills the modal before we can render error UI.
- Updated `CreateAgentModal` to read sessions from the directory, block websocket actions while the target daemon is offline, surface a daemon availability warning, and disable create/import flows until the daemon connects; ran `npm run typecheck`.
- [x] When creating/resuming/cloning agents, route follow-up navigation and queued requests to the daemon returned in the success payload rather than assuming the active daemon changed.
- `CreateAgentModal` now calls `setActiveDaemonId` with the server from the success payload before pushing the agent route, so the global `SessionProvider`/`RealtimeProvider` swaps to the correct daemon and realtime controls no longer stay bound to the previous server (see `packages/app/src/components/create-agent-modal.tsx:210-220` and `packages/app/src/components/create-agent-modal.tsx:845-864`); re-ran `npm run typecheck`.
- [x] Block Create/Import repo + snapshot fetches when the selected daemon is offline so we surface the availability error instead of spinning forever.
- Guarded `requestRepoInfo`/`requestImportCandidates` with the daemon availability signal so offline sessions now surface `daemonAvailabilityError` immediately instead of issuing `ws.send`, then added the missing `sheetDeleteTextDisabled` style in `packages/app/src/components/agent-list.tsx` to clear the lingering `npm run typecheck --workspace=@paseo/app` failure.
- [x] Restore the Import Agent flow (modal entry, mutation wiring, navigation) without undoing the multi-daemon routing work from previous steps.
- After reworking Home/Header and the modals, the import trigger disappeared and existing deep links no longer reach a functioning flow. Bring the Import CTA back (Home, footer, agent screen), ensure it accepts a daemon id, and verify the import mutation routes to the selected daemon without regressing the new server-aware navigation.
- Rewired the import buttons across Home (header + empty state with deep-link auto open), the global footer, and the agent action menu so every trigger passes the correct daemon id into `ImportAgentModal`, and ran `npm run typecheck --workspace=@paseo/app`.
- [x] `npm test` currently fails in `packages/app` with Vitest throwing `Unexpected call to process.send` / `ERR_INVALID_ARG_TYPE` before any tests run. Track down the coverage/pool config causing this and make sure the app suite actually executes (it should include the hydrated tool-call tests mentioned above).
- Forced the app Vitest config to use the `forks` pool (keeps `process.send` intact for Expo/xcode helpers) so the suite boots reliably; `npm test --workspace=@voice/app` now runs the harness tests and reproduces the expected hydration failure instead of crashing ahead of collection.
- [x] WARN [expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead.
- Replaced the deprecated `MediaTypeOptions.Images` flag with the new literal `["images"]` media type array in `packages/app/src/hooks/use-image-attachment-picker.ts`, silencing the Expo warning when attaching photos. `npm run typecheck --workspace=@voice/app` still fails in the pre-existing stream harness files (unchanged from before this fix).
- [x] Update the AgentInput controls so the buttons reflect agent state: when idle show `Dictate` + `Realtime` by default (switch to `Send` when the text box has content); when an agent is running show `Realtime` + `Cancel` regardless of input so you can interrupt without typing. Realtime toggle must remain available in both states.
- AgentInput now inspects the agents status and swaps the right-hand controls accordingly: send replaces dictate once text/images exist, running agents expose a new Cancel pill, and the realtime toggle is always available (it now starts/stops realtime rather than disappearing). Cancel dispatches a new `cancel_agent_request` websocket message that the server routes through `interruptAgentIfRunning`, so make sure any future API clients send that message when wiring similar controls. (FYI `npm run typecheck --workspace=@voice/app` still fails in the existing stream harness suites for unrelated reasons.)
- [x] Implement long press on the agent list item to opena a light bottom sheet with some actions, for now: "Delete agent" all this will do is remove it from our own persistence, but the agent will still exist in the claude/codex persistance and thats out of scope to remove. Its esentially just to remove it from the list, disown it.
- Added a `delete_agent_request` flow: the session now closes the agent, removes its registry entry, and emits a dedicated `agent_deleted` event that the client listens for to prune the agents map/stream state. Long-pressing any agent row opens a lightweight bottom sheet with a Delete action wired to that new context method, so deleting disowns the agent locally without touching the Claude/Codex session.
- [x] Implement a function `ensureValidJson` that walks an object and validates that all values are valid JSON (no undefined, null, or non-string values). Use this for all return valies in the MCP server tools.
- Added `ensureValidJson` (`packages/server/src/server/json-utils.ts`) and wrapped every MCP server tool payload in both `terminal-mcp/server.ts` and `agent/mcp-server.ts`, so tool responses now get validated before streaming; `tsc` still fails earlier in the tree due to `packages/app/src/types/stream.ts` references (see npm run typecheck --workspace=@voice/server).
### 4. Connection State & Persistence
- [x] Stop the Settings daemon list from firing “Daemon unavailable” alerts when background daemons are offline by reading session snapshots via `useSessionForServer` and only performing restart/test flows when a session is actually mounted.
- Updated `packages/app/src/app/settings.tsx` to rely on `useSessionForServer` for both the active daemon and each `DaemonCard`, so offline entries no longer call `useDaemonSession` (which showed alerts) and `npm run typecheck` still passes.
- [x] Introduce React Query (or a similar observable store) around AsyncStorage-backed registries (`DaemonRegistryProvider`, `DaemonConnectionsProvider`, app settings) so callers get loading/error states without bespoke hooks.
- Added `@tanstack/react-query` with a root provider, refactored the daemon registry, connections, and app settings to load/persist via cached queries (surfacing shared loading/error states) and verified everything with `npm run typecheck --workspace=@paseo/app`.
- [x] Add background reconnection + exponential backoff per daemon; surface “connecting/offline/last error” indicators in settings and home.
- WebSocket sessions now retry with exponential backoff and feed precise status/error metadata into the daemon connection store, the home screen shows a connection health banner, settings display colored status badges plus last errors for every daemon, and `npm run typecheck --workspace=@paseo/app` passes.
- [x] Persist the last successful session snapshot per daemon so the UI can hydrate agent lists immediately while a websocket reconnects.
- SessionProviders now hydrate agents/permissions/commands from the last stored `session_state` snapshot, persist new snapshots to AsyncStorage per daemon, and `npm run typecheck --workspace=@paseo/app` passes.
- [x] Standardize request/response handling behind a shared hook (React-Query style states for idle/loading/success/error, request dedupe, retries, timeouts) and document how daemon-facing components consume it.
- Added `useDaemonRequest` with deduped execution, timeout/retry controls, and React Query-style metadata plus wrote `docs/daemon-request-hook.md` describing how daemon clients consume it; ran `npm run typecheck --workspace=@paseo/app`.
- [x] Replace ad-hoc websocket request flows (git info, permission responses, diff/file fetches, etc.) with the new hook so every async action exposes consistent status + cancellation semantics.
- Adopted `useDaemonRequest` for repo-inspection modals, permission cards, git diff, and file explorer interactions (with inline loading/error states) and re-ran `npm run typecheck --workspace=@paseo/app`.
## Voice Interruptions & Streaming
### 5. Performance & UX Polish
- [x] Ensure agent image attachments preserve MIME metadata and are base64 encoded before hitting the daemon.
- `packages/app/src/contexts/session-context.tsx:1198` now accepts `{ uri, mimeType }` attachments and reads them via `expo-file-system`, and `packages/app/src/components/agent-input-area.tsx:140` forwards the stored metadata so queued sends no longer drop screenshots (`npm run typecheck` passes).
- [ ] Profile the Create Agent modal—debounce expensive effects (e.g., provider model fetches) per server and prefetch metadata when daemons are idle to eliminate the visible lag when switching targets.
- [ ] Audit websocket usage so background `SessionProvider`s never duplicate connections for the active daemon (one live connection per daemon id).
- [ ] Enforce “impossible states are impossible” across UI/data models (strict typing, discriminated unions, exhaustive switches) so complex flows remain clean without relying on Expo E2E tests.
- [x] Wire interrupt signal on speech start so VAD immediately issues `abort_request` to the server before any audio segment is sent.
- Realtime speech detection now sends a `session/abort_request` as soon as VAD confirms speech, ensuring any in-flight LLM turn is interrupted before the buffered audio is uploaded (implemented in `packages/app/src/contexts/realtime-context.tsx` with logging/error handling).
- [x] Abort server-side playback/LLM as soon as a realtime audio chunk arrives; set a speech-in-progress flag that pauses new TTS until the turn completes.
- Added a `speechInProgress` guard in `packages/server/src/server/session.ts` that triggers on the first realtime chunk, cancels pending TTS playback via the new `TTSManager.cancelPendingPlaybacks`, and immediately routes through the abort flow before buffering audio; the flag is cleared when transcription finishes so the next assistant reply can synthesize speech. TTS generation now skips while the flag is set, and `npm run typecheck --workspace=@voice/server` still fails in pre-existing cross-workspace `packages/app/src/types/stream.ts` imports (unchanged by this fix).
- [x] Interrupt the currently focused agent whenever a realtime user turn begins, ensuring the next prompt starts fresh.
- Session context now tracks a `focusedAgentId` that each agent screen sets/clears on mount, and the realtime provider looks at that value to send a `cancel_agent_request` before issuing voice aborts so the next spoken prompt doesn't pile onto a running turn. Attempted `npm run typecheck --workspace=@voice/app` but it still fails in the pre-existing stream harness/tests complaining about `parsed*` fields.
- [x] Extend focused-agent tracking to orchestrator mode so realtime interruptions cancel the agent thats actually active, even when no agent detail screen is open.
- Session context now keeps a derived auto-focus pointing at the most recent running agent whenever no explicit agent screen is selected, so realtime speech aborts send `cancel_agent_request` for that agent before streaming audio; agent screens continue to override the focus via `setFocusedAgentId`.
- [x] Keep agent runs alive while speaking to the orchestrator: realtime speech should **not** cancel the focused agent by default. Instead, only interrupt when the user explicitly addresses that agent again or issues a cancel command.
- Removed the automatic `cancel_agent_request` that fired on every realtime speech start, so orchestrator dictation no longer tears down whichever agent is in focus; users can still stop runs explicitly via the agent cancel control while the orchestrator abort logic (`abort_request`) remains intact.
- [x] Harden playback stop/queue clearing so speech detection purges suppressed audio and the server stops emitting TTS while the user is talking.
- Speech detection now aborts any in-flight TTS generation, clears buffered realtime segments (pending chunks plus partial buffers), and cancels pending playbacks before delegating to the existing abort flow, so no additional audio_output events fire once the user starts talking.
- [x] Prototype chunked audio input: emit smaller PCM frames with `isLast=false`, update `handleAudioChunk` to buffer and trigger STT once the minimum duration is reached.
- `useSpeechmaticsAudio` now streams ~1s PCM payloads throughout a speech turn (plus a final tail chunk) and `RealtimeContext` forwards them with `format: "audio/pcm;rate=16000;bits=16"` + accurate `isLast` so the server sees continuous frames instead of one big WAV. On the backend `Session.handleAudioChunk` tracks PCM bytes, converts buffered samples into WAV once the 1s threshold—or final chunk—arrives, and runs the prior buffering/timeout logic so STT fires repeatedly without waiting for speech end. `npm run typecheck --workspaces --if-present` still fails due to the existing cross-workspace `packages/app/src/types/stream.ts` errors (missing module resolution, tool snapshot typing) that occur on main as well.
- [x] Investigate streaming STT/server-side VAD (Whisper or equivalent) and document requirements for production use.
- Captured the current audio pipeline, design goals, candidate approaches (self-hosted Whisper vs managed APIs), and production requirements (transport, VAD, streaming STT engine, infra, observability, compliance) in `docs/streaming-stt-vad.md`, along with an implementation phase plan and open questions for the follow-up agent.
- [x] Prototype streaming TTS output (chunked `audio_output` messages) and update the player to start playback as soon as chunks arrive.
- Split OpenAI TTS responses into chunked `audio_output` payloads via a streaming `TTSManager` pipeline (`packages/server/src/server/agent/tts-manager.ts`, `packages/server/src/server/agent/tts-openai.ts`) that tracks per-chunk acknowledgements, adds utterance IDs, and resolves once every chunk finishes. The apps session context now keeps audio groups active until the last chunk (or an error) lands so playback begins as soon as each chunk arrives without waiting for the full clip (`packages/app/src/contexts/session-context.tsx`). `npm run typecheck --workspace=@voice/server` and `npm run typecheck --workspace=@voice/app` still fail in the pre-existing cross-workspace stream harness files (see earlier tasks); no new regressions were introduced.
- [x] Add telemetry for barge-in latency (speech start → playback stop, chunk arrival → LLM abort) to measure improvements per milestone.
- `RealtimeProvider` now records when speech detection interrupts active playback and logs `[Telemetry] barge_in.playback_stop_latency` once the app reports audio stopped, while the server's `Session.handleRealtimeSpeechStart` logs `[Telemetry] barge_in.llm_abort_latency` after aborting live LLM runs—giving us measurable client + server latency metrics per turn.
- [x] Codex tool calls arent visible in the live stream (they only show up after hydrating/refreshing); fix the reducer/rendering path so Codex tool events appear in real time the same way Claudes do.
- Stream reducer now inspects Codex `provider_event` payloads and converts command/file/MCP/web search items into agent tool entries immediately, so the UI shows the pills while the turn is running instead of waiting for hydration; `stream.test.ts` gained coverage proving provider events alone still surface command + MCP calls, and `npm run test --workspace=@voice/app -- src/types/stream.test.ts` passes.
- [x] Update the AgentInput “Cancel” control to use a square stop icon button (consistent with media players) so interrupting an agent is visually distinct from other actions.
- Replaced the text-based pill with a compact circular stop control that reuses the Square media glyph (plus accessibility labels), making the interrupt action visually distinct without impacting the other pending AgentInput tweaks.
- [x] When the agent input has text and were not in the cancellable state, replace both the mic (Dictate) and Realtime buttons with a single Send button—only surface Dictate/Realtime when the input is empty or the agent is running.
- `packages/app/src/components/agent-input-area.tsx` now shows a lone Send control when text/images are queued while keeping Dictate + Realtime visible only when the input is empty or the agent is actively running.
- [x] Remove the agent status pill next to the permission selector; instead show a “working” indicator inside the chat scroll view itself (three bouncing dots animation) whenever the agent is busy.
- Dropped the old status dot from `AgentStatusBar` (the permission/mode selector now stands alone) and introduced an in-stream "Working" chip inside `AgentStreamView` that renders three bouncing dots via Reanimated whenever the agent reports `status === "running"`, so busy turns surface directly in the chat timeline.
- [x] npm run typecheck and fix all problems
- Split the server build/typecheck configs so the typechecker can include the app stream helpers without breaking builds, added path aliases for `@server/*`, and tightened the shared stream types (status unions + `isAgentToolCallItem`) so the server e2e suites can import them safely. Cleaned up every failing app/server test by narrowing tool-call payloads via the helper, updated the harness + Codex/Claude specs, and re-ran `npm run typecheck` (app + server) to green.
- [x] audit codebase for unecessary untyped code, hacks, casts, and `any`, type things properly
- Tightened the Codex rollout parser typings so permission requests, rollout/event payloads, plan arguments, and JSON helpers all operate on well-defined TypeScript structures instead of `any`. Added explicit payload/entry guards, removed unsafe casts throughout `packages/server/src/server/agent/providers/codex-agent.ts`, and re-ran `npm run typecheck --workspace=@voice/server` to validate the stricter typing.
- [x] audit codebase for duplicated code, clean it up
- Consolidated the duplicated home/back header layouts into a shared `ScreenHeader` component so safe-area padding, borders, and spacing live in one place; both headers now only define their unique button/title content. Ran `npm run typecheck --workspace=@voice/app` to confirm the refactor stays type-safe.
- [x] rename the project to Paseo, including the Expo app, package names etc.
- Renamed the monorepo + workspaces to `paseo`, updated Expo config (name/slug/schemes + bundle IDs), AsyncStorage keys, docs, prompts, and helper text to use `@paseo/*`, and refreshed the CLI scripts + descriptions so every entry point references Paseo instead of Voice Dev. Regenerated the lockfile + pods metadata and re-ran `npm run typecheck --workspaces --if-present` to confirm both server and app builds still typecheck under the new branding.
### 6. Observability & Tooling
- [ ] Add structured logging for daemon connection lifecycle (connect, error, auto-connect skip) so we can diagnose “multi daemon” issues from device logs.
- [ ] Emit analytics when users create/resume agents on background daemons, attempt actions while those daemons are offline, or switch default daemons—helps prioritize reconnection UX.
- [ ] Document the architecture in `docs/multi-daemon.md` (registry, sessions, routing rules) so future contributors understand how to extend it.
- [ ] Land the accumulated multi-daemon changes in source control with a clean commit (linted, type-checked, plan updated).
### Review
- [x] 2025-11-26 Reviewer sanity check for the recent multi-daemon rollout work.
- Confirmed the new `useDaemonSession` hook, guarded Agent/Git Diff/File Explorer screens, and server-aware Create/Import flows align with the documented fixes; no regressions or missing follow-ups spotted, so no additional tasks were opened.
- [x] 2025-11-26 Reviewer follow-up on session isolation across daemons.
- Found that the active `SessionProvider` kept its React state when switching daemons, so stale agents/permissions could leak between server contexts; fixed by keying the provider in `_layout.tsx` so the tree remounts on each daemon change.

43
scripts/run-plan-loop.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
PLAN_FILE=${1:-plan.md}
if [[ ! -f "$PLAN_FILE" ]]; then
echo "Plan file not found: $PLAN_FILE" >&2
exit 1
fi
REPO_ROOT=$(cd "$(dirname "$PLAN_FILE")" && pwd)
PLAN_PATH="$REPO_ROOT/$(basename "$PLAN_FILE")"
IMPLEMENT_PROMPT=$(cat <<EOF2
You are Codex working in $REPO_ROOT.
1. Read the checklist at $PLAN_PATH.
2. Implement the very first unchecked task (top-most "- [ ]" entry) completely. Do not skip around.
3. After finishing the work, update $PLAN_PATH:
- Change that task marker to "- [x]".
- Under that list item add an indented bullet summarizing the work/tests/follow-ups.
4. Stop immediately after completing that single task.
EOF2
)
REVIEW_PROMPT=$(cat <<EOF3
You are Codex acting as a reviewer.
1. Read $PLAN_PATH and inspect the repository.
2. Review the work completed in the prior step: hunt for duplicate code, missing types, bugs, regressions, or corners that were cut.
3. If something is wrong, fix it or add new checklist items describing the follow-up.
4. Reorder or uncheck tasks when needed and always add context lines so we know what changed.
5. Stop after updating $PLAN_PATH with your findings (new tasks, reopened ones, etc.).
EOF3
)
iteration=1
while grep -q '\- \[ \]' "$PLAN_PATH"; do
echo "=== Iteration $iteration: implement next task ==="
codex exec --dangerously-bypass-approvals-and-sandbox -C "$REPO_ROOT" "$IMPLEMENT_PROMPT"
echo "=== Iteration $iteration: review & adjust plan ==="
codex exec --dangerously-bypass-approvals-and-sandbox -C "$REPO_ROOT" "$REVIEW_PROMPT"
iteration=$((iteration + 1))
done
echo "All tasks in $PLAN_PATH are complete."