Commit 7592b61d3 for llama.cpp

commit 7592b61d3b53ca5b39a25a03508fa1246b78c579
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date:   Tue Nov 10 18:58:57 2026 +0100

    ui: move Models, MCP and Settings to a sidebar footer (#32022)

    * ui : move Models, MCP and Settings to a sidebar footer

    The sidebar's actions split in two: New chat, Search and Projects stay at
    its top, and Models, MCP and Settings move to a footer at its bottom, under
    a thin rule, in the expanded sidebar and its collapsed strip alike. On a
    phone the footer keeps clear of the home indicator, and Models stays
    desktop-only as before. The search dialog still lists every action.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui : group the sidebar footer in an inset surface

    The design system gains inset-group-surface, for rows grouped inside a glass
    panel: white on the light glass and a faint tint of the text colour on the
    dark one, with the sidebar's own ambient edge, no inner highlight, a faint
    wide shade under it and the card radius. It is matte-glass pointed at its
    own component tokens, like the sidebar and chat form surfaces, and reads as
    part of the panel: no tight contact shadow or solid border like a button,
    no inset shade like a field.

    The sidebar footer uses it in place of the rule above Models, MCP and
    Settings. DESIGN.md and the Surfaces page document it.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui : give the sidebar's logo button the add button's key look

    Whenever the sidebar shows, open or peeking, its logo button takes the same
    key as the chat form's add button (matte-key, white in light mode, a light
    tint in dark), in place of its own muted fill. While the sidebar is hidden
    the logo has no fill and no shadow, and hover and focus never change the
    fill. It keeps its round shape and its own size.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui : put search in a round key at the top of the phone sidebar

    On a phone the sidebar's Search row moves to a round key at the right end
    of its top row, across from the logo, with the same key look as the logo
    and the chat form's add button. It opens search as the row did. Desktop
    keeps the Search row.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui : size the phone sidebar for a thumb and list its footer with the actions

    On a phone the sidebar's actions and rows are 48px tall at 17px, flush one under
    the other with 16px corners, its section headings 15px and grey, and its groups
    28px apart. MCP and Settings join the top actions there: the footer is a desktop
    pattern.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui : mark the selected sidebar row with a tinted inset surface

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: keep the phone sidebar's round keys white on the grey page

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui : open Settings and MCP as pages on a phone, dialogs elsewhere

    On a phone the sidebar opens them at #/settings and #/mcp-servers, with the menu key and a
    title like any phone page. Pinned under the status bar, Settings' section tabs fill the
    screen above them in the page's colour.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui : open a phone sidebar's pages behind it, settled before they slide back

    A tap in the phone sidebar now opens its page while the current one is still aside and slides
    back once it has rendered, instead of swapping pages mid-slide. The slid page's fixed bars are
    pinned again as the page under them changes, the lock ends with the slide itself, and a
    phone's new chat shows its form and greeting at once, without the move and entry they have
    on a desktop.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: style the sidebar footer group and the selected row with master's classes until the design system lands

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: add GlassKey with master's styles for the phone sidebar's round keys

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: center the top edge of a new chat's form on the screen

    The form rested 42px below the middle up to 2xl and 6px above it past
    that; one drop, from the resting card's height, now puts its top edge on
    the middle at every width from md up.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: round the sidebar's action rows concentric with the footer group

    The sidebar stacks four surfaces: the page, the sidebar, the footer group
    inset in it and a row in the group. app.css now names the group's radius
    and padding, and derives the row radius from them (16px less 4px), so the
    footer's rows run parallel to its corners; the actions at the top of the
    sidebar share that radius. The footer moves to its own component, the
    README documents the pattern, and a client test holds the rule on the
    rendered footer.

    A selected action row was bg-accent, the same grey as a hovered row in
    light and darker than the footer's fill in dark, so it read as hovered or
    not at all; it is now raised and edged, while a hover only tints.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: import demo chats through the Settings page on a phone

    A phone opens Settings as a page now, not a dialog, so the demo harness
    waited for a dialog that never came and every phone demo with traces
    failed; it now imports through whichever of the two opens.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: count the sidebar's rows from their items in the surface test

    The test expected three footer rows and two top actions, which the
    stacked PRs change as they add Projects, Library and Servers; it now
    expects as many rows as the sidebar has items.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: add a line on Meta+Enter off macOS too

    The chat form takes Meta+Enter as Ctrl+Enter and leaves the newline to
    the editor, but the editor bound only Mod-Enter, which is Ctrl off
    macOS, so on Linux Meta+Enter neither sent nor added a line. This failed
    the client test on CI's Linux runners, on master too.

    Assisted-by: Claude (Opus 5.5 Medium)

diff --git a/tools/ui/README.md b/tools/ui/README.md
index 5db75128c..2a8a4d817 100644
--- a/tools/ui/README.md
+++ b/tools/ui/README.md
@@ -684,6 +684,19 @@ llama-server serves the UI with `Cross-Origin-Embedder-Policy: require-corp` and
 - **MCP server icons and web-search result favicons** have no CORS guarantee, so they go through the server's `/cors-proxy` when the user started llama-server with `--ui-mcp-proxy`; without it they fall back to the bundled glyph
 - **Per-row Hub lookups** wait until the row is near the viewport (the `nearViewport` action), so a long model list does not fire one request per row on mount

+### 10. Nested Surfaces
+
+The sidebar stacks four surfaces, outermost first:
+
+| Layer | Example                            | Corners                            |
+| ----- | ---------------------------------- | ---------------------------------- |
+| Page  | the body background                | none                               |
+| Panel | the sidebar                        | `rounded-2xl`                      |
+| Group | the sidebar's footer, inset in it  | `rounded-(--surface-group-radius)` |
+| Row   | Models, MCP or Settings in a group | `rounded-(--surface-row-radius)`   |
+
+A row keeps its corners concentric with its group's: its radius is the group's less the padding between their edges, so the curves run parallel instead of pinching at the corners. `app.css` holds the three tokens (`--surface-group-radius`, `--surface-group-padding` and the derived `--surface-row-radius`); change the group's radius or padding there and the rows follow. A group pads its rows with `p-(--surface-group-padding)`. `SidebarNavigationActions` rounds every row from the token, so the actions at the top of the sidebar (New chat, Search) match the footer's rows; a phone sizes its rows for a thumb instead. A selected row is raised and edged (`bg-background`, `shadow-sm`, `ring-1`), where a hovered one is only tinted, so the two never look alike. The group's hairline border is left out of the sum. `tests/client/nested-surfaces.svelte.test.ts` checks the rule on the rendered footer.
+
 ---

 ## Testing
diff --git a/tools/ui/demo/helpers.ts b/tools/ui/demo/helpers.ts
index 66df4fba7..1c0ad7734 100644
--- a/tools/ui/demo/helpers.ts
+++ b/tools/ui/demo/helpers.ts
@@ -402,13 +402,20 @@ async function importTraces(page: Page, file: string): Promise<void> {

 	await settings.click();

-	const dialog = page.getByRole('dialog');
+	// a dialog on a desktop or tablet, a page of its own on a phone
+	const settingsView = page
+		.getByRole('dialog')
+		.or(page.getByRole('heading', { exact: true, level: 1, name: 'Settings' }));

-	await dialog.getByText('Import/Export', { exact: true }).filter({ visible: true }).click();
+	await settingsView.first().waitFor();
+
+	const scope = (await page.getByRole('dialog').count()) > 0 ? page.getByRole('dialog') : page;
+
+	await scope.getByText('Import/Export', { exact: true }).filter({ visible: true }).click();

 	const chooser = page.waitForEvent('filechooser');

-	await dialog.getByRole('button', { name: 'Import conversations' }).click();
+	await scope.getByRole('button', { name: 'Import conversations' }).click();
 	await (await chooser).setFiles(file);
 	await page.getByRole('button', { name: /^Import \(\d+\)$/ }).click();
 	await page.getByRole('heading', { name: /^Imported \d+ conversation/ }).waitFor();
diff --git a/tools/ui/src/app.css b/tools/ui/src/app.css
index 2a56a0862..7a92c0ba0 100644
--- a/tools/ui/src/app.css
+++ b/tools/ui/src/app.css
@@ -104,8 +104,20 @@
 	--chat-form-area-height: 8rem;
 	--chat-form-area-offset: 2rem;
 	--chat-form-padding-top: 6rem;
+
+	/* Nested surfaces, outermost first: the page, the sidebar on it, a group inset in the
+	   sidebar (its footer) and a row in the group. A row keeps its corners concentric with the
+	   group's: its radius is the group's less the padding between their edges. The sidebar's
+	   actions above the footer share the row radius, so every action row rounds alike. See "Nested
+	   surfaces" in the README; tests/client/nested-surfaces.svelte.test.ts holds the rule */
+	--surface-group-radius: 1rem;
+	--surface-group-padding: 0.25rem;
+	--surface-row-radius: calc(var(--surface-group-radius) - var(--surface-group-padding));
+
 	/* the chat form card holding one line of text, as it rests on a new chat */
 	--chat-form-collapsed-height: 6.375rem;
+	/* the empty chat form card, its text and actions in one row: a new chat centers its top edge */
+	--chat-form-resting-height: 3.375rem;
 	/* the chat form's corners while its text and actions share one row */
 	--chat-form-single-row-radius: 1.25rem;
 	/* the card's translucent fill over the page, made opaque: the phone sheet, and the page and Safari's bars around it */
diff --git a/tools/ui/src/lib/components/app/actions/GlassKey.svelte b/tools/ui/src/lib/components/app/actions/GlassKey.svelte
new file mode 100644
index 000000000..b2ccffe6a
--- /dev/null
+++ b/tools/ui/src/lib/components/app/actions/GlassKey.svelte
@@ -0,0 +1,67 @@
+<script lang="ts">
+	import * as Tooltip from '$lib/components/ui/tooltip';
+	import { deviceStore } from '$lib/stores';
+	import type { Component, Snippet } from 'svelte';
+	import type { HTMLButtonAttributes } from 'svelte/elements';
+
+	interface Props extends Omit<HTMLButtonAttributes, 'children'> {
+		children?: Snippet;
+		icon?: Component;
+		iconClass?: string;
+		/** What it does, read out in place of a visible label. */
+		label: string;
+		/** md (40px) for a phone's corner keys, sm (36px) for a key floating over the page. */
+		size?: 'md' | 'sm';
+		/** Shows the label as a tooltip where there is a pointer to hover with. */
+		tooltip?: boolean;
+	}
+
+	let {
+		children,
+		class: className = '',
+		icon: Icon,
+		iconClass = 'size-6',
+		label,
+		size = 'md',
+		tooltip = false,
+		...rest
+	}: Props = $props();
+</script>
+
+{#snippet key(props: Record<string, unknown> = {})}
+	<button
+		{...props}
+		{...rest}
+		aria-label={label}
+		class={[
+			'inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/20 bg-muted/60 text-accent-foreground shadow-sm backdrop-blur-sm transition-colors hover:bg-muted',
+			size === 'md' ? 'size-10' : 'size-9',
+			className
+		]}
+		type="button"
+	>
+		{#if Icon}
+			<Icon class={iconClass} />
+		{/if}
+
+		{@render children?.()}
+	</button>
+{/snippet}
+
+<!-- a round key over the page: the menu and context gauge keys at a phone's top corners, and the
+     key back to the bottom of a chat -->
+{#if tooltip && !deviceStore.isMobile}
+	<Tooltip.Root>
+		<Tooltip.Trigger>
+			{#snippet child({ props })}
+				{@render key(props)}
+			{/snippet}
+		</Tooltip.Trigger>
+
+		<Tooltip.Content>
+			<p>{label}</p>
+		</Tooltip.Content>
+	</Tooltip.Root>
+{:else}
+	{@render key()}
+{/if}
diff --git a/tools/ui/src/lib/components/app/actions/index.ts b/tools/ui/src/lib/components/app/actions/index.ts
index 4bb2a58d6..73ab76913 100644
--- a/tools/ui/src/lib/components/app/actions/index.ts
+++ b/tools/ui/src/lib/components/app/actions/index.ts
@@ -11,3 +11,6 @@ export { default as ActionIcon } from './ActionIcon.svelte';

 /** Copy-to-clipboard icon button with clipboard logic. */
 export { default as ActionIconCopyToClipboard } from './ActionIconCopyToClipboard.svelte';
+
+/** A round matte glass key: a phone's corner keys and the key back to a chat's bottom. */
+export { default as GlassKey } from './GlassKey.svelte';
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
index ee07dcdee..1c8ffd067 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
@@ -189,11 +189,19 @@
 	 * where it is, drifting toward where it goes, and fades
 	 * back in from the other side once it is there. Reduced motion keeps only
 	 * the fade. Re-reads `isEmpty` after each move, so a flip back mid-move
-	 * ends where the screen is.
+	 * ends where the screen is. A phone keeps the form at the bottom either way, so there it
+	 * only switches, at once.
 	 */
 	async function moveForm() {
 		if (isFormMoving) return;

+		if (deviceStore.isMobile) {
+			isFormCentered = isEmpty;
+			isFormHeldAtBottom = false;
+
+			return;
+		}
+
 		isFormMoving = true;

 		while (formWrapper && isFormCentered !== isEmpty) {
@@ -339,10 +347,10 @@
 						: deviceStore.isIOSSafari
 							? 'bottom-2 left-2 right-2'
 							: 'bottom-3 right-2 left-2',
-				// Lifted to the center on a new chat, by its sticky bottom offset and not a
-				// translate, which a quick reversal starts a frame ahead of the card's height
-				isFormLifted &&
-					'md:[--chat-form-drop:calc(50dvh-7rem)] 2xl:[--chat-form-drop:calc(50dvh-4rem)]',
+				// Lifted on a new chat until the resting card's top edge is on the screen's middle,
+				// by its sticky bottom offset and not a translate, which a quick reversal starts a
+				// frame ahead of the card's height
+				isFormLifted && 'md:[--chat-form-drop:calc(50dvh-1rem-var(--chat-form-resting-height))]',
 				isFormLifted && !uiStore.isComposerExpanded
 					? 'md:bottom-[calc(var(--chat-form-drop)+1rem)]'
 					: 'md:bottom-4',
@@ -350,7 +358,11 @@
 					'transition-[left,right,bottom] duration-400 ease-[var(--chat-form-resize-ease,cubic-bezier(0.3,1.12,0.5,1))] max-md:transition-none motion-reduce:transition-none'
 			]}
 		>
-			<ChatScreenGreeting faded={uiStore.isComposerExpanded} isEmpty={isFormLifted} />
+			<ChatScreenGreeting
+				faded={uiStore.isComposerExpanded}
+				instant={deviceStore.isMobile}
+				isEmpty={isFormLifted}
+			/>

 			<ChatScreenServerError />

diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte
index 11c43290d..51f4e0e84 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte
@@ -5,10 +5,12 @@
 	interface Props {
 		/** Transparent but still in place, e.g. behind the expanded chat form. */
 		faded?: boolean;
+		/** Shown at once, without the entry: a phone's form never moves, so there is none to wait for. */
+		instant?: boolean;
 		isEmpty: boolean;
 	}

-	let { faded = false, isEmpty = false }: Props = $props();
+	let { faded = false, instant = false, isEmpty = false }: Props = $props();

 	let hasEntered = $state(false);

@@ -31,7 +33,7 @@

 	let enterClass = $derived([
 		'transition-opacity duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]',
-		!hasEntered && 'opacity-0'
+		!hasEntered && !instant && 'opacity-0'
 	]);
 </script>

diff --git a/tools/ui/src/lib/components/app/content/MarkdownEditor/MarkdownEditor.svelte b/tools/ui/src/lib/components/app/content/MarkdownEditor/MarkdownEditor.svelte
index 94f4cc0c5..cf0f36167 100644
--- a/tools/ui/src/lib/components/app/content/MarkdownEditor/MarkdownEditor.svelte
+++ b/tools/ui/src/lib/components/app/content/MarkdownEditor/MarkdownEditor.svelte
@@ -144,9 +144,10 @@
 				extensions: [
 					customCompartment.of(extensions),
 					history(),
-					// Shift+Enter and Ctrl/Cmd+Enter add a line, continuing lists and quotes
+					// Shift+Enter and Ctrl/Cmd+Enter add a line, continuing lists and quotes. Mod is
+					// Ctrl off macOS, where the chat form still takes Meta+Enter as Ctrl+Enter
 					keymap.of(
-						['Shift-Enter', 'Mod-Enter', 'Ctrl-Enter'].map((key) => ({
+						['Shift-Enter', 'Mod-Enter', 'Ctrl-Enter', 'Meta-Enter'].map((key) => ({
 							key,
 							run: (target) => insertNewlineContinueMarkup(target) || insertNewlineAndIndent(target)
 						}))
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogSearch.svelte b/tools/ui/src/lib/components/app/dialogs/DialogSearch.svelte
index 20cfdee1d..185c3c1cb 100644
--- a/tools/ui/src/lib/components/app/dialogs/DialogSearch.svelte
+++ b/tools/ui/src/lib/components/app/dialogs/DialogSearch.svelte
@@ -7,7 +7,8 @@
 		ICON_CLASS_DEFAULT,
 		ICON_CLASS_SM,
 		ICON_CLASS_XS,
-		SIDEBAR_ACTIONS_ITEMS
+		SIDEBAR_ACTIONS_ITEMS,
+		SIDEBAR_FOOTER_ITEMS
 	} from '$lib/constants';
 	import { KeyboardKey, SidebarAction } from '$lib/enums';
 	import { RouterService } from '$lib/services/router.service';
@@ -72,12 +73,13 @@
 	const actionEntries = $derived.by((): SearchEntry[] =>
 		// every sidebar action but Search itself, which is what opened this dialog, and the models
 		// manager on a phone, which picks its models in the selector
-		SIDEBAR_ACTIONS_ITEMS.filter(
-			(item) =>
-				item.action &&
-				item.action !== SidebarAction.SEARCH &&
-				(item.action !== SidebarAction.MANAGE_MODELS || !deviceStore.isMobile)
-		)
+		[...SIDEBAR_ACTIONS_ITEMS, ...SIDEBAR_FOOTER_ITEMS]
+			.filter(
+				(item) =>
+					item.action &&
+					item.action !== SidebarAction.SEARCH &&
+					(item.action !== SidebarAction.MANAGE_MODELS || !deviceStore.isMobile)
+			)
 			.map((item) => ({
 				icon: item.icon,
 				id: `action-${item.action}`,
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 773f40448..e261ab064 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
@@ -1,5 +1,5 @@
 <script lang="ts">
-	import { PanelLeftClose, PanelLeftOpen } from '@lucide/svelte';
+	import { PanelLeftClose, PanelLeftOpen, Search } from '@lucide/svelte';
 	import { goto } from '$app/navigation';
 	import { page } from '$app/state';
 	import {
@@ -15,13 +15,16 @@
 		Logo,
 		ModelsManagerModelDrawer,
 		SidebarNavigationActions,
-		SidebarNavigationConversationList
+		SidebarNavigationConversationList,
+		SidebarNavigationFooter
 	} from '$lib/components/app';
 	import {
 		ROUTES,
+		SIDEBAR_ACTIONS_ITEMS,
 		SIDEBAR_COLLAPSE_WIDTH,
 		SIDEBAR_COLLAPSED_WIDTH,
 		SIDEBAR_CONTENT_FADE_MS,
+		SIDEBAR_FOOTER_ITEMS,
 		SIDEBAR_MAX_WIDTH,
 		SIDEBAR_MIN_WIDTH,
 		SIDEBAR_OVERSTRETCH_PX,
@@ -35,6 +38,7 @@
 	import { RouterService } from '$lib/services/router.service';
 	import { chatStore, conversationsStore, deviceStore, settingsStore, uiStore } from '$lib/stores';
 	import { buildConversationTree, remToPx } from '$lib/utils';
+	import { tick } from 'svelte';
 	import { SvelteSet } from 'svelte/reactivity';
 	import { fade } from 'svelte/transition';

@@ -299,7 +303,7 @@
 	}

 	function selectConversation(id: string) {
-		afterMobileClose(() => void goto(RouterService.chat(id)));
+		navigateBehind(() => goto(RouterService.chat(id)));
 	}

 	async function handleEditConversation(id: string) {
@@ -366,8 +370,25 @@
 	}

 	/**
-	 * A phone slides its open sidebar shut first, and only then follows a tap in it, so the page
-	 * it goes to or the dialog it opens appears once the sidebar is out of the way. Anything else
+	 * A phone opens the page a tap in its sidebar goes to while the current one is still aside,
+	 * and slides the sidebar shut once that page has rendered, so it is ready, settled, as it
+	 * slides back. Anything else navigates at once.
+	 */
+	function navigateBehind(go: () => Promise<unknown>): void {
+		if (!deviceStore.isMobile || !uiStore.isSidebarExpanded) {
+			void go();
+
+			return;
+		}
+
+		void go()
+			.then(tick)
+			.finally(() => requestAnimationFrame(() => (uiStore.isSidebarExpanded = false)));
+	}
+
+	/**
+	 * A phone slides its open sidebar shut first, and only then opens what a tap in it asks for,
+	 * so a dialog or the search screen appears once the sidebar is out of the way. Anything else
 	 * acts at once.
 	 */
 	function afterMobileClose(run: () => void): void {
@@ -504,7 +525,7 @@
 	function handleSidebarAction(action: SidebarAction): void {
 		switch (action) {
 			case SidebarAction.NEW_CHAT:
-				afterMobileClose(() => void conversationsStore.openNewChat({ inNewTab: true }));
+				navigateBehind(() => conversationsStore.openNewChat({ inNewTab: true }));

 				break;
 			case SidebarAction.SEARCH:
@@ -515,12 +536,15 @@
 				openDialog(() => uiStore.openModelsManager());

 				break;
+			// dialogs on a desktop or tablet; on a phone, pages of their own
 			case SidebarAction.MCP:
-				openDialog(() => (mcpDialogOpen = true));
+				if (isOnMobile) navigateBehind(() => goto(ROUTES.MCP_SERVERS));
+				else openDialog(() => (mcpDialogOpen = true));

 				break;
 			case SidebarAction.SETTINGS:
-				openDialog(() => (settingsDialogOpen = true));
+				if (isOnMobile) navigateBehind(() => goto(ROUTES.SETTINGS));
+				else openDialog(() => (settingsDialogOpen = true));

 				break;
 		}
@@ -581,15 +605,17 @@
 					role="button"
 					tabindex="0"
 				>
+					<!-- the chat form's add-button key whenever the sidebar shows, open or peeking;
+					     no fill while it is hidden; hover and focus never change the fill -->
 					<ActionIcon
 						ariaLabel={!uiStore.isSidebarExpanded
 							? 'Expand navigation'
 							: isLogoToggle
 								? 'Collapse navigation'
 								: 'Go to start'}
-						class="{uiStore.isSidebarExpanded
-							? 'bg-muted! md:bg-foreground/5!'
-							: 'bg-transparent!'} md:size-9 size-10 rounded-full md:hover:bg-foreground/10! pointer-events-auto"
+						class="pointer-events-auto size-10 rounded-full! md:size-9 {isStripHidden
+							? 'bg-transparent! hover:bg-transparent!'
+							: 'bg-muted/60 border border-border/20 backdrop-blur-sm bg-background! hover:bg-background! dark:bg-muted-foreground/15! dark:hover:bg-muted-foreground/15!'}"
 						href={uiStore.isSidebarExpanded && !isLogoToggle ? ROUTES.START : undefined}
 						icon={isLogoToggle && logoHovered
 							? uiStore.isSidebarExpanded
@@ -607,26 +633,39 @@
 						tooltipSide={TooltipSide.RIGHT}
 					/>
 				</div>
+
+				<!-- a phone reaches search from a round key across from the logo, in place of the
+				     Search row among the sidebar's actions -->
+				{#if isOnMobile}
+					<ActionIcon
+						ariaLabel="Search"
+						class="bg-muted/60 border border-border/20 backdrop-blur-sm size-10 rounded-full! bg-background! hover:bg-background! dark:bg-muted-foreground/15! dark:hover:bg-muted-foreground/15!"
+						icon={Search}
+						iconSize="size-4.5"
+						onclick={() => handleSidebarAction(SidebarAction.SEARCH)}
+						size="lg"
+					/>
+				{/if}
 			</div>

 			<div
 				in:fade={{ duration: SIDEBAR_CONTENT_FADE_MS }}
 				out:fade={{ duration: SIDEBAR_CONTENT_FADE_MS }}
-				class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-1 md:transition-opacity md:duration-150 md:ease-out {isStripHidden
+				class="mt-2 flex min-h-0 flex-1 flex-col gap-7 max-md:mt-6 md:gap-1 md:transition-opacity md:duration-150 md:ease-out {isStripHidden
 					? 'md:opacity-0 md:invisible'
 					: ''}"
 			>
+				<!-- a phone has no footer: its items join these -->
 				<SidebarNavigationActions
 					class="px-2 md:my-3"
+					items={isOnMobile ? [...SIDEBAR_ACTIONS_ITEMS, ...SIDEBAR_FOOTER_ITEMS] : undefined}
 					onAction={handleSidebarAction}
-					onNavigate={() => {
-						if (deviceStore.isMobile) {
-							uiStore.isSidebarExpanded = false;
-						}
-					}}
+					onNavigate={navigateBehind}
 				/>

-				<div class="flex min-h-0 flex-1 flex-col overflow-y-auto opacity-(--sidebar-fade)">
+				<div
+					class="flex min-h-0 flex-1 flex-col overflow-y-auto opacity-(--sidebar-fade) max-md:pb-safe-2"
+				>
 					<SidebarNavigationConversationList
 						{allSelectedArePinned}
 						allVisibleSelected={visibleSelectionStats.visibleCount > 0 &&
@@ -655,6 +694,12 @@
 						visibleCount={visibleSelectionStats.visibleCount}
 					/>
 				</div>
+
+				<!-- the footer, at the bottom of the sidebar and of its collapsed strip alike (a
+				     phone lists its items with the actions above) -->
+				{#if !isOnMobile}
+					<SidebarNavigationFooter onAction={handleSidebarAction} />
+				{/if}
 			</div>
 		</div>

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 6dccbb088..c031b8893 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte
@@ -20,17 +20,22 @@

 	interface Props {
 		class: string;
+		/** The items to show: the sidebar's top actions unless given, e.g. its footer's. */
+		items?: DesktopIconStripItem[];
 		onAction?: (action: SidebarAction) => void;
-		/** Called before following a route item, so a phone can collapse the sidebar. */
-		onNavigate?: () => void;
+		/** Follows a route item, so a phone can open its page behind the sidebar and then close it. */
+		onNavigate?: (go: () => Promise<unknown>) => void;
 	}

-	let { class: className, onAction, onNavigate }: Props = $props();
+	let { class: className, items = SIDEBAR_ACTIONS_ITEMS, onAction, onNavigate }: Props = $props();

-	// the models manager stays desktop-only: a phone picks its models in the selector
+	// the models manager stays desktop-only: a phone picks its models in the selector; a phone
+	// reaches search from the round key at the top of the sidebar instead
 	const actions = $derived(
-		SIDEBAR_ACTIONS_ITEMS.filter(
-			(item) => item.action !== SidebarAction.MANAGE_MODELS || !deviceStore.isMobile
+		items.filter(
+			(item) =>
+				!deviceStore.isMobile ||
+				(item.action !== SidebarAction.MANAGE_MODELS && item.action !== SidebarAction.SEARCH)
 		)
 	);
 	let initialized = $state(false);
@@ -53,10 +58,7 @@
 		}

 		if (route) {
-			return () => {
-				onNavigate?.();
-				goto(route);
-			};
+			return () => (onNavigate ? onNavigate(() => goto(route)) : void goto(route));
 		}

 		return undefined;
@@ -84,10 +86,11 @@
 </script>

 {#snippet itemIcon(IconComponent: Component)}
-	<IconComponent class={ICON_CLASS_DEFAULT} />
+	<IconComponent class="{ICON_CLASS_DEFAULT} max-md:size-5.5" />
 {/snippet}

-<div class="{className} flex flex-col gap-5 md:gap-1 mt-2 md:mt-0">
+<!-- a phone sizes its rows for a thumb, flush one under the other like a list's -->
+<div class="{className} flex flex-col md:gap-1">
 	{#each actions as item, i (item.tooltip)}
 		{@const isActive = isItemActive(item)}
 		{@const itemOnClick = itemClickHandler(item)}
@@ -100,8 +103,10 @@
 		{#if showIcons}
 			<div transition:fade={itemTransition}>
 				<Button
-					class="w-full min-w-9 justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100 {isActive
-						? 'bg-accent text-accent-foreground'
+					class="w-full min-w-9 justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100 rounded-(--surface-row-radius) max-md:h-12 max-md:rounded-2xl max-md:px-3 max-md:text-[17px] {isActive
+						? // raised and edged, where a hover only tints: bg-accent matched the hover in light
+							// and sank below an inset group's fill in dark
+							'bg-background text-accent-foreground shadow-sm ring-1 ring-border/50 hover:bg-background dark:bg-foreground/15 dark:hover:bg-foreground/15'
 						: ''}"
 					href={item.route}
 					onclick={() => {
@@ -113,7 +118,7 @@
 					size="default"
 					variant="ghost"
 				>
-					<span class="flex min-w-0 items-center px-0.5 gap-2">
+					<span class="flex min-w-0 items-center gap-2 px-0.5 max-md:gap-3.5">
 						{@render itemIcon(item.icon)}

 						{#if showIcons}
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 8ab731487..a09c423d4 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
@@ -321,9 +321,9 @@
 		bind:this={rowElement}
 		style:--row-hold-ms="{ROW_LONG_PRESS_MS}ms"
 		class={[
-			'group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg px-2 py-1.5 text-left select-none [-webkit-touch-callout:none] transition-[background-color,color,scale] hover:bg-foreground/10 hover:text-foreground md:min-h-8 md:py-1',
+			'group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg px-2 py-1.5 text-left select-none [-webkit-touch-callout:none] transition-[background-color,color,scale] hover:bg-foreground/10 hover:text-foreground max-md:min-h-12 max-md:rounded-2xl max-md:px-3.5 max-md:py-3 md:min-h-8 md:py-1',
 			isLanding && 'invisible',
-			isActive ? 'bg-foreground/5 text-accent-foreground' : 'text-foreground/75',
+			isActive ? 'bg-accent text-accent-foreground' : 'text-foreground/75',
 			contextMenuOpen && 'bg-foreground/10',
 			isSelected && 'bg-primary/10 hover:bg-primary/15',
 			isSelectionMode && 'is-selection-mode',
@@ -422,7 +422,11 @@
 				</Tooltip.Root>
 			{/if}

-			<TruncatedText class="text-sm" showTooltip={false} text={conversation.name} />
+			<TruncatedText
+				class="text-sm max-md:text-[17px]"
+				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 03f8be318..02c5791cc 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationList.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationList.svelte
@@ -194,7 +194,7 @@
      space under the last section, offers the same actions as the Recent heading -->
 <ContextMenuActions bind:open={listMenuOpen} actions={recentActions} disabled={isSelectionMode}>
 	<!-- a phone spaces its sections a little wider than the rows inside them -->
-	<div class="flex flex-1 flex-col gap-6 md:gap-4">
+	<div class="flex flex-1 flex-col gap-7 md:gap-4">
 		{#each sections as section (section.id)}
 			{@const isDragged = section.id === sidebarDragStore.sectionId}

@@ -304,7 +304,7 @@
 		{/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}">
+			<ul class="flex w-full min-w-0 flex-col 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)}
@@ -319,7 +319,7 @@

 {#snippet recentSection()}
 	<SidebarNavigationDropZone
-		class="flex flex-col gap-4 md:gap-0 whitespace-nowrap {className}"
+		class="flex flex-col whitespace-nowrap {className}"
 		holds={(convId) => isUngrouped(convId)}
 		onDrop={handleDropRecent}
 		sectionId={SIDEBAR_SECTION_IDS.RECENT}
@@ -355,7 +355,7 @@
 		{/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">
+			<ul class="flex w-full min-w-0 flex-col 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)}
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationFooter.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationFooter.svelte
new file mode 100644
index 000000000..c642a3e35
--- /dev/null
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationFooter.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+	import SidebarNavigationActions from './SidebarNavigationActions.svelte';
+	import { SIDEBAR_FOOTER_ITEMS } from '$lib/constants';
+	import type { SidebarAction } from '$lib/enums';
+
+	interface Props {
+		onAction?: (action: SidebarAction) => void;
+	}
+
+	let { onAction }: Props = $props();
+</script>
+
+<!-- the footer: managing the app around the chats, a group inset in the sidebar, its rows
+     rounded concentric with it (see the nested surfaces in app.css) -->
+<SidebarNavigationActions
+	class="mx-2 mb-2 rounded-(--surface-group-radius) border border-border/20 bg-background/55 p-(--surface-group-padding) shadow-sm dark:bg-foreground/5"
+	items={SIDEBAR_FOOTER_ITEMS}
+	{onAction}
+/>
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationGroupSection.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationGroupSection.svelte
index 46d066e7b..122a75cea 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationGroupSection.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationGroupSection.svelte
@@ -135,7 +135,7 @@
 	{/snippet}

 	<CollapsibleRegion {open}>
-		<ul class="flex w-full min-w-0 flex-col gap-4 md:gap-0.5 {className}">
+		<ul class="flex w-full min-w-0 flex-col md:gap-0.5 {className}">
 			{#each shownItems as { conversation, depth } (conversation.id)}
 				<li transition:slide={{ duration: SIDEBAR_ROW_SLIDE_MS }} class="group/item relative p-0">
 					{@render conversationRow(conversation, depth)}
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSectionHeading.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSectionHeading.svelte
index 6dcf49a92..70e01b7c4 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSectionHeading.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSectionHeading.svelte
@@ -36,13 +36,13 @@
      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
+	class="group/heading flex h-8 min-w-0 shrink-0 items-center gap-0.5 pr-1 text-[13px] font-semibold text-foreground max-md:h-9 max-md:text-[15px] max-md:font-medium max-md:text-muted-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"
+		class="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-md pl-2 text-left max-md:pl-3.5 transition-colors hover:text-foreground"
 		onclick={onToggle}
 		onpointerdown={(event) => sidebarDragStore.pressSection(event, dragId)}
 		type="button"
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationShowMore.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationShowMore.svelte
index 1fbe5dbd9..473779cf3 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationShowMore.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationShowMore.svelte
@@ -11,7 +11,7 @@
      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"
+	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 max-md:min-h-12 max-md:rounded-2xl max-md:px-3.5 max-md:text-[17px] md:min-h-8 md:py-1 transition-colors hover:bg-foreground/10 hover:text-foreground"
 	onclick={onToggle}
 	type="button"
 >
diff --git a/tools/ui/src/lib/components/app/navigation/index.ts b/tools/ui/src/lib/components/app/navigation/index.ts
index 4978155c3..a4dc758ad 100644
--- a/tools/ui/src/lib/components/app/navigation/index.ts
+++ b/tools/ui/src/lib/components/app/navigation/index.ts
@@ -182,6 +182,9 @@ export { default as SidebarNavigationSelectionBar } from './SidebarNavigation/Si
 export { default as SidebarNavigationConversationList } from './SidebarNavigation/SidebarNavigationConversationList.svelte';
 export { default as SidebarNavigationActions } from './SidebarNavigation/SidebarNavigationActions.svelte';

+/** The sidebar's footer: Models, MCP and Settings in a group inset in the sidebar. */
+export { default as SidebarNavigationFooter } from './SidebarNavigation/SidebarNavigationFooter.svelte';
+
 /**
  * **SidebarNavigationSearchResults** - Filtered conversation list for search.
  *
diff --git a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte
index ed223dad2..6993e7873 100644
--- a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte
+++ b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte
@@ -24,11 +24,13 @@
 	import { fade } from 'svelte/transition';
 	interface Props {
 		initialSection?: string;
+		/** Where the phone's section tabs pin, see SettingsChatMobileHeader */
+		mobileHeaderClass?: string;
 		onSectionChange?: (section: SettingsSectionTitle) => void;
 		onClose?: () => void;
 	}

-	let { initialSection, onClose, onSectionChange }: Props = $props();
+	let { initialSection, mobileHeaderClass, onClose, onSectionChange }: Props = $props();

 	let activeSlug = $derived(initialSection ?? 'general');

@@ -161,6 +163,7 @@

 		<SettingsChatMobileHeader
 			bind:this={mobileHeader}
+			class={mobileHeaderClass}
 			isActive={(section: SettingsSection) => section.slug === activeSlug}
 			onSectionChange={handleSectionChange}
 			sections={SETTINGS_CHAT_SECTIONS}
diff --git a/tools/ui/src/lib/components/app/settings/SettingsChatMobileHeader.svelte b/tools/ui/src/lib/components/app/settings/SettingsChatMobileHeader.svelte
index ab85ef5ee..911f6162c 100644
--- a/tools/ui/src/lib/components/app/settings/SettingsChatMobileHeader.svelte
+++ b/tools/ui/src/lib/components/app/settings/SettingsChatMobileHeader.svelte
@@ -7,15 +7,34 @@
 	import { onMount, tick } from 'svelte';

 	interface Props {
+		/** Where the tabs pin as the page scrolls and what they lie over: under a dialog's
+		 *  header by default. While pinned they carry `data-stuck`, for a page to fill above them */
+		class?: string;
 		sections: SettingsSection[];
 		isActive: (section: SettingsSection) => boolean;
 		onSectionChange?: (section: SettingsSectionTitle) => void;
 	}

-	let { isActive, onSectionChange, sections }: Props = $props();
+	let { class: className = 'top-13 z-50', isActive, onSectionChange, sections }: Props = $props();

 	const carousel = useScrollCarousel();

+	let root = $state<HTMLDivElement>();
+	let stuck = $state(false);
+
+	// pinned once the page has scrolled them up to their sticky offset; a dialog scrolls its
+	// own content, not the window, so there they never report it
+	function updateStuck() {
+		if (!root) return;
+
+		const top = getComputedStyle(root).top;
+
+		stuck =
+			window.scrollY > 0 &&
+			top !== 'auto' &&
+			root.getBoundingClientRect().top <= parseFloat(top) + 0.5;
+	}
+
 	onMount(async () => {
 		await tick();

@@ -35,7 +54,13 @@
 	}
 </script>

-<div class="flex flex-col bg-background md:hidden sticky top-13 z-50">
+<svelte:window onresize={updateStuck} onscroll={updateStuck} />
+
+<div
+	bind:this={root}
+	class={['sticky flex flex-col bg-background md:hidden', className]}
+	data-stuck={stuck || undefined}
+>
 	<div class="border-b border-border/30">
 		<ScrollCarousel alwaysShowArrows {carousel} containerClass="py-2" innerClass="gap-2">
 			{#each sections as section (section.title)}
diff --git a/tools/ui/src/lib/constants/routes.constants.ts b/tools/ui/src/lib/constants/routes.constants.ts
index 9a475a118..a5d4ad35f 100644
--- a/tools/ui/src/lib/constants/routes.constants.ts
+++ b/tools/ui/src/lib/constants/routes.constants.ts
@@ -11,15 +11,23 @@ export const URL_PARAMS = {
 export const ROUTES = {
 	/** Chat base — for dynamic chat URLs use RouterService. */
 	CHAT: '#/chat',
-	/** MCP servers. */
+	/** MCP servers - a page on a phone, a dialog elsewhere. */
 	MCP_SERVERS: '#/mcp-servers',
 	/** Models manager. */
 	MODELS: '#/models',
 	/** Search — mobile-only full-page conversation search. */
 	SEARCH: '#/search',
+	/** Settings - a page on a phone, a dialog elsewhere. */
+	SETTINGS: '#/settings',
 	/** Root — start of the app. */
 	START: '#/'
 } as const;

 /** SvelteKit route id of the models manager, as `page.route.id` reports it. */
 export const MODELS_ROUTE_ID = '/models';
+
+/** SvelteKit route id of the MCP servers page, as `page.route.id` reports it. */
+export const MCP_SERVERS_ROUTE_ID = '/mcp-servers';
+
+/** SvelteKit route id of the settings page, as `page.route.id` reports it. */
+export const SETTINGS_ROUTE_ID = '/settings';
diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts
index 19ed01c9c..d5098f5b9 100644
--- a/tools/ui/src/lib/constants/ui.constants.ts
+++ b/tools/ui/src/lib/constants/ui.constants.ts
@@ -5,7 +5,7 @@ import {
 	SHORTCUT_SEARCH,
 	SHORTCUT_SETTINGS
 } from './keyboard-shortcuts.constants';
-import { MODELS_ROUTE_ID } from './routes.constants';
+import { MCP_SERVERS_ROUTE_ID, MODELS_ROUTE_ID, SETTINGS_ROUTE_ID } from './routes.constants';
 import { Search, Settings, SquarePen } from '@lucide/svelte';
 import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
 import { SidebarAction, ToolSource } from '$lib/enums';
@@ -155,6 +155,7 @@ export const ROW_LONG_PRESS_SLOP_PX = 8;
 /** Max height for tool-result code blocks (json / source / diff / streaming code). */
 export const MAX_HEIGHT_CODE_BLOCK = '22rem';

+/** The sidebar's actions at its top: starting, finding and organizing chats. */
 export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
 	{
 		action: SidebarAction.NEW_CHAT,
@@ -162,7 +163,11 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
 		keys: SHORTCUT_NEW_CHAT,
 		tooltip: 'New chat'
 	},
-	{ action: SidebarAction.SEARCH, icon: Search, keys: SHORTCUT_SEARCH, tooltip: 'Search' },
+	{ action: SidebarAction.SEARCH, icon: Search, keys: SHORTCUT_SEARCH, tooltip: 'Search' }
+];
+
+/** The sidebar's footer: managing the app around the chats. */
+export const SIDEBAR_FOOTER_ITEMS: DesktopIconStripItem[] = [
 	{
 		action: SidebarAction.MANAGE_MODELS,
 		activeRouteId: MODELS_ROUTE_ID,
@@ -172,11 +177,13 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
 	},
 	{
 		action: SidebarAction.MCP,
+		activeRouteId: MCP_SERVERS_ROUTE_ID,
 		icon: McpLogo,
 		tooltip: 'MCP'
 	},
 	{
 		action: SidebarAction.SETTINGS,
+		activeRouteId: SETTINGS_ROUTE_ID,
 		icon: Settings,
 		keys: SHORTCUT_SETTINGS,
 		tooltip: 'Settings'
diff --git a/tools/ui/src/lib/hooks/use-sidebar-reveal.svelte.ts b/tools/ui/src/lib/hooks/use-sidebar-reveal.svelte.ts
index 34604b9c7..588fb4a38 100644
--- a/tools/ui/src/lib/hooks/use-sidebar-reveal.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-sidebar-reveal.svelte.ts
@@ -143,17 +143,35 @@ export function useSidebarReveal() {
 		syncThemeColor(value);
 	});

+	// Set once the page's slide back has ended, which the tween above can trail by a frame: the
+	// page then has no transform, so its fixed bars must drop their pins on that same frame
+	let isSlideBackDone = $state(true);
+
+	$effect(() => {
+		if (uiStore.isSidebarExpanded || dragOffset !== null) isSlideBackDone = false;
+	});
+
+	/** On the page: its slide back has ended, before the tween settles. */
+	function handleTransitionEnd(event: TransitionEvent) {
+		if (event.target !== event.currentTarget || event.propertyName !== 'transform') return;
+
+		if (!uiStore.isSidebarExpanded && dragOffset === null) isSlideBackDone = true;
+	}
+
 	// the page stays locked from the moment it starts sliding aside until it is fully back
 	const isRevealed = $derived(
 		deviceStore.isMobile &&
-			(uiStore.isSidebarExpanded || dragOffset !== null || progress.current > 0)
+			(uiStore.isSidebarExpanded ||
+				dragOffset !== null ||
+				(progress.current > 0 && !isSlideBackDone))
 	);

 	// The slid page's transform makes it the box its fixed bars are placed in, rather than the
 	// screen, which leaves them at the far ends of a scrolled page. The scroll is locked while it
 	// is slid, so they are shifted back by how far the page reaches past the screen at its two
 	// ends. Measured again when the viewport changes, as the installed iOS app shortens it once a
-	// short page is locked
+	// short page is locked, and when the page itself changes under the lock: a tap in the sidebar
+	// opens its page while this one is still aside (see SidebarNavigation's navigateBehind)
 	$effect(() => {
 		const root = document.documentElement;

@@ -175,8 +193,17 @@ export function useSidebarReveal() {
 		pin();
 		root.style.overflow = 'hidden';
 		window.addEventListener('resize', pin);
+		window.addEventListener('scroll', pin, { passive: true });
+
+		const observer = new ResizeObserver(pin);

-		return () => window.removeEventListener('resize', pin);
+		observer.observe(document.body);
+
+		return () => {
+			window.removeEventListener('resize', pin);
+			window.removeEventListener('scroll', pin);
+			observer.disconnect();
+		};
 	});

 	// A pointer cannot stop a scroll, and a browser that has started one ignores any later
@@ -337,6 +364,7 @@ export function useSidebarReveal() {
 		handlePointerDown,
 		handlePointerMove,
 		handlePointerUp: (event: PointerEvent) => handlePointerEnd(event),
+		handleTransitionEnd,
 		/** The page is slid aside, even partly: its contents take no scrolling or taps. */
 		get isRevealed() {
 			return isRevealed;
diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte
index 1b1faf464..b0d5ced31 100644
--- a/tools/ui/src/routes/+layout.svelte
+++ b/tools/ui/src/routes/+layout.svelte
@@ -4,7 +4,7 @@
 	import { afterNavigate, goto } from '$app/navigation';
 	import { base } from '$app/paths';
 	import { page } from '$app/state';
-	import { ActionIcon, MenuIcon, SidebarNavigation } from '$lib/components/app';
+	import { GlassKey, MenuIcon, SidebarNavigation } from '$lib/components/app';
 	import { PwaMetaTags, PwaRefreshAlert } from '$lib/components/pwa';
 	import * as Tooltip from '$lib/components/ui/tooltip';
 	import {
@@ -358,6 +358,7 @@
 			onpointerdown={sidebarReveal.handlePointerDown}
 			onpointermove={sidebarReveal.handlePointerMove}
 			onpointerup={sidebarReveal.handlePointerUp}
+			ontransitionend={sidebarReveal.handleTransitionEnd}
 		>
 			<!-- the installed app's status bar sits over the page: what scrolls under it fades out;
 			     the expanded chat form keeps clear of the bar itself -->
@@ -392,13 +393,11 @@
 			     aside while the expanded chat form covers the screen -->
 			{#if deviceStore.isMobile && !page.url.hash.includes(ROUTES.SEARCH) && !uiStore.isComposerExpanded}
 				<div transition:fade={{ duration: CHAT_FORM_CHROME_FADE_MS }}>
-					<ActionIcon
-						ariaLabel={uiStore.isSidebarExpanded ? 'Collapse navigation' : 'Expand navigation'}
-						class="sidebar-reveal-pin-top fixed top-(--corner-control-top) left-4 z-20 transition-colors h-10 w-10 rounded-full bg-muted/60 border border-border/20 shadow-sm text-accent-foreground"
+					<GlassKey
+						class="sidebar-reveal-pin-top fixed top-(--corner-control-top) left-4 z-30"
 						icon={MenuIcon}
-						iconSize="size-6"
+						label={uiStore.isSidebarExpanded ? 'Collapse navigation' : 'Expand navigation'}
 						onclick={toggleSidebar}
-						size="lg"
 					/>
 				</div>
 			{/if}
diff --git a/tools/ui/src/routes/mcp-servers/+page.svelte b/tools/ui/src/routes/mcp-servers/+page.svelte
new file mode 100644
index 000000000..e155fa9ba
--- /dev/null
+++ b/tools/ui/src/routes/mcp-servers/+page.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+	import { SettingsMcpServers } from '$lib/components/app/settings';
+	import { APP_NAME } from '$lib/constants';
+</script>
+
+<svelte:head>
+	<title>MCP Servers - {APP_NAME}</title>
+</svelte:head>
+
+<!-- a phone's MCP servers: the page the sidebar opens there, where a desktop or tablet opens the
+     MCP servers dialog -->
+<div
+	class="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 pt-(--page-top) pb-16 max-md:pb-safe-24 md:px-8 md:pt-6"
+>
+	<div class="flex min-w-0 flex-col gap-1">
+		<h1 class="text-2xl font-semibold tracking-tight">MCP Servers</h1>
+
+		<p class="text-sm text-muted-foreground">
+			Connect the servers whose tools, prompts and resources your chats can use.
+		</p>
+	</div>
+
+	<SettingsMcpServers />
+</div>
diff --git a/tools/ui/src/routes/mcp-servers/+page.ts b/tools/ui/src/routes/mcp-servers/+page.ts
new file mode 100644
index 000000000..3be3d5a95
--- /dev/null
+++ b/tools/ui/src/routes/mcp-servers/+page.ts
@@ -0,0 +1,4 @@
+import type { PageLoad } from './$types';
+import { loadAppRoute } from '$lib/stores/init';
+
+export const load: PageLoad = ({ fetch }) => loadAppRoute(fetch);
diff --git a/tools/ui/src/routes/settings/+page.svelte b/tools/ui/src/routes/settings/+page.svelte
new file mode 100644
index 000000000..92da7e070
--- /dev/null
+++ b/tools/ui/src/routes/settings/+page.svelte
@@ -0,0 +1,28 @@
+<script lang="ts">
+	import { SettingsChat } from '$lib/components/app/settings';
+	import { APP_NAME } from '$lib/constants';
+	import { toast } from 'svelte-sonner';
+</script>
+
+<svelte:head>
+	<title>Settings - {APP_NAME}</title>
+</svelte:head>
+
+<!-- a phone's settings: the page the sidebar opens there, where a desktop or tablet opens the
+     settings dialog. The section tabs pin right under the menu key, over the status bar fade;
+     pinned, they fill the screen above them in the page's colour, so nothing scrolls through
+     between the two. They stay under the menu key (z-25, where it is z-30 and the fade z-20) -->
+<div
+	class="[--settings-tabs-top:calc(var(--corner-control-top)+3rem)] flex w-full flex-col pt-(--page-top) max-md:pb-safe-4 md:pt-6"
+>
+	<div class="flex min-w-0 flex-col gap-1 px-4 pb-2 md:px-8">
+		<h1 class="text-2xl font-semibold tracking-tight">Settings</h1>
+
+		<p class="text-sm text-muted-foreground">How the app looks, chats and talks to the server.</p>
+	</div>
+
+	<SettingsChat
+		mobileHeaderClass="top-(--settings-tabs-top) z-[25] data-stuck:before:absolute data-stuck:before:inset-x-0 data-stuck:before:bottom-full data-stuck:before:h-(--settings-tabs-top) data-stuck:before:bg-background"
+		onClose={() => toast.success('Settings saved')}
+	/>
+</div>
diff --git a/tools/ui/src/routes/settings/+page.ts b/tools/ui/src/routes/settings/+page.ts
new file mode 100644
index 000000000..3be3d5a95
--- /dev/null
+++ b/tools/ui/src/routes/settings/+page.ts
@@ -0,0 +1,4 @@
+import type { PageLoad } from './$types';
+import { loadAppRoute } from '$lib/stores/init';
+
+export const load: PageLoad = ({ fetch }) => loadAppRoute(fetch);
diff --git a/tools/ui/tests/client/nested-surfaces.svelte.test.ts b/tools/ui/tests/client/nested-surfaces.svelte.test.ts
new file mode 100644
index 000000000..2ab8a74f6
--- /dev/null
+++ b/tools/ui/tests/client/nested-surfaces.svelte.test.ts
@@ -0,0 +1,85 @@
+// The sidebar's footer is the third of four nested surfaces: the page, the sidebar on it, a
+// group inset in the sidebar and a row in the group. This holds the rule app.css states for
+// them, on what the browser actually renders: a row's corners are concentric with its
+// group's, so its radius is the group's less the padding between their edges.
+
+import '../../src/app.css';
+import SidebarNavigationActions from '$lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte';
+import SidebarNavigationFooter from '$lib/components/app/navigation/SidebarNavigation/SidebarNavigationFooter.svelte';
+import { SIDEBAR_ACTIONS_ITEMS, SIDEBAR_FOOTER_ITEMS } from '$lib/constants';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { page } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
+
+function px(value: string): number {
+	return parseFloat(value);
+}
+
+async function renderFooter() {
+	const screen = render(SidebarNavigationFooter, {});
+	const rows = () => [...screen.container.querySelectorAll<HTMLElement>('[data-slot="button"]')];
+
+	await vi.waitFor(() => expect(rows()).toHaveLength(SIDEBAR_FOOTER_ITEMS.length));
+
+	const group = rows()[0].parentElement?.parentElement;
+
+	if (!group) throw new Error('footer group not rendered');
+
+	return { group, rows: rows() };
+}
+
+describe('nested surfaces', () => {
+	// the footer is a desktop's and a tablet's: a phone lists its items with the sidebar's actions
+	beforeEach(async () => {
+		await page.viewport(1280, 900);
+	});
+
+	it('round the footer group from its tokens', async () => {
+		const { group } = await renderFooter();
+		const root = getComputedStyle(document.documentElement);
+		const style = getComputedStyle(group);
+
+		// the tokens resolve, so the group is not left square or unpadded
+		expect(px(style.borderTopLeftRadius)).toBeGreaterThan(0);
+		expect(px(style.paddingTop)).toBeGreaterThan(0);
+		expect(root.getPropertyValue('--surface-group-radius').trim()).not.toBe('');
+		expect(root.getPropertyValue('--surface-group-padding').trim()).not.toBe('');
+	});
+
+	it('keep each footer row concentric with its group', async () => {
+		const { group, rows } = await renderFooter();
+		const style = getComputedStyle(group);
+		const expected = px(style.borderTopLeftRadius) - px(style.paddingTop);
+
+		for (const row of rows) {
+			const radius = getComputedStyle(row).borderTopLeftRadius;
+
+			expect(px(radius), row.textContent ?? '').toBeCloseTo(expected, 1);
+		}
+	});
+
+	it('inset each footer row by the group padding the radius accounts for', async () => {
+		const { group, rows } = await renderFooter();
+		const style = getComputedStyle(group);
+		const box = group.getBoundingClientRect();
+		// the hairline border sits outside the padding the radius is reduced by
+		const inner = box.left + px(style.borderLeftWidth) + px(style.paddingLeft);
+
+		for (const row of rows) {
+			expect(row.getBoundingClientRect().left).toBeCloseTo(inner, 0);
+		}
+	});
+
+	it('round the top actions of the sidebar like the footer rows', async () => {
+		const { rows: footerRows } = await renderFooter();
+		const expected = getComputedStyle(footerRows[0]).borderTopLeftRadius;
+		const top = render(SidebarNavigationActions, { class: '' });
+		const rows = () => [...top.container.querySelectorAll<HTMLElement>('[data-slot="button"]')];
+
+		await vi.waitFor(() => expect(rows()).toHaveLength(SIDEBAR_ACTIONS_ITEMS.length));
+
+		for (const row of rows()) {
+			expect(getComputedStyle(row).borderTopLeftRadius, row.textContent ?? '').toBe(expected);
+		}
+	});
+});