refactor: simplify footer architecture by removing global footer pattern

Replace portal/registry pattern with direct rendering where each screen
owns its footer. This eliminates FooterControlsContext indirection and
makes the code follow standard React composition patterns.

- Remove GlobalFooter and FooterControlsProvider from _layout.tsx
- Add HomeFooter component rendered directly in home screen
- Render AgentInputArea directly in agent screen (not via registration)
- Add keyboard animation and safe area handling to AgentInputArea
- Move FOOTER_HEIGHT constant to shared constants/layout.ts
- Delete footer-controls-context.tsx and global-footer.tsx

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-12-03 22:25:30 +07:00
co-authored by Claude
parent 5cbeb97391
commit 98c24f22a0
9 changed files with 54 additions and 133 deletions
+20 -25
View File
@@ -6,9 +6,7 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { RealtimeProvider } from "@/contexts/realtime-context"; import { RealtimeProvider } from "@/contexts/realtime-context";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { View, ActivityIndicator, Text } from "react-native"; import { View, ActivityIndicator, Text } from "react-native";
import { GlobalFooter } from "@/components/global-footer";
import { useUnistyles } from "react-native-unistyles"; import { useUnistyles } from "react-native-unistyles";
import { FooterControlsProvider } from "@/contexts/footer-controls-context";
import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context"; import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context";
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host"; import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
@@ -111,29 +109,26 @@ export default function RootLayout() {
<DaemonConnectionsProvider> <DaemonConnectionsProvider>
<MultiDaemonSessionHost /> <MultiDaemonSessionHost />
<ProvidersWrapper> <ProvidersWrapper>
<FooterControlsProvider> <AppContainer>
<AppContainer> <Stack
<Stack screenOptions={{
screenOptions={{ headerShown: false,
headerShown: false, animation: "none",
animation: "none", gestureEnabled: true,
gestureEnabled: true, gestureDirection: "horizontal",
gestureDirection: "horizontal", fullScreenGestureEnabled: true,
fullScreenGestureEnabled: true, }}
}} >
> <Stack.Screen name="index" />
<Stack.Screen name="index" /> <Stack.Screen name="orchestrator" />
<Stack.Screen name="orchestrator" /> <Stack.Screen name="agent/[id]" />
<Stack.Screen name="agent/[id]" /> <Stack.Screen name="agent/[serverId]/[agentId]" />
<Stack.Screen name="agent/[serverId]/[agentId]" /> <Stack.Screen name="settings" />
<Stack.Screen name="settings" /> <Stack.Screen name="audio-test" />
<Stack.Screen name="audio-test" /> <Stack.Screen name="git-diff" />
<Stack.Screen name="git-diff" /> <Stack.Screen name="file-explorer" />
<Stack.Screen name="file-explorer" /> </Stack>
</Stack> </AppContainer>
<GlobalFooter />
</AppContainer>
</FooterControlsProvider>
</ProvidersWrapper> </ProvidersWrapper>
</DaemonConnectionsProvider> </DaemonConnectionsProvider>
</DaemonRegistryProvider> </DaemonRegistryProvider>
@@ -19,7 +19,6 @@ 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 { CreateAgentModal, ImportAgentModal, type CreateAgentInitialValues } from "@/components/create-agent-modal"; import { CreateAgentModal, ImportAgentModal, type CreateAgentInitialValues } from "@/components/create-agent-modal";
import { useFooterControls } from "@/contexts/footer-controls-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus } from "@/utils/daemons"; import { formatConnectionStatus } from "@/utils/daemons";
@@ -177,7 +176,6 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const [menuVisible, setMenuVisible] = useState(false); const [menuVisible, setMenuVisible] = useState(false);
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
@@ -348,24 +346,6 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro
initializeAgent({ agentId: resolvedAgentId }); initializeAgent({ agentId: resolvedAgentId });
}, [resolvedAgentId, initializeAgent, isInitializingFromMap]); }, [resolvedAgentId, initializeAgent, isInitializingFromMap]);
const agentControls = useMemo(() => {
if (!resolvedAgentId) return null;
return <AgentInputArea agentId={resolvedAgentId} serverId={serverId} />;
}, [resolvedAgentId, serverId]);
useEffect(() => {
if (!agentControls || !agent || isInitializing) {
unregisterFooterControls();
return;
}
registerFooterControls(agentControls);
return () => {
unregisterFooterControls();
};
}, [agentControls, agent, isInitializing, registerFooterControls, unregisterFooterControls]);
const recalculateMenuPosition = useCallback( const recalculateMenuPosition = useCallback(
(onMeasured?: () => void) => { (onMeasured?: () => void) => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -567,6 +547,11 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro
</ReanimatedAnimated.View> </ReanimatedAnimated.View>
</View> </View>
{/* Agent Input Area */}
{!isInitializing && agent && resolvedAgentId && (
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} />
)}
{/* Dropdown Menu */} {/* Dropdown Menu */}
<Modal <Modal
visible={menuVisible} visible={menuVisible}
+4
View File
@@ -6,6 +6,7 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller
import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated"; import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { HomeHeader } from "@/components/headers/home-header"; import { HomeHeader } from "@/components/headers/home-header";
import { HomeFooter } from "@/components/home-footer";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { AgentList } from "@/components/agent-list"; import { AgentList } from "@/components/agent-list";
import { CreateAgentModal, ImportAgentModal } from "@/components/create-agent-modal"; import { CreateAgentModal, ImportAgentModal } from "@/components/create-agent-modal";
@@ -133,6 +134,9 @@ export default function HomeScreen() {
)} )}
</ReanimatedAnimated.View> </ReanimatedAnimated.View>
{/* Home Footer */}
<HomeFooter />
{/* Create Agent Modal */} {/* Create Agent Modal */}
<CreateAgentModal isVisible={showCreateModal} onClose={handleCloseCreateModal} /> <CreateAgentModal isVisible={showCreateModal} onClose={handleCloseCreateModal} />
<ImportAgentModal <ImportAgentModal
@@ -20,9 +20,11 @@ import Animated, {
FadeIn, FadeIn,
FadeOut, FadeOut,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useRealtime } from "@/contexts/realtime-context"; import { useRealtime } from "@/contexts/realtime-context";
import { useDictation } from "@/hooks/use-dictation"; import { useDictation } from "@/hooks/use-dictation";
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context"; import { FOOTER_HEIGHT } from "@/constants/layout";
import { VoiceNoteRecordingOverlay } from "./voice-note-recording-overlay"; import { VoiceNoteRecordingOverlay } from "./voice-note-recording-overlay";
import { DictationStatusNotice, type DictationToastVariant } from "./dictation-status-notice"; import { DictationStatusNotice, type DictationToastVariant } from "./dictation-status-notice";
import { generateMessageId } from "@/types/stream"; import { generateMessageId } from "@/types/stream";
@@ -85,6 +87,8 @@ type DictationToastConfig = {
export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const ws = useSessionStore((state) => state.sessions[serverId]?.ws); const ws = useSessionStore((state) => state.sessions[serverId]?.ws);
@@ -825,6 +829,15 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
}; };
}); });
const keyboardAnimatedStyle = useAnimatedStyle(() => {
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = Math.max(0, absoluteHeight - insets.bottom);
return {
transform: [{ translateY: -shift }],
};
});
async function handleRealtimePress() { async function handleRealtimePress() {
try { try {
if (isRealtimeMode) { if (isRealtimeMode) {
@@ -923,7 +936,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
); );
return ( return (
<View style={styles.container}> <Animated.View style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}>
{/* Border separator */} {/* Border separator */}
<View style={styles.borderSeparator} /> <View style={styles.borderSeparator} />
@@ -1104,7 +1117,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
</View> </View>
</View> </View>
) : null} ) : null}
</View> </Animated.View>
); );
} }
@@ -1,12 +1,12 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { View, Pressable, Text, Platform, Modal, Alert } from "react-native"; import { View, Pressable, Text, Platform, Modal, Alert } from "react-native";
import { usePathname, useRouter } from "expo-router"; import { useRouter } from "expo-router";
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 { AudioLines, Users, Plus, Download } from "lucide-react-native"; import { AudioLines, Users, Plus, Download } from "lucide-react-native";
import { useRealtime } from "@/contexts/realtime-context"; import { useRealtime } from "@/contexts/realtime-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useFooterControls, FOOTER_HEIGHT } from "@/contexts/footer-controls-context"; import { FOOTER_HEIGHT } from "@/constants/layout";
import { RealtimeControls } from "./realtime-controls"; import { RealtimeControls } from "./realtime-controls";
import { CreateAgentModal, ImportAgentModal } from "./create-agent-modal"; import { CreateAgentModal, ImportAgentModal } from "./create-agent-modal";
import Animated, { import Animated, {
@@ -16,14 +16,12 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
export function GlobalFooter() { export function HomeFooter() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { isRealtimeMode, startRealtime } = useRealtime(); const { isRealtimeMode, startRealtime } = useRealtime();
const { connectionStates } = useDaemonConnections(); const { connectionStates } = useDaemonConnections();
const { controls } = useFooterControls();
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showRealtimeHostPicker, setShowRealtimeHostPicker] = useState(false); const [showRealtimeHostPicker, setShowRealtimeHostPicker] = useState(false);
@@ -33,12 +31,6 @@ export function GlobalFooter() {
const realtimeFadeIn = shouldDisableEntryExitAnimations ? undefined : FadeIn.duration(250); const realtimeFadeIn = shouldDisableEntryExitAnimations ? undefined : FadeIn.duration(250);
const realtimeFadeOut = shouldDisableEntryExitAnimations ? undefined : FadeOut.duration(250); const realtimeFadeOut = shouldDisableEntryExitAnimations ? undefined : FadeOut.duration(250);
// Determine current screen type
const isAgentScreen = pathname?.startsWith("/agent/");
const hasRegisteredControls = !!controls;
const showAgentControls = isAgentScreen && hasRegisteredControls;
const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = insets.bottom; const bottomInset = insets.bottom;
@@ -76,7 +68,7 @@ export function GlobalFooter() {
} }
if (realtimeEligibleHosts.length === 1) { if (realtimeEligibleHosts.length === 1) {
void startRealtime(realtimeEligibleHosts[0].daemon.id).catch((error) => { void startRealtime(realtimeEligibleHosts[0].daemon.id).catch((error) => {
console.error("[GlobalFooter] Failed to start realtime", error); console.error("[HomeFooter] Failed to start realtime", error);
Alert.alert("Realtime failed", "Unable to start realtime mode for this host."); Alert.alert("Realtime failed", "Unable to start realtime mode for this host.");
}); });
return; return;
@@ -88,7 +80,7 @@ export function GlobalFooter() {
(daemonId: string) => { (daemonId: string) => {
setShowRealtimeHostPicker(false); setShowRealtimeHostPicker(false);
void startRealtime(daemonId).catch((error) => { void startRealtime(daemonId).catch((error) => {
console.error("[GlobalFooter] Failed to start realtime", error); console.error("[HomeFooter] Failed to start realtime", error);
Alert.alert("Realtime failed", "Unable to start realtime mode for this host."); Alert.alert("Realtime failed", "Unable to start realtime mode for this host.");
}); });
}, },
@@ -99,26 +91,6 @@ export function GlobalFooter() {
setShowRealtimeHostPicker(false); setShowRealtimeHostPicker(false);
}, []); }, []);
if (showAgentControls) {
return (
<Animated.View
style={[
styles.container,
{
paddingBottom: insets.bottom,
},
keyboardAnimatedStyle,
]}
>
{controls}
</Animated.View>
);
}
if (isAgentScreen) {
return null;
}
// For home and orchestrator screens, show action buttons with realtime stacked on top // For home and orchestrator screens, show action buttons with realtime stacked on top
const nonAgentFooterHeight = isRealtimeMode const nonAgentFooterHeight = isRealtimeMode
? FOOTER_HEIGHT * 2 + insets.bottom ? FOOTER_HEIGHT * 2 + insets.bottom
@@ -191,7 +163,7 @@ export function GlobalFooter() {
<Pressable <Pressable
onPress={() => { onPress={() => {
console.log("[GlobalFooter] New Agent button pressed"); console.log("[HomeFooter] New Agent button pressed");
setShowCreateModal(true); setShowCreateModal(true);
}} }}
style={({ pressed }) => [ style={({ pressed }) => [
@@ -3,7 +3,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MicOff, Square } from "lucide-react-native"; import { MicOff, Square } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter"; import { VolumeMeter } from "./volume-meter";
import { useRealtime } from "@/contexts/realtime-context"; import { useRealtime } from "@/contexts/realtime-context";
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context"; import { FOOTER_HEIGHT } from "@/constants/layout";
const CONTROL_BUTTON_SIZE = 48; const CONTROL_BUTTON_SIZE = 48;
const VERTICAL_PADDING = (FOOTER_HEIGHT - CONTROL_BUTTON_SIZE) / 2; const VERTICAL_PADDING = (FOOTER_HEIGHT - CONTROL_BUTTON_SIZE) / 2;
@@ -2,7 +2,7 @@ import { View, Text, Pressable, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp, RefreshCcw } from "lucide-react-native"; import { X, ArrowUp, RefreshCcw } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter"; import { VolumeMeter } from "./volume-meter";
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context"; import { FOOTER_HEIGHT } from "@/constants/layout";
import type { DictationStatus } from "@/hooks/use-dictation"; import type { DictationStatus } from "@/hooks/use-dictation";
interface VoiceNoteRecordingOverlayProps { interface VoiceNoteRecordingOverlayProps {
+1
View File
@@ -0,0 +1 @@
export const FOOTER_HEIGHT = 75;
@@ -1,49 +0,0 @@
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";
export const FOOTER_HEIGHT = 75;
interface FooterControlsContextValue {
controls: ReactNode | null;
registerFooterControls: (controls: ReactNode) => void;
unregisterFooterControls: () => void;
}
const FooterControlsContext = createContext<FooterControlsContextValue | undefined>(undefined);
export function FooterControlsProvider({ children }: { children: ReactNode }) {
const [controls, setControls] = useState<ReactNode | null>(null);
const registerFooterControls = useCallback((nextControls: ReactNode) => {
setControls(nextControls);
}, []);
const unregisterFooterControls = useCallback(() => {
setControls(null);
}, []);
const value = useMemo(
() => ({
controls,
registerFooterControls,
unregisterFooterControls,
}),
[controls, registerFooterControls, unregisterFooterControls],
);
return (
<FooterControlsContext.Provider value={value}>
{children}
</FooterControlsContext.Provider>
);
}
export function useFooterControls() {
const context = useContext(FooterControlsContext);
if (!context) {
throw new Error("useFooterControls must be used within a FooterControlsProvider");
}
return context;
}