From 954ea548f6030f3da6541a90c89c45fef40a1428 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 27 May 2026 14:59:10 -0400 Subject: [PATCH 1/4] 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 --- web/src/App.tsx | 438 ++++++++++++++++++---- web/src/components/LanguageSwitcher.tsx | 76 ++-- web/src/components/SidebarFooter.tsx | 9 +- web/src/components/SidebarStatusStrip.tsx | 10 +- web/src/components/ThemeSwitcher.tsx | 98 ++--- web/src/i18n/af.ts | 2 +- web/src/i18n/de.ts | 2 +- web/src/i18n/en.ts | 2 +- web/src/i18n/es.ts | 2 +- web/src/i18n/fr.ts | 2 +- web/src/i18n/ga.ts | 2 +- web/src/i18n/hu.ts | 2 +- web/src/i18n/it.ts | 2 +- web/src/i18n/ja.ts | 2 +- web/src/i18n/ko.ts | 2 +- web/src/i18n/pt.ts | 2 +- web/src/i18n/ru.ts | 2 +- web/src/i18n/tr.ts | 2 +- web/src/i18n/uk.ts | 2 +- web/src/i18n/zh-hant.ts | 2 +- web/src/i18n/zh.ts | 2 +- 21 files changed, 491 insertions(+), 172 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 6220ed263139..c6fdc7413de0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,10 +2,12 @@ import { useCallback, useEffect, useMemo, + useRef, useState, type ComponentType, type ReactNode, } from "react"; +import { createPortal } from "react-dom"; import { Routes, Route, @@ -31,6 +33,8 @@ import { Menu, MessageSquare, Package, + PanelLeftClose, + PanelLeftOpen, Puzzle, RotateCw, Settings, @@ -44,14 +48,15 @@ import { Zap, } from "lucide-react"; import { Button } from "@nous-research/ui/ui/components/button"; -import { ListItem } from "@nous-research/ui/ui/components/list-item"; import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Typography } from "@/components/NouiTypography"; import { cn } from "@/lib/utils"; import { Backdrop } from "@/components/Backdrop"; import { SidebarFooter } from "@/components/SidebarFooter"; -import { SidebarStatusStrip } from "@/components/SidebarStatusStrip"; +import { SidebarStatusStrip, gatewayLine } from "@/components/SidebarStatusStrip"; +import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint"; +import { useSidebarStatus } from "@/hooks/useSidebarStatus"; import { AuthWidget } from "@/components/AuthWidget"; import { PageHeaderProvider } from "@/contexts/PageHeaderProvider"; import { useSystemActions } from "@/contexts/useSystemActions"; @@ -77,6 +82,7 @@ import type { PluginManifest } from "@/plugins"; import { useTheme } from "@/themes"; import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags"; import { api } from "@/lib/api"; +import type { StatusResponse } from "@/lib/api"; function RootRedirect() { return ; @@ -306,6 +312,8 @@ function buildRoutes( return routes; } +const SIDEBAR_COLLAPSED_KEY = "hermes-sidebar-collapsed"; + export default function App() { const { t } = useI18n(); const { pathname } = useLocation(); @@ -313,6 +321,26 @@ export default function App() { const { theme } = useTheme(); const [mobileOpen, setMobileOpen] = useState(false); const closeMobile = useCallback(() => setMobileOpen(false), []); + + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true"; + } catch { + return false; + } + }); + const toggleCollapsed = useCallback(() => { + setCollapsed((prev) => { + const next = !prev; + try { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next)); + } catch { /* localStorage may be unavailable in private browsing */ } + return next; + }); + }, []); + const isMobile = useBelowBreakpoint(1024); + const isDesktopCollapsed = collapsed && !isMobile; + const sidebarStatus = useSidebarStatus(); const isDocsRoute = pathname === "/docs" || pathname === "/docs/"; const normalizedPath = pathname.replace(/\/$/, "") || "/"; const isChatRoute = normalizedPath === "/chat"; @@ -483,9 +511,11 @@ export default function App() { "fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col", "border-r border-current/20", "bg-background-base/95 backdrop-blur-sm", - "transition-transform duration-200 ease-out", + "transition-[transform] duration-200 ease-out", mobileOpen ? "translate-x-0" : "-translate-x-full", - "lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0", + "lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0 lg:overflow-hidden", + "lg:transition-[width] lg:duration-[450ms] lg:ease-[cubic-bezier(0.33,1.35,0.62,1)]", + collapsed && "lg:w-14", )} style={{ background: "var(--component-sidebar-background)", @@ -495,11 +525,19 @@ export default function App() { >
-
+
+ +
); } @@ -134,10 +149,12 @@ function LanguageSwitcherOptions({ return ( ); })} @@ -164,5 +181,6 @@ interface LanguageSwitcherOptionsProps { } interface LanguageSwitcherProps { + collapsed?: boolean; dropUp?: boolean; } diff --git a/web/src/components/SidebarFooter.tsx b/web/src/components/SidebarFooter.tsx index 70ab23d25a8f..71a4b43e05bd 100644 --- a/web/src/components/SidebarFooter.tsx +++ b/web/src/components/SidebarFooter.tsx @@ -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() {
); } + +interface SidebarFooterProps { + status: StatusResponse | null; +} diff --git a/web/src/components/SidebarStatusStrip.tsx b/web/src/components/SidebarStatusStrip.tsx index 6556f492c25b..10612ace6416 100644 --- a/web/src/components/SidebarStatusStrip.tsx +++ b/web/src/components/SidebarStatusStrip.tsx @@ -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["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; +} diff --git a/web/src/components/ThemeSwitcher.tsx b/web/src/components/ThemeSwitcher.tsx index f1359dd442d9..4c891295ff51 100644 --- a/web/src/components/ThemeSwitcher.tsx +++ b/web/src/components/ThemeSwitcher.tsx @@ -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(null); + const dropdownRef = useRef(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,23 +64,18 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
{useMobileSheet && ( @@ -101,34 +96,44 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) { )} - {open && !useMobileSheet && ( -
-
- - {sheetTitle} - -
+ {open && !useMobileSheet && (() => { + const rect = wrapperRef.current?.getBoundingClientRect(); + const dropdown = ( +
+
+ + {sheetTitle} + +
- -
- )} + +
+ ); + return dropUp ? createPortal(dropdown, document.body) : dropdown; + })()}
); } @@ -221,5 +226,6 @@ interface ThemeSwitcherOptionsProps { } interface ThemeSwitcherProps { + collapsed?: boolean; dropUp?: boolean; } diff --git a/web/src/i18n/af.ts b/web/src/i18n/af.ts index 8bc34e81c04f..d0eac9cc3e13 100644 --- a/web/src/i18n/af.ts +++ b/web/src/i18n/af.ts @@ -422,7 +422,7 @@ export const af: Translations = { }, language: { - switchTo: "Skakel oor na Engels", + switchTo: "Verander taal", }, theme: { diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index ef41f4944186..08baf2217188 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -422,7 +422,7 @@ export const de: Translations = { }, language: { - switchTo: "Zu Englisch wechseln", + switchTo: "Sprache wechseln", }, theme: { diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index ac67b6eaf75e..28c7581d8acf 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -422,7 +422,7 @@ export const en: Translations = { }, language: { - switchTo: "Switch to Chinese", + switchTo: "Switch language", }, theme: { diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index 067d595ae88c..72c61774c100 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -422,7 +422,7 @@ export const es: Translations = { }, language: { - switchTo: "Cambiar a inglés", + switchTo: "Cambiar idioma", }, theme: { diff --git a/web/src/i18n/fr.ts b/web/src/i18n/fr.ts index 672f5d907300..880540983de6 100644 --- a/web/src/i18n/fr.ts +++ b/web/src/i18n/fr.ts @@ -422,7 +422,7 @@ export const fr: Translations = { }, language: { - switchTo: "Passer à l'anglais", + switchTo: "Changer de langue", }, theme: { diff --git a/web/src/i18n/ga.ts b/web/src/i18n/ga.ts index 2ad892143485..e8c992fb1de5 100644 --- a/web/src/i18n/ga.ts +++ b/web/src/i18n/ga.ts @@ -422,7 +422,7 @@ export const ga: Translations = { }, language: { - switchTo: "Athraigh go Béarla", + switchTo: "Athraigh teanga", }, theme: { diff --git a/web/src/i18n/hu.ts b/web/src/i18n/hu.ts index 92e21f395967..5f684165e142 100644 --- a/web/src/i18n/hu.ts +++ b/web/src/i18n/hu.ts @@ -422,7 +422,7 @@ export const hu: Translations = { }, language: { - switchTo: "Váltás angolra", + switchTo: "Nyelv váltása", }, theme: { diff --git a/web/src/i18n/it.ts b/web/src/i18n/it.ts index 1089cdbb9a43..da35e55b37fc 100644 --- a/web/src/i18n/it.ts +++ b/web/src/i18n/it.ts @@ -422,7 +422,7 @@ export const it: Translations = { }, language: { - switchTo: "Passa all'inglese", + switchTo: "Cambia lingua", }, theme: { diff --git a/web/src/i18n/ja.ts b/web/src/i18n/ja.ts index d4e23aa46a1d..f8c54621cd95 100644 --- a/web/src/i18n/ja.ts +++ b/web/src/i18n/ja.ts @@ -422,7 +422,7 @@ export const ja: Translations = { }, language: { - switchTo: "英語に切り替え", + switchTo: "言語を切り替え", }, theme: { diff --git a/web/src/i18n/ko.ts b/web/src/i18n/ko.ts index 2766f4d9f580..20361a20c31e 100644 --- a/web/src/i18n/ko.ts +++ b/web/src/i18n/ko.ts @@ -422,7 +422,7 @@ export const ko: Translations = { }, language: { - switchTo: "영어로 전환", + switchTo: "언어 변경", }, theme: { diff --git a/web/src/i18n/pt.ts b/web/src/i18n/pt.ts index 512519a3fd56..6703431eb691 100644 --- a/web/src/i18n/pt.ts +++ b/web/src/i18n/pt.ts @@ -422,7 +422,7 @@ export const pt: Translations = { }, language: { - switchTo: "Mudar para inglês", + switchTo: "Mudar idioma", }, theme: { diff --git a/web/src/i18n/ru.ts b/web/src/i18n/ru.ts index 98b45f9f3a6f..507d555e758b 100644 --- a/web/src/i18n/ru.ts +++ b/web/src/i18n/ru.ts @@ -422,7 +422,7 @@ export const ru: Translations = { }, language: { - switchTo: "Переключиться на английский", + switchTo: "Сменить язык", }, theme: { diff --git a/web/src/i18n/tr.ts b/web/src/i18n/tr.ts index 64b69887f524..1bcf524cca01 100644 --- a/web/src/i18n/tr.ts +++ b/web/src/i18n/tr.ts @@ -422,7 +422,7 @@ export const tr: Translations = { }, language: { - switchTo: "İngilizce'ye geç", + switchTo: "Dil değiştir", }, theme: { diff --git a/web/src/i18n/uk.ts b/web/src/i18n/uk.ts index 69dccf7caf3f..0a3985803d29 100644 --- a/web/src/i18n/uk.ts +++ b/web/src/i18n/uk.ts @@ -422,7 +422,7 @@ export const uk: Translations = { }, language: { - switchTo: "Перемкнути на англійську", + switchTo: "Змінити мову", }, theme: { diff --git a/web/src/i18n/zh-hant.ts b/web/src/i18n/zh-hant.ts index 2edb67e02aad..f333e457a41a 100644 --- a/web/src/i18n/zh-hant.ts +++ b/web/src/i18n/zh-hant.ts @@ -422,7 +422,7 @@ export const zhHant: Translations = { }, language: { - switchTo: "切換為英文", + switchTo: "切換語言", }, theme: { diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 60e6521a0821..3546f5649501 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -417,7 +417,7 @@ export const zh: Translations = { }, language: { - switchTo: "切换到英文", + switchTo: "切换语言", }, theme: { From 4f8aa9b23029e4e655bb962b083dc4c7ebf39889 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 27 May 2026 20:53:10 -0400 Subject: [PATCH 2/4] 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 --- web/src/App.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index c6fdc7413de0..e5dc95edface 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1063,6 +1063,8 @@ function GatewayDot({ status }: GatewayDotProps) { function SidebarTooltip({ anchor, label }: SidebarTooltipProps) { const rect = anchor.getBoundingClientRect(); + const sidebar = document.getElementById("app-sidebar"); + const sidebarRight = sidebar?.getBoundingClientRect().right ?? rect.right; return createPortal( From c2e988871fe6b072a2b80c593777db8564b349bd Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 27 May 2026 21:32:44 -0400 Subject: [PATCH 3/4] 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 --- web/src/App.tsx | 45 +++++++++++++++++++++++----- web/src/components/ThemeSwitcher.tsx | 20 ++++++++++--- web/src/i18n/af.ts | 1 + web/src/i18n/de.ts | 1 + web/src/i18n/en.ts | 1 + web/src/i18n/es.ts | 1 + web/src/i18n/fr.ts | 1 + web/src/i18n/ga.ts | 1 + web/src/i18n/hu.ts | 1 + web/src/i18n/it.ts | 1 + web/src/i18n/ja.ts | 1 + web/src/i18n/ko.ts | 1 + web/src/i18n/pt.ts | 1 + web/src/i18n/ru.ts | 1 + web/src/i18n/tr.ts | 1 + web/src/i18n/types.ts | 1 + web/src/i18n/uk.ts | 1 + web/src/i18n/zh-hant.ts | 1 + web/src/i18n/zh.ts | 1 + web/src/index.css | 6 ++++ web/src/pages/SessionsPage.tsx | 2 +- 21 files changed, 78 insertions(+), 12 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index e5dc95edface..bd7c9e65be7b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -340,6 +340,7 @@ export default function App() { }, []); const isMobile = useBelowBreakpoint(1024); const isDesktopCollapsed = collapsed && !isMobile; + const tooltipWarmRef = useRef(0); const sidebarStatus = useSidebarStatus(); const isDocsRoute = pathname === "/docs" || pathname === "/docs/"; const normalizedPath = pathname.replace(/\/$/, "") || "/"; @@ -589,6 +590,7 @@ export default function App() { item={item} key={item.path} t={t} + tooltipWarmRef={tooltipWarmRef} /> ))} @@ -618,6 +620,7 @@ export default function App() { item={item} key={item.path} t={t} + tooltipWarmRef={tooltipWarmRef} /> ))} @@ -629,6 +632,7 @@ export default function App() { collapsed={isDesktopCollapsed} onNavigate={closeMobile} status={sidebarStatus} + tooltipWarmRef={tooltipWarmRef} />
@@ -659,6 +664,7 @@ export default function App() { @@ -752,6 +758,7 @@ function SidebarNavLink({ closeMobile, collapsed, item, + tooltipWarmRef, t, }: SidebarNavLinkProps) { const { path, label, labelKey, icon: Icon } = item; @@ -819,7 +826,7 @@ function SidebarNavLink({ {collapsed && hovered && liRef.current && ( - + )} ); @@ -829,6 +836,7 @@ function SidebarSystemActions({ collapsed, onNavigate, status, + tooltipWarmRef, }: SidebarSystemActionsProps) { const { t } = useI18n(); const navigate = useNavigate(); @@ -881,7 +889,7 @@ function SidebarSystemActions({
- {collapsed && } + {collapsed && }
    {items.map((item) => ( @@ -889,6 +897,7 @@ function SidebarSystemActions({ key={item.action} collapsed={collapsed} disabled={isBusy && !(pendingAction === item.action || (activeAction === item.action && isRunning))} + tooltipWarmRef={tooltipWarmRef} isPending={pendingAction === item.action} isRunning={activeAction === item.action && isRunning && pendingAction !== item.action} item={item} @@ -907,6 +916,7 @@ function SystemActionButton({ isRunning: isActionRunning, item, onClick, + tooltipWarmRef, }: SystemActionButtonProps) { const { icon: Icon, label, runningLabel, spin } = item; const liRef = useRef(null); @@ -973,7 +983,7 @@ function SystemActionButton({ {collapsed && hovered && liRef.current && ( - + )} ); @@ -983,6 +993,7 @@ function SidebarIconWithTooltip({ children, collapsed, label, + tooltipWarmRef, }: SidebarIconWithTooltipProps) { const ref = useRef(null); const [hovered, setHovered] = useState(false); @@ -1007,13 +1018,13 @@ function SidebarIconWithTooltip({ )} {collapsed && hovered && ref.current && ( - + )} ); } -function GatewayDot({ status }: GatewayDotProps) { +function GatewayDot({ status, tooltipWarmRef }: GatewayDotProps) { const { t } = useI18n(); const ref = useRef(null); const [hovered, setHovered] = useState(false); @@ -1055,16 +1066,26 @@ function GatewayDot({ status }: GatewayDotProps) { /> {hovered && ref.current && ( - + )} ); } -function SidebarTooltip({ anchor, label }: SidebarTooltipProps) { +function SidebarTooltip({ anchor, label, warmRef }: SidebarTooltipProps) { const rect = anchor.getBoundingClientRect(); const sidebar = document.getElementById("app-sidebar"); const sidebarRight = sidebar?.getBoundingClientRect().right ?? rect.right; + + const isWarm = warmRef ? Date.now() - warmRef.current < 300 : false; + + useEffect(() => { + if (warmRef) warmRef.current = Date.now(); + return () => { + if (warmRef) warmRef.current = Date.now(); + }; + }, [warmRef]); + return createPortal( {label} @@ -1085,8 +1108,11 @@ function SidebarTooltip({ anchor, label }: SidebarTooltipProps) { ); } +type TooltipWarmRef = React.RefObject; + interface GatewayDotProps { status: StatusResponse | null; + tooltipWarmRef: TooltipWarmRef; } interface NavItem { @@ -1100,6 +1126,7 @@ interface SidebarIconWithTooltipProps { children: ReactNode; collapsed: boolean; label: string; + tooltipWarmRef: TooltipWarmRef; } interface SidebarNavLinkProps { @@ -1107,17 +1134,20 @@ interface SidebarNavLinkProps { collapsed: boolean; item: NavItem; t: Translations; + tooltipWarmRef: TooltipWarmRef; } interface SidebarSystemActionsProps { collapsed: boolean; onNavigate: () => void; status: StatusResponse | null; + tooltipWarmRef: TooltipWarmRef; } interface SidebarTooltipProps { anchor: HTMLElement; label: string; + warmRef?: TooltipWarmRef; } interface SystemActionButtonProps { @@ -1127,6 +1157,7 @@ interface SystemActionButtonProps { isRunning: boolean; item: SystemActionItem; onClick: () => void; + tooltipWarmRef: TooltipWarmRef; } interface SystemActionItem { diff --git a/web/src/components/ThemeSwitcher.tsx b/web/src/components/ThemeSwitcher.tsx index 4c891295ff51..a591d2d720bf 100644 --- a/web/src/components/ThemeSwitcher.tsx +++ b/web/src/components/ThemeSwitcher.tsx @@ -64,18 +64,30 @@ export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitch
    {useMobileSheet && ( diff --git a/web/src/i18n/af.ts b/web/src/i18n/af.ts index d0eac9cc3e13..c3d6312aa3f2 100644 --- a/web/src/i18n/af.ts +++ b/web/src/i18n/af.ts @@ -127,6 +127,7 @@ export const af: Translations = { sessions: { title: "Sessies", + history: "Geskiedenis", overview: "Oorsig", searchPlaceholder: "Soek boodskap-inhoud...", noSessions: "Nog geen sessies nie", diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index 08baf2217188..d6fdfe64548e 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -127,6 +127,7 @@ export const de: Translations = { sessions: { title: "Sitzungen", + history: "Verlauf", overview: "Übersicht", searchPlaceholder: "Nachrichteninhalt suchen...", noSessions: "Noch keine Sitzungen", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 28c7581d8acf..f792bf4dc3fa 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -127,6 +127,7 @@ export const en: Translations = { sessions: { title: "Sessions", + history: "History", overview: "Overview", searchPlaceholder: "Search message content...", noSessions: "No sessions yet", diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index 72c61774c100..84a1501e97b4 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -127,6 +127,7 @@ export const es: Translations = { sessions: { title: "Sesiones", + history: "Historial", overview: "Resumen", searchPlaceholder: "Buscar contenido de mensajes...", noSessions: "Aún no hay sesiones", diff --git a/web/src/i18n/fr.ts b/web/src/i18n/fr.ts index 880540983de6..409c0a1e397c 100644 --- a/web/src/i18n/fr.ts +++ b/web/src/i18n/fr.ts @@ -127,6 +127,7 @@ export const fr: Translations = { sessions: { title: "Sessions", + history: "Historique", overview: "Aperçu", searchPlaceholder: "Rechercher dans les messages...", noSessions: "Aucune session pour l'instant", diff --git a/web/src/i18n/ga.ts b/web/src/i18n/ga.ts index e8c992fb1de5..a4d41e303542 100644 --- a/web/src/i18n/ga.ts +++ b/web/src/i18n/ga.ts @@ -127,6 +127,7 @@ export const ga: Translations = { sessions: { title: "Seisiúin", + history: "Stair", overview: "Forbhreathnú", searchPlaceholder: "Cuardaigh ábhar teachtaireachta...", noSessions: "Gan seisiúin go fóill", diff --git a/web/src/i18n/hu.ts b/web/src/i18n/hu.ts index 5f684165e142..7814aff86c83 100644 --- a/web/src/i18n/hu.ts +++ b/web/src/i18n/hu.ts @@ -127,6 +127,7 @@ export const hu: Translations = { sessions: { title: "Munkamenetek", + history: "Előzmények", overview: "Áttekintés", searchPlaceholder: "Keresés üzenettartalomban...", noSessions: "Még nincsenek munkamenetek", diff --git a/web/src/i18n/it.ts b/web/src/i18n/it.ts index da35e55b37fc..1485cb68778f 100644 --- a/web/src/i18n/it.ts +++ b/web/src/i18n/it.ts @@ -127,6 +127,7 @@ export const it: Translations = { sessions: { title: "Sessioni", + history: "Cronologia", overview: "Panoramica", searchPlaceholder: "Cerca nel contenuto dei messaggi...", noSessions: "Nessuna sessione", diff --git a/web/src/i18n/ja.ts b/web/src/i18n/ja.ts index f8c54621cd95..1b9ad88ea5fe 100644 --- a/web/src/i18n/ja.ts +++ b/web/src/i18n/ja.ts @@ -127,6 +127,7 @@ export const ja: Translations = { sessions: { title: "セッション", + history: "履歴", overview: "概要", searchPlaceholder: "メッセージ内容を検索...", noSessions: "まだセッションがありません", diff --git a/web/src/i18n/ko.ts b/web/src/i18n/ko.ts index 20361a20c31e..4fcb6f0010e3 100644 --- a/web/src/i18n/ko.ts +++ b/web/src/i18n/ko.ts @@ -127,6 +127,7 @@ export const ko: Translations = { sessions: { title: "세션", + history: "기록", overview: "개요", searchPlaceholder: "메시지 내용 검색...", noSessions: "아직 세션이 없습니다", diff --git a/web/src/i18n/pt.ts b/web/src/i18n/pt.ts index 6703431eb691..b84c99b67bff 100644 --- a/web/src/i18n/pt.ts +++ b/web/src/i18n/pt.ts @@ -127,6 +127,7 @@ export const pt: Translations = { sessions: { title: "Sessões", + history: "Histórico", overview: "Visão geral", searchPlaceholder: "Pesquisar conteúdo das mensagens...", noSessions: "Ainda não há sessões", diff --git a/web/src/i18n/ru.ts b/web/src/i18n/ru.ts index 507d555e758b..e9b5e2cb84a2 100644 --- a/web/src/i18n/ru.ts +++ b/web/src/i18n/ru.ts @@ -127,6 +127,7 @@ export const ru: Translations = { sessions: { title: "Сессии", + history: "История", overview: "Обзор", searchPlaceholder: "Поиск по содержимому сообщений...", noSessions: "Сессий пока нет", diff --git a/web/src/i18n/tr.ts b/web/src/i18n/tr.ts index 1bcf524cca01..f9aaa14d4b19 100644 --- a/web/src/i18n/tr.ts +++ b/web/src/i18n/tr.ts @@ -127,6 +127,7 @@ export const tr: Translations = { sessions: { title: "Oturumlar", + history: "Geçmiş", overview: "Genel bakış", searchPlaceholder: "Mesaj içeriğinde ara...", noSessions: "Henüz oturum yok", diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index b45c6339f75f..15f2f1a0c92c 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -145,6 +145,7 @@ export interface Translations { // ── Sessions page ── sessions: { title: string; + history: string; overview: string; searchPlaceholder: string; noSessions: string; diff --git a/web/src/i18n/uk.ts b/web/src/i18n/uk.ts index 0a3985803d29..8d67f58eccab 100644 --- a/web/src/i18n/uk.ts +++ b/web/src/i18n/uk.ts @@ -127,6 +127,7 @@ export const uk: Translations = { sessions: { title: "Сесії", + history: "Історія", overview: "Огляд", searchPlaceholder: "Пошук у вмісті повідомлень...", noSessions: "Поки немає сесій", diff --git a/web/src/i18n/zh-hant.ts b/web/src/i18n/zh-hant.ts index f333e457a41a..e569b27a4879 100644 --- a/web/src/i18n/zh-hant.ts +++ b/web/src/i18n/zh-hant.ts @@ -127,6 +127,7 @@ export const zhHant: Translations = { sessions: { title: "工作階段", + history: "歷史", overview: "總覽", searchPlaceholder: "搜尋訊息內容...", noSessions: "尚無工作階段", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 3546f5649501..5bc5ae493553 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -126,6 +126,7 @@ export const zh: Translations = { sessions: { title: "会话", + history: "历史", overview: "概览", searchPlaceholder: "搜索消息内容...", noSessions: "暂无会话", diff --git a/web/src/index.css b/web/src/index.css index 01b6d9bd1784..212406b7e766 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -170,6 +170,12 @@ code { font-size: 0.875rem; } } +/* Collapsed sidebar tooltip entrance — skipped when moving between items. */ +@keyframes sidebar-tooltip-in { + from { opacity: 0; transform: translateY(-50%) translateX(-4px); } + to { opacity: 1; transform: translateY(-50%) translateX(0); } +} + /* Toast animations used by `components/Toast.tsx`. */ @keyframes toast-in { from { opacity: 0; transform: translateX(16px); } diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 5e8f65f35f63..9dff48016146 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -778,7 +778,7 @@ export default function SessionsPage() { onChange={setView} options={[ { value: "overview", label: t.sessions.overview }, - { value: "list", label: t.sessions.title }, + { value: "list", label: t.sessions.history }, ]} /> )} From 7846602b7185fbfc03d5efd2b6e610b7ec084755 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 27 May 2026 22:52:27 -0400 Subject: [PATCH 4/4] 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 --- web/src/App.tsx | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index bd7c9e65be7b..6e6eeee05e30 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -515,7 +515,7 @@ export default function App() { "transition-[transform] duration-200 ease-out", mobileOpen ? "translate-x-0" : "-translate-x-full", "lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0 lg:overflow-hidden", - "lg:transition-[width] lg:duration-[450ms] lg:ease-[cubic-bezier(0.33,1.35,0.62,1)]", + "lg:transition-[width] lg:duration-[600ms] lg:ease-[cubic-bezier(0.33,1.35,0.62,1)]", collapsed && "lg:w-14", )} style={{ @@ -526,11 +526,9 @@ export default function App() { >
    @@ -792,7 +790,6 @@ function SidebarNavLink({ isActive ? "text-midground" : "text-text-secondary hover:text-midground", - collapsed && "lg:justify-center lg:gap-0 lg:px-0", ) } style={{ @@ -804,7 +801,10 @@ function SidebarNavLink({ {navLabel} @@ -889,7 +889,7 @@ function SidebarSystemActions({
    - {collapsed && } +
      {items.map((item) => ( @@ -948,7 +948,6 @@ function SystemActionButton({ ? "text-midground" : "text-text-secondary hover:text-midground", "disabled:text-text-disabled disabled:cursor-not-allowed", - collapsed && "lg:justify-center lg:gap-0 lg:px-0", )} > {isPending ? ( @@ -964,7 +963,10 @@ function SystemActionButton({ /> )} - + {displayLabel} @@ -1024,7 +1026,7 @@ function SidebarIconWithTooltip({ ); } -function GatewayDot({ status, tooltipWarmRef }: GatewayDotProps) { +function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) { const { t } = useI18n(); const ref = useRef(null); const [hovered, setHovered] = useState(false); @@ -1051,14 +1053,17 @@ function GatewayDot({ status, tooltipWarmRef }: GatewayDotProps) { return (
      setHovered(true)} - onMouseLeave={() => setHovered(false)} - onFocus={() => setHovered(true)} - onBlur={() => setHovered(false)} + tabIndex={collapsed ? 0 : -1} + onMouseEnter={collapsed ? () => setHovered(true) : undefined} + onMouseLeave={collapsed ? () => setHovered(false) : undefined} + onFocus={collapsed ? () => setHovered(true) : undefined} + onBlur={collapsed ? () => setHovered(false) : undefined} > ; interface GatewayDotProps { + collapsed: boolean; status: StatusResponse | null; tooltipWarmRef: TooltipWarmRef; }