feat(web): add collapsible sidebar for the dashboard (#33421)

* feat(web): add collapsible sidebar for the dashboard

The desktop sidebar can now be collapsed to an icon-only rail via a
toggle button in the sidebar header.  State is persisted in
localStorage so it survives page reloads.

When collapsed (lg+ only):
- Sidebar shrinks from w-64 to w-14 with a smooth width transition
- Nav items show only their icon with a native title tooltip
- Brand text, plugin headings, system actions, theme/language
  switchers, auth widget, and footer are hidden
- Mobile drawer behavior is unchanged (always full-width)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): align sidebar tooltips to sidebar edge consistently

Tooltip left position now uses the sidebar's right edge instead of the
anchor element's right edge, so narrow anchors (theme/language switchers)
align with full-width anchors (nav links, system actions).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): add tooltip animations, restore theme label, rename Sessions tab

- Sidebar tooltips now animate in with a subtle 120ms ease-out slide;
  subsequent tooltips within the same hover sequence appear instantly
  (no delay/animation) following Emil Kowalski's tooltip pattern
- Restore theme name label when sidebar is expanded
- Rename Sessions segment tab to "History" across all 16 locales

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): smooth sidebar collapse animation

- Remove icon centering on collapse; icons stay left-aligned at px-5
  so they don't jump during the width transition
- Text labels fade out with opacity transition instead of instant
  display:none, clipped naturally by overflow-hidden
- Slow collapse duration from 450ms to 600ms for a more relaxed feel
- Gateway dot always rendered with opacity toggle so it doesn't
  slide in from the right on collapse
- Pin gateway dot at fixed left offset (pl-[1.625rem]) to align
  with nav icons
- Align header toggle button with justify-center when collapsed
- Bottom switchers use items-start when collapsed to prevent reflow

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Austin Pickett
2026-05-27 23:58:41 -04:00
committed by GitHub
co-authored by Cursor
parent c341a2d107
commit c9410b3462
24 changed files with 564 additions and 171 deletions
+47 -29
View File
@@ -1,4 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { Check } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { BottomPickSheet } from "@/components/BottomPickSheet";
import { Typography } from "@/components/NouiTypography";
@@ -25,10 +27,11 @@ import { cn } from "@/lib/utils";
* viewport / overflow ancestors. Below the `sm` breakpoint, `dropUp` uses a
* bottom sheet portaled to `document.body` instead of an anchored dropdown.
*/
export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
export function LanguageSwitcher({ collapsed = false, dropUp = false }: LanguageSwitcherProps) {
const { locale, setLocale, t } = useI18n();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const narrowViewport = useBelowBreakpoint(640);
const useMobileSheet = Boolean(dropUp && narrowViewport);
@@ -41,15 +44,14 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
return () => document.removeEventListener("keydown", onKey);
}, [open]);
// Outside-click closing only for anchored dropdown — sheet uses backdrop + portal.
useEffect(() => {
if (!open || useMobileSheet) return;
function onPointerDown(e: PointerEvent) {
if (!containerRef.current) return;
if (!containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
const target = e.target as Node;
if (containerRef.current?.contains(target)) return;
if (dropdownRef.current?.contains(target)) return;
setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
@@ -69,7 +71,10 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
aria-label={t.language.switchTo}
aria-haspopup="listbox"
aria-expanded={open}
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground"
className={cn(
"px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground",
collapsed && "hover:bg-transparent",
)}
>
<span className="inline-flex items-center gap-1.5">
<Typography
@@ -99,23 +104,33 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
</BottomPickSheet>
)}
{open && !useMobileSheet && (
<div
aria-label={sheetTitle}
className={cn(
"absolute right-0 z-50 min-w-[10rem] rounded-md border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
dropUp ? "bottom-full mb-1" : "top-full mt-1",
)}
role="listbox"
>
<LanguageSwitcherOptions
allLocales={allLocales}
locale={locale}
setLocale={setLocale}
setOpen={setOpen}
/>
</div>
)}
{open && !useMobileSheet && (() => {
const rect = containerRef.current?.getBoundingClientRect();
const dropdown = (
<div
ref={dropdownRef}
aria-label={sheetTitle}
className={cn(
"min-w-[10rem] border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
)}
role="listbox"
style={
dropUp && rect
? { bottom: window.innerHeight - rect.top + 4, left: rect.left }
: undefined
}
>
<LanguageSwitcherOptions
allLocales={allLocales}
locale={locale}
setLocale={setLocale}
setOpen={setOpen}
/>
</div>
);
return dropUp ? createPortal(dropdown, document.body) : dropdown;
})()}
</div>
);
}
@@ -134,10 +149,12 @@ function LanguageSwitcherOptions({
return (
<button
aria-selected={selected}
className={
"w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-accent hover:text-accent-foreground transition-colors " +
(selected ? "font-semibold text-foreground" : "text-muted-foreground")
}
className={cn(
"w-full text-left px-3 py-1.5 flex items-center gap-2 cursor-pointer",
"font-mondwest text-display text-xs tracking-[0.08em]",
"hover:bg-accent hover:text-accent-foreground transition-colors",
selected ? "font-semibold text-foreground" : "text-muted-foreground",
)}
key={code}
onClick={() => {
setLocale(code);
@@ -148,7 +165,7 @@ function LanguageSwitcherOptions({
>
<span className="truncate">{meta.name}</span>
{selected && <span className="ml-auto text-xs"></span>}
{selected && <Check className="ml-auto h-3 w-3 shrink-0 text-midground" />}
</button>
);
})}
@@ -164,5 +181,6 @@ interface LanguageSwitcherOptionsProps {
}
interface LanguageSwitcherProps {
collapsed?: boolean;
dropUp?: boolean;
}
+6 -3
View File
@@ -1,10 +1,9 @@
import { Typography } from "@/components/NouiTypography";
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
import type { StatusResponse } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useI18n } from "@/i18n";
export function SidebarFooter() {
const status = useSidebarStatus();
export function SidebarFooter({ status }: SidebarFooterProps) {
const { t } = useI18n();
return (
@@ -37,3 +36,7 @@ export function SidebarFooter() {
</div>
);
}
interface SidebarFooterProps {
status: StatusResponse | null;
}
+6 -4
View File
@@ -1,12 +1,10 @@
import { Link } from "react-router-dom";
import type { StatusResponse } from "@/lib/api";
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
import { cn } from "@/lib/utils";
import { useI18n } from "@/i18n";
/** Gateway + session summary for the System sidebar block (no separate strip chrome). */
export function SidebarStatusStrip() {
const status = useSidebarStatus();
export function SidebarStatusStrip({ status }: SidebarStatusStripProps) {
const { t } = useI18n();
if (status === null) {
@@ -50,7 +48,7 @@ export function SidebarStatusStrip() {
);
}
function gatewayLine(
export function gatewayLine(
status: StatusResponse,
t: ReturnType<typeof useI18n>["t"],
): { label: string; tone: string } {
@@ -68,3 +66,7 @@ function gatewayLine(
? { label: g.running, tone: "text-success" }
: { label: g.off, tone: "text-muted-foreground" };
}
interface SidebarStatusStripProps {
status: StatusResponse | null;
}
+60 -42
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Palette, Check } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
@@ -23,11 +24,12 @@ import { cn } from "@/lib/utils";
* bottom sheet portaled to `document.body` so the picker is not clipped by
* the sidebar (same idea as a responsive Drawer).
*/
export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitcherProps) {
const { themeName, availableThemes, setTheme } = useTheme();
const { t } = useI18n();
const [open, setOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const narrowViewport = useBelowBreakpoint(640);
const useMobileSheet = Boolean(dropUp && narrowViewport);
@@ -45,12 +47,10 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
useEffect(() => {
if (!open || useMobileSheet) return;
const onMouseDown = (e: MouseEvent) => {
if (
wrapperRef.current &&
!wrapperRef.current.contains(e.target as Node)
) {
close();
}
const target = e.target as Node;
if (wrapperRef.current?.contains(target)) return;
if (dropdownRef.current?.contains(target)) return;
close();
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
@@ -64,9 +64,14 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
<div ref={wrapperRef} className="relative">
<Button
ghost
size={collapsed ? "icon" : undefined}
onClick={() => setOpen((o) => !o)}
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground"
title={t.theme?.switchTheme ?? "Switch theme"}
className={cn(
collapsed
? "text-text-secondary hover:text-foreground hover:bg-transparent"
: "px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground",
)}
title={`${t.theme?.switchTheme ?? "Switch theme"}: ${label}`}
aria-label={t.theme?.switchTheme ?? "Switch theme"}
aria-expanded={open}
aria-haspopup="listbox"
@@ -74,12 +79,14 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
<span className="inline-flex items-center gap-1.5">
<Palette className="h-3.5 w-3.5" />
<Typography
mondwest
className="hidden sm:inline text-display tracking-wide text-xs"
>
{label}
</Typography>
{!collapsed && (
<Typography
mondwest
className="hidden sm:inline text-display tracking-wide text-xs"
>
{label}
</Typography>
)}
</span>
</Button>
@@ -101,34 +108,44 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
</BottomPickSheet>
)}
{open && !useMobileSheet && (
<div
aria-label={sheetTitle}
className={cn(
"absolute z-50 min-w-[240px] max-h-[70dvh] overflow-y-auto",
dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1",
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
)}
role="listbox"
>
<div className="border-b border-current/20 px-3 py-2">
<Typography
mondwest
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
>
{sheetTitle}
</Typography>
</div>
{open && !useMobileSheet && (() => {
const rect = wrapperRef.current?.getBoundingClientRect();
const dropdown = (
<div
ref={dropdownRef}
aria-label={sheetTitle}
className={cn(
"min-w-[240px] max-h-[70dvh] overflow-y-auto",
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
)}
role="listbox"
style={
dropUp && rect
? { bottom: window.innerHeight - rect.top + 4, left: rect.left }
: undefined
}
>
<div className="border-b border-current/20 px-3 py-2">
<Typography
mondwest
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
>
{sheetTitle}
</Typography>
</div>
<ThemeSwitcherOptions
availableThemes={availableThemes}
close={close}
setTheme={setTheme}
themeName={themeName}
/>
</div>
)}
<ThemeSwitcherOptions
availableThemes={availableThemes}
close={close}
setTheme={setTheme}
themeName={themeName}
/>
</div>
);
return dropUp ? createPortal(dropdown, document.body) : dropdown;
})()}
</div>
);
}
@@ -221,5 +238,6 @@ interface ThemeSwitcherOptionsProps {
}
interface ThemeSwitcherProps {
collapsed?: boolean;
dropUp?: boolean;
}