From 98c24f22a0fdac10ff86b5e6f9f65b7b44cd761c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Dec 2025 22:25:30 +0700 Subject: [PATCH] refactor: simplify footer architecture by removing global footer pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/app/src/app/_layout.tsx | 45 ++++++++--------- .../src/app/agent/[serverId]/[agentId].tsx | 25 ++-------- packages/app/src/app/index.tsx | 4 ++ .../app/src/components/agent-input-area.tsx | 19 +++++-- .../{global-footer.tsx => home-footer.tsx} | 40 +++------------ .../app/src/components/realtime-controls.tsx | 2 +- .../voice-note-recording-overlay.tsx | 2 +- packages/app/src/constants/layout.ts | 1 + .../src/contexts/footer-controls-context.tsx | 49 ------------------- 9 files changed, 54 insertions(+), 133 deletions(-) rename packages/app/src/components/{global-footer.tsx => home-footer.tsx} (91%) create mode 100644 packages/app/src/constants/layout.ts delete mode 100644 packages/app/src/contexts/footer-controls-context.tsx diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 588b3cf71..d53840b3e 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -6,9 +6,7 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { RealtimeProvider } from "@/contexts/realtime-context"; import { useAppSettings } from "@/hooks/use-settings"; import { View, ActivityIndicator, Text } from "react-native"; -import { GlobalFooter } from "@/components/global-footer"; import { useUnistyles } from "react-native-unistyles"; -import { FooterControlsProvider } from "@/contexts/footer-controls-context"; import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context"; import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host"; @@ -111,29 +109,26 @@ export default function RootLayout() { - - - - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index cb6534c1d..b573ee57c 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -19,7 +19,6 @@ import { BackHeader } from "@/components/headers/back-header"; import { AgentStreamView } from "@/components/agent-stream-view"; import { AgentInputArea } from "@/components/agent-input-area"; 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 type { ConnectionStatus } from "@/contexts/daemon-connections-context"; import { formatConnectionStatus } from "@/utils/daemons"; @@ -177,7 +176,6 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const router = useRouter(); - const { registerFooterControls, unregisterFooterControls } = useFooterControls(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const [menuVisible, setMenuVisible] = useState(false); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); @@ -348,24 +346,6 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro initializeAgent({ agentId: resolvedAgentId }); }, [resolvedAgentId, initializeAgent, isInitializingFromMap]); - const agentControls = useMemo(() => { - if (!resolvedAgentId) return null; - return ; - }, [resolvedAgentId, serverId]); - - useEffect(() => { - if (!agentControls || !agent || isInitializing) { - unregisterFooterControls(); - return; - } - - registerFooterControls(agentControls); - - return () => { - unregisterFooterControls(); - }; - }, [agentControls, agent, isInitializing, registerFooterControls, unregisterFooterControls]); - const recalculateMenuPosition = useCallback( (onMeasured?: () => void) => { requestAnimationFrame(() => { @@ -567,6 +547,11 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro + {/* Agent Input Area */} + {!isInitializing && agent && resolvedAgentId && ( + + )} + {/* Dropdown Menu */} + {/* Home Footer */} + + {/* Create Agent Modal */} 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() { try { if (isRealtimeMode) { @@ -923,7 +936,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { ); return ( - + {/* Border separator */} @@ -1104,7 +1117,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { ) : null} - + ); } diff --git a/packages/app/src/components/global-footer.tsx b/packages/app/src/components/home-footer.tsx similarity index 91% rename from packages/app/src/components/global-footer.tsx rename to packages/app/src/components/home-footer.tsx index 7fe1db851..9155a6aee 100644 --- a/packages/app/src/components/global-footer.tsx +++ b/packages/app/src/components/home-footer.tsx @@ -1,12 +1,12 @@ import { useCallback, useMemo, useState } from "react"; 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 { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AudioLines, Users, Plus, Download } from "lucide-react-native"; import { useRealtime } from "@/contexts/realtime-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 { CreateAgentModal, ImportAgentModal } from "./create-agent-modal"; import Animated, { @@ -16,14 +16,12 @@ import Animated, { } from "react-native-reanimated"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; -export function GlobalFooter() { +export function HomeFooter() { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); - const pathname = usePathname(); const router = useRouter(); const { isRealtimeMode, startRealtime } = useRealtime(); const { connectionStates } = useDaemonConnections(); - const { controls } = useFooterControls(); const [showCreateModal, setShowCreateModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false); const [showRealtimeHostPicker, setShowRealtimeHostPicker] = useState(false); @@ -33,12 +31,6 @@ export function GlobalFooter() { const realtimeFadeIn = shouldDisableEntryExitAnimations ? undefined : FadeIn.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 bottomInset = insets.bottom; @@ -76,7 +68,7 @@ export function GlobalFooter() { } if (realtimeEligibleHosts.length === 1) { 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."); }); return; @@ -88,7 +80,7 @@ export function GlobalFooter() { (daemonId: string) => { setShowRealtimeHostPicker(false); 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."); }); }, @@ -99,26 +91,6 @@ export function GlobalFooter() { setShowRealtimeHostPicker(false); }, []); - if (showAgentControls) { - return ( - - {controls} - - ); - } - - if (isAgentScreen) { - return null; - } - // For home and orchestrator screens, show action buttons with realtime stacked on top const nonAgentFooterHeight = isRealtimeMode ? FOOTER_HEIGHT * 2 + insets.bottom @@ -191,7 +163,7 @@ export function GlobalFooter() { { - console.log("[GlobalFooter] New Agent button pressed"); + console.log("[HomeFooter] New Agent button pressed"); setShowCreateModal(true); }} style={({ pressed }) => [ diff --git a/packages/app/src/components/realtime-controls.tsx b/packages/app/src/components/realtime-controls.tsx index 535ed35c7..234351255 100644 --- a/packages/app/src/components/realtime-controls.tsx +++ b/packages/app/src/components/realtime-controls.tsx @@ -3,7 +3,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { MicOff, Square } from "lucide-react-native"; import { VolumeMeter } from "./volume-meter"; 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 VERTICAL_PADDING = (FOOTER_HEIGHT - CONTROL_BUTTON_SIZE) / 2; diff --git a/packages/app/src/components/voice-note-recording-overlay.tsx b/packages/app/src/components/voice-note-recording-overlay.tsx index 77886d7d1..a9e8f3b96 100644 --- a/packages/app/src/components/voice-note-recording-overlay.tsx +++ b/packages/app/src/components/voice-note-recording-overlay.tsx @@ -2,7 +2,7 @@ import { View, Text, Pressable, ActivityIndicator } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { X, ArrowUp, RefreshCcw } from "lucide-react-native"; 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"; interface VoiceNoteRecordingOverlayProps { diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts new file mode 100644 index 000000000..77097f884 --- /dev/null +++ b/packages/app/src/constants/layout.ts @@ -0,0 +1 @@ +export const FOOTER_HEIGHT = 75; diff --git a/packages/app/src/contexts/footer-controls-context.tsx b/packages/app/src/contexts/footer-controls-context.tsx deleted file mode 100644 index 5d15330ea..000000000 --- a/packages/app/src/contexts/footer-controls-context.tsx +++ /dev/null @@ -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(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; -}