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
co-authored by Claude
parent 7d8979e125
commit 4ab79b1a50
33 changed files with 4795 additions and 1481 deletions
@@ -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,
@@ -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 */}
+123 -87
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,
},
@@ -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
@@ -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);
@@ -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>
+357 -115
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,
+38 -8
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,
},
}));
+32 -6
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],
@@ -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,
},
}));
@@ -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>
))}
</>
);
}