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 }, ]} /> )}