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(),