Remove primary host plumbing
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { useState, useEffect, useRef, useLayoutEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from "react";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, ArrowUp, AudioLines, Square, Paperclip, X, Pencil } from "lucide-react-native";
|
||||
import Animated, {
|
||||
@@ -31,6 +31,9 @@ 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";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import type { SessionContextValue, Agent } from "@/contexts/session-context";
|
||||
import type { SessionOutboundMessage } from "@server/server/messages";
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string;
|
||||
@@ -71,17 +74,35 @@ type TextAreaHandle = {
|
||||
|
||||
export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
ws,
|
||||
sendAgentMessage,
|
||||
sendAgentAudio,
|
||||
agents,
|
||||
cancelAgentRun,
|
||||
getDraftInput,
|
||||
saveDraftInput,
|
||||
queuedMessages: queuedMessagesByAgent,
|
||||
setQueuedMessages: setQueuedMessagesByAgent,
|
||||
} = useDaemonSession(serverId);
|
||||
const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true });
|
||||
const inertWebSocket = useMemo<UseWebSocketReturn>(
|
||||
() => ({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
conversationId: null,
|
||||
lastError: null,
|
||||
send: () => {},
|
||||
on: () => () => {},
|
||||
sendPing: () => {},
|
||||
sendUserMessage: () => {},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const noopSendAgentMessage = useCallback<SessionContextValue["sendAgentMessage"]>(async () => {}, []);
|
||||
const noopSendAgentAudio = useCallback<SessionContextValue["sendAgentAudio"]>(async () => {}, []);
|
||||
const noopCancelAgentRun = useCallback<SessionContextValue["cancelAgentRun"]>(() => {}, []);
|
||||
const noopGetDraftInput = useCallback<SessionContextValue["getDraftInput"]>(() => undefined, []);
|
||||
const noopSaveDraftInput = useCallback<SessionContextValue["saveDraftInput"]>(() => {}, []);
|
||||
const noopSetQueuedMessages = useCallback<SessionContextValue["setQueuedMessages"]>(() => {}, []);
|
||||
const ws = session?.ws ?? inertWebSocket;
|
||||
const sendAgentMessage = session?.sendAgentMessage ?? noopSendAgentMessage;
|
||||
const sendAgentAudio = session?.sendAgentAudio ?? noopSendAgentAudio;
|
||||
const agents = session?.agents ?? new Map<string, Agent>();
|
||||
const cancelAgentRun = session?.cancelAgentRun ?? noopCancelAgentRun;
|
||||
const getDraftInput = session?.getDraftInput ?? noopGetDraftInput;
|
||||
const saveDraftInput = session?.saveDraftInput ?? noopSaveDraftInput;
|
||||
const queuedMessagesByAgent = session?.queuedMessages ?? new Map<string, QueuedMessage[]>();
|
||||
const setQueuedMessagesByAgent = session?.setQueuedMessages ?? noopSetQueuedMessages;
|
||||
const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime();
|
||||
|
||||
const [userInput, setUserInput] = useState("");
|
||||
@@ -268,7 +289,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = ws.on("transcription_result", (message) => {
|
||||
const unsubscribe = ws.on("transcription_result", (message: SessionOutboundMessage) => {
|
||||
if (message.type !== "transcription_result") {
|
||||
return;
|
||||
}
|
||||
@@ -626,10 +647,12 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
useEffect(() => {
|
||||
const existing = getDraftInput(agentId);
|
||||
const isSameText = existing?.text === userInput;
|
||||
const existingImages = existing?.images ?? [];
|
||||
const existingImages: Array<{ uri: string; mimeType?: string }> = existing?.images ?? [];
|
||||
const isSameImages =
|
||||
existingImages.length === selectedImages.length &&
|
||||
existingImages.every((img, idx) => img.uri === selectedImages[idx]?.uri && img.mimeType === selectedImages[idx]?.mimeType);
|
||||
existingImages.every((img: { uri: string; mimeType?: string }, idx: number) => {
|
||||
return img.uri === selectedImages[idx]?.uri && img.mimeType === selectedImages[idx]?.mimeType;
|
||||
});
|
||||
|
||||
if (isSameText && isSameImages) {
|
||||
return;
|
||||
@@ -658,10 +681,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
if (isRealtimeMode) {
|
||||
await stopRealtime();
|
||||
} else {
|
||||
if (!ws.isConnected) {
|
||||
if (!ws.isConnected || !serverId) {
|
||||
return;
|
||||
}
|
||||
await startRealtime();
|
||||
await startRealtime(serverId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[AgentInput] Failed to toggle realtime mode:", error);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { ModeSelectorModal } from "./mode-selector-modal";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
import type { SessionContextValue, Agent } from "@/contexts/session-context";
|
||||
|
||||
interface AgentStatusBarProps {
|
||||
agentId: string;
|
||||
@@ -12,7 +13,10 @@ interface AgentStatusBarProps {
|
||||
|
||||
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { agents, setAgentMode } = useDaemonSession(serverId);
|
||||
const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true });
|
||||
const noopSetAgentMode = useCallback<SessionContextValue["setAgentMode"]>(() => {}, []);
|
||||
const agents = session?.agents ?? new Map<string, Agent>();
|
||||
const setAgentMode = session?.setAgentMode ?? noopSetAgentMode;
|
||||
const [showModeSelector, setShowModeSelector] = useState(false);
|
||||
|
||||
const agent = agents.get(agentId);
|
||||
|
||||
@@ -71,9 +71,24 @@ export function AgentStreamView({
|
||||
const isNearBottomRef = useRef(true);
|
||||
const isUserScrollingRef = useRef(false);
|
||||
const router = useRouter();
|
||||
const session = useDaemonSession(serverId);
|
||||
const { requestDirectoryListing, requestFilePreview, ws } = session;
|
||||
const resolvedServerId = serverId ?? session.serverId;
|
||||
const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true });
|
||||
const inertWebSocket = useMemo<UseWebSocketReturn>(
|
||||
() => ({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
conversationId: null,
|
||||
lastError: null,
|
||||
send: () => {},
|
||||
on: () => () => {},
|
||||
sendPing: () => {},
|
||||
sendUserMessage: () => {},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const ws = session?.ws ?? inertWebSocket;
|
||||
const requestDirectoryListing = session?.requestDirectoryListing ?? (() => {});
|
||||
const requestFilePreview = session?.requestFilePreview ?? (() => {});
|
||||
const resolvedServerId = serverId ?? session?.serverId ?? agent.serverId ?? "";
|
||||
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||
// tracked in react-native-reanimated#8422.
|
||||
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
||||
|
||||
@@ -62,7 +62,7 @@ import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||
import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/messages";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { trackAnalyticsEvent } from "@/utils/analytics";
|
||||
import { useSession, type SessionContextValue } from "@/contexts/session-context";
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
import { useSessionDirectory, useSessionForServer } from "@/hooks/use-session-directory";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
|
||||
@@ -224,10 +224,8 @@ function AgentFlowModal({
|
||||
return daemonEntries[0]?.daemon.id ?? null;
|
||||
}, [serverId, daemonEntries]);
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(initialServerId);
|
||||
const activeSession = useSession();
|
||||
const selectedSession = useSessionForServer(selectedServerId);
|
||||
const session =
|
||||
selectedServerId === null ? activeSession : selectedSession ?? null;
|
||||
const session = selectedSession ?? null;
|
||||
const sessionDirectory = useSessionDirectory();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { View, Pressable, Text, Platform } from "react-native";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { View, Pressable, Text, Platform, Modal, Alert } 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, Download } from "lucide-react-native";
|
||||
import { useRealtime } from "@/contexts/realtime-context";
|
||||
import { useSession } from "@/contexts/session-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useFooterControls, FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
|
||||
import { RealtimeControls } from "./realtime-controls";
|
||||
import { CreateAgentModal, ImportAgentModal } from "./create-agent-modal";
|
||||
@@ -22,10 +22,11 @@ export function GlobalFooter() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { isRealtimeMode, startRealtime } = useRealtime();
|
||||
const { ws } = useSession();
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const { controls } = useFooterControls();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showRealtimeHostPicker, setShowRealtimeHostPicker] = 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";
|
||||
@@ -53,6 +54,40 @@ export function GlobalFooter() {
|
||||
[bottomInset],
|
||||
);
|
||||
|
||||
const realtimeEligibleHosts = useMemo(() => {
|
||||
return Array.from(connectionStates.values()).filter((entry) => entry.status === "online");
|
||||
}, [connectionStates]);
|
||||
|
||||
const handleStartRealtime = useCallback(() => {
|
||||
if (realtimeEligibleHosts.length === 0) {
|
||||
Alert.alert("No connected hosts", "Connect a host before starting realtime mode.");
|
||||
return;
|
||||
}
|
||||
if (realtimeEligibleHosts.length === 1) {
|
||||
void startRealtime(realtimeEligibleHosts[0].daemon.id).catch((error) => {
|
||||
console.error("[GlobalFooter] Failed to start realtime", error);
|
||||
Alert.alert("Realtime failed", "Unable to start realtime mode for this host.");
|
||||
});
|
||||
return;
|
||||
}
|
||||
setShowRealtimeHostPicker(true);
|
||||
}, [realtimeEligibleHosts, startRealtime]);
|
||||
|
||||
const handleSelectRealtimeHost = useCallback(
|
||||
(daemonId: string) => {
|
||||
setShowRealtimeHostPicker(false);
|
||||
void startRealtime(daemonId).catch((error) => {
|
||||
console.error("[GlobalFooter] Failed to start realtime", error);
|
||||
Alert.alert("Realtime failed", "Unable to start realtime mode for this host.");
|
||||
});
|
||||
},
|
||||
[startRealtime]
|
||||
);
|
||||
|
||||
const handleDismissHostPicker = useCallback(() => {
|
||||
setShowRealtimeHostPicker(false);
|
||||
}, []);
|
||||
|
||||
if (showAgentControls) {
|
||||
return (
|
||||
<Animated.View
|
||||
@@ -162,12 +197,12 @@ export function GlobalFooter() {
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={startRealtime}
|
||||
disabled={!ws.isConnected}
|
||||
onPress={handleStartRealtime}
|
||||
disabled={realtimeEligibleHosts.length === 0}
|
||||
style={({ pressed }) => [
|
||||
styles.footerButton,
|
||||
!ws.isConnected && styles.buttonDisabled,
|
||||
pressed && !ws.isConnected && styles.buttonPressed,
|
||||
realtimeEligibleHosts.length === 0 && styles.buttonDisabled,
|
||||
pressed && realtimeEligibleHosts.length === 0 && styles.buttonPressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.footerIconWrapper}>
|
||||
@@ -191,6 +226,31 @@ export function GlobalFooter() {
|
||||
isVisible={showImportModal}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
/>
|
||||
<Modal
|
||||
visible={showRealtimeHostPicker}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={handleDismissHostPicker}
|
||||
>
|
||||
<View style={styles.hostPickerOverlay}>
|
||||
<Pressable style={styles.hostPickerBackdrop} onPress={handleDismissHostPicker} />
|
||||
<View style={styles.hostPickerContainer}>
|
||||
<Text style={styles.hostPickerTitle}>Choose a host</Text>
|
||||
{realtimeEligibleHosts.map((entry) => (
|
||||
<Pressable
|
||||
key={entry.daemon.id}
|
||||
style={styles.hostPickerButton}
|
||||
onPress={() => handleSelectRealtimeHost(entry.daemon.id)}
|
||||
>
|
||||
<Text style={styles.hostPickerButtonText}>{entry.daemon.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<Pressable style={styles.hostPickerCancel} onPress={handleDismissHostPicker}>
|
||||
<Text style={styles.hostPickerCancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -240,4 +300,43 @@ const styles = StyleSheet.create((theme) => ({
|
||||
buttonPressed: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
hostPickerOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
hostPickerBackdrop: {
|
||||
flex: 1,
|
||||
},
|
||||
hostPickerContainer: {
|
||||
backgroundColor: theme.colors.card,
|
||||
padding: theme.spacing[4],
|
||||
borderTopLeftRadius: theme.borderRadius.xl,
|
||||
borderTopRightRadius: theme.borderRadius.xl,
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
hostPickerTitle: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: "center",
|
||||
},
|
||||
hostPickerButton: {
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
hostPickerButtonText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
hostPickerCancel: {
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
hostPickerCancelText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
import { useEffect, useMemo, useRef } 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, sessionAccessorRoles } = useDaemonConnections();
|
||||
const autoConnectStatesRef = useRef<Map<string, boolean>>(new Map());
|
||||
|
||||
const primaryDaemonIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
sessionAccessorRoles.forEach((roles, daemonId) => {
|
||||
if (roles.has("primary")) {
|
||||
ids.add(daemonId);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}, [sessionAccessorRoles]);
|
||||
|
||||
useEffect(() => {
|
||||
const trackedStates = autoConnectStatesRef.current;
|
||||
const activeIds = new Set<string>();
|
||||
@@ -44,38 +32,18 @@ export function MultiDaemonSessionHost() {
|
||||
}
|
||||
}, [daemons]);
|
||||
|
||||
const backgroundDaemons = useMemo<DaemonProfile[]>(() => {
|
||||
const shouldConnect = new Map<string, DaemonProfile>();
|
||||
const connectedDaemons = useMemo<DaemonProfile[]>(() => {
|
||||
return daemons.filter((daemon) => daemon.autoConnect !== false);
|
||||
}, [daemons]);
|
||||
|
||||
for (const daemon of daemons) {
|
||||
if (!daemon.autoConnect) {
|
||||
continue;
|
||||
}
|
||||
if (daemon.id === activeDaemonId) {
|
||||
continue;
|
||||
}
|
||||
if (primaryDaemonIds.has(daemon.id)) {
|
||||
continue;
|
||||
}
|
||||
shouldConnect.set(daemon.id, daemon);
|
||||
}
|
||||
|
||||
return Array.from(shouldConnect.values());
|
||||
}, [daemons, activeDaemonId, primaryDaemonIds]);
|
||||
|
||||
if (backgroundDaemons.length === 0) {
|
||||
if (connectedDaemons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{backgroundDaemons.map((daemon) => (
|
||||
<SessionProvider
|
||||
key={daemon.id}
|
||||
serverUrl={daemon.wsUrl}
|
||||
serverId={daemon.id}
|
||||
role="background"
|
||||
>
|
||||
{connectedDaemons.map((daemon) => (
|
||||
<SessionProvider key={daemon.id} serverUrl={daemon.wsUrl} serverId={daemon.id}>
|
||||
{null}
|
||||
</SessionProvider>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { MicOff, Square } from "lucide-react-native";
|
||||
import { VolumeMeter } from "./volume-meter";
|
||||
import { useRealtime } from "@/contexts/realtime-context";
|
||||
import { useSession } from "@/contexts/session-context";
|
||||
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
|
||||
|
||||
const CONTROL_BUTTON_SIZE = 48;
|
||||
@@ -11,7 +10,6 @@ const VERTICAL_PADDING = (FOOTER_HEIGHT - CONTROL_BUTTON_SIZE) / 2;
|
||||
|
||||
export function RealtimeControls() {
|
||||
const { theme } = useUnistyles();
|
||||
const { audioPlayer } = useSession();
|
||||
const {
|
||||
volume,
|
||||
isMuted,
|
||||
@@ -23,7 +21,6 @@ export function RealtimeControls() {
|
||||
} = useRealtime();
|
||||
|
||||
function handleStop() {
|
||||
audioPlayer.stop();
|
||||
stopRealtime();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user