38 KiB
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.mdandfrontend/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 sidebaragainst the working tree: it may overwrite the existing Base UIButton. 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/reactpackage. - 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, ortscfromfrontend/. Rungitcommands 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:
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:
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, /<SidebarProvider>/);
assert.match(page, /<AppSidebar/);
assert.match(page, /<SidebarInset/);
assert.match(page, /<SidebarTrigger/);
assert.doesNotMatch(page, /mobileSidebarOpen|setMobileSidebarOpen/);
assert.doesNotMatch(page, /className="sidebar-backdrop"/);
assert.doesNotMatch(page, /<aside className="sidebar"/);
});
test("exports only the retained sidebar composition surface", () => {
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:
### 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:
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:
export type SidebarViewport = "mobile" | "tablet" | "desktop";
export function sidebarViewportForWidth(width: number): SidebarViewport;
export function defaultSidebarOpen(viewport: SidebarViewport): boolean;
export function shouldHandleSidebarShortcut(event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey" | "target">): boolean;
export function useSidebarViewport(): { viewport: SidebarViewport; ready: boolean };
- Step 1: Write pure-state tests first
Create frontend/tests/sidebar-state.test.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:
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:
- classify
window.innerWidth; - set
{ viewport, ready: true }; - subscribe to
window.resizewith the same classifier; - 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:
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:
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:
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, andshouldHandleSidebarShortcut; - uses
data-state,data-viewport, anddata-mobile-open; - uses one
keydownlistener and callspreventDefault()for the accepted shortcut; - closes
openMobilewhen leaving mobile mode; - imports Base UI Tooltip and never imports a Sheet;
- uses
cnand 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
SidebarProvideranduseSidebar
In frontend/src/components/ui/sidebar.tsx:
- add
"use client"; - create a context initialized to
nulland auseSidebarhook that throws outside the provider; - support controlled and uncontrolled desktop
openstate; - keep mobile
openMobileindependent; - use a
userChangedDesktopStateref so the first ready viewport applies its breakpoint default, while later resizes do not erase a user's explicit desktop/tablet choice; - close mobile state whenever
viewport !== "mobile"; - route
toggleSidebartoopenMobileon mobile and desktopopenotherwise; - install Command/Control+B through
shouldHandleSidebarShortcut; - render a provider wrapper with
data-state,data-viewport,data-ready, anddata-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:
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:
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:
- do nothing while
escapeBlockedis true; - close only the mobile drawer;
- 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:
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:
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:
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<HTMLButtonElement>;
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:
AppSidebarcomposesSidebarHeader,SidebarContent,SidebarFooter, andSidebarRail;- collapsed history uses one
MessageSquareTextaction 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
Popoverfrom@base-ui/react/popover, usesPopover.Portal, and setscollisionPadding={12}; AppSidebarPropscontains 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:
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:
<Popover.Positioner
side={isMobile || state === "expanded" ? "top" : "right"}
align={isMobile || state === "expanded" ? "end" : "center"}
sideOffset={8}
collisionPadding={12}
>
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:
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:
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:
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const accountMenu = useRef<HTMLDivElement>(null);
const mobileMenuTrigger = useRef<HTMLButtonElement>(null);
const sidebar = useRef<HTMLElement>(null);
const sidebarCloseButton = useRef<HTMLButtonElement>(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:
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:
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:
<SidebarProvider escapeBlocked={accountMenuOpen || activeAccountDialog !== null}>
<main className="chat-app">
<AppSidebar
sessions={sidebarSessions}
activeSessionId={activeSession?.id ?? null}
account={sidebarAccount}
accountMenuOpen={accountMenuOpen}
accountTriggerRef={accountTrigger}
newChatDisabled={!hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending}
creatingSession={creatingSession}
onAccountMenuOpenChange={setAccountMenuOpen}
onNewChat={() => void startNewChat()}
onSelectSession={selectSession}
onOpenProfile={() => openAccountDialog("profile")}
onOpenRedeem={() => openAccountDialog("redeem")}
onOpenLogout={() => openAccountDialog("logout")}
/>
<SidebarInset className="chat-panel" inert={activeAccountDialog !== null}>
{/* existing chat panel contents */}
</SidebarInset>
{/* existing account task-dialog overlays remain siblings inside main */}
</main>
</SidebarProvider>
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 <aside>, footer/menu markup, and mobile-only trigger. Put SidebarTrigger immediately before the existing chat title block.
SidebarInset must combine the caller's modal inertness with its own isMobile && openMobile; the page must never calculate drawer inertness itself.
- Step 5: Preserve Escape ordering explicitly
Confirm the existing account-dialog keydown listener remains registered only while a dialog is open. Base UI Popover consumes Escape while the account menu is open. Assert in sidebar-contract.test.ts that the page passes escapeBlocked={accountMenuOpen || activeAccountDialog !== null} and that the provider's mobile Escape branch returns while escapeBlocked is true.
- Step 6: Run the page integration contract
Run:
node --test tests/sidebar-contract.test.ts tests/starter-questions.test.ts
npx tsc --noEmit
npm run lint -- src/app/page.tsx src/components/app-sidebar.tsx src/components/ui/sidebar.tsx
Expected: all sidebar contract assertions pass; existing account/session tests pass; TypeScript and ESLint exit 0.
- Step 7: Commit the page migration
Run:
git add frontend/src/app/page.tsx frontend/tests/sidebar-contract.test.ts
git commit -m "refactor: migrate chat shell to app sidebar"
Task 6: Implement the Jyotisha sidebar visual states
Files:
- Modify:
frontend/src/app/globals.css - Modify:
frontend/tests/sidebar-contract.test.ts
Interfaces:
-
Consumes: provider data attributes and stable primitive class/data-slot names.
-
Produces: desktop/tablet grid widths, mobile drawer, collapsed rail, portal popup, tooltips, focus, and accessibility preference styles.
-
Step 1: Add semantic aliases and shell dimensions
Add aliases beside the existing root tokens:
--sidebar-background: var(--color-sidebar);
--sidebar-solid: var(--color-sidebar-solid);
--sidebar-foreground: var(--color-ink);
--sidebar-muted-foreground: var(--color-ink-secondary);
--sidebar-accent: var(--color-selected);
--sidebar-accent-foreground: var(--color-ink);
--sidebar-border: var(--color-border);
--sidebar-ring: var(--color-focus);
--sidebar-primary: var(--color-surface-dark);
--sidebar-primary-foreground: var(--color-on-dark);
--sidebar-width-desktop: 288px;
--sidebar-width-tablet: 240px;
--sidebar-width-icon: 64px;
--sidebar-width-mobile: min(86vw, 320px);
Use these aliases everywhere in the new sidebar selectors.
- Step 2: Replace the desktop/tablet shell rules
The provider wrapper fills 100dvh. .chat-app remains a two-column grid whose first column is selected by provider data:
- desktop expanded:
288px; - tablet expanded:
240px; - desktop/tablet collapsed:
64px; - mobile:
1fr.
Do not add transition: width or transition: grid-template-columns. Collapsed labels may use opacity/transform but must also leave the accessibility tree only when their control has a replacement accessible label.
- Step 3: Make
SidebarContentthe only sidebar scroll owner
Set the sidebar shell to a fixed-height flex column. Header/footer use flex: 0 0 auto; content uses min-height: 0; overflow-y: auto. Remove overflow-y: auto from .session-list so large histories do not create a nested scrollbar.
Keep one-line session truncation and the existing active marker.
- Step 4: Style collapsed rail content
At data-state="collapsed" and non-mobile viewport:
- center the 44px logo/new-chat/history/avatar controls in 64px;
- hide wordmark, session list, metadata, and account text visually;
- keep full accessible names through
aria-label/tooltip trigger text; - show
SidebarRailas a narrow edge target with a visible focus ring; - do not render or style repeated session icons.
Use 120ms opacity/transform/color transitions only. Disabled new-chat remains visibly disabled.
- Step 5: Port the existing mobile drawer to provider data attributes
Below 768px:
- make grid one column;
- position sidebar fixed at the left with
width: var(--sidebar-width-mobile); - translate it fully off-canvas when
data-mobile-open="false"; - show a fixed scrim only when open;
- use the existing 180ms transform/opacity/visibility choreography;
- add safe-area padding;
- hide
SidebarRail; - render full expanded labels regardless of desktop
openstate.
Remove all .sidebar-open selectors because the class no longer exists.
- Step 6: Style portal account menu and collapsed tooltips
Replace .account-menu { position: absolute; right: 0; bottom: ... } with Base UI positioner/popup rules:
- positioner owns z-index and viewport constraints;
- popup width remains
min(280px, calc(100vw - var(--space-6))); - background, border, radius, and shadow stay token-based;
- starting/ending styles use opacity and 4px transform;
- transform origin follows Base UI's origin custom property if exposed.
Tooltip popup uses the sidebar solid/canvas surface, border, caption typography, and a 120ms opacity/translate entry. It must never intercept pointer events.
- Step 7: Preserve accessibility preferences
Extend existing media rules:
@media (prefers-reduced-motion: reduce) { /* remove sidebar/popup/tooltip transforms */ }
@media (prefers-reduced-transparency: reduce) { /* use --sidebar-solid; remove blur */ }
@media (prefers-contrast: more) { /* strengthen border/current marker/focus outline */ }
Do not replace the existing rules for other components; merge sidebar selectors into them.
- Step 8: Remove obsolete selectors and run contract checks
Delete old .sidebar-backdrop, .sidebar-close, .mobile-menu, .sidebar-open, absolute account-menu anchoring, and redundant session-list scroll rules after their replacements exist.
Run:
node --test tests/sidebar-contract.test.ts tests/starter-questions.test.ts
npm run lint
npx tsc --noEmit
git diff --check -- frontend/src/app/globals.css frontend/tests/sidebar-contract.test.ts
Expected: all tests, lint, TypeScript, and whitespace checks pass; no contract references the old class-based drawer state.
- Step 9: Commit the visual implementation
Run:
git add frontend/src/app/globals.css frontend/tests/sidebar-contract.test.ts
git commit -m "style: add responsive sidebar rail and drawer"
Task 7: Verify behavior, accessibility, and production output
Files:
-
Modify only if a verified issue is found: sidebar files from Tasks 2–6 and their tests.
-
Step 1: Run the complete automated suite
Run:
npm test
npm run lint
npx tsc --noEmit
npm run build
Expected: all commands exit 0. The production build must not report hydration warnings, missing CSS imports, or client/server boundary errors.
- Step 2: Start the local preview without real API mutations
Run: npm run dev
Open http://localhost:3000/?preview=conversation. Use only preview modes for visual checks unless the user explicitly asks to exercise cloud-backed data.
- Step 3: Verify the three required viewport widths
At 1280px:
- initial width is 288px;
- trigger and rail collapse to 64px and expand again;
- chat resizes beside the rail rather than being covered;
- logo, new-chat, one history action, and account avatar remain useful;
- tooltips appear for keyboard focus and pointer hover only while collapsed.
At 768px:
- initial state is the 64px rail;
- expansion is 240px;
- no overlay or scrim appears;
- Chinese session titles truncate without horizontal overflow.
At 375px:
-
initial drawer is closed and no rail remains;
-
header trigger opens
min(86vw, 320px)drawer; -
focus moves into the drawer;
-
scrim and Escape close it and return focus;
-
chat becomes inert only while the drawer is open.
-
Step 4: Verify state and content edge cases
Check empty, one-session, many-session, and very-long-title states. Confirm:
-
collapsed history action expands and focuses the first session without selecting it;
-
empty history focuses the
聊天记录heading and shows暂无对话expanded; -
long history scrolls only
SidebarContent; header/footer remain fixed; -
selecting a session while another session answers remains possible;
-
selecting on mobile closes the drawer; selecting on desktop/tablet does not collapse it;
-
new-chat loading and disabled states match current business locks.
-
Step 5: Verify account surfaces from every sidebar state
From desktop expanded, desktop collapsed, tablet collapsed, and mobile drawer:
-
account popover is fully visible and collision-safe;
-
expanded/mobile placement is above the footer;
-
collapsed placement is to the avatar's right;
-
profile, redeem, logout confirmation, and administrator navigation route correctly;
-
dialogs remain above the drawer/popover and return focus to the account trigger;
-
Escape closes one topmost surface at a time.
-
Step 6: Verify keyboard and preference behavior
Confirm:
-
Command+B on macOS and Control+B elsewhere toggles the sidebar;
-
the shortcut does nothing while focus is in input, textarea, select, or contenteditable;
-
all controls have a visible focus ring and at least a 44px target;
-
reduced-motion removes translations without breaking state changes;
-
reduced-transparency uses an opaque warm background and no blur;
-
increased contrast preserves borders, focus, and the active-session marker.
-
Step 7: Inspect runtime evidence
Confirm the browser console has no React, hydration, accessibility, or Base UI warnings. Confirm there is no invisible scrim, stale inert, double scrollbar, clipped popup, or horizontal overflow after repeated resize/open/close cycles.
- Step 8: Run the final diff audit
Run:
git status --short
git diff --check HEAD
git diff --stat HEAD~5..HEAD
git diff HEAD~5..HEAD -- frontend/src frontend/tests frontend/DESIGN.md
Expected: only planned sidebar/design/test files are included; unrelated research manifests, assets, and frontend/plans/ remain uncommitted and untouched.
- Step 9: Commit only verified follow-up fixes, if any
If QA required changes, stage only the affected planned files and commit:
git commit -m "fix: polish sidebar interaction states"
If QA required no changes, do not create an empty commit. Do not push; hand the verified local branch back to the user for local acceptance.
Completion Gate
Implementation is complete only when all of the following are true:
- All 13 acceptance criteria in the approved design spec are satisfied.
npm test,npm run lint,npx tsc --noEmit, andnpm run buildpass.- Manual checks at 375px, 768px, and 1280px pass.
- Account popover/dialog behavior is unchanged except for responsive anchoring.
- Session reading remains available during an active answer.
- No raw colors, stock shadcn palette, nested scroll owner, invisible overlay, stale inert state, or hydration warning remains.
- The final diff contains no unrelated user changes.
- No push has been performed.