From 6c6924706450fb2b7c5b1aec42aefa842b17dc82 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Mon, 23 Feb 2026 16:08:24 -0600 Subject: [PATCH] feat(ui): add mobile bottom navigation bar with scroll-hide Five-tab bottom nav (Home, Issues, Create, Agents, Inbox) that hides on scroll-down and reappears on scroll-up for more screen real estate. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/Layout.tsx | 40 +++++++- ui/src/components/MobileBottomNav.tsx | 130 ++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 ui/src/components/MobileBottomNav.tsx diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 9bad4a0b..53da41aa 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState, type UIEvent } from "react"; import { useQuery } from "@tanstack/react-query"; import { BookOpen } from "lucide-react"; import { Outlet } from "react-router-dom"; @@ -14,6 +14,7 @@ import { NewGoalDialog } from "./NewGoalDialog"; import { NewAgentDialog } from "./NewAgentDialog"; import { OnboardingWizard } from "./OnboardingWizard"; import { ToastViewport } from "./ToastViewport"; +import { MobileBottomNav } from "./MobileBottomNav"; import { useDialog } from "../context/DialogContext"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; @@ -30,6 +31,8 @@ export function Layout() { const { panelContent, closePanel } = usePanel(); const { companies, loading: companiesLoading, setSelectedCompanyId } = useCompany(); const onboardingTriggered = useRef(false); + const lastMainScrollTop = useRef(0); + const [mobileNavVisible, setMobileNavVisible] = useState(true); const { data: health } = useQuery({ queryKey: queryKeys.health, queryFn: () => healthApi.get(), @@ -68,6 +71,35 @@ export function Layout() { onSwitchCompany: switchCompany, }); + useEffect(() => { + if (!isMobile) { + setMobileNavVisible(true); + return; + } + lastMainScrollTop.current = 0; + setMobileNavVisible(true); + }, [isMobile]); + + const handleMainScroll = useCallback( + (event: UIEvent) => { + if (!isMobile) return; + + const currentTop = event.currentTarget.scrollTop; + const delta = currentTop - lastMainScrollTop.current; + + if (currentTop <= 24) { + setMobileNavVisible(true); + } else if (delta > 8) { + setMobileNavVisible(false); + } else if (delta < -8) { + setMobileNavVisible(true); + } + + lastMainScrollTop.current = currentTop; + }, + [isMobile], + ); + return (
{/* Mobile backdrop */} @@ -119,12 +151,16 @@ export function Layout() {
-
+
+ {isMobile && } diff --git a/ui/src/components/MobileBottomNav.tsx b/ui/src/components/MobileBottomNav.tsx new file mode 100644 index 00000000..3234be60 --- /dev/null +++ b/ui/src/components/MobileBottomNav.tsx @@ -0,0 +1,130 @@ +import { useMemo } from "react"; +import { NavLink, useLocation } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + House, + CircleDot, + SquarePen, + Users, + Inbox, +} from "lucide-react"; +import { sidebarBadgesApi } from "../api/sidebarBadges"; +import { useCompany } from "../context/CompanyContext"; +import { useDialog } from "../context/DialogContext"; +import { queryKeys } from "../lib/queryKeys"; +import { cn } from "../lib/utils"; + +interface MobileBottomNavProps { + visible: boolean; +} + +interface MobileNavLinkItem { + type: "link"; + to: string; + label: string; + icon: typeof House; + badge?: number; +} + +interface MobileNavActionItem { + type: "action"; + label: string; + icon: typeof SquarePen; + onClick: () => void; +} + +type MobileNavItem = MobileNavLinkItem | MobileNavActionItem; + +export function MobileBottomNav({ visible }: MobileBottomNavProps) { + const location = useLocation(); + const { selectedCompanyId } = useCompany(); + const { openNewIssue } = useDialog(); + + const { data: sidebarBadges } = useQuery({ + queryKey: queryKeys.sidebarBadges(selectedCompanyId!), + queryFn: () => sidebarBadgesApi.get(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const items = useMemo( + () => [ + { type: "link", to: "/dashboard", label: "Home", icon: House }, + { type: "link", to: "/issues", label: "Issues", icon: CircleDot }, + { type: "action", label: "Create", icon: SquarePen, onClick: () => openNewIssue() }, + { type: "link", to: "/agents/all", label: "Agents", icon: Users }, + { + type: "link", + to: "/inbox", + label: "Inbox", + icon: Inbox, + badge: sidebarBadges?.inbox, + }, + ], + [openNewIssue, sidebarBadges?.inbox], + ); + + return ( + + ); +}