# Composable Collapsible Sidebar Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the page-local navigation shell with a shadcn-style composable sidebar that expands to 288px on desktop, collapses to a useful 64px icon rail, defaults to a compact rail on tablet, and remains an off-canvas drawer on mobile. **Architecture:** Put deterministic breakpoint and initial-state decisions in a pure sidebar-state module; put provider state, responsive mechanics, shortcuts, tooltips, and semantic primitives in `components/ui/sidebar.tsx`; put Jyotisha-specific brand, new-chat, history, and account composition in `components/app-sidebar.tsx`. Keep sessions, request locks, account mutations, dialogs, and API calls in `app/page.tsx`, which supplies typed data and callbacks to the product component. **Tech Stack:** Next.js 16 App Router, React 19, TypeScript 5, Base UI 1.6 Popover/Tooltip, Tailwind CSS 4 infrastructure with existing global token CSS, Lucide icons, Node test runner, ESLint. ## Global Constraints - Follow `docs/superpowers/specs/2026-07-17-composable-sidebar-design.md` and `frontend/DESIGN.md`; when they disagree, the dated sidebar spec governs this feature. - Treat the user's pasted Base Nova sidebar documentation as the composition reference. Do not import the stock shadcn visual palette. - Do not run `shadcn add sidebar` against the working tree: it may overwrite the existing Base UI `Button`. Task 3 inspects the registry source read-only, then hand-adapts only the retained primitives. - Add no dependency unless an imported Base UI primitive is demonstrably absent from the existing `@base-ui/react` package. - Use only Jyotisha color, spacing, radius, shadow, typography, and motion tokens; no raw colors or new shadow recipes. - Preserve all session selection, request lock, onboarding, model selection, cancellation, credit, profile, redemption, administrator, and logout behavior. - Existing sessions remain clickable while another session is answering. Only new requests and new-chat creation retain their current request locks. - Keep the account task dialogs in `page.tsx`. The extracted account menu may only route to callbacks. - Do not persist sidebar state in cookies or local storage. Reload defaults are desktop expanded, tablet collapsed, and mobile closed. - Do not animate grid-column width or another layout property. Animate only opacity, transform, and colors; respect reduced-motion. - Keep the current unrelated working-tree changes untouched. Do not push during implementation. - Run commands that reference `node_modules/`, `src/`, `tests/`, `npm`, `npx`, or `tsc` from `frontend/`. Run `git` commands from the repository root so the paths shown below resolve exactly. --- ## Target File Map | File | Responsibility | |---|---| | `frontend/src/lib/sidebar-state.ts` | Pure viewport classification and default-state rules. | | `frontend/src/hooks/use-sidebar-viewport.ts` | One browser subscription for mobile/tablet/desktop viewport state and hydration readiness. | | `frontend/src/components/ui/sidebar.tsx` | Provider, context, generic semantic primitives, trigger, rail, tooltip, mobile focus/keyboard mechanics. | | `frontend/src/components/app-sidebar.tsx` | Jyotisha brand, new-chat action, session history, collapsed history control, account menu composition. | | `frontend/src/app/page.tsx` | Business state and callbacks; provider/app-sidebar integration; existing task dialogs. | | `frontend/src/app/globals.css` | Token aliases, shell widths, responsive drawer/rail states, account popover, tooltip, accessibility preferences. | | `frontend/tests/sidebar-state.test.ts` | Pure state unit tests. | | `frontend/tests/sidebar-contract.test.ts` | Source-level architecture and token contract tests. | | `frontend/tests/starter-questions.test.ts` | Update existing account/session assertions to follow extracted markup. | | `frontend/DESIGN.md` | Shipped component contract for the new shell. | --- ### Task 1: Lock the architecture and design contract **Files:** - Create: `frontend/tests/sidebar-contract.test.ts` - Modify: `frontend/DESIGN.md` **Interfaces:** - Consumes: the approved design spec and existing source-contract testing style. - Produces: an initially red architecture test that prevents a partial migration or stock-theme regression. - [ ] **Step 1: Read the local Next.js 16 guidance before touching client boundaries** Run: ```bash sed -n '1,220p' node_modules/next/dist/docs/01-app/01-getting-started/05-server-and-client-components.md sed -n '1,180p' node_modules/next/dist/docs/01-app/03-api-reference/01-directives/use-client.md sed -n '1,180p' node_modules/next/dist/docs/01-app/02-guides/preventing-flash-before-hydration.md ``` Expected: confirm that the sidebar provider and product sidebar are client components, props crossing boundaries remain serializable only where a Server Component boundary exists, and initial responsive markup must not depend on reading `window` during render. - [ ] **Step 2: Add the failing structural contract test** Create `frontend/tests/sidebar-contract.test.ts`: ```ts import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; import test from "node:test"; const projectFile = (path: string) => new URL(`../${path}`, import.meta.url); const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8"); test("uses a composable sidebar instead of page-local navigation state", () => { assert.equal(existsSync(projectFile("src/components/ui/sidebar.tsx")), true); assert.equal(existsSync(projectFile("src/components/app-sidebar.tsx")), true); const page = readProjectFile("src/app/page.tsx"); assert.match(page, //); assert.match(page, / { const sidebar = readProjectFile("src/components/ui/sidebar.tsx"); for (const name of [ "SidebarProvider", "Sidebar", "SidebarHeader", "SidebarContent", "SidebarGroup", "SidebarGroupLabel", "SidebarGroupContent", "SidebarMenu", "SidebarMenuItem", "SidebarMenuButton", "SidebarFooter", "SidebarInset", "SidebarTrigger", "SidebarRail", "useSidebar", ]) { assert.match(sidebar, new RegExp(`export (?:function|const) ${name}\\b`)); } assert.doesNotMatch(sidebar, /SidebarMenuBadge|SidebarMenuSkeleton|SidebarMenuSub|side\?:|variant\?:/); }); test("maps sidebar semantics to Jyotisha tokens", () => { const styles = readProjectFile("src/app/globals.css"); assert.match(styles, /--sidebar-background:\s*var\(--color-sidebar\)/); assert.match(styles, /--sidebar-solid:\s*var\(--color-sidebar-solid\)/); assert.match(styles, /--sidebar-foreground:\s*var\(--color-ink\)/); assert.match(styles, /--sidebar-accent:\s*var\(--color-selected\)/); assert.match(styles, /--sidebar-border:\s*var\(--color-border\)/); assert.match(styles, /--sidebar-ring:\s*var\(--color-focus\)/); }); ``` - [ ] **Step 3: Run the new test and confirm the intended red state** Run: `node --test tests/sidebar-contract.test.ts` Expected: failures report missing `ui/sidebar.tsx`, missing `app-sidebar.tsx`, and the old page-local state. A syntax/import failure is not the expected red state and must be corrected first. - [ ] **Step 4: Add the shipped Sidebar primitive to `frontend/DESIGN.md`** Insert after `### Navigation item`: ```md ### Sidebar shell - **Composition:** provider, fixed header, one scroll-owning content region, fixed footer, trigger, rail, and flexible chat inset. - **Desktop:** 288px expanded by default at 1024px and above; 64px collapsed icon rail. - **Tablet:** 64px collapsed by default from 768px through 1023px; 240px when expanded. - **Mobile:** no icon rail; an off-canvas drawer uses `min(86vw, 320px)` and closes through its scrim, trigger, or Escape. - **Collapsed content:** logo, new-chat action, one history expansion action, and account avatar. Individual sessions do not become indistinguishable repeated icons. - **Scroll ownership:** header and footer remain fixed; `SidebarContent` is the sole sidebar scroll owner. - **Accessibility:** Command/Control+B shortcut outside editable controls, contextual trigger labels, 44px targets, focus return, collapsed-only tooltips, reduced-motion, reduced-transparency, and increased-contrast support. - **State:** session-local; reload uses breakpoint defaults rather than cookie or local-storage persistence. ``` - [ ] **Step 5: Check the contract artifacts without committing a broken test state** Run: ```bash git diff --check -- frontend/DESIGN.md frontend/tests/sidebar-contract.test.ts ``` Expected: whitespace check passes. Leave both files uncommitted until Task 3 makes the structural contract green; never create a commit that knowingly breaks the frontend test suite. --- ### Task 2: Implement deterministic responsive state **Files:** - Create: `frontend/src/lib/sidebar-state.ts` - Create: `frontend/src/hooks/use-sidebar-viewport.ts` - Create: `frontend/tests/sidebar-state.test.ts` **Interfaces:** ```ts export type SidebarViewport = "mobile" | "tablet" | "desktop"; export function sidebarViewportForWidth(width: number): SidebarViewport; export function defaultSidebarOpen(viewport: SidebarViewport): boolean; export function shouldHandleSidebarShortcut(event: Pick): boolean; export function useSidebarViewport(): { viewport: SidebarViewport; ready: boolean }; ``` - [ ] **Step 1: Write pure-state tests first** Create `frontend/tests/sidebar-state.test.ts`: ```ts import assert from "node:assert/strict"; import test from "node:test"; import { defaultSidebarOpen, shouldHandleSidebarShortcut, sidebarViewportForWidth, } from "../src/lib/sidebar-state.ts"; test("classifies the exact sidebar breakpoints", () => { assert.equal(sidebarViewportForWidth(0), "mobile"); assert.equal(sidebarViewportForWidth(767), "mobile"); assert.equal(sidebarViewportForWidth(768), "tablet"); assert.equal(sidebarViewportForWidth(1023), "tablet"); assert.equal(sidebarViewportForWidth(1024), "desktop"); }); test("uses the approved reload defaults", () => { assert.equal(defaultSidebarOpen("mobile"), false); assert.equal(defaultSidebarOpen("tablet"), false); assert.equal(defaultSidebarOpen("desktop"), true); }); test("accepts Command/Control+B only outside editable controls", () => { const shortcut = { key: "b", metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }; assert.equal(shouldHandleSidebarShortcut({ ...shortcut, target: null }), true); assert.equal(shouldHandleSidebarShortcut({ ...shortcut, target: { tagName: "TEXTAREA" } }), false); assert.equal(shouldHandleSidebarShortcut({ ...shortcut, target: { isContentEditable: true } }), false); assert.equal(shouldHandleSidebarShortcut({ ...shortcut, key: "k", target: null }), false); }); ``` - [ ] **Step 2: Run the state test and confirm red** Run: `node --test tests/sidebar-state.test.ts` Expected: module-not-found for `src/lib/sidebar-state.ts`. - [ ] **Step 3: Implement the pure rules** Create `frontend/src/lib/sidebar-state.ts`: ```ts export type SidebarViewport = "mobile" | "tablet" | "desktop"; export function sidebarViewportForWidth(width: number): SidebarViewport { if (width < 768) return "mobile"; if (width < 1024) return "tablet"; return "desktop"; } export function defaultSidebarOpen(viewport: SidebarViewport) { return viewport === "desktop"; } export function shouldHandleSidebarShortcut( event: { key: string; metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean; target: { tagName?: string; isContentEditable?: boolean } | null; }, ) { const target = event.target; const editable = Boolean(target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName ?? ""))); return !editable && event.key.toLowerCase() === "b" && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey; } ``` Keep `HTMLElement` usage inside the browser-called function; the current unit tests import but do not invoke that branch in Node. - [ ] **Step 4: Add one viewport subscription hook** Create `frontend/src/hooks/use-sidebar-viewport.ts` as a client module. Initialize `{ viewport: "desktop", ready: false }` so server and first-client markup match. In one effect: 1. classify `window.innerWidth`; 2. set `{ viewport, ready: true }`; 3. subscribe to `window.resize` with the same classifier; 4. remove the listener on cleanup. Do not read `window` in a state initializer and do not create three independent `matchMedia` subscriptions. - [ ] **Step 5: Run state tests and static checks** Run: ```bash node --test tests/sidebar-state.test.ts npx tsc --noEmit git diff --check -- frontend/src/lib/sidebar-state.ts frontend/src/hooks/use-sidebar-viewport.ts frontend/tests/sidebar-state.test.ts ``` Expected: both state tests pass; TypeScript and whitespace checks exit 0. - [ ] **Step 6: Commit the responsive state unit** Run: ```bash git add frontend/src/lib/sidebar-state.ts frontend/src/hooks/use-sidebar-viewport.ts frontend/tests/sidebar-state.test.ts git commit -m "feat: add responsive sidebar state model" ``` --- ### Task 3: Build the retained generic sidebar primitives **Files:** - Create: `frontend/src/components/ui/sidebar.tsx` - Modify: `frontend/tests/sidebar-contract.test.ts` **Interfaces:** ```ts export type SidebarState = "expanded" | "collapsed"; export type SidebarContextValue = { state: SidebarState; open: boolean; setOpen: (open: boolean) => void; openMobile: boolean; setOpenMobile: (open: boolean) => void; viewport: SidebarViewport; ready: boolean; isMobile: boolean; toggleSidebar: () => void; }; export type SidebarProviderProps = React.ComponentProps<"div"> & { defaultOpen?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; onMobileOpenChange?: (open: boolean) => void; escapeBlocked?: boolean; }; export type SidebarTriggerProps = React.ComponentProps<"button"> & { placement?: "inset" | "sidebar"; }; ``` - [ ] **Step 1: Inspect the current Base Nova source without mutating files** Run: `npx shadcn@latest view sidebar` Expected: registry source/API is printed. Compare its provider, data attributes, trigger, rail, and menu semantics with the approved spec. Do not run `add`, do not accept generated `Button`, `Sheet`, skeleton, badge, submenu, right-side, floating, or inset-variant code. - [ ] **Step 2: Extend the contract test for behavioral invariants** Append assertions that the primitive source: - imports `useSidebarViewport`, `defaultSidebarOpen`, and `shouldHandleSidebarShortcut`; - uses `data-state`, `data-viewport`, and `data-mobile-open`; - uses one `keydown` listener and calls `preventDefault()` for the accepted shortcut; - closes `openMobile` when leaving mobile mode; - imports Base UI Tooltip and never imports a Sheet; - uses `cn` and never includes raw hex/HSL colors. Run: `node --test tests/sidebar-contract.test.ts` Expected: the original missing-file failure changes to specific missing-export/behavior failures after the file is created in the next step. - [ ] **Step 3: Implement `SidebarProvider` and `useSidebar`** In `frontend/src/components/ui/sidebar.tsx`: 1. add `"use client"`; 2. create a context initialized to `null` and a `useSidebar` hook that throws outside the provider; 3. support controlled and uncontrolled desktop `open` state; 4. keep mobile `openMobile` independent; 5. use a `userChangedDesktopState` ref so the first ready viewport applies its breakpoint default, while later resizes do not erase a user's explicit desktop/tablet choice; 6. close mobile state whenever `viewport !== "mobile"`; 7. route `toggleSidebar` to `openMobile` on mobile and desktop `open` otherwise; 8. install Command/Control+B through `shouldHandleSidebarShortcut`; 9. render a provider wrapper with `data-state`, `data-viewport`, `data-ready`, and `data-mobile-open`. The provider wrapper must not calculate viewport-dependent inline markup before `ready`; CSS will use `data-ready="false"` plus media queries for the initial visual width. - [ ] **Step 4: Implement the semantic primitives** Each primitive forwards native element props, merges classes with `cn`, and adds stable `data-slot`/`data-sidebar` attributes: ```ts Sidebar // aside, id="chat-sidebar", aria-label supplied by caller SidebarHeader // div SidebarContent // div; sole scroll owner SidebarGroup // section SidebarGroupLabel // h2 by default, supports tabIndex for empty-history focus SidebarGroupContent // div SidebarMenu // ul SidebarMenuItem // li SidebarMenuButton // button; accepts isActive and optional collapsed tooltip SidebarFooter // div SidebarInset // section; combines caller inert with isMobile && openMobile SidebarTrigger // button; contextual aria-label and aria-expanded SidebarRail // button; desktop/tablet only, aria-label reflects action ``` `SidebarMenuButton` must accept: ```ts type SidebarMenuButtonProps = React.ComponentProps<"button"> & { isActive?: boolean; tooltip?: string; }; ``` When `tooltip` is present, wrap only the trigger with Base UI `Tooltip.Root`, `Tooltip.Portal`, `Tooltip.Positioner side="right" sideOffset={8}`, and `Tooltip.Popup`. Mount the tooltip only when `state === "collapsed" && !isMobile`; expanded and mobile states render no tooltip popup. - [ ] **Step 5: Implement mobile focus and Escape behavior in the provider layer** Keep refs for the chat-header trigger and mobile sidebar close trigger inside the provider. `SidebarTrigger placement="inset"` registers the return target; `SidebarTrigger placement="sidebar"` registers the drawer target. When the mobile drawer opens, focus the sidebar trigger on the next animation frame. When it closes, return focus to the inset trigger. The mobile Escape handler must: 1. do nothing while `escapeBlocked` is true; 2. close only the mobile drawer; 3. never collapse desktop/tablet state. The page supplies `escapeBlocked={accountMenuOpen || activeAccountDialog !== null}`. This makes the Escape priority explicit and independent of effect registration order or DOM class queries. - [ ] **Step 6: Run the primitive contract and type checks** Run: ```bash node --test tests/sidebar-contract.test.ts npx tsc --noEmit npm run lint -- src/components/ui/sidebar.tsx src/hooks/use-sidebar-viewport.ts src/lib/sidebar-state.ts ``` Expected: retained-export and provider-behavior assertions pass. The page-integration assertions remain red until Task 5. TypeScript and ESLint exit 0. - [ ] **Step 7: Commit the primitive foundation** Run: ```bash git add frontend/src/components/ui/sidebar.tsx frontend/tests/sidebar-contract.test.ts frontend/DESIGN.md git commit -m "feat: add composable sidebar primitives" ``` --- ### Task 4: Compose the Jyotisha product sidebar and portal account menu **Files:** - Create: `frontend/src/components/app-sidebar.tsx` - Modify: `frontend/tests/sidebar-contract.test.ts` - Modify: `frontend/tests/starter-questions.test.ts` **Interfaces:** ```ts export type SidebarSession = { id: string; title: string; messageCount: number; }; export type SidebarAccount = { name: string; email: string; credits: number; isAdmin: boolean; initial: string; }; export type AppSidebarProps = { sessions: readonly SidebarSession[]; activeSessionId: string | null; account: SidebarAccount; accountMenuOpen: boolean; accountTriggerRef: React.Ref; newChatDisabled: boolean; creatingSession: boolean; onAccountMenuOpenChange: (open: boolean) => void; onNewChat: () => void; onSelectSession: (sessionId: string) => void; onOpenProfile: () => void; onOpenRedeem: () => void; onOpenLogout: () => void; }; ``` - [ ] **Step 1: Add product-composition tests before implementation** Extend `sidebar-contract.test.ts` to require: - `AppSidebar` composes `SidebarHeader`, `SidebarContent`, `SidebarFooter`, and `SidebarRail`; - collapsed history uses one `MessageSquareText` action and not a mapped list of icon-only sessions; - selecting a session invokes `onSelectSession(session.id)` even while request state exists outside this component; - account menu imports `Popover` from `@base-ui/react/popover`, uses `Popover.Portal`, and sets `collisionPadding={12}`; - `AppSidebarProps` contains callbacks/data only and no Supabase, `fetch`, or API import. Update the account assertions in `starter-questions.test.ts` to read `app-sidebar.tsx` for account popover/admin-link markup while keeping dialog routing assertions against `page.tsx`. Run: `node --test tests/sidebar-contract.test.ts tests/starter-questions.test.ts` Expected: product composition tests fail because `app-sidebar.tsx` does not exist. - [ ] **Step 2: Implement brand and new-chat regions** Create the header with the existing `brand-mark` plus `Jyotisha` wordmark. Create the existing new-chat button through `SidebarMenuButton`: - expanded label: `正在创建` or `新对话`; - collapsed visual: centered Plus icon; - collapsed tooltip: `新对话`; - disabled value comes exclusively from `newChatDisabled`; - mobile activation closes the drawer only after invoking `onNewChat`. - [ ] **Step 3: Implement history expansion and session rows** Use `useSidebar()` and refs for the first session and history heading. In collapsed desktop/tablet state render one history control. Its handler must: ```ts setOpen(true); window.requestAnimationFrame(() => { (firstSessionRef.current ?? historyHeadingRef.current)?.focus(); }); ``` Expanded/mobile content renders: - focusable `聊天记录` heading; - `暂无对话` for an empty array; - one row per session with title, optional `${messageCount} 条消息`, `aria-current="page"`, and one-line truncation; - `onSelectSession(session.id)` without a pending-request disabled prop. After selection, close the drawer only when `isMobile`; leave desktop/tablet expansion unchanged. - [ ] **Step 4: Implement the responsive account trigger and menu with Base UI Popover** Use a controlled `Popover.Root open={accountMenuOpen} onOpenChange={onAccountMenuOpenChange}`. The trigger shows avatar/name expanded and avatar only collapsed. Configure the positioner from sidebar context: ```tsx ``` Keep the current identity header, profile action, redeem action/balance, administrator-only `/admin/codes` link, separator, and logout action. Each task action first closes the popover and then invokes its callback. Do not render task forms here. When state or viewport changes while the popover is open, close it through `onAccountMenuOpenChange(false)` so it cannot remain detached from its trigger. - [ ] **Step 5: Run product tests and checks** Run: ```bash node --test tests/sidebar-contract.test.ts tests/starter-questions.test.ts npx tsc --noEmit npm run lint -- src/components/app-sidebar.tsx ``` Expected: product/account structural tests pass; page-integration portions remain red. TypeScript and ESLint exit 0. - [ ] **Step 6: Commit the product composition** Run: ```bash git add frontend/src/components/app-sidebar.tsx frontend/tests/sidebar-contract.test.ts frontend/tests/starter-questions.test.ts git commit -m "feat: compose Jyotisha app sidebar" ``` --- ### Task 5: Replace page-local sidebar state and markup **Files:** - Modify: `frontend/src/app/page.tsx` - Modify: `frontend/tests/sidebar-contract.test.ts` **Interfaces:** - Consumes: existing `sessions`, `activeSession`, `profile`, `account`, `creatingSession`, request locks, account dialog handlers, and refs. - Produces: typed props/callbacks for `AppSidebar`; no sidebar responsive state in the page. - [ ] **Step 1: Import the new composition and remove obsolete imports/state/refs** Add imports for `AppSidebar`, `SidebarProvider`, `SidebarInset`, and `SidebarTrigger`. Remove sidebar-only Lucide icons from `page.tsx` once no longer used there. Delete: ```ts const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const accountMenu = useRef(null); const mobileMenuTrigger = useRef(null); const sidebar = useRef(null); const sidebarCloseButton = useRef(null); ``` Delete the page-local mobile-drawer focus effect and page-local account outside-click/Escape effect; Base UI Popover and `SidebarProvider` now own those interactions. Keep `accountTrigger`, because account dialogs return focus to it. - [ ] **Step 2: Isolate page-owned session callbacks** Change `startNewChat` only by removing `setMobileSidebarOpen(false)`. Add: ```ts function selectSession(sessionId: string) { setActiveSessionId(sessionId); setDraft(""); setComposerNotice(""); } ``` Do not add request-state guards to `selectSession`; reading another session remains available during an answer. - [ ] **Step 3: Build serializable view data without moving domain types** Immediately before the successful return, derive: ```ts const sidebarAccount = { name: profile.name.trim() || account.user.email || "账户", email: account.user.email || "尚未读取邮箱", credits: account.credits, isAdmin: account.isAdmin, initial: profile.name.trim().slice(0, 1) || account.user.email?.slice(0, 1).toUpperCase() || "你", }; const sidebarSessions = sessions.map((session) => ({ id: session.id, title: session.title, messageCount: session.messages.length, })); ``` These are client-to-client props; no server serialization boundary is introduced. - [ ] **Step 4: Replace the shell markup** Use this hierarchy: ```tsx
void startNewChat()} onSelectSession={selectSession} onOpenProfile={() => openAccountDialog("profile")} onOpenRedeem={() => openAccountDialog("redeem")} onOpenLogout={() => openAccountDialog("logout")} /> {/* existing chat panel contents */} {/* existing account task-dialog overlays remain siblings inside main */}
``` Pass `escapeBlocked={accountMenuOpen || activeAccountDialog !== null}` to `SidebarProvider`, and render `SidebarTrigger placement="inset"` in the chat header. `AppSidebar` renders `SidebarTrigger placement="sidebar"` as the mobile close control in its brand row; CSS hides it outside the open mobile drawer. Remove the old backdrop, inline `