+ parseStoredSidebarOpen(window.localStorage.getItem(SIDEBAR_STORAGE_KEY)),
+ );
+
+ useEffect(() => {
+ applySidebarOpen(open, {
+ setStoredValue: (key, value) => window.localStorage.setItem(key, value),
+ });
+ }, [open]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/src/components/layout/sidebar-toggle.tsx b/app/src/components/layout/sidebar-toggle.tsx
new file mode 100644
index 000000000..672f90931
--- /dev/null
+++ b/app/src/components/layout/sidebar-toggle.tsx
@@ -0,0 +1,89 @@
+import { IconLayoutSidebar } from "@tabler/icons-react";
+
+import { Button } from "@/components/ui/button";
+import { useOptionalSidebar } from "@/components/ui/sidebar";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+
+/**
+ * ⌘ on Apple platforms, Ctrl everywhere else. The primitive's listener accepts either modifier, so
+ * this only decides which of the two to name.
+ */
+const SHORTCUT_LABEL = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
+ ? "⌘B"
+ : "Ctrl+B";
+
+/**
+ * The control that opens and closes the shell's sidebar.
+ *
+ * WHAT THIS IS FIXING. The sidebar could always collapse — the primitive has had the state, the
+ * width transition and a ⌘B shortcut since it was vendored in. Nothing ever rendered a trigger for
+ * it. The only affordance was `SidebarRail`, a 16px transparent strip carrying `tabIndex={-1}`, so
+ * the eye could not find it and the keyboard could not reach it; and under 768px, where the sidebar
+ * becomes a Sheet that starts closed, there was no way to open the roster at all.
+ *
+ * It is therefore drawn in the chrome each screen already has, and in BOTH states, rather than
+ * inside the sidebar it hides — a trigger that disappears along with the sidebar cannot bring it
+ * back.
+ *
+ * Built from `Button` rather than the primitive's `SidebarTrigger` because that component hardcodes
+ * its own children after the prop spread, including an `sr-only` "Toggle Sidebar" that would
+ * contradict the label below. The state still belongs to the primitive: `toggleSidebar` is its hook.
+ */
+export function SidebarToggle({ className }: { className?: string }) {
+ const sidebar = useOptionalSidebar();
+ // No sidebar in scope, so nothing to toggle and nothing to draw.
+ if (!sidebar) return null;
+ const { isMobile, open, openMobile, toggleSidebar } = sidebar;
+ /*
+ * The label says what the click will do, not what is on screen. Below 768px the sidebar is an
+ * overlay Sheet with its own open state, and `open` describes the desktop pane — reading it there
+ * would name the wrong action.
+ */
+ const label = (isMobile ? openMobile : open)
+ ? "Hide sidebar"
+ : "Show sidebar";
+
+ return (
+
+
+
+
+ }
+ />
+ {/* An accelerator nobody is told about is not a feature. */}
+
+ {label}
+ {SHORTCUT_LABEL}
+
+
+ );
+}
+
+/**
+ * The toggle on a screen that draws no header of its own.
+ *
+ * Three `_app` screens open straight into their content, and the toggle still has to land in the
+ * same 48px band it occupies everywhere else — a control that moves between screens is a control
+ * somebody has to look for each time. No bottom border: a divider under an otherwise empty bar is a
+ * line with nothing to divide.
+ */
+export function SidebarToggleBar() {
+ return (
+
+
+
+ );
+}
diff --git a/app/src/components/ui/sidebar.tsx b/app/src/components/ui/sidebar.tsx
index b639c56fc..ceec714c1 100644
--- a/app/src/components/ui/sidebar.tsx
+++ b/app/src/components/ui/sidebar.tsx
@@ -25,8 +25,6 @@ import {
} from "@/components/ui/tooltip";
import { IconLayoutSidebar } from "@tabler/icons-react";
-const SIDEBAR_COOKIE_NAME = "sidebar_state";
-const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
@@ -53,6 +51,27 @@ function useSidebar() {
return context;
}
+/**
+ * The sidebar's state, or null where there is no sidebar.
+ *
+ * `useSidebar` throwing is right for a part OF a sidebar, where its absence is a wiring bug. A
+ * control that merely offers to toggle one is the other case: `PageShell` sits inside the three
+ * shells today, and a screen that renders it outside one is a layout choice rather than a defect.
+ * Throwing there would take a whole screen down over a button that should simply not be drawn.
+ */
+function useOptionalSidebar() {
+ return React.useContext(SidebarContext);
+}
+
+/**
+ * Sidebar state, uncontrolled by default.
+ *
+ * This vendored file used to write a `sidebar_state` cookie on every toggle, so a server-rendered
+ * shell could paint the right width on its first byte. Nothing renders this app on a server and
+ * nothing ever read the cookie back, so the preference lives in `lib/sidebar.ts` and the shells
+ * drive `open`/`onOpenChange` through `layout/sidebar-shell.tsx` instead. Re-adding the cookie would
+ * stand a second, staler answer beside that one.
+ */
function SidebarProvider({
defaultOpen = true,
open: openProp,
@@ -81,9 +100,6 @@ function SidebarProvider({
} else {
_setOpen(openState);
}
-
- // This sets the cookie to keep the sidebar state.
- document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
@@ -719,5 +735,6 @@ export {
SidebarRail,
SidebarSeparator,
SidebarTrigger,
+ useOptionalSidebar,
useSidebar,
};
diff --git a/app/src/lib/sidebar.ts b/app/src/lib/sidebar.ts
new file mode 100644
index 000000000..8a0d7fa89
--- /dev/null
+++ b/app/src/lib/sidebar.ts
@@ -0,0 +1,33 @@
+/**
+ * Whether the shell's sidebar is open, remembered across reloads.
+ *
+ * The primitive under `components/ui/sidebar.tsx` writes a `sidebar_state` cookie of its own, which
+ * exists so a server-rendered shell can paint the right width on the first byte. Nothing renders
+ * this app on a server, and nothing ever read that cookie back — so the preference lives here
+ * instead, in the same shape and the same storage as the theme preference next door.
+ */
+export const SIDEBAR_STORAGE_KEY = "openbot-sidebar";
+
+/**
+ * Only the exact stored `collapsed` starts the sidebar closed.
+ *
+ * Everything else opens it: a key never written, a value from an older build, a value somebody
+ * else's script left behind. The roster is this app's navigation, and the cost of the two mistakes
+ * is not symmetric — opening a sidebar somebody wanted shut costs them one click, while shutting one
+ * on a guess hides every channel they have behind an affordance they have not found yet.
+ */
+export function parseStoredSidebarOpen(value: string | null) {
+ return value !== "collapsed";
+}
+
+type SidebarEffects = {
+ setStoredValue: (key: string, value: string) => void;
+};
+
+/**
+ * Records the state in the vocabulary the primitive already uses for it — `expanded` and
+ * `collapsed`, the two values of its `data-state` attribute — rather than inventing a second pair.
+ */
+export function applySidebarOpen(open: boolean, effects: SidebarEffects) {
+ effects.setStoredValue(SIDEBAR_STORAGE_KEY, open ? "expanded" : "collapsed");
+}
diff --git a/app/src/routes/_authed/_app.tsx b/app/src/routes/_authed/_app.tsx
index 7b46a4900..dcf51a53d 100644
--- a/app/src/routes/_authed/_app.tsx
+++ b/app/src/routes/_authed/_app.tsx
@@ -1,6 +1,6 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
import { AppSidebar } from "@/components/app-sidebar/app-sidebar";
-import { SidebarProvider } from "@/components/ui/sidebar";
+import { SidebarShell } from "@/components/layout/sidebar-shell";
export const Route = createFileRoute("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/_authed/_app")({
component: RouteComponent,
@@ -10,19 +10,11 @@ function RouteComponent() {
return (
// One viewport, never scrolls: panes scroll inside it. A growable shell lets the transcript's
// scroller size against the page, grow it, and grow again.
-
+
-
+
);
}
diff --git a/app/src/routes/_authed/_app/agents/index.tsx b/app/src/routes/_authed/_app/agents/index.tsx
index aa4b42162..7ff455ff5 100644
--- a/app/src/routes/_authed/_app/agents/index.tsx
+++ b/app/src/routes/_authed/_app/agents/index.tsx
@@ -6,6 +6,7 @@ import { AgentCard } from "@/components/agents/agent-card";
import { AgentProfile as AgentProfileDetail } from "@/components/agents/agent-profile";
import { NewAgent } from "@/components/agents/new-agent";
import { DetailPanel } from "@/components/layout/detail-panel";
+import { SidebarToggleBar } from "@/components/layout/sidebar-toggle";
import { StaggerItem } from "@/components/layout/stagger";
import { Button } from "@/components/ui/button";
import { Empty, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
@@ -58,6 +59,7 @@ function AgentsScreen() {
) : null
}
>
+
diff --git a/app/src/routes/_authed/_app/bot.tsx b/app/src/routes/_authed/_app/bot.tsx
index 7eed66aa8..20e925f5a 100644
--- a/app/src/routes/_authed/_app/bot.tsx
+++ b/app/src/routes/_authed/_app/bot.tsx
@@ -2,6 +2,7 @@ import { CopilotChat } from "@copilotkit/react-core/v2";
import { IconPlus } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
+import { SidebarToggleBar } from "@/components/layout/sidebar-toggle";
import { Button } from "@/components/ui/button";
import { agentListQueryOptions } from "@/lib/agents/queries";
import { useActiveBot } from "@/lib/copilot/active-bot";
@@ -76,6 +77,7 @@ function BotChat({ agentId, name }: { agentId: string; name: string }) {
return (