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:
Mohamed Boudra
2025-10-26 15:03:33 +01:00
parent c4bb10d3c9
commit d47019919e
6 changed files with 292 additions and 238 deletions

View File

@@ -8,7 +8,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
import { AgentStatusBar } from "@/components/agent-status-bar";
import { useSession } from "@/contexts/session-context";
import { useFooterControls } from "@/contexts/footer-controls-context";
@@ -89,22 +88,15 @@ export default function AgentScreen() {
<Text style={styles.loadingText}>Loading agent...</Text>
</View>
) : (
<>
<AgentStreamView
agentId={id!}
agent={agent}
streamItems={streamItems}
pendingPermissions={agentPermissions}
onPermissionResponse={(requestId, optionId) =>
respondToPermission(requestId, id!, agent.sessionId || "", [optionId])
}
/>
{/* Footer area - status bar pinned within screen */}
<View style={styles.footerContainer}>
<AgentStatusBar agentId={id!} />
</View>
</>
<AgentStreamView
agentId={id!}
agent={agent}
streamItems={streamItems}
pendingPermissions={agentPermissions}
onPermissionResponse={(requestId, optionId) =>
respondToPermission(requestId, id!, agent.sessionId || "", [optionId])
}
/>
)}
</ReanimatedAnimated.View>
</View>
@@ -119,9 +111,6 @@ const styles = StyleSheet.create((theme) => ({
content: {
flex: 1,
},
footerContainer: {
backgroundColor: theme.colors.background,
},
loadingContainer: {
flex: 1,
alignItems: "center",

View File

@@ -15,6 +15,8 @@ import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
FadeIn,
FadeOut,
} from "react-native-reanimated";
import { useSession } from "@/contexts/session-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 { VoiceNoteRecordingOverlay } from "./voice-note-recording-overlay";
import { generateMessageId } from "@/types/stream";
import { AgentStatusBar } from "./agent-status-bar";
import { RealtimeControls } from "./realtime-controls";
interface AgentInputAreaProps {
agentId: string;
@@ -34,7 +38,7 @@ const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2;
export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const { theme } = useUnistyles();
const { ws, sendAgentMessage, sendAgentAudio } = useSession();
const { startRealtime } = useRealtime();
const { startRealtime, isRealtimeMode } = useRealtime();
const [userInput, setUserInput] = useState("");
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
@@ -257,121 +261,156 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
return (
<View style={styles.container}>
{/* 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>
)}
{/* Status bar */}
<View style={styles.statusBarContainer}>
<AgentStatusBar agentId={agentId} />
</View>
{/* Input row */}
<View style={styles.inputRow}>
{/* Attachment button */}
{!isRecording && (
<Pressable
onPress={handlePickImage}
disabled={!ws.isConnected}
style={[
styles.attachButton,
!ws.isConnected && styles.buttonDisabled,
]}
>
<Paperclip size={20} color={theme.colors.foreground} />
</Pressable>
{/* Border separator */}
<View style={styles.borderSeparator} />
{/* Realtime controls - only when active */}
{isRealtimeMode && (
<Animated.View
style={styles.realtimeControlsContainer}
entering={FadeIn.duration(250)}
exiting={FadeOut.duration(250)}
>
<RealtimeControls />
</Animated.View>
)}
{/* 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 */}
<TextInput
value={userInput}
onChangeText={setUserInput}
placeholder="Message agent..."
placeholderTextColor={theme.colors.mutedForeground}
style={[
styles.textInput,
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT },
]}
multiline
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
onContentSizeChange={handleContentSizeChange}
editable={!isRecording && ws.isConnected}
/>
{/* Input row */}
<View style={styles.inputRow}>
{/* Attachment button */}
{!isRecording && (
<Pressable
onPress={handlePickImage}
disabled={!ws.isConnected}
style={[
styles.attachButton,
!ws.isConnected && styles.buttonDisabled,
]}
>
<Paperclip size={20} color={theme.colors.foreground} />
</Pressable>
)}
{/* Buttons */}
<View style={styles.buttonRow}>
{hasText || hasImages ? (
// Send button when text is entered or images are selected
<Pressable
onPress={handleSendMessage}
disabled={!ws.isConnected || isProcessing}
style={[
styles.sendButton,
(!ws.isConnected || isProcessing) && styles.buttonDisabled,
]}
>
<ArrowUp size={20} color="white" />
</Pressable>
) : (
// Voice and Realtime buttons when no text
<>
{/* Voice recording button */}
{/* Text input */}
<TextInput
value={userInput}
onChangeText={setUserInput}
placeholder="Message agent..."
placeholderTextColor={theme.colors.mutedForeground}
style={[
styles.textInput,
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT },
]}
multiline
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
onContentSizeChange={handleContentSizeChange}
editable={!isRecording && ws.isConnected}
/>
{/* Buttons */}
<View style={styles.buttonRow}>
{hasText || hasImages ? (
// Send button when text is entered or images are selected
<Pressable
onPress={handleVoicePress}
disabled={!ws.isConnected}
onPress={handleSendMessage}
disabled={!ws.isConnected || isProcessing}
style={[
styles.voiceButton,
!ws.isConnected && styles.buttonDisabled,
isRecording && styles.voiceButtonRecording,
styles.sendButton,
(!ws.isConnected || isProcessing) && styles.buttonDisabled,
]}
>
{isRecording ? (
<Square size={14} color="white" fill="white" />
) : (
<Mic size={20} color={theme.colors.foreground} />
)}
<ArrowUp size={20} color="white" />
</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 */}
<Pressable
onPress={startRealtime}
disabled={!ws.isConnected}
style={[
styles.realtimeButton,
!ws.isConnected && styles.buttonDisabled,
]}
>
<AudioLines size={20} color={theme.colors.background} />
</Pressable>
</>
)}
{/* Realtime button */}
<Pressable
onPress={startRealtime}
disabled={!ws.isConnected}
style={[
styles.realtimeButton,
!ws.isConnected && styles.buttonDisabled,
]}
>
<AudioLines size={20} color={theme.colors.background} />
</Pressable>
</>
) : null}
</View>
</View>
</View>
</Animated.View>
</Animated.View>
{/* Voice note recording overlay */}
<Animated.View style={[styles.overlayContainer, overlayAnimatedStyle]}>
<VoiceNoteRecordingOverlay
volume={recordingVolume}
duration={recordingDuration}
onCancel={handleCancelRecording}
onSend={handleSendRecording}
isTranscribing={transcribingRequestId !== null}
/>
</Animated.View>
{/* Voice note recording overlay */}
<Animated.View style={[styles.overlayContainer, overlayAnimatedStyle]}>
<VoiceNoteRecordingOverlay
volume={recordingVolume}
duration={recordingDuration}
onCancel={handleCancelRecording}
onSend={handleSendRecording}
isTranscribing={transcribingRequestId !== null}
/>
</Animated.View>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "column",
},
statusBarContainer: {
backgroundColor: theme.colors.background,
},
borderSeparator: {
height: theme.borderWidth[1],
backgroundColor: theme.colors.border,
},
realtimeControlsContainer: {
height: FOOTER_HEIGHT,
},
inputAreaContainer: {
position: "relative",
minHeight: FOOTER_HEIGHT,
},

View File

@@ -3,6 +3,8 @@ import { View, Text, ScrollView, Pressable } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { ChevronDown } from "lucide-react-native";
import {
AssistantMessage,
UserMessage,
@@ -64,9 +66,21 @@ export function AgentStreamView({
contentSize.height - contentOffset.y - layoutMeasurement.height;
// Consider user "at bottom" if within 10px of the end
const nearBottom = distanceFromBottom < 10;
console.log('[AgentStreamView] Scroll:', {
contentHeight: contentSize.height,
scrollY: contentOffset.y,
layoutHeight: layoutMeasurement.height,
distanceFromBottom,
nearBottom,
currentIsNearBottom: isNearBottom
});
setIsNearBottom(nearBottom);
}
function scrollToBottom() {
scrollViewRef.current?.scrollToEnd({ animated: true });
}
return (
<View style={stylesheet.container}>
{/* Content list */}
@@ -189,6 +203,22 @@ export function AgentStreamView({
))}
</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
bottomSheetRef={bottomSheetRef}
selectedToolCall={selectedToolCall}
@@ -365,6 +395,33 @@ const stylesheet = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
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) => ({

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { View, Pressable, Text } from "react-native";
import { usePathname, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -13,8 +13,6 @@ import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
withTiming,
useSharedValue,
} from "react-native-reanimated";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
@@ -31,21 +29,12 @@ export function GlobalFooter() {
// Determine current screen type
const isAgentScreen = pathname?.startsWith("/agent/");
const agentControlsHeight = useSharedValue(FOOTER_HEIGHT);
const hasRegisteredControls = !!controls;
const showAgentControls = isAgentScreen && hasRegisteredControls;
const transition = useSharedValue(isRealtimeMode ? 1 : 0);
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = insets.bottom;
useEffect(() => {
if (showAgentControls) {
transition.value = withTiming(isRealtimeMode ? 1 : 0, { duration: 250 });
}
}, [isRealtimeMode, showAgentControls]);
const keyboardAnimatedStyle = useAnimatedStyle(
() => {
"worklet";
@@ -58,33 +47,6 @@ export function GlobalFooter() {
[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) {
return (
<Animated.View
@@ -93,25 +55,10 @@ export function GlobalFooter() {
{
paddingBottom: insets.bottom,
},
containerAnimatedStyle,
keyboardAnimatedStyle,
]}
>
<View style={styles.content}>
<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>
{controls}
</Animated.View>
);
}
@@ -120,76 +67,72 @@ export function GlobalFooter() {
return null;
}
// Determine if realtime is active on non-agent screens
if (isRealtimeMode) {
return (
<Animated.View
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 with realtime stacked on top
const nonAgentFooterHeight = isRealtimeMode
? FOOTER_HEIGHT * 2 + insets.bottom
: FOOTER_HEIGHT + insets.bottom;
// For home and orchestrator screens, show three buttons
return (
<>
<Animated.View
entering={FadeIn.duration(250)}
exiting={FadeOut.duration(250)}
style={[
styles.container,
{
paddingBottom: insets.bottom,
height: FOOTER_HEIGHT + insets.bottom,
height: nonAgentFooterHeight,
},
keyboardAnimatedStyle,
]}
>
<View style={styles.threeButtonContainer}>
<Pressable
onPress={() => router.push("/")}
style={({ pressed }) => [
styles.footerButton,
pressed && styles.buttonPressed,
]}
>
<Users size={20} color={theme.colors.foreground} />
<Text style={styles.footerButtonText}>Agents</Text>
</Pressable>
<View style={styles.nonAgentContent}>
{/* Realtime controls - only visible when active */}
{isRealtimeMode && (
<Animated.View
style={styles.realtimeSection}
entering={FadeIn.duration(250)}
exiting={FadeOut.duration(250)}
>
<RealtimeControls />
</Animated.View>
)}
<Pressable
onPress={() => setShowCreateModal(true)}
style={({ pressed }) => [
styles.footerButton,
pressed && styles.buttonPressed,
]}
>
<Plus size={20} color={theme.colors.foreground} />
<Text style={styles.footerButtonText}>New Agent</Text>
</Pressable>
{/* Three button menu - always visible */}
<View style={styles.threeButtonContainer}>
<Pressable
onPress={() => router.push("/")}
style={({ pressed }) => [
styles.footerButton,
pressed && styles.buttonPressed,
]}
>
<Users size={20} color={theme.colors.foreground} />
<Text style={styles.footerButtonText}>Agents</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>
<Pressable
onPress={() => setShowCreateModal(true)}
style={({ pressed }) => [
styles.footerButton,
pressed && styles.buttonPressed,
]}
>
<Plus size={20} color={theme.colors.foreground} />
<Text style={styles.footerButtonText}>New Agent</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>
</Animated.View>
@@ -207,20 +150,19 @@ const styles = StyleSheet.create((theme) => ({
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
},
content: {
flex: 1,
minHeight: FOOTER_HEIGHT,
nonAgentContent: {
flexDirection: "column",
},
absoluteBottomFill: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
realtimeSection: {
height: FOOTER_HEIGHT,
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
threeButtonContainer: {
flexDirection: "row",
padding: theme.spacing[4],
gap: theme.spacing[3],
height: FOOTER_HEIGHT,
},
footerButton: {
flex: 1,

View File

@@ -284,7 +284,7 @@
"sessionId": "20b0b2c2-18ec-409b-b932-a20c9ca8e0d3"
},
"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"
},
{
@@ -358,5 +358,29 @@
"createdAt": "2025-10-26T12:25:48.081Z",
"lastActivityAt": "2025-10-26T12:26:13.370Z",
"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"
}
]

View File

@@ -37,6 +37,7 @@ function serializeAgentInfo(info: any): any {
return {
...info,
createdAt: info.createdAt.toISOString(),
lastActivityAt: info.lastActivityAt.toISOString(),
};
}
@@ -212,6 +213,7 @@ export async function createAgentMcpServer(
id: z.string(),
status: z.string(),
createdAt: z.string(),
lastActivityAt: z.string(),
type: z.literal("claude"),
sessionId: z.string().nullable(),
error: z.string().nullable(),
@@ -260,6 +262,7 @@ export async function createAgentMcpServer(
id: z.string(),
status: z.string(),
createdAt: z.string(),
lastActivityAt: z.string(),
type: z.literal("claude"),
sessionId: z.string().nullable(),
error: z.string().nullable(),