Commit b39680c41 for llama.cpp

commit b39680c41a541b8f623196f7552dce72d211b6e6
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date:   Mon Nov 9 13:42:44 2026 +0100

    ui: fold, preview and rearrange the sidebar's sections (#31936)

    * ui : fold the sidebar's conversation sections

    Pinned and Recent conversations fold under their heading. The whole heading
    row is the toggle; its chevron follows the title, shows on hover and stays
    while the section is folded. Folded sections are remembered across reloads.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui : show a sidebar section's first six chats

    Pinned and Recent list their first six chats and end with a Show more
    row that opens the whole section; Show less, sticking to the bottom of
    the sidebar while the section scrolls under it, closes it back. The
    expanded sections last for the session.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui : rearrange the sidebar's sections and chats by dragging

    Pinned and Recent can be dragged by their title. The real section follows
    the pointer, tinted like the sidebar's resize handle and edged with a
    faint border, the other sliding aside as it passes; let go, it glides from
    there into its new slot. The order is remembered across reloads, and a
    section never moved keeps its default place. All sections now scroll
    together.

    A chat dragged onto Pinned is pinned, onto Recent unpinned. A lifted copy
    of the row follows the pointer while the row slides out of its section.
    The section under the pointer takes the same tint and border; a section
    ignores its own chats. Let go, the row slides in where its section sorts
    it, the rows below making room, and the copy glides into it; anywhere
    else, it slides back into its own section.

    While dragging, the rows and headings passed over take no hover. Dragging
    needs a mouse or a pen, so a finger still scrolls the sidebar; the sidebar
    scrolls near its edges, and Escape cancels.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui : set the sidebar's sections apart from their rows

    A section heading now reads above its chats: larger, semibold and in the
    full foreground colour. Chat titles drop to a regular weight in a slightly
    softened colour, full again when the chat is open or hovered, and "Show
    more" drops to a regular weight in the muted colour. Chats inside a
    section sit closer together, sections further apart, and the sidebar's
    actions get more room above and below.

    Assisted-by: Claude (Opus 5.5 Medium)

diff --git a/tools/ui/src/app.css b/tools/ui/src/app.css
index bf1a071a9..c1634fca6 100644
--- a/tools/ui/src/app.css
+++ b/tools/ui/src/app.css
@@ -251,6 +251,56 @@
 		);
 	}

+	/* The sidebar's "Show more" row, stuck to its bottom: an opaque copy of the
+	   panel's translucent muted tint, and a fade above it, like the one under
+	   the tab bar, so the rows scrolling beneath end softly */
+	.sidebar-show-more {
+		--sidebar-surface: color-mix(in oklab, var(--muted) 60%, var(--background));
+		background: var(--sidebar-surface);
+
+		&::before {
+			content: '';
+			position: absolute;
+			inset-inline: 0;
+			bottom: 100%;
+			height: 1rem;
+			pointer-events: none;
+			background: linear-gradient(
+				to top,
+				var(--sidebar-surface) 0%,
+				color-mix(in srgb, var(--sidebar-surface) 60%, transparent) 50%,
+				transparent 100%
+			);
+		}
+	}
+
+	/* An opaque copy of the sidebar panel's translucent tint, under what moves
+	   over the other sections while dragged */
+	.sidebar-drag-surface,
+	.sidebar-drag-overlay {
+		background: color-mix(in oklab, var(--muted) 60%, var(--background));
+	}
+
+	/* the copy of a chat row that follows the pointer, raised above the page */
+	.sidebar-drag-overlay {
+		box-shadow:
+			0 10px 30px -10px rgb(0 0 0 / 0.35),
+			0 0 0 1px color-mix(in srgb, var(--foreground) 12%, transparent);
+	}
+
+	/* While something is dragged, what it passes over takes no hover: only the
+	   sections themselves stay targets, for the drop */
+	body[data-sidebar-dragging] [data-sidebar-section] * {
+		pointer-events: none;
+	}
+
+	.sidebar-drag-overlay {
+		position: fixed;
+		z-index: 1000;
+		border-radius: 0.5rem;
+		pointer-events: none;
+	}
+
 	.shimmer-text {
 		background: linear-gradient(
 			90deg,
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 6304518be..df99917ab 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
@@ -650,7 +650,7 @@
 				: ''} {isStripHidden ? 'md:opacity-0 md:invisible' : ''}"
 		>
 			<SidebarNavigationActions
-				class="px-2"
+				class="px-2 md:my-3"
 				onAction={handleSidebarAction}
 				onNavigate={() => {
 					if (deviceStore.isMobile) {
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
index e4fc91cb8..9b329c92a 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
@@ -25,7 +25,13 @@
 		UI_DATA_ATTRS
 	} from '$lib/constants';
 	import { RouterService } from '$lib/services/router.service';
-	import { chatStore, conversationsStore, settingsStore, tabsStore } from '$lib/stores';
+	import {
+		chatStore,
+		conversationsStore,
+		settingsStore,
+		sidebarDragStore,
+		tabsStore
+	} from '$lib/stores';
 	import type { MenuAction } from '$lib/types';
 	import { onMount } from 'svelte';

@@ -66,6 +72,20 @@
 	let contextMenuOpen = $state(false);

 	let isLoading = $derived(chatStore.getAllLoadingChats().includes(conversation.id));
+	let isLanding = $derived(sidebarDragStore.landingConvId === conversation.id);
+
+	// the row's own controls (the fork link, stop, its menu) keep their press
+	function handlePointerDown(event: PointerEvent) {
+		if (isSelectionMode) return;
+
+		const control = (event.target as Element).closest(
+			'a, button, [role="button"], [data-slot$="-trigger"]'
+		);
+
+		if (control && control !== event.currentTarget) return;
+
+		sidebarDragStore.pressConversation(event, conversation.id);
+	}

 	function handleEdit(event: Event) {
 		event.stopPropagation();
@@ -236,14 +256,17 @@

 <ContextMenuActions bind:open={contextMenuOpen} {actions} disabled={isSelectionMode}>
 	<button
-		class="group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg py-1.5 text-left transition-colors hover:bg-foreground/10 {isActive
+		class="group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg py-1.5 text-left md:min-h-8 md:py-1 transition-colors hover:bg-foreground/10 hover:text-foreground {isLanding
+			? 'invisible'
+			: ''} {isActive
 			? 'bg-foreground/5 text-accent-foreground'
-			: ''} {contextMenuOpen ? 'bg-foreground/10' : ''} {isSelected
+			: 'text-foreground/75'} {contextMenuOpen ? 'bg-foreground/10' : ''} {isSelected
 			? 'bg-primary/10 hover:bg-primary/15'
 			: ''} {isSelectionMode ? 'is-selection-mode' : ''} px-2"
 		{...{ [UI_DATA_ATTRS.CONVERSATION_ROW]: conversation.id }}
 		onauxclick={handleAuxClick}
 		onclick={(e) => handleSelect(e)}
+		ondragstart={(e) => e.preventDefault()}
 		onfocusin={handleMouseOver}
 		onfocusout={(e) => {
 			if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
@@ -253,6 +276,7 @@
 		onmousedown={(e) => handleRowMouseDown(e)}
 		onmouseleave={handleMouseLeave}
 		onmouseover={handleMouseOver}
+		onpointerdown={handlePointerDown}
 	>
 		<div
 			style:padding-left="{depth * FORK_TREE_DEPTH_PADDING}px"
@@ -321,7 +345,7 @@
 				</Tooltip.Root>
 			{/if}

-			<TruncatedText class="text-sm font-medium" showTooltip={false} text={conversation.name} />
+			<TruncatedText class="text-sm" showTooltip={false} text={conversation.name} />
 		</div>

 		{#if !isSelectionMode && renderActionsDropdown}
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationList.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationList.svelte
index 4dfd23b2c..cfd9f082e 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationList.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationList.svelte
@@ -1,8 +1,22 @@
 <script lang="ts">
 	import SidebarNavigationConversationItem from './SidebarNavigationConversationItem.svelte';
+	import SidebarNavigationDropZone from './SidebarNavigationDropZone.svelte';
+	import SidebarNavigationSectionHeading from './SidebarNavigationSectionHeading.svelte';
 	import SidebarNavigationSelectionBar from './SidebarNavigationSelectionBar.svelte';
+	import SidebarNavigationShowMore from './SidebarNavigationShowMore.svelte';
 	import { Pin } from '@lucide/svelte';
+	import { CollapsibleRegion } from '$lib/components/app';
+	import {
+		SIDEBAR_ROW_SLIDE_MS,
+		SIDEBAR_SECTION_IDS,
+		SIDEBAR_SECTION_PREVIEW_COUNT,
+		SIDEBAR_SHOW_MORE_ROW_CLASS
+	} from '$lib/constants';
+	import { SidebarSectionKind } from '$lib/enums';
+	import { conversationsStore, sidebarDragStore, uiStore } from '$lib/stores';
 	import { buildConversationTree } from '$lib/utils';
+	import { flip } from 'svelte/animate';
+	import { slide } from 'svelte/transition';

 	interface Props {
 		class: string;
@@ -65,96 +79,97 @@
 	let unpinnedConversations = $derived(
 		conversationTree.filter(({ conversation }) => !conversation.pinned)
 	);
-</script>

-<div class="flex min-h-0 flex-1 flex-col">
-	{#if pinnedConversations.length > 0}
-		<div class="py-2 flex whitespace-nowrap {className}">
-			<div
-				class="text-muted-foreground inline-flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium gap-1"
-			>
-				<Pin class="h-3.5 w-3.5" />
+	/** How long the other sections take to slide aside for a moved one. */
+	const SECTION_MOVE_MS = 200;

-				<span>Pinned</span>
-			</div>
-		</div>
+	// Pinned, then Recent by default; dragging a heading reorders them
+	let savedSections = $derived(
+		uiStore.orderSidebarSections([
+			...(pinnedConversations.length > 0
+				? [{ id: SIDEBAR_SECTION_IDS.PINNED as string, kind: SidebarSectionKind.PINNED as const }]
+				: []),
+			{ id: SIDEBAR_SECTION_IDS.RECENT as string, kind: SidebarSectionKind.RECENT as const }
+		])
+	);

-		<ul class="flex w-full min-w-0 flex-col gap-4 md:gap-1 {className}">
-			{#each pinnedConversations as { conversation, depth } (conversation.id)}
-				<li class="group/item relative mb-1 p-0">
-					<SidebarNavigationConversationItem
-						conversation={{
-							currNode: conversation.currNode,
-							forkedFromConversationId: conversation.forkedFromConversationId,
-							id: conversation.id,
-							lastModified: conversation.lastModified,
-							name: conversation.name,
-							pinned: conversation.pinned
-						}}
-						{depth}
-						isActive={currentChatId === conversation.id}
-						isSelected={selectedIds.has(conversation.id)}
-						{isSelectionMode}
-						{onDelete}
-						{onEdit}
-						{onEnterSelectionMode}
-						{onRowMouseDown}
-						{onSelect}
-						{onSelectionClick}
-						{onStop}
-						{onToggleSelect}
-					/>
-				</li>
-			{/each}
-		</ul>
-	{/if}
+	// while a section is dragged the others make room for it as it passes them
+	let sections = $derived.by(() => {
+		const order = sidebarDragStore.sectionOrder;

-	<div class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-0 whitespace-nowrap {className}">
-		{#if conversations.length > 0}
-			<div
-				class="text-muted-foreground flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium"
-			>
-				Recent conversations
-			</div>
-		{/if}
-
-		<div class="min-h-0 flex-1 md:overflow-y-auto">
-			<ul class="flex w-full min-w-0 flex-col gap-4 md:gap-0">
-				{#each unpinnedConversations as { conversation, depth } (conversation.id)}
-					<li class="group/item relative mb-1 p-0">
-						<SidebarNavigationConversationItem
-							conversation={{
-								currNode: conversation.currNode,
-								forkedFromConversationId: conversation.forkedFromConversationId,
-								id: conversation.id,
-								lastModified: conversation.lastModified,
-								name: conversation.name,
-								pinned: conversation.pinned
-							}}
-							{depth}
-							isActive={currentChatId === conversation.id}
-							isSelected={selectedIds.has(conversation.id)}
-							{isSelectionMode}
-							{onDelete}
-							{onEdit}
-							{onEnterSelectionMode}
-							{onRowMouseDown}
-							{onSelect}
-							{onSelectionClick}
-							{onStop}
-							{onToggleSelect}
-						/>
-					</li>
-				{/each}
+		if (!order) return savedSections;

-				{#if unpinnedConversations.length === 0}
-					<li class="px-2 py-4 text-center">
-						<p class="mb-4 p-4 text-sm text-muted-foreground">No conversations yet</p>
-					</li>
-				{/if}
-			</ul>
+		return [...savedSections].sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
+	});
+
+	// a section lists its first chats until its "Show more" row opens all of it
+	function previewOf<T extends { conversation: DatabaseConversation }>(
+		sectionId: string,
+		items: T[]
+	): T[] {
+		// a dragged chat leaves its section until it is dropped somewhere
+		const shown = items.filter(({ conversation }) => conversation.id !== sidebarDragStore.convId);
+
+		return uiStore.isSidebarSectionExpanded(sectionId)
+			? shown
+			: shown.slice(0, SIDEBAR_SECTION_PREVIEW_COUNT);
+	}
+
+	function findConversation(convId: string) {
+		return conversationsStore.conversations.find((item) => item.id === convId);
+	}
+
+	function isPinned(convId: string): boolean {
+		return Boolean(findConversation(convId)?.pinned);
+	}
+
+	function isUnpinned(convId: string): boolean {
+		const conversation = findConversation(convId);
+
+		return Boolean(conversation && !conversation.pinned);
+	}
+
+	/** Dropping on Pinned pins the chat, wherever it came from. */
+	async function handleDropPinned(convId: string) {
+		const conversation = conversationsStore.conversations.find((item) => item.id === convId);
+
+		if (conversation && !conversation.pinned)
+			await conversationsStore.toggleConversationPin(convId);
+	}
+
+	/** Dropping on Recent unpins the chat. */
+	async function handleDropRecent(convId: string) {
+		const conversation = conversationsStore.conversations.find((item) => item.id === convId);
+
+		if (conversation?.pinned) await conversationsStore.toggleConversationPin(convId);
+	}
+</script>
+
+<div class="flex flex-col gap-4">
+	{#each sections as section (section.id)}
+		{@const isDragged = section.id === sidebarDragStore.sectionId}
+
+		<!-- the dragged section follows the pointer and, let go, glides into its slot;
+		     the others slide aside, the dragged one is already where the pointer is -->
+		<div
+			animate:flip={{ duration: isDragged ? 0 : SECTION_MOVE_MS }}
+			style:transform={isDragged ? `translateY(${sidebarDragStore.sectionOffset}px)` : undefined}
+			class={[
+				// opaque under its tint, so the sections it passes do not show through
+				isDragged && 'sidebar-drag-surface relative z-20 rounded-lg',
+				isDragged &&
+					sidebarDragStore.settling &&
+					'transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]'
+			]}
+			data-sidebar-slot={section.id}
+		>
+			{#if section.kind === SidebarSectionKind.PINNED}
+				{@render pinnedSection()}
+			{:else}
+				{@render recentSection()}
+			{/if}
 		</div>
-	</div>
+	{/each}

 	{#if isSelectionMode}
 		<SidebarNavigationSelectionBar
@@ -173,3 +188,111 @@
 		/>
 	{/if}
 </div>
+
+{#snippet showMore(sectionId: string, total: number)}
+	{#if total > SIDEBAR_SECTION_PREVIEW_COUNT}
+		<!-- only an opened section is long enough for its row to need to stick -->
+		<li class={uiStore.isSidebarSectionExpanded(sectionId) ? SIDEBAR_SHOW_MORE_ROW_CLASS : 'p-0'}>
+			<SidebarNavigationShowMore
+				expanded={uiStore.isSidebarSectionExpanded(sectionId)}
+				onToggle={() => uiStore.toggleSidebarSectionExpanded(sectionId)}
+			/>
+		</li>
+	{/if}
+{/snippet}
+
+{#snippet conversationItem(conversation: DatabaseConversation, depth = 0)}
+	<SidebarNavigationConversationItem
+		conversation={{
+			currNode: conversation.currNode,
+			forkedFromConversationId: conversation.forkedFromConversationId,
+			id: conversation.id,
+			lastModified: conversation.lastModified,
+			name: conversation.name,
+			pinned: conversation.pinned
+		}}
+		{depth}
+		isActive={currentChatId === conversation.id}
+		isSelected={selectedIds.has(conversation.id)}
+		{isSelectionMode}
+		{onDelete}
+		{onEdit}
+		{onEnterSelectionMode}
+		{onRowMouseDown}
+		{onSelect}
+		{onSelectionClick}
+		{onStop}
+		{onToggleSelect}
+	/>
+{/snippet}
+
+{#snippet pinnedSection()}
+	<SidebarNavigationDropZone
+		class="flex flex-col"
+		holds={(convId) => isPinned(convId)}
+		onDrop={handleDropPinned}
+		sectionId={SIDEBAR_SECTION_IDS.PINNED}
+	>
+		{#snippet heading()}
+			<div class="whitespace-nowrap {className}">
+				<SidebarNavigationSectionHeading
+					dragId={SIDEBAR_SECTION_IDS.PINNED}
+					icon={Pin}
+					label="Pinned"
+					onToggle={() => uiStore.toggleSidebarSection(SIDEBAR_SECTION_IDS.PINNED)}
+					open={uiStore.isSidebarSectionOpen(SIDEBAR_SECTION_IDS.PINNED)}
+				/>
+			</div>
+		{/snippet}
+
+		<CollapsibleRegion open={uiStore.isSidebarSectionOpen(SIDEBAR_SECTION_IDS.PINNED)}>
+			<ul class="flex w-full min-w-0 flex-col gap-4 md:gap-0.5 {className}">
+				{#each previewOf(SIDEBAR_SECTION_IDS.PINNED, pinnedConversations) as { conversation, depth } (conversation.id)}
+					<li transition:slide={{ duration: SIDEBAR_ROW_SLIDE_MS }} class="group/item relative p-0">
+						{@render conversationItem(conversation, depth)}
+					</li>
+				{/each}
+
+				{@render showMore(SIDEBAR_SECTION_IDS.PINNED, pinnedConversations.length)}
+			</ul>
+		</CollapsibleRegion>
+	</SidebarNavigationDropZone>
+{/snippet}
+
+{#snippet recentSection()}
+	<SidebarNavigationDropZone
+		class="flex flex-col gap-4 md:gap-0 whitespace-nowrap {className}"
+		holds={(convId) => isUnpinned(convId)}
+		onDrop={handleDropRecent}
+		sectionId={SIDEBAR_SECTION_IDS.RECENT}
+	>
+		{#snippet heading()}
+			{#if conversations.length > 0}
+				<SidebarNavigationSectionHeading
+					dragId={SIDEBAR_SECTION_IDS.RECENT}
+					label="Recent conversations"
+					onToggle={() => uiStore.toggleSidebarSection(SIDEBAR_SECTION_IDS.RECENT)}
+					open={uiStore.isSidebarSectionOpen(SIDEBAR_SECTION_IDS.RECENT)}
+				/>
+			{/if}
+		{/snippet}
+
+		<CollapsibleRegion open={uiStore.isSidebarSectionOpen(SIDEBAR_SECTION_IDS.RECENT)}>
+			<ul class="flex w-full min-w-0 flex-col gap-4 md:gap-0.5">
+				{#each previewOf(SIDEBAR_SECTION_IDS.RECENT, unpinnedConversations) as { conversation, depth } (conversation.id)}
+					<li transition:slide={{ duration: SIDEBAR_ROW_SLIDE_MS }} class="group/item relative p-0">
+						{@render conversationItem(conversation, depth)}
+					</li>
+				{/each}
+
+				{#if conversations.length === 0}
+					<li class="px-2 py-4 text-center">
+						<p class="mb-4 p-4 text-sm text-muted-foreground">No conversations yet</p>
+					</li>
+				{/if}
+
+				{@render showMore(SIDEBAR_SECTION_IDS.RECENT, unpinnedConversations.length)}
+			</ul>
+		</CollapsibleRegion>
+	</SidebarNavigationDropZone>
+{/snippet}
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationDropZone.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationDropZone.svelte
new file mode 100644
index 000000000..25a47b9c9
--- /dev/null
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationDropZone.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+	import { SIDEBAR_DRAG_HIGHLIGHT_CLASS } from '$lib/constants';
+	import { sidebarDragStore } from '$lib/stores';
+	import type { Snippet } from 'svelte';
+	import type { ClassValue } from 'svelte/elements';
+
+	interface Props {
+		class?: ClassValue;
+		/** Receives the id of the conversation dropped on the zone. */
+		onDrop: (convId: string) => Promise<void> | void;
+		/** The section this zone is. */
+		sectionId: string;
+		/** Whether the chat is in this section already; dropping it here would change nothing. */
+		holds: (convId: string) => boolean;
+		heading: Snippet;
+		children: Snippet;
+	}
+
+	let { children, class: className, heading, holds, onDrop, sectionId }: Props = $props();
+
+	$effect(() => sidebarDragStore.registerZone(sectionId, { holds, onDrop }));
+
+	let isOver = $derived(sidebarDragStore.targetSectionId === sectionId);
+	// the section being moved follows the pointer, marked like a section a chat is dragged over
+	let isLifted = $derived(sidebarDragStore.sectionId === sectionId);
+</script>
+
+<div
+	class={[
+		'rounded-lg transition-[background-color,box-shadow] duration-200 ease-out motion-reduce:transition-none',
+		className,
+		(isOver || isLifted) && SIDEBAR_DRAG_HIGHLIGHT_CLASS
+	]}
+	data-sidebar-section={sectionId}
+>
+	{@render heading()}
+
+	{@render children()}
+</div>
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSectionHeading.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSectionHeading.svelte
new file mode 100644
index 000000000..6dcf49a92
--- /dev/null
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSectionHeading.svelte
@@ -0,0 +1,67 @@
+<script lang="ts">
+	import { ChevronDown } from '@lucide/svelte';
+	import { TruncatedText } from '$lib/components/app';
+	import { sidebarDragStore } from '$lib/stores';
+	import type { Component, Snippet } from 'svelte';
+
+	interface Props {
+		class?: string;
+		/** Section id the heading drags its section by. */
+		dragId: string;
+		/** Keeps the heading lit, e.g. while its menu is open. */
+		highlighted?: boolean;
+		icon?: Component;
+		label: string;
+		onToggle: () => void;
+		open: boolean;
+		/** Sits at the far end, e.g. a "more actions" menu. */
+		trailing?: Snippet;
+	}
+
+	let {
+		class: className = '',
+		dragId,
+		highlighted = false,
+		icon: Icon,
+		label,
+		onToggle,
+		open,
+		trailing
+	}: Props = $props();
+</script>
+
+<!-- the whole row folds the section, all but its trailing menu; the chevron
+     follows the title, not the far end, as it folds what the title names. It
+     shows on hover, and stays while the section is folded so a folded
+     section still reads as one -->
+<!-- dragging the title moves the whole section among the others -->
+<div
+	class="group/heading flex h-8 min-w-0 shrink-0 items-center gap-0.5 pr-1 text-[13px] font-semibold text-foreground {highlighted
+		? 'text-foreground'
+		: ''} {className}"
+>
+	<button
+		aria-expanded={open}
+		class="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-md pl-2 text-left transition-colors hover:text-foreground"
+		onclick={onToggle}
+		onpointerdown={(event) => sidebarDragStore.pressSection(event, dragId)}
+		type="button"
+	>
+		{#if Icon}
+			<Icon class="h-3.5 w-3.5 shrink-0" />
+		{/if}
+
+		<TruncatedText showTooltip={false} text={label} />
+
+		<ChevronDown
+			aria-hidden="true"
+			class="h-3.5 w-3.5 shrink-0 transition-[opacity,rotate] duration-200 ease-out group-hover/heading:opacity-100 [button:focus-visible_&]:opacity-100 {open
+				? 'opacity-0'
+				: '-rotate-90 opacity-100'}"
+		/>
+	</button>
+
+	{#if trailing}
+		<div class="shrink-0">{@render trailing()}</div>
+	{/if}
+</div>
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationShowMore.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationShowMore.svelte
new file mode 100644
index 000000000..1fbe5dbd9
--- /dev/null
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationShowMore.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+	interface Props {
+		expanded: boolean;
+		onToggle: () => void;
+	}
+
+	let { expanded, onToggle }: Props = $props();
+</script>
+
+<!-- sits after the section's last row, sized like one but lighter than the
+     section's heading: "Show more" opens the
+     whole section, "Show less" closes it back to its first rows -->
+<button
+	class="flex min-h-9 w-full cursor-pointer items-center rounded-lg px-2 py-1.5 text-left text-sm font-normal text-muted-foreground md:min-h-8 md:py-1 transition-colors hover:bg-foreground/10 hover:text-foreground"
+	onclick={onToggle}
+	type="button"
+>
+	{expanded ? 'Show less' : 'Show more'}
+</button>
diff --git a/tools/ui/src/lib/constants/storage.constants.ts b/tools/ui/src/lib/constants/storage.constants.ts
index 8b3fcd8da..463f0a68b 100644
--- a/tools/ui/src/lib/constants/storage.constants.ts
+++ b/tools/ui/src/lib/constants/storage.constants.ts
@@ -35,6 +35,10 @@ export const RECENT_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.recentModels`
 /** Model ids kept out of the selector, backend-qualified. */
 export const HIDDEN_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.hiddenModels`;
 export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
+/** Sidebar conversation sections the user folded. */
+/** Order the user gave the sidebar's sections by dragging their headings. */
+export const SIDEBAR_SECTION_ORDER_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.sidebarSectionOrder`;
+export const SIDEBAR_COLLAPSED_SECTIONS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.sidebarCollapsedSections`;
 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`;
diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts
index bfe1fd9f0..49e9f8501 100644
--- a/tools/ui/src/lib/constants/ui.constants.ts
+++ b/tools/ui/src/lib/constants/ui.constants.ts
@@ -24,6 +24,31 @@ export const COLLAPSIBLE_TRANSITION_MS = 200;
 export const NATIVE_CONTEXT_MENU_SELECTOR =
 	'a, img, video, audio, pre, code, input, textarea, [contenteditable="true"]';

+/**
+ * A sidebar section under a dragged chat, or one being moved: the faint tint
+ * of the sidebar's resize handle, edged like the handle while it is dragged.
+ */
+export const SIDEBAR_DRAG_HIGHLIGHT_CLASS = 'bg-foreground/6 ring-1 ring-foreground/15 ring-inset';
+
+/** How long a sidebar chat row takes to slide out of a section, or into one. */
+export const SIDEBAR_ROW_SLIDE_MS = 200;
+
+/** Chats a sidebar section shows before its "Show more" row. */
+export const SIDEBAR_SECTION_PREVIEW_COUNT = 6;
+
+/**
+ * The "Show less" row of an opened section stays at the bottom of the sidebar
+ * while the section scrolls under it, with room below it and a fade above
+ * (see app.css). "Show more" is a plain row.
+ */
+export const SIDEBAR_SHOW_MORE_ROW_CLASS = 'sidebar-show-more sticky bottom-0 z-10 pb-2';
+
+/** Ids of the sidebar's conversation sections, for their folded state. */
+export const SIDEBAR_SECTION_IDS = {
+	PINNED: 'pinned',
+	RECENT: 'recent'
+} as const;
+
 export const FORK_TREE_DEPTH_PADDING = 8;
 export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';

diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts
index 439b5f3ad..90037a8fc 100644
--- a/tools/ui/src/lib/enums/index.ts
+++ b/tools/ui/src/lib/enums/index.ts
@@ -103,6 +103,7 @@ export {
 	MenuKind,
 	ScrollCarouselVariant,
 	SidebarAction,
+	SidebarSectionKind,
 	TooltipSide,
 	UrlProtocol
 } from './ui.enums';
diff --git a/tools/ui/src/lib/enums/ui.enums.ts b/tools/ui/src/lib/enums/ui.enums.ts
index 2f57cfc4c..5f4e5d06a 100644
--- a/tools/ui/src/lib/enums/ui.enums.ts
+++ b/tools/ui/src/lib/enums/ui.enums.ts
@@ -30,6 +30,12 @@ export enum SidebarAction {
 	SETTINGS = 'settings'
 }

+/** Kind of a sidebar conversation section, which can be dragged into another order. */
+export enum SidebarSectionKind {
+	PINNED = 'pinned',
+	RECENT = 'recent'
+}
+
 /**
  * MCP prompt display variant
  */
diff --git a/tools/ui/src/lib/stores/index.ts b/tools/ui/src/lib/stores/index.ts
index fcab8a806..f1798f1cb 100644
--- a/tools/ui/src/lib/stores/index.ts
+++ b/tools/ui/src/lib/stores/index.ts
@@ -32,6 +32,7 @@ export { contextStatsStore } from './chat/context-stats.svelte';
 export { agenticStore } from './agentic/index.svelte';

 // CONVERSATIONS
+export { sidebarDragStore } from './sidebar-drag.svelte';
 export { conversationsStore } from './conversations/index.svelte';

 // MCP
diff --git a/tools/ui/src/lib/stores/sidebar-drag.svelte.ts b/tools/ui/src/lib/stores/sidebar-drag.svelte.ts
new file mode 100644
index 000000000..69ca76a8b
--- /dev/null
+++ b/tools/ui/src/lib/stores/sidebar-drag.svelte.ts
@@ -0,0 +1,404 @@
+/**
+ * SidebarDrag - Pointer-driven drag and drop in the sidebar: a whole section
+ * by its heading, or a conversation row into another section.
+ *
+ * The browser's own drag and drop only moves a ghost picture, and the element
+ * jumps from where it started once dropped. Here the real section follows the
+ * pointer while the others slide around it, and a dragged chat is a lifted
+ * copy of its row; either glides from where it was let go into its new place.
+ */
+
+import { uiStore } from '$lib/stores/ui.svelte';
+import { tick } from 'svelte';
+
+/** A section that takes dropped chats, registered by its drop zone. */
+export interface SidebarDropZone {
+	/** Whether the chat is in the section already; dropping it there would change nothing. */
+	holds: (convId: string) => boolean;
+	onDrop: (convId: string) => Promise<void> | void;
+}
+
+/** Pointer travel before a press becomes a drag, so a click stays a click. */
+const DRAG_THRESHOLD_PX = 4;
+/** How long a dropped element takes to glide into its place. */
+const SETTLE_MS = 200;
+/** Distance from the sidebar's top or bottom edge where dragging scrolls it. */
+const AUTO_SCROLL_EDGE_PX = 48;
+/** Fastest auto-scroll, in pixels per frame, right at the edge. */
+const AUTO_SCROLL_MAX_SPEED = 14;
+const SETTLE_EASING = 'cubic-bezier(0.23, 1, 0.32, 1)';
+const SECTION_SLOT_ATTR = 'data-sidebar-slot';
+const SECTION_ATTR = 'data-sidebar-section';
+const CONVERSATION_ROW_ATTR = 'data-conversation-row';
+
+interface Gesture {
+	kind: 'section' | 'conversation';
+	id: string;
+	/** The section slot, or the conversation row. */
+	element: HTMLElement;
+	scroller: HTMLElement | null;
+	startX: number;
+	startY: number;
+	startScroll: number;
+	startOffsetTop: number;
+	startRect: DOMRect;
+	x: number;
+	y: number;
+	active: boolean;
+	overlay: HTMLElement | null;
+}
+
+function findScroller(element: HTMLElement): HTMLElement | null {
+	for (let node = element.parentElement; node; node = node.parentElement) {
+		const { overflowY } = getComputedStyle(node);
+
+		if (overflowY === 'auto' || overflowY === 'scroll') return node;
+	}
+
+	return null;
+}
+
+class SidebarDragStore {
+	/** Chat being dragged, out of its section while a copy of its row follows the pointer. */
+	convId = $state<string | null>(null);
+	/** Chat whose row is hidden while its copy glides into it. */
+	landingConvId = $state<string | null>(null);
+	/** Section being dragged, or gliding into its slot after the drop. */
+	sectionId = $state<string | null>(null);
+	/** Vertical offset of the dragged section from its slot. */
+	sectionOffset = $state(0);
+	/** Sections in the order they take while one is dragged among them. */
+	sectionOrder = $state<string[] | null>(null);
+	/** Whether the dragged section is gliding into its slot. */
+	settling = $state(false);
+	/** Section the dragged chat would drop into. */
+	targetSectionId = $state<string | null>(null);
+
+	#autoScroll = () => {
+		const gesture = this.#gesture;
+
+		if (!gesture?.active) return;
+
+		const scroller = gesture.scroller;
+
+		if (scroller) {
+			const rect = scroller.getBoundingClientRect();
+			const fromTop = gesture.y - rect.top;
+			const fromBottom = rect.bottom - gesture.y;
+
+			let speed = 0;
+
+			if (fromTop < AUTO_SCROLL_EDGE_PX) {
+				speed = -AUTO_SCROLL_MAX_SPEED * (1 - Math.max(fromTop, 0) / AUTO_SCROLL_EDGE_PX);
+			} else if (fromBottom < AUTO_SCROLL_EDGE_PX) {
+				speed = AUTO_SCROLL_MAX_SPEED * (1 - Math.max(fromBottom, 0) / AUTO_SCROLL_EDGE_PX);
+			}
+
+			if (speed !== 0) {
+				scroller.scrollTop += speed;
+				this.#update();
+			}
+		}
+
+		this.#frame = requestAnimationFrame(this.#autoScroll);
+	};
+	#frame = 0;
+	#gesture: Gesture | null = null;
+
+	#handleCancel = () => {
+		void this.#finish(false);
+	};
+
+	#handleKeydown = (event: KeyboardEvent) => {
+		if (event.key !== 'Escape' || !this.#gesture?.active) return;
+
+		event.preventDefault();
+		void this.#finish(false);
+	};
+
+	#handleMove = (event: PointerEvent) => {
+		const gesture = this.#gesture;
+
+		if (!gesture) return;
+
+		gesture.x = event.clientX;
+		gesture.y = event.clientY;
+
+		if (!gesture.active) {
+			if (
+				Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) <
+				DRAG_THRESHOLD_PX
+			) {
+				return;
+			}
+
+			this.#activate(gesture);
+		}
+
+		this.#update();
+	};
+
+	#handleUp = () => {
+		void this.#finish(true);
+	};
+
+	#zones: Record<string, SidebarDropZone> = {};
+
+	/** Starts watching a press on a conversation row; it becomes a drag once the pointer travels. */
+	pressConversation(event: PointerEvent, convId: string): void {
+		const row = (event.currentTarget as HTMLElement).closest<HTMLElement>(
+			`[${CONVERSATION_ROW_ATTR}]`
+		);
+
+		if (row) this.#press(event, 'conversation', convId, row);
+	}
+
+	/** Starts watching a press on a section heading; it becomes a drag once the pointer travels. */
+	pressSection(event: PointerEvent, sectionId: string): void {
+		const slot = (event.currentTarget as HTMLElement).closest<HTMLElement>(
+			`[${SECTION_SLOT_ATTR}]`
+		);
+
+		if (slot) this.#press(event, 'section', sectionId, slot);
+	}
+
+	registerZone(sectionId: string, zone: SidebarDropZone): () => void {
+		this.#zones[sectionId] = zone;
+
+		return () => {
+			if (this.#zones[sectionId] === zone) delete this.#zones[sectionId];
+		};
+	}
+
+	#activate(gesture: Gesture): void {
+		gesture.active = true;
+		window.getSelection()?.removeAllRanges();
+		document.body.style.userSelect = 'none';
+		document.body.style.cursor = 'grabbing';
+		// rows and headings under the pointer stop reacting to it (see app.css)
+		document.body.dataset.sidebarDragging = '';
+
+		if (gesture.kind === 'section') {
+			this.sectionId = gesture.id;
+			this.sectionOffset = 0;
+			this.sectionOrder = this.#slotIds(gesture);
+		} else {
+			// the copy first: the row leaves its section as soon as the chat is dragged
+			gesture.overlay = this.#createOverlay(gesture.element, gesture.startRect);
+			this.convId = gesture.id;
+		}
+
+		this.#frame = requestAnimationFrame(this.#autoScroll);
+	}
+
+	/** A lifted copy of the row, above everything, that follows the pointer. */
+	#createOverlay(row: HTMLElement, rect: DOMRect): HTMLElement {
+		const overlay = document.createElement('div');
+
+		overlay.className = 'sidebar-drag-overlay';
+		Object.assign(overlay.style, {
+			height: `${rect.height}px`,
+			left: `${rect.left}px`,
+			top: `${rect.top}px`,
+			width: `${rect.width}px`
+		});
+		overlay.append(row.cloneNode(true));
+		document.body.append(overlay);
+
+		return overlay;
+	}
+
+	async #finish(drop: boolean): Promise<void> {
+		const gesture = this.#gesture;
+
+		this.#gesture = null;
+		cancelAnimationFrame(this.#frame);
+		window.removeEventListener('pointermove', this.#handleMove);
+		window.removeEventListener('pointerup', this.#handleUp);
+		window.removeEventListener('pointercancel', this.#handleCancel);
+		window.removeEventListener('keydown', this.#handleKeydown);
+
+		if (!gesture?.active) return;
+
+		document.body.style.userSelect = '';
+		document.body.style.cursor = '';
+		delete document.body.dataset.sidebarDragging;
+		this.#swallowNextClick();
+
+		if (gesture.kind === 'section') {
+			await this.#settleSection(drop);
+		} else {
+			await this.#settleConversation(gesture, drop);
+		}
+	}
+
+	#press(event: PointerEvent, kind: Gesture['kind'], id: string, element: HTMLElement): void {
+		// a mouse or a pen: a finger keeps scrolling the sidebar
+		if (event.button !== 0 || event.pointerType === 'touch' || this.#gesture) return;
+
+		const scroller = findScroller(element);
+
+		this.#gesture = {
+			active: false,
+			element,
+			id,
+			kind,
+			overlay: null,
+			scroller,
+			startOffsetTop: element.offsetTop,
+			startRect: element.getBoundingClientRect(),
+			startScroll: scroller?.scrollTop ?? 0,
+			startX: event.clientX,
+			startY: event.clientY,
+			x: event.clientX,
+			y: event.clientY
+		};
+
+		window.addEventListener('pointermove', this.#handleMove);
+		window.addEventListener('pointerup', this.#handleUp);
+		window.addEventListener('pointercancel', this.#handleCancel);
+		window.addEventListener('keydown', this.#handleKeydown);
+	}
+
+	#scrollDelta(gesture: Gesture): number {
+		return (gesture.scroller?.scrollTop ?? 0) - gesture.startScroll;
+	}
+
+	async #settleConversation(gesture: Gesture, drop: boolean): Promise<void> {
+		const overlay = gesture.overlay;
+		const targetId = this.targetSectionId;
+		const zone = drop && targetId ? this.#zones[targetId] : undefined;
+
+		this.targetSectionId = null;
+
+		if (zone) await zone.onDrop(gesture.id);
+
+		// the row slides into its new section, or back into its own, hidden
+		// until the copy has glided into it
+		this.landingConvId = gesture.id;
+		this.convId = null;
+		await tick();
+
+		const row = document.querySelector<HTMLElement>(
+			`[${CONVERSATION_ROW_ATTR}="${CSS.escape(gesture.id)}"]`
+		);
+		// a folded section, or one past its preview, shows no row to land in
+		const destination = row?.getBoundingClientRect();
+
+		if (overlay) {
+			overlay.style.transition = `transform ${SETTLE_MS}ms ${SETTLE_EASING}, opacity ${SETTLE_MS}ms ease-out`;
+
+			if (destination) {
+				overlay.style.transform = `translate(${destination.left - gesture.startRect.left}px, ${
+					destination.top - gesture.startRect.top
+				}px)`;
+			} else {
+				overlay.style.opacity = '0';
+			}
+
+			await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
+			overlay.remove();
+		}
+
+		this.landingConvId = null;
+	}
+
+	async #settleSection(drop: boolean): Promise<void> {
+		const order = this.sectionOrder;
+
+		if (drop && order) {
+			uiStore.setSidebarSectionOrder(order);
+		} else {
+			this.sectionOrder = null;
+			await tick();
+		}
+
+		// the section glides from where it was let go into its slot
+		this.settling = true;
+		await tick();
+		this.sectionOffset = 0;
+		await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
+
+		this.sectionId = null;
+		this.sectionOrder = null;
+		this.settling = false;
+	}
+
+	#slotIds(gesture: Gesture): string[] {
+		const list = gesture.element.parentElement;
+
+		return list
+			? [...list.querySelectorAll<HTMLElement>(`:scope > [${SECTION_SLOT_ATTR}]`)].map(
+					(slot) => slot.getAttribute(SECTION_SLOT_ATTR)!
+				)
+			: [];
+	}
+
+	/** The press that ended the drag must not also click what it was let go over. */
+	#swallowNextClick(): void {
+		const swallow = (event: MouseEvent) => {
+			event.preventDefault();
+			event.stopPropagation();
+		};
+
+		window.addEventListener('click', swallow, { capture: true, once: true });
+		// the click, if any, comes right after the release
+		setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0);
+	}
+
+	#update(): void {
+		const gesture = this.#gesture;
+
+		if (!gesture?.active) return;
+
+		if (gesture.kind === 'section') {
+			void this.#updateSection(gesture);
+		} else {
+			this.#updateConversation(gesture);
+		}
+	}
+
+	#updateConversation(gesture: Gesture): void {
+		if (gesture.overlay) {
+			gesture.overlay.style.transform = `translate(${gesture.x - gesture.startX}px, ${
+				gesture.y - gesture.startY
+			}px)`;
+		}
+
+		const section = document
+			.elementFromPoint(gesture.x, gesture.y)
+			?.closest<HTMLElement>(`[${SECTION_ATTR}]`);
+		const sectionId = section?.getAttribute(SECTION_ATTR) ?? null;
+		const zone = sectionId ? this.#zones[sectionId] : undefined;
+
+		this.targetSectionId = zone && !zone.holds(gesture.id) ? sectionId : null;
+	}
+
+	async #updateSection(gesture: Gesture): Promise<void> {
+		const travel = gesture.y - gesture.startY + this.#scrollDelta(gesture);
+		const height = gesture.element.offsetHeight;
+		const center = gesture.startOffsetTop + travel + height / 2;
+		const list = gesture.element.parentElement;
+
+		if (list && this.sectionOrder) {
+			// the dragged section goes below every other section whose middle it passed
+			const others = [...list.querySelectorAll<HTMLElement>(`:scope > [${SECTION_SLOT_ATTR}]`)]
+				.filter((slot) => slot !== gesture.element)
+				.sort((a, b) => a.offsetTop - b.offsetTop);
+			const index = others.filter((slot) => slot.offsetTop + slot.offsetHeight / 2 < center).length;
+			const next = others.map((slot) => slot.getAttribute(SECTION_SLOT_ATTR)!);
+
+			next.splice(index, 0, gesture.id);
+
+			if (next.join() !== this.sectionOrder.join()) {
+				this.sectionOrder = next;
+				await tick();
+			}
+		}
+
+		// the slot may have moved under the section: keep the section under the pointer
+		this.sectionOffset = gesture.startOffsetTop + travel - gesture.element.offsetTop;
+	}
+}
+
+export const sidebarDragStore = new SidebarDragStore();
diff --git a/tools/ui/src/lib/stores/ui.svelte.ts b/tools/ui/src/lib/stores/ui.svelte.ts
index fb27e482f..269a06aed 100644
--- a/tools/ui/src/lib/stores/ui.svelte.ts
+++ b/tools/ui/src/lib/stores/ui.svelte.ts
@@ -13,33 +13,72 @@ import {
 	MODELS_ROUTE_ID,
 	ROUTES,
 	SETTINGS_KEYS,
+	SIDEBAR_COLLAPSED_SECTIONS_LOCALSTORAGE_KEY,
 	SIDEBAR_EXPANDED_LOCALSTORAGE_KEY,
 	SIDEBAR_MAX_WIDTH,
 	SIDEBAR_MIN_WIDTH,
+	SIDEBAR_SECTION_ORDER_LOCALSTORAGE_KEY,
 	SIDEBAR_WIDTH_LOCALSTORAGE_KEY
 } from '$lib/constants';
 import { deviceStore } from '$lib/stores/device.svelte';
 import { settingsStore } from '$lib/stores/settings/index.svelte';
 import { remToPx } from '$lib/utils/css';
+import { SvelteSet } from 'svelte/reactivity';
+
+/** Sidebar sections the user folded, read once on load. */
+function loadCollapsedSidebarSections(): SvelteSet<string> {
+	if (!browser) return new SvelteSet<string>();
+
+	try {
+		const raw = localStorage.getItem(SIDEBAR_COLLAPSED_SECTIONS_LOCALSTORAGE_KEY);
+		const parsed = raw ? (JSON.parse(raw) as unknown) : null;
+
+		return new SvelteSet(
+			Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
+		);
+	} catch {
+		return new SvelteSet<string>();
+	}
+}
+
+/** Section order the user dragged into place, read once on load. */
+function loadSidebarSectionOrder(): string[] {
+	if (!browser) return [];
+
+	try {
+		const parsed = JSON.parse(localStorage.getItem(SIDEBAR_SECTION_ORDER_LOCALSTORAGE_KEY) ?? '[]');
+
+		return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
+	} catch {
+		return [];
+	}
+}

 class UiStore {
+	/** Sidebar conversation sections the user folded, kept across reloads. */
+	collapsedSidebarSections = loadCollapsedSidebarSections();
 	/** Set when a flow outside the chat wants the composer focused once it can take it. */
 	composerFocusRequested = $state(false);

 	/** Repo the Discover dialog selects when it opens, set by a model row. */
 	discoverModelFocus = $state<string | null>(null);
+
 	/** Open state of the Discover dialog. */
 	discoverModelsOpen = $state(false);
-
+	/** Sidebar sections showing all their chats past the preview, for this session. */
+	expandedSidebarSections = new SvelteSet<string>();
 	/** Set while the user drags the sidebar edge, so width transitions can step aside. */
 	isSidebarResizing = $state(false);

 	/** Model the details drawer shows, a qualified id or a raw model name. */
 	modelDetailsFocus = $state<string | null>(null);
+
 	/** Route the models page returns to: the last page visited before it. */
 	modelsReturnRoute: string = ROUTES.START;
 	/** Bumped to close every open models selector; a counter, so no selector has to reset it. */
 	modelsSelectorCloseSignal = $state(0);
+	/** Section ids in the order the user dragged them into, kept across reloads. */
+	sidebarSectionOrder = $state<string[]>(loadSidebarSectionOrder());
 	/** Expanded desktop sidebar width in px; null keeps the stylesheet default. */
 	sidebarWidth = $state<number | null>(this.loadSidebarWidth());

@@ -91,6 +130,14 @@ class UiStore {
 		this.modelsSelectorCloseSignal++;
 	}

+	isSidebarSectionExpanded(id: string): boolean {
+		return this.expandedSidebarSections.has(id);
+	}
+
+	isSidebarSectionOpen(id: string): boolean {
+		return !this.collapsedSidebarSections.has(id);
+	}
+
 	/** Open the model details drawer over the current page, without leaving it. */
 	openModelDetails(focus: string): void {
 		this.modelDetailsFocus = focus;
@@ -111,18 +158,68 @@ class UiStore {
 		void goto(ROUTES.MODELS);
 	}

+	/**
+	 * Puts sections in the order the user gave them. One never moved, such as a
+	 * section that was empty until now, keeps its default place: right before the next section in
+	 * the default order that was.
+	 */
+	orderSidebarSections<T extends { id: string }>(sections: T[]): T[] {
+		const keys: number[] = [];
+
+		// past every known rank, so trailing unplaced sections stay last
+		let following = this.sidebarSectionOrder.length;
+
+		for (let index = sections.length - 1; index >= 0; index--) {
+			const known = this.sidebarSectionOrder.indexOf(sections[index].id);
+
+			if (known === -1) {
+				keys[index] = following - 0.5;
+			} else {
+				following = known;
+				keys[index] = known;
+			}
+		}
+
+		// a stable sort keeps unplaced neighbours in their default order
+		return sections
+			.map((section, index) => ({ key: keys[index], section }))
+			.sort((a, b) => a.key - b.key)
+			.map(({ section }) => section);
+	}
+
 	/** Remember the current route as the models page's way back, unless it is that page. */
 	rememberModelsReturnRoute(): void {
 		if (this.isModelsPageOpen) return;

 		this.modelsReturnRoute = page.url.hash || ROUTES.START;
 	}
-
 	/** Ask the composer to take focus, e.g. after a dialog closes onto the chat. */
 	requestComposerFocus(): void {
 		this.composerFocusRequested = true;
 	}

+	/**
+	 * Remembers the order the user dragged the sections into.
+	 *
+	 * @param ids - The sections as the sidebar shows them now
+	 */
+	setSidebarSectionOrder(ids: string[]): void {
+		// sections not shown right now (an empty Pinned) keep their place
+		this.sidebarSectionOrder = [
+			...ids,
+			...this.sidebarSectionOrder.filter((id) => !ids.includes(id))
+		];
+
+		try {
+			localStorage.setItem(
+				SIDEBAR_SECTION_ORDER_LOCALSTORAGE_KEY,
+				JSON.stringify(this.sidebarSectionOrder)
+			);
+		} catch {
+			// the order still applies for this session
+		}
+	}
+
 	/** Set the expanded sidebar width in px, or null to go back to the default. */
 	setSidebarWidth(width: number | null): void {
 		this.sidebarWidth = width;
@@ -136,6 +233,33 @@ class UiStore {
 		}
 	}

+	/** Fold or unfold a sidebar conversation section and remember it. */
+	toggleSidebarSection(id: string): void {
+		if (this.collapsedSidebarSections.has(id)) {
+			this.collapsedSidebarSections.delete(id);
+		} else {
+			this.collapsedSidebarSections.add(id);
+		}
+
+		try {
+			localStorage.setItem(
+				SIDEBAR_COLLAPSED_SECTIONS_LOCALSTORAGE_KEY,
+				JSON.stringify([...this.collapsedSidebarSections])
+			);
+		} catch {
+			// the fold still applies for this session
+		}
+	}
+
+	/** Show all of a sidebar section's chats, or only its first ones again. */
+	toggleSidebarSectionExpanded(id: string): void {
+		if (this.expandedSidebarSections.has(id)) {
+			this.expandedSidebarSections.delete(id);
+		} else {
+			this.expandedSidebarSections.add(id);
+		}
+	}
+
 	private loadSidebarExpanded(): boolean {
 		if (!browser || deviceStore.isMobile) return false;