Files
ai-agent/frontend/src/App.tsx
Gabriel Vidal d3ed893d85 feat(nav): the bottom bar is now @gabvdl/ui's BottomNav, with sub-links and swipe
The bar's markup moved into the design system (BottomNav 0.35.4) and
BottomBar became what only this app can know: which sections exist, what
each one routes to, and that tapping Conversation on home brings the
tucked-away composer back instead of navigating nowhere.

What is new for the user:

- a second level. Tap the section you are already in and its pages rise
  from behind the bar — Dashboards (activity, analytics, environment,
  graph), Files (memories, plans, goals, skills, agents, notifs) and
  Settings (shortcuts, tags, cron). Five slots per drawer; the rest wait
  in the hold-to-rearrange stash, and both orders persist server-side
  under the new "nav-sub" list.
- swipe navigation. A left/right swipe on the content area walks the bar
  one section at a time, with the page following the finger; inside an
  expanded section it walks that section's pages and steps out past
  either end. Swipes that start in a horizontal scroller or on the bar
  itself are left alone.

nav.ts grew SUB_SECTIONS (the second level, with the route prefixes each
entry owns) and navKeyOf(), which resolves a path to the finest-grained
bar destination — sectionOf() is unchanged and still owns the sections.

Also the frontend's first test setup: vitest + testing-library + jsdom,
60 tests covering the bar (sections, active route, taps, the composer
tap, drawers, swipes) and the route→section/sub-section mapping.
2026-08-11 21:26:32 +02:00

110 lines
4.3 KiB
TypeScript

import { useEffect, useRef } from "react";
import { Outlet } from "react-router-dom";
import { ResizableLayout } from "@gabvdl/ui";
import { useStore } from "@/store";
import { useSettings } from "@/settings";
import { useDesktop } from "@/lib/floating";
import { useHydrated } from "@/lib/useHydrated";
import { useLiveEvents } from "@/lib/sseBus";
import { DrawerContent } from "@/business/navigation/components/Drawer";
import { BottomBar } from "@/business/composer/components/BottomBar";
import { GlobalSpawn } from "@/business/composer/components/GlobalSpawn";
import { OfflineIndicator } from "@/technical/OfflineIndicator";
import { UpdateToast } from "@/technical/UpdateToast";
import { DeployBanner } from "@/technical/DeployBanner";
import { AgentAvatar } from "@/technical/AgentAvatar";
/** App shell: contextual left drawer + centered content + bottom toolbar. */
export default function Layout() {
const setOnline = useStore((s) => s.setOnline);
const setSidebar = useStore((s) => s.setSidebar);
const sidebarOpen = useStore((s) => s.sidebarOpen);
const theme = useSettings((s) => s.theme);
// The bottom bar listens for left/right swipes here — and drags this element
// with the finger while you do it.
const contentRef = useRef<HTMLDivElement>(null);
// One app-wide SSE subscription; conversation views read `live` from the
// shared store and refresh via React Query invalidation (see sseBus.ts).
useLiveEvents();
// The nav is a focus-taking drawer on mobile: open, it puts a backdrop over
// the whole app. It must never be what greets you on a phone — and
// `sidebarOpen` persists, so a desktop session's `true` follows you there.
// Close it on mount and again once the persisted value lands.
const isDesktop = useDesktop();
const hydrated = useHydrated(useStore);
useEffect(() => {
if (!isDesktop) setSidebar(false);
}, [isDesktop, hydrated, setSidebar]);
// Apply the color scheme: explicit dark/light, or follow the OS for "system".
useEffect(() => {
const root = document.documentElement;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
const dark = theme === "dark" || (theme === "system" && mq.matches);
root.classList.toggle("dark", dark);
};
apply();
if (theme === "system") {
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}
}, [theme]);
// Online/offline flag for the indicator. Data freshness needs no hand-rolled
// refetch here: every server resource is a React Query with
// refetchOnReconnect/refetchOnWindowFocus, fetched by the views that use it.
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener("online", on);
window.addEventListener("offline", off);
return () => {
window.removeEventListener("online", on);
window.removeEventListener("offline", off);
};
}, [setOnline]);
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<OfflineIndicator />
<UpdateToast />
<DeployBanner />
<div ref={contentRef} className="relative flex min-h-0 flex-1 overflow-hidden">
<ResizableLayout
autoSaveId="ai-agent:shell"
left={{
content: <DrawerContent />,
defaultSize: 18,
minSize: 12,
maxSize: 34,
mobileWidth: "82%",
edgeSwipeToOpen: true,
}}
leftOpen={sidebarOpen}
onLeftOpenChange={setSidebar}
>
{/* Each view handles its own loading; only the Files section needs the
file bundle, so we never gate the whole app on it. */}
<Outlet />
</ResizableLayout>
{/* App-level "new session" composer — lives outside the Outlet so it
(and its draft) survives navigation and can be summoned anywhere.
Inside the relative content wrapper so the mobile overlay drawer
pins to its bottom edge, right above the navbar. */}
<GlobalSpawn />
{/* The roaming agent avatar walks along the bottom edge of the content
area (right above the navbar). Settings → Avatar configures it. */}
<AgentAvatar />
</div>
<BottomBar contentRef={contentRef} />
</div>
);
}