Update files

This commit is contained in:
Mohamed Boudra
2026-02-07 14:06:11 +07:00
parent cef7d8c134
commit 3cc7feab21
19 changed files with 556 additions and 630 deletions
@@ -4,10 +4,11 @@ import {
Text,
ActivityIndicator,
Platform,
Alert,
} from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowUp, Square, Pencil } from "lucide-react-native";
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -30,6 +31,7 @@ import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { focusWithRetries } from "@/utils/web-focus";
import { useVoiceOptional } from "@/contexts/voice-context";
type QueuedMessage = {
id: string;
@@ -76,6 +78,7 @@ export function AgentInputArea({
const client = useSessionStore(
(state) => state.sessions[serverId]?.client ?? null
);
const voice = useVoiceOptional();
const isConnected = client?.isConnected ?? false;
const agent = useSessionStore((state) =>
@@ -411,6 +414,29 @@ export function AgentInputArea({
messageInputRef.current?.focus();
}
const isVoiceModeForAgent =
voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
const handleToggleRealtimeVoice = useCallback(() => {
if (!voice || !isConnected) {
return;
}
if (voice.isVoiceSwitching) {
return;
}
if (voice.isVoiceModeForAgent(serverId, agentId)) {
void voice.stopVoice().catch((error) => {
console.error("[AgentInputArea] Failed to stop voice mode", error);
Alert.alert("Voice failed", "Unable to stop realtime voice mode.");
});
return;
}
void voice.startVoice(serverId, agentId).catch((error) => {
console.error("[AgentInputArea] Failed to start voice mode", error);
Alert.alert("Voice failed", "Unable to start realtime voice mode.");
});
}, [agentId, isConnected, serverId, voice]);
function handleEditQueuedMessage(id: string) {
const item = queuedMessages.find((q) => q.id === id);
if (!item) return;
@@ -494,7 +520,7 @@ export function AgentInputArea({
]
);
const rightContent = isAgentRunning ? (
const cancelButton = isAgentRunning ? (
<Pressable
onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent}
@@ -515,6 +541,35 @@ export function AgentInputArea({
</Pressable>
) : null;
const rightContent = (
<View style={styles.rightControls}>
<Pressable
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityLabel={
isVoiceModeForAgent ? "Disable realtime voice mode" : "Enable realtime voice mode"
}
accessibilityRole="button"
style={[
styles.realtimeVoiceButton as any,
isVoiceModeForAgent ? (styles.realtimeVoiceButtonActive as any) : undefined,
(!isConnected || voice?.isVoiceSwitching
? styles.buttonDisabled
: undefined) as any,
]}
>
{voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : isVoiceModeForAgent ? (
<Square size={16} color="white" fill="white" />
) : (
<AudioLines size={18} color={theme.colors.foreground} />
)}
</Pressable>
{cancelButton}
</View>
);
const leftContent = <AgentStatusBar agentId={agentId} serverId={serverId} />;
return (
@@ -588,6 +643,8 @@ export function AgentInputArea({
isScreenFocused={isScreenFocused}
leftContent={leftContent}
rightContent={rightContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
onQueue={handleQueue}
onKeyPress={handleCommandKeyPress}
@@ -631,6 +688,25 @@ const styles = StyleSheet.create(((theme: Theme) => ({
alignItems: "center",
justifyContent: "center",
},
rightControls: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
realtimeVoiceButton: {
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButtonActive: {
backgroundColor: theme.colors.palette.green[600],
borderColor: theme.colors.palette.green[800],
},
buttonDisabled: {
opacity: 0.5,
},
@@ -102,7 +102,7 @@ export function AgentStreamView({
const flatListRef = useRef<FlatList<StreamItem>>(null);
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const { isVoiceMode } = useVoice();
const { isVoiceModeForAgent } = useVoice();
const [isNearBottom, setIsNearBottom] = useState(true);
const hasScrolledInitially = useRef(false);
const hasAutoScrolledOnce = useRef(false);
@@ -114,6 +114,7 @@ export function AgentStreamView({
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? agent.serverId ?? "";
const isVoiceMode = isVoiceModeForAgent(resolvedServerId, agentId);
const client = useSessionStore(
(state) => state.sessions[resolvedServerId]?.client ?? null
+12 -3
View File
@@ -61,6 +61,8 @@ export interface MessageInputProps {
leftContent?: React.ReactNode;
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
rightContent?: React.ReactNode;
voiceServerId?: string;
voiceAgentId?: string;
/** When true and there's sendable content, calls onQueue instead of onSubmit */
isAgentRunning?: boolean;
/** Callback for queue button when agent is running */
@@ -117,6 +119,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isScreenFocused = true,
leftContent,
rightContent,
voiceServerId,
voiceAgentId,
isAgentRunning = false,
onQueue,
onKeyPress: onKeyPressCallback,
@@ -310,15 +314,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}));
const handleVoicePress = useCallback(async () => {
const isRealtimeVoiceForCurrentAgent =
voice &&
!!voiceServerId &&
!!voiceAgentId &&
voice.isVoiceModeForAgent(voiceServerId, voiceAgentId);
if (isDictating) {
await cancelDictation();
} else {
if (voice?.isVoiceMode) {
if (isRealtimeVoiceForCurrentAgent) {
await voice.stopVoice();
}
await startDictation();
}
}, [isDictating, cancelDictation, startDictation, voice]);
}, [isDictating, cancelDictation, startDictation, voice, voiceAgentId, voiceServerId]);
const handleCancelRecording = useCallback(async () => {
await cancelDictation();
@@ -626,6 +635,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
<Mic size={20} color={theme.colors.foreground} />
)}
</Pressable>
{rightContent}
{shouldShowSendButton && (
<Pressable
onPress={handleSendMessage}
@@ -647,7 +657,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
<ArrowUp size={20} color="white" />
</Pressable>
)}
{!shouldShowSendButton && rightContent}
</View>
</View>
</Animated.View>
+2 -198
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState, useEffect } from "react";
import { View, Pressable, Text, Platform, Alert, ActivityIndicator } from "react-native";
import { View, Pressable, Text, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
@@ -9,21 +9,17 @@ import Animated, {
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Plus, Settings, Users, AudioLines } from "lucide-react-native";
import { Plus, Settings, Users } from "lucide-react-native";
import { router } from "expo-router";
import { usePanelStore } from "@/stores/panel-store";
import { GroupedAgentList } from "./grouped-agent-list";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useVoice } from "@/contexts/voice-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { VoicePanel } from "./voice-panel";
import { useSidebarAgentSections } from "@/hooks/use-sidebar-agent-sections";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
const DESKTOP_SIDEBAR_WIDTH = 320;
const SIDEBAR_AGENT_LIMIT = 50; // temporary for screenshots
@@ -56,9 +52,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
} = useSidebarAnimation();
const trafficLightPadding = useTrafficLightPadding();
const dragHandlers = useTauriDragHandlers();
const { connectionStates } = useDaemonConnections();
const { isVoiceMode, isVoiceSwitching, startVoice, stopVoice } = useVoice();
const [showVoiceHostPicker, setShowVoiceHostPicker] = useState(false);
// Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -142,65 +135,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
router.push("/agents");
}, [backdropOpacity, closeToAgent, isMobile, translateX, windowWidth]);
const voiceEligibleHosts = useMemo(() => {
return Array.from(connectionStates.values()).filter((entry) => entry.status === "online");
}, [connectionStates]);
const hasAnyConfiguredHosts = connectionStates.size > 0;
const handleToggleVoice = useCallback(() => {
if (isVoiceSwitching) {
return;
}
if (isVoiceMode) {
void stopVoice().catch((error) => {
console.error("[SlidingSidebar] Failed to stop voice", error);
Alert.alert("Voice failed", "Unable to stop voice mode.");
});
return;
}
if (voiceEligibleHosts.length === 0) {
if (!hasAnyConfiguredHosts) {
Alert.alert(
"No hosts available",
"Add a host in Settings before starting Voice mode.",
[{ text: "Open Settings", onPress: () => router.push("/settings") }, { text: "OK" }]
);
return;
}
Alert.alert(
"Hosts reconnecting",
"Every host is offline right now. Paseo reconnects automatically—try Voice mode again once one comes online."
);
return;
}
if (voiceEligibleHosts.length === 1) {
void startVoice(voiceEligibleHosts[0].daemon.serverId).catch((error) => {
console.error("[SlidingSidebar] Failed to start voice", error);
Alert.alert("Voice failed", "Unable to start Voice mode for this host.");
});
return;
}
setShowVoiceHostPicker(true);
}, [hasAnyConfiguredHosts, isVoiceMode, isVoiceSwitching, startVoice, stopVoice, voiceEligibleHosts]);
const handleSelectVoiceHost = useCallback(
(serverId: string) => {
setShowVoiceHostPicker(false);
void startVoice(serverId).catch((error) => {
console.error("[SlidingSidebar] Failed to start voice", error);
Alert.alert("Voice failed", "Unable to start Voice mode for this host.");
});
},
[startVoice]
);
const handleDismissVoiceHostPicker = useCallback(() => {
setShowVoiceHostPicker(false);
}, []);
// Close gesture (swipe left to close when sidebar is open)
// Only activates on leftward swipe, fails on rightward or vertical movement
// This mirrors the explorer-sidebar pattern for the right sidebar
@@ -301,8 +235,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
parentGestureRef={closeGestureRef}
/>
{isVoiceMode ? <VoicePanel /> : null}
{/* Footer */}
<View style={styles.sidebarFooter}>
<Pressable
@@ -319,43 +251,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
)}
</Pressable>
<View style={styles.footerIconRow}>
<Pressable
style={styles.footerIconButton}
testID="sidebar-voice"
nativeID="sidebar-voice"
collapsable={false}
accessible
accessibilityLabel="Voice"
accessibilityRole="button"
disabled={isVoiceSwitching}
onPress={handleToggleVoice}
>
{({ hovered }) => (
isVoiceSwitching ? (
<ActivityIndicator
size="small"
color={
isVoiceMode
? theme.colors.foreground
: hovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
) : (
<AudioLines
size={20}
color={
isVoiceMode
? theme.colors.foreground
: hovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)
)}
</Pressable>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
@@ -375,32 +270,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</View>
</Animated.View>
</GestureDetector>
<AdaptiveModalSheet
title="Choose a host"
visible={showVoiceHostPicker}
onClose={handleDismissVoiceHostPicker}
snapPoints={["45%", "70%"]}
testID="voice-host-picker"
>
<View style={styles.hostPickerList}>
{voiceEligibleHosts.map((entry) => (
<Pressable
key={entry.daemon.serverId}
style={styles.hostPickerOption}
onPress={() => handleSelectVoiceHost(entry.daemon.serverId)}
>
<Text style={styles.hostPickerOptionText}>{entry.daemon.label}</Text>
</Pressable>
))}
<Pressable
style={styles.hostPickerCancel}
onPress={handleDismissVoiceHostPicker}
>
<Text style={styles.hostPickerCancelText}>Cancel</Text>
</Pressable>
</View>
</AdaptiveModalSheet>
</View>
);
}
@@ -439,8 +308,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
selectedAgentId={selectedAgentId}
/>
{isVoiceMode ? <VoicePanel /> : null}
{/* Footer */}
<View style={styles.sidebarFooter}>
<Pressable
@@ -457,43 +324,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
)}
</Pressable>
<View style={styles.footerIconRow}>
<Pressable
style={styles.footerIconButton}
testID="sidebar-voice"
nativeID="sidebar-voice"
collapsable={false}
accessible
accessibilityLabel="Voice"
accessibilityRole="button"
disabled={isVoiceSwitching}
onPress={handleToggleVoice}
>
{({ hovered }) => (
isVoiceSwitching ? (
<ActivityIndicator
size="small"
color={
isVoiceMode
? theme.colors.foreground
: hovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
) : (
<AudioLines
size={20}
color={
isVoiceMode
? theme.colors.foreground
: hovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)
)}
</Pressable>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
@@ -510,32 +340,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</Pressable>
</View>
</View>
<AdaptiveModalSheet
title="Choose a host"
visible={showVoiceHostPicker}
onClose={handleDismissVoiceHostPicker}
snapPoints={["45%", "70%"]}
testID="voice-host-picker"
>
<View style={styles.hostPickerList}>
{voiceEligibleHosts.map((entry) => (
<Pressable
key={entry.daemon.serverId}
style={styles.hostPickerOption}
onPress={() => handleSelectVoiceHost(entry.daemon.serverId)}
>
<Text style={styles.hostPickerOptionText}>{entry.daemon.label}</Text>
</Pressable>
))}
<Pressable
style={styles.hostPickerCancel}
onPress={handleDismissVoiceHostPicker}
>
<Text style={styles.hostPickerCancelText}>Cancel</Text>
</Pressable>
</View>
</AdaptiveModalSheet>
</View>
);
}