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:
@@ -20,6 +20,7 @@
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "^54.0.18",
|
||||
"expo-audio": "~1.0.13",
|
||||
|
||||
@@ -5,13 +5,37 @@ import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import { RealtimeProvider } from "@/contexts/realtime-context";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { View, ActivityIndicator } from "react-native";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { View, ActivityIndicator, Text } from "react-native";
|
||||
import { GlobalFooter } from "@/components/global-footer";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { FooterControlsProvider } from "@/contexts/footer-controls-context";
|
||||
import { DaemonRegistryProvider } from "@/contexts/daemon-registry-context";
|
||||
import { DaemonConnectionsProvider, useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
function AppContainer({ children }: { children: React.ReactNode }) {
|
||||
function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
function AppContainer({ children }: { children: ReactNode }) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
@@ -21,61 +45,107 @@ function AppContainer({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ProvidersWrapper({ children }: { children: React.ReactNode }) {
|
||||
const { settings, isLoading } = useSettings();
|
||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||
const { activeDaemon, isLoading: connectionsLoading } = useDaemonConnections();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#09090b",
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="large" color="#fafafa" />
|
||||
</View>
|
||||
);
|
||||
if (settingsLoading || connectionsLoading) {
|
||||
return <LoadingView />;
|
||||
}
|
||||
|
||||
if (!activeDaemon) {
|
||||
return <MissingDaemonView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionProvider serverUrl={settings.serverUrl}>
|
||||
<SessionProvider
|
||||
key={activeDaemon.id}
|
||||
serverUrl={activeDaemon.wsUrl}
|
||||
serverId={activeDaemon.id}
|
||||
>
|
||||
<RealtimeProvider>{children}</RealtimeProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingView() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#09090b",
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="large" color="#fafafa" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MissingDaemonView() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 24,
|
||||
backgroundColor: "#09090b",
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="small" color="#fafafa" />
|
||||
<Text
|
||||
style={{
|
||||
color: "#fafafa",
|
||||
marginTop: 16,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
No daemon configured. Open Settings to add a server URL.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<ProvidersWrapper>
|
||||
<FooterControlsProvider>
|
||||
<AppContainer>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
gestureEnabled: true,
|
||||
gestureDirection: "horizontal",
|
||||
fullScreenGestureEnabled: true,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="orchestrator" />
|
||||
<Stack.Screen name="agent/[id]" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
<Stack.Screen name="git-diff" />
|
||||
<Stack.Screen name="file-explorer" />
|
||||
</Stack>
|
||||
<GlobalFooter />
|
||||
</AppContainer>
|
||||
</FooterControlsProvider>
|
||||
</ProvidersWrapper>
|
||||
<QueryProvider>
|
||||
<DaemonRegistryProvider>
|
||||
<DaemonConnectionsProvider>
|
||||
<MultiDaemonSessionHost />
|
||||
<ProvidersWrapper>
|
||||
<FooterControlsProvider>
|
||||
<AppContainer>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
gestureEnabled: true,
|
||||
gestureDirection: "horizontal",
|
||||
fullScreenGestureEnabled: true,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="orchestrator" />
|
||||
<Stack.Screen name="agent/[id]" />
|
||||
<Stack.Screen name="agent/[serverId]/[agentId]" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
<Stack.Screen name="git-diff" />
|
||||
<Stack.Screen name="file-explorer" />
|
||||
</Stack>
|
||||
<GlobalFooter />
|
||||
</AppContainer>
|
||||
</FooterControlsProvider>
|
||||
</ProvidersWrapper>
|
||||
</DaemonConnectionsProvider>
|
||||
</DaemonRegistryProvider>
|
||||
</QueryProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
|
||||
+148
-604
@@ -1,560 +1,148 @@
|
||||
import { useEffect, useMemo, useRef, useCallback, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
Modal,
|
||||
useWindowDimensions,
|
||||
LayoutChangeEvent,
|
||||
} from "react-native";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { MoreVertical, GitBranch, Folder, RotateCcw, PlusCircle } from "lucide-react-native";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { CreateAgentModal, type CreateAgentInitialValues } from "@/components/create-agent-modal";
|
||||
import { useSession } from "@/contexts/session-context";
|
||||
import { useSessionDirectory } from "@/hooks/use-session-directory";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import { useFooterControls } from "@/contexts/footer-controls-context";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
type AgentMatch = {
|
||||
serverId: string;
|
||||
serverLabel: string;
|
||||
agent: Agent;
|
||||
};
|
||||
|
||||
type BranchStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
function extractAgentModel(agent?: Agent | null): string | null {
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directModel = typeof agent.model === "string" ? agent.model.trim() : "";
|
||||
if (directModel.length > 0) {
|
||||
return directModel;
|
||||
}
|
||||
|
||||
const metadata = agent.persistence?.metadata;
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persistedModel = (metadata as Record<string, unknown>).model;
|
||||
if (typeof persistedModel === "string" && persistedModel.trim().length > 0) {
|
||||
return persistedModel.trim();
|
||||
}
|
||||
|
||||
const extra = (metadata as Record<string, unknown>).extra;
|
||||
if (!extra || typeof extra !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getModelFrom = (source: unknown) => {
|
||||
if (!source || typeof source !== "object") {
|
||||
return null;
|
||||
}
|
||||
const candidate = (source as Record<string, unknown>).model;
|
||||
return typeof candidate === "string" && candidate.trim().length > 0
|
||||
? candidate.trim()
|
||||
: null;
|
||||
};
|
||||
|
||||
return (
|
||||
getModelFrom((extra as Record<string, unknown>).codex) ??
|
||||
getModelFrom((extra as Record<string, unknown>).claude)
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
export default function LegacyAgentRedirectScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const {
|
||||
agents,
|
||||
agentStreamState,
|
||||
initializingAgents,
|
||||
pendingPermissions,
|
||||
respondToPermission,
|
||||
initializeAgent,
|
||||
refreshAgent,
|
||||
setFocusedAgentId,
|
||||
ws,
|
||||
} = useSession();
|
||||
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
|
||||
const [menuContentHeight, setMenuContentHeight] = useState(0);
|
||||
const menuButtonRef = useRef<View>(null);
|
||||
const [branchStatus, setBranchStatus] = useState<BranchStatus>("idle");
|
||||
const [branchLabel, setBranchLabel] = useState<string | null>(null);
|
||||
const [branchError, setBranchError] = useState<string | null>(null);
|
||||
const [showCreateAgentModal, setShowCreateAgentModal] = useState(false);
|
||||
const [createAgentInitialValues, setCreateAgentInitialValues] =
|
||||
useState<CreateAgentInitialValues | undefined>();
|
||||
const repoInfoRequestIdRef = useRef<string | null>(null);
|
||||
const hasPendingRepoRequest = repoInfoRequestIdRef.current !== null;
|
||||
const branchDisplayValue =
|
||||
branchStatus === "error"
|
||||
? branchError ?? "Unavailable"
|
||||
: branchLabel ?? "Unknown";
|
||||
const shouldListenForBranchInfo = menuVisible || hasPendingRepoRequest;
|
||||
const router = useRouter();
|
||||
const { theme } = useUnistyles();
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const sessionDirectory = useSessionDirectory();
|
||||
const agentId = typeof id === "string" ? id.trim() : undefined;
|
||||
|
||||
// Keyboard animation
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
const bottomInset = useSharedValue(insets.bottom);
|
||||
|
||||
useEffect(() => {
|
||||
bottomInset.value = insets.bottom;
|
||||
}, [insets.bottom, bottomInset]);
|
||||
|
||||
const animatedKeyboardStyle = useAnimatedStyle(() => {
|
||||
"worklet";
|
||||
const absoluteHeight = Math.abs(keyboardHeight.value);
|
||||
const shift = Math.max(0, absoluteHeight - bottomInset.value);
|
||||
return {
|
||||
transform: [{ translateY: -shift }],
|
||||
};
|
||||
});
|
||||
|
||||
const agent = id ? agents.get(id) : undefined;
|
||||
const streamItems = id ? agentStreamState.get(id) || [] : [];
|
||||
const agentPermissions = new Map(
|
||||
Array.from(pendingPermissions.entries()).filter(([_, perm]) => perm.agentId === id)
|
||||
);
|
||||
const agentModel = extractAgentModel(agent);
|
||||
const modelDisplayValue = agentModel ?? "Unknown";
|
||||
|
||||
const resetBranchState = useCallback(() => {
|
||||
repoInfoRequestIdRef.current = null;
|
||||
setBranchStatus("idle");
|
||||
setBranchLabel(null);
|
||||
setBranchError(null);
|
||||
}, []);
|
||||
|
||||
const sendGitRepoInfoRequest = useCallback(
|
||||
(cwd: string) => {
|
||||
if (!cwd) {
|
||||
resetBranchState();
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = generateMessageId();
|
||||
repoInfoRequestIdRef.current = requestId;
|
||||
setBranchStatus("loading");
|
||||
setBranchLabel(null);
|
||||
setBranchError(null);
|
||||
|
||||
ws.send({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "git_repo_info_request",
|
||||
cwd,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
},
|
||||
[resetBranchState, ws]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent?.cwd) {
|
||||
resetBranchState();
|
||||
return;
|
||||
const matches = useMemo<AgentMatch[]>(() => {
|
||||
if (!agentId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
sendGitRepoInfoRequest(agent.cwd);
|
||||
}, [agent?.cwd, resetBranchState, sendGitRepoInfoRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldListenForBranchInfo) {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = ws.on("git_repo_info_response", (message) => {
|
||||
if (message.type !== "git_repo_info_response") {
|
||||
const results: AgentMatch[] = [];
|
||||
sessionDirectory.forEach((session, serverId) => {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
repoInfoRequestIdRef.current &&
|
||||
message.payload.requestId &&
|
||||
message.payload.requestId !== repoInfoRequestIdRef.current
|
||||
) {
|
||||
const agent = session.agents.get(agentId);
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (agent?.cwd && message.payload.cwd && message.payload.cwd !== agent.cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
repoInfoRequestIdRef.current = null;
|
||||
|
||||
if (message.payload.error) {
|
||||
setBranchStatus("error");
|
||||
setBranchError(message.payload.error);
|
||||
setBranchLabel(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setBranchStatus("ready");
|
||||
setBranchError(null);
|
||||
setBranchLabel(message.payload.currentBranch ?? null);
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
results.push({ serverId, serverLabel, agent });
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [agent?.cwd, shouldListenForBranchInfo, ws]);
|
||||
return results;
|
||||
}, [agentId, sessionDirectory, connectionStates]);
|
||||
|
||||
const hasSessions = sessionDirectory.size > 0;
|
||||
const isRedirecting = Boolean(agentId && matches.length === 1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setFocusedAgentId(null);
|
||||
if (!isRedirecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusedAgentId(id);
|
||||
return () => {
|
||||
setFocusedAgentId(null);
|
||||
};
|
||||
}, [id, setFocusedAgentId]);
|
||||
|
||||
const hasStreamState = id ? agentStreamState.has(id) : false;
|
||||
const initializationState = id ? initializingAgents.get(id) : undefined;
|
||||
const isInitializing = id
|
||||
? initializationState !== undefined
|
||||
? initializationState
|
||||
: !hasStreamState
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (initializationState !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasStreamState) {
|
||||
return;
|
||||
}
|
||||
|
||||
initializeAgent({ agentId: id });
|
||||
}, [id, initializeAgent, initializationState, hasStreamState]);
|
||||
|
||||
const agentControls = useMemo(() => {
|
||||
if (!id) return null;
|
||||
return <AgentInputArea agentId={id} />;
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentControls || !agent || isInitializing) {
|
||||
unregisterFooterControls();
|
||||
return;
|
||||
}
|
||||
|
||||
registerFooterControls(agentControls);
|
||||
|
||||
return () => {
|
||||
unregisterFooterControls();
|
||||
};
|
||||
}, [agentControls, agent, isInitializing, registerFooterControls, unregisterFooterControls]);
|
||||
|
||||
const recalculateMenuPosition = useCallback(
|
||||
(onMeasured?: () => void) => {
|
||||
requestAnimationFrame(() => {
|
||||
const anchor = menuButtonRef.current;
|
||||
|
||||
if (!anchor) {
|
||||
if (onMeasured) {
|
||||
onMeasured();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
anchor.measureInWindow((x, y, width, height) => {
|
||||
const verticalOffset = 8;
|
||||
const horizontalMargin = 16;
|
||||
const desiredLeft = x + width - DROPDOWN_WIDTH;
|
||||
const maxLeft = windowWidth - DROPDOWN_WIDTH - horizontalMargin;
|
||||
const clampedLeft = Math.min(Math.max(desiredLeft, horizontalMargin), maxLeft);
|
||||
|
||||
// Position menu below button - add insets.top to account for status bar
|
||||
const buttonBottom = y + height + insets.top;
|
||||
const top = buttonBottom + verticalOffset;
|
||||
|
||||
// If menu would go off screen, clamp to visible area
|
||||
const bottomEdge = top + menuContentHeight;
|
||||
const maxBottom = windowHeight - horizontalMargin;
|
||||
const clampedTop = bottomEdge > maxBottom
|
||||
? Math.max(verticalOffset, maxBottom - menuContentHeight)
|
||||
: top;
|
||||
|
||||
console.log('[Menu] Button position:', { x, y, width, height, insetsTop: insets.top });
|
||||
console.log('[Menu] Calculated position:', { buttonBottom, top, clampedTop, left: clampedLeft });
|
||||
|
||||
setMenuPosition({
|
||||
top: clampedTop,
|
||||
left: clampedLeft,
|
||||
});
|
||||
|
||||
if (onMeasured) {
|
||||
onMeasured();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
[menuContentHeight, windowHeight, windowWidth]
|
||||
);
|
||||
|
||||
const handleOpenMenu = useCallback(() => {
|
||||
if (agent?.cwd) {
|
||||
sendGitRepoInfoRequest(agent.cwd);
|
||||
}
|
||||
|
||||
recalculateMenuPosition(() => {
|
||||
setMenuVisible(true);
|
||||
const match = matches[0];
|
||||
router.replace({
|
||||
pathname: "/agent/[serverId]/[agentId]",
|
||||
params: {
|
||||
serverId: match.serverId,
|
||||
agentId: match.agent.id,
|
||||
},
|
||||
});
|
||||
}, [agent?.cwd, recalculateMenuPosition, sendGitRepoInfoRequest]);
|
||||
}, [isRedirecting, matches, router]);
|
||||
|
||||
const handleCloseMenu = useCallback(() => {
|
||||
setMenuVisible(false);
|
||||
setMenuContentHeight(0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
recalculateMenuPosition();
|
||||
}, [menuVisible, recalculateMenuPosition]);
|
||||
|
||||
const handleMenuLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
setMenuContentHeight((current) => (current === height ? current : height));
|
||||
}, []);
|
||||
|
||||
const handleBackToHome = useCallback(() => {
|
||||
const handleGoHome = useCallback(() => {
|
||||
router.replace("/");
|
||||
}, [router]);
|
||||
|
||||
const handleViewChanges = useCallback(() => {
|
||||
handleCloseMenu();
|
||||
if (id) {
|
||||
router.push(`/git-diff?agentId=${id}`);
|
||||
}
|
||||
}, [id, router, handleCloseMenu]);
|
||||
|
||||
const handleBrowseFiles = useCallback(() => {
|
||||
handleCloseMenu();
|
||||
if (id) {
|
||||
router.push(`/file-explorer?agentId=${id}`);
|
||||
}
|
||||
}, [handleCloseMenu, id, router]);
|
||||
|
||||
const handleRefreshAgent = useCallback(() => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
handleCloseMenu();
|
||||
refreshAgent({ agentId: id });
|
||||
}, [handleCloseMenu, id, refreshAgent]);
|
||||
|
||||
const handleCreateNewAgent = useCallback(() => {
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
handleCloseMenu();
|
||||
setCreateAgentInitialValues({
|
||||
workingDir: agent.cwd,
|
||||
provider: agent.provider,
|
||||
modeId: agent.currentModeId,
|
||||
model: agentModel ?? undefined,
|
||||
});
|
||||
setShowCreateAgentModal(true);
|
||||
}, [agent, agentModel, handleCloseMenu]);
|
||||
|
||||
const handleCloseCreateAgentModal = useCallback(() => {
|
||||
setShowCreateAgentModal(false);
|
||||
}, []);
|
||||
|
||||
const createAgentModal = (
|
||||
<CreateAgentModal
|
||||
isVisible={showCreateAgentModal}
|
||||
onClose={handleCloseCreateAgentModal}
|
||||
initialValues={createAgentInitialValues}
|
||||
/>
|
||||
const handleSelectMatch = useCallback(
|
||||
(match: AgentMatch) => {
|
||||
router.replace({
|
||||
pathname: "/agent/[serverId]/[agentId]",
|
||||
params: {
|
||||
serverId: match.serverId,
|
||||
agentId: match.agent.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
let body: ReactNode = null;
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.container}>
|
||||
<BackHeader onBack={handleBackToHome} />
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
</View>
|
||||
if (!agentId) {
|
||||
body = (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.title}>Missing agent</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
This link is missing an agent id. Go back to the Agents screen to pick one.
|
||||
</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={handleGoHome}>
|
||||
<Text style={styles.primaryButtonText}>Go Home</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
} else if (!hasSessions || isRedirecting) {
|
||||
body = (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
<Text style={styles.subtitle}>
|
||||
{isRedirecting ? "Opening your agent..." : "Looking for your agent..."}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (matches.length === 0) {
|
||||
body = (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.title}>Agent not found</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
We couldn't find "{agentId}" on any connected daemon. Make sure the daemon is online
|
||||
or reopen the agent from the Home screen after it reconnects.
|
||||
</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={handleGoHome}>
|
||||
<Text style={styles.primaryButtonText}>Go Home</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
} else if (matches.length > 1) {
|
||||
body = (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.title}>Pick a daemon</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Multiple daemons have an agent with this id. Choose the one you intended to open.
|
||||
</Text>
|
||||
<View style={styles.matchList}>
|
||||
{matches.map((match) => (
|
||||
<Pressable
|
||||
key={`${match.serverId}:${match.agent.id}`}
|
||||
style={styles.matchButton}
|
||||
onPress={() => handleSelectMatch(match)}
|
||||
>
|
||||
<Text style={styles.matchLabel}>{match.serverLabel}</Text>
|
||||
<Text style={styles.matchMeta} numberOfLines={1}>
|
||||
{match.agent.cwd}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
{createAgentModal}
|
||||
</>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<BackHeader
|
||||
title={agent.title || "Agent"}
|
||||
onBack={handleBackToHome}
|
||||
rightContent={
|
||||
<View ref={menuButtonRef} collapsable={false}>
|
||||
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
|
||||
<MoreVertical size={20} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Content Area with Keyboard Animation */}
|
||||
<View style={styles.contentContainer}>
|
||||
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
|
||||
{isInitializing ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
<Text style={styles.loadingText}>Loading agent...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<AgentStreamView
|
||||
agentId={id!}
|
||||
agent={agent}
|
||||
streamItems={streamItems}
|
||||
pendingPermissions={agentPermissions}
|
||||
onPermissionResponse={(agentId, requestId, response) =>
|
||||
respondToPermission(agentId, requestId, response)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
<Modal
|
||||
visible={menuVisible}
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
onRequestClose={handleCloseMenu}
|
||||
>
|
||||
<View style={styles.menuOverlay}>
|
||||
<Pressable style={styles.menuBackdrop} onPress={handleCloseMenu} />
|
||||
<View
|
||||
style={[
|
||||
styles.dropdownMenu,
|
||||
{
|
||||
position: "absolute",
|
||||
top: menuPosition.top,
|
||||
left: menuPosition.left,
|
||||
width: DROPDOWN_WIDTH,
|
||||
},
|
||||
]}
|
||||
onLayout={handleMenuLayout}
|
||||
>
|
||||
<View style={styles.menuMetaContainer}>
|
||||
<View style={styles.menuMetaRow}>
|
||||
<Text style={styles.menuMetaLabel}>Directory</Text>
|
||||
<Text
|
||||
style={styles.menuMetaValue}
|
||||
numberOfLines={2}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{agent.cwd}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.menuMetaRow}>
|
||||
<Text style={styles.menuMetaLabel}>Model</Text>
|
||||
<Text
|
||||
style={styles.menuMetaValue}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{modelDisplayValue}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.menuMetaRow}>
|
||||
<Text style={styles.menuMetaLabel}>Branch</Text>
|
||||
<View style={styles.menuMetaValueRow}>
|
||||
{branchStatus === "loading" ? (
|
||||
<>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.mutedForeground}
|
||||
/>
|
||||
<Text style={styles.menuMetaPendingText}>Fetching…</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.menuMetaValue,
|
||||
branchStatus === "error" ? styles.menuMetaValueError : null,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{branchDisplayValue}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.menuDivider} />
|
||||
|
||||
<Pressable onPress={handleViewChanges} style={styles.menuItem}>
|
||||
<GitBranch size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>View Changes</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={handleBrowseFiles} style={styles.menuItem}>
|
||||
<Folder size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>Browse Files</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={handleCreateNewAgent} style={styles.menuItem}>
|
||||
<PlusCircle size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>New Agent</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleRefreshAgent}
|
||||
style={[
|
||||
styles.menuItem,
|
||||
isInitializing ? styles.menuItemDisabled : null,
|
||||
]}
|
||||
disabled={isInitializing}
|
||||
>
|
||||
<RotateCcw size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>
|
||||
{isInitializing ? "Refreshing..." : "Refresh"}
|
||||
</Text>
|
||||
{isInitializing && (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.primary}
|
||||
style={styles.menuItemSpinner}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
{createAgentModal}
|
||||
</>
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Agent" onBack={handleGoHome} />
|
||||
<View style={styles.content}>{body}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -563,103 +151,59 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingVertical: theme.spacing[8],
|
||||
},
|
||||
loadingContainer: {
|
||||
centerState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
menuButton: {
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
menuOverlay: {
|
||||
flex: 1,
|
||||
},
|
||||
menuBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
dropdownMenu: {
|
||||
backgroundColor: theme.colors.popover,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[2],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
menuMetaContainer: {
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
menuMetaRow: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
menuMetaLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.mutedForeground,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
menuMetaValue: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
title: {
|
||||
fontSize: theme.fontSize.xl,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: "center",
|
||||
},
|
||||
menuMetaValueRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
menuMetaPendingText: {
|
||||
subtitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
textAlign: "center",
|
||||
},
|
||||
menuMetaValueError: {
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
menuDivider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: theme.colors.border,
|
||||
marginVertical: theme.spacing[2],
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
primaryButton: {
|
||||
marginTop: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.primary,
|
||||
},
|
||||
menuItemDisabled: {
|
||||
opacity: 0.6,
|
||||
primaryButtonText: {
|
||||
color: theme.colors.primaryForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
menuItemSpinner: {
|
||||
marginLeft: "auto",
|
||||
matchList: {
|
||||
width: "100%",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
menuItemText: {
|
||||
matchButton: {
|
||||
width: "100%",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
matchLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
matchMeta: {
|
||||
marginTop: theme.spacing[1],
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
import { useEffect, useMemo, useRef, useCallback, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
Modal,
|
||||
useWindowDimensions,
|
||||
LayoutChangeEvent,
|
||||
} from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { MoreVertical, GitBranch, Folder, RotateCcw, PlusCircle, Download } from "lucide-react-native";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { CreateAgentModal, ImportAgentModal, type CreateAgentInitialValues } from "@/components/create-agent-modal";
|
||||
import type { Agent, SessionContextValue } from "@/contexts/session-context";
|
||||
import { useFooterControls } from "@/contexts/footer-controls-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
|
||||
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||
import type { SessionOutboundMessage } from "@server/server/messages";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
|
||||
type GitRepoInfoResponseMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "git_repo_info_response" }
|
||||
>;
|
||||
|
||||
type BranchStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
function extractAgentModel(agent?: Agent | null): string | null {
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directModel = typeof agent.model === "string" ? agent.model.trim() : "";
|
||||
if (directModel.length > 0) {
|
||||
return directModel;
|
||||
}
|
||||
|
||||
const metadata = agent.persistence?.metadata;
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persistedModel = (metadata as Record<string, unknown>).model;
|
||||
if (typeof persistedModel === "string" && persistedModel.trim().length > 0) {
|
||||
return persistedModel.trim();
|
||||
}
|
||||
|
||||
const extra = (metadata as Record<string, unknown>).extra;
|
||||
if (!extra || typeof extra !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getModelFrom = (source: unknown) => {
|
||||
if (!source || typeof source !== "object") {
|
||||
return null;
|
||||
}
|
||||
const candidate = (source as Record<string, unknown>).model;
|
||||
return typeof candidate === "string" && candidate.trim().length > 0
|
||||
? candidate.trim()
|
||||
: null;
|
||||
};
|
||||
|
||||
return (
|
||||
getModelFrom((extra as Record<string, unknown>).codex) ??
|
||||
getModelFrom((extra as Record<string, unknown>).claude)
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { serverId, agentId } = useLocalSearchParams<{ serverId: string; agentId: string }>();
|
||||
const resolvedAgentId = typeof agentId === "string" ? agentId : undefined;
|
||||
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
||||
const handleBackToHome = useCallback(() => {
|
||||
router.replace("/");
|
||||
}, [router]);
|
||||
const { connectionStates, activeDaemonId, setActiveDaemonId } = useDaemonConnections();
|
||||
let session: SessionContextValue | null = null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedServerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeDaemonId === resolvedServerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveDaemonId(resolvedServerId);
|
||||
}, [resolvedServerId, activeDaemonId, setActiveDaemonId]);
|
||||
|
||||
try {
|
||||
session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true });
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonSessionUnavailableError) {
|
||||
session = null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const connectionServerId = resolvedServerId ?? null;
|
||||
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
||||
const serverLabel = connection?.daemon.label ?? connectionServerId ?? "Selected daemon";
|
||||
const connectionStatus = connection?.status ?? "idle";
|
||||
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
|
||||
const lastConnectionError = connection?.lastError ?? null;
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AgentSessionUnavailableState
|
||||
onBack={handleBackToHome}
|
||||
serverLabel={serverLabel}
|
||||
connectionStatus={connectionStatus}
|
||||
connectionStatusLabel={connectionStatusLabel}
|
||||
lastError={lastConnectionError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
agents,
|
||||
agentStreamState,
|
||||
initializingAgents,
|
||||
pendingPermissions,
|
||||
initializeAgent,
|
||||
refreshAgent,
|
||||
setFocusedAgentId,
|
||||
ws,
|
||||
} = session;
|
||||
const routeServerId = resolvedServerId ?? session.serverId;
|
||||
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
|
||||
const [menuContentHeight, setMenuContentHeight] = useState(0);
|
||||
const menuButtonRef = useRef<View>(null);
|
||||
const [showCreateAgentModal, setShowCreateAgentModal] = useState(false);
|
||||
const [showImportAgentModal, setShowImportAgentModal] = useState(false);
|
||||
const [createAgentInitialValues, setCreateAgentInitialValues] =
|
||||
useState<CreateAgentInitialValues | undefined>();
|
||||
|
||||
// Keyboard animation
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
const bottomInset = useSharedValue(insets.bottom);
|
||||
|
||||
useEffect(() => {
|
||||
bottomInset.value = insets.bottom;
|
||||
}, [insets.bottom, bottomInset]);
|
||||
|
||||
const animatedKeyboardStyle = useAnimatedStyle(() => {
|
||||
"worklet";
|
||||
const absoluteHeight = Math.abs(keyboardHeight.value);
|
||||
const shift = Math.max(0, absoluteHeight - bottomInset.value);
|
||||
return {
|
||||
transform: [{ translateY: -shift }],
|
||||
};
|
||||
});
|
||||
|
||||
const agent = resolvedAgentId ? agents.get(resolvedAgentId) : undefined;
|
||||
const streamItems = resolvedAgentId ? agentStreamState.get(resolvedAgentId) || [] : [];
|
||||
const agentPermissions = new Map(
|
||||
Array.from(pendingPermissions.entries()).filter(([_, perm]) => perm.agentId === resolvedAgentId)
|
||||
);
|
||||
const agentModel = extractAgentModel(agent);
|
||||
const modelDisplayValue = agentModel ?? "Unknown";
|
||||
|
||||
const gitRepoInfoRequest = useDaemonRequest<
|
||||
{ cwd: string },
|
||||
{ cwd: string; currentBranch: string | null },
|
||||
GitRepoInfoResponseMessage
|
||||
>({
|
||||
ws,
|
||||
responseType: "git_repo_info_response",
|
||||
buildRequest: ({ params, requestId }) => ({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "git_repo_info_request",
|
||||
cwd: params?.cwd ?? ".",
|
||||
requestId,
|
||||
},
|
||||
}),
|
||||
getRequestKey: (params) => params?.cwd ?? "default",
|
||||
selectData: (message) => ({
|
||||
cwd: message.payload.cwd,
|
||||
currentBranch: message.payload.currentBranch ?? null,
|
||||
}),
|
||||
extractError: (message) =>
|
||||
message.payload.error ? new Error(message.payload.error) : null,
|
||||
keepPreviousData: false,
|
||||
});
|
||||
const { execute: fetchGitRepoInfo, reset: resetGitRepoInfo } = gitRepoInfoRequest;
|
||||
const branchStatus: BranchStatus = !agent?.cwd
|
||||
? "idle"
|
||||
: gitRepoInfoRequest.status === "loading"
|
||||
? "loading"
|
||||
: gitRepoInfoRequest.status === "error"
|
||||
? "error"
|
||||
: gitRepoInfoRequest.status === "success"
|
||||
? "ready"
|
||||
: "idle";
|
||||
const branchLabel = gitRepoInfoRequest.data?.currentBranch ?? null;
|
||||
const branchError = gitRepoInfoRequest.error?.message ?? null;
|
||||
const branchDisplayValue =
|
||||
branchStatus === "error"
|
||||
? branchError ?? "Unavailable"
|
||||
: branchLabel ?? "Unknown";
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent?.cwd) {
|
||||
resetGitRepoInfo();
|
||||
return;
|
||||
}
|
||||
fetchGitRepoInfo({ cwd: agent.cwd }).catch(() => {});
|
||||
}, [agent?.cwd, fetchGitRepoInfo, resetGitRepoInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId) {
|
||||
setFocusedAgentId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusedAgentId(resolvedAgentId);
|
||||
return () => {
|
||||
setFocusedAgentId(null);
|
||||
};
|
||||
}, [resolvedAgentId, setFocusedAgentId]);
|
||||
|
||||
const hasStreamState = resolvedAgentId ? agentStreamState.has(resolvedAgentId) : false;
|
||||
const initializationState = resolvedAgentId ? initializingAgents.get(resolvedAgentId) : undefined;
|
||||
const isInitializing = resolvedAgentId
|
||||
? initializationState !== undefined
|
||||
? initializationState
|
||||
: !hasStreamState
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (initializationState !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasStreamState) {
|
||||
return;
|
||||
}
|
||||
|
||||
initializeAgent({ agentId: resolvedAgentId });
|
||||
}, [resolvedAgentId, initializeAgent, initializationState, hasStreamState]);
|
||||
|
||||
const agentControls = useMemo(() => {
|
||||
if (!resolvedAgentId) return null;
|
||||
return <AgentInputArea agentId={resolvedAgentId} serverId={routeServerId} />;
|
||||
}, [resolvedAgentId, routeServerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentControls || !agent || isInitializing) {
|
||||
unregisterFooterControls();
|
||||
return;
|
||||
}
|
||||
|
||||
registerFooterControls(agentControls);
|
||||
|
||||
return () => {
|
||||
unregisterFooterControls();
|
||||
};
|
||||
}, [agentControls, agent, isInitializing, registerFooterControls, unregisterFooterControls]);
|
||||
|
||||
const recalculateMenuPosition = useCallback(
|
||||
(onMeasured?: () => void) => {
|
||||
requestAnimationFrame(() => {
|
||||
const anchor = menuButtonRef.current;
|
||||
|
||||
if (!anchor) {
|
||||
if (onMeasured) {
|
||||
onMeasured();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
anchor.measureInWindow((x, y, width, height) => {
|
||||
const verticalOffset = 8;
|
||||
const horizontalMargin = 16;
|
||||
const desiredLeft = x + width - DROPDOWN_WIDTH;
|
||||
const maxLeft = windowWidth - DROPDOWN_WIDTH - horizontalMargin;
|
||||
const clampedLeft = Math.min(Math.max(desiredLeft, horizontalMargin), maxLeft);
|
||||
|
||||
// Position menu below button using the raw coordinates from measureInWindow
|
||||
const buttonBottom = y + height;
|
||||
const top = buttonBottom + verticalOffset;
|
||||
|
||||
// If menu would go off screen, clamp to visible area
|
||||
const bottomEdge = top + menuContentHeight;
|
||||
const maxBottom = windowHeight - horizontalMargin;
|
||||
const clampedTop = bottomEdge > maxBottom
|
||||
? Math.max(verticalOffset, maxBottom - menuContentHeight)
|
||||
: top;
|
||||
|
||||
setMenuPosition({
|
||||
top: clampedTop,
|
||||
left: clampedLeft,
|
||||
});
|
||||
|
||||
if (onMeasured) {
|
||||
onMeasured();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
[menuContentHeight, windowHeight, windowWidth]
|
||||
);
|
||||
|
||||
const handleOpenMenu = useCallback(() => {
|
||||
if (agent?.cwd) {
|
||||
fetchGitRepoInfo({ cwd: agent.cwd }).catch(() => {});
|
||||
}
|
||||
|
||||
recalculateMenuPosition(() => {
|
||||
setMenuVisible(true);
|
||||
});
|
||||
}, [agent?.cwd, fetchGitRepoInfo, recalculateMenuPosition]);
|
||||
|
||||
const handleCloseMenu = useCallback(() => {
|
||||
setMenuVisible(false);
|
||||
setMenuContentHeight(0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
recalculateMenuPosition();
|
||||
}, [menuVisible, recalculateMenuPosition]);
|
||||
|
||||
const handleMenuLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
setMenuContentHeight((current) => (current === height ? current : height));
|
||||
}, []);
|
||||
|
||||
const handleViewChanges = useCallback(() => {
|
||||
handleCloseMenu();
|
||||
if (resolvedAgentId) {
|
||||
router.push({
|
||||
pathname: "/git-diff",
|
||||
params: {
|
||||
agentId: resolvedAgentId,
|
||||
serverId: routeServerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [resolvedAgentId, routeServerId, router, handleCloseMenu]);
|
||||
|
||||
const handleBrowseFiles = useCallback(() => {
|
||||
handleCloseMenu();
|
||||
if (resolvedAgentId) {
|
||||
router.push({
|
||||
pathname: "/file-explorer",
|
||||
params: {
|
||||
agentId: resolvedAgentId,
|
||||
serverId: routeServerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [handleCloseMenu, resolvedAgentId, routeServerId, router]);
|
||||
|
||||
const handleRefreshAgent = useCallback(() => {
|
||||
if (!resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
handleCloseMenu();
|
||||
refreshAgent({ agentId: resolvedAgentId });
|
||||
}, [handleCloseMenu, resolvedAgentId, refreshAgent]);
|
||||
|
||||
const handleCreateNewAgent = useCallback(() => {
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
handleCloseMenu();
|
||||
setCreateAgentInitialValues({
|
||||
workingDir: agent.cwd,
|
||||
provider: agent.provider,
|
||||
modeId: agent.currentModeId,
|
||||
model: agentModel ?? undefined,
|
||||
});
|
||||
setShowCreateAgentModal(true);
|
||||
}, [agent, agentModel, handleCloseMenu]);
|
||||
|
||||
const handleCloseCreateAgentModal = useCallback(() => {
|
||||
setShowCreateAgentModal(false);
|
||||
}, []);
|
||||
|
||||
const handleImportAgent = useCallback(() => {
|
||||
handleCloseMenu();
|
||||
setShowImportAgentModal(true);
|
||||
}, [handleCloseMenu]);
|
||||
|
||||
const handleCloseImportAgentModal = useCallback(() => {
|
||||
setShowImportAgentModal(false);
|
||||
}, []);
|
||||
|
||||
const createAgentModal = (
|
||||
<CreateAgentModal
|
||||
isVisible={showCreateAgentModal}
|
||||
onClose={handleCloseCreateAgentModal}
|
||||
initialValues={createAgentInitialValues}
|
||||
serverId={routeServerId}
|
||||
/>
|
||||
);
|
||||
|
||||
const importAgentModal = (
|
||||
<ImportAgentModal
|
||||
isVisible={showImportAgentModal}
|
||||
onClose={handleCloseImportAgentModal}
|
||||
serverId={routeServerId}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.container}>
|
||||
<BackHeader onBack={handleBackToHome} />
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
</View>
|
||||
</View>
|
||||
{createAgentModal}
|
||||
{importAgentModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<BackHeader
|
||||
title={agent.title || "Agent"}
|
||||
onBack={handleBackToHome}
|
||||
rightContent={
|
||||
<View ref={menuButtonRef} collapsable={false}>
|
||||
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
|
||||
<MoreVertical size={20} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Content Area with Keyboard Animation */}
|
||||
<View style={styles.contentContainer}>
|
||||
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
|
||||
{isInitializing ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
<Text style={styles.loadingText}>Loading agent...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<AgentStreamView
|
||||
agentId={agent.id}
|
||||
serverId={routeServerId}
|
||||
agent={agent}
|
||||
streamItems={streamItems}
|
||||
pendingPermissions={agentPermissions}
|
||||
/>
|
||||
)}
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
<Modal
|
||||
visible={menuVisible}
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
onRequestClose={handleCloseMenu}
|
||||
>
|
||||
<View style={styles.menuOverlay}>
|
||||
<Pressable style={styles.menuBackdrop} onPress={handleCloseMenu} />
|
||||
<View
|
||||
style={[
|
||||
styles.dropdownMenu,
|
||||
{
|
||||
position: "absolute",
|
||||
top: menuPosition.top,
|
||||
left: menuPosition.left,
|
||||
width: DROPDOWN_WIDTH,
|
||||
},
|
||||
]}
|
||||
onLayout={handleMenuLayout}
|
||||
>
|
||||
<View style={styles.menuMetaContainer}>
|
||||
<View style={styles.menuMetaRow}>
|
||||
<Text style={styles.menuMetaLabel}>Directory</Text>
|
||||
<Text
|
||||
style={styles.menuMetaValue}
|
||||
numberOfLines={2}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{agent.cwd}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.menuMetaRow}>
|
||||
<Text style={styles.menuMetaLabel}>Model</Text>
|
||||
<Text
|
||||
style={styles.menuMetaValue}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{modelDisplayValue}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.menuMetaRow}>
|
||||
<Text style={styles.menuMetaLabel}>Branch</Text>
|
||||
<View style={styles.menuMetaValueRow}>
|
||||
{branchStatus === "loading" ? (
|
||||
<>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.mutedForeground}
|
||||
/>
|
||||
<Text style={styles.menuMetaPendingText}>Fetching…</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.menuMetaValue,
|
||||
branchStatus === "error" ? styles.menuMetaValueError : null,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{branchDisplayValue}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.menuDivider} />
|
||||
|
||||
<Pressable onPress={handleViewChanges} style={styles.menuItem}>
|
||||
<GitBranch size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>View Changes</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={handleBrowseFiles} style={styles.menuItem}>
|
||||
<Folder size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>Browse Files</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={handleImportAgent} style={styles.menuItem}>
|
||||
<Download size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>Import Agent</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={handleCreateNewAgent} style={styles.menuItem}>
|
||||
<PlusCircle size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>New Agent</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleRefreshAgent}
|
||||
style={[
|
||||
styles.menuItem,
|
||||
isInitializing ? styles.menuItemDisabled : null,
|
||||
]}
|
||||
disabled={isInitializing}
|
||||
>
|
||||
<RotateCcw size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>
|
||||
{isInitializing ? "Refreshing..." : "Refresh"}
|
||||
</Text>
|
||||
{isInitializing && (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.primary}
|
||||
style={styles.menuItemSpinner}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
{createAgentModal}
|
||||
{importAgentModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSessionUnavailableState({
|
||||
onBack,
|
||||
serverLabel,
|
||||
connectionStatus,
|
||||
connectionStatusLabel,
|
||||
lastError,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
serverLabel: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
connectionStatusLabel: string;
|
||||
lastError: string | null;
|
||||
}) {
|
||||
const isConnecting = connectionStatus === "connecting";
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Agent" onBack={onBack} />
|
||||
<View style={styles.centerState}>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text style={styles.loadingText}>Connecting to {serverLabel}...</Text>
|
||||
<Text style={styles.statusText}>We'll show this agent once the daemon is online.</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.errorText}>
|
||||
Can't open this agent while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
|
||||
</Text>
|
||||
<Text style={styles.statusText}>
|
||||
Connect this daemon or switch to another one to continue.
|
||||
</Text>
|
||||
{lastError ? <Text style={styles.errorDetails}>{lastError}</Text> : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
centerState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
color: theme.colors.mutedForeground,
|
||||
textAlign: "center",
|
||||
},
|
||||
statusText: {
|
||||
marginTop: theme.spacing[2],
|
||||
textAlign: "center",
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
errorDetails: {
|
||||
marginTop: theme.spacing[1],
|
||||
textAlign: "center",
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
menuButton: {
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
menuOverlay: {
|
||||
flex: 1,
|
||||
},
|
||||
menuBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
dropdownMenu: {
|
||||
backgroundColor: theme.colors.popover,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[2],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
menuMetaContainer: {
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
menuMetaRow: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
menuMetaLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.mutedForeground,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
menuMetaValue: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
menuMetaValueRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
menuMetaPendingText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
menuMetaValueError: {
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
menuDivider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: theme.colors.border,
|
||||
marginVertical: theme.spacing[2],
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
menuItemDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
menuItemSpinner: {
|
||||
marginLeft: "auto",
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
@@ -29,26 +29,104 @@ import {
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { useSession, type ExplorerEntry } from "@/contexts/session-context";
|
||||
import type { ExplorerEntry, SessionContextValue } from "@/contexts/session-context";
|
||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
|
||||
|
||||
export default function FileExplorerScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
agentId,
|
||||
path: pathParamRaw,
|
||||
file: fileParamRaw,
|
||||
serverId,
|
||||
} = useLocalSearchParams<{
|
||||
agentId: string;
|
||||
path?: string | string[];
|
||||
file?: string | string[];
|
||||
serverId?: string;
|
||||
}>();
|
||||
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
|
||||
let session: SessionContextValue | null = null;
|
||||
|
||||
try {
|
||||
session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true });
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonSessionUnavailableError) {
|
||||
session = null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const connectionServerId = resolvedServerId ?? null;
|
||||
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
||||
const serverLabel = connection?.daemon.label ?? connectionServerId ?? session?.serverId ?? "Selected daemon";
|
||||
const connectionStatus = connection?.status ?? "idle";
|
||||
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
|
||||
const lastError = connection?.lastError ?? null;
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<FileExplorerSessionUnavailable
|
||||
agentId={agentId}
|
||||
serverId={resolvedServerId}
|
||||
serverLabel={serverLabel}
|
||||
connectionStatus={connectionStatus}
|
||||
connectionStatusLabel={connectionStatusLabel}
|
||||
lastError={lastError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const routeServerId = resolvedServerId ?? session.serverId;
|
||||
|
||||
return (
|
||||
<FileExplorerContent
|
||||
session={session}
|
||||
agentId={agentId}
|
||||
pathParamRaw={pathParamRaw}
|
||||
fileParamRaw={fileParamRaw}
|
||||
routeServerId={routeServerId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type FileExplorerContentProps = {
|
||||
session: SessionContextValue;
|
||||
agentId?: string;
|
||||
pathParamRaw?: string | string[];
|
||||
fileParamRaw?: string | string[];
|
||||
routeServerId: string;
|
||||
};
|
||||
|
||||
type FileExplorerSessionUnavailableProps = {
|
||||
agentId?: string;
|
||||
serverId?: string;
|
||||
serverLabel: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
connectionStatusLabel: string;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
function FileExplorerContent({
|
||||
session,
|
||||
agentId,
|
||||
pathParamRaw,
|
||||
fileParamRaw,
|
||||
routeServerId,
|
||||
}: FileExplorerContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
agents,
|
||||
fileExplorer,
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
navigateExplorerBack,
|
||||
} = useSession();
|
||||
} = session;
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
const [selectedEntryPath, setSelectedEntryPath] = useState<string | null>(null);
|
||||
const pendingPathParamRef = useRef<string | null>(null);
|
||||
@@ -258,12 +336,15 @@ export default function FileExplorerScreen() {
|
||||
|
||||
const handleCloseExplorer = useCallback(() => {
|
||||
if (agentId) {
|
||||
router.replace({ pathname: "/agent/[id]", params: { id: agentId } });
|
||||
router.replace({
|
||||
pathname: "/agent/[serverId]/[agentId]",
|
||||
params: { serverId: routeServerId, agentId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
router.back();
|
||||
}, [agentId]);
|
||||
}, [agentId, routeServerId]);
|
||||
|
||||
const handleBackNavigation = useCallback(() => {
|
||||
if (!agentId) {
|
||||
@@ -576,6 +657,61 @@ export default function FileExplorerScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function FileExplorerSessionUnavailable({
|
||||
agentId,
|
||||
serverId,
|
||||
serverLabel,
|
||||
connectionStatus,
|
||||
connectionStatusLabel,
|
||||
lastError,
|
||||
}: FileExplorerSessionUnavailableProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (agentId && serverId) {
|
||||
router.replace({
|
||||
pathname: "/agent/[serverId]/[agentId]",
|
||||
params: { serverId, agentId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.back();
|
||||
}, [agentId, serverId]);
|
||||
|
||||
const isConnecting = connectionStatus === "connecting";
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader
|
||||
title="Files"
|
||||
onBack={handleClose}
|
||||
rightContent={
|
||||
<Pressable style={styles.closeButton} onPress={handleClose}>
|
||||
<X size={18} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
<View style={styles.centerState}>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Connecting to {serverLabel}...</Text>
|
||||
<Text style={styles.statusText}>We'll load files once this daemon is online.</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.errorText}>
|
||||
Can't open files while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
|
||||
</Text>
|
||||
<Text style={styles.statusText}>Connect this daemon and try again.</Text>
|
||||
{lastError ? <Text style={styles.errorDetails}>{lastError}</Text> : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewToggle({
|
||||
viewMode,
|
||||
onChange,
|
||||
@@ -828,6 +964,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
statusText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
errorDetails: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
textAlign: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
@@ -3,7 +3,11 @@ import { View, Text, ScrollView, ActivityIndicator } from "react-native";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { useSession } from "@/contexts/session-context";
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
|
||||
|
||||
interface ParsedDiffFile {
|
||||
path: string;
|
||||
@@ -51,8 +55,55 @@ function parseDiff(diffText: string): ParsedDiffFile[] {
|
||||
}
|
||||
|
||||
export default function GitDiffScreen() {
|
||||
const { agentId } = useLocalSearchParams<{ agentId: string }>();
|
||||
const { agents, gitDiffs, requestGitDiff } = useSession();
|
||||
const { agentId, serverId } = useLocalSearchParams<{ agentId: string; serverId?: string }>();
|
||||
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
|
||||
let session: SessionContextValue | null = null;
|
||||
|
||||
try {
|
||||
session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true });
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonSessionUnavailableError) {
|
||||
session = null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const connectionServerId = resolvedServerId ?? null;
|
||||
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
||||
const serverLabel = connection?.daemon.label ?? connectionServerId ?? session?.serverId ?? "Active daemon";
|
||||
const connectionStatus = connection?.status ?? "idle";
|
||||
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
|
||||
const lastError = connection?.lastError ?? null;
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<SessionUnavailableState
|
||||
serverLabel={serverLabel}
|
||||
connectionStatus={connectionStatus}
|
||||
connectionStatusLabel={connectionStatusLabel}
|
||||
lastError={lastError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const routeServerId = resolvedServerId ?? session.serverId;
|
||||
|
||||
return <GitDiffContent session={session} agentId={agentId} routeServerId={routeServerId} />;
|
||||
}
|
||||
|
||||
function GitDiffContent({
|
||||
session,
|
||||
agentId,
|
||||
routeServerId,
|
||||
}: {
|
||||
session: SessionContextValue;
|
||||
agentId?: string;
|
||||
routeServerId: string;
|
||||
}) {
|
||||
const { agents, gitDiffs, requestGitDiff } = session;
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const agent = agentId ? agents.get(agentId) : undefined;
|
||||
@@ -64,7 +115,6 @@ export default function GitDiffScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always request fresh diff when screen mounts
|
||||
setIsLoading(true);
|
||||
requestGitDiff(agentId);
|
||||
|
||||
@@ -85,6 +135,7 @@ export default function GitDiffScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Changes" />
|
||||
<Text style={styles.metaText}>Server: {routeServerId}</Text>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
</View>
|
||||
@@ -99,6 +150,7 @@ export default function GitDiffScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Changes" />
|
||||
<Text style={styles.metaText}>Server: {routeServerId}</Text>
|
||||
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.contentContainer}>
|
||||
{isLoading ? (
|
||||
@@ -156,11 +208,55 @@ export default function GitDiffScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function SessionUnavailableState({
|
||||
serverLabel,
|
||||
connectionStatus,
|
||||
connectionStatusLabel,
|
||||
lastError,
|
||||
}: {
|
||||
serverLabel: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
connectionStatusLabel: string;
|
||||
lastError: string | null;
|
||||
}) {
|
||||
const isConnecting = connectionStatus === "connecting";
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Changes" />
|
||||
<Text style={styles.metaText}>Server: {serverLabel}</Text>
|
||||
<View style={styles.errorContainer}>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text style={styles.loadingText}>Connecting to {serverLabel}...</Text>
|
||||
<Text style={styles.statusText}>We'll show changes once this session is online.</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.errorText}>
|
||||
Can't load changes while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
|
||||
</Text>
|
||||
<Text style={styles.statusText}>Connect this daemon or switch to another one to continue.</Text>
|
||||
{lastError ? <Text style={styles.errorDetails}>{lastError}</Text> : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
metaText: {
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
marginBottom: theme.spacing[2],
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -191,6 +287,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.destructive,
|
||||
textAlign: "center",
|
||||
},
|
||||
statusText: {
|
||||
marginTop: theme.spacing[3],
|
||||
textAlign: "center",
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
errorDetails: {
|
||||
marginTop: theme.spacing[2],
|
||||
textAlign: "center",
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
|
||||
+145
-10
@@ -1,5 +1,5 @@
|
||||
import { View } from "react-native";
|
||||
import { useState, useCallback } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
@@ -9,15 +9,29 @@ import { EmptyState } from "@/components/empty-state";
|
||||
import { AgentList } from "@/components/agent-list";
|
||||
import { CreateAgentModal, ImportAgentModal } from "@/components/create-agent-modal";
|
||||
import { useSession } from "@/contexts/session-context";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus, getConnectionStatusTone, type ConnectionStatusTone } from "@/utils/daemons";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { agents } = useSession();
|
||||
const { activeDaemonId, connectionStates } = useDaemonConnections();
|
||||
const aggregatedAgents = useAggregatedAgents(agents);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [createModalMounted, setCreateModalMounted] = useState(false);
|
||||
const [importModalMounted, setImportModalMounted] = useState(false);
|
||||
const [pendingImportServerId, setPendingImportServerId] = useState<string | null>(null);
|
||||
const { modal, flow, action, serverId: serverIdParam } = useLocalSearchParams<{
|
||||
modal?: string;
|
||||
flow?: string;
|
||||
action?: string;
|
||||
serverId?: string;
|
||||
}>();
|
||||
const deepLinkHandledRef = useRef<string | null>(null);
|
||||
|
||||
// Keyboard animation
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
@@ -30,17 +44,38 @@ export default function HomeScreen() {
|
||||
};
|
||||
});
|
||||
|
||||
const hasAgents = agents.size > 0;
|
||||
const aggregatedCount = aggregatedAgents.reduce((count, group) => count + group.agents.length, 0);
|
||||
const hasAgents = aggregatedCount > 0;
|
||||
const connectionIssues = useMemo(() => {
|
||||
return Array.from(connectionStates.values()).filter(
|
||||
(entry) => entry.status === "connecting" || entry.status === "offline" || entry.status === "error"
|
||||
);
|
||||
}, [connectionStates]);
|
||||
const statusColors: Record<ConnectionStatusTone, string> = {
|
||||
success: theme.colors.palette.green[400],
|
||||
warning: theme.colors.palette.amber[500],
|
||||
error: theme.colors.destructive,
|
||||
muted: theme.colors.mutedForeground,
|
||||
};
|
||||
|
||||
const handleCreateAgent = useCallback(() => {
|
||||
setCreateModalMounted(true);
|
||||
setShowCreateModal(true);
|
||||
}, []);
|
||||
|
||||
const openImportModal = useCallback(
|
||||
(serverIdOverride?: string | null) => {
|
||||
const resolvedServerId = serverIdOverride ?? activeDaemonId ?? null;
|
||||
setPendingImportServerId(resolvedServerId);
|
||||
setImportModalMounted(true);
|
||||
setShowImportModal(true);
|
||||
},
|
||||
[activeDaemonId]
|
||||
);
|
||||
|
||||
const handleImportAgent = useCallback(() => {
|
||||
setImportModalMounted(true);
|
||||
setShowImportModal(true);
|
||||
}, []);
|
||||
openImportModal();
|
||||
}, [openImportModal]);
|
||||
|
||||
const handleCloseCreateModal = useCallback(() => {
|
||||
setShowCreateModal(false);
|
||||
@@ -48,8 +83,40 @@ export default function HomeScreen() {
|
||||
|
||||
const handleCloseImportModal = useCallback(() => {
|
||||
setShowImportModal(false);
|
||||
setPendingImportServerId(null);
|
||||
}, []);
|
||||
|
||||
const wantsImportDeepLink = useMemo(() => {
|
||||
const values = [modal, flow, action];
|
||||
return values.some(
|
||||
(value) => typeof value === "string" && value.trim().toLowerCase() === "import"
|
||||
);
|
||||
}, [action, flow, modal]);
|
||||
const deepLinkServerId = typeof serverIdParam === "string" ? serverIdParam : null;
|
||||
const deepLinkKey = useMemo(() => {
|
||||
if (!wantsImportDeepLink) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
action: action ?? null,
|
||||
flow: flow ?? null,
|
||||
modal: modal ?? null,
|
||||
serverId: deepLinkServerId,
|
||||
});
|
||||
}, [action, flow, modal, deepLinkServerId, wantsImportDeepLink]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wantsImportDeepLink || !deepLinkKey) {
|
||||
deepLinkHandledRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (deepLinkHandledRef.current === deepLinkKey) {
|
||||
return;
|
||||
}
|
||||
deepLinkHandledRef.current = deepLinkKey;
|
||||
openImportModal(deepLinkServerId);
|
||||
}, [deepLinkKey, deepLinkServerId, openImportModal, wantsImportDeepLink]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
@@ -58,21 +125,56 @@ export default function HomeScreen() {
|
||||
onImportAgent={handleImportAgent}
|
||||
/>
|
||||
|
||||
{connectionIssues.length > 0 ? (
|
||||
<View style={styles.connectionBanner}>
|
||||
{connectionIssues.map((entry) => {
|
||||
const tone = getConnectionStatusTone(entry.status);
|
||||
const statusColor = statusColors[tone];
|
||||
return (
|
||||
<View key={entry.daemon.id} style={styles.connectionRow}>
|
||||
<View style={styles.connectionHeader}>
|
||||
<View style={[styles.connectionDot, { backgroundColor: statusColor }]} />
|
||||
<Text style={styles.connectionLabel}>
|
||||
{entry.daemon.label} · {formatConnectionStatus(entry.status)}
|
||||
</Text>
|
||||
</View>
|
||||
{entry.lastError ? (
|
||||
<Text style={styles.connectionError} numberOfLines={2}>
|
||||
{entry.lastError}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Content Area with Keyboard Animation */}
|
||||
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
|
||||
{hasAgents ? (
|
||||
<AgentList agents={agents} />
|
||||
<AgentList agentGroups={aggregatedAgents} />
|
||||
) : (
|
||||
<EmptyState onCreateAgent={handleCreateAgent} />
|
||||
<EmptyState
|
||||
onCreateAgent={handleCreateAgent}
|
||||
onImportAgent={handleImportAgent}
|
||||
/>
|
||||
)}
|
||||
</ReanimatedAnimated.View>
|
||||
|
||||
{/* Create Agent Modal */}
|
||||
{createModalMounted ? (
|
||||
<CreateAgentModal isVisible={showCreateModal} onClose={handleCloseCreateModal} />
|
||||
<CreateAgentModal
|
||||
isVisible={showCreateModal}
|
||||
onClose={handleCloseCreateModal}
|
||||
serverId={activeDaemonId}
|
||||
/>
|
||||
) : null}
|
||||
{importModalMounted ? (
|
||||
<ImportAgentModal isVisible={showImportModal} onClose={handleCloseImportModal} />
|
||||
<ImportAgentModal
|
||||
isVisible={showImportModal}
|
||||
onClose={handleCloseImportModal}
|
||||
serverId={pendingImportServerId ?? activeDaemonId}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
@@ -83,6 +185,39 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
connectionBanner: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingTop: theme.spacing[4],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
connectionRow: {
|
||||
backgroundColor: theme.colors.card,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
connectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
connectionDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
connectionLabel: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
connectionError: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
+628
-150
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -11,11 +12,14 @@ import {
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { useSession } from "@/contexts/session-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useDaemonRegistry, type DaemonProfile } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { useSessionForServer } from "@/hooks/use-session-directory";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -67,6 +71,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
inputDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
helperText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -115,11 +122,23 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[4],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
daemonCard: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
daemonCardActive: {
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.palette.blue[500],
|
||||
},
|
||||
settingRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
daemonHeaderRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
settingContent: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -132,6 +151,87 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
connectionStatusBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
connectionStatusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
connectionStatusText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
connectionErrorText: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
daemonActionsRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
daemonActionButton: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
daemonActionText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
daemonActionDestructive: {
|
||||
borderColor: theme.colors.destructive,
|
||||
},
|
||||
daemonActionDestructiveText: {
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
daemonFormActionsRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[3],
|
||||
},
|
||||
daemonActionPrimary: {
|
||||
backgroundColor: theme.colors.palette.blue[500],
|
||||
borderColor: theme.colors.palette.blue[500],
|
||||
},
|
||||
daemonActionPrimaryText: {
|
||||
color: theme.colors.palette.white,
|
||||
},
|
||||
daemonActionDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
testResultSuccessText: {
|
||||
color: theme.colors.palette.green[400],
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
testResultErrorText: {
|
||||
color: theme.colors.palette.red[200],
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
testResultInfoText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
addButton: {
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
},
|
||||
addButtonText: {
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
themeCardDisabled: {
|
||||
backgroundColor: theme.colors.card,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -202,24 +302,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
restartButton: {
|
||||
padding: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginTop: theme.spacing[3],
|
||||
backgroundColor: theme.colors.destructive,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
restartButtonDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
restartButtonText: {
|
||||
color: theme.colors.destructiveForeground,
|
||||
textAlign: "center",
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
footer: {
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
borderTopColor: theme.colors.border,
|
||||
@@ -238,11 +320,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { settings, isLoading, updateSettings, resetSettings } = useSettings();
|
||||
const { restartServer, ws } = useSession();
|
||||
type DaemonTestState = {
|
||||
status: "idle" | "testing" | "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const [serverUrl, setServerUrl] = useState(settings.serverUrl);
|
||||
export default function SettingsScreen() {
|
||||
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
|
||||
const { daemons, isLoading: daemonLoading, addDaemon, updateDaemon, removeDaemon, setDefaultDaemon } = useDaemonRegistry();
|
||||
const { activeDaemon, activeDaemonId, setActiveDaemonId, connectionStates, updateConnectionStatus } = useDaemonConnections();
|
||||
const activeDaemonSession = useSessionForServer(activeDaemonId ?? null);
|
||||
const activeDaemonConnection = activeDaemonId ? connectionStates.get(activeDaemonId) : null;
|
||||
const activeDaemonStatusLabel = activeDaemonConnection ? formatConnectionStatus(activeDaemonConnection.status) : null;
|
||||
|
||||
const [serverUrl, setServerUrl] = useState(activeDaemon?.wsUrl ?? "");
|
||||
const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker);
|
||||
const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn);
|
||||
const [theme, setTheme] = useState<"dark" | "light" | "auto">(settings.theme);
|
||||
@@ -252,9 +343,24 @@ export default function SettingsScreen() {
|
||||
success: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false);
|
||||
const [daemonForm, setDaemonForm] = useState<{ id: string | null; label: string; wsUrl: string; autoConnect: boolean }>(
|
||||
{ id: null, label: "", wsUrl: "", autoConnect: true }
|
||||
);
|
||||
const [isSavingDaemon, setIsSavingDaemon] = useState(false);
|
||||
const [daemonTestStates, setDaemonTestStates] = useState<Map<string, DaemonTestState>>(() => new Map());
|
||||
const isLoading = settingsLoading || daemonLoading;
|
||||
const baselineServerUrl = activeDaemon?.wsUrl ?? "";
|
||||
const isMountedRef = useRef(true);
|
||||
const wsIsConnectedRef = useRef(ws.isConnected);
|
||||
const isServerConfigLocked = Boolean(activeDaemon && !activeDaemonSession);
|
||||
const serverDescriptionText = activeDaemon
|
||||
? `${activeDaemon.label}${activeDaemonStatusLabel ? ` - ${activeDaemonStatusLabel}` : ""}${
|
||||
isServerConfigLocked ? " - Session unavailable" : ""
|
||||
}`
|
||||
: "No active daemon selected.";
|
||||
const serverHelperText = isServerConfigLocked
|
||||
? `Connect to ${activeDaemon?.label ?? "this daemon"} to edit its server URL.`
|
||||
: "Must be a valid WebSocket URL (ws:// or wss://)";
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -262,10 +368,6 @@ export default function SettingsScreen() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
wsIsConnectedRef.current = ws.isConnected;
|
||||
}, [ws.isConnected]);
|
||||
|
||||
const waitForCondition = useCallback(
|
||||
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
@@ -336,71 +438,147 @@ export default function SettingsScreen() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const waitForServerRestart = useCallback(async () => {
|
||||
const maxAttempts = 12;
|
||||
const retryDelayMs = 2500;
|
||||
const disconnectTimeoutMs = 7000;
|
||||
const reconnectTimeoutMs = 10000;
|
||||
const handleOpenDaemonForm = useCallback((profile?: DaemonProfile) => {
|
||||
if (profile) {
|
||||
setDaemonForm({
|
||||
id: profile.id,
|
||||
label: profile.label,
|
||||
wsUrl: profile.wsUrl,
|
||||
autoConnect: profile.autoConnect,
|
||||
});
|
||||
} else {
|
||||
setDaemonForm({ id: null, label: "", wsUrl: "", autoConnect: true });
|
||||
}
|
||||
setIsDaemonFormVisible(true);
|
||||
}, []);
|
||||
|
||||
if (wsIsConnectedRef.current) {
|
||||
await waitForCondition(() => !wsIsConnectedRef.current, disconnectTimeoutMs);
|
||||
const handleCloseDaemonForm = useCallback(() => {
|
||||
setIsDaemonFormVisible(false);
|
||||
setDaemonForm({ id: null, label: "", wsUrl: "", autoConnect: true });
|
||||
}, []);
|
||||
|
||||
const handleSubmitDaemonForm = useCallback(async () => {
|
||||
if (!daemonForm.label.trim()) {
|
||||
Alert.alert("Label required", "Please enter a label for the daemon.");
|
||||
return;
|
||||
}
|
||||
if (!validateServerUrl(daemonForm.wsUrl)) {
|
||||
Alert.alert("Invalid URL", "Daemon URL must be ws:// or wss://");
|
||||
return;
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await testServerConnection(settings.serverUrl);
|
||||
const reconnected = await waitForCondition(
|
||||
() => wsIsConnectedRef.current,
|
||||
reconnectTimeoutMs
|
||||
);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setIsRestarting(false);
|
||||
if (!reconnected) {
|
||||
Alert.alert(
|
||||
"Server reachable",
|
||||
"The server came back online but the app has not reconnected yet."
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Settings] Restart poll attempt ${attempt}/${maxAttempts} failed`,
|
||||
error
|
||||
);
|
||||
if (attempt === maxAttempts) {
|
||||
if (isMountedRef.current) {
|
||||
setIsRestarting(false);
|
||||
Alert.alert(
|
||||
"Unable to reconnect",
|
||||
"The server did not come back online. Please verify the daemon restarted."
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await delay(retryDelayMs);
|
||||
try {
|
||||
setIsSavingDaemon(true);
|
||||
const payload = {
|
||||
label: daemonForm.label.trim(),
|
||||
wsUrl: daemonForm.wsUrl.trim(),
|
||||
autoConnect: daemonForm.autoConnect,
|
||||
};
|
||||
if (daemonForm.id) {
|
||||
await updateDaemon(daemonForm.id, payload);
|
||||
setActiveDaemonId(daemonForm.id);
|
||||
} else {
|
||||
const created = await addDaemon(payload);
|
||||
setActiveDaemonId(created.id);
|
||||
}
|
||||
handleCloseDaemonForm();
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to save daemon", error);
|
||||
Alert.alert("Error", "Unable to save daemon");
|
||||
} finally {
|
||||
setIsSavingDaemon(false);
|
||||
}
|
||||
}, [settings.serverUrl, testServerConnection, waitForCondition]);
|
||||
}, [daemonForm, addDaemon, updateDaemon, handleCloseDaemonForm, setActiveDaemonId]);
|
||||
|
||||
const handleRemoveDaemon = useCallback(
|
||||
(profile: DaemonProfile) => {
|
||||
Alert.alert(
|
||||
"Remove Daemon",
|
||||
`Remove ${profile.label}?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Remove",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await removeDaemon(profile.id);
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to remove daemon", error);
|
||||
Alert.alert("Error", "Unable to remove daemon");
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
},
|
||||
[removeDaemon]
|
||||
);
|
||||
|
||||
const handleSetDefaultDaemon = useCallback(
|
||||
(profile: DaemonProfile) => {
|
||||
void setDefaultDaemon(profile.id);
|
||||
},
|
||||
[setDefaultDaemon]
|
||||
);
|
||||
|
||||
const handleSetActiveDaemon = useCallback(
|
||||
(profile: DaemonProfile) => {
|
||||
setActiveDaemonId(profile.id);
|
||||
},
|
||||
[setActiveDaemonId]
|
||||
);
|
||||
|
||||
const updateDaemonTestState = useCallback((daemonId: string, state: { status: "idle" | "testing" | "success" | "error"; message?: string }) => {
|
||||
setDaemonTestStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(daemonId, state);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTestDaemonConnection = useCallback(
|
||||
async (profile: DaemonProfile) => {
|
||||
const url = profile.wsUrl;
|
||||
if (!validateServerUrl(url)) {
|
||||
Alert.alert("Invalid URL", "Daemon URL must be ws:// or wss://");
|
||||
return;
|
||||
}
|
||||
updateDaemonTestState(profile.id, { status: "testing" });
|
||||
updateConnectionStatus(profile.id, "connecting");
|
||||
try {
|
||||
await testServerConnection(url, 4000);
|
||||
updateDaemonTestState(profile.id, { status: "success", message: "Reachable" });
|
||||
updateConnectionStatus(profile.id, "online", { lastOnlineAt: new Date().toISOString() });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Connection failed";
|
||||
updateDaemonTestState(profile.id, { status: "error", message });
|
||||
updateConnectionStatus(profile.id, "offline", { lastError: message });
|
||||
}
|
||||
},
|
||||
[testServerConnection, updateConnectionStatus, updateDaemonTestState]
|
||||
);
|
||||
|
||||
// Update local state when settings load
|
||||
useEffect(() => {
|
||||
setServerUrl(settings.serverUrl);
|
||||
setUseSpeaker(settings.useSpeaker);
|
||||
setKeepScreenOn(settings.keepScreenOn);
|
||||
setTheme(settings.theme);
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
setServerUrl(activeDaemon?.wsUrl ?? "");
|
||||
}, [activeDaemon?.wsUrl]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
const changed =
|
||||
serverUrl !== settings.serverUrl ||
|
||||
serverUrl !== baselineServerUrl ||
|
||||
useSpeaker !== settings.useSpeaker ||
|
||||
keepScreenOn !== settings.keepScreenOn ||
|
||||
theme !== settings.theme;
|
||||
setHasChanges(changed);
|
||||
}, [serverUrl, useSpeaker, keepScreenOn, theme, settings]);
|
||||
}, [serverUrl, baselineServerUrl, useSpeaker, keepScreenOn, theme, settings]);
|
||||
|
||||
function validateServerUrl(url: string): boolean {
|
||||
try {
|
||||
@@ -411,6 +589,15 @@ export default function SettingsScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
function deriveDaemonLabel(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname || "Daemon";
|
||||
} catch {
|
||||
return "Daemon";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// Validate server URL
|
||||
if (!validateServerUrl(serverUrl)) {
|
||||
@@ -423,13 +610,28 @@ export default function SettingsScreen() {
|
||||
}
|
||||
|
||||
try {
|
||||
const trimmedUrl = serverUrl.trim();
|
||||
|
||||
await updateSettings({
|
||||
serverUrl,
|
||||
useSpeaker,
|
||||
keepScreenOn,
|
||||
theme,
|
||||
});
|
||||
|
||||
if (activeDaemon) {
|
||||
await updateDaemon(activeDaemon.id, {
|
||||
wsUrl: trimmedUrl,
|
||||
label: activeDaemon.label || deriveDaemonLabel(trimmedUrl),
|
||||
});
|
||||
} else {
|
||||
await addDaemon({
|
||||
label: deriveDaemonLabel(trimmedUrl),
|
||||
wsUrl: trimmedUrl,
|
||||
autoConnect: true,
|
||||
isDefault: true,
|
||||
});
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
"Settings Saved",
|
||||
"Your settings have been saved successfully.",
|
||||
@@ -478,58 +680,16 @@ export default function SettingsScreen() {
|
||||
const restartConfirmationMessage =
|
||||
"This will immediately stop the Voice Dev backend process. The app will disconnect until it restarts.";
|
||||
|
||||
const beginServerRestart = useCallback(() => {
|
||||
if (!wsIsConnectedRef.current) {
|
||||
Alert.alert(
|
||||
"Not Connected",
|
||||
"Connect to the server before attempting a restart."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRestarting(true);
|
||||
try {
|
||||
restartServer("settings_screen_restart");
|
||||
} catch (error) {
|
||||
setIsRestarting(false);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Failed to send restart request. Please ensure you are connected to the server."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void waitForServerRestart();
|
||||
}, [restartServer, waitForServerRestart]);
|
||||
|
||||
function handleRestartServer() {
|
||||
if (Platform.OS === "web") {
|
||||
const hasBrowserConfirm =
|
||||
typeof globalThis !== "undefined" &&
|
||||
typeof (globalThis as any).confirm === "function";
|
||||
|
||||
const confirmed = hasBrowserConfirm
|
||||
? (globalThis as any).confirm(restartConfirmationMessage)
|
||||
: true;
|
||||
|
||||
if (confirmed) {
|
||||
beginServerRestart();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert("Restart Server", restartConfirmationMessage, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Restart",
|
||||
style: "destructive",
|
||||
onPress: beginServerRestart,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
async function handleTestConnection() {
|
||||
if (isServerConfigLocked) {
|
||||
Alert.alert(
|
||||
"Session unavailable",
|
||||
`${activeDaemon?.label ?? "This daemon"} is not connected. Connect to it before testing the URL.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateServerUrl(serverUrl)) {
|
||||
Alert.alert(
|
||||
"Invalid URL",
|
||||
@@ -579,10 +739,11 @@ export default function SettingsScreen() {
|
||||
{/* Server Configuration */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Server Configuration</Text>
|
||||
<Text style={styles.helperText}>{serverDescriptionText}</Text>
|
||||
|
||||
<Text style={styles.label}>WebSocket URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
style={[styles.input, isServerConfigLocked && styles.inputDisabled]}
|
||||
placeholder="wss://example.com/ws"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
value={serverUrl}
|
||||
@@ -593,9 +754,11 @@ export default function SettingsScreen() {
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!isServerConfigLocked}
|
||||
selectTextOnFocus={!isServerConfigLocked}
|
||||
/>
|
||||
<Text style={styles.helperText}>
|
||||
Must be a valid WebSocket URL (ws:// or wss://)
|
||||
{serverHelperText}
|
||||
</Text>
|
||||
|
||||
{/* Test Connection Button */}
|
||||
@@ -604,7 +767,7 @@ export default function SettingsScreen() {
|
||||
disabled={isTesting || !validateServerUrl(serverUrl)}
|
||||
style={[
|
||||
styles.testButton,
|
||||
(isTesting || !validateServerUrl(serverUrl)) &&
|
||||
(isTesting || !validateServerUrl(serverUrl) || isServerConfigLocked) &&
|
||||
styles.testButtonDisabled,
|
||||
]}
|
||||
>
|
||||
@@ -640,6 +803,101 @@ export default function SettingsScreen() {
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Daemon Management */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Daemons</Text>
|
||||
|
||||
{daemons.length === 0 ? (
|
||||
<View style={styles.settingCard}>
|
||||
<Text style={styles.settingDescription}>No daemons configured.</Text>
|
||||
</View>
|
||||
) : (
|
||||
daemons.map((daemon) => {
|
||||
const connection = connectionStates.get(daemon.id);
|
||||
const connectionStatus = connection?.status ?? "idle";
|
||||
const lastConnectionError = connection?.lastError ?? null;
|
||||
const testState = daemonTestStates.get(daemon.id);
|
||||
return (
|
||||
<DaemonCard
|
||||
key={daemon.id}
|
||||
daemon={daemon}
|
||||
isActive={daemon.id === activeDaemonId}
|
||||
connectionStatus={connectionStatus}
|
||||
lastError={lastConnectionError}
|
||||
testState={testState}
|
||||
onSetActive={handleSetActiveDaemon}
|
||||
onSetDefault={handleSetDefaultDaemon}
|
||||
onTestConnection={handleTestDaemonConnection}
|
||||
onEdit={handleOpenDaemonForm}
|
||||
onRemove={handleRemoveDaemon}
|
||||
restartConfirmationMessage={restartConfirmationMessage}
|
||||
waitForCondition={waitForCondition}
|
||||
testServerConnection={testServerConnection}
|
||||
isScreenMountedRef={isMountedRef}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{isDaemonFormVisible ? (
|
||||
<View style={styles.settingCard}>
|
||||
<Text style={styles.settingTitle}>{daemonForm.id ? "Edit Daemon" : "Add Daemon"}</Text>
|
||||
<Text style={styles.label}>Label</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={daemonForm.label}
|
||||
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, label: text }))}
|
||||
placeholder="My Server"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>WebSocket URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={daemonForm.wsUrl}
|
||||
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, wsUrl: text }))}
|
||||
placeholder="wss://example.com/ws"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingContent}>
|
||||
<Text style={styles.settingTitle}>Auto-connect</Text>
|
||||
<Text style={styles.settingDescription}>Connect automatically when Paseo launches.</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={daemonForm.autoConnect}
|
||||
onValueChange={(value) => setDaemonForm((prev) => ({ ...prev, autoConnect: value }))}
|
||||
trackColor={{ false: defaultTheme.colors.palette.gray[700], true: defaultTheme.colors.palette.blue[500] }}
|
||||
thumbColor={daemonForm.autoConnect ? defaultTheme.colors.palette.blue[400] : defaultTheme.colors.palette.gray[300]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.daemonFormActionsRow}>
|
||||
<Pressable style={[styles.daemonActionButton, styles.daemonActionDestructive]} onPress={handleCloseDaemonForm}>
|
||||
<Text style={[styles.daemonActionText, styles.daemonActionDestructiveText]}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.daemonActionButton, styles.daemonActionPrimary, isSavingDaemon && styles.daemonActionDisabled]}
|
||||
onPress={handleSubmitDaemonForm}
|
||||
disabled={isSavingDaemon}
|
||||
>
|
||||
<Text style={[styles.daemonActionText, styles.daemonActionPrimaryText]}>
|
||||
{isSavingDaemon ? "Saving..." : daemonForm.id ? "Save Daemon" : "Add Daemon"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable style={styles.addButton} onPress={() => handleOpenDaemonForm()}>
|
||||
<Text style={styles.addButtonText}>Add Daemon</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Audio Settings */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Audio</Text>
|
||||
@@ -728,26 +986,6 @@ export default function SettingsScreen() {
|
||||
<Pressable style={styles.resetButton} onPress={handleReset}>
|
||||
<Text style={styles.resetButtonText}>Reset to Defaults</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
styles.restartButton,
|
||||
isRestarting && styles.restartButtonDisabled,
|
||||
]}
|
||||
onPress={handleRestartServer}
|
||||
disabled={isRestarting}
|
||||
>
|
||||
{isRestarting && (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={defaultTheme.colors.destructiveForeground}
|
||||
style={{ marginRight: defaultTheme.spacing[2] }}
|
||||
/>
|
||||
)}
|
||||
<Text style={styles.restartButtonText}>
|
||||
{isRestarting ? "Restarting..." : "Restart Server"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* App Info */}
|
||||
@@ -760,3 +998,243 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface DaemonCardProps {
|
||||
daemon: DaemonProfile;
|
||||
isActive: boolean;
|
||||
connectionStatus: ConnectionStatus;
|
||||
lastError: string | null;
|
||||
testState?: DaemonTestState;
|
||||
onSetActive: (daemon: DaemonProfile) => void;
|
||||
onSetDefault: (daemon: DaemonProfile) => void;
|
||||
onTestConnection: (daemon: DaemonProfile) => void;
|
||||
onEdit: (daemon: DaemonProfile) => void;
|
||||
onRemove: (daemon: DaemonProfile) => void;
|
||||
restartConfirmationMessage: string;
|
||||
waitForCondition: (predicate: () => boolean, timeoutMs: number, intervalMs?: number) => Promise<boolean>;
|
||||
testServerConnection: (url: string, timeoutMs?: number) => Promise<void>;
|
||||
isScreenMountedRef: MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
function DaemonCard({
|
||||
daemon,
|
||||
isActive,
|
||||
connectionStatus,
|
||||
lastError,
|
||||
testState,
|
||||
onSetActive,
|
||||
onSetDefault,
|
||||
onTestConnection,
|
||||
onEdit,
|
||||
onRemove,
|
||||
restartConfirmationMessage,
|
||||
waitForCondition,
|
||||
testServerConnection,
|
||||
isScreenMountedRef,
|
||||
}: DaemonCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const statusLabel = formatConnectionStatus(connectionStatus);
|
||||
const statusTone = getConnectionStatusTone(connectionStatus);
|
||||
const statusColor =
|
||||
statusTone === "success"
|
||||
? theme.colors.palette.green[400]
|
||||
: statusTone === "warning"
|
||||
? theme.colors.palette.amber[500]
|
||||
: statusTone === "error"
|
||||
? theme.colors.destructive
|
||||
: theme.colors.mutedForeground;
|
||||
const badgeText = isActive ? `Active · ${statusLabel}` : statusLabel;
|
||||
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||
const daemonSession = useSessionForServer(daemon.id);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const wsIsConnectedRef = useRef(daemonSession?.ws.isConnected ?? false);
|
||||
const isTesting = testState?.status === "testing";
|
||||
const sessionIsConnected = daemonSession?.ws.isConnected ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
wsIsConnectedRef.current = sessionIsConnected;
|
||||
}, [sessionIsConnected]);
|
||||
|
||||
const waitForDaemonRestart = useCallback(async () => {
|
||||
const maxAttempts = 12;
|
||||
const retryDelayMs = 2500;
|
||||
const disconnectTimeoutMs = 7000;
|
||||
const reconnectTimeoutMs = 10000;
|
||||
|
||||
if (wsIsConnectedRef.current) {
|
||||
await waitForCondition(() => !wsIsConnectedRef.current, disconnectTimeoutMs);
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await testServerConnection(daemon.wsUrl);
|
||||
const reconnected = await waitForCondition(() => wsIsConnectedRef.current, reconnectTimeoutMs);
|
||||
|
||||
if (isScreenMountedRef.current) {
|
||||
setIsRestarting(false);
|
||||
if (!reconnected) {
|
||||
Alert.alert(
|
||||
"Server reachable",
|
||||
`${daemon.label} came back online but Paseo has not reconnected yet.`
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Settings] Restart poll attempt ${attempt}/${maxAttempts} failed for ${daemon.label}`,
|
||||
error
|
||||
);
|
||||
if (attempt === maxAttempts) {
|
||||
if (isScreenMountedRef.current) {
|
||||
setIsRestarting(false);
|
||||
Alert.alert(
|
||||
"Unable to reconnect",
|
||||
`${daemon.label} did not come back online. Please verify it restarted.`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await delay(retryDelayMs);
|
||||
}
|
||||
}
|
||||
}, [daemon.label, daemon.wsUrl, isScreenMountedRef, testServerConnection, waitForCondition]);
|
||||
|
||||
const beginServerRestart = useCallback(() => {
|
||||
if (!daemonSession) {
|
||||
Alert.alert(
|
||||
"Daemon unavailable",
|
||||
`${daemon.label} is not connected. Select it to connect before restarting.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wsIsConnectedRef.current) {
|
||||
Alert.alert("Not Connected", "Connect to the server before attempting a restart.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRestarting(true);
|
||||
try {
|
||||
daemonSession.restartServer(`settings_daemon_restart_${daemon.id}`);
|
||||
} catch (error) {
|
||||
console.error(`[Settings] Failed to restart daemon ${daemon.label}`, error);
|
||||
setIsRestarting(false);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Failed to send restart request. Please ensure you are connected to the server."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void waitForDaemonRestart();
|
||||
}, [daemon.id, daemon.label, daemonSession, waitForDaemonRestart]);
|
||||
|
||||
const handleRestartPress = useCallback(() => {
|
||||
if (!daemonSession) {
|
||||
Alert.alert(
|
||||
"Daemon unavailable",
|
||||
`${daemon.label} is not connected. Select it to connect before restarting.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
const hasBrowserConfirm =
|
||||
typeof globalThis !== "undefined" &&
|
||||
typeof (globalThis as any).confirm === "function";
|
||||
|
||||
const confirmed = hasBrowserConfirm
|
||||
? (globalThis as any).confirm(`Restart ${daemon.label}? ${restartConfirmationMessage}`)
|
||||
: true;
|
||||
|
||||
if (confirmed) {
|
||||
beginServerRestart();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(`Restart ${daemon.label}`, restartConfirmationMessage, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Restart",
|
||||
style: "destructive",
|
||||
onPress: beginServerRestart,
|
||||
},
|
||||
]);
|
||||
}, [beginServerRestart, daemon.label, daemonSession, restartConfirmationMessage]);
|
||||
|
||||
return (
|
||||
<View style={[styles.settingCard, styles.daemonCard, isActive && styles.daemonCardActive]}>
|
||||
<View style={styles.daemonHeaderRow}>
|
||||
<Text style={styles.settingTitle}>{daemon.label}</Text>
|
||||
<View style={styles.connectionStatusBadge}>
|
||||
<View style={[styles.connectionStatusDot, { backgroundColor: statusColor }]} />
|
||||
<Text style={[styles.connectionStatusText, { color: statusColor }]}>{badgeText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.settingDescription}>{daemon.wsUrl}</Text>
|
||||
{connectionError ? <Text style={styles.connectionErrorText}>{connectionError}</Text> : null}
|
||||
{testState && testState.status !== "idle" ? (
|
||||
<Text
|
||||
style={
|
||||
testState.status === "success"
|
||||
? styles.testResultSuccessText
|
||||
: testState.status === "error"
|
||||
? styles.testResultErrorText
|
||||
: styles.testResultInfoText
|
||||
}
|
||||
>
|
||||
{testState.message ?? (testState.status === "success" ? "Reachable" : "Testing...")}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.daemonActionsRow}>
|
||||
{!isActive ? (
|
||||
<Pressable style={styles.daemonActionButton} onPress={() => onSetActive(daemon)}>
|
||||
<Text style={styles.daemonActionText}>Set Active</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable style={styles.daemonActionButton} onPress={() => onSetDefault(daemon)}>
|
||||
<Text style={styles.daemonActionText}>{daemon.isDefault ? "Default" : "Make Default"}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.daemonActionButton,
|
||||
styles.daemonActionPrimary,
|
||||
isTesting && styles.daemonActionDisabled,
|
||||
]}
|
||||
onPress={() => onTestConnection(daemon)}
|
||||
disabled={isTesting}
|
||||
>
|
||||
<Text style={[styles.daemonActionText, styles.daemonActionPrimaryText]}>
|
||||
{isTesting ? "Testing..." : "Test"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.daemonActionButton,
|
||||
styles.daemonActionDestructive,
|
||||
isRestarting && styles.daemonActionDisabled,
|
||||
]}
|
||||
onPress={handleRestartPress}
|
||||
disabled={isRestarting}
|
||||
>
|
||||
{isRestarting ? (
|
||||
<ActivityIndicator size="small" color={defaultTheme.colors.destructive} />
|
||||
) : (
|
||||
<Text style={[styles.daemonActionText, styles.daemonActionDestructiveText]}>Restart</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable style={styles.daemonActionButton} onPress={() => onEdit(daemon)}>
|
||||
<Text style={styles.daemonActionText}>Edit</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.daemonActionButton, styles.daemonActionDestructive]}
|
||||
onPress={() => onRemove(daemon)}
|
||||
>
|
||||
<Text style={[styles.daemonActionText, styles.daemonActionDestructiveText]}>Remove</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDaemonRegistry, type DaemonProfile } from "./daemon-registry-context";
|
||||
import type { SessionContextValue } from "./session-context";
|
||||
|
||||
export interface SessionDirectoryEntry {
|
||||
getSnapshot: () => SessionContextValue | null;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
export type ConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||
|
||||
export interface DaemonConnectionRecord {
|
||||
daemon: DaemonProfile;
|
||||
status: ConnectionStatus;
|
||||
lastOnlineAt?: string;
|
||||
lastError?: string | null;
|
||||
}
|
||||
|
||||
interface DaemonConnectionsContextValue {
|
||||
activeDaemonId: string | null;
|
||||
activeDaemon: DaemonProfile | null;
|
||||
connectionStates: Map<string, DaemonConnectionRecord>;
|
||||
isLoading: boolean;
|
||||
setActiveDaemonId: (daemonId: string) => void;
|
||||
updateConnectionStatus: (
|
||||
daemonId: string,
|
||||
status: ConnectionStatus,
|
||||
extras?: { lastError?: string | null; lastOnlineAt?: string }
|
||||
) => void;
|
||||
sessionAccessors: Map<string, SessionDirectoryEntry>;
|
||||
registerSessionAccessor: (daemonId: string, entry: SessionDirectoryEntry) => void;
|
||||
unregisterSessionAccessor: (daemonId: string) => void;
|
||||
subscribeToSessionDirectory: (listener: () => void) => () => void;
|
||||
notifySessionDirectoryChange: () => void;
|
||||
}
|
||||
|
||||
const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(null);
|
||||
|
||||
const ACTIVE_DAEMON_STORAGE_KEY = "@paseo:active-daemon-id";
|
||||
const ACTIVE_DAEMON_QUERY_KEY = ["active-daemon-id"];
|
||||
|
||||
export function useDaemonConnections(): DaemonConnectionsContextValue {
|
||||
const ctx = useContext(DaemonConnectionsContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useDaemonConnections must be used within DaemonConnectionsProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function DaemonConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const [activeDaemonId, setActiveDaemonIdState] = useState<string | null>(null);
|
||||
const [connectionStates, setConnectionStates] = useState<Map<string, DaemonConnectionRecord>>(new Map());
|
||||
const [sessionAccessors, setSessionAccessors] = useState<Map<string, SessionDirectoryEntry>>(new Map());
|
||||
const queryClient = useQueryClient();
|
||||
const activeDaemonPreference = useQuery({
|
||||
queryKey: ACTIVE_DAEMON_QUERY_KEY,
|
||||
queryFn: loadActiveDaemonPreference,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
const sessionDirectoryListenersRef = useRef<Set<() => void>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (activeDaemonPreference.isPending) {
|
||||
return;
|
||||
}
|
||||
setActiveDaemonIdState(activeDaemonPreference.data ?? null);
|
||||
}, [activeDaemonPreference.data, activeDaemonPreference.isPending]);
|
||||
|
||||
// Ensure connection states stay in sync with registry entries
|
||||
useEffect(() => {
|
||||
setConnectionStates((prev) => {
|
||||
const next = new Map<string, DaemonConnectionRecord>();
|
||||
for (const daemon of daemons) {
|
||||
const existing = prev.get(daemon.id);
|
||||
next.set(daemon.id, {
|
||||
daemon,
|
||||
status: existing?.status ?? "idle",
|
||||
lastOnlineAt: existing?.lastOnlineAt,
|
||||
lastError: existing?.lastError,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [daemons]);
|
||||
|
||||
useEffect(() => {
|
||||
setSessionAccessors((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const key of Array.from(next.keys())) {
|
||||
if (!daemons.some((daemon) => daemon.id === key)) {
|
||||
next.delete(key);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [daemons]);
|
||||
|
||||
// Keep active daemon aligned with registry default
|
||||
const persistActiveDaemonId = useCallback(async (daemonId: string | null) => {
|
||||
queryClient.setQueryData<string | null>(ACTIVE_DAEMON_QUERY_KEY, daemonId);
|
||||
try {
|
||||
if (daemonId) {
|
||||
await AsyncStorage.setItem(ACTIVE_DAEMON_STORAGE_KEY, daemonId);
|
||||
} else {
|
||||
await AsyncStorage.removeItem(ACTIVE_DAEMON_STORAGE_KEY);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[DaemonConnections] Failed to persist active daemon", error);
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
const setActiveDaemonId = useCallback(
|
||||
(daemonId: string) => {
|
||||
setActiveDaemonIdState(daemonId);
|
||||
void persistActiveDaemonId(daemonId);
|
||||
},
|
||||
[persistActiveDaemonId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeDaemonPreference.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (daemons.length === 0) {
|
||||
setActiveDaemonIdState(null);
|
||||
void persistActiveDaemonId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeDaemonId && daemons.some((daemon) => daemon.id === activeDaemonId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallback = daemons.find((daemon) => daemon.isDefault) ?? daemons[0];
|
||||
setActiveDaemonId(fallback.id);
|
||||
}, [daemons, activeDaemonId, activeDaemonPreference.isPending, persistActiveDaemonId, setActiveDaemonId]);
|
||||
|
||||
const activeDaemon = useMemo(() => {
|
||||
if (!activeDaemonId) {
|
||||
return null;
|
||||
}
|
||||
return daemons.find((daemon) => daemon.id === activeDaemonId) ?? null;
|
||||
}, [activeDaemonId, daemons]);
|
||||
|
||||
const updateConnectionStatus = useCallback(
|
||||
(
|
||||
daemonId: string,
|
||||
status: ConnectionStatus,
|
||||
extras?: { lastError?: string | null; lastOnlineAt?: string }
|
||||
) => {
|
||||
setConnectionStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(daemonId);
|
||||
if (!existing) {
|
||||
return prev;
|
||||
}
|
||||
const hasExplicitLastError = Boolean(extras && Object.prototype.hasOwnProperty.call(extras, "lastError"));
|
||||
next.set(daemonId, {
|
||||
...existing,
|
||||
status,
|
||||
lastError: hasExplicitLastError ? (extras!.lastError ?? null) : existing.lastError ?? null,
|
||||
lastOnlineAt: extras?.lastOnlineAt ?? existing.lastOnlineAt,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const registerSessionAccessor = useCallback((daemonId: string, entry: SessionDirectoryEntry) => {
|
||||
setSessionAccessors((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(daemonId, entry);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const unregisterSessionAccessor = useCallback((daemonId: string) => {
|
||||
setSessionAccessors((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(daemonId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const subscribeToSessionDirectory = useCallback((listener: () => void) => {
|
||||
sessionDirectoryListenersRef.current.add(listener);
|
||||
return () => {
|
||||
sessionDirectoryListenersRef.current.delete(listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const notifySessionDirectoryChange = useCallback(() => {
|
||||
const listeners = Array.from(sessionDirectoryListenersRef.current);
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
console.error("[DaemonConnections] Session directory listener failed", error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value: DaemonConnectionsContextValue = {
|
||||
activeDaemonId,
|
||||
activeDaemon,
|
||||
connectionStates,
|
||||
isLoading: registryLoading || activeDaemonPreference.isPending,
|
||||
setActiveDaemonId,
|
||||
updateConnectionStatus,
|
||||
sessionAccessors,
|
||||
registerSessionAccessor,
|
||||
unregisterSessionAccessor,
|
||||
subscribeToSessionDirectory,
|
||||
notifySessionDirectoryChange,
|
||||
};
|
||||
|
||||
return (
|
||||
<DaemonConnectionsContext.Provider value={value}>
|
||||
{children}
|
||||
</DaemonConnectionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadActiveDaemonPreference(): Promise<string | null> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(ACTIVE_DAEMON_STORAGE_KEY);
|
||||
return stored ?? null;
|
||||
} catch (error) {
|
||||
console.error("[DaemonConnections] Failed to read active daemon preference", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
const FALLBACK_DAEMON_URL = "ws://localhost:6767/ws";
|
||||
const DAEMON_REGISTRY_QUERY_KEY = ["daemon-registry"];
|
||||
|
||||
export type DaemonProfile = {
|
||||
id: string;
|
||||
label: string;
|
||||
wsUrl: string;
|
||||
restUrl?: string | null;
|
||||
autoConnect: boolean;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type CreateDaemonInput = {
|
||||
label: string;
|
||||
wsUrl: string;
|
||||
restUrl?: string | null;
|
||||
autoConnect?: boolean;
|
||||
isDefault?: boolean;
|
||||
};
|
||||
|
||||
type UpdateDaemonInput = Partial<Omit<DaemonProfile, "id" | "createdAt">>;
|
||||
|
||||
interface DaemonRegistryContextValue {
|
||||
daemons: DaemonProfile[];
|
||||
isLoading: boolean;
|
||||
error: unknown | null;
|
||||
defaultDaemon: DaemonProfile | null;
|
||||
addDaemon: (input: CreateDaemonInput) => Promise<DaemonProfile>;
|
||||
updateDaemon: (id: string, updates: UpdateDaemonInput) => Promise<void>;
|
||||
removeDaemon: (id: string) => Promise<void>;
|
||||
setDefaultDaemon: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null);
|
||||
|
||||
export function useDaemonRegistry(): DaemonRegistryContextValue {
|
||||
const ctx = useContext(DaemonRegistryContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useDaemonRegistry must be used within DaemonRegistryProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: daemons = [], isPending, error } = useQuery({
|
||||
queryKey: DAEMON_REGISTRY_QUERY_KEY,
|
||||
queryFn: loadDaemonRegistryFromStorage,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const persist = useCallback(
|
||||
async (profiles: DaemonProfile[]) => {
|
||||
queryClient.setQueryData<DaemonProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles);
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles));
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const readDaemons = useCallback(() => {
|
||||
return queryClient.getQueryData<DaemonProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons;
|
||||
}, [queryClient, daemons]);
|
||||
|
||||
const addDaemon = useCallback(async (input: CreateDaemonInput) => {
|
||||
const existing = readDaemons();
|
||||
const timestamp = new Date().toISOString();
|
||||
const profile: DaemonProfile = {
|
||||
id: generateDaemonId(),
|
||||
label: input.label.trim() || deriveLabelFromUrl(input.wsUrl),
|
||||
wsUrl: input.wsUrl,
|
||||
restUrl: input.restUrl ?? null,
|
||||
autoConnect: input.autoConnect ?? true,
|
||||
isDefault: input.isDefault ?? existing.length === 0,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
const next = normalizeDefaults([...existing, profile]);
|
||||
await persist(next);
|
||||
return profile;
|
||||
}, [persist, readDaemons]);
|
||||
|
||||
const updateDaemon = useCallback(async (id: string, updates: UpdateDaemonInput) => {
|
||||
const next = readDaemons().map((daemon) =>
|
||||
daemon.id === id
|
||||
? {
|
||||
...daemon,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: daemon
|
||||
);
|
||||
await persist(next);
|
||||
}, [persist, readDaemons]);
|
||||
|
||||
const removeDaemon = useCallback(async (id: string) => {
|
||||
const remaining = readDaemons().filter((daemon) => daemon.id !== id);
|
||||
const normalized = normalizeDefaults(remaining);
|
||||
await persist(normalized.length > 0 ? normalized : [createProfile("Local Daemon", FALLBACK_DAEMON_URL, true)]);
|
||||
}, [persist, readDaemons]);
|
||||
|
||||
const setDefaultDaemon = useCallback(async (id: string) => {
|
||||
const next = readDaemons().map((daemon) => ({
|
||||
...daemon,
|
||||
isDefault: daemon.id === id,
|
||||
updatedAt: daemon.id === id ? new Date().toISOString() : daemon.updatedAt,
|
||||
}));
|
||||
await persist(next);
|
||||
}, [persist, readDaemons]);
|
||||
|
||||
const defaultDaemon = useMemo(() => {
|
||||
if (daemons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const explicit = daemons.find((daemon) => daemon.isDefault);
|
||||
return explicit ?? daemons[0];
|
||||
}, [daemons]);
|
||||
|
||||
const value: DaemonRegistryContextValue = {
|
||||
daemons,
|
||||
isLoading: isPending,
|
||||
error: error ?? null,
|
||||
defaultDaemon,
|
||||
addDaemon,
|
||||
updateDaemon,
|
||||
removeDaemon,
|
||||
setDefaultDaemon,
|
||||
};
|
||||
|
||||
return (
|
||||
<DaemonRegistryContext.Provider value={value}>
|
||||
{children}
|
||||
</DaemonRegistryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function generateDaemonId(): string {
|
||||
const random = Math.random().toString(36).slice(2, 8);
|
||||
return `daemon_${Date.now().toString(36)}_${random}`;
|
||||
}
|
||||
|
||||
function deriveLabelFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname || "Unnamed Daemon";
|
||||
} catch {
|
||||
return "Unnamed Daemon";
|
||||
}
|
||||
}
|
||||
|
||||
function createProfile(label: string, wsUrl: string, isDefault: boolean): DaemonProfile {
|
||||
const timestamp = new Date().toISOString();
|
||||
return {
|
||||
id: generateDaemonId(),
|
||||
label,
|
||||
wsUrl,
|
||||
restUrl: null,
|
||||
autoConnect: true,
|
||||
isDefault,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
metadata: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDefaults(profiles: DaemonProfile[]): DaemonProfile[] {
|
||||
if (profiles.length === 0) {
|
||||
return profiles;
|
||||
}
|
||||
const hasDefault = profiles.some((daemon) => daemon.isDefault);
|
||||
if (hasDefault) {
|
||||
return profiles;
|
||||
}
|
||||
const [first, ...rest] = profiles;
|
||||
return [
|
||||
{ ...first, isDefault: true },
|
||||
...rest,
|
||||
];
|
||||
}
|
||||
|
||||
async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as DaemonProfile[];
|
||||
}
|
||||
|
||||
const legacy = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
|
||||
if (legacy) {
|
||||
const legacyParsed = JSON.parse(legacy) as Record<string, unknown>;
|
||||
const legacyUrl = typeof legacyParsed.serverUrl === "string" ? legacyParsed.serverUrl : null;
|
||||
if (legacyUrl) {
|
||||
const migrated = [createProfile("Primary Daemon", legacyUrl, true)];
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated));
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = [createProfile("Local Daemon", FALLBACK_DAEMON_URL, true)];
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(fallback));
|
||||
return fallback;
|
||||
} catch (error) {
|
||||
console.error("[DaemonRegistry] Failed to load daemon registry", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createContext, useContext, useState, useRef, ReactNode, useCallback, useEffect } from "react";
|
||||
import { createContext, useContext, useState, useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useWebSocket, type UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||
import { useAudioPlayer } from "@/hooks/use-audio-player";
|
||||
import { reduceStreamUpdate, generateMessageId, hydrateStreamState, type StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
@@ -9,6 +11,7 @@ import type {
|
||||
AgentStreamEventPayload,
|
||||
WSInboundMessage,
|
||||
GitSetupOptions,
|
||||
SessionOutboundMessage,
|
||||
} from "@server/server/messages";
|
||||
import type {
|
||||
AgentLifecycleStatus,
|
||||
@@ -26,6 +29,7 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { ScrollView } from "react-native";
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { useDaemonConnections } from "./daemon-connections-context";
|
||||
|
||||
const derivePendingPermissionKey = (agentId: string, request: AgentPermissionRequest) => {
|
||||
const fallbackId =
|
||||
@@ -43,6 +47,16 @@ type DraftInput = {
|
||||
images: Array<{ uri: string; mimeType: string }>;
|
||||
};
|
||||
|
||||
type GitDiffResponseMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "git_diff_response" }
|
||||
>;
|
||||
|
||||
type FileExplorerResponseMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "file_explorer_response" }
|
||||
>;
|
||||
|
||||
export type MessageEntry =
|
||||
| {
|
||||
type: "user";
|
||||
@@ -91,6 +105,7 @@ type ProviderModelState = {
|
||||
};
|
||||
|
||||
export interface Agent {
|
||||
serverId: string;
|
||||
id: string;
|
||||
provider: AgentProvider;
|
||||
status: AgentLifecycleStatus;
|
||||
@@ -183,6 +198,48 @@ const pushHistory = (history: string[], path: string): string[] => {
|
||||
return [...normalizedHistory, path];
|
||||
};
|
||||
|
||||
const SESSION_SNAPSHOT_STORAGE_PREFIX = "@paseo:session-snapshot:";
|
||||
|
||||
type PersistedSessionSnapshot = {
|
||||
agents: AgentSnapshotPayload[];
|
||||
commands: Command[];
|
||||
savedAt: string;
|
||||
};
|
||||
|
||||
const getSessionSnapshotStorageKey = (serverId: string): string => {
|
||||
return `${SESSION_SNAPSHOT_STORAGE_PREFIX}${serverId}`;
|
||||
};
|
||||
|
||||
async function loadPersistedSessionSnapshot(serverId: string): Promise<PersistedSessionSnapshot | null> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(getSessionSnapshotStorageKey(serverId));
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as PersistedSessionSnapshot;
|
||||
if (!Array.isArray(parsed?.agents) || !Array.isArray(parsed?.commands)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.error(`[Session] Failed to load persisted snapshot for ${serverId}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistSessionSnapshot(serverId: string, snapshot: { agents: AgentSnapshotPayload[]; commands: Command[] }) {
|
||||
try {
|
||||
const payload: PersistedSessionSnapshot = {
|
||||
agents: snapshot.agents,
|
||||
commands: snapshot.commands,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
await AsyncStorage.setItem(getSessionSnapshotStorageKey(serverId), JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
console.error(`[Session] Failed to persist snapshot for ${serverId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
type PendingAgentLifecycleRequest = {
|
||||
kind: "initialize" | "refresh";
|
||||
params: {
|
||||
@@ -191,11 +248,12 @@ type PendingAgentLifecycleRequest = {
|
||||
};
|
||||
};
|
||||
|
||||
function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload): Agent {
|
||||
function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string): Agent {
|
||||
const createdAt = new Date(snapshot.createdAt);
|
||||
const updatedAt = new Date(snapshot.updatedAt);
|
||||
const lastUserMessageAt = snapshot.lastUserMessageAt ? new Date(snapshot.lastUserMessageAt) : null;
|
||||
return {
|
||||
serverId,
|
||||
id: snapshot.id,
|
||||
provider: snapshot.provider,
|
||||
status: snapshot.status as AgentLifecycleStatus,
|
||||
@@ -217,8 +275,25 @@ function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload): Agent {
|
||||
};
|
||||
}
|
||||
|
||||
function buildSessionStateFromSnapshots(serverId: string, snapshots: AgentSnapshotPayload[]) {
|
||||
const agents = new Map<string, Agent>();
|
||||
const pendingPermissions = new Map<string, PendingPermission>();
|
||||
|
||||
interface SessionContextValue {
|
||||
for (const snapshot of snapshots) {
|
||||
const agent = normalizeAgentSnapshot(snapshot, serverId);
|
||||
agents.set(agent.id, agent);
|
||||
for (const request of agent.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(agent.id, request);
|
||||
pendingPermissions.set(key, { key, agentId: agent.id, request });
|
||||
}
|
||||
}
|
||||
|
||||
return { agents, pendingPermissions };
|
||||
}
|
||||
|
||||
|
||||
export interface SessionContextValue {
|
||||
serverId: string;
|
||||
// WebSocket
|
||||
ws: UseWebSocketReturn;
|
||||
|
||||
@@ -275,7 +350,11 @@ interface SessionContextValue {
|
||||
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||
refreshAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||
cancelAgentRun: (agentId: string) => void;
|
||||
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
|
||||
sendAgentMessage: (
|
||||
agentId: string,
|
||||
message: string,
|
||||
images?: Array<{ uri: string; mimeType?: string }>
|
||||
) => Promise<void>;
|
||||
sendAgentAudio: (
|
||||
agentId: string,
|
||||
audioBlob: Blob,
|
||||
@@ -308,11 +387,46 @@ export function useSession() {
|
||||
interface SessionProviderProps {
|
||||
children: ReactNode;
|
||||
serverUrl: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
export function SessionProvider({ children, serverUrl, serverId }: SessionProviderProps) {
|
||||
const ws = useWebSocket(serverUrl);
|
||||
const wsIsConnected = ws.isConnected;
|
||||
const {
|
||||
updateConnectionStatus,
|
||||
registerSessionAccessor,
|
||||
unregisterSessionAccessor,
|
||||
notifySessionDirectoryChange,
|
||||
} = useDaemonConnections();
|
||||
|
||||
useEffect(() => {
|
||||
if (ws.isConnected) {
|
||||
updateConnectionStatus(serverId, "online", {
|
||||
lastOnlineAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws.isConnecting) {
|
||||
updateConnectionStatus(serverId, "connecting");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws.lastError) {
|
||||
updateConnectionStatus(serverId, "error", { lastError: ws.lastError });
|
||||
return;
|
||||
}
|
||||
|
||||
updateConnectionStatus(serverId, "offline");
|
||||
}, [serverId, updateConnectionStatus, ws.isConnected, ws.isConnecting, ws.lastError]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
updateConnectionStatus(serverId, "offline", { lastError: null });
|
||||
};
|
||||
}, [serverId, updateConnectionStatus]);
|
||||
|
||||
// State for voice detection flags (will be set by RealtimeContext)
|
||||
const isDetectingRef = useRef(false);
|
||||
@@ -353,6 +467,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
||||
const providerModelRequestIdsRef = useRef<Map<AgentProvider, string>>(new Map());
|
||||
const pendingAgentLifecycleRequestsRef = useRef<PendingAgentLifecycleRequest[]>([]);
|
||||
const hasHydratedSnapshotRef = useRef(false);
|
||||
|
||||
// Buffer for streaming audio chunks
|
||||
interface AudioChunk {
|
||||
@@ -368,6 +483,54 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
setFocusedAgentOverride(agentId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const hydrateFromSnapshot = async () => {
|
||||
if (hasHydratedSnapshotRef.current) {
|
||||
return;
|
||||
}
|
||||
hasHydratedSnapshotRef.current = true;
|
||||
const snapshot = await loadPersistedSessionSnapshot(serverId);
|
||||
if (!snapshot || !isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { agents: hydratedAgents, pendingPermissions: hydratedPermissions } = buildSessionStateFromSnapshots(
|
||||
serverId,
|
||||
snapshot.agents
|
||||
);
|
||||
|
||||
let applied = false;
|
||||
setAgents((prev) => {
|
||||
if (prev.size > 0) {
|
||||
return prev;
|
||||
}
|
||||
applied = true;
|
||||
return hydratedAgents;
|
||||
});
|
||||
|
||||
if (!applied) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingPermissions(hydratedPermissions);
|
||||
const commandEntries = snapshot.commands ?? [];
|
||||
setCommands((prev) => {
|
||||
if (prev.size > 0) {
|
||||
return prev;
|
||||
}
|
||||
return new Map(commandEntries.map((command) => [command.id, command]));
|
||||
});
|
||||
};
|
||||
|
||||
void hydrateFromSnapshot();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusedAgentOverride) {
|
||||
if (orchestratorFocusedAgentId !== null) {
|
||||
@@ -408,6 +571,91 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
[]
|
||||
);
|
||||
|
||||
const gitDiffRequest = useDaemonRequest<
|
||||
{ agentId: string },
|
||||
{ agentId: string; diff: string },
|
||||
GitDiffResponseMessage
|
||||
>({
|
||||
ws,
|
||||
responseType: "git_diff_response",
|
||||
buildRequest: ({ params }) => ({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "git_diff_request",
|
||||
agentId: params?.agentId ?? "",
|
||||
},
|
||||
}),
|
||||
matchResponse: (message, context) =>
|
||||
message.payload.agentId === context.params?.agentId,
|
||||
getRequestKey: (params) => params?.agentId ?? "default",
|
||||
selectData: (message) => ({
|
||||
agentId: message.payload.agentId,
|
||||
diff: message.payload.diff ?? "",
|
||||
}),
|
||||
extractError: (message) =>
|
||||
message.payload.error ? new Error(message.payload.error) : null,
|
||||
timeoutMs: 10000,
|
||||
keepPreviousData: false,
|
||||
});
|
||||
|
||||
const directoryListingRequest = useDaemonRequest<
|
||||
{ agentId: string; path: string },
|
||||
FileExplorerResponseMessage["payload"],
|
||||
FileExplorerResponseMessage
|
||||
>({
|
||||
ws,
|
||||
responseType: "file_explorer_response",
|
||||
buildRequest: ({ params }) => ({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "file_explorer_request",
|
||||
agentId: params?.agentId ?? "",
|
||||
path: params?.path,
|
||||
mode: "list",
|
||||
},
|
||||
}),
|
||||
matchResponse: (message, context) =>
|
||||
message.payload.mode === "list" &&
|
||||
message.payload.agentId === context.params?.agentId &&
|
||||
message.payload.path === context.params?.path,
|
||||
getRequestKey: (params) =>
|
||||
params ? `${params.agentId}:list:${params.path}` : "default",
|
||||
selectData: (message) => message.payload,
|
||||
extractError: (message) =>
|
||||
message.payload.error ? new Error(message.payload.error) : null,
|
||||
timeoutMs: 10000,
|
||||
keepPreviousData: false,
|
||||
});
|
||||
|
||||
const filePreviewRequest = useDaemonRequest<
|
||||
{ agentId: string; path: string },
|
||||
FileExplorerResponseMessage["payload"],
|
||||
FileExplorerResponseMessage
|
||||
>({
|
||||
ws,
|
||||
responseType: "file_explorer_response",
|
||||
buildRequest: ({ params }) => ({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "file_explorer_request",
|
||||
agentId: params?.agentId ?? "",
|
||||
path: params?.path,
|
||||
mode: "file",
|
||||
},
|
||||
}),
|
||||
matchResponse: (message, context) =>
|
||||
message.payload.mode === "file" &&
|
||||
message.payload.agentId === context.params?.agentId &&
|
||||
message.payload.path === context.params?.path,
|
||||
getRequestKey: (params) =>
|
||||
params ? `${params.agentId}:file:${params.path}` : "default",
|
||||
selectData: (message) => message.payload,
|
||||
extractError: (message) =>
|
||||
message.payload.error ? new Error(message.payload.error) : null,
|
||||
timeoutMs: 10000,
|
||||
keepPreviousData: false,
|
||||
});
|
||||
|
||||
const sendAgentLifecycleRequest = useCallback(
|
||||
(request: PendingAgentLifecycleRequest) => {
|
||||
const { kind, params } = request;
|
||||
@@ -452,21 +700,15 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
|
||||
console.log("[Session] Session state:", agentsList.length, "agents,", commandsList.length, "commands");
|
||||
|
||||
const nextAgents = new Map<string, Agent>();
|
||||
const nextPermissions = new Map<string, PendingPermission>();
|
||||
const { agents: hydratedAgents, pendingPermissions: hydratedPermissions } = buildSessionStateFromSnapshots(
|
||||
serverId,
|
||||
agentsList
|
||||
);
|
||||
const normalizedCommands = commandsList.map((command) => command as Command);
|
||||
|
||||
for (const snapshot of agentsList) {
|
||||
const agent = normalizeAgentSnapshot(snapshot);
|
||||
nextAgents.set(agent.id, agent);
|
||||
for (const request of agent.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(agent.id, request);
|
||||
nextPermissions.set(key, { key, agentId: agent.id, request });
|
||||
}
|
||||
}
|
||||
|
||||
setAgents(nextAgents);
|
||||
setPendingPermissions(nextPermissions);
|
||||
setCommands(new Map(commandsList.map((c) => [c.id, c as Command])));
|
||||
setAgents(hydratedAgents);
|
||||
setPendingPermissions(hydratedPermissions);
|
||||
setCommands(new Map(normalizedCommands.map((command) => [command.id, command])));
|
||||
setAgentStreamState((prev) => {
|
||||
if (prev.size === 0) {
|
||||
return prev;
|
||||
@@ -503,12 +745,13 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
void persistSessionSnapshot(serverId, { agents: agentsList, commands: normalizedCommands });
|
||||
});
|
||||
|
||||
const unsubAgentState = ws.on("agent_state", (message) => {
|
||||
if (message.type !== "agent_state") return;
|
||||
const snapshot = message.payload;
|
||||
const agent = normalizeAgentSnapshot(snapshot);
|
||||
const agent = normalizeAgentSnapshot(snapshot, serverId);
|
||||
|
||||
console.log("[Session] Agent state update:", agent.id, agent.status);
|
||||
|
||||
@@ -912,62 +1155,6 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
}
|
||||
});
|
||||
|
||||
// Git diff response handler
|
||||
const unsubGitDiff = ws.on("git_diff_response", (message) => {
|
||||
if (message.type !== "git_diff_response") return;
|
||||
const { agentId, diff, error } = message.payload;
|
||||
|
||||
console.log("[Session] Git diff response for agent:", agentId, error ? `(error: ${error})` : "");
|
||||
|
||||
if (error) {
|
||||
console.error("[Session] Git diff error:", error);
|
||||
setGitDiffs((prev) => new Map(prev).set(agentId, `Error: ${error}`));
|
||||
} else {
|
||||
setGitDiffs((prev) => new Map(prev).set(agentId, diff || ""));
|
||||
}
|
||||
});
|
||||
|
||||
const unsubFileExplorer = ws.on("file_explorer_response", (message) => {
|
||||
if (message.type !== "file_explorer_response") {
|
||||
return;
|
||||
}
|
||||
const { agentId, directory, file, mode, error } = message.payload;
|
||||
|
||||
console.log(
|
||||
"[Session] File explorer response for agent:",
|
||||
agentId,
|
||||
mode,
|
||||
error ? `(error: ${error})` : ""
|
||||
);
|
||||
|
||||
updateExplorerState(agentId, (state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: error ?? null,
|
||||
pendingRequest: null,
|
||||
directories: state.directories,
|
||||
files: state.files,
|
||||
};
|
||||
|
||||
if (!error) {
|
||||
if (mode === "list" && directory) {
|
||||
const directories = new Map(state.directories);
|
||||
directories.set(directory.path, directory);
|
||||
nextState.directories = directories;
|
||||
}
|
||||
|
||||
if (mode === "file" && file) {
|
||||
const files = new Map(state.files);
|
||||
files.set(file.path, file);
|
||||
nextState.files = files;
|
||||
}
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
});
|
||||
|
||||
const unsubProviderModels = ws.on(
|
||||
"list_provider_models_response",
|
||||
(message) => {
|
||||
@@ -1074,8 +1261,6 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
unsubActivity();
|
||||
unsubChunk();
|
||||
unsubTranscription();
|
||||
unsubGitDiff();
|
||||
unsubFileExplorer();
|
||||
unsubProviderModels();
|
||||
unsubAgentDeleted();
|
||||
};
|
||||
@@ -1166,7 +1351,11 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
const sendAgentMessage = useCallback(async (agentId: string, message: string, imageUris?: string[]) => {
|
||||
const sendAgentMessage = useCallback(async (
|
||||
agentId: string,
|
||||
message: string,
|
||||
images?: Array<{ uri: string; mimeType?: string }>
|
||||
) => {
|
||||
// Generate unique message ID for deduplication
|
||||
const messageId = generateMessageId();
|
||||
|
||||
@@ -1186,24 +1375,28 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
|
||||
// Convert images to base64 if provided
|
||||
let imagesData: Array<{ data: string; mimeType: string }> | undefined;
|
||||
if (imageUris && imageUris.length > 0) {
|
||||
imagesData = [];
|
||||
for (const imageUri of imageUris) {
|
||||
try {
|
||||
// Use FileSystem.File to read the image as base64
|
||||
const file = new FileSystem.File(imageUri);
|
||||
const base64 = file.base64Sync();
|
||||
|
||||
// Get MIME type from the file
|
||||
const mimeType = file.type || 'image/jpeg';
|
||||
|
||||
imagesData.push({
|
||||
data: base64,
|
||||
mimeType,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Session] Failed to convert image:', error);
|
||||
}
|
||||
if (images && images.length > 0) {
|
||||
const encodedImages = await Promise.all(
|
||||
images.map(async ({ uri, mimeType }) => {
|
||||
try {
|
||||
const data = await FileSystem.readAsStringAsync(uri, {
|
||||
encoding: "base64",
|
||||
});
|
||||
return {
|
||||
data,
|
||||
mimeType: mimeType ?? "image/jpeg",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Session] Failed to convert image:", error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
const validImages = encodedImages.filter(
|
||||
(entry): entry is { data: string; mimeType: string } => entry !== null
|
||||
);
|
||||
if (validImages.length > 0) {
|
||||
imagesData = validImages;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1229,7 +1422,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
const queue = queuedMessages.get(agentId);
|
||||
if (queue && queue.length > 0) {
|
||||
const [next, ...rest] = queue;
|
||||
void sendAgentMessage(agentId, next.text, next.images?.map((img) => img.uri));
|
||||
void sendAgentMessage(agentId, next.text, next.images);
|
||||
setQueuedMessages((prev) => {
|
||||
const updated = new Map(prev);
|
||||
updated.set(agentId, rest);
|
||||
@@ -1389,15 +1582,15 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
}, []);
|
||||
|
||||
const requestGitDiff = useCallback((agentId: string) => {
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "git_diff_request",
|
||||
agentId,
|
||||
},
|
||||
};
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
gitDiffRequest
|
||||
.execute({ agentId })
|
||||
.then((result) => {
|
||||
setGitDiffs((prev) => new Map(prev).set(result.agentId, result.diff));
|
||||
})
|
||||
.catch((error) => {
|
||||
setGitDiffs((prev) => new Map(prev).set(agentId, `Error: ${error.message}`));
|
||||
});
|
||||
}, [gitDiffRequest, setGitDiffs]);
|
||||
|
||||
const requestDirectoryListing = useCallback((agentId: string, path: string, options?: { recordHistory?: boolean }) => {
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
@@ -1413,17 +1606,37 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
lastVisitedPath: normalizedPath,
|
||||
}));
|
||||
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "file_explorer_request",
|
||||
agentId,
|
||||
path: normalizedPath,
|
||||
mode: "list",
|
||||
},
|
||||
};
|
||||
ws.send(msg);
|
||||
}, [updateExplorerState, ws]);
|
||||
directoryListingRequest
|
||||
.execute({ agentId, path: normalizedPath })
|
||||
.then((payload) => {
|
||||
updateExplorerState(agentId, (state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: payload.error ?? null,
|
||||
pendingRequest: null,
|
||||
directories: state.directories,
|
||||
files: state.files,
|
||||
};
|
||||
|
||||
if (!payload.error && payload.directory) {
|
||||
const directories = new Map(state.directories);
|
||||
directories.set(payload.directory.path, payload.directory);
|
||||
nextState.directories = directories;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: error.message,
|
||||
pendingRequest: null,
|
||||
}));
|
||||
});
|
||||
}, [directoryListingRequest, updateExplorerState]);
|
||||
|
||||
const requestFilePreview = useCallback((agentId: string, path: string) => {
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
@@ -1434,17 +1647,37 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
pendingRequest: { path: normalizedPath, mode: "file" },
|
||||
}));
|
||||
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "file_explorer_request",
|
||||
agentId,
|
||||
path: normalizedPath,
|
||||
mode: "file",
|
||||
},
|
||||
};
|
||||
ws.send(msg);
|
||||
}, [updateExplorerState, ws]);
|
||||
filePreviewRequest
|
||||
.execute({ agentId, path: normalizedPath })
|
||||
.then((payload) => {
|
||||
updateExplorerState(agentId, (state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: payload.error ?? null,
|
||||
pendingRequest: null,
|
||||
directories: state.directories,
|
||||
files: state.files,
|
||||
};
|
||||
|
||||
if (!payload.error && payload.file) {
|
||||
const files = new Map(state.files);
|
||||
files.set(payload.file.path, payload.file);
|
||||
nextState.files = files;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: error.message,
|
||||
pendingRequest: null,
|
||||
}));
|
||||
});
|
||||
}, [filePreviewRequest, updateExplorerState]);
|
||||
|
||||
const navigateExplorerBack = useCallback((agentId: string) => {
|
||||
let targetPath: string | null = null;
|
||||
@@ -1485,6 +1718,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
}, [updateExplorerState, ws]);
|
||||
|
||||
const value: SessionContextValue = {
|
||||
serverId,
|
||||
ws,
|
||||
audioPlayer,
|
||||
isPlayingAudio,
|
||||
@@ -1530,6 +1764,42 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
respondToPermission,
|
||||
};
|
||||
|
||||
const sessionValueRef = useRef<SessionContextValue>(value);
|
||||
const sessionListenersRef = useRef(new Set<() => void>());
|
||||
useEffect(() => {
|
||||
sessionValueRef.current = value;
|
||||
sessionListenersRef.current.forEach((listener) => {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
console.error("[SessionProvider] Session listener failed", error);
|
||||
}
|
||||
});
|
||||
notifySessionDirectoryChange();
|
||||
}, [value, notifySessionDirectoryChange]);
|
||||
|
||||
const sessionAccessor = useCallback(() => sessionValueRef.current, []);
|
||||
const subscribeToSession = useCallback((listener: () => void) => {
|
||||
sessionListenersRef.current.add(listener);
|
||||
return () => {
|
||||
sessionListenersRef.current.delete(listener);
|
||||
};
|
||||
}, []);
|
||||
const sessionDirectoryEntry = useMemo(
|
||||
() => ({
|
||||
getSnapshot: sessionAccessor,
|
||||
subscribe: subscribeToSession,
|
||||
}),
|
||||
[sessionAccessor, subscribeToSession]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
registerSessionAccessor(serverId, sessionDirectoryEntry);
|
||||
return () => {
|
||||
unregisterSessionAccessor(serverId);
|
||||
};
|
||||
}, [serverId, registerSessionAccessor, unregisterSessionAccessor, sessionDirectoryEntry]);
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={value}>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionDirectory } from "@/hooks/use-session-directory";
|
||||
|
||||
export interface AggregatedAgentGroup {
|
||||
serverId: string;
|
||||
serverLabel: string;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
const sortAgents = (agents: Agent[]): Agent[] => {
|
||||
return [...agents].sort((left, right) => {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
return -1;
|
||||
}
|
||||
if (!leftRunning && rightRunning) {
|
||||
return 1;
|
||||
}
|
||||
const leftTime = (left.lastUserMessageAt ?? left.lastActivityAt).getTime();
|
||||
const rightTime = (right.lastUserMessageAt ?? right.lastActivityAt).getTime();
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
};
|
||||
|
||||
export function useAggregatedAgents(fallbackAgents: Map<string, Agent>): AggregatedAgentGroup[] {
|
||||
const { activeDaemonId, connectionStates } = useDaemonConnections();
|
||||
const sessionDirectory = useSessionDirectory();
|
||||
const activeServerId = activeDaemonId ?? "default";
|
||||
|
||||
return useMemo(() => {
|
||||
const groups = new Map<string, { serverLabel: string; agents: Agent[] }>();
|
||||
const daemonOrder = new Map<string, number>();
|
||||
Array.from(connectionStates.keys()).forEach((serverId, index) => {
|
||||
daemonOrder.set(serverId, index);
|
||||
});
|
||||
|
||||
if (sessionDirectory.size > 0) {
|
||||
sessionDirectory.forEach((session, serverId) => {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
const label = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const sessionAgents = Array.from(session.agents.values());
|
||||
if (sessionAgents.length === 0) {
|
||||
return;
|
||||
}
|
||||
const existing = groups.get(serverId);
|
||||
if (existing) {
|
||||
existing.agents.push(...sessionAgents);
|
||||
} else {
|
||||
groups.set(serverId, { serverLabel: label, agents: sessionAgents });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (groups.size === 0) {
|
||||
const label = connectionStates.get(activeServerId)?.daemon.label ?? activeServerId;
|
||||
const fallbackList = Array.from(fallbackAgents.values());
|
||||
if (fallbackList.length > 0) {
|
||||
groups.set(activeServerId, { serverLabel: label, agents: fallbackList });
|
||||
}
|
||||
}
|
||||
|
||||
const aggregatedGroups = Array.from(groups.entries()).map(([serverId, { serverLabel, agents }]) => ({
|
||||
serverId,
|
||||
serverLabel,
|
||||
agents: sortAgents(agents),
|
||||
}));
|
||||
|
||||
aggregatedGroups.sort((left, right) => {
|
||||
const leftIndex = daemonOrder.get(left.serverId);
|
||||
const rightIndex = daemonOrder.get(right.serverId);
|
||||
if (leftIndex !== undefined && rightIndex !== undefined && leftIndex !== rightIndex) {
|
||||
return leftIndex - rightIndex;
|
||||
}
|
||||
if (leftIndex !== undefined) {
|
||||
return -1;
|
||||
}
|
||||
if (rightIndex !== undefined) {
|
||||
return 1;
|
||||
}
|
||||
return left.serverLabel.localeCompare(right.serverLabel);
|
||||
});
|
||||
|
||||
return aggregatedGroups;
|
||||
}, [sessionDirectory, fallbackAgents, activeServerId, connectionStates]);
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { SessionOutboundMessage, WSInboundMessage } from "@server/server/messages";
|
||||
import type { UseWebSocketReturn } from "./use-websocket";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
|
||||
type RequestStatus = "idle" | "loading" | "success" | "error";
|
||||
|
||||
type MaybeUndefined<T> = T | undefined;
|
||||
type ExecuteParams<TParams> = TParams extends void ? void | undefined : TParams;
|
||||
|
||||
export interface RequestContext<TParams> {
|
||||
params: MaybeUndefined<TParams>;
|
||||
requestId: string;
|
||||
key: string;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
type RetryDelay =
|
||||
| number
|
||||
| ((attempt: number) => number);
|
||||
|
||||
export interface UseDaemonRequestOptions<
|
||||
TParams = void,
|
||||
TData = unknown,
|
||||
TMessage extends SessionOutboundMessage = SessionOutboundMessage
|
||||
> {
|
||||
ws: UseWebSocketReturn;
|
||||
/**
|
||||
* Session message type to subscribe to for responses.
|
||||
*/
|
||||
responseType: TMessage["type"];
|
||||
/**
|
||||
* Builder that receives params + the generated requestId and returns the outbound WS message.
|
||||
*/
|
||||
buildRequest: (context: { params: MaybeUndefined<TParams>; requestId: string }) => WSInboundMessage;
|
||||
/**
|
||||
* Extracts the typed data from the inbound response.
|
||||
*/
|
||||
selectData: (message: TMessage, context: RequestContext<TParams>) => TData;
|
||||
/**
|
||||
* Override response matching behavior. Defaults to comparing payload.requestId when available.
|
||||
*/
|
||||
matchResponse?: (message: TMessage, context: RequestContext<TParams>) => boolean;
|
||||
/**
|
||||
* Returns the request key for dedupe. Defaults to JSON.stringify(params) or "default".
|
||||
*/
|
||||
getRequestKey?: (params: MaybeUndefined<TParams>) => string;
|
||||
/**
|
||||
* Returns an error (string or Error) when the payload represents a failure.
|
||||
*/
|
||||
extractError?: (message: TMessage, context: RequestContext<TParams>) => string | Error | null;
|
||||
/**
|
||||
* Milliseconds before a request automatically times out. Pass null to disable.
|
||||
*/
|
||||
timeoutMs?: number | null;
|
||||
/**
|
||||
* Number of retry attempts after the initial send.
|
||||
*/
|
||||
retryCount?: number;
|
||||
/**
|
||||
* Delay between retries (ms) or function that maps attempt -> delay.
|
||||
*/
|
||||
retryDelayMs?: RetryDelay;
|
||||
/**
|
||||
* Keep previously resolved data while loading.
|
||||
*/
|
||||
keepPreviousData?: boolean;
|
||||
/**
|
||||
* Provide initial data before running the request.
|
||||
*/
|
||||
initialData?: TData | null;
|
||||
/**
|
||||
* Disable deduplication (send every execute call even if one is in-flight).
|
||||
*/
|
||||
dedupe?: boolean;
|
||||
}
|
||||
|
||||
export interface ExecuteRequestOptions {
|
||||
timeoutMs?: number | null;
|
||||
retryCount?: number;
|
||||
retryDelayMs?: RetryDelay;
|
||||
dedupe?: boolean;
|
||||
requestKeyOverride?: string;
|
||||
}
|
||||
|
||||
export interface UseDaemonRequestResult<TParams, TData> {
|
||||
status: RequestStatus;
|
||||
data: TData | null;
|
||||
error: Error | null;
|
||||
isIdle: boolean;
|
||||
isLoading: boolean;
|
||||
isSuccess: boolean;
|
||||
isError: boolean;
|
||||
requestId: string | null;
|
||||
updatedAt: number | null;
|
||||
execute: (params?: ExecuteParams<TParams>, options?: ExecuteRequestOptions) => Promise<TData>;
|
||||
reset: () => void;
|
||||
cancel: (reason?: string) => void;
|
||||
}
|
||||
|
||||
interface ActiveRequest<TParams, TData, TMessage extends SessionOutboundMessage> {
|
||||
key: string;
|
||||
params: MaybeUndefined<TParams>;
|
||||
requestId: string;
|
||||
attempt: number;
|
||||
promise: Promise<TData>;
|
||||
resolve: (data: TData) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle: ReturnType<typeof setTimeout> | null;
|
||||
retryHandle: ReturnType<typeof setTimeout> | null;
|
||||
aborted: boolean;
|
||||
options: ResolvedBehavior;
|
||||
}
|
||||
|
||||
interface ResolvedBehavior {
|
||||
timeoutMs: number | null;
|
||||
retryCount: number;
|
||||
retryDelayMs: RetryDelay;
|
||||
dedupe: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
const DEFAULT_RETRY_DELAY_MS = 750;
|
||||
|
||||
function useLatest<T>(value: T) {
|
||||
const ref = useRef(value);
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
return ref;
|
||||
}
|
||||
|
||||
function resolveKey(params: unknown): string {
|
||||
if (params === undefined || params === null) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (typeof params === "string" || typeof params === "number" || typeof params === "boolean") {
|
||||
return String(params);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(params);
|
||||
} catch {
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function ensureError(error: unknown): Error {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
return new Error(typeof error === "string" ? error : "Unknown request error");
|
||||
}
|
||||
|
||||
export function useDaemonRequest<
|
||||
TParams = void,
|
||||
TData = unknown,
|
||||
TMessage extends SessionOutboundMessage = SessionOutboundMessage
|
||||
>(options: UseDaemonRequestOptions<TParams, TData, TMessage>): UseDaemonRequestResult<TParams, TData> {
|
||||
const {
|
||||
ws,
|
||||
responseType,
|
||||
buildRequest,
|
||||
selectData,
|
||||
matchResponse,
|
||||
getRequestKey,
|
||||
extractError,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
retryCount = 0,
|
||||
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
||||
keepPreviousData = true,
|
||||
initialData = null,
|
||||
dedupe = true,
|
||||
} = options;
|
||||
|
||||
const buildRequestRef = useLatest(buildRequest);
|
||||
const selectDataRef = useLatest(selectData);
|
||||
const matchResponseRef = useLatest(matchResponse);
|
||||
const extractErrorRef = useLatest(extractError);
|
||||
const getRequestKeyRef = useLatest(getRequestKey);
|
||||
|
||||
const [state, setState] = useState<{
|
||||
status: RequestStatus;
|
||||
data: TData | null;
|
||||
error: Error | null;
|
||||
requestId: string | null;
|
||||
updatedAt: number | null;
|
||||
}>(() => ({
|
||||
status: initialData === null ? "idle" : "success",
|
||||
data: initialData,
|
||||
error: null,
|
||||
requestId: null,
|
||||
updatedAt: initialData === null ? null : Date.now(),
|
||||
}));
|
||||
|
||||
const activeRequestRef = useRef<ActiveRequest<TParams, TData, TMessage> | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
const active = activeRequestRef.current;
|
||||
if (active) {
|
||||
if (active.timeoutHandle) {
|
||||
clearTimeout(active.timeoutHandle);
|
||||
}
|
||||
if (active.retryHandle) {
|
||||
clearTimeout(active.retryHandle);
|
||||
}
|
||||
activeRequestRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleanupActiveRequest = useCallback(() => {
|
||||
const current = activeRequestRef.current;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
if (current.timeoutHandle) {
|
||||
clearTimeout(current.timeoutHandle);
|
||||
}
|
||||
if (current.retryHandle) {
|
||||
clearTimeout(current.retryHandle);
|
||||
}
|
||||
activeRequestRef.current = null;
|
||||
}, []);
|
||||
|
||||
const applyState = useCallback(
|
||||
(updater: (prev: typeof state) => typeof state) => {
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
setState(updater);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const getMatchFn = useCallback(
|
||||
(context: RequestContext<TParams>) => {
|
||||
const userMatch = matchResponseRef.current;
|
||||
if (userMatch) {
|
||||
return (message: TMessage) => userMatch(message, context);
|
||||
}
|
||||
return (message: TMessage) => {
|
||||
const payload = (message as { payload?: { requestId?: unknown } }).payload;
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"requestId" in payload &&
|
||||
typeof (payload as { requestId?: unknown }).requestId === "string"
|
||||
) {
|
||||
return (payload as { requestId?: string }).requestId === context.requestId;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
},
|
||||
[matchResponseRef]
|
||||
);
|
||||
|
||||
const resolvedBehavior = useCallback(
|
||||
(overrides?: ExecuteRequestOptions): ResolvedBehavior => ({
|
||||
timeoutMs:
|
||||
overrides?.timeoutMs !== undefined
|
||||
? overrides.timeoutMs
|
||||
: timeoutMs ?? null,
|
||||
retryCount: overrides?.retryCount ?? retryCount,
|
||||
retryDelayMs: overrides?.retryDelayMs ?? retryDelayMs,
|
||||
dedupe: overrides?.dedupe ?? dedupe,
|
||||
}),
|
||||
[timeoutMs, retryCount, retryDelayMs, dedupe]
|
||||
);
|
||||
|
||||
const sendAttempt = useCallback(
|
||||
(request: ActiveRequest<TParams, TData, TMessage>) => {
|
||||
if (request.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
request.attempt += 1;
|
||||
|
||||
if (request.timeoutHandle) {
|
||||
clearTimeout(request.timeoutHandle);
|
||||
}
|
||||
|
||||
try {
|
||||
const message = buildRequestRef.current({
|
||||
params: request.params,
|
||||
requestId: request.requestId,
|
||||
});
|
||||
ws.send(message);
|
||||
} catch (error) {
|
||||
const err = ensureError(error);
|
||||
request.reject(err);
|
||||
cleanupActiveRequest();
|
||||
applyState((prev) => ({
|
||||
status: "error",
|
||||
data: keepPreviousData ? prev.data : null,
|
||||
error: err,
|
||||
requestId: null,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutDuration =
|
||||
typeof request.options.timeoutMs === "number"
|
||||
? request.options.timeoutMs
|
||||
: null;
|
||||
|
||||
if (timeoutDuration && timeoutDuration > 0) {
|
||||
request.timeoutHandle = setTimeout(() => {
|
||||
if (request.aborted) {
|
||||
return;
|
||||
}
|
||||
const timeoutError = new Error(
|
||||
`Request timed out after ${timeoutDuration}ms`
|
||||
);
|
||||
const maxAttempts = request.options.retryCount + 1;
|
||||
if (request.attempt >= maxAttempts) {
|
||||
cleanupActiveRequest();
|
||||
request.reject(timeoutError);
|
||||
applyState((prev) => ({
|
||||
status: "error",
|
||||
data: keepPreviousData ? prev.data : null,
|
||||
error: timeoutError,
|
||||
requestId: null,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const delay =
|
||||
typeof request.options.retryDelayMs === "function"
|
||||
? request.options.retryDelayMs(request.attempt)
|
||||
: request.options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
||||
request.retryHandle = setTimeout(() => {
|
||||
sendAttempt(request);
|
||||
}, Math.max(0, delay));
|
||||
}, timeoutDuration);
|
||||
}
|
||||
},
|
||||
[applyState, buildRequestRef, cleanupActiveRequest, keepPreviousData, ws]
|
||||
);
|
||||
|
||||
const startRequest = useCallback(
|
||||
(params?: ExecuteParams<TParams>, overrides?: ExecuteRequestOptions) => {
|
||||
const normalizedParams = params as MaybeUndefined<TParams>;
|
||||
const behavior = resolvedBehavior(overrides);
|
||||
const computedKey =
|
||||
overrides?.requestKeyOverride ??
|
||||
getRequestKeyRef.current?.(normalizedParams) ??
|
||||
resolveKey(normalizedParams);
|
||||
const active = activeRequestRef.current;
|
||||
if (behavior.dedupe && active && active.key === computedKey) {
|
||||
return active.promise;
|
||||
}
|
||||
|
||||
let resolveFn!: (value: TData) => void;
|
||||
let rejectFn!: (error: Error) => void;
|
||||
const promise = new Promise<TData>((resolve, reject) => {
|
||||
resolveFn = resolve;
|
||||
rejectFn = reject;
|
||||
});
|
||||
|
||||
const request: ActiveRequest<TParams, TData, TMessage> = {
|
||||
key: computedKey,
|
||||
params: normalizedParams,
|
||||
requestId: generateMessageId(),
|
||||
attempt: 0,
|
||||
promise,
|
||||
resolve: resolveFn,
|
||||
reject: rejectFn,
|
||||
timeoutHandle: null,
|
||||
retryHandle: null,
|
||||
aborted: false,
|
||||
options: behavior,
|
||||
};
|
||||
|
||||
activeRequestRef.current = request;
|
||||
|
||||
applyState((prev) => ({
|
||||
status: "loading",
|
||||
data: keepPreviousData ? prev.data : null,
|
||||
error: null,
|
||||
requestId: request.requestId,
|
||||
updatedAt: prev.updatedAt,
|
||||
}));
|
||||
|
||||
sendAttempt(request);
|
||||
return promise;
|
||||
},
|
||||
[applyState, getRequestKeyRef, keepPreviousData, resolvedBehavior, sendAttempt]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = ws.on(responseType, (message) => {
|
||||
const active = activeRequestRef.current;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const castedMessage = message as TMessage;
|
||||
const context: RequestContext<TParams> = {
|
||||
params: active.params,
|
||||
requestId: active.requestId,
|
||||
key: active.key,
|
||||
attempt: active.attempt,
|
||||
};
|
||||
const matcher = getMatchFn(context);
|
||||
if (!matcher(castedMessage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (active.timeoutHandle) {
|
||||
clearTimeout(active.timeoutHandle);
|
||||
active.timeoutHandle = null;
|
||||
}
|
||||
if (active.retryHandle) {
|
||||
clearTimeout(active.retryHandle);
|
||||
active.retryHandle = null;
|
||||
}
|
||||
|
||||
const maybeError = extractErrorRef.current
|
||||
? extractErrorRef.current(castedMessage, context)
|
||||
: null;
|
||||
|
||||
if (maybeError) {
|
||||
const error = ensureError(maybeError);
|
||||
const maxAttempts = active.options.retryCount + 1;
|
||||
if (active.attempt >= maxAttempts) {
|
||||
cleanupActiveRequest();
|
||||
active.reject(error);
|
||||
applyState((prev) => ({
|
||||
status: "error",
|
||||
data: keepPreviousData ? prev.data : null,
|
||||
error,
|
||||
requestId: null,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const delay =
|
||||
typeof active.options.retryDelayMs === "function"
|
||||
? active.options.retryDelayMs(active.attempt)
|
||||
: active.options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
||||
active.retryHandle = setTimeout(() => {
|
||||
sendAttempt(active);
|
||||
}, Math.max(0, delay));
|
||||
return;
|
||||
}
|
||||
|
||||
let data: TData;
|
||||
try {
|
||||
data = selectDataRef.current(castedMessage, context);
|
||||
} catch (error) {
|
||||
const err = ensureError(error);
|
||||
cleanupActiveRequest();
|
||||
active.reject(err);
|
||||
applyState((prev) => ({
|
||||
status: "error",
|
||||
data: keepPreviousData ? prev.data : null,
|
||||
error: err,
|
||||
requestId: null,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupActiveRequest();
|
||||
active.resolve(data);
|
||||
applyState(() => ({
|
||||
status: "success",
|
||||
data,
|
||||
error: null,
|
||||
requestId: context.requestId,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [
|
||||
applyState,
|
||||
cleanupActiveRequest,
|
||||
extractErrorRef,
|
||||
getMatchFn,
|
||||
keepPreviousData,
|
||||
responseType,
|
||||
selectDataRef,
|
||||
sendAttempt,
|
||||
ws,
|
||||
]);
|
||||
|
||||
const execute = useCallback(
|
||||
(params?: ExecuteParams<TParams>, overrides?: ExecuteRequestOptions) => startRequest(params, overrides),
|
||||
[startRequest]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
cleanupActiveRequest();
|
||||
applyState(() => ({
|
||||
status: "idle",
|
||||
data: initialData,
|
||||
error: null,
|
||||
requestId: null,
|
||||
updatedAt: null,
|
||||
}));
|
||||
}, [applyState, cleanupActiveRequest, initialData]);
|
||||
|
||||
const cancel = useCallback(
|
||||
(reason?: string) => {
|
||||
const active = activeRequestRef.current;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
active.aborted = true;
|
||||
cleanupActiveRequest();
|
||||
const error = new Error(reason ?? "Request cancelled");
|
||||
active.reject(error);
|
||||
applyState((prev) => ({
|
||||
status: "idle",
|
||||
data: keepPreviousData ? prev.data : null,
|
||||
error: null,
|
||||
requestId: null,
|
||||
updatedAt: prev.updatedAt,
|
||||
}));
|
||||
},
|
||||
[applyState, cleanupActiveRequest, keepPreviousData]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
status: state.status,
|
||||
data: state.data,
|
||||
error: state.error,
|
||||
isIdle: state.status === "idle",
|
||||
isLoading: state.status === "loading",
|
||||
isSuccess: state.status === "success",
|
||||
isError: state.status === "error",
|
||||
requestId: state.requestId,
|
||||
updatedAt: state.updatedAt,
|
||||
execute,
|
||||
reset,
|
||||
cancel,
|
||||
}),
|
||||
[execute, reset, cancel, state]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useRef } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
import { useSession } from "@/contexts/session-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionDirectory } from "./use-session-directory";
|
||||
|
||||
export class DaemonSessionUnavailableError extends Error {
|
||||
serverId: string;
|
||||
|
||||
constructor(serverId: string) {
|
||||
super(`Daemon session "${serverId}" is unavailable`);
|
||||
this.name = "DaemonSessionUnavailableError";
|
||||
this.serverId = serverId;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionForServer(
|
||||
serverId: string,
|
||||
directory: Map<string, SessionContextValue | null>
|
||||
): SessionContextValue {
|
||||
const session = directory.get(serverId) ?? null;
|
||||
if (!session) {
|
||||
throw new DaemonSessionUnavailableError(serverId);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
type UseDaemonSessionOptions = {
|
||||
suppressUnavailableAlert?: boolean;
|
||||
};
|
||||
|
||||
export function useDaemonSession(serverId?: string, options?: UseDaemonSessionOptions) {
|
||||
const activeSession = useSession();
|
||||
const sessionDirectory = useSessionDirectory();
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const alertedDaemonsRef = useRef<Set<string>>(new Set());
|
||||
const loggedDaemonsRef = useRef<Set<string>>(new Set());
|
||||
const { suppressUnavailableAlert = false } = options ?? {};
|
||||
|
||||
const targetServerId = serverId ?? activeSession.serverId;
|
||||
const isActiveSession = targetServerId === activeSession.serverId;
|
||||
|
||||
if (isActiveSession || !targetServerId) {
|
||||
return activeSession;
|
||||
}
|
||||
|
||||
try {
|
||||
return getSessionForServer(targetServerId, sessionDirectory);
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonSessionUnavailableError) {
|
||||
const connection = connectionStates.get(targetServerId);
|
||||
const label = connection?.daemon.label ?? targetServerId;
|
||||
const status = connection?.status ?? "unknown";
|
||||
const lastError = connection?.lastError ? `\n${connection.lastError}` : "";
|
||||
const message = `${label} isn't connected yet (${status}). Switch to it or enable auto-connect so Paseo can reach it.${lastError}`;
|
||||
|
||||
if (!suppressUnavailableAlert && !alertedDaemonsRef.current.has(targetServerId)) {
|
||||
alertedDaemonsRef.current.add(targetServerId);
|
||||
Alert.alert("Daemon unavailable", message.trim());
|
||||
}
|
||||
|
||||
if (!loggedDaemonsRef.current.has(targetServerId)) {
|
||||
loggedDaemonsRef.current.add(targetServerId);
|
||||
console.warn(`[useDaemonSession] Session unavailable for daemon "${label}" (${status}).`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
|
||||
export function useSessionDirectory(): Map<string, SessionContextValue | null> {
|
||||
const { sessionAccessors, subscribeToSessionDirectory } = useDaemonConnections();
|
||||
const [revision, setRevision] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeToSessionDirectory(() => {
|
||||
setRevision((current) => current + 1);
|
||||
});
|
||||
}, [subscribeToSessionDirectory]);
|
||||
|
||||
return useMemo(() => {
|
||||
const entries = new Map<string, SessionContextValue | null>();
|
||||
sessionAccessors.forEach((entry, serverId) => {
|
||||
try {
|
||||
entries.set(serverId, entry.getSnapshot());
|
||||
} catch (error) {
|
||||
console.error(`[useSessionDirectory] Failed to read session accessor for "${serverId}"`, error);
|
||||
entries.set(serverId, null);
|
||||
}
|
||||
});
|
||||
return entries;
|
||||
}, [sessionAccessors, revision]);
|
||||
}
|
||||
|
||||
export function useSessionForServer(serverId: string | null): SessionContextValue | null {
|
||||
const directory = useSessionDirectory();
|
||||
if (!serverId) {
|
||||
return null;
|
||||
}
|
||||
return directory.get(serverId) ?? null;
|
||||
}
|
||||
@@ -1,78 +1,114 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const STORAGE_KEY = '@paseo:settings';
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
|
||||
export interface Settings {
|
||||
serverUrl: string;
|
||||
export interface AppSettings {
|
||||
useSpeaker: boolean;
|
||||
keepScreenOn: boolean;
|
||||
theme: 'dark' | 'light' | 'auto';
|
||||
theme: "dark" | "light" | "auto";
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
serverUrl: 'wss://mohameds-macbook-pro.tail8fe838.ts.net/ws',
|
||||
const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
useSpeaker: true,
|
||||
keepScreenOn: true,
|
||||
theme: 'dark',
|
||||
theme: "dark",
|
||||
};
|
||||
|
||||
export interface UseSettingsReturn {
|
||||
settings: Settings;
|
||||
export interface UseAppSettingsReturn {
|
||||
settings: AppSettings;
|
||||
isLoading: boolean;
|
||||
updateSettings: (updates: Partial<Settings>) => Promise<void>;
|
||||
error: unknown | null;
|
||||
updateSettings: (updates: Partial<AppSettings>) => Promise<void>;
|
||||
resetSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSettings(): UseSettingsReturn {
|
||||
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
export function useAppSettings(): UseAppSettingsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending, error } = useQuery({
|
||||
queryKey: APP_SETTINGS_QUERY_KEY,
|
||||
queryFn: loadSettingsFromStorage,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
// Load settings from AsyncStorage on mount
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Partial<Settings>;
|
||||
setSettings({ ...DEFAULT_SETTINGS, ...parsed });
|
||||
const updateSettings = useCallback(
|
||||
async (updates: Partial<AppSettings>) => {
|
||||
try {
|
||||
const prev = queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_APP_SETTINGS;
|
||||
const next = { ...prev, ...updates };
|
||||
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
} catch (err) {
|
||||
console.error("[AppSettings] Failed to save settings:", err);
|
||||
throw err;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to load settings:', error);
|
||||
// Continue with default settings
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const updateSettings = useCallback(async (updates: Partial<Settings>) => {
|
||||
try {
|
||||
const newSettings = { ...settings, ...updates };
|
||||
setSettings(newSettings);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newSettings));
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to save settings:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [settings]);
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const resetSettings = useCallback(async () => {
|
||||
try {
|
||||
setSettings(DEFAULT_SETTINGS);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_SETTINGS));
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to reset settings:', error);
|
||||
throw error;
|
||||
const next = { ...DEFAULT_APP_SETTINGS };
|
||||
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
} catch (err) {
|
||||
console.error("[AppSettings] Failed to reset settings:", err);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
}, [queryClient]);
|
||||
|
||||
return {
|
||||
settings,
|
||||
isLoading,
|
||||
settings: data ?? DEFAULT_APP_SETTINGS,
|
||||
isLoading: isPending,
|
||||
error: error ?? null,
|
||||
updateSettings,
|
||||
resetSettings,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSettingsFromStorage(): Promise<AppSettings> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Partial<AppSettings>;
|
||||
return { ...DEFAULT_APP_SETTINGS, ...parsed };
|
||||
}
|
||||
|
||||
const legacyStored = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
|
||||
if (legacyStored) {
|
||||
const legacyParsed = JSON.parse(legacyStored) as Record<string, unknown>;
|
||||
const next = {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...pickAppSettingsFromLegacy(legacyParsed),
|
||||
} satisfies AppSettings;
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_APP_SETTINGS));
|
||||
return DEFAULT_APP_SETTINGS;
|
||||
} catch (error) {
|
||||
console.error("[AppSettings] Failed to load settings:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (typeof legacy.useSpeaker === "boolean") {
|
||||
result.useSpeaker = legacy.useSpeaker;
|
||||
}
|
||||
if (typeof legacy.keepScreenOn === "boolean") {
|
||||
result.keepScreenOn = legacy.keepScreenOn;
|
||||
}
|
||||
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
||||
result.theme = legacy.theme;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const useSettings = useAppSettings;
|
||||
|
||||
@@ -1,54 +1,123 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import type {
|
||||
WSInboundMessage,
|
||||
WSOutboundMessage,
|
||||
SessionOutboundMessage
|
||||
} from '@server/server/messages';
|
||||
SessionOutboundMessage,
|
||||
} from "@server/server/messages";
|
||||
|
||||
export interface UseWebSocketReturn {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
conversationId: string | null;
|
||||
lastError: string | null;
|
||||
send: (message: WSInboundMessage) => void;
|
||||
on: (type: SessionOutboundMessage['type'], handler: (message: SessionOutboundMessage) => void) => () => void;
|
||||
on: (
|
||||
type: SessionOutboundMessage["type"],
|
||||
handler: (message: SessionOutboundMessage) => void
|
||||
) => () => void;
|
||||
sendPing: () => void;
|
||||
sendUserMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
const RECONNECT_BASE_DELAY_MS = 1500;
|
||||
const RECONNECT_MAX_DELAY_MS = 30000;
|
||||
|
||||
export function useWebSocket(url: string, conversationId?: string | null): UseWebSocketReturn {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(true);
|
||||
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const handlersRef = useRef<Map<SessionOutboundMessage['type'], Set<(message: SessionOutboundMessage) => void>>>(new Map());
|
||||
const handlersRef =
|
||||
useRef<Map<SessionOutboundMessage["type"], Set<(message: SessionOutboundMessage) => void>>>(new Map());
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const reconnectAttemptRef = useRef(0);
|
||||
const shouldReconnectRef = useRef(true);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scheduledReconnect = false;
|
||||
const scheduleReconnect = (reason?: string) => {
|
||||
if (scheduledReconnect || !shouldReconnectRef.current) {
|
||||
return;
|
||||
}
|
||||
scheduledReconnect = true;
|
||||
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
if (typeof reason === "string" && reason.trim().length > 0) {
|
||||
setLastError(reason.trim());
|
||||
}
|
||||
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
|
||||
const attempt = reconnectAttemptRef.current;
|
||||
const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** attempt, RECONNECT_MAX_DELAY_MS);
|
||||
reconnectAttemptRef.current = attempt + 1;
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectTimeoutRef.current = undefined;
|
||||
if (!shouldReconnectRef.current) {
|
||||
return;
|
||||
}
|
||||
setIsConnecting(true);
|
||||
connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
try {
|
||||
// Add conversation ID to URL if provided
|
||||
const wsUrl = conversationId ? `${url}?conversationId=${conversationId}` : url;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
setIsConnecting(true);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[WS] Connected to server');
|
||||
console.log("[WS] Connected to server");
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
setLastError(null);
|
||||
reconnectAttemptRef.current = 0;
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[WS] Disconnected from server');
|
||||
ws.onclose = (event) => {
|
||||
console.log("[WS] Disconnected from server");
|
||||
const reason =
|
||||
typeof event?.reason === "string" && event.reason.trim().length > 0
|
||||
? event.reason.trim()
|
||||
: `Socket closed (code ${event?.code ?? "unknown"})`;
|
||||
setIsConnected(false);
|
||||
|
||||
// Attempt to reconnect after 3 seconds
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
console.log('[WS] Attempting to reconnect...');
|
||||
connect();
|
||||
}, 3000);
|
||||
scheduleReconnect(reason);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.warn('[WS] Error:', error);
|
||||
ws.onerror = (errorEvent) => {
|
||||
let reason = "WebSocket error";
|
||||
if (
|
||||
errorEvent &&
|
||||
typeof errorEvent === "object" &&
|
||||
"message" in errorEvent &&
|
||||
typeof (errorEvent as { message: unknown }).message === "string"
|
||||
) {
|
||||
const message = ((errorEvent as { message: string }).message || "").trim();
|
||||
reason = message.length > 0 ? message : reason;
|
||||
}
|
||||
console.warn("[WS] Error:", errorEvent);
|
||||
scheduleReconnect(reason);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -56,12 +125,12 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
|
||||
const wsMessage: WSOutboundMessage = JSON.parse(event.data);
|
||||
|
||||
// Only session messages trigger handlers
|
||||
if (wsMessage.type === 'session') {
|
||||
if (wsMessage.type === "session") {
|
||||
const sessionMessage = wsMessage.message;
|
||||
console.log(`[WS] Received session message type: ${sessionMessage.type}`);
|
||||
|
||||
// Track conversation ID when loaded
|
||||
if (sessionMessage.type === 'conversation_loaded') {
|
||||
if (sessionMessage.type === "conversation_loaded") {
|
||||
setCurrentConversationId(sessionMessage.payload.conversationId);
|
||||
}
|
||||
|
||||
@@ -81,20 +150,23 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
|
||||
console.log(`[WS] Received ${wsMessage.type}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WS] Failed to parse message:', err);
|
||||
console.error("[WS] Failed to parse message:", err);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
} catch (err) {
|
||||
console.warn('[WS] Failed to create WebSocket:', err);
|
||||
console.warn("[WS] Failed to create WebSocket:", err);
|
||||
const reason = err instanceof Error ? err.message : "Failed to create WebSocket";
|
||||
scheduleReconnect(reason);
|
||||
}
|
||||
}, [url, conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
shouldReconnectRef.current = true;
|
||||
setIsConnecting(true);
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
shouldReconnectRef.current = false;
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
@@ -108,41 +180,41 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn('[WS] Cannot send message - not connected');
|
||||
console.warn("[WS] Cannot send message - not connected");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const on = useCallback((
|
||||
type: SessionOutboundMessage['type'],
|
||||
handler: (message: SessionOutboundMessage) => void
|
||||
) => {
|
||||
if (!handlersRef.current.has(type)) {
|
||||
handlersRef.current.set(type, new Set());
|
||||
}
|
||||
handlersRef.current.get(type)!.add(handler);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
const handlers = handlersRef.current.get(type);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
handlersRef.current.delete(type);
|
||||
}
|
||||
const on = useCallback(
|
||||
(type: SessionOutboundMessage["type"], handler: (message: SessionOutboundMessage) => void) => {
|
||||
if (!handlersRef.current.has(type)) {
|
||||
handlersRef.current.set(type, new Set());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
handlersRef.current.get(type)!.add(handler);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
const handlers = handlersRef.current.get(type);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
handlersRef.current.delete(type);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const sendPing = useCallback(() => {
|
||||
send({ type: 'ping' });
|
||||
send({ type: "ping" });
|
||||
}, [send]);
|
||||
|
||||
const sendUserMessage = useCallback(
|
||||
(message: string) => {
|
||||
send({
|
||||
type: 'session',
|
||||
type: "session",
|
||||
message: {
|
||||
type: 'user_text',
|
||||
type: "user_text",
|
||||
text: message,
|
||||
},
|
||||
});
|
||||
@@ -153,12 +225,14 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
|
||||
return useMemo(
|
||||
() => ({
|
||||
isConnected,
|
||||
isConnecting,
|
||||
conversationId: currentConversationId,
|
||||
lastError,
|
||||
send,
|
||||
on,
|
||||
sendPing,
|
||||
sendUserMessage,
|
||||
}),
|
||||
[isConnected, currentConversationId, send, on, sendPing, sendUserMessage]
|
||||
[isConnected, isConnecting, currentConversationId, lastError, send, on, sendPing, sendUserMessage]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
|
||||
export function formatConnectionStatus(status: ConnectionStatus): string {
|
||||
switch (status) {
|
||||
case "online":
|
||||
return "Online";
|
||||
case "connecting":
|
||||
return "Connecting";
|
||||
case "offline":
|
||||
return "Offline";
|
||||
case "error":
|
||||
return "Error";
|
||||
default:
|
||||
return "Idle";
|
||||
}
|
||||
}
|
||||
|
||||
export type ConnectionStatusTone = "success" | "warning" | "error" | "muted";
|
||||
|
||||
export function getConnectionStatusTone(status: ConnectionStatus): ConnectionStatusTone {
|
||||
switch (status) {
|
||||
case "online":
|
||||
return "success";
|
||||
case "connecting":
|
||||
return "warning";
|
||||
case "error":
|
||||
return "error";
|
||||
default:
|
||||
return "muted";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user