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:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user