import {
AgentSidebar,
isAgentChatHomeHandoffActive,
useAgentChatHomeHandoff,
useAgentChatHomeHandoffLinks,
} from "@agent-native/core/client/agent-chat";
import { useT } from "@agent-native/core/client/i18n";
import { HeaderActionsProvider } from "@agent-native/toolkit/app-shell";
import { IconMenu2 } from "@tabler/icons-react";
import { useState, useEffect } from "react";
import { useLocation } from "react-router";
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from "@/components/ui/sheet";
import { Header } from "./Header";
import { Sidebar } from "./Sidebar";
const PLAN_READER_VIEW_EVENT = "plans-reader-view-change";
interface LayoutProps {
children: React.ReactNode;
}
/**
* Routes whose page renders its own h-12 toolbar (with title + AgentToggleButton).
* Layout still wraps these with the left Sidebar and AgentSidebar but skips the
* global Header so they don't double-stack a header bar.
*/
function routeOwnsToolbar(pathname: string): boolean {
return pathname.startsWith("/extensions") || isPlanDetailRoute(pathname);
}
// Recaps are a kind of plan: `/plans/:id` and `/recaps/:id` both render
// PlansPage and share the immersive full-screen reader, so the layout must
// treat them identically (matching `viewForPath` in use-navigation-state.ts).
// Without `/recaps/` here, recap routes never owned their toolbar and never
// went immersive — they were stuck in app view and the full-screen toggle did
// nothing.
function isPlanDetailRoute(pathname: string): boolean {
return /^\/(plans|recaps|local-plans)\/[^/]+/.test(pathname);
}
export function Layout({ children }: LayoutProps) {
const location = useLocation();
const t = useT();
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
if (typeof window === "undefined") return true;
const stored = window.localStorage.getItem("plans.sidebarCollapsed.v3");
return stored ? stored === "true" : true;
});
const [chatSidebarCollapsed, setChatSidebarCollapsed] = useState(() => {
if (typeof window === "undefined") return false;
const stored = window.localStorage.getItem("plans.chatSidebarCollapsed.v1");
return stored ? stored === "true" : false;
});
const [planReaderImmersive, setPlanReaderImmersive] = useState(() => {
if (typeof window === "undefined") return false;
return (
isPlanDetailRoute(window.location.pathname) &&
window.document.documentElement.dataset.planReaderView !== "app"
);
});
useEffect(() => {
setMobileSidebarOpen(false);
}, [location.pathname]);
useEffect(() => {
window.localStorage.setItem(
"plans.sidebarCollapsed.v3",
String(sidebarCollapsed),
);
}, [sidebarCollapsed]);
useEffect(() => {
window.localStorage.setItem(
"plans.chatSidebarCollapsed.v1",
String(chatSidebarCollapsed),
);
}, [chatSidebarCollapsed]);
const ownsToolbar = routeOwnsToolbar(location.pathname);
const planDetailRoute = isPlanDetailRoute(location.pathname);
const chatRoute = location.pathname === "/";
const chatHomeHandoffActive = useAgentChatHomeHandoff({
storageKey: "plans",
activePath: location.pathname,
enabled: !chatRoute,
});
const chatHomeHandoffPending = isAgentChatHomeHandoffActive("plans");
useAgentChatHomeHandoffLinks({
storageKey: "plans",
chatPath: "/",
requireActiveHandoff: true,
});
const hideAppNavigation = planDetailRoute && planReaderImmersive;
const effectiveSidebarCollapsed = chatRoute
? chatSidebarCollapsed
: sidebarCollapsed;
const setEffectiveSidebarCollapsed = chatRoute
? setChatSidebarCollapsed
: setSidebarCollapsed;
useEffect(() => {
if (!planDetailRoute) {
setPlanReaderImmersive(false);
return;
}
const readCurrentView = () => {
setPlanReaderImmersive(
window.document.documentElement.dataset.planReaderView !== "app",
);
};
const onPlanReaderView = (event: Event) => {
const detail = (
event as CustomEvent<{
immersive?: boolean;
view?: "immersive" | "app";
}>
).detail;
if (typeof detail?.immersive === "boolean") {
setPlanReaderImmersive(detail.immersive);
return;
}
readCurrentView();
};
readCurrentView();
window.addEventListener(PLAN_READER_VIEW_EVENT, onPlanReaderView);
return () =>
window.removeEventListener(PLAN_READER_VIEW_EVENT, onPlanReaderView);
}, [planDetailRoute]);
// Embed mode: render just the reader, flowing — no Sidebar, no AgentSidebar,
// no h-screen shell. Those (some in shared core) lock the embed to the iframe
// height; bypassing them lets the document flow so the shell sizes to content
// (see global.css `html[data-embed]` + frame.ts content-height reporting).
const embedded = new URLSearchParams(location.search).get("embedded") === "1";
if (embedded) {
return (