From ae3fba6406a6a4532e0346cb856d94de93501868 Mon Sep 17 00:00:00 2001 From: brendandebeasi Date: Tue, 12 May 2026 21:38:56 -0700 Subject: [PATCH 1/7] feat(interface): add mobile responsive primitives (#601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three primitives that the rest of the mobile pass will lean on: - `useMediaQuery` hook + `useIsMobile()` (max-width: 767px). SSR-safe. - `` — slide-in sheet over Radix Dialog with left/right/bottom sides. Caps at 320px on left/right, 85vh on bottom. - `` — hamburger + title + trailing-slot bar for the shell. No callers wired yet; safe to land standalone. --- interface/src/components/MobileTopBar.tsx | 43 +++++++++ interface/src/hooks/useMediaQuery.ts | 25 ++++++ interface/src/ui/Drawer.tsx | 101 ++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 interface/src/components/MobileTopBar.tsx create mode 100644 interface/src/hooks/useMediaQuery.ts create mode 100644 interface/src/ui/Drawer.tsx diff --git a/interface/src/components/MobileTopBar.tsx b/interface/src/components/MobileTopBar.tsx new file mode 100644 index 000000000..ac47743e5 --- /dev/null +++ b/interface/src/components/MobileTopBar.tsx @@ -0,0 +1,43 @@ +import {List} from "@phosphor-icons/react"; +import clsx from "clsx"; +import type {ReactNode} from "react"; + +interface MobileTopBarProps { + title?: string; + onMenu: () => void; + leading?: ReactNode; + trailing?: ReactNode; + className?: string; +} + +export function MobileTopBar({ + title, + onMenu, + leading, + trailing, + className, +}: MobileTopBarProps) { + return ( +
+ {leading ?? ( + + )} +
+ {title} +
+ {trailing &&
{trailing}
} +
+ ); +} diff --git a/interface/src/hooks/useMediaQuery.ts b/interface/src/hooks/useMediaQuery.ts new file mode 100644 index 000000000..2411c6428 --- /dev/null +++ b/interface/src/hooks/useMediaQuery.ts @@ -0,0 +1,25 @@ +import {useEffect, useState} from "react"; + +export function useMediaQuery(query: string): boolean { + const getMatch = () => { + if (typeof window === "undefined") return false; + return window.matchMedia(query).matches; + }; + + const [matches, setMatches] = useState(getMatch); + + useEffect(() => { + if (typeof window === "undefined") return; + const mql = window.matchMedia(query); + const handler = (e: MediaQueryListEvent) => setMatches(e.matches); + setMatches(mql.matches); + mql.addEventListener("change", handler); + return () => mql.removeEventListener("change", handler); + }, [query]); + + return matches; +} + +export function useIsMobile(): boolean { + return useMediaQuery("(max-width: 767px)"); +} diff --git a/interface/src/ui/Drawer.tsx b/interface/src/ui/Drawer.tsx new file mode 100644 index 000000000..e5d042585 --- /dev/null +++ b/interface/src/ui/Drawer.tsx @@ -0,0 +1,101 @@ +import * as RDialog from "@radix-ui/react-dialog"; +import clsx from "clsx"; +import {motion, AnimatePresence} from "framer-motion"; +import type {ReactNode} from "react"; + +export type DrawerSide = "left" | "right" | "bottom"; + +interface DrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + side?: DrawerSide; + children: ReactNode; + className?: string; + ariaLabel?: string; +} + +const sideAnim: Record< + DrawerSide, + { + initial: object; + animate: object; + exit: object; + positionClass: string; + } +> = { + left: { + initial: {x: "-100%"}, + animate: {x: 0}, + exit: {x: "-100%"}, + positionClass: "inset-y-0 left-0 h-full w-[85vw] max-w-[320px]", + }, + right: { + initial: {x: "100%"}, + animate: {x: 0}, + exit: {x: "100%"}, + positionClass: "inset-y-0 right-0 h-full w-[85vw] max-w-[320px]", + }, + bottom: { + initial: {y: "100%"}, + animate: {y: 0}, + exit: {y: "100%"}, + positionClass: "inset-x-0 bottom-0 max-h-[85vh] w-full", + }, +}; + +export function Drawer({ + open, + onOpenChange, + side = "left", + children, + className, + ariaLabel, +}: DrawerProps) { + const cfg = sideAnim[side]; + + return ( + + + {open && ( + + + + + e.preventDefault()} + > + + + {ariaLabel || "Drawer"} + + {children} + + + + )} + + + ); +} From 2a20a0550b69ca21441b4aa12e4cdce92ebb6664 Mon Sep 17 00:00:00 2001 From: brendandebeasi Date: Tue, 12 May 2026 21:39:04 -0700 Subject: [PATCH 2/7] =?UTF-8?q?feat(interface):=20mobile=20shell=20?= =?UTF-8?q?=E2=80=94=20Sidebar=20becomes=20a=20drawer=20below=20md=20(#601?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Below the `md` breakpoint, RootLayout swaps the always-mounted 220px sidebar for `MobileTopBar` + a left-side `Drawer` containing the same ``. Drawer state lives in RootLayout and auto-closes on route change. Sidebar accepts a `fillWidth` prop so it expands to the drawer's container instead of staying clamped to 220px. Desktop layout is unchanged. --- interface/src/components/Sidebar.tsx | 15 ++++++- interface/src/router.tsx | 62 ++++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/interface/src/components/Sidebar.tsx b/interface/src/components/Sidebar.tsx index 0f84a4712..09b7a055c 100644 --- a/interface/src/components/Sidebar.tsx +++ b/interface/src/components/Sidebar.tsx @@ -62,6 +62,8 @@ import {IS_DESKTOP, IS_MACOS} from "@/platform"; interface SidebarProps { liveStates: Record; + /** When true, drop the fixed 220px width — sidebar fills its drawer container. */ + fillWidth?: boolean; } const agentSubItems = [ @@ -185,7 +187,10 @@ function readProjectId(search: unknown): string | null { return typeof id === "string" ? id : null; } -export function Sidebar({liveStates: _liveStates}: SidebarProps) { +export function Sidebar({ + liveStates: _liveStates, + fillWidth = false, +}: SidebarProps) { const navigate = useNavigate(); const [createOpen, setCreateOpen] = useState(false); const [userExpandedAgent, setUserExpandedAgent] = useState( @@ -273,7 +278,13 @@ export function Sidebar({liveStates: _liveStates}: SidebarProps) { }; return ( -