refactor(ui): improve footer controls layout and add scroll-to-bottom button
Simplify agent screen layout by removing redundant status bar container and consolidating all footer controls into the input area component. Add smooth fade animations for realtime mode transitions and a floating scroll-to-bottom button for better UX. Changes: - Consolidate status bar and controls into AgentInputArea for cleaner layout hierarchy - Add scroll-to-bottom button to AgentStreamView with fade animations - Fix realtime controls stacking on non-agent screens - Add lastActivityAt serialization to MCP server agent responses - Remove unnecessary layout wrapper in agent screen 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
import { AgentInputArea } from "@/components/agent-input-area";
|
import { AgentInputArea } from "@/components/agent-input-area";
|
||||||
import { AgentStatusBar } from "@/components/agent-status-bar";
|
|
||||||
import { useSession } from "@/contexts/session-context";
|
import { useSession } from "@/contexts/session-context";
|
||||||
import { useFooterControls } from "@/contexts/footer-controls-context";
|
import { useFooterControls } from "@/contexts/footer-controls-context";
|
||||||
|
|
||||||
@@ -89,22 +88,15 @@ export default function AgentScreen() {
|
|||||||
<Text style={styles.loadingText}>Loading agent...</Text>
|
<Text style={styles.loadingText}>Loading agent...</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<AgentStreamView
|
||||||
<AgentStreamView
|
agentId={id!}
|
||||||
agentId={id!}
|
agent={agent}
|
||||||
agent={agent}
|
streamItems={streamItems}
|
||||||
streamItems={streamItems}
|
pendingPermissions={agentPermissions}
|
||||||
pendingPermissions={agentPermissions}
|
onPermissionResponse={(requestId, optionId) =>
|
||||||
onPermissionResponse={(requestId, optionId) =>
|
respondToPermission(requestId, id!, agent.sessionId || "", [optionId])
|
||||||
respondToPermission(requestId, id!, agent.sessionId || "", [optionId])
|
}
|
||||||
}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Footer area - status bar pinned within screen */}
|
|
||||||
<View style={styles.footerContainer}>
|
|
||||||
<AgentStatusBar agentId={id!} />
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</ReanimatedAnimated.View>
|
</ReanimatedAnimated.View>
|
||||||
</View>
|
</View>
|
||||||
@@ -119,9 +111,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
footerContainer: {
|
|
||||||
backgroundColor: theme.colors.background,
|
|
||||||
},
|
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
withTiming,
|
withTiming,
|
||||||
|
FadeIn,
|
||||||
|
FadeOut,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSession } from "@/contexts/session-context";
|
import { useSession } from "@/contexts/session-context";
|
||||||
import { useRealtime } from "@/contexts/realtime-context";
|
import { useRealtime } from "@/contexts/realtime-context";
|
||||||
@@ -22,6 +24,8 @@ import { useAudioRecorder } from "@/hooks/use-audio-recorder";
|
|||||||
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
|
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
|
||||||
import { VoiceNoteRecordingOverlay } from "./voice-note-recording-overlay";
|
import { VoiceNoteRecordingOverlay } from "./voice-note-recording-overlay";
|
||||||
import { generateMessageId } from "@/types/stream";
|
import { generateMessageId } from "@/types/stream";
|
||||||
|
import { AgentStatusBar } from "./agent-status-bar";
|
||||||
|
import { RealtimeControls } from "./realtime-controls";
|
||||||
|
|
||||||
interface AgentInputAreaProps {
|
interface AgentInputAreaProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -34,7 +38,7 @@ const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2;
|
|||||||
export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { ws, sendAgentMessage, sendAgentAudio } = useSession();
|
const { ws, sendAgentMessage, sendAgentAudio } = useSession();
|
||||||
const { startRealtime } = useRealtime();
|
const { startRealtime, isRealtimeMode } = useRealtime();
|
||||||
|
|
||||||
const [userInput, setUserInput] = useState("");
|
const [userInput, setUserInput] = useState("");
|
||||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||||
@@ -257,121 +261,156 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Regular input controls */}
|
{/* Status bar */}
|
||||||
<Animated.View style={[styles.inputContainer, inputAnimatedStyle]}>
|
<View style={styles.statusBarContainer}>
|
||||||
{/* Image preview pills */}
|
<AgentStatusBar agentId={agentId} />
|
||||||
{hasImages && (
|
</View>
|
||||||
<View style={styles.imagePreviewContainer}>
|
|
||||||
{selectedImages.map((image, index) => (
|
|
||||||
<View key={`${image.uri}-${index}`} style={styles.imagePill}>
|
|
||||||
<Image source={{ uri: image.uri }} style={styles.imageThumbnail} />
|
|
||||||
<Pressable onPress={() => handleRemoveImage(index)} style={styles.removeImageButton}>
|
|
||||||
<X size={16} color={theme.colors.foreground} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Input row */}
|
{/* Border separator */}
|
||||||
<View style={styles.inputRow}>
|
<View style={styles.borderSeparator} />
|
||||||
{/* Attachment button */}
|
|
||||||
{!isRecording && (
|
{/* Realtime controls - only when active */}
|
||||||
<Pressable
|
{isRealtimeMode && (
|
||||||
onPress={handlePickImage}
|
<Animated.View
|
||||||
disabled={!ws.isConnected}
|
style={styles.realtimeControlsContainer}
|
||||||
style={[
|
entering={FadeIn.duration(250)}
|
||||||
styles.attachButton,
|
exiting={FadeOut.duration(250)}
|
||||||
!ws.isConnected && styles.buttonDisabled,
|
>
|
||||||
]}
|
<RealtimeControls />
|
||||||
>
|
</Animated.View>
|
||||||
<Paperclip size={20} color={theme.colors.foreground} />
|
)}
|
||||||
</Pressable>
|
|
||||||
|
{/* Input area */}
|
||||||
|
<View style={styles.inputAreaContainer}>
|
||||||
|
{/* Regular input controls */}
|
||||||
|
<Animated.View style={[styles.inputContainer, inputAnimatedStyle]}>
|
||||||
|
{/* Image preview pills */}
|
||||||
|
{hasImages && (
|
||||||
|
<View style={styles.imagePreviewContainer}>
|
||||||
|
{selectedImages.map((image, index) => (
|
||||||
|
<View key={`${image.uri}-${index}`} style={styles.imagePill}>
|
||||||
|
<Image source={{ uri: image.uri }} style={styles.imageThumbnail} />
|
||||||
|
<Pressable onPress={() => handleRemoveImage(index)} style={styles.removeImageButton}>
|
||||||
|
<X size={16} color={theme.colors.foreground} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Text input */}
|
{/* Input row */}
|
||||||
<TextInput
|
<View style={styles.inputRow}>
|
||||||
value={userInput}
|
{/* Attachment button */}
|
||||||
onChangeText={setUserInput}
|
{!isRecording && (
|
||||||
placeholder="Message agent..."
|
<Pressable
|
||||||
placeholderTextColor={theme.colors.mutedForeground}
|
onPress={handlePickImage}
|
||||||
style={[
|
disabled={!ws.isConnected}
|
||||||
styles.textInput,
|
style={[
|
||||||
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT },
|
styles.attachButton,
|
||||||
]}
|
!ws.isConnected && styles.buttonDisabled,
|
||||||
multiline
|
]}
|
||||||
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
|
>
|
||||||
onContentSizeChange={handleContentSizeChange}
|
<Paperclip size={20} color={theme.colors.foreground} />
|
||||||
editable={!isRecording && ws.isConnected}
|
</Pressable>
|
||||||
/>
|
)}
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Text input */}
|
||||||
<View style={styles.buttonRow}>
|
<TextInput
|
||||||
{hasText || hasImages ? (
|
value={userInput}
|
||||||
// Send button when text is entered or images are selected
|
onChangeText={setUserInput}
|
||||||
<Pressable
|
placeholder="Message agent..."
|
||||||
onPress={handleSendMessage}
|
placeholderTextColor={theme.colors.mutedForeground}
|
||||||
disabled={!ws.isConnected || isProcessing}
|
style={[
|
||||||
style={[
|
styles.textInput,
|
||||||
styles.sendButton,
|
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT },
|
||||||
(!ws.isConnected || isProcessing) && styles.buttonDisabled,
|
]}
|
||||||
]}
|
multiline
|
||||||
>
|
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
|
||||||
<ArrowUp size={20} color="white" />
|
onContentSizeChange={handleContentSizeChange}
|
||||||
</Pressable>
|
editable={!isRecording && ws.isConnected}
|
||||||
) : (
|
/>
|
||||||
// Voice and Realtime buttons when no text
|
|
||||||
<>
|
{/* Buttons */}
|
||||||
{/* Voice recording button */}
|
<View style={styles.buttonRow}>
|
||||||
|
{hasText || hasImages ? (
|
||||||
|
// Send button when text is entered or images are selected
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleVoicePress}
|
onPress={handleSendMessage}
|
||||||
disabled={!ws.isConnected}
|
disabled={!ws.isConnected || isProcessing}
|
||||||
style={[
|
style={[
|
||||||
styles.voiceButton,
|
styles.sendButton,
|
||||||
!ws.isConnected && styles.buttonDisabled,
|
(!ws.isConnected || isProcessing) && styles.buttonDisabled,
|
||||||
isRecording && styles.voiceButtonRecording,
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isRecording ? (
|
<ArrowUp size={20} color="white" />
|
||||||
<Square size={14} color="white" fill="white" />
|
|
||||||
) : (
|
|
||||||
<Mic size={20} color={theme.colors.foreground} />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
) : !isRealtimeMode ? (
|
||||||
|
// Voice and Realtime buttons when no text and not in realtime mode
|
||||||
|
<>
|
||||||
|
{/* Voice recording button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={handleVoicePress}
|
||||||
|
disabled={!ws.isConnected}
|
||||||
|
style={[
|
||||||
|
styles.voiceButton,
|
||||||
|
!ws.isConnected && styles.buttonDisabled,
|
||||||
|
isRecording && styles.voiceButtonRecording,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{isRecording ? (
|
||||||
|
<Square size={14} color="white" fill="white" />
|
||||||
|
) : (
|
||||||
|
<Mic size={20} color={theme.colors.foreground} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
{/* Realtime button */}
|
{/* Realtime button */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={startRealtime}
|
onPress={startRealtime}
|
||||||
disabled={!ws.isConnected}
|
disabled={!ws.isConnected}
|
||||||
style={[
|
style={[
|
||||||
styles.realtimeButton,
|
styles.realtimeButton,
|
||||||
!ws.isConnected && styles.buttonDisabled,
|
!ws.isConnected && styles.buttonDisabled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<AudioLines size={20} color={theme.colors.background} />
|
<AudioLines size={20} color={theme.colors.background} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Animated.View>
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Voice note recording overlay */}
|
{/* Voice note recording overlay */}
|
||||||
<Animated.View style={[styles.overlayContainer, overlayAnimatedStyle]}>
|
<Animated.View style={[styles.overlayContainer, overlayAnimatedStyle]}>
|
||||||
<VoiceNoteRecordingOverlay
|
<VoiceNoteRecordingOverlay
|
||||||
volume={recordingVolume}
|
volume={recordingVolume}
|
||||||
duration={recordingDuration}
|
duration={recordingDuration}
|
||||||
onCancel={handleCancelRecording}
|
onCancel={handleCancelRecording}
|
||||||
onSend={handleSendRecording}
|
onSend={handleSendRecording}
|
||||||
isTranscribing={transcribingRequestId !== null}
|
isTranscribing={transcribingRequestId !== null}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
statusBarContainer: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
borderSeparator: {
|
||||||
|
height: theme.borderWidth[1],
|
||||||
|
backgroundColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
realtimeControlsContainer: {
|
||||||
|
height: FOOTER_HEIGHT,
|
||||||
|
},
|
||||||
|
inputAreaContainer: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
minHeight: FOOTER_HEIGHT,
|
minHeight: FOOTER_HEIGHT,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { View, Text, ScrollView, Pressable } from "react-native";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
|
import { ChevronDown } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
UserMessage,
|
UserMessage,
|
||||||
@@ -64,9 +66,21 @@ export function AgentStreamView({
|
|||||||
contentSize.height - contentOffset.y - layoutMeasurement.height;
|
contentSize.height - contentOffset.y - layoutMeasurement.height;
|
||||||
// Consider user "at bottom" if within 10px of the end
|
// Consider user "at bottom" if within 10px of the end
|
||||||
const nearBottom = distanceFromBottom < 10;
|
const nearBottom = distanceFromBottom < 10;
|
||||||
|
console.log('[AgentStreamView] Scroll:', {
|
||||||
|
contentHeight: contentSize.height,
|
||||||
|
scrollY: contentOffset.y,
|
||||||
|
layoutHeight: layoutMeasurement.height,
|
||||||
|
distanceFromBottom,
|
||||||
|
nearBottom,
|
||||||
|
currentIsNearBottom: isNearBottom
|
||||||
|
});
|
||||||
setIsNearBottom(nearBottom);
|
setIsNearBottom(nearBottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={stylesheet.container}>
|
<View style={stylesheet.container}>
|
||||||
{/* Content list */}
|
{/* Content list */}
|
||||||
@@ -189,6 +203,22 @@ export function AgentStreamView({
|
|||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Scroll to bottom button */}
|
||||||
|
{!isNearBottom && (
|
||||||
|
<Animated.View
|
||||||
|
style={stylesheet.scrollToBottomContainer}
|
||||||
|
entering={FadeIn.duration(200)}
|
||||||
|
exiting={FadeOut.duration(200)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={stylesheet.scrollToBottomButton}
|
||||||
|
onPress={scrollToBottom}
|
||||||
|
>
|
||||||
|
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
<ToolCallBottomSheet
|
<ToolCallBottomSheet
|
||||||
bottomSheetRef={bottomSheetRef}
|
bottomSheetRef={bottomSheetRef}
|
||||||
selectedToolCall={selectedToolCall}
|
selectedToolCall={selectedToolCall}
|
||||||
@@ -365,6 +395,33 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
|
scrollToBottomContainer: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 16,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
pointerEvents: "box-none",
|
||||||
|
},
|
||||||
|
scrollToBottomButton: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: theme.colors.muted,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
scrollToBottomIcon: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const permissionStyles = StyleSheet.create((theme) => ({
|
const permissionStyles = StyleSheet.create((theme) => ({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { View, Pressable, Text } from "react-native";
|
import { View, Pressable, Text } from "react-native";
|
||||||
import { usePathname, useRouter } from "expo-router";
|
import { usePathname, useRouter } from "expo-router";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -13,8 +13,6 @@ import Animated, {
|
|||||||
FadeIn,
|
FadeIn,
|
||||||
FadeOut,
|
FadeOut,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
withTiming,
|
|
||||||
useSharedValue,
|
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||||
|
|
||||||
@@ -31,21 +29,12 @@ export function GlobalFooter() {
|
|||||||
// Determine current screen type
|
// Determine current screen type
|
||||||
const isAgentScreen = pathname?.startsWith("/agent/");
|
const isAgentScreen = pathname?.startsWith("/agent/");
|
||||||
|
|
||||||
const agentControlsHeight = useSharedValue(FOOTER_HEIGHT);
|
|
||||||
|
|
||||||
const hasRegisteredControls = !!controls;
|
const hasRegisteredControls = !!controls;
|
||||||
const showAgentControls = isAgentScreen && hasRegisteredControls;
|
const showAgentControls = isAgentScreen && hasRegisteredControls;
|
||||||
|
|
||||||
const transition = useSharedValue(isRealtimeMode ? 1 : 0);
|
|
||||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||||
const bottomInset = insets.bottom;
|
const bottomInset = insets.bottom;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (showAgentControls) {
|
|
||||||
transition.value = withTiming(isRealtimeMode ? 1 : 0, { duration: 250 });
|
|
||||||
}
|
|
||||||
}, [isRealtimeMode, showAgentControls]);
|
|
||||||
|
|
||||||
const keyboardAnimatedStyle = useAnimatedStyle(
|
const keyboardAnimatedStyle = useAnimatedStyle(
|
||||||
() => {
|
() => {
|
||||||
"worklet";
|
"worklet";
|
||||||
@@ -58,33 +47,6 @@ export function GlobalFooter() {
|
|||||||
[bottomInset],
|
[bottomInset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerAnimatedStyle = useAnimatedStyle(
|
|
||||||
() => {
|
|
||||||
"worklet";
|
|
||||||
const agentHeight = Math.max(agentControlsHeight.value, FOOTER_HEIGHT);
|
|
||||||
const expandedHeight = agentHeight - (agentHeight - FOOTER_HEIGHT) * transition.value;
|
|
||||||
|
|
||||||
return {
|
|
||||||
height: expandedHeight + bottomInset,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[bottomInset],
|
|
||||||
);
|
|
||||||
|
|
||||||
const realtimeAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
opacity: transition.value,
|
|
||||||
pointerEvents: transition.value > 0.5 ? ("auto" as const) : ("none" as const),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const agentControlsAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
opacity: 1 - transition.value,
|
|
||||||
pointerEvents: transition.value < 0.5 ? ("auto" as const) : ("none" as const),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (showAgentControls) {
|
if (showAgentControls) {
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -93,25 +55,10 @@ export function GlobalFooter() {
|
|||||||
{
|
{
|
||||||
paddingBottom: insets.bottom,
|
paddingBottom: insets.bottom,
|
||||||
},
|
},
|
||||||
containerAnimatedStyle,
|
|
||||||
keyboardAnimatedStyle,
|
keyboardAnimatedStyle,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.content}>
|
{controls}
|
||||||
<Animated.View
|
|
||||||
onLayout={(event) => {
|
|
||||||
agentControlsHeight.value = Math.max(event.nativeEvent.layout.height, FOOTER_HEIGHT);
|
|
||||||
}}
|
|
||||||
style={[styles.absoluteBottomFill, agentControlsAnimatedStyle]}
|
|
||||||
>
|
|
||||||
{controls}
|
|
||||||
</Animated.View>
|
|
||||||
<Animated.View
|
|
||||||
style={[styles.absoluteBottomFill, realtimeAnimatedStyle]}
|
|
||||||
>
|
|
||||||
<RealtimeControls />
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -120,76 +67,72 @@ export function GlobalFooter() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if realtime is active on non-agent screens
|
// For home and orchestrator screens, show three buttons with realtime stacked on top
|
||||||
if (isRealtimeMode) {
|
const nonAgentFooterHeight = isRealtimeMode
|
||||||
return (
|
? FOOTER_HEIGHT * 2 + insets.bottom
|
||||||
<Animated.View
|
: FOOTER_HEIGHT + insets.bottom;
|
||||||
style={[
|
|
||||||
styles.container,
|
|
||||||
{
|
|
||||||
paddingBottom: insets.bottom,
|
|
||||||
height: FOOTER_HEIGHT + insets.bottom,
|
|
||||||
},
|
|
||||||
keyboardAnimatedStyle,
|
|
||||||
]}
|
|
||||||
entering={FadeIn.duration(400)}
|
|
||||||
exiting={FadeOut.duration(250)}
|
|
||||||
>
|
|
||||||
<RealtimeControls />
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For home and orchestrator screens, show three buttons
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeIn.duration(250)}
|
|
||||||
exiting={FadeOut.duration(250)}
|
|
||||||
style={[
|
style={[
|
||||||
styles.container,
|
styles.container,
|
||||||
{
|
{
|
||||||
paddingBottom: insets.bottom,
|
paddingBottom: insets.bottom,
|
||||||
height: FOOTER_HEIGHT + insets.bottom,
|
height: nonAgentFooterHeight,
|
||||||
},
|
},
|
||||||
keyboardAnimatedStyle,
|
keyboardAnimatedStyle,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.threeButtonContainer}>
|
<View style={styles.nonAgentContent}>
|
||||||
<Pressable
|
{/* Realtime controls - only visible when active */}
|
||||||
onPress={() => router.push("/")}
|
{isRealtimeMode && (
|
||||||
style={({ pressed }) => [
|
<Animated.View
|
||||||
styles.footerButton,
|
style={styles.realtimeSection}
|
||||||
pressed && styles.buttonPressed,
|
entering={FadeIn.duration(250)}
|
||||||
]}
|
exiting={FadeOut.duration(250)}
|
||||||
>
|
>
|
||||||
<Users size={20} color={theme.colors.foreground} />
|
<RealtimeControls />
|
||||||
<Text style={styles.footerButtonText}>Agents</Text>
|
</Animated.View>
|
||||||
</Pressable>
|
)}
|
||||||
|
|
||||||
<Pressable
|
{/* Three button menu - always visible */}
|
||||||
onPress={() => setShowCreateModal(true)}
|
<View style={styles.threeButtonContainer}>
|
||||||
style={({ pressed }) => [
|
<Pressable
|
||||||
styles.footerButton,
|
onPress={() => router.push("/")}
|
||||||
pressed && styles.buttonPressed,
|
style={({ pressed }) => [
|
||||||
]}
|
styles.footerButton,
|
||||||
>
|
pressed && styles.buttonPressed,
|
||||||
<Plus size={20} color={theme.colors.foreground} />
|
]}
|
||||||
<Text style={styles.footerButtonText}>New Agent</Text>
|
>
|
||||||
</Pressable>
|
<Users size={20} color={theme.colors.foreground} />
|
||||||
|
<Text style={styles.footerButtonText}>Agents</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={startRealtime}
|
onPress={() => setShowCreateModal(true)}
|
||||||
disabled={!ws.isConnected}
|
style={({ pressed }) => [
|
||||||
style={({ pressed }) => [
|
styles.footerButton,
|
||||||
styles.footerButton,
|
pressed && styles.buttonPressed,
|
||||||
!ws.isConnected && styles.buttonDisabled,
|
]}
|
||||||
pressed && !ws.isConnected && styles.buttonPressed,
|
>
|
||||||
]}
|
<Plus size={20} color={theme.colors.foreground} />
|
||||||
>
|
<Text style={styles.footerButtonText}>New Agent</Text>
|
||||||
<AudioLines size={20} color={theme.colors.foreground} />
|
</Pressable>
|
||||||
<Text style={styles.footerButtonText}>Realtime</Text>
|
|
||||||
</Pressable>
|
<Pressable
|
||||||
|
onPress={startRealtime}
|
||||||
|
disabled={!ws.isConnected}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.footerButton,
|
||||||
|
!ws.isConnected && styles.buttonDisabled,
|
||||||
|
pressed && !ws.isConnected && styles.buttonPressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<AudioLines size={20} color={theme.colors.foreground} />
|
||||||
|
<Text style={styles.footerButtonText}>Realtime</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
@@ -207,20 +150,19 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderTopWidth: theme.borderWidth[1],
|
borderTopWidth: theme.borderWidth[1],
|
||||||
borderTopColor: theme.colors.border,
|
borderTopColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
content: {
|
nonAgentContent: {
|
||||||
flex: 1,
|
flexDirection: "column",
|
||||||
minHeight: FOOTER_HEIGHT,
|
|
||||||
},
|
},
|
||||||
absoluteBottomFill: {
|
realtimeSection: {
|
||||||
position: "absolute",
|
height: FOOTER_HEIGHT,
|
||||||
left: 0,
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
right: 0,
|
borderBottomColor: theme.colors.border,
|
||||||
bottom: 0,
|
|
||||||
},
|
},
|
||||||
threeButtonContainer: {
|
threeButtonContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
|
height: FOOTER_HEIGHT,
|
||||||
},
|
},
|
||||||
footerButton: {
|
footerButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -284,7 +284,7 @@
|
|||||||
"sessionId": "20b0b2c2-18ec-409b-b932-a20c9ca8e0d3"
|
"sessionId": "20b0b2c2-18ec-409b-b932-a20c9ca8e0d3"
|
||||||
},
|
},
|
||||||
"createdAt": "2025-10-26T09:45:29.563Z",
|
"createdAt": "2025-10-26T09:45:29.563Z",
|
||||||
"lastActivityAt": "2025-10-26T12:25:20.827Z",
|
"lastActivityAt": "2025-10-26T13:11:22.795Z",
|
||||||
"cwd": "/Users/moboudra/dev/voice-dev"
|
"cwd": "/Users/moboudra/dev/voice-dev"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -358,5 +358,29 @@
|
|||||||
"createdAt": "2025-10-26T12:25:48.081Z",
|
"createdAt": "2025-10-26T12:25:48.081Z",
|
||||||
"lastActivityAt": "2025-10-26T12:26:13.370Z",
|
"lastActivityAt": "2025-10-26T12:26:13.370Z",
|
||||||
"cwd": "/Users/moboudra/dev/voice-dev-gland"
|
"cwd": "/Users/moboudra/dev/voice-dev-gland"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d1ebfcdf-039a-459e-a918-13d290e26c85",
|
||||||
|
"title": "Add Floating Scroll-to-Bottom Button",
|
||||||
|
"sessionId": "9e5654e1-a459-43e9-8f6d-757208c8f2b5",
|
||||||
|
"options": {
|
||||||
|
"type": "claude",
|
||||||
|
"sessionId": "9e5654e1-a459-43e9-8f6d-757208c8f2b5"
|
||||||
|
},
|
||||||
|
"createdAt": "2025-10-26T13:31:29.627Z",
|
||||||
|
"lastActivityAt": "2025-10-26T13:32:14.171Z",
|
||||||
|
"cwd": "/Users/moboudra/dev/voice-dev"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "97423777-33d3-414a-ae79-ea303230010a",
|
||||||
|
"title": "Commit Local Changes",
|
||||||
|
"sessionId": "019a20d4-5fd4-76db-8021-697b8204597e",
|
||||||
|
"options": {
|
||||||
|
"type": "claude",
|
||||||
|
"sessionId": "addbd42f-dbb1-4536-bc2b-e70a64587de6"
|
||||||
|
},
|
||||||
|
"createdAt": "2025-10-26T14:02:59.844Z",
|
||||||
|
"lastActivityAt": "2025-10-26T14:03:22.696Z",
|
||||||
|
"cwd": "/Users/moboudra/dev/voice-dev"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -37,6 +37,7 @@ function serializeAgentInfo(info: any): any {
|
|||||||
return {
|
return {
|
||||||
...info,
|
...info,
|
||||||
createdAt: info.createdAt.toISOString(),
|
createdAt: info.createdAt.toISOString(),
|
||||||
|
lastActivityAt: info.lastActivityAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +213,7 @@ export async function createAgentMcpServer(
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
|
lastActivityAt: z.string(),
|
||||||
type: z.literal("claude"),
|
type: z.literal("claude"),
|
||||||
sessionId: z.string().nullable(),
|
sessionId: z.string().nullable(),
|
||||||
error: z.string().nullable(),
|
error: z.string().nullable(),
|
||||||
@@ -260,6 +262,7 @@ export async function createAgentMcpServer(
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
|
lastActivityAt: z.string(),
|
||||||
type: z.literal("claude"),
|
type: z.literal("claude"),
|
||||||
sessionId: z.string().nullable(),
|
sessionId: z.string().nullable(),
|
||||||
error: z.string().nullable(),
|
error: z.string().nullable(),
|
||||||
|
|||||||
Reference in New Issue
Block a user