From d47019919e8082b308b7cdd507fb01fabc808aad Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 26 Oct 2025 15:03:33 +0100 Subject: [PATCH] refactor(ui): improve footer controls layout and add scroll-to-bottom button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/app/src/app/agent/[id].tsx | 29 +-- .../app/src/components/agent-input-area.tsx | 235 ++++++++++-------- .../app/src/components/agent-stream-view.tsx | 57 +++++ packages/app/src/components/global-footer.tsx | 180 +++++--------- packages/server/agents.json | 26 +- packages/server/src/server/acp/mcp-server.ts | 3 + 6 files changed, 292 insertions(+), 238 deletions(-) diff --git a/packages/app/src/app/agent/[id].tsx b/packages/app/src/app/agent/[id].tsx index a9829cf06..90d105e78 100644 --- a/packages/app/src/app/agent/[id].tsx +++ b/packages/app/src/app/agent/[id].tsx @@ -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() { Loading agent... ) : ( - <> - - respondToPermission(requestId, id!, agent.sessionId || "", [optionId]) - } - /> - - {/* Footer area - status bar pinned within screen */} - - - - + + respondToPermission(requestId, id!, agent.sessionId || "", [optionId]) + } + /> )} @@ -119,9 +111,6 @@ const styles = StyleSheet.create((theme) => ({ content: { flex: 1, }, - footerContainer: { - backgroundColor: theme.colors.background, - }, loadingContainer: { flex: 1, alignItems: "center", diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 588143e80..1f0cdb147 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -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 ( - {/* Regular input controls */} - - {/* Image preview pills */} - {hasImages && ( - - {selectedImages.map((image, index) => ( - - - handleRemoveImage(index)} style={styles.removeImageButton}> - - - - ))} - - )} + {/* Status bar */} + + + - {/* Input row */} - - {/* Attachment button */} - {!isRecording && ( - - - + {/* Border separator */} + + + {/* Realtime controls - only when active */} + {isRealtimeMode && ( + + + + )} + + {/* Input area */} + + {/* Regular input controls */} + + {/* Image preview pills */} + {hasImages && ( + + {selectedImages.map((image, index) => ( + + + handleRemoveImage(index)} style={styles.removeImageButton}> + + + + ))} + )} - {/* Text input */} - = MAX_INPUT_HEIGHT} - onContentSizeChange={handleContentSizeChange} - editable={!isRecording && ws.isConnected} - /> + {/* Input row */} + + {/* Attachment button */} + {!isRecording && ( + + + + )} - {/* Buttons */} - - {hasText || hasImages ? ( - // Send button when text is entered or images are selected - - - - ) : ( - // Voice and Realtime buttons when no text - <> - {/* Voice recording button */} + {/* Text input */} + = MAX_INPUT_HEIGHT} + onContentSizeChange={handleContentSizeChange} + editable={!isRecording && ws.isConnected} + /> + + {/* Buttons */} + + {hasText || hasImages ? ( + // Send button when text is entered or images are selected - {isRecording ? ( - - ) : ( - - )} + + ) : !isRealtimeMode ? ( + // Voice and Realtime buttons when no text and not in realtime mode + <> + {/* Voice recording button */} + + {isRecording ? ( + + ) : ( + + )} + - {/* Realtime button */} - - - - - )} + {/* Realtime button */} + + + + + ) : null} + - - + - {/* Voice note recording overlay */} - - - + {/* Voice note recording overlay */} + + + + ); } 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, }, diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index d64a35486..2f8b689a2 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -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 ( {/* Content list */} @@ -189,6 +203,22 @@ export function AgentStreamView({ ))} + {/* Scroll to bottom button */} + {!isNearBottom && ( + + + + + + )} + ({ 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) => ({ diff --git a/packages/app/src/components/global-footer.tsx b/packages/app/src/components/global-footer.tsx index 35697de53..18b3783a3 100644 --- a/packages/app/src/components/global-footer.tsx +++ b/packages/app/src/components/global-footer.tsx @@ -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 ( - - { - agentControlsHeight.value = Math.max(event.nativeEvent.layout.height, FOOTER_HEIGHT); - }} - style={[styles.absoluteBottomFill, agentControlsAnimatedStyle]} - > - {controls} - - - - - + {controls} ); } @@ -120,76 +67,72 @@ export function GlobalFooter() { return null; } - // Determine if realtime is active on non-agent screens - if (isRealtimeMode) { - return ( - - - - ); - } + // 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 ( <> - - router.push("/")} - style={({ pressed }) => [ - styles.footerButton, - pressed && styles.buttonPressed, - ]} - > - - Agents - + + {/* Realtime controls - only visible when active */} + {isRealtimeMode && ( + + + + )} - setShowCreateModal(true)} - style={({ pressed }) => [ - styles.footerButton, - pressed && styles.buttonPressed, - ]} - > - - New Agent - + {/* Three button menu - always visible */} + + router.push("/")} + style={({ pressed }) => [ + styles.footerButton, + pressed && styles.buttonPressed, + ]} + > + + Agents + - [ - styles.footerButton, - !ws.isConnected && styles.buttonDisabled, - pressed && !ws.isConnected && styles.buttonPressed, - ]} - > - - Realtime - + setShowCreateModal(true)} + style={({ pressed }) => [ + styles.footerButton, + pressed && styles.buttonPressed, + ]} + > + + New Agent + + + [ + styles.footerButton, + !ws.isConnected && styles.buttonDisabled, + pressed && !ws.isConnected && styles.buttonPressed, + ]} + > + + Realtime + + @@ -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, diff --git a/packages/server/agents.json b/packages/server/agents.json index 4cdc710dc..1d53d7bb9 100644 --- a/packages/server/agents.json +++ b/packages/server/agents.json @@ -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" } ] \ No newline at end of file diff --git a/packages/server/src/server/acp/mcp-server.ts b/packages/server/src/server/acp/mcp-server.ts index 16aa7e00a..ed044f1e7 100644 --- a/packages/server/src/server/acp/mcp-server.ts +++ b/packages/server/src/server/acp/mcp-server.ts @@ -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(),