fabro/apps/fabro-web/app/layouts/app-shell.tsx
Bryan Helmkamp a8900c465d
feat: make the Ask Fabro sidebar resizable
Add a drag handle on the left edge of the docked Ask Fabro panel. The
user can widen the panel up to 2x its default width and no narrower than
the default. The chosen width persists across open/close.

An `isResizing` flag on the layout context lets `<main>` and the steer
bar drop their width transitions during a drag so the layout tracks the
cursor instead of trailing it.

Also bundles in-progress sidebar wiring: a collapsed tool-call summary
renderer and the remark-gfm dependency for GitHub-flavored Markdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:55:41 -04:00

345 lines
13 KiB
TypeScript

import {
Disclosure,
DisclosureButton,
DisclosurePanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
} from "@headlessui/react";
import {
Bars3Icon,
BeakerIcon,
ChartBarIcon,
ChatBubbleLeftRightIcon,
Cog6ToothIcon,
PlayIcon,
SparklesIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import { Link, Outlet, useLocation, useMatches } from "react-router";
import { ErrorState } from "../components/state";
import { ToastProvider } from "../components/toast";
import { AskFabroLayoutProvider, useAskFabroLayout } from "../lib/ask-fabro-layout";
import { DemoModeProvider } from "../lib/demo-mode";
import { useToggleDemoMode } from "../lib/mutations";
import { useAuthMe } from "../lib/queries";
const allNavigation = [
{ name: "Automations", href: "/automations", icon: SparklesIcon, demoOnly: true },
{
name: "Chats",
href: "/chats/new",
icon: ChatBubbleLeftRightIcon,
demoOnly: true,
// /chats/new is the click target, but the entry stays active on any
// /chats/:id child route as well.
activePathPrefix: "/chats",
},
{ name: "Runs", href: "/runs", icon: PlayIcon, demoOnly: false },
{ name: "Insights", href: "/insights", icon: ChartBarIcon, demoOnly: true },
{ name: "Settings", href: "/settings", icon: Cog6ToothIcon, demoOnly: false },
];
function activeFor(item: (typeof allNavigation)[number], pathname: string): boolean {
return pathname.startsWith(item.activePathPrefix ?? item.href);
}
export function getVisibleNavigation(demoMode: boolean) {
return allNavigation.filter((item) => !item.demoOnly || demoMode);
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
export default function AppShell() {
const { data: auth, error, isLoading } = useAuthMe();
const { pathname } = useLocation();
const matches = useMatches();
const toggleDemoModeMutation = useToggleDemoMode();
if (isLoading && !auth) {
return <div className="min-h-full bg-page" />;
}
if (error || !auth) {
return (
<div className="min-h-full bg-page py-12">
<ErrorState
title="Couldn't load your session"
description="Refresh the page or sign in again."
/>
</div>
);
}
const { user, provider, demoMode } = auth;
const navigation = getVisibleNavigation(demoMode);
const currentNav = navigation.find((item) => activeFor(item, pathname));
const title = currentNav?.name ?? "";
const lastMatch = matches[matches.length - 1];
const handle = lastMatch?.handle as { headerExtra?: React.ReactNode } | undefined;
const headerExtra = handle?.headerExtra;
const hideHeader = matches.some((m) => (m.handle as { hideHeader?: boolean } | undefined)?.hideHeader);
const wide = matches.some((m) => (m.handle as { wide?: boolean } | undefined)?.wide);
const fullHeight = matches.some(
(m) => (m.handle as { fullHeight?: boolean } | undefined)?.fullHeight,
);
const maxWidth = wide ? "" : "max-w-5xl";
async function toggleDemoMode() {
await toggleDemoModeMutation.trigger({ enabled: !demoMode });
}
return (
<DemoModeProvider value={demoMode}>
<ToastProvider>
<AskFabroLayoutProvider>
<div
className={classNames(
"isolate",
fullHeight ? "flex h-dvh flex-col" : "min-h-full",
)}
>
<Disclosure
as="nav"
className={classNames("bg-panel", fullHeight && "shrink-0")}
>
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
<div className="flex items-center">
<div className="shrink-0">
<Link to={demoMode ? "/start" : "/runs"}>
<img alt="Fabro" src="/images/logotype.svg" className="h-8 w-auto" />
</Link>
</div>
<div className="hidden md:block">
<div className="ml-10 flex items-baseline space-x-4">
{navigation.map((item) => {
const current = activeFor(item, pathname);
return (
<Link
key={item.name}
to={item.href}
aria-current={current ? "page" : undefined}
className={classNames(
current
? "bg-page/50 text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg",
"inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium",
)}
>
<item.icon className="size-4" aria-hidden="true" />
{item.name}
</Link>
);
})}
</div>
</div>
</div>
<div className="hidden md:block">
<div className="ml-4 flex items-center gap-3 md:ml-6">
<button
type="button"
onClick={toggleDemoMode}
className={classNames(
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
demoMode ? "text-teal-500" : "text-fg-muted",
)}
title={demoMode ? "Switch to live data" : "Switch to demo data"}
>
<BeakerIcon className="size-5" aria-hidden="true" />
<span className="sr-only">Toggle demo mode</span>
</button>
<Menu as="div" className="relative">
<MenuButton className="relative flex max-w-xs items-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500">
<span className="absolute -inset-1.5" />
<span className="sr-only">Open user menu</span>
<img
alt=""
src={user.avatarUrl}
className="size-8 rounded-full outline -outline-offset-1 outline-line-strong"
/>
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem>
<Link
to="/profile"
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
>
Profile
</Link>
</MenuItem>
<MenuItem>
<form method="POST" action="/auth/logout">
<button
type="submit"
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
>
Sign out
</button>
</form>
</MenuItem>
</MenuItems>
</Menu>
</div>
</div>
<div className="-mr-2 flex md:hidden">
<DisclosureButton className="group relative inline-flex items-center justify-center rounded-md p-2 text-fg-muted hover:bg-overlay hover:text-fg focus:outline-2 focus:outline-offset-2 focus:outline-teal-500">
<span className="absolute -inset-0.5" />
<span className="sr-only">Open main menu</span>
<Bars3Icon
aria-hidden="true"
className="block size-6 group-data-open:hidden"
/>
<XMarkIcon
aria-hidden="true"
className="hidden size-6 group-data-open:block"
/>
</DisclosureButton>
</div>
</div>
</div>
<DisclosurePanel className="md:hidden">
<div className="space-y-1 px-2 pt-2 pb-3 sm:px-3">
{navigation.map((item) => {
const current = activeFor(item, pathname);
return (
<DisclosureButton
key={item.name}
as={Link}
to={item.href}
aria-current={current ? "page" : undefined}
className={classNames(
current
? "bg-page text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg",
"flex items-center gap-2 rounded-md px-3 py-2 text-base font-medium",
)}
>
<item.icon className="size-5" aria-hidden="true" />
{item.name}
</DisclosureButton>
);
})}
</div>
<div className="border-t border-line-strong pt-4 pb-3">
<div className="flex items-center px-5">
<div className="shrink-0">
<img
alt=""
src={user.avatarUrl}
className="size-10 rounded-full outline -outline-offset-1 outline-line-strong"
/>
</div>
<div className="ml-3">
<div className="text-base font-medium text-fg">
{user.name}
</div>
<div className="text-sm font-medium text-fg-muted">
{user.email}
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={toggleDemoMode}
className={classNames(
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
demoMode ? "text-teal-500" : "text-fg-muted",
)}
title={demoMode ? "Switch to live data" : "Switch to demo data"}
>
<BeakerIcon className="size-5" aria-hidden="true" />
<span className="sr-only">Toggle demo mode</span>
</button>
</div>
</div>
<div className="mt-3 space-y-1 px-2">
<DisclosureButton
as={Link}
to="/profile"
className="block w-full rounded-md px-3 py-2 text-left text-base font-medium text-fg-muted hover:bg-overlay hover:text-fg"
>
Profile
</DisclosureButton>
{provider !== "tailscale" && (
<form method="POST" action="/auth/logout">
<DisclosureButton
as="button"
type="submit"
className="block w-full rounded-md px-3 py-2 text-left text-base font-medium text-fg-muted hover:bg-overlay hover:text-fg"
>
Sign out
</DisclosureButton>
</form>
)}
</div>
</div>
</DisclosurePanel>
</Disclosure>
{!hideHeader && (
<header
className={classNames(
"relative bg-panel after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:border-b after:border-line-strong",
fullHeight && "shrink-0",
)}
>
<div className={`mx-auto ${maxWidth} px-4 py-4 sm:px-6 lg:px-8`}>
<div className="flex items-center">
<h1 className="text-xl font-semibold tracking-tight text-fg">
{title}
</h1>
{headerExtra && <div className="ml-auto">{headerExtra}</div>}
</div>
</div>
</header>
)}
<ShellMain fullHeight={fullHeight} maxWidth={maxWidth} />
</div>
</AskFabroLayoutProvider>
</ToastProvider>
</DemoModeProvider>
);
}
/**
* The scrollable content region. Inset on the right by the docked Ask Fabro
* sidebar's width so opening the sidebar shifts the page content left rather
* than covering it.
*/
function ShellMain({
fullHeight,
maxWidth,
}: {
fullHeight: boolean;
maxWidth: string;
}) {
const { sidebarWidth, isResizing } = useAskFabroLayout();
return (
<main
className={classNames(
!isResizing &&
"transition-[padding] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]",
fullHeight && "min-h-0 flex-1",
)}
style={{ paddingRight: sidebarWidth }}
>
<div
className={classNames(
`mx-auto ${maxWidth} px-4 py-6 sm:px-6 lg:px-8`,
fullHeight && "box-border flex h-full min-h-0 flex-col",
)}
>
<Outlet />
</div>
</main>
);
}