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.
110 lines
4.3 KiB
TypeScript
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>
|
|
);
|
|
}
|