diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 983456c98..143c2ccf2 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -9,6 +9,7 @@ import { useSettings } from "@/hooks/use-settings";
import { View, ActivityIndicator } from "react-native";
import { GlobalFooter } from "@/components/global-footer";
import { useUnistyles } from "react-native-unistyles";
+import { FooterControlsProvider } from "@/contexts/footer-controls-context";
function AppContainer({ children }: { children: React.ReactNode }) {
const { theme } = useUnistyles();
@@ -52,26 +53,28 @@ export default function RootLayout() {
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/app/agent/[id].tsx b/packages/app/src/app/agent/[id].tsx
index eab03d0a1..a198f9e6b 100644
--- a/packages/app/src/app/agent/[id].tsx
+++ b/packages/app/src/app/agent/[id].tsx
@@ -1,31 +1,34 @@
+import { useEffect, useMemo } from "react";
import { View, Text } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
-import ReanimatedAnimated, { useAnimatedStyle, FadeIn } from "react-native-reanimated";
+import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated";
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 { useRealtime } from "@/contexts/realtime-context";
+import { FOOTER_HEIGHT, useFooterControls } from "@/contexts/footer-controls-context";
export default function AgentScreen() {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const { id } = useLocalSearchParams<{ id: string }>();
const { agents, agentStreamState, pendingPermissions, respondToPermission } = useSession();
- const { isRealtimeMode } = useRealtime();
+ const { registerFooterControls, unregisterFooterControls } = useFooterControls();
// Keyboard animation
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
+ const footerHeight = FOOTER_HEIGHT + insets.bottom;
+ const bottomInset = insets.bottom;
const animatedKeyboardStyle = useAnimatedStyle(() => {
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
- const padding = Math.max(0, absoluteHeight - insets.bottom);
+ const padding = Math.max(0, absoluteHeight - bottomInset);
return {
- paddingBottom: padding,
+ paddingBottom: footerHeight + padding,
};
});
@@ -35,6 +38,24 @@ export default function AgentScreen() {
Array.from(pendingPermissions.entries()).filter(([_, perm]) => perm.agentId === id)
);
+ const agentControls = useMemo(() => {
+ if (!id) return null;
+ return ;
+ }, [id]);
+
+ useEffect(() => {
+ if (!agentControls || !agent) {
+ unregisterFooterControls();
+ return;
+ }
+
+ registerFooterControls(agentControls);
+
+ return () => {
+ unregisterFooterControls();
+ };
+ }, [agentControls, agent, registerFooterControls, unregisterFooterControls]);
+
if (!agent) {
return (
@@ -63,16 +84,9 @@ export default function AgentScreen() {
}
/>
- {/* Footer area - status bar + controls */}
-
- {/* Status bar - always visible, floating above controls */}
+ {/* Footer area - status bar pinned within screen */}
+
-
- {/* Controls - AgentInputArea always mounted, fades in/out */}
- {/* When in realtime mode, GlobalFooter's RealtimeControls overlays this */}
-
-
-
@@ -89,10 +103,7 @@ const styles = StyleSheet.create((theme) => ({
},
footerContainer: {
backgroundColor: theme.colors.background,
- },
- controlsContainer: {
- borderTopWidth: theme.borderWidth[1],
- borderTopColor: theme.colors.border,
+ paddingBottom: theme.spacing[6],
},
errorContainer: {
flex: 1,
diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx
index d0236c850..7d084d8ee 100644
--- a/packages/app/src/components/agent-input-area.tsx
+++ b/packages/app/src/components/agent-input-area.tsx
@@ -1,22 +1,16 @@
import { View, TextInput, Pressable } from "react-native";
-import { useState, useEffect } from "react";
+import { useState } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, ArrowUp, AudioLines, Square } from "lucide-react-native";
import { useSession } from "@/contexts/session-context";
import { useRealtime } from "@/contexts/realtime-context";
import { useAudioRecorder } from "@/hooks/use-audio-recorder";
-import Animated, {
- useAnimatedStyle,
- withTiming,
- useSharedValue,
-} from "react-native-reanimated";
interface AgentInputAreaProps {
agentId: string;
- isRealtimeMode: boolean;
}
-export function AgentInputArea({ agentId, isRealtimeMode }: AgentInputAreaProps) {
+export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const { theme } = useUnistyles();
const { ws, sendAgentMessage, sendAgentAudio } = useSession();
const { startRealtime } = useRealtime();
@@ -26,23 +20,6 @@ export function AgentInputArea({ agentId, isRealtimeMode }: AgentInputAreaProps)
const [isRecording, setIsRecording] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
- // Animated opacity for smooth transitions
- const opacity = useSharedValue(isRealtimeMode ? 0 : 1);
-
- useEffect(() => {
- opacity.value = withTiming(isRealtimeMode ? 0 : 1, { duration: 250 });
- }, [isRealtimeMode]);
-
- const animatedStyle = useAnimatedStyle(() => {
- return {
- opacity: opacity.value,
- // When hidden, use absolute positioning to remove from layout flow
- position: opacity.value < 0.5 ? ("absolute" as const) : ("relative" as const),
- // When hidden, disable pointer events so GlobalFooter's controls are interactive
- pointerEvents: opacity.value < 0.5 ? ("none" as const) : ("auto" as const),
- };
- });
-
async function handleSendMessage() {
if (!userInput.trim() || !ws.isConnected) return;
@@ -99,7 +76,7 @@ export function AgentInputArea({ agentId, isRealtimeMode }: AgentInputAreaProps)
const hasText = userInput.trim().length > 0;
return (
-
+
{/* Text input */}
)}
-
+
);
}
diff --git a/packages/app/src/components/global-footer.tsx b/packages/app/src/components/global-footer.tsx
index b290a8574..0f3e2ea4f 100644
--- a/packages/app/src/components/global-footer.tsx
+++ b/packages/app/src/components/global-footer.tsx
@@ -1,14 +1,15 @@
import { useEffect } from "react";
-import { View, Pressable } from "react-native";
+import { View, Pressable, StyleSheet as RNStyleSheet } from "react-native";
import { usePathname } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AudioLines } from "lucide-react-native";
import { useRealtime } from "@/contexts/realtime-context";
import { useSession } from "@/contexts/session-context";
+import { useFooterControls, FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
import { RealtimeControls } from "./realtime-controls";
-import Animated, {
- FadeIn,
+import Animated, {
+ FadeIn,
FadeOut,
useAnimatedStyle,
withTiming,
@@ -21,44 +22,80 @@ export function GlobalFooter() {
const pathname = usePathname();
const { isRealtimeMode, startRealtime } = useRealtime();
const { ws } = useSession();
+ const { controls } = useFooterControls();
// Determine current screen type
const isAgentScreen = pathname?.startsWith("/agent/");
- // Determine if footer should be visible
- // Hidden when: on agent screen AND realtime is off
- const shouldHide = isAgentScreen && !isRealtimeMode;
+ const hasRegisteredControls = !!controls;
+ const showAgentControls = isAgentScreen && hasRegisteredControls;
- // Controlled opacity for agent screen transitions (synced with AgentInputArea)
- const realtimeOpacity = useSharedValue(isRealtimeMode ? 1 : 0);
+ const transition = useSharedValue(isRealtimeMode ? 1 : 0);
useEffect(() => {
- if (isAgentScreen) {
- // On agent screen, use controlled animation for smooth cross-fade
- realtimeOpacity.value = withTiming(isRealtimeMode ? 1 : 0, { duration: 250 });
+ if (showAgentControls) {
+ transition.value = withTiming(isRealtimeMode ? 1 : 0, { duration: 250 });
}
- }, [isRealtimeMode, isAgentScreen]);
+ }, [isRealtimeMode, showAgentControls]);
const realtimeAnimatedStyle = useAnimatedStyle(() => {
return {
- opacity: realtimeOpacity.value,
- pointerEvents: realtimeOpacity.value > 0.5 ? ("auto" as const) : ("none" as const),
+ opacity: transition.value,
+ pointerEvents: transition.value > 0.5 ? ("auto" as const) : ("none" as const),
};
});
- // If realtime is active, show realtime controls
+ const agentControlsAnimatedStyle = useAnimatedStyle(() => {
+ return {
+ opacity: 1 - transition.value,
+ pointerEvents: transition.value < 0.5 ? ("auto" as const) : ("none" as const),
+ };
+ });
+
+ if (showAgentControls) {
+ return (
+
+
+
+ {controls}
+
+
+
+
+
+
+ );
+ }
+
+ if (isAgentScreen) {
+ return null;
+ }
+
+ // Determine if realtime is active on non-agent screens
if (isRealtimeMode) {
return (
@@ -66,18 +103,16 @@ export function GlobalFooter() {
}
// For home and orchestrator screens, show centered realtime button
- // On agent screens, don't render at all
- if (shouldHide) {
- return null;
- }
-
return (
@@ -102,8 +137,9 @@ const styles = StyleSheet.create((theme) => ({
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
},
- realtimeContainer: {
- backgroundColor: theme.colors.background,
+ content: {
+ flex: 1,
+ height: FOOTER_HEIGHT,
},
centeredButtonContainer: {
padding: theme.spacing[6],
diff --git a/packages/app/src/contexts/footer-controls-context.tsx b/packages/app/src/contexts/footer-controls-context.tsx
new file mode 100644
index 000000000..7c7af8930
--- /dev/null
+++ b/packages/app/src/contexts/footer-controls-context.tsx
@@ -0,0 +1,49 @@
+import { createContext, useCallback, useContext, useMemo, useState } from "react";
+import type { ReactNode } from "react";
+
+export const FOOTER_HEIGHT = 88;
+
+interface FooterControlsContextValue {
+ controls: ReactNode | null;
+ registerFooterControls: (controls: ReactNode) => void;
+ unregisterFooterControls: () => void;
+}
+
+const FooterControlsContext = createContext(undefined);
+
+export function FooterControlsProvider({ children }: { children: ReactNode }) {
+ const [controls, setControls] = useState(null);
+
+ const registerFooterControls = useCallback((nextControls: ReactNode) => {
+ setControls(nextControls);
+ }, []);
+
+ const unregisterFooterControls = useCallback(() => {
+ setControls(null);
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ controls,
+ registerFooterControls,
+ unregisterFooterControls,
+ }),
+ [controls, registerFooterControls, unregisterFooterControls],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useFooterControls() {
+ const context = useContext(FooterControlsContext);
+
+ if (!context) {
+ throw new Error("useFooterControls must be used within a FooterControlsProvider");
+ }
+
+ return context;
+}