diff --git a/web/src/App.tsx b/web/src/App.tsx
index 6220ed26313..6e6eeee05e3 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,27 @@ 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 tooltipWarmRef = useRef(0);
+ const sidebarStatus = useSidebarStatus();
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
const normalizedPath = pathname.replace(/\/$/, "") || "/";
const isChatRoute = normalizedPath === "/chat";
@@ -483,9 +512,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-[600ms] lg:ease-[cubic-bezier(0.33,1.35,0.62,1)]",
+ collapsed && "lg:w-14",
)}
style={{
background: "var(--component-sidebar-background)",
@@ -495,11 +526,17 @@ export default function App() {
>
-
+
+
+
-
+
-
-
-
+
@@ -660,22 +752,37 @@ export default function App() {
);
}
-function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
+function SidebarNavLink({
+ closeMobile,
+ collapsed,
+ item,
+ tooltipWarmRef,
+ t,
+}: SidebarNavLinkProps) {
const { path, label, labelKey, icon: Icon } = item;
+ const liRef = useRef(null);
+ const [hovered, setHovered] = useState(false);
const navLabel = labelKey
? ((t.app.nav as Record)[labelKey] ?? label)
: label;
return (
-
+ setHovered(true) : undefined}
+ onMouseLeave={collapsed ? () => setHovered(false) : undefined}
+ >
setHovered(true) : undefined}
+ onBlur={collapsed ? () => setHovered(false) : undefined}
className={({ isActive }) =>
cn(
- "group relative flex items-center gap-3",
+ "group/nav relative flex items-center gap-3",
"px-5 py-2.5",
"font-mondwest text-display uppercase text-sm tracking-[0.12em]",
"whitespace-nowrap transition-colors cursor-pointer",
@@ -692,11 +799,19 @@ function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
{({ isActive }) => (
<>
- {navLabel}
+
+
+ {navLabel}
+
{isActive && (
@@ -709,11 +824,20 @@ function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
>
)}
+
+ {collapsed && hovered && liRef.current && (
+
+ )}
);
}
-function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
+function SidebarSystemActions({
+ collapsed,
+ onNavigate,
+ status,
+ tooltipWarmRef,
+}: SidebarSystemActionsProps) {
const { t } = useI18n();
const navigate = useNavigate();
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
@@ -755,75 +879,248 @@ function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
className={cn(
"px-5 pt-0.5 pb-0.5",
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
+ collapsed && "lg:hidden",
)}
>
{t.app.system}
-
+
+
+
+
+
- {items.map(({ action, icon: Icon, label, runningLabel, spin }) => {
- const isPending = pendingAction === action;
- const isActionRunning =
- activeAction === action && isRunning && !isPending;
- const busy = isPending || isActionRunning;
- const displayLabel = isActionRunning ? runningLabel : label;
- const disabled = isBusy && !busy;
-
- return (
- -
- handleClick(action)}
- disabled={disabled}
- aria-busy={busy}
- active={busy}
- className={cn(
- "gap-3 px-5 py-1.5 whitespace-nowrap",
- "font-mondwest text-display text-xs tracking-[0.1em]",
- "transition-colors",
- busy
- ? "text-midground"
- : "text-text-secondary hover:text-midground",
- "disabled:text-text-disabled",
- )}
- >
- {isPending ? (
-
- ) : isActionRunning && spin ? (
-
- ) : (
-
- )}
-
- {displayLabel}
-
-
-
- {busy && (
-
- )}
-
-
- );
- })}
+ {items.map((item) => (
+ handleClick(item.action)}
+ />
+ ))}
);
}
+function SystemActionButton({
+ collapsed,
+ disabled,
+ isPending,
+ isRunning: isActionRunning,
+ item,
+ onClick,
+ tooltipWarmRef,
+}: SystemActionButtonProps) {
+ const { icon: Icon, label, runningLabel, spin } = item;
+ const liRef = useRef
(null);
+ const [hovered, setHovered] = useState(false);
+ const busy = isPending || isActionRunning;
+ const displayLabel = isActionRunning ? runningLabel : label;
+
+ return (
+ setHovered(true) : undefined}
+ onMouseLeave={collapsed ? () => setHovered(false) : undefined}
+ >
+
+
+ {collapsed && hovered && liRef.current && (
+
+ )}
+
+ );
+}
+
+function SidebarIconWithTooltip({
+ children,
+ collapsed,
+ label,
+ tooltipWarmRef,
+}: SidebarIconWithTooltipProps) {
+ const ref = useRef(null);
+ const [hovered, setHovered] = useState(false);
+
+ return (
+ setHovered(true) : undefined}
+ onMouseLeave={collapsed ? () => setHovered(false) : undefined}
+ >
+ {children}
+
+ {collapsed && (
+
+ )}
+
+ {collapsed && hovered && ref.current && (
+
+ )}
+
+ );
+}
+
+function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
+ const { t } = useI18n();
+ const ref = useRef(null);
+ const [hovered, setHovered] = useState(false);
+
+ const toneToColor: Record = {
+ "text-success": "bg-success",
+ "text-warning": "bg-warning",
+ "text-destructive": "bg-destructive",
+ "text-muted-foreground": "bg-muted-foreground",
+ };
+
+ let color: string;
+ let label: string;
+
+ if (!status) {
+ color = "bg-midground/20";
+ label = t.status.gateway;
+ } else {
+ const gw = gatewayLine(status, t);
+ color = toneToColor[gw.tone] ?? "bg-muted-foreground";
+ label = `${t.status.gateway} ${gw.label}`;
+ }
+
+ return (
+ setHovered(true) : undefined}
+ onMouseLeave={collapsed ? () => setHovered(false) : undefined}
+ onFocus={collapsed ? () => setHovered(true) : undefined}
+ onBlur={collapsed ? () => setHovered(false) : undefined}
+ >
+
+
+ {hovered && ref.current && (
+
+ )}
+
+ );
+}
+
+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}
+ ,
+ document.body,
+ );
+}
+
+type TooltipWarmRef = React.RefObject;
+
+interface GatewayDotProps {
+ collapsed: boolean;
+ status: StatusResponse | null;
+ tooltipWarmRef: TooltipWarmRef;
+}
+
interface NavItem {
icon: ComponentType<{ className?: string }>;
label: string;
@@ -831,10 +1128,42 @@ interface NavItem {
path: string;
}
+interface SidebarIconWithTooltipProps {
+ children: ReactNode;
+ collapsed: boolean;
+ label: string;
+ tooltipWarmRef: TooltipWarmRef;
+}
+
interface SidebarNavLinkProps {
closeMobile: () => void;
+ 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 {
+ collapsed: boolean;
+ disabled: boolean;
+ isPending: boolean;
+ isRunning: boolean;
+ item: SystemActionItem;
+ onClick: () => void;
+ tooltipWarmRef: TooltipWarmRef;
}
interface SystemActionItem {
diff --git a/web/src/components/LanguageSwitcher.tsx b/web/src/components/LanguageSwitcher.tsx
index 9f790026550..9dd160822ea 100644
--- a/web/src/components/LanguageSwitcher.tsx
+++ b/web/src/components/LanguageSwitcher.tsx
@@ -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(null);
+ const dropdownRef = useRef(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",
+ )}
>
)}
- {open && !useMobileSheet && (
-
-
-
- )}
+ {open && !useMobileSheet && (() => {
+ const rect = containerRef.current?.getBoundingClientRect();
+ const dropdown = (
+
+
+
+ );
+ return dropUp ? createPortal(dropdown, document.body) : dropdown;
+ })()}
);
}
@@ -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 70ab23d25a8..71a4b43e05b 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 6556f492c25..10612ace641 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 f1359dd442d..a591d2d720b 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,9 +64,14 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
@@ -101,34 +108,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 +238,6 @@ interface ThemeSwitcherOptionsProps {
}
interface ThemeSwitcherProps {
+ collapsed?: boolean;
dropUp?: boolean;
}
diff --git a/web/src/i18n/af.ts b/web/src/i18n/af.ts
index 8bc34e81c04..c3d6312aa3f 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",
@@ -422,7 +423,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 ef41f494418..d6fdfe64548 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",
@@ -422,7 +423,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 ac67b6eaf75..f792bf4dc3f 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",
@@ -422,7 +423,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 067d595ae88..84a1501e97b 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",
@@ -422,7 +423,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 672f5d90730..409c0a1e397 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",
@@ -422,7 +423,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 2ad89214348..a4d41e30354 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",
@@ -422,7 +423,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 92e21f39596..7814aff86c8 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",
@@ -422,7 +423,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 1089cdbb9a4..1485cb68778 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",
@@ -422,7 +423,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 d4e23aa46a1..1b9ad88ea5f 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: "まだセッションがありません",
@@ -422,7 +423,7 @@ export const ja: Translations = {
},
language: {
- switchTo: "英語に切り替え",
+ switchTo: "言語を切り替え",
},
theme: {
diff --git a/web/src/i18n/ko.ts b/web/src/i18n/ko.ts
index 2766f4d9f58..4fcb6f0010e 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: "아직 세션이 없습니다",
@@ -422,7 +423,7 @@ export const ko: Translations = {
},
language: {
- switchTo: "영어로 전환",
+ switchTo: "언어 변경",
},
theme: {
diff --git a/web/src/i18n/pt.ts b/web/src/i18n/pt.ts
index 512519a3fd5..b84c99b67bf 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",
@@ -422,7 +423,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 98b45f9f3a6..e9b5e2cb84a 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: "Сессий пока нет",
@@ -422,7 +423,7 @@ export const ru: Translations = {
},
language: {
- switchTo: "Переключиться на английский",
+ switchTo: "Сменить язык",
},
theme: {
diff --git a/web/src/i18n/tr.ts b/web/src/i18n/tr.ts
index 64b69887f52..f9aaa14d4b1 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",
@@ -422,7 +423,7 @@ export const tr: Translations = {
},
language: {
- switchTo: "İngilizce'ye geç",
+ switchTo: "Dil değiştir",
},
theme: {
diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts
index b45c6339f75..15f2f1a0c92 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 69dccf7caf3..8d67f58ecca 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: "Поки немає сесій",
@@ -422,7 +423,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 2edb67e02aa..e569b27a487 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: "尚無工作階段",
@@ -422,7 +423,7 @@ export const zhHant: Translations = {
},
language: {
- switchTo: "切換為英文",
+ switchTo: "切換語言",
},
theme: {
diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts
index 60e6521a082..5bc5ae49355 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: "暂无会话",
@@ -417,7 +418,7 @@ export const zh: Translations = {
},
language: {
- switchTo: "切换到英文",
+ switchTo: "切换语言",
},
theme: {
diff --git a/web/src/index.css b/web/src/index.css
index 01b6d9bd178..212406b7e76 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 5e8f65f35f6..9dff4801614 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 },
]}
/>
)}