Commit 69b3cc406 for llama.cpp

commit 69b3cc406ea5e8c5cd893c37beb6d9cc8505fb8e
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date:   Mon Oct 26 09:07:25 2026 +0100

    ui: Sidebar resizing improvements (#31119)

    * ui : let the expanded sidebar be resized by dragging its edge

    Bring back the desktop sidebar resize handle that was dropped with the
    shadcn sidebar primitive. The width is clamped between 18rem and 32rem,
    persists across reloads, and resets on a double-click of the handle. The
    tab bar follows the same width so a wider sidebar never covers it.

    Assisted by: Claude (Opus 5.5 Medium)

    * ui : rubber-band the sidebar edge past its bounds and drag it shut

    Past the max width the edge resists harder the further it is pulled, and
    the same happens below the min width until the pull passes 9rem, where it
    snaps to the pointer and letting go hides the sidebar. A release outside
    the bounds springs the width back to the nearest one.

    Assisted by: Claude (Opus 5.5 Medium)

    * ui : show the sidebar edge strip faintly on hover and solid while dragging

    The strip is clipped to the sidebar's rounded corners through its own
    overlay, so the sidebar itself is never clipped.

    Assisted by: Claude (Opus 5.5 Medium)

    * ui : remember whether the desktop sidebar is open across reloads

    A phone opens the sidebar as an overlay, so it always starts closed there
    and its toggling never overwrites the desktop preference.

    Assisted by: Claude (Opus 5.5 Medium)

    * ui : fade the sidebar labels and conversation list with its width

    The fade follows the live width, so it tracks both the toggle's width
    transition and a drag below the minimum width.

    Assisted by: Claude (Opus 5.5 Medium)

    * ui : persist the sidebar width on release and clamp a restored one

    A drag now updates only the live width and writes it to localStorage once
    when released. A width restored from localStorage is clamped to the
    current bounds, so a value saved under older bounds does not stick.

    Assisted-by: Claude (Opus 5.5 Medium)

diff --git a/tools/ui/src/app.css b/tools/ui/src/app.css
index ccf13fbec..94f9ff0b9 100644
--- a/tools/ui/src/app.css
+++ b/tools/ui/src/app.css
@@ -35,6 +35,8 @@

 :root {
 	--radius: 0.625rem;
+	/* expanded desktop sidebar width, overridden once the user drags its edge */
+	--sidebar-width: 18rem;
 	--background: oklch(1 0 0);
 	--foreground: oklch(0.145 0 0);
 	--card: oklch(1 0 0);
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 828c63623..9fb451361 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
@@ -13,13 +13,21 @@
 		SidebarNavigationActions,
 		SidebarNavigationConversationList
 	} from '$lib/components/app';
-	import { ROUTES } from '$lib/constants';
+	import {
+		ROUTES,
+		SIDEBAR_COLLAPSE_WIDTH,
+		SIDEBAR_COLLAPSED_WIDTH,
+		SIDEBAR_MAX_WIDTH,
+		SIDEBAR_MIN_WIDTH,
+		SIDEBAR_OVERSTRETCH_PX,
+		SIDEBAR_SETTLE_MS
+	} from '$lib/constants';
 	import { TooltipSide } from '$lib/enums';
 	import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
 	import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
 	import { RouterService } from '$lib/services/router.service';
 	import { chatStore, conversationsStore, deviceStore, settingsStore, uiStore } from '$lib/stores';
-	import { buildConversationTree } from '$lib/utils';
+	import { buildConversationTree, remToPx } from '$lib/utils';
 	import { circIn } from 'svelte/easing';
 	import { SvelteSet } from 'svelte/reactivity';
 	import { fade } from 'svelte/transition';
@@ -311,6 +319,120 @@
 		}
 	}

+	let asideEl = $state<HTMLElement | null>(null);
+	/** Set briefly after a drag ends past a bound, while the width springs back to it. */
+	let isSettling = $state(false);
+
+	// the tab bar sits outside the sidebar, so the width lives on the root
+	$effect(() => {
+		const width = uiStore.sidebarWidth;
+
+		if (width === null) {
+			document.documentElement.style.removeProperty('--sidebar-width');
+		} else {
+			document.documentElement.style.setProperty('--sidebar-width', `${width}px`);
+		}
+	});
+
+	// labels and the conversation list fade with the live width, so they follow
+	// both the toggle's width transition and a drag below the minimum width
+	$effect(() => {
+		const el = asideEl;
+
+		if (!el) return;
+
+		const collapsedPx = remToPx(SIDEBAR_COLLAPSED_WIDTH);
+		const minPx = remToPx(SIDEBAR_MIN_WIDTH);
+		const observer = new ResizeObserver(([entry]) => {
+			const fade = (entry.contentRect.width - collapsedPx) / (minPx - collapsedPx);
+
+			el.style.setProperty('--sidebar-fade', String(Math.min(1, Math.max(0, fade))));
+		});
+
+		observer.observe(el);
+
+		return () => observer.disconnect();
+	});
+
+	function handleResizePointerDown(event: PointerEvent) {
+		if (!asideEl || isOnMobile) return;
+
+		event.preventDefault();
+
+		const handle = event.currentTarget as HTMLElement;
+		const left = asideEl.getBoundingClientRect().left;
+		const minPx = remToPx(SIDEBAR_MIN_WIDTH);
+		const maxPx = remToPx(SIDEBAR_MAX_WIDTH);
+		const collapsePx = remToPx(SIDEBAR_COLLAPSE_WIDTH);
+		const startWidth = uiStore.sidebarWidth;
+
+		let willCollapse = false;
+
+		handle.setPointerCapture(event.pointerId);
+		uiStore.isSidebarResizing = true;
+
+		/** Rubber-band offset: grows with the distance, ever slower, never past the overstretch. */
+		function stretch(distance: number): number {
+			return SIDEBAR_OVERSTRETCH_PX * (1 - 1 / (distance / SIDEBAR_OVERSTRETCH_PX + 1));
+		}
+
+		function onPointerMove(moveEvent: PointerEvent) {
+			const pointerWidth = moveEvent.clientX - left;
+
+			// past either bound the edge resists harder the further it is pulled,
+			// until a pull below the collapse width snaps it to the pointer
+			willCollapse = pointerWidth < collapsePx;
+
+			const width = willCollapse
+				? Math.max(0, pointerWidth)
+				: pointerWidth < minPx
+					? minPx - stretch(minPx - pointerWidth)
+					: pointerWidth > maxPx
+						? maxPx + stretch(pointerWidth - maxPx)
+						: pointerWidth;
+
+			// persisted once on release, not on every move
+			uiStore.sidebarWidth = Math.round(width);
+		}
+
+		function onPointerUp() {
+			handle.removeEventListener('pointermove', onPointerMove);
+			handle.removeEventListener('pointerup', onPointerUp);
+			handle.removeEventListener('pointercancel', onPointerCancel);
+
+			uiStore.isSidebarResizing = false;
+
+			// it reopens at its width from before the drag, without springing on the way out
+			if (willCollapse) {
+				uiStore.sidebarWidth = startWidth;
+				toggleExpandedMode();
+
+				return;
+			}
+
+			if (uiStore.sidebarWidth === startWidth) return;
+
+			const width = uiStore.sidebarWidth ?? minPx;
+			const clamped = Math.min(maxPx, Math.max(minPx, width));
+
+			if (clamped !== width) {
+				isSettling = true;
+				setTimeout(() => (isSettling = false), SIDEBAR_SETTLE_MS);
+			}
+
+			uiStore.setSidebarWidth(Math.round(clamped));
+		}
+
+		function onPointerCancel() {
+			willCollapse = false;
+			onPointerUp();
+		}
+
+		handle.addEventListener('pointermove', onPointerMove);
+		handle.addEventListener('pointerup', onPointerUp);
+		handle.addEventListener('pointercancel', onPointerCancel);
+	}
+
 	/** A dialog owns the screen on a phone, so the expanded list steps aside for it. */
 	function openDialog(open: () => void): void {
 		if (deviceStore.isMobile) {
@@ -325,6 +447,7 @@

 {#if innerWidth > 768 || !page.url.hash.includes(ROUTES.SEARCH)}
 	<aside
+		bind:this={asideEl}
 		class={[
 			'fixed md:sticky top-2 left-2 md:left-0 md:ml-2 md:mt-2 pt-2 z-10 w-[calc(100dvw-1rem)]',
 			'md:h-[calc(100dvh-1.125rem)]',
@@ -337,7 +460,9 @@
 			'rounded-3xl md:rounded-2xl',
 			'flex flex-col justify-between',
 			'md:transition-[width,padding] duration-200 ease-out',
-			isStripExpanded && 'md:w-72 md:bg-muted/60 md:backdrop-blur-xl shadow-md',
+			uiStore.isSidebarResizing && 'md:duration-0',
+			isSettling && 'md:duration-300 md:ease-[cubic-bezier(0.34,1.56,0.64,1)]',
+			isStripExpanded && 'md:w-(--sidebar-width) md:bg-muted/60 md:backdrop-blur-xl shadow-md',
 			!isStripExpanded && 'md:w-12',
 			uiStore.isSidebarExpanded && 'is-expanded'
 		]}
@@ -421,7 +546,7 @@
 			/>

 			{#if uiStore.isSidebarExpanded || isOnMobile}
-				<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
+				<div class="flex min-h-0 flex-1 flex-col overflow-y-auto opacity-(--sidebar-fade)">
 					<SidebarNavigationConversationList
 						{allSelectedArePinned}
 						allVisibleSelected={visibleSelectionStats.visibleCount > 0 &&
@@ -454,6 +579,33 @@
 				</div>
 			{/if}
 		</div>
+
+		{#if uiStore.isSidebarExpanded && !isOnMobile}
+			<!-- svelte-ignore a11y_no_static_element_interactions -->
+			<div
+				class="peer absolute inset-y-3 -right-1 z-20 hidden w-2 cursor-ew-resize touch-none select-none md:block"
+				data-slot="sidebar-resize-handle"
+				ondblclick={() => uiStore.setSidebarWidth(null)}
+				onpointerdown={handleResizePointerDown}
+				title="Drag to resize or hide, double-click to reset"
+			></div>
+
+			<!-- the edge strip is clipped to the sidebar's rounded corners without
+			     clipping the sidebar itself; faint on hover, solid while dragging -->
+			<div
+				class={[
+					'pointer-events-none absolute inset-0 hidden overflow-hidden rounded-[inherit] transition-opacity duration-150 md:block',
+					uiStore.isSidebarResizing ? 'opacity-100' : 'opacity-0 peer-hover:opacity-100'
+				]}
+			>
+				<div
+					class={[
+						'absolute inset-y-0 right-0 w-1.5',
+						uiStore.isSidebarResizing ? 'bg-foreground/15' : 'bg-foreground/6'
+					]}
+				></div>
+			</div>
+		{/if}
 	</aside>
 {/if}

diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte
index 95efa5372..d048bbce8 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte
@@ -175,8 +175,10 @@
 							{@render itemIcon(item.icon)}

 							{#if showIcons}
-								<span in:fade={itemTransition} out:fade={itemTransition} class="min-w-0 truncate"
-									>{item.tooltip}</span
+								<span
+									in:fade={itemTransition}
+									out:fade={itemTransition}
+									class="min-w-0 truncate opacity-(--sidebar-fade)">{item.tooltip}</span
 								>
 							{/if}
 						</span>
diff --git a/tools/ui/src/lib/constants/chat-tabs.constants.ts b/tools/ui/src/lib/constants/chat-tabs.constants.ts
index 1c0bb051a..cc3f97b83 100644
--- a/tools/ui/src/lib/constants/chat-tabs.constants.ts
+++ b/tools/ui/src/lib/constants/chat-tabs.constants.ts
@@ -9,10 +9,11 @@ export const UNNAMED_CHAT_LABEL = 'Chat';

 /**
  * Tab bar max width so it stays clear of the sidebar strip. The expanded strip
- * is `md:w-72` and the collapsed one `md:w-12`; these hold the fully tuned
- * `max-w-[calc(100vw-?rem)]` classes so the offset has a single source.
+ * is `--sidebar-width` wide (user resizable) and the collapsed one `md:w-12`;
+ * these hold the fully tuned `max-w-[calc(100vw-?)]` classes so the offset has
+ * a single source.
  */
 export const CHAT_TABS_MAX_WIDTH = {
 	COLLAPSED_SIDEBAR: 'max-w-[calc(100vw-5rem)]',
-	EXPANDED_SIDEBAR: 'max-w-[calc(100vw-20rem)]'
+	EXPANDED_SIDEBAR: 'max-w-[calc(100vw-var(--sidebar-width)-2rem)]'
 } as const;
diff --git a/tools/ui/src/lib/constants/storage.constants.ts b/tools/ui/src/lib/constants/storage.constants.ts
index 637a1e0e8..8b3fcd8da 100644
--- a/tools/ui/src/lib/constants/storage.constants.ts
+++ b/tools/ui/src/lib/constants/storage.constants.ts
@@ -36,6 +36,8 @@ export const RECENT_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.recentModels`
 export const HIDDEN_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.hiddenModels`;
 export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
 export const CONVERSATION_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.conversationTabs`;
+export const SIDEBAR_EXPANDED_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.sidebarExpanded`;
+export const SIDEBAR_WIDTH_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.sidebarWidth`;
 export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
 export const DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.dismissedRecommendedMcpServers`;

diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts
index 9af1424fa..414168aad 100644
--- a/tools/ui/src/lib/constants/ui.constants.ts
+++ b/tools/ui/src/lib/constants/ui.constants.ts
@@ -68,6 +68,18 @@ export const RECENT_MODEL_LIMIT = 20;
 export const ICON_STRIP_TRANSITION_DURATION = 150;
 export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;

+/** Collapsed desktop sidebar strip width, matching its `md:w-12`. */
+export const SIDEBAR_COLLAPSED_WIDTH = '3rem';
+/** Bounds the expanded desktop sidebar can be dragged between. */
+export const SIDEBAR_MIN_WIDTH = '18rem';
+export const SIDEBAR_MAX_WIDTH = '32rem';
+/** Dragging the sidebar edge narrower than this snaps it to the pointer; letting go hides it. */
+export const SIDEBAR_COLLAPSE_WIDTH = '9rem';
+/** How far past its min or max width a drag can stretch the sidebar before it resists fully. */
+export const SIDEBAR_OVERSTRETCH_PX = 80;
+/** Spring-back duration when a drag ends outside the width bounds. */
+export const SIDEBAR_SETTLE_MS = 300;
+
 /** Max height for tool-result code blocks (json / source / diff / streaming code). */
 export const MAX_HEIGHT_CODE_BLOCK = '22rem';

diff --git a/tools/ui/src/lib/stores/ui.svelte.ts b/tools/ui/src/lib/stores/ui.svelte.ts
index 49ac2fbff..354b31744 100644
--- a/tools/ui/src/lib/stores/ui.svelte.ts
+++ b/tools/ui/src/lib/stores/ui.svelte.ts
@@ -6,11 +6,22 @@
  * controls and the chat tab bar reacts to).
  */

+import { browser } from '$app/environment';
 import { goto } from '$app/navigation';
 import { page } from '$app/state';
-import { MODELS_ROUTE_ID, ROUTES, SETTINGS_KEYS } from '$lib/constants';
+import {
+	MODELS_ROUTE_ID,
+	ROUTES,
+	SETTINGS_KEYS,
+	SIDEBAR_EXPANDED_LOCALSTORAGE_KEY,
+	SIDEBAR_MAX_WIDTH,
+	SIDEBAR_MIN_WIDTH,
+	SIDEBAR_WIDTH_LOCALSTORAGE_KEY
+} from '$lib/constants';
+import { deviceStore } from '$lib/stores/device.svelte';
 import { settingsStore } from '$lib/stores/settings/index.svelte';
 import type { ModelOption } from '$lib/types/models';
+import { remToPx } from '$lib/utils/css';

 class UiStore {
 	/** Set when a flow outside the chat wants the composer focused once it can take it. */
@@ -21,14 +32,19 @@ class UiStore {
 	/** Open state of the Discover dialog. */
 	discoverModelsOpen = $state(false);

-	/** Whether the desktop sidebar is expanded (open). */
-	isSidebarExpanded = $state(false);
+	/** Set while the user drags the sidebar edge, so width transitions can step aside. */
+	isSidebarResizing = $state(false);
+
 	/** Model the manager reveals when it opens, a qualified id or a raw model name. */
 	manageModelFocus = $state<string | null>(null);
 	/** Model the information dialog shows; a phone opens it instead of the manager. */
 	modelInformation = $state<ModelOption | null>(null);
 	/** Route the models page returns to: the last page visited before it. */
 	modelsReturnRoute: string = ROUTES.START;
+	/** Expanded desktop sidebar width in px; null keeps the stylesheet default. */
+	sidebarWidth = $state<number | null>(this.loadSidebarWidth());
+
+	#isSidebarExpanded = $state(this.loadSidebarExpanded());

 	/** Whether Discover is turned on; every Discover entry point hides without it. */
 	get isDiscoverEnabled(): boolean {
@@ -40,6 +56,20 @@ class UiStore {
 		return page.route.id === MODELS_ROUTE_ID;
 	}

+	/** Whether the desktop sidebar is expanded (open); remembered across reloads on desktop. */
+	get isSidebarExpanded(): boolean {
+		return this.#isSidebarExpanded;
+	}
+
+	set isSidebarExpanded(expanded: boolean) {
+		this.#isSidebarExpanded = expanded;
+
+		// a phone opens the sidebar as an overlay, which is no layout to restore
+		if (!browser || deviceStore.isMobile) return;
+
+		localStorage.setItem(SIDEBAR_EXPANDED_LOCALSTORAGE_KEY, String(expanded));
+	}
+
 	/** Leave the models page for the route it was opened from. */
 	async closeModelsManager(): Promise<void> {
 		if (!this.isModelsPageOpen) return;
@@ -80,6 +110,41 @@ class UiStore {
 	requestComposerFocus(): void {
 		this.composerFocusRequested = true;
 	}
+
+	/** Set the expanded sidebar width in px, or null to go back to the default. */
+	setSidebarWidth(width: number | null): void {
+		this.sidebarWidth = width;
+
+		if (!browser) return;
+
+		if (width === null) {
+			localStorage.removeItem(SIDEBAR_WIDTH_LOCALSTORAGE_KEY);
+		} else {
+			localStorage.setItem(SIDEBAR_WIDTH_LOCALSTORAGE_KEY, String(width));
+		}
+	}
+
+	private loadSidebarExpanded(): boolean {
+		if (!browser || deviceStore.isMobile) return false;
+
+		return localStorage.getItem(SIDEBAR_EXPANDED_LOCALSTORAGE_KEY) === 'true';
+	}
+
+	private loadSidebarWidth(): number | null {
+		if (!browser) return null;
+
+		const width = Number(localStorage.getItem(SIDEBAR_WIDTH_LOCALSTORAGE_KEY));
+
+		if (!Number.isFinite(width) || width <= 0) return null;
+
+		// a width saved under older bounds is pulled back inside the current ones
+		const clamped = Math.min(
+			remToPx(SIDEBAR_MAX_WIDTH),
+			Math.max(remToPx(SIDEBAR_MIN_WIDTH), width)
+		);
+
+		return Math.round(clamped);
+	}
 }

 export const uiStore = new UiStore();