From 3ed915e3094801acf45e1621c197024586333bc1 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Thu, 29 Oct 2026 09:15:31 +0100 Subject: [PATCH] ui: reveal the collapsed sidebar from its logo and toggle it there (#31307) * ui: reveal the collapsed sidebar from its logo and toggle it there The collapsed sidebar now shows only the logo; hovering it reveals the icon strip until the pointer leaves. The expanded sidebar drops its top-right collapse button and the logo collapses it on hover instead. Assisted-by: Claude (Opus 5.5 Medium) * ui: fade the peeked sidebar in at full width and keep the toggle slide Hovering the logo of the collapsed sidebar now fades in the fully open sidebar, floating over the page at its full width, instead of resizing it. Opening or closing with the toggle still resizes and slides: the sidebar docks to strip width while it slides, then floats out of sight, and it stays hidden under the pointer after a toggle close. The unused icon-only strip is removed from the sidebar actions. Assisted-by: Claude (Opus 5.5 Medium) * ui: stack the sidebar above the chat tabs on desktop The peeked sidebar floats over the page, but the chat tab bar shared its z-index and came later in the page, so the tabs were drawn on top of it. Assisted-by: Claude (Opus 5.5 Medium) --- .../SidebarNavigation.svelte | 227 ++++++++++++------ .../SidebarNavigationActions.svelte | 117 +++------ tools/ui/src/lib/constants/ui.constants.ts | 2 + 3 files changed, 200 insertions(+), 146 deletions(-) diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte index a6d408439..af2266728 100644 --- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte +++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte @@ -22,7 +22,8 @@ SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH, SIDEBAR_OVERSTRETCH_PX, - SIDEBAR_SETTLE_MS + SIDEBAR_SETTLE_MS, + SIDEBAR_TOGGLE_MS } from '$lib/constants'; import { SidebarAction, TooltipSide } from '$lib/enums'; import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte'; @@ -41,12 +42,29 @@ toggleSidebar: () => toggleExpandedMode() }); - let hoveredTooltip = $state(null); + let innerWidth = $state(0); let logoHovered = $state(false); + /** Set while the collapsed sidebar is revealed by hovering its logo, until the pointer leaves it. */ + let isPeeking = $state(false); + /** Set after the toggle closes the sidebar under the pointer, until the pointer leaves the logo. */ + let isPeekSuppressed = $state(false); + /** Keeps the collapsed sidebar in its strip-width slot while it slides closed or before it slides open. */ + let isDocked = $state(false); + /** Drops the transitions for the frame the sidebar swaps between docked and floating, out of sight. */ + let isSnapping = $state(false); + let dockTimeout: ReturnType | undefined; - const isStripExpanded = $derived(uiStore.isSidebarExpanded || hoveredTooltip !== null); const isOnMobile = $derived(deviceStore.isMobile); const alwaysShowOnDesktop = $derived(settingsStore.config.alwaysShowSidebarOnDesktop as boolean); + // the collapsed sidebar shows only its logo until it is peeked at, which fades the rest in + const isStripHidden = $derived(!uiStore.isSidebarExpanded && !isOnMobile && !isPeeking); + // once settled, the collapsed sidebar keeps its full width, floating over the page, + // so a peek only fades it in instead of resizing it + const isFloating = $derived(!uiStore.isSidebarExpanded && !isOnMobile && !isDocked); + // on desktop the logo doubles as the sidebar toggle, unless the sidebar is pinned open + const isLogoToggle = $derived( + innerWidth > 768 && (!uiStore.isSidebarExpanded || !alwaysShowOnDesktop) + ); $effect(() => { if (alwaysShowOnDesktop && !isOnMobile) { @@ -54,11 +72,63 @@ } }); - function toggleExpandedMode() { - uiStore.isSidebarExpanded = !uiStore.isSidebarExpanded; + /** Swaps between docked and floating without a transition, on the next frames. */ + function snapDocked(docked: boolean, then?: () => void) { + isSnapping = true; + isDocked = docked; - if (!uiStore.isSidebarExpanded) { - hoveredTooltip = null; + requestAnimationFrame(() => + requestAnimationFrame(() => { + isSnapping = false; + then?.(); + }) + ); + } + + function toggleExpandedMode() { + clearTimeout(dockTimeout); + + if (isOnMobile) { + uiStore.isSidebarExpanded = !uiStore.isSidebarExpanded; + + return; + } + + if (uiStore.isSidebarExpanded) { + // slide closed at strip width, kept hidden under the pointer, then float once out of sight + isPeeking = false; + isPeekSuppressed = true; + isDocked = true; + uiStore.isSidebarExpanded = false; + dockTimeout = setTimeout(() => snapDocked(false), SIDEBAR_TOGGLE_MS); + + return; + } + + if (isPeeking) { + // already shown at full width, so only the page slides aside + isDocked = false; + uiStore.isSidebarExpanded = true; + + return; + } + + // dock to strip width out of sight, then slide open from there + snapDocked(true, () => { + isDocked = false; + uiStore.isSidebarExpanded = true; + }); + } + + function peek() { + if (!isPeekSuppressed) { + isPeeking = true; + } + } + + function handleAsideFocusOut(event: FocusEvent) { + if (!asideEl?.contains(event.relatedTarget as Node | null)) { + isPeeking = false; } } @@ -301,7 +371,6 @@ chatStore.stopGenerationForChat(id); } - let innerWidth = $state(0); let pendingCollapse = $state | null>(null); function scheduleMobileCollapse() { @@ -479,10 +548,11 @@ {#if innerWidth > 768 || !page.url.hash.includes(ROUTES.SEARCH)} +