Commit 610f7ab40 for llama.cpp

commit 610f7ab40a1422898c7916621b9c698cca7669fc
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date:   Mon Nov 9 21:06:28 2026 +0100

    ui: make the app feel native on phones (#31949)

    * ui: reveal the mobile sidebar by sliding the page aside

    On a phone the sidebar now stays put under the page, and the page slides
    right to uncover it, as a rounded card with the card border. The menu
    button rides on the page; a swipe from the left edge opens the sidebar,
    and a swipe back or a tap on the page closes it.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: polish the mobile sidebar reveal and add a hold menu to rows

    Holding a conversation row on a phone opens its action menu anchored to
    the row, in place of the hidden three-dot trigger. The page no longer
    widens the document when it slides aside, so iOS stops panning it on the
    first open.

    The page and sidebar backgrounds start in the page color and blend into
    the sidebar tint as the sidebar opens, following a drag frame by frame,
    and a theme-color tag follows them for the browser bars. The sidebar's
    contents fade in on a log curve and scale up slightly, and sit at the
    same inset as the menu toggle, which now uses a two-line icon in a
    filled circle.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: pin the context gauge on phones and lock the slid-aside page

    On a phone the context gauge leaves the chat form for the top right of
    the chat screen, in a round button across from the menu toggle. Both
    take the scroll-down button's frosted look.

    While the sidebar is out, and until the page is fully back in place,
    the page neither scrolls nor takes taps; a tap or drag on it still
    closes the sidebar.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: open the mobile sidebar with a swipe from anywhere

    A rightward swipe from anywhere on the page now opens the phone sidebar
    too, beside the edge swipe that competes with the browser's own back and
    forward gesture. It starts after a short sideways move, skips text
    fields and content that scrolls sideways, and keeps the page from
    scrolling under it. Letting go opens or closes the sidebar past 30% of
    the way or on a flick.

    The top buttons and the chat form now hold their place on screen while
    the page is slid aside, instead of jumping to the far ends of a
    scrolled conversation.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: hide the model org name on mobile unless a setting keeps it

    A phone's model selector is narrow, so it now drops the organization
    from model names by default. A new Display setting, "Show organization
    name on mobile", brings it back; it applies only while the organization
    name is shown at all.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: close the mobile sidebar before following a tap in it

    On a phone, picking a conversation, New chat, Search, MCP or Settings
    in the open sidebar now slides it shut first and acts 180ms later, so
    the page or dialog appears once the sidebar is out of the way. With the
    sidebar already closed, or on a desktop, the action runs at once.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: make the app feel native on phones and run it under the status bar

    Applies the mobile-native checklist: no tap highlight or landscape text
    inflation, instant taps on buttons and links, no label selection or
    callout on a held control, hover styles only where a pointer can hover,
    and 16px textareas on phones so focusing them does not zoom the page.

    The page now runs under the notch (viewport-fit=cover), so the installed
    iOS app tints its status bar from the page and the sidebar. The menu
    toggle, the context gauge, the sidebar and the top of the chat and
    models pages pad themselves clear of it, and the locked, slid page
    reaches down by the status bar's height, which iOS leaves out of it.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: add a viewport height readout for debugging on phones

    With VITE_DEBUG set, a dev build shows the window, viewport unit and
    safe-area heights, and the elements that reach past the window's bottom
    and make the page scroll. It is loaded on demand, so production builds
    leave it out.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: fill the screen from launch in the installed iOS app

    iOS 26 gives an installed app with viewport-fit=cover and a translucent
    status bar a viewport short of that bar (WebKit bug 301108), clipping
    the bottom of the page and lifting the chat form, but only while the
    page is shorter than the screen. In the installed app the page now
    reaches the screen plus the status bar, capped at the screen, so it
    gets the full screen from the start without ever scrolling.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: raise the phone's corner controls in the installed app

    The installed app's status bar already sets the menu toggle, the context
    gauge and the open sidebar's logo apart from the screen's edge, so they
    sit just below it rather than a full 1rem lower. They share one value,
    which stays at 1rem below the inset in a browser tab.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: add safe-area utilities for what is pinned to a screen edge

    The page runs under the status bar and over the home indicator, which an
    installed iPhone app always shows. New pt-safe, pb-safe, top-safe and
    bottom-safe utilities, each with a spacing step (pt-safe-12 is 3rem plus
    the top inset), keep pinned content clear of them instead of hand-written
    calc()s. A --safe-area-bottom joins --safe-area-top, and the chat screen and
    Models page use pt-safe-12.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: open search in a full-height drawer on phones

    A phone used to leave for the /search page. It now opens the same search
    as the desktop dialog, in a bottom drawer that fills the screen below the
    status bar. Keyboard shortcuts, the Enter hint and the keyboard highlight
    stay on desktop, and so does the models manager, as in the phone sidebar.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: show every dialog full screen on phones, clear of the status bar

    A phone now shows every dialog full screen, whatever size it asks for on a
    desktop, instead of each full-screen dialog repeating its own classes. Its
    top and bottom borders take the status bar's and the home indicator's
    height, transparent, so an installed app's dialogs keep their content clear
    of both with no change to their padding. The attachment preview, which has
    its own full-screen content, pads itself and lowers its close button the
    same way.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: fade the page out under the status bar of the installed app on phones

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: show search in the full-screen dialog on phones instead of a drawer

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: start a phone page's title a row below the corner controls

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: pin the slid page's bottom bars against the screen in the installed iOS app

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: cast a soft shadow from the slid page onto the phone sidebar

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: hide the scroll-down key at the real bottom on iOS and show it only well above

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: fade the status-bar fade out as the page slides aside for the sidebar

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: draw the slid page's shadow above its rounded frame, so it follows the corners

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: show a phone chat's title and working directory at the top, and pick it in a drawer

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: give iOS Safari a solid top and fade the bottom of the screen on phones

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: let iOS Safari find the top strip, which it skips when it takes no taps

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: lengthen the top fade and lift the chat form a little in phone browsers

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: give iOS Safari's bottom toolbar the page's colour, not the chat form's

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: draw the phone's context gauge key like the menu key across from it

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: confirm opening the phone sidebar, its hold menu and its rows with a haptic tap

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: keep the screen-edge fades on the page as it slides aside for the sidebar

    Fading them out with the slide made the page flicker; the rounded frame over the
    slid page already clips them at its corners.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: keep the working directory chip optional for hosts with their own trigger

    The phone layout opens the picker from the chat's header, so ChatForm hides the
    chip there. Master's version of the chip has no such switch; bring it back.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: style layer 5's additions with master's tokens until the design system lands

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: drop the design system's stylesheets and fonts from app.css until it lands

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: keep master's font stacks until the design system brings its fonts

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: restore master's colour palette in app.css until the design system lands

    Resolving the safe-area commit took the design system's app.css, which drops
    the palette for its token files and aliases the shadcn names to semantic
    tokens. With the tokens gone those aliases point at themselves, so every
    colour went invalid: transparent surfaces and currentColor borders.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: center alert dialogs on phones instead of sliding them up as drawers

    An alert is a short question, not a drawer: on a phone it is now a centered
    card, 90% of the screen wide and as tall as its text, scrolling only past the
    screen's height. It zooms in as on desktop, and its footer drops the margin
    the sheet needed.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: space the working directory drawer for a phone

    The drawer reused the popover's compact picker: Browse sat against the search
    field, the divider ran past the rows and each row had its own inset. In the
    drawer the picker now takes the header's inset, gives Browse room, lines its
    rows up with the search results and sizes its text for a phone.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: tap the haptic engine when a message sends, and once per hold

    A phone now confirms a sent message by feel, at the moment the send goes
    through (not on every press of the button), and before any await, so it still
    runs inside the tap or keypress that iOS needs to play its tick. The hold that
    opens a row's menu now taps the engine from the long-press hook itself, once:
    the hook vibrated and the row tapped as well, which buzzed twice on Android.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: name the gauge dial sizes, the chat form's bottom offset and the sidebar fade

    Review follow-ups: the context gauge dial reads its grid, radius, stroke and
    track from one table per size instead of ternaries; the chat form's distance
    from the screen's bottom per platform and the sidebar content's fade duration
    are named constants. The viewport debug readout moves out of src, next to the
    tests, as it is a debug tool the app only loads in a dev build.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: say why the microphone can't record instead of failing silently

    A llama-server opened over plain HTTP on the local network or over Tailscale
    is not a secure context, where browsers hide the microphone, so a tap on the
    mic did nothing but log to the console. The button now shows a crossed-out mic
    in the warning tone with the reason in its tooltip, and a tap says it in a
    toast, as a phone shows no tooltip. A start that fails names the browser's
    reason (access denied, no microphone, in use by another app) the same way,
    and the next tap tries again. The recorder passes the browser's error on
    instead of replacing it with a generic one.

    Assisted-by: Claude (Opus 5.5 Medium)

diff --git a/tools/ui/src/app.css b/tools/ui/src/app.css
index c1634fca6..af6ab16e3 100644
--- a/tools/ui/src/app.css
+++ b/tools/ui/src/app.css
@@ -1,8 +1,11 @@
 @import 'tailwindcss';
 @source '.';
+/* stories may use classes the app does not, so tailwind scans them too */
+@source '../tests/stories';
 @plugin '@tailwindcss/forms';
 @plugin '@tailwindcss/typography';
 @import 'tw-animate-css';
+@import './lib/styles/safe-area.css';

 @custom-variant dark (&:is(.dark *));

@@ -35,6 +38,15 @@

 :root {
 	--radius: 0.625rem;
+	/* the page runs under the status bar and over the home indicator
+	   (viewport-fit=cover); what is pinned to a screen edge keeps clear of them
+	   with the pt-safe / pb-safe utilities (see lib/styles/safe-area.css) */
+	--safe-area-top: env(safe-area-inset-top, 0px);
+	--safe-area-bottom: env(safe-area-inset-bottom, 0px);
+	/* where the phone's corner controls (menu toggle, context gauge, sidebar logo) sit */
+	--corner-control-top: calc(1rem + var(--safe-area-top));
+	/* where a phone page's title starts: a row below the corner controls */
+	--page-top: calc(var(--corner-control-top) + 3.75rem);
 	/* expanded desktop sidebar width, overridden once the user drags its edge */
 	--sidebar-width: 18rem;
 	--background: oklch(1 0 0);
@@ -70,6 +82,20 @@
 	--sidebar-ring: oklch(0.708 0 0);
 	--code-background: oklch(0.985 0 0);
 	--code-foreground: oklch(0.145 0 0);
+	/* phone sidebar width, which the page slides aside to reveal */
+	--sidebar-reveal: min(80dvw, 20rem);
+	/* the desktop sidebar's muted/60 over the page, made opaque so the slid page's corners can match it */
+	--sidebar-reveal-tint: color-mix(in oklab, var(--muted) 60%, var(--background));
+	/* how far the phone sidebar is open, 0 to 1, stepped every frame by useSidebarReveal */
+	--sidebar-reveal-progress: 0;
+	/* the same on a log curve: the sidebar's contents hold on until the page nearly covers them */
+	--sidebar-reveal-ease: calc(log(1 + 9 * var(--sidebar-reveal-progress)) / log(10));
+	/* the page background, blending into the sidebar tint as the phone sidebar opens */
+	--sidebar-reveal-bg: color-mix(
+		in oklab,
+		var(--sidebar-reveal-tint) calc(var(--sidebar-reveal-progress) * 100%),
+		var(--background)
+	);
 	--font-mono:
 		ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
 		'Liberation Mono', Menlo, monospace;
@@ -81,6 +107,21 @@
 	--max-message-height: max(24rem, min(80dvh, calc(100dvh - var(--chat-form-area-height) - 12rem)));
 }

+/* iOS 26 gives an installed app with viewport-fit=cover and a translucent status bar a
+   viewport short of that bar (WebKit bug 301108), but only while the page is shorter than
+   the screen. A page that reaches past it gets the full screen, after which this is 100%,
+   capped at the screen so it never scrolls; elsewhere the inset is 0 and it is 100% too. */
+@media (display-mode: standalone) {
+	/* the installed app's status bar already sets them apart from the screen's edge */
+	:root {
+		--corner-control-top: calc(0.25rem + var(--safe-area-top));
+	}
+
+	html {
+		min-height: min(100lvh, calc(100% + var(--safe-area-top)));
+	}
+}
+
 @media (min-width: 640px) {
 	:root {
 		--chat-form-area-height: 24rem;
@@ -169,7 +210,7 @@
 	}

 	body {
-		@apply bg-background text-foreground antialiased;
+		@apply bg-(--sidebar-reveal-bg) text-foreground antialiased;
 		scrollbar-width: thin;
 		scrollbar-gutter: stable;
 	}
@@ -181,6 +222,30 @@
 		overscroll-behavior: none;
 	}

+	/* no gray flash over a tapped control, which has its own press state, and
+	   no text inflation when a phone turns to landscape */
+	html {
+		-webkit-tap-highlight-color: transparent;
+		-webkit-text-size-adjust: 100%;
+		text-size-adjust: 100%;
+	}
+
+	/* a tap clicks at once rather than waiting to see if it is a double-tap zoom */
+	button,
+	a,
+	[role='button'] {
+		touch-action: manipulation;
+	}
+
+	/* holding a control does not select its label or pop the link callout;
+	   links stay selectable, as in a message they are content */
+	button,
+	[role='button'] {
+		-webkit-touch-callout: none;
+		-webkit-user-select: none;
+		user-select: none;
+	}
+
 	/* same for dialogs, whose panels and lists scroll on their own with sticky
 	   headers and footers. Scroll containers only: Firefox treats an
 	   overflow-hidden box (truncated text) as one, and with `none` it would
@@ -198,7 +263,7 @@
 	}

 	*:hover {
-		scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
+		scrollbar-color: color-mix(in oklch, var(--muted-foreground) 30%, transparent) transparent;
 	}

 	*::-webkit-scrollbar {
@@ -217,11 +282,11 @@
 	}

 	*:hover::-webkit-scrollbar-thumb {
-		background: hsl(var(--muted-foreground) / 0.3);
+		background: color-mix(in oklch, var(--muted-foreground) 30%, transparent);
 	}

 	*::-webkit-scrollbar-thumb:hover {
-		background: hsl(var(--muted-foreground) / 0.5);
+		background: color-mix(in oklch, var(--muted-foreground) 50%, transparent);
 	}

 	:where(code, pre, kbd, samp) {
@@ -251,6 +316,54 @@
 		);
 	}

+	/* The same fade at the top of a phone's screen: solid behind the status bar and the row of
+	   corner controls under it (the menu key, the chat's header), fading out below them, so the
+	   page scrolls away under them instead of into them. It rides on the page as it slides aside
+	   for the sidebar and stays as it is: the rounded frame over the slid page clips it. */
+	.status-bar-fade {
+		/* down to where a page's content starts (--page-top): solid to the middle of the corner
+		   row, then a long, even fade over its lower half and on to the content */
+		height: var(--page-top);
+		background: linear-gradient(
+			to bottom,
+			var(--background) calc(var(--corner-control-top) + 1.25rem),
+			color-mix(in srgb, var(--background) 55%, transparent)
+				calc(var(--corner-control-top) + 2.5rem),
+			transparent 100%
+		);
+	}
+
+	/* iOS Safari colours the screen's top, above the page, from a solid fixed element at the
+	   page's top edge (theme-color no longer), and shows the page through it otherwise. The
+	   fade above is a gradient, which it does not take, so this strip under it carries the
+	   page's colour there, as the installed app has. Safari finds it by hit-testing the top
+	   edge, so it takes taps (pointer-events: none would hide it) and is thicker than the
+	   10px Safari ignores; what it covers sits under the fade's solid part anyway */
+	.status-bar-edge {
+		height: 0.75rem;
+		background-color: var(--background);
+	}
+
+	/* The bottom edge's counterpart, which Safari takes for its bottom toolbar the same way:
+	   without it, it finds the chat form there and takes the form's colour */
+	.screen-bottom-edge {
+		height: 0.75rem;
+		background-color: var(--background);
+	}
+
+	/* The same fade at the bottom of a phone's screen: solid behind the home indicator and
+	   fading out above it, so the page ends softly at the screen's edge and under the chat
+	   form instead of running out under it. It stays through the slide as the top one does */
+	.screen-bottom-fade {
+		height: calc(var(--safe-area-bottom) + 3rem);
+		background: linear-gradient(
+			to top,
+			var(--background) var(--safe-area-bottom),
+			color-mix(in srgb, var(--background) 60%, transparent) calc(var(--safe-area-bottom) + 1.5rem),
+			transparent 100%
+		);
+	}
+
 	/* 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 */
@@ -301,6 +414,27 @@
 		pointer-events: none;
 	}

+	/* the phone sidebar's contents zoom out and fade as the page slides back over them */
+	.sidebar-reveal-content {
+		opacity: var(--sidebar-reveal-ease);
+		scale: calc(0.98 + 0.02 * var(--sidebar-reveal-ease));
+	}
+
+	/* the slid page's fixed bars, held where they sit on screen, see useSidebarReveal */
+	.sidebar-reveal-pin-top {
+		translate: 0 var(--sidebar-reveal-pin-top, 0px);
+	}
+
+	.sidebar-reveal-pin-bottom {
+		translate: 0 calc(-1 * var(--sidebar-reveal-pin-bottom, 0px));
+	}
+
+	@media (prefers-reduced-motion: reduce) {
+		.sidebar-reveal-content {
+			scale: none;
+		}
+	}
+
 	.shimmer-text {
 		background: linear-gradient(
 			90deg,
diff --git a/tools/ui/src/app.html b/tools/ui/src/app.html
index ef2787ad1..65a9d9288 100644
--- a/tools/ui/src/app.html
+++ b/tools/ui/src/app.html
@@ -12,7 +12,7 @@

 		<meta
 			name="viewport"
-			content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
+			content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
 		/>
 		%sveltekit.head%
 	</head>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
index 639577134..4ba70bc9a 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
@@ -29,6 +29,7 @@
 	import {
 		chatStore,
 		conversationsStore,
+		deviceStore,
 		mcpStore,
 		modelsStore,
 		serverStore,
@@ -59,9 +60,12 @@
 		AudioRecorder,
 		convertToWav,
 		createAudioFile,
-		isAudioRecordingSupported
+		describeMicrophoneError,
+		isAudioRecordingSupported,
+		microphoneUnavailableReason
 	} from '$lib/utils/browser-only';
 	import { onMount } from 'svelte';
+	import { toast } from 'svelte-sonner';

 	interface Props {
 		// Data
@@ -134,6 +138,10 @@
 	// Audio Recording State
 	let isRecording = $state(false);
 	let recordingSupported = $state(false);
+	/** Why this page can't record at all (no HTTPS, no recorder), which no tap fixes. */
+	let micUnavailableReason = $state<string | null>(null);
+	/** Why the last start failed (denied, no microphone, busy); the next tap tries again. */
+	let micError = $state<string | null>(null);

 	// Invisible anchor at the form's top edge so the mention/WD popovers
 	// float above the box.
@@ -143,6 +151,14 @@
 		conversationsStore.activeConversation?.cwd ?? conversationsStore.preferences.pendingCwd
 	);

+	let showWorkingDirectory = $derived(conversationsStore.preferences.hasEnabledCwdTools());
+	// A phone has no chip for the working directory under the form: a chat shows it in its
+	// header (see ChatScreenMobileHeader), and a new chat, which has no header yet, offers it in
+	// the + menu
+	let isWorkingDirectoryInAddMenu = $derived(
+		showWorkingDirectory && deviceStore.isMobile && !conversationsStore.activeConversation
+	);
+
 	const pickers = useChatFormPickers({
 		canCompact: () => chatStore.canCompact(conversationsStore.activeMessages),
 		focusInput: refocusInput,
@@ -249,6 +265,7 @@

 	onMount(() => {
 		recordingSupported = isAudioRecordingSupported();
+		micUnavailableReason = microphoneUnavailableReason();
 		audioRecorder = new AudioRecorder();
 	});

@@ -260,6 +277,10 @@
 		inputRef?.resetHeight();
 	}

+	export function openWorkingDirectoryPicker() {
+		pickers.handleWorkingDirectoryOpen();
+	}
+
 	export function openModelSelector() {
 		chatFormActionsRef?.openModelSelector();
 	}
@@ -518,6 +539,13 @@
 	}

 	async function handleMicClick() {
+		// a phone shows no tooltip on a tap, so the reason comes as a toast
+		if (micUnavailableReason) {
+			toast.error(micUnavailableReason);
+
+			return;
+		}
+
 		if (!audioRecorder || !recordingSupported) {
 			console.warn('Audio recording not supported');

@@ -539,8 +567,10 @@
 			try {
 				await audioRecorder.startRecording();
 				isRecording = true;
+				micError = null;
 			} catch (error) {
-				console.error('Failed to start recording:', error);
+				micError = describeMicrophoneError(error);
+				toast.error(micError);
 			}
 		}
 	}
@@ -638,10 +668,14 @@
 				{isLoading}
 				isReasoning={chatStore.isReasoning}
 				{isRecording}
+				micWarning={micUnavailableReason ?? micError}
 				onFileUpload={handleFileUpload}
 				onMicClick={handleMicClick}
 				{onStop}
 				onSystemPromptClick={() => onSystemPromptClick?.({ files: uploadedFiles, message: value })}
+				onWorkingDirectoryClick={isWorkingDirectoryInAddMenu
+					? pickers.handleWorkingDirectoryOpen
+					: undefined}
 				{showAddButton}
 				{showModelSelector}
 				{uploadedFiles}
@@ -651,7 +685,8 @@

 	<ContextGaugePopup />

-	{#if conversationsStore.preferences.hasEnabledCwdTools()}
+	{#if showWorkingDirectory}
+		<!-- a phone opens the picker from elsewhere (see isWorkingDirectoryInAddMenu): no chip here -->
 		<ChatFormCurrentWorkingDirectory
 			bind:query={pickers.workingDirectoryQuery}
 			customAnchor={mentionAnchor}
@@ -661,6 +696,7 @@
 			onChange={handleWorkingDirectoryChange}
 			onClose={pickers.handleWorkingDirectoryClose}
 			onOpen={pickers.handleWorkingDirectoryOpen}
+			showChip={!deviceStore.isMobile}
 		/>
 	{/if}
 </form>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDrawer.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDrawer.svelte
index 16a5880a0..addf56009 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDrawer.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDrawer.svelte
@@ -3,6 +3,7 @@
 		ChevronDown,
 		ChevronRight,
 		File,
+		Folder,
 		Image,
 		MessageSquare,
 		Mic,
@@ -17,7 +18,8 @@
 		ATTACHMENT_FILE_ITEMS,
 		ATTACHMENT_MENU_TEXT,
 		ATTACHMENT_MODALITY_TEXT,
-		ICON_CLASS_DEFAULT
+		ICON_CLASS_DEFAULT,
+		SET_WORKING_DIRECTORY_LABEL
 	} from '$lib/constants';
 	import { getChatFormActionsContext } from '$lib/contexts';
 	import { AttachmentAction, AttachmentItemEnabledWhen } from '$lib/enums/attachment.enums';
@@ -135,6 +137,21 @@
 					<span>{ATTACHMENT_MENU_TEXT.SYSTEM_MESSAGE}</span>
 				</button>

+				{#if chatFormActions.onWorkingDirectoryClick}
+					<button
+						class={itemClass}
+						onclick={() => {
+							drawerOpen = false;
+							chatFormActions.onWorkingDirectoryClick?.();
+						}}
+						type="button"
+					>
+						<Folder class="{ICON_CLASS_DEFAULT} shrink-0" />
+
+						<span>{SET_WORKING_DIRECTORY_LABEL}</span>
+					</button>
+				{/if}
+
 				{#if toolsPanel.totalToolCount > 0}
 					<Collapsible.Root onOpenChange={(open) => (toolsExpanded = open)} open={toolsExpanded}>
 						<Collapsible.Trigger class={itemClass}>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte
index 7c305acbe..db69fbbd5 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte
@@ -1,5 +1,5 @@
 <script lang="ts">
-	import { Mic, Square } from '@lucide/svelte';
+	import { Mic, MicOff, Square } from '@lucide/svelte';
 	import { Button } from '$lib/components/ui/button';
 	import * as Tooltip from '$lib/components/ui/tooltip';
 	import { ICON_CLASS_DEFAULT } from '$lib/constants';
@@ -10,6 +10,8 @@
 		hasAudioModality?: boolean;
 		isLoading?: boolean;
 		isRecording?: boolean;
+		/** Why the microphone can't record (no HTTPS, access denied...), shown on the button. */
+		micWarning?: string | null;
 		onMicClick?: () => void;
 	}

@@ -19,8 +21,16 @@
 		hasAudioModality = false,
 		isLoading = false,
 		isRecording = false,
+		micWarning = null,
 		onMicClick
 	}: Props = $props();
+
+	// a model without audio input disables the button; a microphone that can't record keeps it
+	// tappable, so a tap can say why
+	const showWarning = $derived(hasAudioModality && !isRecording && !!micWarning);
+	const tooltip = $derived(
+		!hasAudioModality ? 'Current model does not support audio' : showWarning ? micWarning : null
+	);
 </script>

 <div class="flex items-center gap-1 {className}">
@@ -29,24 +39,34 @@
 			<Button
 				class="h-8 w-8 max-md:h-9 max-md:w-9 rounded-full p-0 {isRecording
 					? 'animate-pulse bg-red-500 text-white hover:bg-red-600'
-					: ''}"
+					: showWarning
+						? 'bg-amber-500/15 text-amber-600 hover:bg-amber-500/25 dark:text-amber-400'
+						: ''}"
 				disabled={disabled || isLoading || !hasAudioModality}
 				onclick={onMicClick}
 				type="button"
 			>
-				<span class="sr-only">{isRecording ? 'Stop recording' : 'Start recording'}</span>
+				<span class="sr-only"
+					>{isRecording
+						? 'Stop recording'
+						: showWarning
+							? `Recording unavailable: ${micWarning}`
+							: 'Start recording'}</span
+				>

 				{#if isRecording}
 					<Square class="{ICON_CLASS_DEFAULT} animate-pulse fill-white" />
+				{:else if showWarning}
+					<MicOff class={ICON_CLASS_DEFAULT} />
 				{:else}
 					<Mic class={ICON_CLASS_DEFAULT} />
 				{/if}
 			</Button>
 		</Tooltip.Trigger>

-		{#if !hasAudioModality}
-			<Tooltip.Content>
-				<p>Current model does not support audio</p>
+		{#if tooltip}
+			<Tooltip.Content class="max-w-64">
+				<p>{tooltip}</p>
 			</Tooltip.Content>
 		{/if}
 	</Tooltip.Root>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
index c51ac72a1..e27485a04 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
@@ -1,6 +1,5 @@
 <script lang="ts">
 	import { SkipForward, Square } from '@lucide/svelte';
-	import { page } from '$app/state';
 	import {
 		ChatFormActionReasoning,
 		ChatFormActionRecord,
@@ -12,10 +11,11 @@
 	import { Button } from '$lib/components/ui/button';
 	import { ICON_CLASS_DEFAULT } from '$lib/constants';
 	import { setChatFormActionsContext } from '$lib/contexts';
-	import { FileTypeCategory, MessageRole } from '$lib/enums';
+	import { FileTypeCategory } from '$lib/enums';
 	import { useChatFormModel } from '$lib/hooks/use-chat-form-model.svelte';
+	import { useShowContextGauge } from '$lib/hooks/use-context-gauge.svelte';
 	import { ChatService } from '$lib/services';
-	import { chatStore, conversationsStore, settingsStore } from '$lib/stores';
+	import { conversationsStore, deviceStore, settingsStore } from '$lib/stores';
 	import { getFileTypeCategory } from '$lib/utils';

 	interface Props {
@@ -25,6 +25,8 @@
 		isLoading?: boolean;
 		isReasoning?: boolean;
 		isRecording?: boolean;
+		/** Why the microphone can't record, shown on its button. */
+		micWarning?: string | null;
 		showAddButton?: boolean;
 		showModelSelector?: boolean;
 		uploadedFiles?: ChatUploadedFile[];
@@ -32,6 +34,7 @@
 		onMicClick?: () => void;
 		onStop?: () => void;
 		onSystemPromptClick?: () => void;
+		onWorkingDirectoryClick?: () => void;
 	}

 	let {
@@ -41,10 +44,12 @@
 		isLoading = false,
 		isReasoning = false,
 		isRecording = false,
+		micWarning = null,
 		onFileUpload,
 		onMicClick,
 		onStop,
 		onSystemPromptClick,
+		onWorkingDirectoryClick,
 		showAddButton = true,
 		showModelSelector = true,
 		uploadedFiles = []
@@ -85,45 +90,7 @@
 		conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1]
 	);

-	let hasProcessedTokens = $derived.by(() => {
-		if (!page.params.id) return false;
-
-		const messages = conversationsStore.activeMessages as DatabaseMessage[];
-
-		let totalHistoricalTokens = 0;
-
-		for (const m of messages) {
-			if (m.role !== MessageRole.ASSISTANT) continue;
-
-			const timings = m.timings;
-
-			if (!timings) continue;
-
-			const agenticLlm = timings.agentic?.llm;
-
-			if (agenticLlm?.prompt_n != null || agenticLlm?.predicted_n != null) {
-				totalHistoricalTokens += (agenticLlm?.prompt_n ?? 0) + (agenticLlm?.predicted_n ?? 0);
-			} else {
-				totalHistoricalTokens += (timings.prompt_n ?? 0) + (timings.predicted_n ?? 0);
-			}
-		}
-
-		if (totalHistoricalTokens > 0) return true;
-
-		if (!chatStore.isLoading && !chatStore.isStreaming()) return false;
-
-		const processingState = chatStore.processing.activeState;
-
-		if (!processingState) return false;
-
-		const livePromptTokens = Math.max(
-			processingState.promptTokens ?? 0,
-			processingState.promptProgress?.processed ?? 0
-		);
-		const liveOutputTokens = processingState.outputTokensUsed ?? 0;
-
-		return livePromptTokens > 0 || liveOutputTokens > 0;
-	});
+	const showContextGauge = useShowContextGauge();

 	setChatFormActionsContext({
 		get disabled() {
@@ -143,6 +110,9 @@
 		},
 		get onSystemPromptClick() {
 			return onSystemPromptClick;
+		},
+		get onWorkingDirectoryClick() {
+			return onWorkingDirectoryClick;
 		}
 	});
 </script>
@@ -158,7 +128,8 @@
 	{/if}

 	<div class="flex items-center gap-1.5">
-		{#if hasProcessedTokens}
+		<!-- a phone shows the gauge pinned to the chat screen's top right instead -->
+		{#if showContextGauge.current && !deviceStore.isMobile}
 			<ChatFormContextGauge />
 		{/if}

@@ -205,7 +176,14 @@
 			/>
 		</Button>
 	{:else if shouldShowRecordButton}
-		<ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
+		<ChatFormActionRecord
+			{disabled}
+			{hasAudioModality}
+			{isLoading}
+			{isRecording}
+			{micWarning}
+			{onMicClick}
+		/>
 	{:else}
 		<ChatFormActionSubmit
 			canSend={canSend && (showModelSelector ? hasModelSelected && isSelectedModelInCache : true)}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte
index cb3d07e11..c998f5f59 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte
@@ -11,6 +11,15 @@
 	import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
 	import { untrack } from 'svelte';

+	interface Props {
+		/** Sizes and places the trigger, the inline dial size by default. */
+		class?: string;
+		/** Draws the dial as the icon of a phone's corner key, matching the menu key's glyph. */
+		corner?: boolean;
+	}
+
+	let { class: className = 'h-5 w-5 max-md:h-6 max-md:w-6', corner = false }: Props = $props();
+
 	const gauge = useContextGauge();

 	$effect(() => {
@@ -43,7 +52,7 @@

 <div
 	aria-label="Context usage"
-	class="flex h-5 w-5 cursor-default items-center justify-center max-md:h-6 max-md:w-6"
+	class={['flex cursor-default items-center justify-center', className]}
 	data-context-gauge-trigger
 	onclick={gaugeTriggerClick}
 	onkeydown={gaugeTriggerKeydown}
@@ -57,6 +66,6 @@
 	<ContextGaugeDial
 		level={gauge.colorLevel}
 		percent={gauge.contextPercent}
-		size={deviceStore.isMobile ? 'md' : 'sm'}
+		size={corner ? 'key' : deviceStore.isMobile ? 'md' : 'sm'}
 	/>
 </div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte
index ca520862d..f9f2346af 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte
@@ -1,44 +1,49 @@
 <script lang="ts">
 	import { colorLevelTextClass } from './context-gauge';
-	import type { ColorLevel } from '$lib/enums';
+	import { CONTEXT_GAUGE_DIAL_SIZES } from '$lib/constants';
+	import { ColorLevel } from '$lib/enums';

 	interface Props {
 		percent: number | null;
 		level: ColorLevel;
-		size?: 'sm' | 'md';
+		/** sm inline, md a phone's inline touch target, key the icon of a phone's corner key */
+		size?: keyof typeof CONTEXT_GAUGE_DIAL_SIZES;
 	}

 	let { level, percent, size = 'sm' }: Props = $props();

-	// the md dial fills its box: the ring would sit inside a padding ring otherwise
-	const RADIUS = $derived(size === 'md' ? 13 : 11);
-	const CIRCUMFERENCE = $derived(2 * Math.PI * RADIUS);
+	const dial = $derived(CONTEXT_GAUGE_DIAL_SIZES[size]);
+	const center = $derived(dial.grid / 2);
+	const circumference = $derived(2 * Math.PI * dial.radius);

-	const strokeLevelClass = $derived(colorLevelTextClass(level));
-	const dimensions = $derived(size === 'md' ? 'h-6 w-6' : 'h-5 w-5');
-	const strokeWidth = $derived(size === 'md' ? 4 : 3);
+	// on the key, at rest, the arc takes the icon colour of the key, not the muted one
+	const strokeLevelClass = $derived(
+		size === 'key' && level !== ColorLevel.WARNING && level !== ColorLevel.CRITICAL
+			? 'text-accent-foreground'
+			: colorLevelTextClass(level)
+	);
 </script>

-<svg class={dimensions} fill="none" viewBox="0 0 32 32">
+<svg class={dial.sizeClass} fill="none" viewBox="0 0 {dial.grid} {dial.grid}">
 	<circle
-		cx="16"
-		cy="16"
-		r={RADIUS}
+		cx={center}
+		cy={center}
+		r={dial.radius}
 		stroke="currentColor"
-		stroke-opacity="0.1"
-		stroke-width={strokeWidth}
+		stroke-opacity={dial.trackOpacity}
+		stroke-width={dial.strokeWidth}
 	/>

 	<circle
 		class="transition-colors duration-300 {strokeLevelClass}"
-		cx="16"
-		cy="16"
-		r={RADIUS}
+		cx={center}
+		cy={center}
+		r={dial.radius}
 		stroke="currentColor"
-		stroke-dasharray={CIRCUMFERENCE}
-		stroke-dashoffset={percent !== null ? CIRCUMFERENCE * (1 - percent / 100) : CIRCUMFERENCE}
+		stroke-dasharray={circumference}
+		stroke-dashoffset={percent !== null ? circumference * (1 - percent / 100) : circumference}
 		stroke-linecap="round"
-		stroke-width={strokeWidth}
-		transform="rotate(-90 16 16)"
+		stroke-width={dial.strokeWidth}
+		transform="rotate(-90 {center} {center})"
 	/>
 </svg>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge.ts b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge.ts
index e0a7f7478..552d0c4b9 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge.ts
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge.ts
@@ -1,4 +1,5 @@
-import { ColorLevel } from '$lib/enums';
+import { ColorLevel, MessageRole } from '$lib/enums';
+import type { ApiProcessingState, DatabaseMessage } from '$lib/types';

 const WARNING_THRESHOLD = 80;
 const CRITICAL_THRESHOLD = 95;
@@ -38,3 +39,42 @@ export function colorLevelBgClass(level: ColorLevel): string {
 			return 'bg-muted';
 	}
 }
+
+/**
+ * Whether the conversation has spent any tokens yet, so the gauge has something to show: the
+ * timings its replies recorded, or the live state of a reply still being generated.
+ */
+export function hasProcessedTokens(
+	messages: DatabaseMessage[],
+	liveState: ApiProcessingState | null
+): boolean {
+	let totalHistoricalTokens = 0;
+
+	for (const m of messages) {
+		if (m.role !== MessageRole.ASSISTANT) continue;
+
+		const timings = m.timings;
+
+		if (!timings) continue;
+
+		const agenticLlm = timings.agentic?.llm;
+
+		if (agenticLlm?.prompt_n != null || agenticLlm?.predicted_n != null) {
+			totalHistoricalTokens += (agenticLlm?.prompt_n ?? 0) + (agenticLlm?.predicted_n ?? 0);
+		} else {
+			totalHistoricalTokens += (timings.prompt_n ?? 0) + (timings.predicted_n ?? 0);
+		}
+	}
+
+	if (totalHistoricalTokens > 0) return true;
+
+	if (!liveState) return false;
+
+	const livePromptTokens = Math.max(
+		liveState.promptTokens ?? 0,
+		liveState.promptProgress?.processed ?? 0
+	);
+	const liveOutputTokens = liveState.outputTokensUsed ?? 0;
+
+	return livePromptTokens > 0 || liveOutputTokens > 0;
+}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte
index c6b8b3b0c..146b3f736 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte
@@ -3,6 +3,7 @@
 	import ChatFormCurrentWorkingDirectoryResultsList from './ChatFormCurrentWorkingDirectoryResultsList.svelte';
 	import { FolderOpen } from '@lucide/svelte';
 	import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
+	import * as Drawer from '$lib/components/ui/drawer';
 	import * as Popover from '$lib/components/ui/popover';
 	import { DEFAULT_MOBILE_BREAKPOINT, HOME_TILDE, SEARCH, UI_DATA_ATTRS } from '$lib/constants';
 	import { BuiltInTool, GlobSearchType, KeyboardKey, ToolSource } from '$lib/enums';
@@ -10,7 +11,7 @@
 	import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
 	import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
 	import { ToolsService } from '$lib/services/tools.service';
-	import { conversationsStore, toolsStore } from '$lib/stores';
+	import { conversationsStore, deviceStore, toolsStore } from '$lib/stores';
 	import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';

 	// Microtask delay so the popover's focus scope tears down first.
@@ -32,6 +33,8 @@
 		onClose?: () => void;
 		/** Fired when the chip is clicked so the host can open the picker. */
 		onOpen?: () => void;
+		/** Renders the chip trigger; off when the host anchors its own trigger. */
+		showChip?: boolean;
 	}

 	let {
@@ -43,7 +46,8 @@
 		onChange,
 		onClose,
 		onOpen,
-		query = $bindable('')
+		query = $bindable(''),
+		showChip = true
 	}: Props = $props();

 	// File System Access API is opt-in (Chrome / Edge / Opera): the popover
@@ -297,93 +301,128 @@
 	const showTooltip = $derived(innerWidth > DEFAULT_MOBILE_BREAKPOINT);
 </script>

-<button
-	class={[
-		'justify-self-start flex min-w-0 w-auto items-center gap-1 mt-1.5 py-1 px-2 backdrop-blur-2xl rounded-md',
-		className
-	]}
-	{disabled}
-	onclick={onOpen}
-	type="button"
->
-	<ChatFormCurrentWorkingDirectoryChip
-		{directory}
+{#if showChip}
+	<button
+		class={[
+			'justify-self-start flex min-w-0 w-auto items-center gap-1 mt-1.5 py-1 px-2 backdrop-blur-2xl rounded-md',
+			className
+		]}
 		{disabled}
-		{homeBase}
-		onClear={handleDismiss}
-		{showTooltip}
-	/>
-</button>
-
-<Popover.Root onOpenChange={handleOpenChange} open={isOpen}>
-	<Popover.Trigger
-		aria-hidden="true"
-		class="pointer-events-none absolute inset-0 opacity-0"
-		tabindex={-1}
+		onclick={onOpen}
+		type="button"
 	>
-		<span class="sr-only">Open working directory picker</span>
-	</Popover.Trigger>
-
-	<Popover.Content
-		align="start"
-		class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl"
-		{customAnchor}
-		onCloseAutoFocus={(event) => event.preventDefault()}
-		onOpenAutoFocus={(event) => event.preventDefault()}
-		onkeydown={handleKeydown}
-		preventScroll={false}
-		side="top"
-		sideOffset={12}
-	>
-		<div class="p-2 min-h-22 flex flex-col justify-between">
-			<SearchInput
-				bind:ref={searchInputRef}
-				bind:value={query}
-				class="w-full"
-				onClose={closePicker}
-				placeholder="Choose working directory"
+		<ChatFormCurrentWorkingDirectoryChip
+			{directory}
+			{disabled}
+			{homeBase}
+			onClear={handleDismiss}
+			{showTooltip}
+		/>
+	</button>
+{/if}
+
+<!-- the drawer's version is roomier: the header's inset, rows spaced for a finger and
+     lined up with the search results, and text sized for a phone -->
+{#snippet picker(inDrawer: boolean)}
+	<div class={inDrawer ? 'flex flex-col gap-1 px-4' : 'p-2 min-h-22 flex flex-col justify-between'}>
+		<SearchInput
+			bind:ref={searchInputRef}
+			bind:value={query}
+			class="w-full"
+			onClose={closePicker}
+			placeholder="Choose working directory"
+		/>
+
+		{#if !fileSearchEnabled}
+			<div class="px-2 py-1.5 text-sm text-muted-foreground">{searchUnavailableMessage}</div>
+		{:else if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
+			<ChatFormCurrentWorkingDirectoryResultsList
+				bind:container={listContainer}
+				error={searchError}
+				hoveredIndex={nav.hoveredIndex}
+				isSearching={search.isSearching}
+				onCommit={commit}
+				onHover={(index) => nav.setHover(index)}
+				rawQuery={query}
+				results={queryResults}
 			/>
-
-			{#if !fileSearchEnabled}
-				<div class="px-2 py-1.5 text-sm text-muted-foreground">{searchUnavailableMessage}</div>
-			{:else if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
-				<ChatFormCurrentWorkingDirectoryResultsList
-					bind:container={listContainer}
-					error={searchError}
-					hoveredIndex={nav.hoveredIndex}
-					isSearching={search.isSearching}
-					onCommit={commit}
-					onHover={(index) => nav.setHover(index)}
-					rawQuery={query}
-					results={queryResults}
-				/>
-			{/if}
-
-			{#if pickerSupported && fileSearchEnabled}
-				<button
-					class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
-					onclick={browseNative}
-					type="button"
+		{/if}
+
+		{#if pickerSupported && fileSearchEnabled}
+			<button
+				class={[
+					'flex cursor-pointer items-center outline-hidden select-none hover:bg-accent hover:text-accent-foreground',
+					inDrawer
+						? 'mt-1 gap-2 rounded-lg px-2 py-2.5 text-[15px]'
+						: '-mt-1 gap-2 rounded-sm px-2 py-1.5 text-sm'
+				]}
+				onclick={browseNative}
+				type="button"
+			>
+				<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
+
+				<span>Browse</span>
+			</button>
+		{/if}
+
+		{#if homeBase && fileSearchEnabled}
+			<div
+				aria-hidden="true"
+				class={['h-px', inDrawer ? 'my-1 bg-border/60' : '-mx-2 my-2 bg-border/20']}
+			></div>
+
+			<span class={['font-mono', inDrawer ? 'px-2 py-2 text-xs' : 'px-2 py-1.5 text-[10px]']}>
+				Searching in:
+
+				<span class="truncate text-muted-foreground/70" title={searchScope}
+					>{abbreviateHome(searchScope, homeBase)}</span
+				>
+			</span>
+		{/if}
+	</div>
+{/snippet}
+
+<!-- a phone picks in a drawer from the screen's bottom; elsewhere a popover above the form -->
+{#if deviceStore.isMobile}
+	<Drawer.Root onOpenChange={handleOpenChange} open={isOpen}>
+		<Drawer.Content class="gap-0" onkeydown={handleKeydown}>
+			<Drawer.Header>
+				<Drawer.Title>Working directory</Drawer.Title>
+
+				<Drawer.Description class="sr-only"
+					>Choose the directory the chat works in</Drawer.Description
 				>
-					<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
-
-					<span>Browse</span>
-				</button>
-			{/if}
-
-			{#if homeBase && fileSearchEnabled}
-				<div aria-hidden="true" class="-mx-2 my-2 h-px bg-border/20"></div>
-
-				<span class="px-2 py-1.5 font-mono text-[10px]">
-					Searching in:
-
-					<span class="truncate text-muted-foreground/70" title={searchScope}
-						>{abbreviateHome(searchScope, homeBase)}</span
-					>
-				</span>
-			{/if}
-		</div>
-	</Popover.Content>
-</Popover.Root>
+			</Drawer.Header>
+
+			<div class="pb-safe-4">
+				{@render picker(true)}
+			</div>
+		</Drawer.Content>
+	</Drawer.Root>
+{:else}
+	<Popover.Root onOpenChange={handleOpenChange} open={isOpen}>
+		<Popover.Trigger
+			aria-hidden="true"
+			class="pointer-events-none absolute inset-0 opacity-0"
+			tabindex={-1}
+		>
+			<span class="sr-only">Open working directory picker</span>
+		</Popover.Trigger>
+
+		<Popover.Content
+			align="start"
+			class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl p-0"
+			{customAnchor}
+			onCloseAutoFocus={(event) => event.preventDefault()}
+			onOpenAutoFocus={(event) => event.preventDefault()}
+			onkeydown={handleKeydown}
+			preventScroll={false}
+			side="top"
+			sideOffset={12}
+		>
+			{@render picker(false)}
+		</Popover.Content>
+	</Popover.Root>
+{/if}

 <svelte:window bind:innerWidth />
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte
index 0c5d8fd7c..3a7022227 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte
@@ -84,7 +84,7 @@
 		<div class="w-full max-w-[80%]">
 			<textarea
 				bind:this={textareaElement}
-				class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
+				class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-base md:text-sm {INPUT_CLASSES}"
 				oninput={(e) => {
 					autoResizeTextarea(e.currentTarget);
 					editCtx.setContent(e.currentTarget.value);
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 f67b610ea..954e7e374 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
@@ -2,9 +2,11 @@
 	import ChatScreenActionScrollDown from './ChatScreenActionScrollDown.svelte';
 	import ChatScreenDialogsAndAlerts from './ChatScreenDialogsAndAlerts.svelte';
 	import ChatScreenGreeting from './ChatScreenGreeting.svelte';
+	import ChatScreenMobileHeader from './ChatScreenMobileHeader.svelte';
 	import { disableScrollHandling, onNavigate } from '$app/navigation';
 	import { page } from '$app/state';
 	import {
+		ChatFormContextGauge,
 		ChatMessages,
 		ChatScreenDragOverlay,
 		ChatScreenForm,
@@ -13,6 +15,7 @@
 		ServerLoadingSplash
 	} from '$lib/components/app';
 	import {
+		CHAT_FORM_BOTTOM_OFFSET,
 		CHAT_FORM_MOVE_IN_EASING,
 		CHAT_FORM_MOVE_IN_MS,
 		CHAT_FORM_MOVE_OUT_EASING,
@@ -25,6 +28,7 @@
 	import { useChatScreenActiveModel } from '$lib/hooks/use-chat-screen-active-model.svelte';
 	import { useChatScreenDragAndDrop } from '$lib/hooks/use-chat-screen-drag-and-drop.svelte';
 	import { useChatScreenFileUpload } from '$lib/hooks/use-chat-screen-file-upload.svelte';
+	import { useShowContextGauge } from '$lib/hooks/use-context-gauge.svelte';
 	import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
 	import { createStickToBottom } from '$lib/hooks/use-stick-to-bottom.svelte';
 	import {
@@ -49,23 +53,25 @@
 	let isFormCentered = $state(untrack(() => isEmpty));
 	let isFormMoving = false;
 	let formWrapper: HTMLElement | undefined = $state();
+	let chatScreenForm: ChatScreenForm | undefined = $state();
 	let activeErrorDialog = $derived(chatStore.errorDialogState);
 	let isServerLoading = $derived(serverStore.loading);
 	let hasPropsError = $derived(!!serverStore.error);
 	let isCurrentConversationLoading = $derived(chatStore.isLoading || chatStore.isStreaming());
 	let chatFormBottomPosition = $derived.by(() => {
-		if (!deviceStore.isMobile) return '1rem';
+		if (!deviceStore.isMobile) return CHAT_FORM_BOTTOM_OFFSET.desktop;

-		if (deviceStore.isStandalone) return '1.5rem';
+		if (deviceStore.isStandalone) return CHAT_FORM_BOTTOM_OFFSET.installedApp;

-		if (deviceStore.isIOSSafari) return '0.25rem';
+		if (deviceStore.isIOSSafari) return CHAT_FORM_BOTTOM_OFFSET.iosSafari;

-		return '0.5rem';
+		return CHAT_FORM_BOTTOM_OFFSET.phoneBrowser;
 	});

 	// The page is the scroller: it follows new content while the reader sits at
 	// the bottom, and otherwise leaves their position alone.
 	const stick = createStickToBottom();
+	const showContextGauge = useShowContextGauge();
 	let stickPin: HTMLDivElement | undefined = $state();
 	const activeModel = useChatScreenActiveModel();
 	const fileUpload = useChatScreenFileUpload({
@@ -234,13 +240,29 @@
 {:else}
 	<div
 		style:--chat-form-bottom-position={chatFormBottomPosition}
-		class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-[calc(100dvh-var(--chat-tabs-offset,0px))] px-4 md:py-0 pt-12 pb-48 md:pb-4"
+		class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-[calc(100dvh-var(--chat-tabs-offset,0px))] px-4 md:py-0 pt-(--page-top) pb-48 md:pb-4"
 		ondragenter={dragAndDrop.dragHandlers.dragenter}
 		ondragleave={dragAndDrop.dragHandlers.dragleave}
 		ondragover={dragAndDrop.dragHandlers.dragover}
 		ondrop={dragAndDrop.dragHandlers.drop}
 		role="main"
 	>
+		<!-- a phone pins the gauge to the top right, across from the menu toggle -->
+		{#if deviceStore.isMobile && showContextGauge.current}
+			<ChatFormContextGauge
+				class="sidebar-reveal-pin-top fixed top-(--corner-control-top) right-4 z-20 size-10 rounded-full bg-muted/60 border border-border/20 shadow-sm backdrop-blur-sm"
+				corner
+			/>
+		{/if}
+
+		<!-- a phone's chat shows its title and working directory between the corner keys; a new
+		     chat has neither yet, and offers the directory in the + menu instead -->
+		{#if deviceStore.isMobile && page.params.id}
+			<ChatScreenMobileHeader
+				onWorkingDirectoryClick={() => chatScreenForm?.openWorkingDirectoryPicker()}
+			/>
+		{/if}
+
 		{#if !isEmpty}
 			<ChatMessages
 				messages={conversationsStore.activeMessages}
@@ -259,12 +281,14 @@
 				// a fade, see moveForm. Its height in the page is the same in both
 				// (the padding stays, the desktop greeting floats above the form),
 				// so the messages never shift while it moves
-				'pointer-events-none md:sticky fixed mt-auto',
+				'pointer-events-none md:sticky fixed mt-auto max-md:z-10',
+				// a phone holds it in place on screen while the page is slid aside
+				deviceStore.isMobile && 'sidebar-reveal-pin-bottom',
 				deviceStore.isStandalone
 					? 'bottom-6 right-4 left-4'
 					: deviceStore.isIOSSafari
-						? 'bottom-1 left-2 right-2'
-						: 'bottom-2 right-2 left-2',
+						? 'bottom-2 left-2 right-2'
+						: 'bottom-3 right-2 left-2',
 				'md:bottom-4',
 				isFormCentered
 					? 'md:translate-y-[calc(-50dvh+7rem)] 2xl:translate-y-[calc(-50dvh+4rem)]'
@@ -280,12 +304,13 @@
 			{/if}

 			<div class="pointer-events-none flex flex-col gap-6 items-center w-full">
-				{#if !stick.atBottom && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
+				{#if stick.farFromBottom && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
 					<ChatScreenActionScrollDown onclick={() => stick.scrollToBottom('smooth')} />
 				{/if}
 			</div>

 			<ChatScreenForm
+				bind:this={chatScreenForm}
 				bind:uploadedFiles={fileUpload.uploadedFiles}
 				class="pointer-events-auto conversation-chat-form"
 				disabled={hasPropsError || chatStore.isEditing()}
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
index d1438434b..e922dc482 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
@@ -4,6 +4,7 @@
 	import { ChatForm } from '$lib/components/app';
 	import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
 	import { deviceStore } from '$lib/stores';
+	import { hapticTap } from '$lib/utils';
 	import { onMount } from 'svelte';

 	interface Props {
@@ -81,6 +82,10 @@

 		if (!chatFormRef?.checkModelSelected()) return;

+		// the message goes: a phone confirms it by feel. Before any await, so it still runs inside
+		// the tap or keypress that sent it, which iOS needs to play the tick
+		hapticTap();
+
 		const messageToSend = message.trim();
 		const filesToSend = [...uploadedFiles];

@@ -106,6 +111,10 @@
 		onFileRemove?.(fileId);
 	}

+	export function openWorkingDirectoryPicker() {
+		chatFormRef?.openWorkingDirectoryPicker();
+	}
+
 	// Auto-focus must not steal focus already claimed elsewhere (e.g. the system
 	// message editor opened just before a navigation)
 	function focusFormUnlessCaptured() {
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenMobileHeader.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenMobileHeader.svelte
new file mode 100644
index 000000000..39f02504f
--- /dev/null
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenMobileHeader.svelte
@@ -0,0 +1,45 @@
+<script lang="ts">
+	import { HOME_TILDE, SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
+	import { conversationsStore, toolsStore } from '$lib/stores';
+	import { lastPathSegment } from '$lib/utils';
+
+	interface Props {
+		/** Opens the chat form's working directory picker. */
+		onWorkingDirectoryClick?: () => void;
+	}
+
+	let { onWorkingDirectoryClick }: Props = $props();
+
+	const title = $derived(conversationsStore.activeConversation?.name || 'New chat');
+	const directory = $derived(
+		conversationsStore.activeConversation?.cwd ?? conversationsStore.preferences.pendingCwd
+	);
+	const showWorkingDirectory = $derived(conversationsStore.preferences.hasEnabledCwdTools());
+	// the folder's own name: the full path does not fit between the corner keys
+	const directoryLabel = $derived(
+		!directory
+			? SET_WORKING_DIRECTORY_LABEL
+			: directory === toolsStore.serverHome
+				? HOME_TILDE
+				: lastPathSegment(directory)
+	);
+</script>
+
+<!-- a phone's chat header, between the menu key and the context gauge: the chat's title and,
+     under it, its working directory, which opens the picker -->
+<div
+	class="sidebar-reveal-pin-top pointer-events-none fixed top-(--corner-control-top) right-16 left-16 z-20 flex h-10 flex-col items-center justify-center text-center"
+>
+	<p class="w-full truncate text-[15px] leading-5 font-semibold">{title}</p>
+
+	{#if showWorkingDirectory}
+		<button
+			class="pointer-events-auto max-w-full truncate text-[13px] leading-4 text-muted-foreground"
+			onclick={onWorkingDirectoryClick}
+			title={directory ?? undefined}
+			type="button"
+		>
+			{directoryLabel}
+		</button>
+	{/if}
+</div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte
index 5435abcf9..d161f0f1d 100644
--- a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte
@@ -150,8 +150,10 @@
 		:global(.loading-icon) {
 			display: block;
 		}
+	}

-		&:is(:hover) {
+	@media (hover: hover) and (pointer: fine) {
+		.stop-button:hover {
 			:global(.stop-icon) {
 				display: block;
 			}
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css
index 0f1560398..d9d18b44c 100644
--- a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css
@@ -132,8 +132,10 @@
 	word-break: break-all;
 }

-.markdown-content :global(a:hover) {
-	color: var(--primary);
+@media (hover: hover) and (pointer: fine) {
+	.markdown-content :global(a:hover) {
+		color: var(--primary);
+	}
 }

 /* Lists */
@@ -334,10 +336,12 @@ div.markdown-user-content :global(.table-wrapper) {
 	transition: all 0.2s ease;
 }

-.markdown-content :global(.copy-code-btn:hover),
-.markdown-content :global(.preview-code-btn:hover),
-.markdown-content :global(.toggle-source-btn:hover) {
-	transform: scale(1.05);
+@media (hover: hover) and (pointer: fine) {
+	.markdown-content :global(.copy-code-btn:hover),
+	.markdown-content :global(.preview-code-btn:hover),
+	.markdown-content :global(.toggle-source-btn:hover) {
+		transform: scale(1.05);
+	}
 }

 .markdown-content :global(.copy-code-btn:active),
@@ -366,8 +370,10 @@ div.markdown-user-content :global(.table-wrapper) {
 	text-decoration: none;
 }

-.markdown-content :global(.mention:hover) {
-	text-decoration: underline;
+@media (hover: hover) and (pointer: fine) {
+	.markdown-content :global(.mention:hover) {
+		text-decoration: underline;
+	}
 }

 .markdown-content :global(.hashtag) {
@@ -376,8 +382,10 @@ div.markdown-user-content :global(.table-wrapper) {
 	text-decoration: none;
 }

-.markdown-content :global(.hashtag:hover) {
-	text-decoration: underline;
+@media (hover: hover) and (pointer: fine) {
+	.markdown-content :global(.hashtag:hover) {
+		text-decoration: underline;
+	}
 }

 /* Advanced table enhancements */
@@ -385,15 +393,17 @@ div.markdown-user-content :global(.table-wrapper) {
 	transition: all 0.2s ease;
 }

-.markdown-content :global(table:hover) {
-	box-shadow:
-		0 4px 6px -1px rgb(0 0 0 / 0.1),
-		0 2px 4px -2px rgb(0 0 0 / 0.1);
-}
+@media (hover: hover) and (pointer: fine) {
+	.markdown-content :global(table:hover) {
+		box-shadow:
+			0 4px 6px -1px rgb(0 0 0 / 0.1),
+			0 2px 4px -2px rgb(0 0 0 / 0.1);
+	}

-.markdown-content :global(th:hover),
-.markdown-content :global(td:hover) {
-	background: var(--muted);
+	.markdown-content :global(th:hover),
+	.markdown-content :global(td:hover) {
+		background: var(--muted);
+	}
 }

 /* Disable hover effects when rendering user messages */
@@ -417,10 +427,12 @@ div.markdown-user-content :global(.table-wrapper) {
 	position: relative;
 }

-.markdown-content :global(blockquote:hover) {
-	border-left-width: 6px;
-	background: var(--muted);
-	transform: translateX(2px);
+@media (hover: hover) and (pointer: fine) {
+	.markdown-content :global(blockquote:hover) {
+		border-left-width: 6px;
+		background: var(--muted);
+		transform: translateX(2px);
+	}
 }

 .markdown-content :global(blockquote::before) {
@@ -539,7 +551,7 @@ div.markdown-user-content :global(.table-wrapper) {
 }

 /* Dark mode adjustments */
-@media (prefers-color-scheme: dark) {
+@media (prefers-color-scheme: dark) and (hover: hover) and (pointer: fine) {
 	.markdown-content :global(blockquote:hover) {
 		background: var(--muted);
 	}
@@ -589,9 +601,11 @@ div.markdown-user-content :global(.table-wrapper) {
 	transition: all 0.2s ease;
 }

-.markdown-content :global(.image-error-link:hover) {
-	background: var(--muted);
-	border-color: var(--primary);
+@media (hover: hover) and (pointer: fine) {
+	.markdown-content :global(.image-error-link:hover) {
+		background: var(--muted);
+		border-color: var(--primary);
+	}
 }

 /* Mermaid and svg blocks share the same block styling */
@@ -624,9 +638,11 @@ div.markdown-user-content :global(.table-wrapper) {
 	display: none;
 }

-.markdown-content :global(pre.mermaid:hover),
-.markdown-content :global(.svg-block:hover) {
-	opacity: 0.85;
+@media (hover: hover) and (pointer: fine) {
+	.markdown-content :global(pre.mermaid:hover),
+	.markdown-content :global(.svg-block:hover) {
+		opacity: 0.85;
+	}
 }

 .markdown-content :global(pre.mermaid svg) {
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogChatAttachmentsPreview.svelte b/tools/ui/src/lib/components/app/dialogs/DialogChatAttachmentsPreview.svelte
index 5ce0de9ae..bac04d817 100644
--- a/tools/ui/src/lib/components/app/dialogs/DialogChatAttachmentsPreview.svelte
+++ b/tools/ui/src/lib/components/app/dialogs/DialogChatAttachmentsPreview.svelte
@@ -68,10 +68,12 @@
 	<Dialog.Portal>
 		<DialogUI.Overlay class="bg-black/85" style="z-index: 1000" />

-		<Dialog.Content class="fixed inset-0 z-[1000] flex flex-col bg-transparent outline-none">
+		<Dialog.Content
+			class="fixed inset-0 z-[1000] flex flex-col pt-safe pb-safe bg-transparent! shadow-none! backdrop-filter-none! outline-none"
+		>
 			<Dialog.Close
 				aria-label="Close"
-				class="absolute top-4 right-4 z-10 cursor-pointer text-white hover:text-gray-400"
+				class="absolute top-safe-4 right-4 z-10 cursor-pointer text-white hover:text-gray-400"
 				onclick={handleClose}
 			>
 				<X class="size-4" />
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogDiscoverModels.svelte b/tools/ui/src/lib/components/app/dialogs/DialogDiscoverModels.svelte
index ed3d651d2..3e09f4cad 100644
--- a/tools/ui/src/lib/components/app/dialogs/DialogDiscoverModels.svelte
+++ b/tools/ui/src/lib/components/app/dialogs/DialogDiscoverModels.svelte
@@ -31,7 +31,7 @@

 <Dialog.Root onOpenChange={handleOpenChange} {open}>
 	<Dialog.Content
-		class="max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! max-md:rounded-none! max-md:border-0! md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-380! flex flex-col p-4 pb-0"
+		class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-380! flex flex-col p-4 pb-0"
 		onOpenAutoFocus={(event) => event.preventDefault()}
 	>
 		<Dialog.Header class="flex flex-row items-center p-2 pr-8">
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpServers.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpServers.svelte
index 1f814d6b8..8ab425c39 100644
--- a/tools/ui/src/lib/components/app/dialogs/DialogMcpServers.svelte
+++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpServers.svelte
@@ -19,7 +19,7 @@

 <Dialog.Root onOpenChange={handleOpenChange} {open}>
 	<Dialog.Content
-		class="max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! max-md:rounded-none! max-md:border-0! md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360! flex flex-col p-4"
+		class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360! flex flex-col p-4"
 		onOpenAutoFocus={(event) => deviceStore.isMobile && event.preventDefault()}
 	>
 		<Dialog.Header>
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogSearch.svelte b/tools/ui/src/lib/components/app/dialogs/DialogSearch.svelte
index 58de5b025..20cfdee1d 100644
--- a/tools/ui/src/lib/components/app/dialogs/DialogSearch.svelte
+++ b/tools/ui/src/lib/components/app/dialogs/DialogSearch.svelte
@@ -11,7 +11,7 @@
 	} from '$lib/constants';
 	import { KeyboardKey, SidebarAction } from '$lib/enums';
 	import { RouterService } from '$lib/services/router.service';
-	import { conversationsStore } from '$lib/stores';
+	import { conversationsStore, deviceStore } from '$lib/stores';
 	import { formatRelativeTime, isIMEComposing } from '$lib/utils';
 	import type { Component } from 'svelte';

@@ -70,8 +70,14 @@
 	);

 	const actionEntries = $derived.by((): SearchEntry[] =>
-		// every sidebar action but Search itself, which is what opened this dialog
-		SIDEBAR_ACTIONS_ITEMS.filter((item) => item.action && item.action !== SidebarAction.SEARCH)
+		// 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)
+		)
 			.map((item) => ({
 				icon: item.icon,
 				id: `action-${item.action}`,
@@ -153,9 +159,108 @@
 	</span>
 {/snippet}

+{#snippet search()}
+	<div class="border-b border-border/30 px-4 py-3 dark:border-border/20">
+		<div class="flex items-center gap-2">
+			<input
+				bind:this={inputRef}
+				bind:value={query}
+				aria-activedescendant={entries.length > 0 ? `search-result-${activeIndex}` : undefined}
+				aria-controls="search-results"
+				aria-expanded="true"
+				autocomplete="off"
+				class="h-10 min-w-0 flex-1 border-0 bg-transparent px-1 text-lg outline-none placeholder:text-muted-foreground focus:outline-none focus-visible:ring-0"
+				oninput={() => (highlighted = 0)}
+				onkeydown={handleKeydown}
+				placeholder="Search"
+				role="combobox"
+				spellcheck={false}
+			/>
+
+			<ActionIcon
+				ariaLabel="Close search"
+				class="size-8 shrink-0 text-muted-foreground"
+				icon={X}
+				iconSize={ICON_CLASS_DEFAULT}
+				onclick={() => handleOpenChange(false)}
+			/>
+		</div>
+	</div>
+
+	<div class="min-h-0 flex-1 overflow-y-auto p-2" id="search-results" role="listbox">
+		{#each sections as section, sectionIndex (section.title)}
+			{@const offset = sections
+				.slice(0, sectionIndex)
+				.reduce((total, previous) => total + previous.entries.length, 0)}
+
+			<div class="flex flex-col gap-0.5 {sectionIndex > 0 ? 'mt-3' : ''}" role="group">
+				<p class="px-2.5 pt-1 pb-1.5 text-xs font-medium text-muted-foreground">
+					{section.title}
+				</p>
+
+				{#each section.entries as entry, entryIndex (entry.id)}
+					{@const index = offset + entryIndex}
+					{@const isActive = index === activeIndex && !deviceStore.isMobile}
+
+					<!-- a phone has no keyboard to move a highlight with: no row is lit there -->
+					<button
+						aria-selected={isActive}
+						class="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm transition-colors max-md:rounded-xl max-md:py-3 max-md:text-base {isActive
+							? 'bg-accent text-accent-foreground'
+							: ''}"
+						id="search-result-{index}"
+						onclick={entry.run}
+						onmousemove={() => (highlighted = index)}
+						role="option"
+						tabindex={-1}
+						type="button"
+					>
+						<entry.icon class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
+
+						<span class="min-w-0 flex-1 truncate">{entry.label}</span>
+
+						{#if entry.pinned}
+							<Pin aria-label="Pinned" class="{ICON_CLASS_SM} shrink-0 text-muted-foreground" />
+						{/if}
+
+						{#if entry.meta}
+							<span class="max-w-[40%] shrink-0 truncate text-xs text-muted-foreground">
+								{entry.meta}
+							</span>
+						{/if}
+
+						{#if entry.keys && !deviceStore.isMobile}
+							<KeyboardShortcutInfo class="opacity-100" keys={entry.keys} />
+						{/if}
+
+						<CornerDownLeft
+							class="{ICON_CLASS_SM} shrink-0 max-md:hidden text-muted-foreground {isActive
+								? 'opacity-100'
+								: 'opacity-0'}"
+						/>
+					</button>
+				{/each}
+			</div>
+		{:else}
+			<p class="px-3 py-12 text-center text-sm text-muted-foreground">{emptyMessage}</p>
+		{/each}
+	</div>
+
+	<div
+		class="flex items-center gap-5 border-t border-border/30 px-4 py-2.5 text-xs text-muted-foreground max-md:hidden dark:border-border/20"
+	>
+		{@render hint('Close', ['Esc'])}
+
+		{@render hint('Navigate', [ArrowUp, ArrowDown])}
+
+		<span class="ml-auto">{@render hint('Open', [CornerDownLeft])}</span>
+	</div>
+{/snippet}
+
+<!-- like every dialog, a phone shows it full screen -->
 <Dialog.Root onOpenChange={handleOpenChange} {open}>
 	<Dialog.Content
-		class="top-[12%] flex h-[min(36rem,76dvh)]! w-full max-w-2xl! translate-y-0 flex-col gap-0 overflow-hidden rounded-xl p-0 max-md:top-4 md:w-full md:max-w-2xl!"
+		class="top-[12%] flex h-[min(36rem,76dvh)]! w-full max-w-2xl! translate-y-0 flex-col gap-0 overflow-hidden rounded-xl p-0"
 		onOpenAutoFocus={(event) => {
 			event.preventDefault();
 			inputRef?.focus();
@@ -163,99 +268,6 @@
 	>
 		<Dialog.Title class="sr-only">Search</Dialog.Title>

-		<div class="border-b border-border/30 px-4 py-3 dark:border-border/20">
-			<div class="flex items-center gap-2">
-				<input
-					bind:this={inputRef}
-					bind:value={query}
-					aria-activedescendant={entries.length > 0 ? `search-result-${activeIndex}` : undefined}
-					aria-controls="search-results"
-					aria-expanded="true"
-					autocomplete="off"
-					class="h-10 min-w-0 flex-1 border-0 bg-transparent px-1 text-lg outline-none placeholder:text-muted-foreground focus:outline-none focus-visible:ring-0"
-					oninput={() => (highlighted = 0)}
-					onkeydown={handleKeydown}
-					placeholder="Search"
-					role="combobox"
-					spellcheck={false}
-				/>
-
-				<ActionIcon
-					ariaLabel="Close search"
-					class="h-8 w-8 shrink-0 text-muted-foreground"
-					icon={X}
-					iconSize={ICON_CLASS_DEFAULT}
-					onclick={() => handleOpenChange(false)}
-				/>
-			</div>
-		</div>
-
-		<div class="min-h-0 flex-1 overflow-y-auto p-2" id="search-results" role="listbox">
-			{#each sections as section, sectionIndex (section.title)}
-				{@const offset = sections
-					.slice(0, sectionIndex)
-					.reduce((total, previous) => total + previous.entries.length, 0)}
-
-				<div class="flex flex-col gap-0.5 {sectionIndex > 0 ? 'mt-3' : ''}" role="group">
-					<p class="px-2.5 pt-1 pb-1.5 text-xs font-medium text-muted-foreground">
-						{section.title}
-					</p>
-
-					{#each section.entries as entry, entryIndex (entry.id)}
-						{@const index = offset + entryIndex}
-						{@const isActive = index === activeIndex}
-
-						<button
-							aria-selected={isActive}
-							class="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm transition-colors {isActive
-								? 'bg-accent text-accent-foreground'
-								: ''}"
-							id="search-result-{index}"
-							onclick={entry.run}
-							onmousemove={() => (highlighted = index)}
-							role="option"
-							tabindex={-1}
-							type="button"
-						>
-							<entry.icon class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
-
-							<span class="min-w-0 flex-1 truncate">{entry.label}</span>
-
-							{#if entry.pinned}
-								<Pin aria-label="Pinned" class="{ICON_CLASS_SM} shrink-0 text-muted-foreground" />
-							{/if}
-
-							{#if entry.meta}
-								<span class="max-w-[40%] shrink-0 truncate text-xs text-muted-foreground">
-									{entry.meta}
-								</span>
-							{/if}
-
-							{#if entry.keys}
-								<KeyboardShortcutInfo class="opacity-100" keys={entry.keys} />
-							{/if}
-
-							<CornerDownLeft
-								class="{ICON_CLASS_SM} shrink-0 text-muted-foreground {isActive
-									? 'opacity-100'
-									: 'opacity-0'}"
-							/>
-						</button>
-					{/each}
-				</div>
-			{:else}
-				<p class="px-3 py-12 text-center text-sm text-muted-foreground">{emptyMessage}</p>
-			{/each}
-		</div>
-
-		<div
-			class="flex items-center gap-5 border-t border-border/30 px-4 py-2.5 text-xs text-muted-foreground max-md:hidden dark:border-border/20"
-		>
-			{@render hint('Close', ['Esc'])}
-
-			{@render hint('Navigate', [ArrowUp, ArrowDown])}
-
-			<span class="ml-auto">{@render hint('Open', [CornerDownLeft])}</span>
-		</div>
+		{@render search()}
 	</Dialog.Content>
 </Dialog.Root>
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogSettingsChat.svelte b/tools/ui/src/lib/components/app/dialogs/DialogSettingsChat.svelte
index 56efc47cf..82749a4e7 100644
--- a/tools/ui/src/lib/components/app/dialogs/DialogSettingsChat.svelte
+++ b/tools/ui/src/lib/components/app/dialogs/DialogSettingsChat.svelte
@@ -21,7 +21,7 @@

 <Dialog.Root onOpenChange={handleOpenChange} {open}>
 	<Dialog.Content
-		class="max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! max-md:rounded-none! max-md:border-0! md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-6xl! flex flex-col p-0 md:p-6 gap-0"
+		class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-6xl! flex flex-col p-0 md:p-6 gap-0"
 		onOpenAutoFocus={(event) => deviceStore.isMobile && event.preventDefault()}
 	>
 		<Dialog.Header class="md:p-0 p-4" showCloseButton={false}>
diff --git a/tools/ui/src/lib/components/app/dialogs/index.ts b/tools/ui/src/lib/components/app/dialogs/index.ts
index 2083e2660..ca46ba753 100644
--- a/tools/ui/src/lib/components/app/dialogs/index.ts
+++ b/tools/ui/src/lib/components/app/dialogs/index.ts
@@ -112,7 +112,8 @@ export { default as DialogConfirmation } from './DialogConfirmation.svelte';
  * **DialogSearch** - Spotlight-style search over chats and sidebar actions
  *
  * Opens over the app with a focused input and a keyboard-driven result list:
- * arrow keys move the highlight, Enter opens, Escape closes. Desktop only; phones use the `/search` page.
+ * arrow keys move the highlight, Enter opens, Escape closes. A phone shows it full screen, like
+ * every dialog.
  */
 export { default as DialogSearch } from './DialogSearch.svelte';

diff --git a/tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte b/tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte
index 9bd275611..3c90d5da5 100644
--- a/tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte
+++ b/tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte
@@ -139,7 +139,7 @@

 					<textarea
 						use:autoResizeTextarea
-						class="flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm leading-5 placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
+						class="flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-base leading-5 md:text-sm placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
 						maxlength={KEY_VALUE_PAIR_VALUE_MAX_LENGTH}
 						onblur={(e) => trimPairValue(index, e.currentTarget.value)}
 						oninput={(e) => {
diff --git a/tools/ui/src/lib/components/app/misc/MenuIcon.svelte b/tools/ui/src/lib/components/app/misc/MenuIcon.svelte
new file mode 100644
index 000000000..da2adecc2
--- /dev/null
+++ b/tools/ui/src/lib/components/app/misc/MenuIcon.svelte
@@ -0,0 +1,18 @@
+<script>
+	let { class: className = '', style = '' } = $props();
+</script>
+
+<svg
+	class={className}
+	fill="none"
+	stroke="currentColor"
+	stroke-linecap="round"
+	stroke-width="2"
+	{style}
+	viewBox="0 0 24 24"
+	xmlns="http://www.w3.org/2000/svg"
+>
+	<path d="M2.5 8.5h19" />
+
+	<path d="M2.5 15.5h12.67" />
+</svg>
diff --git a/tools/ui/src/lib/components/app/misc/index.ts b/tools/ui/src/lib/components/app/misc/index.ts
index a4ee2ebc3..89a3c5313 100644
--- a/tools/ui/src/lib/components/app/misc/index.ts
+++ b/tools/ui/src/lib/components/app/misc/index.ts
@@ -60,6 +60,9 @@ export { default as CodeBlockActions } from './CodeBlockActions.svelte';
  */
 export { default as Logo } from './Logo.svelte';

+/** Two-line menu glyph for the phone sidebar toggle, its lower line two thirds as long. */
+export { default as MenuIcon } from './MenuIcon.svelte';
+
 /** Section with a trigger that expands in place. */
 export { default as CollapsibleSection } from './CollapsibleSection.svelte';
 export {
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelector.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelector.svelte
index 1ed74a3ea..4b53ef6a0 100644
--- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelector.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelector.svelte
@@ -35,11 +35,15 @@
 		useGlobalSelection = false
 	}: Props = $props();

-	// one setting for every model id in the selector: the trigger and the rows
-	let showOrgName = $derived(settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME] ?? true);
-
 	// a phone opens the picker in a drawer, a desktop keeps the anchored dropdown
 	let isMobile = $derived(deviceStore.isMobile);
+
+	// one setting for every model id in the selector: the trigger and the rows. A phone, where
+	// the selector is narrow, drops it too unless its own setting keeps it
+	let showOrgName = $derived(
+		(settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME] ?? true) &&
+			(!isMobile || settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_ON_MOBILE] === true)
+	);
 	let isOffline = $derived(!!serverStore.error);

 	let isOpen = $state(false);
diff --git a/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte b/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
index 49d38d45d..7dd6ea6ad 100644
--- a/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
+++ b/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
@@ -16,12 +16,15 @@
 		triggerClass?: string;
 		actions: MenuAction[];
 		align?: 'start' | 'center' | 'end';
+		/** Anchors the menu to this element instead of its trigger, and keeps it a menu on a phone too, for one opened by a press-and-hold. */
+		anchor?: HTMLElement | null;
 		open?: boolean;
 	}

 	let {
 		actions,
 		align = 'end',
+		anchor = null,
 		open = $bindable(false),
 		triggerClass = '',
 		triggerIcon,
@@ -30,7 +33,7 @@

 	// a phone shows the same actions in a drawer, where a thumb reaches them and every
 	// row is a touch target of its own
-	let isMobile = $derived(deviceStore.isMobile);
+	let isMobile = $derived(deviceStore.isMobile && !anchor);

 	function runAction(action: MenuAction, event: Event) {
 		open = false;
@@ -125,7 +128,7 @@
 			{/if}
 		</Tooltip.Root>

-		<DropdownMenu.Content {align} class="z-[999999] w-48">
+		<DropdownMenu.Content {align} class="z-[999999] w-48" customAnchor={anchor}>
 			<MenuActionsList {actions} kind={MenuKind.DROPDOWN} />
 		</DropdownMenu.Content>
 	</DropdownMenu.Root>
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 df99917ab..850de8b4b 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, X } from '@lucide/svelte';
+	import { PanelLeftClose, PanelLeftOpen } from '@lucide/svelte';
 	import { goto } from '$app/navigation';
 	import { page } from '$app/state';
 	import {
@@ -19,9 +19,11 @@
 		ROUTES,
 		SIDEBAR_COLLAPSE_WIDTH,
 		SIDEBAR_COLLAPSED_WIDTH,
+		SIDEBAR_CONTENT_FADE_MS,
 		SIDEBAR_MAX_WIDTH,
 		SIDEBAR_MIN_WIDTH,
 		SIDEBAR_OVERSTRETCH_PX,
+		SIDEBAR_REVEAL_ACTION_DELAY_MS,
 		SIDEBAR_SETTLE_MS,
 		SIDEBAR_TOGGLE_MS
 	} from '$lib/constants';
@@ -31,7 +33,6 @@
 	import { RouterService } from '$lib/services/router.service';
 	import { chatStore, conversationsStore, deviceStore, settingsStore, uiStore } from '$lib/stores';
 	import { buildConversationTree, remToPx } from '$lib/utils';
-	import { circIn } from 'svelte/easing';
 	import { SvelteSet } from 'svelte/reactivity';
 	import { fade } from 'svelte/transition';

@@ -135,8 +136,6 @@
 	$effect(() => {
 		if (!uiStore.isSidebarExpanded) {
 			if (isSelectionMode) exitSelectionMode();
-
-			cancelMobileCollapse();
 		}
 	});

@@ -149,15 +148,9 @@
 	let currentChatId = $derived(page.params.id);
 	let searchOpen = $state(false);

-	// a phone shows the search page instead of the spotlight overlay
+	// a phone shows the search as a full-height sheet, once the sidebar has stepped aside
 	function openSearch() {
-		if (deviceStore.isMobile) {
-			void goto(ROUTES.SEARCH);
-
-			return;
-		}
-
-		searchOpen = true;
+		afterMobileClose(() => (searchOpen = true));
 	}

 	let isSelectionMode = $state(false);
@@ -300,12 +293,8 @@
 		marquee.rowClick(id, options.shiftKey);
 	}

-	async function selectConversation(id: string) {
-		if (deviceStore.isMobile) {
-			scheduleMobileCollapse();
-		}
-
-		await goto(RouterService.chat(id));
+	function selectConversation(id: string) {
+		afterMobileClose(() => void goto(RouterService.chat(id)));
 	}

 	async function handleEditConversation(id: string) {
@@ -371,24 +360,20 @@
 		chatStore.stopGenerationForChat(id);
 	}

-	let pendingCollapse = $state<ReturnType<typeof setTimeout> | null>(null);
+	/**
+	 * 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
+	 * acts at once.
+	 */
+	function afterMobileClose(run: () => void): void {
+		if (!deviceStore.isMobile || !uiStore.isSidebarExpanded) {
+			run();

-	function scheduleMobileCollapse() {
-		if (pendingCollapse) {
-			clearTimeout(pendingCollapse);
+			return;
 		}

-		pendingCollapse = setTimeout(() => {
-			uiStore.isSidebarExpanded = false;
-			pendingCollapse = null;
-		}, 100);
-	}
-
-	function cancelMobileCollapse() {
-		if (pendingCollapse) {
-			clearTimeout(pendingCollapse);
-			pendingCollapse = null;
-		}
+		uiStore.isSidebarExpanded = false;
+		setTimeout(run, SIDEBAR_REVEAL_ACTION_DELAY_MS);
 	}

 	let asideEl = $state<HTMLElement | null>(null);
@@ -507,22 +492,14 @@

 	/** A dialog owns the screen on a phone, so the expanded list steps aside for it. */
 	function openDialog(open: () => void): void {
-		if (deviceStore.isMobile) {
-			scheduleMobileCollapse();
-		}
-
-		open();
+		afterMobileClose(open);
 	}

 	/** Runs a sidebar action, whether picked from the icon strip, the search dialog or a shortcut. */
 	function handleSidebarAction(action: SidebarAction): void {
 		switch (action) {
 			case SidebarAction.NEW_CHAT:
-				if (deviceStore.isMobile) {
-					scheduleMobileCollapse();
-				}
-
-				void conversationsStore.openNewChat();
+				afterMobileClose(() => void conversationsStore.openNewChat());

 				break;
 			case SidebarAction.SEARCH:
@@ -552,141 +529,123 @@
 	<aside
 		bind:this={asideEl}
 		class={[
-			'fixed md:sticky top-2 left-2 md:left-0 md:ml-2 md:mt-2 pt-2 z-10 md:z-20 w-[calc(100dvw-1rem)]',
-			'md:h-[calc(100dvh-1.125rem)]',
-			uiStore.isSidebarExpanded &&
-				(deviceStore.isStandalone
-					? 'h-[calc(100dvh-2rem)]'
-					: deviceStore.isIOSDevice
-						? 'h-[calc(100dvh-0.5rem)]'
-						: 'h-[calc(100dvh-1rem)]'),
-			'rounded-3xl md:rounded-2xl',
+			'fixed md:sticky top-0 left-0 md:top-2 md:left-0 md:ml-2 md:mt-2 pt-2 z-0 md:z-20 w-(--sidebar-reveal)',
+			'h-dvh md:h-[calc(100dvh-1.125rem)]',
+			// phone: it stays put under the page, which slides aside to uncover it
+			isOnMobile && 'bg-(--sidebar-reveal-bg)',
+			'md:rounded-2xl',
 			'flex flex-col justify-between',
-			'md:transition-[width,padding,margin,background-color,box-shadow,backdrop-filter] duration-200 ease-out',
+			// desktop only: on a phone the background follows the reveal frame by frame
+			'md:transition-[width,padding,margin,background-color,box-shadow,backdrop-filter] md:duration-200 md:ease-out',
 			(uiStore.isSidebarResizing || isSnapping) && 'md:duration-0',
 			isSettling && 'md:duration-300 md:ease-[cubic-bezier(0.34,1.56,0.64,1)]',
 			isDocked && !uiStore.isSidebarExpanded ? 'md:w-12' : 'md:w-(--sidebar-width)',
 			(uiStore.isSidebarExpanded || (isFloating && isPeeking)) &&
-				'md:bg-muted/60 md:backdrop-blur-xl shadow-md',
+				'md:bg-muted/60 md:backdrop-blur-xl max-md:shadow-md',
 			isFloating && 'md:-mr-[calc(var(--sidebar-width)-3rem)]',
 			isStripHidden && 'md:pointer-events-none',
 			uiStore.isSidebarExpanded && 'is-expanded'
 		]}
+		inert={isOnMobile && !uiStore.isSidebarExpanded}
 		onfocusin={peek}
 		onfocusout={handleAsideFocusOut}
 		onmouseleave={() => (isPeeking = false)}
 	>
-		<div class="px-2 flex items-center justify-between">
-			<div
-				class="relative"
-				onmouseenter={() => {
-					logoHovered = true;
-					peek();
-				}}
-				onmouseleave={() => {
-					logoHovered = false;
-					isPeekSuppressed = false;
-				}}
-				role="button"
-				tabindex="0"
-			>
-				<ActionIcon
-					ariaLabel={!uiStore.isSidebarExpanded
-						? 'Expand navigation'
-						: isLogoToggle
-							? 'Collapse navigation'
-							: 'Go to start'}
-					class="{uiStore.isSidebarExpanded
-						? 'bg-muted! md:bg-foreground/5!'
-						: 'bg-transparent!'} md:h-9 md:w-9 h-10 w-10 rounded-full md:hover:bg-foreground/10! pointer-events-auto"
-					href={uiStore.isSidebarExpanded && !isLogoToggle ? ROUTES.START : undefined}
-					icon={isLogoToggle && logoHovered
-						? uiStore.isSidebarExpanded
-							? PanelLeftClose
-							: PanelLeftOpen
-						: Logo}
-					iconSize="h-4.5 w-4.5 md:h-4 md:w-4"
-					onclick={!uiStore.isSidebarExpanded || isLogoToggle ? toggleExpandedMode : undefined}
-					size="lg"
-					tooltip={!uiStore.isSidebarExpanded
-						? 'Open Sidebar'
-						: isLogoToggle
-							? 'Close Sidebar'
-							: undefined}
-					tooltipSide={TooltipSide.RIGHT}
-				/>
-			</div>
-
-			{#if isOnMobile}
+		<!-- phone: inset so the logo lands where the page's menu toggle sits -->
+		<div
+			class={[
+				'flex min-h-0 flex-1 flex-col',
+				isOnMobile && 'sidebar-reveal-content px-2 pt-[calc(var(--corner-control-top)-0.5rem)]'
+			]}
+		>
+			<div class="px-2 flex items-center justify-between">
 				<div
-					in:fade={{ delay: 50, duration: 150, easing: circIn }}
-					out:fade={{ duration: 100 }}
-					class="flex items-center transition-all duration-150 ease-out {!uiStore.isSidebarExpanded
-						? 'opacity-0 h-0!'
-						: ''}"
+					class="relative"
+					onmouseenter={() => {
+						logoHovered = true;
+						peek();
+					}}
+					onmouseleave={() => {
+						logoHovered = false;
+						isPeekSuppressed = false;
+					}}
+					role="button"
+					tabindex="0"
 				>
 					<ActionIcon
-						ariaLabel="Collapse navigation"
-						class="backdrop-blur-none h-10 w-10 rounded-full -mr-1 -mt-1 hover:bg-accent!"
-						icon={X}
-						iconSize="h-4 w-4"
-						onclick={toggleExpandedMode}
+						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"
+						href={uiStore.isSidebarExpanded && !isLogoToggle ? ROUTES.START : undefined}
+						icon={isLogoToggle && logoHovered
+							? uiStore.isSidebarExpanded
+								? PanelLeftClose
+								: PanelLeftOpen
+							: Logo}
+						iconSize="size-4.5 md:size-4"
+						onclick={!uiStore.isSidebarExpanded || isLogoToggle ? toggleExpandedMode : undefined}
 						size="lg"
-						tooltip="Close Sidebar"
-						tooltipSide={TooltipSide.LEFT}
+						tooltip={!uiStore.isSidebarExpanded
+							? 'Open Sidebar'
+							: isLogoToggle
+								? 'Close Sidebar'
+								: undefined}
+						tooltipSide={TooltipSide.RIGHT}
 					/>
 				</div>
-			{/if}
-		</div>
+			</div>

-		<div
-			in:fade={{ duration: 200 }}
-			out:fade={{ duration: 200 }}
-			class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-1 {deviceStore.isMobile
-				? 'transition-[opacity,height] duration-200 ease-out'
-				: 'md:transition-opacity md:duration-150 md:ease-out'} {deviceStore.isMobile &&
-			!uiStore.isSidebarExpanded
-				? 'opacity-0 invisible !h-0'
-				: ''} {isStripHidden ? 'md:opacity-0 md:invisible' : ''}"
-		>
-			<SidebarNavigationActions
-				class="px-2 md:my-3"
-				onAction={handleSidebarAction}
-				onNavigate={() => {
-					if (deviceStore.isMobile) {
-						scheduleMobileCollapse();
-					}
-				}}
-			/>
-
-			<div class="flex min-h-0 flex-1 flex-col overflow-y-auto opacity-(--sidebar-fade)">
-				<SidebarNavigationConversationList
-					{allSelectedArePinned}
-					allVisibleSelected={visibleSelectionStats.visibleCount > 0 &&
-						visibleSelectionStats.selectedVisibleCount === visibleSelectionStats.visibleCount}
-					class="px-2"
-					conversations={conversationsStore.conversations}
-					{currentChatId}
-					{isSelectionMode}
-					onBulkDelete={handleBulkDelete}
-					onBulkExport={handleBulkExport}
-					onBulkPinToggle={handleBulkPinToggle}
-					onCloseSelection={exitSelectionMode}
-					onDelete={handleDeleteConversation}
-					onEdit={handleEditConversation}
-					onEnterSelectionMode={enterSelectionMode}
-					onRowMouseDown={handleRowMouseDown}
-					onSelect={selectConversation}
-					onSelectAllToggle={toggleSelectAllVisible}
-					onSelectionClick={handleSelectionClick}
-					onStop={handleStopGeneration}
-					onToggleSelect={toggleSelected}
-					{pinStateIsMixed}
-					{selectedIds}
-					someVisibleSelected={visibleSelectionStats.selectedVisibleCount > 0 &&
-						visibleSelectionStats.selectedVisibleCount < visibleSelectionStats.visibleCount}
-					visibleCount={visibleSelectionStats.visibleCount}
+			<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
+					? 'md:opacity-0 md:invisible'
+					: ''}"
+			>
+				<SidebarNavigationActions
+					class="px-2 md:my-3"
+					onAction={handleSidebarAction}
+					onNavigate={() => {
+						if (deviceStore.isMobile) {
+							uiStore.isSidebarExpanded = false;
+						}
+					}}
 				/>
+
+				<div class="flex min-h-0 flex-1 flex-col overflow-y-auto opacity-(--sidebar-fade)">
+					<SidebarNavigationConversationList
+						{allSelectedArePinned}
+						allVisibleSelected={visibleSelectionStats.visibleCount > 0 &&
+							visibleSelectionStats.selectedVisibleCount === visibleSelectionStats.visibleCount}
+						class="px-2"
+						conversations={conversationsStore.conversations}
+						{currentChatId}
+						{isSelectionMode}
+						onBulkDelete={handleBulkDelete}
+						onBulkExport={handleBulkExport}
+						onBulkPinToggle={handleBulkPinToggle}
+						onCloseSelection={exitSelectionMode}
+						onDelete={handleDeleteConversation}
+						onEdit={handleEditConversation}
+						onEnterSelectionMode={enterSelectionMode}
+						onRowMouseDown={handleRowMouseDown}
+						onSelect={selectConversation}
+						onSelectAllToggle={toggleSelectAllVisible}
+						onSelectionClick={handleSelectionClick}
+						onStop={handleStopGeneration}
+						onToggleSelect={toggleSelected}
+						{pinStateIsMixed}
+						{selectedIds}
+						someVisibleSelected={visibleSelectionStats.selectedVisibleCount > 0 &&
+							visibleSelectionStats.selectedVisibleCount < visibleSelectionStats.visibleCount}
+						visibleCount={visibleSelectionStats.visibleCount}
+					/>
+				</div>
 			</div>
 		</div>

@@ -749,25 +708,4 @@
 			--size: 1.125rem;
 		}
 	}
-
-	@media (max-width: 768px) {
-		aside {
-			&:not(.is-expanded) {
-				pointer-events: none;
-			}
-		}
-
-		aside.is-expanded::before {
-			content: '';
-			position: fixed;
-			top: -0.5rem;
-			bottom: -0.25rem;
-			left: -0.5rem;
-			right: -0.5rem;
-			z-index: -1;
-			background: var(--background);
-			backdrop-filter: blur(1rem);
-			pointer-events: none;
-		}
-	}
 </style>
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 dc5f25a6f..6dccbb088 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte
@@ -12,6 +12,7 @@
 	import { SidebarAction } from '$lib/enums';
 	import { deviceStore } from '$lib/stores';
 	import type { DesktopIconStripItem } from '$lib/types';
+	import { hapticTap } from '$lib/utils';
 	import type { Component } from 'svelte';
 	import { onMount } from 'svelte';
 	import { circIn } from 'svelte/easing';
@@ -103,7 +104,12 @@
 						? 'bg-accent text-accent-foreground'
 						: ''}"
 					href={item.route}
-					onclick={itemOnClick}
+					onclick={() => {
+						// a phone confirms the tap by feel, as a native list does
+						if (deviceStore.isMobile) hapticTap();
+
+						itemOnClick?.();
+					}}
 					size="default"
 					variant="ghost"
 				>
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 9b329c92a..e804ed91e 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
@@ -19,21 +19,25 @@
 	import {
 		FORK_TREE_DEPTH_PADDING,
 		ICON_CLASS_DEFAULT,
+		ROW_LONG_PRESS_MS,
 		SHORTCUT_DELETE_CONVERSATION,
 		SHORTCUT_EDIT_CONVERSATION,
 		SHORTCUT_EXPORT_CONVERSATION,
 		UI_DATA_ATTRS
 	} from '$lib/constants';
+	import { useLongPress } from '$lib/hooks/use-long-press.svelte';
 	import { RouterService } from '$lib/services/router.service';
 	import {
 		chatStore,
 		conversationsStore,
+		deviceStore,
 		settingsStore,
 		sidebarDragStore,
 		tabsStore
 	} from '$lib/stores';
 	import type { MenuAction } from '$lib/types';
-	import { onMount } from 'svelte';
+	import { hapticTap } from '$lib/utils';
+	import { onMount, tick } from 'svelte';

 	interface Props {
 		isActive?: boolean;
@@ -70,6 +74,20 @@
 	let renderActionsDropdown = $state(false);
 	let dropdownOpen = $state(false);
 	let contextMenuOpen = $state(false);
+	let rowElement = $state<HTMLButtonElement | null>(null);
+
+	// a phone has no three-dot trigger: holding the row opens its menu, anchored to the row
+	const longPress = useLongPress({
+		enabled: () => deviceStore.isMobile && !isSelectionMode,
+		onLongPress: openHoldMenu
+	});
+
+	// the hold itself taps the haptic engine
+	async function openHoldMenu() {
+		renderActionsDropdown = true;
+		await tick();
+		dropdownOpen = true;
+	}

 	let isLoading = $derived(chatStore.getAllLoadingChats().includes(conversation.id));
 	let isLanding = $derived(sidebarDragStore.landingConvId === conversation.id);
@@ -205,6 +223,8 @@
 		} else if ((event.metaKey || event.ctrlKey) && canOpenInNewTab) {
 			openInNewTab();
 		} else {
+			if (deviceStore.isMobile) hapticTap();
+
 			onSelect?.(conversation.id);
 		}
 	}
@@ -256,16 +276,24 @@

 <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 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'
-			: 'text-foreground/75'} {contextMenuOpen ? 'bg-foreground/10' : ''} {isSelected
-			? 'bg-primary/10 hover:bg-primary/15'
-			: ''} {isSelectionMode ? 'is-selection-mode' : ''} px-2"
+		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',
+			isLanding && 'invisible',
+			isActive ? 'bg-foreground/5 text-accent-foreground' : 'text-foreground/75',
+			contextMenuOpen && 'bg-foreground/10',
+			isSelected && 'bg-primary/10 hover:bg-primary/15',
+			isSelectionMode && 'is-selection-mode',
+			// the row sinks while a finger holds it, and stays lit while its menu is open
+			longPress.isPressing && 'scale-[0.97] bg-foreground/10 duration-(--row-hold-ms) ease-out',
+			deviceStore.isMobile && dropdownOpen && 'bg-foreground/10'
+		]}
 		{...{ [UI_DATA_ATTRS.CONVERSATION_ROW]: conversation.id }}
 		onauxclick={handleAuxClick}
 		onclick={(e) => handleSelect(e)}
+		onclickcapture={longPress.handleClickCapture}
+		oncontextmenu={longPress.handleContextMenu}
 		ondragstart={(e) => e.preventDefault()}
 		onfocusin={handleMouseOver}
 		onfocusout={(e) => {
@@ -276,7 +304,14 @@
 		onmousedown={(e) => handleRowMouseDown(e)}
 		onmouseleave={handleMouseLeave}
 		onmouseover={handleMouseOver}
-		onpointerdown={handlePointerDown}
+		onpointercancel={longPress.handlePointerEnd}
+		onpointerdown={(e) => {
+			// a mouse drags the row, a finger holds it for its menu
+			handlePointerDown(e);
+			longPress.handlePointerDown(e);
+		}}
+		onpointermove={longPress.handlePointerMove}
+		onpointerup={longPress.handlePointerEnd}
 	>
 		<div
 			style:padding-left="{depth * FORK_TREE_DEPTH_PADDING}px"
@@ -349,10 +384,12 @@
 		</div>

 		{#if !isSelectionMode && renderActionsDropdown}
-			<div class="actions flex items-center">
+			<!-- a phone opens the menu by holding the row, so it shows no trigger -->
+			<div class="actions flex items-center" hidden={deviceStore.isMobile}>
 				<DropdownMenuActions
 					bind:open={dropdownOpen}
 					{actions}
+					anchor={deviceStore.isMobile ? rowElement : null}
 					triggerIcon={MoreHorizontal}
 					triggerTooltip="More actions"
 				/>
@@ -363,20 +400,6 @@

 <style>
 	button {
-		:global([data-slot='dropdown-menu-trigger']:not([data-state='open'])) {
-			opacity: 0;
-		}
-
-		&:is(:hover) :global([data-slot='dropdown-menu-trigger']),
-		&:focus-within :global([data-slot='dropdown-menu-trigger']) {
-			opacity: 1;
-		}
-		@media (max-width: 768px) {
-			:global([data-slot='dropdown-menu-trigger']) {
-				opacity: 1 !important;
-			}
-		}
-
 		&.is-selection-mode :global([data-slot='dropdown-menu-trigger']) {
 			display: none !important;
 		}
@@ -390,14 +413,27 @@
 				display: block;
 			}
 		}
+	}

-		&:is(:hover) .stop-button {
-			:global(.stop-icon) {
-				display: block;
-			}
+	/* the row's menu waits for a hover where there is one; a touch screen shows it all along */
+	@media (hover: hover) and (pointer: fine) {
+		button :global([data-slot='dropdown-menu-trigger']:not([data-state='open'])) {
+			opacity: 0;
+		}

-			:global(.loading-icon) {
-				display: none;
+		button:is(:hover, :focus-within) :global([data-slot='dropdown-menu-trigger']) {
+			opacity: 1;
+		}
+
+		button:hover {
+			.stop-button {
+				:global(.stop-icon) {
+					display: block;
+				}
+
+				:global(.loading-icon) {
+					display: none;
+				}
 			}
 		}
 	}
diff --git a/tools/ui/src/lib/components/app/navigation/index.ts b/tools/ui/src/lib/components/app/navigation/index.ts
index 1ebf08026..4978155c3 100644
--- a/tools/ui/src/lib/components/app/navigation/index.ts
+++ b/tools/ui/src/lib/components/app/navigation/index.ts
@@ -49,6 +49,7 @@ export { default as DropdownMenuSearchable } from './DropdownMenuSearchable.svel
  * - Destructive variant styling
  * - Keyboard shortcut display
  * - Separator support between groups
+ * - A drawer on a phone, unless `anchor` pins the menu to an element (a held row)
  *
  * @example
  * ```svelte
@@ -124,7 +125,8 @@ export { default as SidebarNavigation } from './SidebarNavigation/SidebarNavigat
  * Single conversation item in sidebar. Displays conversation title (truncated),
  * last message preview, and timestamp. Shows context menu on right-click with
  * rename and delete options. Highlights when active (matches current route).
- * Handles click to navigate and keyboard accessibility.
+ * Handles click to navigate and keyboard accessibility. On a phone, holding the
+ * row opens its actions menu anchored below it, in place of the three-dot trigger.
  */
 export { default as SidebarNavigationConversationItem } from './SidebarNavigation/SidebarNavigationConversationItem.svelte';

@@ -186,7 +188,7 @@ export { default as SidebarNavigationActions } from './SidebarNavigation/Sidebar
  * Pure-presentational rendering of the search-mode subtree: "Search results"
  * header, the matching items rendered through {@link SidebarNavigationConversationItem},
  * and contextual empty-state messages. Used as the body of the mobile `/search`
- * route; desktop search goes through {@link DialogSearch}.
+ * route; the sidebar's search opens {@link DialogSearch}.
  *
  * The caller is expected to provide an already-filtered list via
  * `filteredConversations` and a `searchQuery` for the empty-state messages.
diff --git a/tools/ui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/tools/ui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
index 496c2dbe3..e8763f76f 100644
--- a/tools/ui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
+++ b/tools/ui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
@@ -19,15 +19,13 @@
 	<AlertDialogPrimitive.Content
 		bind:ref
 		class={cn(
-			'fixed z-[999999] grid w-full gap-4 border border-border bg-popover p-6 text-popover-foreground shadow-md duration-200 dark:border-border/20',
-			// Mobile: Bottom sheet behavior
-			'right-0 bottom-0 left-0 max-h-[100dvh] translate-x-0 translate-y-0 overflow-y-auto rounded-t-xl',
-			'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:slide-out-to-bottom-full',
-			'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-bottom-full',
-			// Desktop: Centered dialog behavior
-			'sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-h-[100vh] sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-xl',
-			'sm:data-[state=closed]:slide-out-to-bottom-0 sm:data-[state=closed]:zoom-out-95',
-			'sm:data-[state=open]:slide-in-from-bottom-0 sm:data-[state=open]:zoom-in-95',
+			'fixed z-[999999] grid gap-4 border border-border bg-popover p-6 text-popover-foreground shadow-md duration-200 dark:border-border/20',
+			// centered on every screen: an alert is short, so a phone shows it as a card of its
+			// own height rather than as a drawer from the bottom, which it is not. The phone's
+			// 90% is a width, so a caller's max-w-* still caps it on wider screens
+			'top-[50%] left-[50%] max-h-[calc(100dvh-2rem)] w-[90%] translate-x-[-50%] translate-y-[-50%] overflow-y-auto rounded-xl sm:w-full sm:max-w-lg',
+			'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:fill-mode-forwards',
+			'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
 			className
 		)}
 		data-slot="alert-dialog-content"
diff --git a/tools/ui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/tools/ui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
index c4e9dda55..e4fb3c7de 100644
--- a/tools/ui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
+++ b/tools/ui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
@@ -12,10 +12,7 @@

 <div
 	bind:this={ref}
-	class={cn(
-		'mt-6 flex flex-row gap-2 sm:mt-0 sm:justify-end [&>*]:flex-1 sm:[&>*]:flex-none',
-		className
-	)}
+	class={cn('flex flex-row gap-2 sm:justify-end [&>*]:flex-1 sm:[&>*]:flex-none', className)}
 	data-slot="alert-dialog-footer"
 	{...restProps}
 >
diff --git a/tools/ui/src/lib/components/ui/dialog/dialog-content.svelte b/tools/ui/src/lib/components/ui/dialog/dialog-content.svelte
index 3f633df5b..973775502 100644
--- a/tools/ui/src/lib/components/ui/dialog/dialog-content.svelte
+++ b/tools/ui/src/lib/components/ui/dialog/dialog-content.svelte
@@ -29,6 +29,10 @@
 		class={cn(
 			`fixed top-[50%] left-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border border-border/30 bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95`,
 			'max-h-[100dvh] max-w-full size-auto sm:max-w-lg md:w-auto md:size-auto md:max-w-[calc(100%-2rem)]',
+			// a phone shows every dialog full screen, whatever size it asks for on a desktop. The
+			// status bar and the home indicator get transparent borders of their own height, so
+			// the content keeps clear of them with no change to its padding (see safe-area.css)
+			'max-md:top-0! max-md:left-0! max-md:translate-x-0! max-md:translate-y-0! max-md:h-dvh! max-md:max-h-none! max-md:w-screen! max-md:max-w-none! max-md:rounded-none! max-md:border-x-0! max-md:border-t-(length:--safe-area-top)! max-md:border-b-(length:--safe-area-bottom)! max-md:border-transparent!',
 			className
 		)}
 		data-slot="dialog-content"
diff --git a/tools/ui/src/lib/constants/chat-form.constants.ts b/tools/ui/src/lib/constants/chat-form.constants.ts
index fcdaf17e7..16bab566c 100644
--- a/tools/ui/src/lib/constants/chat-form.constants.ts
+++ b/tools/ui/src/lib/constants/chat-form.constants.ts
@@ -7,6 +7,18 @@ export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
 export const PROMPT_TRIGGER_PREFIX = '/';
 export const NEW_CHAT_DRAFT_KEY = '__new_chat__';

+/**
+ * How far the chat form floats above the bottom of the screen: a phone browser's toolbar is
+ * already a margin below it (iOS Safari's most of all), and the installed app keeps it clear
+ * of the home indicator, in line with the bottom corner action.
+ */
+export const CHAT_FORM_BOTTOM_OFFSET = {
+	desktop: '1rem',
+	installedApp: '1.5rem',
+	iosSafari: '0.5rem',
+	phoneBrowser: '0.75rem'
+} as const;
+
 /**
  * The chat form moving between its centered (new chat) and bottom positions:
  * it fades out drifting toward where it goes, then fades back in where it
diff --git a/tools/ui/src/lib/constants/context-gauge-dial.constants.ts b/tools/ui/src/lib/constants/context-gauge-dial.constants.ts
new file mode 100644
index 000000000..122f3e02f
--- /dev/null
+++ b/tools/ui/src/lib/constants/context-gauge-dial.constants.ts
@@ -0,0 +1,14 @@
+/**
+ * The context gauge's dial at each size. The sm and md dials draw on a 32 grid, md filling its
+ * box, as the ring would sit inside a padding ring otherwise. The key dial draws on the icon grid
+ * instead (24, a 2px stroke), as wide as the menu key's glyph across from it, so the two keys
+ * read alike, and its track is stronger to show on the key's surface.
+ */
+export const CONTEXT_GAUGE_DIAL_SIZES = {
+	/** the icon of a phone's corner key */
+	key: { grid: 24, radius: 8.5, sizeClass: 'size-6', strokeWidth: 2, trackOpacity: 0.2 },
+	/** a phone's inline touch target */
+	md: { grid: 32, radius: 13, sizeClass: 'size-6', strokeWidth: 4, trackOpacity: 0.1 },
+	/** inline */
+	sm: { grid: 32, radius: 11, sizeClass: 'size-5', strokeWidth: 3, trackOpacity: 0.1 }
+} as const;
diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts
index dde863520..fc6a6c83b 100644
--- a/tools/ui/src/lib/constants/index.ts
+++ b/tools/ui/src/lib/constants/index.ts
@@ -13,6 +13,7 @@ export * from './storage.constants';
 export * from './icons.constants';
 export * from './attachment-menu.constants';
 export * from './auto-scroll.constants';
+export * from './context-gauge-dial.constants';
 export * from './context-gauge-popup.constants';
 export * from './conversation-import.constants';
 export * from './binary-detection.constants';
@@ -45,6 +46,7 @@ export * from './mcp-form.constants';
 export * from './mcp-resource.constants';
 export * from './mention-badge.constants';
 export * from './message-export.constants';
+export * from './microphone.constants';
 export * from './path-display.constants';
 export * from './model-id.constants';
 export * from './model-loading.constants';
diff --git a/tools/ui/src/lib/constants/microphone.constants.ts b/tools/ui/src/lib/constants/microphone.constants.ts
new file mode 100644
index 000000000..170299f9b
--- /dev/null
+++ b/tools/ui/src/lib/constants/microphone.constants.ts
@@ -0,0 +1,10 @@
+/** Why the chat form's microphone can't record, as its button and toast tell the user. */
+export const MICROPHONE_UNAVAILABLE_MESSAGE = {
+	busy: 'The microphone is in use by another app.',
+	denied: "Microphone access was denied. Allow it in the browser's site settings to record.",
+	failed: "The microphone couldn't start.",
+	insecure:
+		'The microphone only works over HTTPS or on localhost. Open this page over HTTPS to record.',
+	notFound: 'No microphone was found.',
+	unsupported: "This browser can't record audio."
+} as const;
diff --git a/tools/ui/src/lib/constants/settings-keys.constants.ts b/tools/ui/src/lib/constants/settings-keys.constants.ts
index 8ba01e9df..d553fb448 100644
--- a/tools/ui/src/lib/constants/settings-keys.constants.ts
+++ b/tools/ui/src/lib/constants/settings-keys.constants.ts
@@ -61,6 +61,7 @@ export const SETTINGS_KEYS = {
 	SHOW_MESSAGE_STATS: 'showMessageStats',
 	SHOW_MODEL_CAPABILITIES_IN_SELECTOR: 'showModelCapabilitiesInSelector',
 	SHOW_MODEL_ORG_NAME: 'showModelOrgName',
+	SHOW_MODEL_ORG_NAME_ON_MOBILE: 'showModelOrgNameOnMobile',
 	SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
 	SHOW_MODEL_TAGS: 'showModelTags',
 	SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
diff --git a/tools/ui/src/lib/constants/settings.constants.ts b/tools/ui/src/lib/constants/settings.constants.ts
index f275f9adf..fc80e94b0 100644
--- a/tools/ui/src/lib/constants/settings.constants.ts
+++ b/tools/ui/src/lib/constants/settings.constants.ts
@@ -334,6 +334,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
 				label: 'Show organization name in model selector',
 				type: SettingsFieldType.CHECKBOX
 			},
+			{
+				defaultValue: false,
+				help: 'Keep the organization name on a phone too, where the model selector is narrow. Applies only while the organization name is shown.',
+				key: SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_ON_MOBILE,
+				label: 'Show organization name on mobile',
+				type: SettingsFieldType.CHECKBOX
+			},
 			{
 				defaultValue: false,
 				help: 'Display the current build version in the bottom-right corner of the interface.',
diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts
index 49e9f8501..46066b25c 100644
--- a/tools/ui/src/lib/constants/ui.constants.ts
+++ b/tools/ui/src/lib/constants/ui.constants.ts
@@ -122,6 +122,31 @@ export const SIDEBAR_OVERSTRETCH_PX = 80;
 export const SIDEBAR_SETTLE_MS = 300;
 /** Duration of the sidebar's open and close slide, matching its `duration-200` transition. */
 export const SIDEBAR_TOGGLE_MS = 200;
+/** The sidebar's contents fading out while a phone's full-screen search covers them, and back in. */
+export const SIDEBAR_CONTENT_FADE_MS = 200;
+
+/** Phone sidebar reveal: the page slides aside by this share of the screen, up to the max width. */
+export const SIDEBAR_REVEAL_RATIO = 0.8;
+export const SIDEBAR_REVEAL_MAX_WIDTH = '20rem';
+/** Duration of the phone sidebar reveal, matching the page's `duration-300` slide. */
+export const SIDEBAR_REVEAL_MS = 300;
+/** Wait after a tap in the phone sidebar closes it, before the tap's page or dialog appears. */
+export const SIDEBAR_REVEAL_ACTION_DELAY_MS = 180;
+/** A swipe that starts this close to the left edge can open the phone sidebar. */
+export const SIDEBAR_REVEAL_EDGE_PX = 24;
+/** Movement before a reveal swipe locks to a horizontal drag. */
+export const SIDEBAR_REVEAL_SLOP_PX = 8;
+/** A rightward swipe this long from anywhere on the page opens the phone sidebar too. */
+export const SIDEBAR_REVEAL_SWIPE_PX = 16;
+/** Share of the reveal a drag must cover to open or close the phone sidebar when let go slowly. */
+export const SIDEBAR_REVEAL_COMMIT_RATIO = 0.3;
+/** Release speed past which a drag opens or closes the phone sidebar the way it was heading. */
+export const SIDEBAR_REVEAL_FLING_PX_PER_MS = 0.3;
+
+/** Hold time before a phone conversation row opens its action menu. */
+export const ROW_LONG_PRESS_MS = 450;
+/** Drift that cancels a row hold, so scrolling still works. */
+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';
@@ -153,3 +178,6 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
 		tooltip: 'Settings'
 	}
 ];
+
+/** A light haptic tap's length where the browser vibrates (Android): a click, not a buzz. */
+export const HAPTIC_TAP_MS = 8;
diff --git a/tools/ui/src/lib/hooks/use-context-gauge.svelte.ts b/tools/ui/src/lib/hooks/use-context-gauge.svelte.ts
index c6d55e393..3e216ff08 100644
--- a/tools/ui/src/lib/hooks/use-context-gauge.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-context-gauge.svelte.ts
@@ -5,10 +5,15 @@
  */

 import { useProcessingState } from './use-processing-state.svelte';
-import { colorLevelFromPercent } from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge';
+import { page } from '$app/state';
+import {
+	colorLevelFromPercent,
+	hasProcessedTokens
+} from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge';
 import { STATS_UNITS } from '$lib/constants';
 import { ColorLevel } from '$lib/enums';
-import { contextStatsStore, modelsStore } from '$lib/stores';
+import { chatStore, contextStatsStore, conversationsStore, modelsStore } from '$lib/stores';
+import type { DatabaseMessage } from '$lib/types';

 export interface UseContextGaugeReturn {
 	readonly activeModelId: string | null;
@@ -46,6 +51,23 @@ function filterTransientDetails(raw: string[]): string[] {
 	});
 }

+/** Whether the open conversation has spent any tokens yet, which is when the gauge shows. */
+export function useShowContextGauge(): { readonly current: boolean } {
+	const show = $derived(
+		Boolean(page.params.id) &&
+			hasProcessedTokens(
+				conversationsStore.activeMessages as DatabaseMessage[],
+				chatStore.isLoading || chatStore.isStreaming() ? chatStore.processing.activeState : null
+			)
+	);
+
+	return {
+		get current() {
+			return show;
+		}
+	};
+}
+
 export function useContextGauge(): UseContextGaugeReturn {
 	const processingState = useProcessingState();

diff --git a/tools/ui/src/lib/hooks/use-long-press.svelte.ts b/tools/ui/src/lib/hooks/use-long-press.svelte.ts
new file mode 100644
index 000000000..932acb9d6
--- /dev/null
+++ b/tools/ui/src/lib/hooks/use-long-press.svelte.ts
@@ -0,0 +1,99 @@
+import { ROW_LONG_PRESS_MS, ROW_LONG_PRESS_SLOP_PX } from '$lib/constants';
+import { hapticTap } from '$lib/utils';
+
+interface LongPressOptions {
+	/** Read on every press, so a hold only counts while this holds. */
+	enabled: () => boolean;
+	onLongPress: () => void;
+}
+
+interface Press {
+	pointerId: number;
+	startX: number;
+	startY: number;
+	timer: ReturnType<typeof setTimeout>;
+}
+
+/**
+ * Touch press-and-hold: a finger that rests on an element fires onLongPress, and the click
+ * that follows the release is swallowed. A drift past the slop is a scroll and cancels it.
+ */
+export function useLongPress({ enabled, onLongPress }: LongPressOptions) {
+	/** True while a finger holds the element and the hold has not fired yet. */
+	let isPressing = $state(false);
+	let press: Press | null = null;
+	let suppressClick = false;
+
+	function cancel() {
+		if (!press) return;
+
+		clearTimeout(press.timer);
+		press = null;
+		isPressing = false;
+	}
+
+	function fire() {
+		press = null;
+		isPressing = false;
+		suppressClick = true;
+		// a hold lands with a tap of the haptic engine, as a native one does (Android: iOS plays
+		// its tick only inside a gesture's handler, and the hold fires from a timer)
+		hapticTap();
+		onLongPress();
+	}
+
+	function handlePointerDown(event: PointerEvent) {
+		suppressClick = false;
+		cancel();
+
+		if (event.pointerType === 'mouse' || !event.isPrimary || !enabled()) return;
+
+		press = {
+			pointerId: event.pointerId,
+			startX: event.clientX,
+			startY: event.clientY,
+			timer: setTimeout(fire, ROW_LONG_PRESS_MS)
+		};
+		isPressing = true;
+	}
+
+	function handlePointerMove(event: PointerEvent) {
+		if (!press || event.pointerId !== press.pointerId) return;
+
+		const drift = Math.hypot(event.clientX - press.startX, event.clientY - press.startY);
+
+		if (drift > ROW_LONG_PRESS_SLOP_PX) cancel();
+	}
+
+	/** Swallows the click that follows a hold. */
+	function handleClickCapture(event: MouseEvent) {
+		if (!suppressClick) return;
+
+		suppressClick = false;
+		event.stopPropagation();
+		event.preventDefault();
+	}
+
+	// Android raises its own context menu for a long touch, which stands in for the hold here
+	function handleContextMenu(event: MouseEvent) {
+		if (!press && !suppressClick) return;
+
+		event.preventDefault();
+
+		if (press) {
+			clearTimeout(press.timer);
+			fire();
+		}
+	}
+
+	return {
+		handleClickCapture,
+		handleContextMenu,
+		handlePointerDown,
+		handlePointerEnd: cancel,
+		handlePointerMove,
+		get isPressing() {
+			return isPressing;
+		}
+	};
+}
diff --git a/tools/ui/src/lib/hooks/use-sidebar-reveal.svelte.ts b/tools/ui/src/lib/hooks/use-sidebar-reveal.svelte.ts
new file mode 100644
index 000000000..1055c064f
--- /dev/null
+++ b/tools/ui/src/lib/hooks/use-sidebar-reveal.svelte.ts
@@ -0,0 +1,341 @@
+import {
+	SIDEBAR_REVEAL_COMMIT_RATIO,
+	SIDEBAR_REVEAL_EDGE_PX,
+	SIDEBAR_REVEAL_FLING_PX_PER_MS,
+	SIDEBAR_REVEAL_MAX_WIDTH,
+	SIDEBAR_REVEAL_MS,
+	SIDEBAR_REVEAL_RATIO,
+	SIDEBAR_REVEAL_SLOP_PX,
+	SIDEBAR_REVEAL_SWIPE_PX
+} from '$lib/constants';
+import { deviceStore, uiStore } from '$lib/stores';
+import { hapticTap, remToPx } from '$lib/utils';
+import { untrack } from 'svelte';
+import { quartOut } from 'svelte/easing';
+import { Tween } from 'svelte/motion';
+
+interface RevealGesture {
+	startX: number;
+	startY: number;
+	pointerId: number;
+	wasExpanded: boolean;
+	locked: boolean;
+	/** Started away from the left edge, so it takes a longer swipe to open the sidebar. */
+	isAnywhere: boolean;
+	lastX: number;
+	lastTime: number;
+	/** Horizontal speed of the latest move in px/ms, positive to the right. */
+	velocity: number;
+}
+
+/** Content a sideways drag already belongs to: text fields and anything that scrolls sideways. */
+function ownsHorizontalDrag(target: EventTarget | null, root: Element): boolean {
+	if (!(target instanceof Element)) return false;
+
+	if (target.closest('input, textarea, [contenteditable="true"]')) return true;
+
+	for (let el: Element | null = target; el && el !== root; el = el.parentElement) {
+		const { overflowX } = getComputedStyle(el);
+
+		if ((overflowX === 'auto' || overflowX === 'scroll') && el.scrollWidth > el.clientWidth) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+const THEME_COLOR_SELECTOR = 'meta[name="theme-color"][data-sidebar-reveal]';
+
+let colorContext: CanvasRenderingContext2D | null = null;
+
+/** Resolves any CSS color (the computed oklab mix among them) to the plain rgb a theme-color takes. */
+function toRgb(color: string): string {
+	colorContext ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
+
+	if (!colorContext) return color;
+
+	colorContext.clearRect(0, 0, 1, 1);
+	colorContext.fillStyle = color;
+	colorContext.fillRect(0, 0, 1, 1);
+
+	const [r, g, b] = colorContext.getImageData(0, 0, 1, 1).data;
+
+	return `rgb(${r}, ${g}, ${b})`;
+}
+
+/**
+ * iOS Safari and the installed app tint their bars from theme-color, not from the page. While the
+ * sidebar is open a theme-color of its own, ahead of the app's ones, follows the page background;
+ * once it is closed again it goes, and the app's own theme-color applies as before.
+ */
+function syncThemeColor(progress: number) {
+	let meta = document.head.querySelector<HTMLMetaElement>(THEME_COLOR_SELECTOR);
+
+	if (progress === 0) {
+		meta?.remove();
+
+		return;
+	}
+
+	if (!meta) {
+		meta = document.createElement('meta');
+		meta.name = 'theme-color';
+		meta.dataset.sidebarReveal = '';
+		document.head.prepend(meta);
+	}
+
+	meta.content = toRgb(getComputedStyle(document.body).backgroundColor);
+}
+
+/**
+ * How much of the page the screen shows, in CSS px. The installed iOS app draws over the whole
+ * screen even while it reports a viewport short of the status bar, which it does for a page
+ * shorter than the screen (see app.css), as a locked page is: its bars are pinned against the
+ * screen, not that viewport. iOS keeps screen sizes in portrait, so the shorter side is the
+ * height in landscape.
+ */
+function visibleHeight(): number {
+	if (!deviceStore.isIOSDevice || !deviceStore.isStandalone) return window.innerHeight;
+
+	const isLandscape = window.matchMedia('(orientation: landscape)').matches;
+	const screenHeight = isLandscape
+		? Math.min(screen.width, screen.height)
+		: Math.max(screen.width, screen.height);
+
+	return Math.max(window.innerHeight, screenHeight);
+}
+
+/**
+ * Phone sidebar reveal: a horizontal drag on the page slides it aside to uncover the sidebar.
+ * A drag from the left edge opens it, as does a longer rightward swipe from anywhere else, which
+ * stays clear of the browser's own edge swipe for back and forward. A drag or tap on the page
+ * closes it.
+ */
+export function useSidebarReveal() {
+	/** Live page offset in px while a finger drags it, null when the store drives it. */
+	let dragOffset = $state<number | null>(null);
+	let gesture: RevealGesture | null = null;
+	let suppressClick = false;
+
+	function revealPx(): number {
+		return Math.min(window.innerWidth * SIDEBAR_REVEAL_RATIO, remToPx(SIDEBAR_REVEAL_MAX_WIDTH));
+	}
+
+	// How far the sidebar is open, which the page and sidebar backgrounds follow (app.css) and
+	// the browser bars with them. It is stepped every frame, since a theme-color cannot transition.
+	const progress = new Tween(0, { duration: SIDEBAR_REVEAL_MS, easing: quartOut });
+
+	$effect(() => {
+		const dragProgress = dragOffset === null ? null : dragOffset / revealPx();
+		const target = deviceStore.isMobile && uiStore.isSidebarExpanded ? 1 : 0;
+
+		untrack(() => {
+			if (dragProgress !== null) void progress.set(dragProgress, { duration: 0 });
+			else void progress.set(target);
+		});
+	});
+
+	$effect(() => {
+		const value = progress.current;
+
+		document.documentElement.style.setProperty('--sidebar-reveal-progress', String(value));
+		syncThemeColor(value);
+	});
+
+	// 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)
+	);
+
+	// 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
+	$effect(() => {
+		const root = document.documentElement;
+
+		if (!isRevealed) {
+			root.style.removeProperty('--sidebar-reveal-pin-top');
+			root.style.removeProperty('--sidebar-reveal-pin-bottom');
+			root.style.overflow = '';
+
+			return;
+		}
+
+		const pin = () => {
+			const scrollBottom = root.scrollHeight - window.scrollY - visibleHeight();
+
+			root.style.setProperty('--sidebar-reveal-pin-top', `${window.scrollY}px`);
+			root.style.setProperty('--sidebar-reveal-pin-bottom', `${Math.max(0, scrollBottom)}px`);
+		};
+
+		pin();
+		root.style.overflow = 'hidden';
+		window.addEventListener('resize', pin);
+
+		return () => window.removeEventListener('resize', pin);
+	});
+
+	// A pointer cannot stop a scroll, and a browser that has started one ignores any later
+	// attempt, deciding on a touch's first move. So a move that leans sideways while a swipe to
+	// open is pending, and every move of a swipe already under way, keeps the page from scrolling
+	// under it; one that leans vertical is left alone, and the swipe gives way to the scroll.
+	$effect(() => {
+		if (!deviceStore.isMobile) return;
+
+		function handleTouchMove(event: TouchEvent) {
+			const touch = event.touches[0];
+
+			if (!gesture || !touch || !event.cancelable) return;
+
+			if (!gesture.locked) {
+				if (gesture.wasExpanded) return;
+
+				const dx = touch.clientX - gesture.startX;
+				const dy = touch.clientY - gesture.startY;
+
+				if (dx <= 0 || Math.abs(dx) <= Math.abs(dy)) return;
+			}
+
+			event.preventDefault();
+		}
+
+		window.addEventListener('touchmove', handleTouchMove, { passive: false });
+
+		return () => window.removeEventListener('touchmove', handleTouchMove);
+	});
+
+	function handlePointerDown(event: PointerEvent) {
+		suppressClick = false;
+
+		if (!deviceStore.isMobile || !event.isPrimary) return;
+
+		const wasExpanded = uiStore.isSidebarExpanded;
+		const isAnywhere = !wasExpanded && event.clientX > SIDEBAR_REVEAL_EDGE_PX;
+
+		if (isAnywhere && ownsHorizontalDrag(event.target, event.currentTarget as Element)) return;
+
+		gesture = {
+			isAnywhere,
+			lastTime: event.timeStamp,
+			lastX: event.clientX,
+			locked: false,
+			pointerId: event.pointerId,
+			startX: event.clientX,
+			startY: event.clientY,
+			velocity: 0,
+			wasExpanded
+		};
+	}
+
+	function handlePointerMove(event: PointerEvent) {
+		if (!gesture || event.pointerId !== gesture.pointerId) return;
+
+		let dx = event.clientX - gesture.startX;
+
+		const dy = event.clientY - gesture.startY;
+
+		if (!gesture.locked) {
+			const slop = gesture.isAnywhere ? SIDEBAR_REVEAL_SWIPE_PX : SIDEBAR_REVEAL_SLOP_PX;
+
+			if (Math.abs(dx) < slop && Math.abs(dy) < slop) return;
+
+			// a vertical move is a scroll, and a closed sidebar only opens to a rightward swipe; one
+			// from anywhere must also lean sideways, as a slanted scroll is not a swipe
+			const verticalShare = gesture.isAnywhere ? 1.5 : 1;
+
+			if (Math.abs(dy) * verticalShare > Math.abs(dx) || (!gesture.wasExpanded && dx < 0)) {
+				gesture = null;
+
+				return;
+			}
+
+			gesture.locked = true;
+
+			// the page follows the finger from here, rather than jumping by the swipe so far
+			if (gesture.isAnywhere) {
+				gesture.startX = event.clientX;
+				dx = 0;
+			}
+
+			(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+		}
+
+		const elapsed = event.timeStamp - gesture.lastTime;
+
+		if (elapsed > 0) gesture.velocity = (event.clientX - gesture.lastX) / elapsed;
+
+		gesture.lastX = event.clientX;
+		gesture.lastTime = event.timeStamp;
+
+		const max = revealPx();
+		const start = gesture.wasExpanded ? max : 0;
+
+		dragOffset = Math.min(max, Math.max(0, start + dx));
+	}
+
+	function handlePointerEnd(event: PointerEvent) {
+		if (!gesture || event.pointerId !== gesture.pointerId) return;
+
+		const { locked, velocity, wasExpanded } = gesture;
+
+		gesture = null;
+
+		if (!locked) {
+			// a tap on the page while the sidebar is open closes it and is not a click on the page
+			if (wasExpanded && event.type === 'pointerup') {
+				suppressClick = true;
+				uiStore.isSidebarExpanded = false;
+			}
+
+			return;
+		}
+
+		suppressClick = true;
+
+		if (dragOffset !== null && event.type === 'pointerup') {
+			// a flick settles the way it was heading; otherwise a short pull is enough to move it
+			// out of the state it started in
+			if (Math.abs(velocity) >= SIDEBAR_REVEAL_FLING_PX_PER_MS) {
+				uiStore.isSidebarExpanded = velocity > 0;
+			} else {
+				const pulled = wasExpanded ? revealPx() - dragOffset : dragOffset;
+
+				uiStore.isSidebarExpanded =
+					wasExpanded !== pulled > revealPx() * SIDEBAR_REVEAL_COMMIT_RATIO;
+			}
+
+			// a swipe that opens the sidebar lands with a tap, as the menu key's does
+			if (!wasExpanded && uiStore.isSidebarExpanded) hapticTap();
+		}
+
+		dragOffset = null;
+	}
+
+	/** Swallows the click that follows a drag or a tap that closed the sidebar. */
+	function handleClickCapture(event: MouseEvent) {
+		if (!suppressClick) return;
+
+		suppressClick = false;
+		event.stopPropagation();
+		event.preventDefault();
+	}
+
+	return {
+		get dragOffset() {
+			return dragOffset;
+		},
+		handleClickCapture,
+		handlePointerCancel: (event: PointerEvent) => handlePointerEnd(event),
+		handlePointerDown,
+		handlePointerMove,
+		handlePointerUp: (event: PointerEvent) => handlePointerEnd(event),
+		/** The page is slid aside, even partly: its contents take no scrolling or taps. */
+		get isRevealed() {
+			return isRevealed;
+		}
+	};
+}
diff --git a/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts b/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts
index 2ba504434..78678854f 100644
--- a/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts
@@ -19,6 +19,8 @@ import { untrack } from 'svelte';

 /** Distance from the bottom that still counts as being at the bottom. */
 const AT_BOTTOM_PX = 24;
+/** Distance from the bottom past which a way back down is offered. */
+const FAR_FROM_BOTTOM_PX = 240;
 /**
  * Growth this soon after a press is the reader's own doing (expanding a block,
  * opening a preview), so it must not drag the viewport down with it.
@@ -29,8 +31,10 @@ const STICK_ATTRIBUTE = 'data-stick-to-bottom';
 const NATIVE_ANCHORING = typeof CSS !== 'undefined' && CSS.supports('overflow-anchor', 'none');

 export class StickToBottom {
-	/** Measured position, which drives the "scroll to bottom" affordance. */
+	/** Measured position: at the bottom, or within AT_BOTTOM_PX of it. */
 	atBottom = $state(true);
+	/** Far enough up that the "scroll to bottom" affordance is worth showing. */
+	farFromBottom = $state(false);
 	/** The reader is at the bottom, so new content should follow. */
 	stick = $state(true);

@@ -107,6 +111,7 @@ export class StickToBottom {
 		el.scrollTo({ behavior, top: el.scrollHeight });
 		this.stick = true;
 		this.atBottom = true;
+		this.farFromBottom = false;
 		// asking for the bottom outranks the press that led here (a sidebar row,
 		// the send button), so the growth that follows is followed
 		this.pressedAt = -Infinity;
@@ -118,8 +123,19 @@ export class StickToBottom {
 		untrack(() => this.syncAnchor());
 	}

+	/**
+	 * The document scroller's clientHeight is iOS's smaller viewport: Safari's with its
+	 * toolbar shown, the installed app's without the status bar. The page scrolls on past it
+	 * by that difference, so at its real bottom it would read as that far above it; the
+	 * window's own height is the room it really shows.
+	 */
 	private distanceFromBottom(el: HTMLElement): number {
-		return el.scrollHeight - el.clientHeight - el.scrollTop;
+		const shown =
+			el === document.documentElement
+				? Math.max(el.clientHeight, window.innerHeight)
+				: el.clientHeight;
+
+		return Math.max(0, el.scrollHeight - shown - el.scrollTop);
 	}

 	private follow(): void {
@@ -135,11 +151,13 @@ export class StickToBottom {
 		if (this.enabled && this.stick && !pressedRecently && !(this.native && atBottom)) {
 			el.scrollTop = el.scrollHeight;
 			this.atBottom = true;
+			this.farFromBottom = false;

 			return;
 		}

 		this.atBottom = atBottom;
+		this.farFromBottom = this.distanceFromBottom(el) > FAR_FROM_BOTTOM_PX;
 	}

 	/**
@@ -158,8 +176,10 @@ export class StickToBottom {

 		const top = el.scrollTop;
 		const height = el.scrollHeight;
+		const distance = this.distanceFromBottom(el);

-		this.atBottom = this.distanceFromBottom(el) <= AT_BOTTOM_PX;
+		this.atBottom = distance <= AT_BOTTOM_PX;
+		this.farFromBottom = distance > FAR_FROM_BOTTOM_PX;

 		if (top < this.lastScrollTop && height >= this.lastScrollHeight) {
 			this.stick = false;
diff --git a/tools/ui/src/lib/styles/safe-area.css b/tools/ui/src/lib/styles/safe-area.css
new file mode 100644
index 000000000..4d031b758
--- /dev/null
+++ b/tools/ui/src/lib/styles/safe-area.css
@@ -0,0 +1,46 @@
+/*
+ * Safe areas. The page runs under the status bar and over the home indicator
+ * (viewport-fit=cover), which an installed iPhone app always shows, so anything
+ * pinned to a screen edge keeps clear of them with these utilities instead of
+ * hand-written calc()s. Each adds the edge's inset (--safe-area-top or
+ * --safe-area-bottom, 0 where there is none) to an optional spacing step:
+ *
+ *   pt-safe       padding-top: the top inset
+ *   pt-safe-3     padding-top: 0.75rem + the top inset
+ *   pb-safe-2     padding-bottom: 0.5rem + the bottom inset
+ *   top-safe-2.5  top: 0.625rem + the top inset, for an absolutely placed control
+ *
+ * They take variants like any utility, e.g. max-md:pt-safe-12.
+ */
+
+@utility pt-safe {
+	padding-top: var(--safe-area-top);
+}
+
+@utility pt-safe-* {
+	padding-top: calc(--spacing(--value(number)) + var(--safe-area-top));
+}
+
+@utility pb-safe {
+	padding-bottom: var(--safe-area-bottom);
+}
+
+@utility pb-safe-* {
+	padding-bottom: calc(--spacing(--value(number)) + var(--safe-area-bottom));
+}
+
+@utility top-safe {
+	top: var(--safe-area-top);
+}
+
+@utility top-safe-* {
+	top: calc(--spacing(--value(number)) + var(--safe-area-top));
+}
+
+@utility bottom-safe {
+	bottom: var(--safe-area-bottom);
+}
+
+@utility bottom-safe-* {
+	bottom: calc(--spacing(--value(number)) + var(--safe-area-bottom));
+}
diff --git a/tools/ui/src/lib/types/chat.d.ts b/tools/ui/src/lib/types/chat.d.ts
index 2ba74e9dc..7527fdac4 100644
--- a/tools/ui/src/lib/types/chat.d.ts
+++ b/tools/ui/src/lib/types/chat.d.ts
@@ -356,4 +356,6 @@ export interface ChatFormActionsContext {
 	readonly hasVisionModality: boolean;
 	onFileUpload?: () => void;
 	onSystemPromptClick?: () => void;
+	/** Opens the working directory picker; set where the + menu offers it (a phone's new chat). */
+	onWorkingDirectoryClick?: () => void;
 }
diff --git a/tools/ui/src/lib/utils/audio-recording.ts b/tools/ui/src/lib/utils/audio-recording.ts
index 4cfe17378..2f3e4106b 100644
--- a/tools/ui/src/lib/utils/audio-recording.ts
+++ b/tools/ui/src/lib/utils/audio-recording.ts
@@ -1,3 +1,4 @@
+import { MICROPHONE_UNAVAILABLE_MESSAGE } from '$lib/constants';
 import { MimeTypeAudio } from '$lib/enums';

 /**
@@ -65,7 +66,8 @@ export class AudioRecorder {
 		} catch (error) {
 			console.error('Failed to start recording:', error);

-			throw new Error('Failed to access microphone. Please check permissions.');
+			// the browser's own error, which says why: see describeMicrophoneError
+			throw error;
 		}
 	}

@@ -261,3 +263,37 @@ export function isAudioRecordingSupported(): boolean {
 		window.MediaRecorder
 	);
 }
+
+/**
+ * Why this page can't record at all, or null when it can: a reason no tap will fix. Browsers
+ * only expose the microphone to a secure context, HTTPS or localhost, so a llama-server
+ * opened over plain HTTP on the local network or over Tailscale can't record.
+ */
+export function microphoneUnavailableReason(): string | null {
+	if (typeof window === 'undefined') return null;
+
+	if (!window.isSecureContext) return MICROPHONE_UNAVAILABLE_MESSAGE.insecure;
+
+	if (!isAudioRecordingSupported()) return MICROPHONE_UNAVAILABLE_MESSAGE.unsupported;
+
+	return null;
+}
+
+/** Why the microphone would not start, from the error the browser raised. */
+export function describeMicrophoneError(error: unknown): string {
+	switch (error instanceof DOMException ? error.name : '') {
+		case 'NotAllowedError':
+		case 'SecurityError':
+			return MICROPHONE_UNAVAILABLE_MESSAGE.denied;
+		case 'NotFoundError':
+		case 'OverconstrainedError':
+			return MICROPHONE_UNAVAILABLE_MESSAGE.notFound;
+		case 'AbortError':
+		case 'NotReadableError':
+			return MICROPHONE_UNAVAILABLE_MESSAGE.busy;
+		default:
+			return error instanceof Error && error.message
+				? `${MICROPHONE_UNAVAILABLE_MESSAGE.failed} ${error.message}`
+				: MICROPHONE_UNAVAILABLE_MESSAGE.failed;
+	}
+}
diff --git a/tools/ui/src/lib/utils/browser-only.ts b/tools/ui/src/lib/utils/browser-only.ts
index 27d2be4aa..5e09b8a1b 100644
--- a/tools/ui/src/lib/utils/browser-only.ts
+++ b/tools/ui/src/lib/utils/browser-only.ts
@@ -11,7 +11,9 @@ export {
 	AudioRecorder,
 	convertToWav,
 	createAudioFile,
-	isAudioRecordingSupported
+	describeMicrophoneError,
+	isAudioRecordingSupported,
+	microphoneUnavailableReason
 } from './audio-recording';

 // PDF processing utilities (pdfjs-dist with DOMMatrix)
diff --git a/tools/ui/src/lib/utils/haptics.ts b/tools/ui/src/lib/utils/haptics.ts
new file mode 100644
index 000000000..cdf5ab21f
--- /dev/null
+++ b/tools/ui/src/lib/utils/haptics.ts
@@ -0,0 +1,32 @@
+import { HAPTIC_TAP_MS } from '$lib/constants';
+
+const COARSE_POINTER_QUERY = '(pointer: coarse)';
+
+/**
+ * A light tap of the phone's haptic engine, the confirmation a native app gives by feel.
+ *
+ * Android browsers vibrate. iOS Safari has no vibration API, but toggling a native switch
+ * control plays the system's switch haptic (iOS 18 and later), so a hidden one is toggled
+ * instead. Either has to run inside the handler of the tap it confirms: called from a timer,
+ * iOS stays silent. A screen without touch gets nothing.
+ */
+export function hapticTap(): void {
+	if (typeof window === 'undefined' || !window.matchMedia(COARSE_POINTER_QUERY).matches) return;
+
+	if (typeof navigator.vibrate === 'function') {
+		navigator.vibrate(HAPTIC_TAP_MS);
+
+		return;
+	}
+
+	const label = document.createElement('label');
+	const input = document.createElement('input');
+
+	input.type = 'checkbox';
+	input.setAttribute('switch', '');
+	label.style.display = 'none';
+	label.append(input);
+	document.body.append(label);
+	label.click();
+	label.remove();
+}
diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index 1f9cffcbe..fb4ee2006 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -132,6 +132,9 @@ export {
 // Portal utilities
 export { portalToBody } from './portal-to-body';

+// Haptic utilities
+export { hapticTap } from './haptics';
+
 // Precision utilities
 export { normalizeFloatingPoint, normalizeNumber } from './precision';

diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte
index 3fc1e46ef..03a46b5a6 100644
--- a/tools/ui/src/routes/+layout.svelte
+++ b/tools/ui/src/routes/+layout.svelte
@@ -4,20 +4,23 @@
 	import { afterNavigate, goto } from '$app/navigation';
 	import { base } from '$app/paths';
 	import { page } from '$app/state';
-	import { SidebarNavigation } from '$lib/components/app';
+	import { ActionIcon, MenuIcon, SidebarNavigation } from '$lib/components/app';
 	import { PwaMetaTags, PwaRefreshAlert } from '$lib/components/pwa';
 	import * as Tooltip from '$lib/components/ui/tooltip';
 	import {
+		BOX_BORDER,
 		FAVICON_PATHS,
 		FAVICON_SELECTORS,
 		HEADERS,
 		MODELS_ROUTE_ID,
 		NEW_CHAT_TAB_ID,
+		ROUTES,
 		SETTINGS_KEYS,
 		TOOLTIP_DELAY_DURATION
 	} from '$lib/constants';
 	import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
 	import { usePwa } from '$lib/hooks/use-pwa.svelte';
+	import { useSidebarReveal } from '$lib/hooks/use-sidebar-reveal.svelte';
 	import { RouterService } from '$lib/services/router.service';
 	import {
 		chatStore,
@@ -32,6 +35,7 @@
 		versionStore
 	} from '$lib/stores';
 	import { initStores } from '$lib/stores/init';
+	import { hapticTap } from '$lib/utils';
 	import { ModeWatcher } from 'mode-watcher';
 	import { untrack } from 'svelte';
 	import { onMount } from 'svelte';
@@ -119,6 +123,27 @@
 		}
 	}

+	// the phone's menu key: opening the sidebar lands with a haptic tap, as a native drawer does
+	function toggleSidebar() {
+		if (!uiStore.isSidebarExpanded) hapticTap();
+
+		uiStore.isSidebarExpanded = !uiStore.isSidebarExpanded;
+	}
+
+	const sidebarReveal = useSidebarReveal();
+
+	const isRevealed = $derived(uiStore.isSidebarExpanded || sidebarReveal.dragOffset !== null);
+
+	// phone only: the page slides aside by the sidebar's width, or follows the finger mid-drag
+	const revealTransform = $derived.by(() => {
+		if (!deviceStore.isMobile) return undefined;
+
+		if (sidebarReveal.dragOffset !== null)
+			return `translate3d(${sidebarReveal.dragOffset}px, 0, 0)`;
+
+		return uiStore.isSidebarExpanded ? 'translateX(var(--sidebar-reveal))' : undefined;
+	});
+
 	// the models page returns to wherever it was reached from, by any kind of navigation
 	afterNavigate(() => uiStore.rememberModelsReturnRoute());

@@ -301,20 +326,129 @@
 <svelte:document onvisibilitychange={handleVisibilityChange} />

 <Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}>
-	<div class="flex flex-col md:flex-row">
+	<!-- clip, not hide: the page slid aside for the phone sidebar must not widen the document,
+		 or iOS pans it sideways and leaves it stuck there -->
+	<div class="flex flex-col overflow-x-clip md:flex-row">
 		<SidebarNavigation />

 		<!-- min-w-0 lets the chat column shrink below its content width, so wide
 		     code blocks and tables scroll inside their own containers instead of
 		     stretching the page into a horizontal scrollbar -->
-		<div class="min-w-0 flex-1">
+		<!-- svelte-ignore a11y_no_static_element_interactions -->
+		<div
+			style:transform={revealTransform}
+			class={[
+				'min-w-0 flex-1',
+				deviceStore.isMobile && 'relative z-10 bg-background',
+				// the installed iOS app sizes the locked, slid page to the screen less its status bar,
+				// so it reaches down by that much more; locked, it cannot scroll by it
+				deviceStore.isMobile &&
+					(sidebarReveal.isRevealed ? 'min-h-[calc(100dvh+var(--safe-area-top))]' : 'min-h-dvh'),
+				deviceStore.isMobile &&
+					sidebarReveal.dragOffset === null &&
+					'transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
+				// slid aside: nothing inside scrolls or takes a tap, which lands on the page itself and
+				// closes the sidebar; forced on every descendant, as the fixed bars opt back into events
+				sidebarReveal.isRevealed && 'touch-none [&_*]:pointer-events-none!'
+			]}
+			onclickcapture={sidebarReveal.handleClickCapture}
+			onpointercancel={sidebarReveal.handlePointerUp}
+			onpointerdown={sidebarReveal.handlePointerDown}
+			onpointermove={sidebarReveal.handlePointerMove}
+			onpointerup={sidebarReveal.handlePointerUp}
+		>
+			<!-- the installed app's status bar sits over the page: what scrolls under it fades out -->
+			{#if deviceStore.isMobile}
+				<div
+					aria-hidden="true"
+					class="status-bar-fade sidebar-reveal-pin-top pointer-events-none fixed inset-x-0 top-0 z-20"
+				></div>
+
+				{#if deviceStore.isIOSSafari}
+					<div
+						aria-hidden="true"
+						class="status-bar-edge sidebar-reveal-pin-top fixed inset-x-0 top-0 z-20"
+					></div>
+
+					<!-- under the chat form, which keeps its taps where the two overlap -->
+					<div
+						aria-hidden="true"
+						class="screen-bottom-edge sidebar-reveal-pin-bottom fixed inset-x-0 bottom-0 z-[6]"
+					></div>
+				{/if}
+
+				<!-- over the content, under the chat form (see its wrapper in ChatScreen) -->
+				<div
+					aria-hidden="true"
+					class="screen-bottom-fade sidebar-reveal-pin-bottom pointer-events-none fixed inset-x-0 bottom-0 z-[5]"
+				></div>
+			{/if}
+
+			<!-- the phone sidebar sits under the page, so its toggle rides on the page -->
+			{#if deviceStore.isMobile && !page.url.hash.includes(ROUTES.SEARCH)}
+				<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"
+					icon={MenuIcon}
+					iconSize="size-6"
+					onclick={toggleSidebar}
+					size="lg"
+				/>
+			{/if}
+
 			{@render children?.()}
 		</div>
+
+		<!-- frames the slid page as a rounded card; its corners are masked in the sidebar tint -->
+		{#if deviceStore.isMobile}
+			<!-- a soft shadow cast on the sidebar beside the slid page, so the two read apart. It lies
+			     above the frame below: the frame paints a band of the sidebar's tint around the page
+			     to round its corners, which would cut the shadow's inner part off at a hard edge. An
+			     outer shadow paints only outside its own rounded shape, so the page stays clear -->
+			<div
+				style:transform={revealTransform}
+				aria-hidden="true"
+				class={[
+					'pointer-events-none fixed inset-0 z-[25] rounded-[2.5rem]',
+					'shadow-[-0.5rem_0_2rem_-0.25rem_rgb(0_0_0/0.12)] dark:shadow-[-0.5rem_0_2rem_-0.25rem_rgb(0_0_0/0.45)]',
+					sidebarReveal.dragOffset === null &&
+						'transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
+					isRevealed
+						? 'opacity-100'
+						: 'opacity-0 [transition-delay:0ms,150ms] [transition-duration:300ms,150ms]'
+				]}
+			></div>
+
+			<div
+				style:transform={revealTransform}
+				aria-hidden="true"
+				class={[
+					'pointer-events-none fixed inset-0 z-20 overflow-hidden',
+					sidebarReveal.dragOffset === null &&
+						'transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
+					// on close the corners hold until the page is nearly home, then fade
+					isRevealed
+						? 'opacity-100'
+						: 'opacity-0 [transition-delay:0ms,150ms] [transition-duration:300ms,150ms]'
+				]}
+			>
+				<div
+					class="absolute inset-0 rounded-[2.5rem] shadow-[0_0_0_2.5rem_var(--sidebar-reveal-bg)] {BOX_BORDER}"
+				></div>
+			</div>
+		{/if}
 	</div>

 	<ModeWatcher />

 	<Toaster closeButton richColors />
+
+	<!-- a debug tool, kept with the tests and loaded on demand, so it stays out of the production bundle -->
+	{#if import.meta.env.DEV && import.meta.env.VITE_DEBUG}
+		{#await import('../../tests/debug/ViewportProbe.svelte') then { default: ViewportProbe }}
+			<ViewportProbe />
+		{/await}
+	{/if}
 </Tooltip.Provider>

 <!-- PWA update prompt + version -->
diff --git a/tools/ui/src/routes/models/+page.svelte b/tools/ui/src/routes/models/+page.svelte
index eb65c75dc..0a0da7c56 100644
--- a/tools/ui/src/routes/models/+page.svelte
+++ b/tools/ui/src/routes/models/+page.svelte
@@ -23,7 +23,9 @@
 	<title>Models - {APP_NAME}</title>
 </svelte:head>

-<div class="mx-auto flex h-[100dvh] w-full max-w-7xl flex-col gap-6 px-4 pt-12 md:px-8 md:pt-6">
+<div
+	class="mx-auto flex h-[100dvh] w-full max-w-7xl flex-col gap-6 px-4 pt-(--page-top) md:px-8 md:pt-6"
+>
 	<div class="flex items-start gap-4">
 		<div class="flex min-w-0 flex-col gap-1">
 			<h1 class="text-2xl font-semibold tracking-tight">Models</h1>
diff --git a/tools/ui/tests/client/components/ChatFormActionRecordWrapper.svelte b/tools/ui/tests/client/components/ChatFormActionRecordWrapper.svelte
new file mode 100644
index 000000000..394bbe52b
--- /dev/null
+++ b/tools/ui/tests/client/components/ChatFormActionRecordWrapper.svelte
@@ -0,0 +1,14 @@
+<script lang="ts">
+	import ChatFormActionRecord from '$lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
+	import * as Tooltip from '$lib/components/ui/tooltip';
+
+	let {
+		hasAudioModality = true,
+		micWarning = null,
+		onMicClick
+	}: { hasAudioModality?: boolean; micWarning?: string | null; onMicClick?: () => void } = $props();
+</script>
+
+<Tooltip.Provider>
+	<ChatFormActionRecord {hasAudioModality} {micWarning} {onMicClick} />
+</Tooltip.Provider>
diff --git a/tools/ui/tests/client/components/ConversationRowHarness.svelte b/tools/ui/tests/client/components/ConversationRowHarness.svelte
new file mode 100644
index 000000000..4c670bec2
--- /dev/null
+++ b/tools/ui/tests/client/components/ConversationRowHarness.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+	import SidebarNavigationConversationItem from '$lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte';
+	import * as Tooltip from '$lib/components/ui/tooltip';
+	import type { ComponentProps } from 'svelte';
+
+	let props: ComponentProps<typeof SidebarNavigationConversationItem> = $props();
+</script>
+
+<!-- the row's menu sits in a tooltip, whose provider the app layout mounts -->
+<Tooltip.Provider>
+	<SidebarNavigationConversationItem {...props} />
+</Tooltip.Provider>
diff --git a/tools/ui/tests/client/conversation-row-hold-mobile.svelte.test.ts b/tools/ui/tests/client/conversation-row-hold-mobile.svelte.test.ts
new file mode 100644
index 000000000..2e0ed19e6
--- /dev/null
+++ b/tools/ui/tests/client/conversation-row-hold-mobile.svelte.test.ts
@@ -0,0 +1,94 @@
+// Guards the phone shape of a conversation row's actions: holding the row opens its menu
+// anchored below it, while a tap still opens the chat and a drift is a scroll.
+
+import ConversationRowHarness from './components/ConversationRowHarness.svelte';
+import { ROW_LONG_PRESS_MS, ROW_LONG_PRESS_SLOP_PX } from '$lib/constants';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { page } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
+
+const PHONE = { height: 844, width: 390 };
+const DESKTOP = { height: 900, width: 1280 };
+const conversation: DatabaseConversation = {
+	currNode: null,
+	id: 'conv-1',
+	lastModified: Date.now(),
+	name: 'Rust borrow checker help'
+};
+
+function touch(row: Element, type: string, dy = 0) {
+	const rect = row.getBoundingClientRect();
+
+	row.dispatchEvent(
+		new PointerEvent(type, {
+			bubbles: true,
+			cancelable: true,
+			clientX: rect.left + 20,
+			clientY: rect.top + 10 + dy,
+			isPrimary: true,
+			pointerId: 1,
+			pointerType: 'touch'
+		})
+	);
+}
+
+function wait(ms: number) {
+	return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+describe('conversation row on a phone', () => {
+	beforeEach(async () => {
+		await page.viewport(PHONE.width, PHONE.height);
+	});
+
+	afterEach(async () => {
+		await page.viewport(DESKTOP.width, DESKTOP.height);
+	});
+
+	it('shows no three-dot trigger', async () => {
+		const screen = render(ConversationRowHarness, { conversation });
+		const row = screen.getByRole('button', { name: conversation.name });
+
+		await row.hover();
+
+		expect(screen.getByRole('button', { name: 'More actions' }).query()).toBeNull();
+	});
+
+	it('opens the actions menu on a hold, without opening the chat', async () => {
+		const onSelect = vi.fn();
+		const screen = render(ConversationRowHarness, { conversation, onSelect });
+		const row = screen.getByRole('button', { name: conversation.name }).element();
+
+		touch(row, 'pointerdown');
+		await wait(ROW_LONG_PRESS_MS + 50);
+		touch(row, 'pointerup');
+		(row as HTMLElement).click();
+
+		await expect.element(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
+		expect(onSelect).not.toHaveBeenCalled();
+	});
+
+	it('treats a drift as a scroll, not a hold', async () => {
+		const screen = render(ConversationRowHarness, { conversation });
+		const row = screen.getByRole('button', { name: conversation.name }).element();
+
+		touch(row, 'pointerdown');
+		touch(row, 'pointermove', ROW_LONG_PRESS_SLOP_PX * 2);
+		await wait(ROW_LONG_PRESS_MS + 50);
+
+		expect(page.getByRole('menuitem').query()).toBeNull();
+	});
+
+	it('opens the chat on a tap', async () => {
+		const onSelect = vi.fn();
+		const screen = render(ConversationRowHarness, { conversation, onSelect });
+		const row = screen.getByRole('button', { name: conversation.name }).element();
+
+		touch(row, 'pointerdown');
+		touch(row, 'pointerup');
+		(row as HTMLElement).click();
+
+		expect(onSelect).toHaveBeenCalledWith(conversation.id);
+		expect(page.getByRole('menuitem').query()).toBeNull();
+	});
+});
diff --git a/tools/ui/tests/client/haptics.svelte.test.ts b/tools/ui/tests/client/haptics.svelte.test.ts
new file mode 100644
index 000000000..acea10517
--- /dev/null
+++ b/tools/ui/tests/client/haptics.svelte.test.ts
@@ -0,0 +1,51 @@
+// Guards hapticTap's three paths: nothing without touch, a short vibration where the browser
+// vibrates (Android), and a toggled hidden switch where it does not (iOS), which leaves no
+// element behind.
+
+import { HAPTIC_TAP_MS } from '$lib/constants';
+import { hapticTap } from '$lib/utils';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+function setCoarsePointer(coarse: boolean) {
+	vi.spyOn(window, 'matchMedia').mockImplementation(
+		(query: string) => ({ matches: coarse, media: query }) as MediaQueryList
+	);
+}
+
+afterEach(() => {
+	vi.restoreAllMocks();
+	vi.unstubAllGlobals();
+});
+
+describe('hapticTap', () => {
+	it('does nothing without a touch screen', () => {
+		setCoarsePointer(false);
+		const vibrate = vi.fn();
+
+		vi.stubGlobal('navigator', { ...navigator, vibrate });
+		hapticTap();
+
+		expect(vibrate).not.toHaveBeenCalled();
+	});
+
+	it('vibrates briefly where the browser can', () => {
+		setCoarsePointer(true);
+		const vibrate = vi.fn();
+
+		vi.stubGlobal('navigator', { ...navigator, vibrate });
+		hapticTap();
+
+		expect(vibrate).toHaveBeenCalledWith(HAPTIC_TAP_MS);
+	});
+
+	it('toggles a hidden switch where it cannot, and removes it', () => {
+		setCoarsePointer(true);
+		vi.stubGlobal('navigator', { ...navigator, vibrate: undefined });
+		const clicks = vi.spyOn(HTMLLabelElement.prototype, 'click');
+
+		hapticTap();
+
+		expect(clicks).toHaveBeenCalledTimes(1);
+		expect(document.querySelector('input[switch]')).toBeNull();
+	});
+});
diff --git a/tools/ui/tests/client/microphone-unavailable.svelte.test.ts b/tools/ui/tests/client/microphone-unavailable.svelte.test.ts
new file mode 100644
index 000000000..7cccfbe80
--- /dev/null
+++ b/tools/ui/tests/client/microphone-unavailable.svelte.test.ts
@@ -0,0 +1,88 @@
+// Guards how the chat form tells why its microphone can't record: a page without HTTPS (a
+// llama-server opened over plain HTTP on the network or over Tailscale) can't reach the
+// microphone at all, the browser's own errors are named, and the button shows the reason
+// while staying tappable, so a tap on a phone, which shows no tooltip, can explain it.
+
+import ChatFormActionRecordWrapper from './components/ChatFormActionRecordWrapper.svelte';
+import { MICROPHONE_UNAVAILABLE_MESSAGE } from '$lib/constants';
+import { describeMicrophoneError, microphoneUnavailableReason } from '$lib/utils/browser-only';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+afterEach(() => {
+	vi.unstubAllGlobals();
+});
+
+describe('microphoneUnavailableReason', () => {
+	it('names HTTPS when the page is not a secure context', () => {
+		vi.stubGlobal('isSecureContext', false);
+
+		expect(microphoneUnavailableReason()).toBe(MICROPHONE_UNAVAILABLE_MESSAGE.insecure);
+	});
+
+	it('names the browser when it has no recorder', () => {
+		vi.stubGlobal('isSecureContext', true);
+		vi.stubGlobal('MediaRecorder', undefined);
+
+		expect(microphoneUnavailableReason()).toBe(MICROPHONE_UNAVAILABLE_MESSAGE.unsupported);
+	});
+
+	it('has nothing to say when the page can record', () => {
+		vi.stubGlobal('isSecureContext', true);
+
+		expect(microphoneUnavailableReason()).toBeNull();
+	});
+});
+
+describe('describeMicrophoneError', () => {
+	it.each([
+		['NotAllowedError', MICROPHONE_UNAVAILABLE_MESSAGE.denied],
+		['SecurityError', MICROPHONE_UNAVAILABLE_MESSAGE.denied],
+		['NotFoundError', MICROPHONE_UNAVAILABLE_MESSAGE.notFound],
+		['NotReadableError', MICROPHONE_UNAVAILABLE_MESSAGE.busy]
+	])('names a %s', (name, message) => {
+		expect(describeMicrophoneError(new DOMException('', name))).toBe(message);
+	});
+
+	it("keeps an unknown error's own message", () => {
+		expect(describeMicrophoneError(new Error('boom'))).toBe(
+			`${MICROPHONE_UNAVAILABLE_MESSAGE.failed} boom`
+		);
+	});
+});
+
+// the tooltip's trigger wraps the button and takes its name, so the button is the inner one
+describe('the record button', () => {
+	it('shows the reason and stays tappable when the microphone cannot record', async () => {
+		const onMicClick = vi.fn();
+		const screen = render(ChatFormActionRecordWrapper, {
+			micWarning: MICROPHONE_UNAVAILABLE_MESSAGE.insecure,
+			onMicClick
+		});
+		const button = screen.getByRole('button', { name: /^Recording unavailable: / }).last();
+
+		await expect.element(button).toBeEnabled();
+		await button.click();
+
+		expect(onMicClick).toHaveBeenCalledOnce();
+	});
+
+	it('starts recording when nothing is wrong', async () => {
+		const screen = render(ChatFormActionRecordWrapper, {});
+
+		await expect
+			.element(screen.getByRole('button', { name: 'Start recording' }).last())
+			.toBeEnabled();
+	});
+
+	it('stays disabled for a model without audio, whatever the microphone', async () => {
+		const screen = render(ChatFormActionRecordWrapper, {
+			hasAudioModality: false,
+			micWarning: MICROPHONE_UNAVAILABLE_MESSAGE.insecure
+		});
+
+		await expect
+			.element(screen.getByRole('button', { name: 'Start recording' }).last())
+			.toBeDisabled();
+	});
+});
diff --git a/tools/ui/tests/client/stick-to-bottom-affordance.svelte.test.ts b/tools/ui/tests/client/stick-to-bottom-affordance.svelte.test.ts
new file mode 100644
index 000000000..1af2f46a1
--- /dev/null
+++ b/tools/ui/tests/client/stick-to-bottom-affordance.svelte.test.ts
@@ -0,0 +1,67 @@
+// Guards when StickToBottom offers the way back down: not for a small step up from the
+// bottom, which still reads as near it, only once the reader is well above it, and
+// never once they are back at the bottom.
+
+import { createStickToBottom } from '$lib/hooks/use-stick-to-bottom.svelte';
+import { afterEach, describe, expect, it } from 'vitest';
+
+let cleanup: (() => void)[] = [];
+
+afterEach(() => {
+	cleanup.forEach((fn) => fn());
+	cleanup = [];
+});
+
+function setup() {
+	const container = document.createElement('div');
+	const content = document.createElement('div');
+
+	container.style.cssText = 'height: 200px; overflow-y: auto;';
+	content.style.height = '1000px';
+	container.append(content);
+	document.body.append(container);
+
+	const stick = createStickToBottom();
+
+	stick.attach(container, content);
+	stick.scrollToBottom();
+	stick.handleScroll();
+	cleanup.push(() => {
+		stick.detach();
+		container.remove();
+	});
+
+	return { container, stick };
+}
+
+function scrollTo(
+	container: HTMLElement,
+	stick: ReturnType<typeof createStickToBottom>,
+	top: number
+) {
+	container.scrollTop = top;
+	stick.handleScroll();
+}
+
+describe('StickToBottom affordance', () => {
+	it('is not offered a small step above the bottom', () => {
+		const { container, stick } = setup();
+
+		scrollTo(container, stick, container.scrollTop - 100);
+
+		expect(stick.atBottom).toBe(false);
+		expect(stick.farFromBottom).toBe(false);
+	});
+
+	it('is offered well above the bottom and withdrawn back at it', () => {
+		const { container, stick } = setup();
+		const bottom = container.scrollTop;
+
+		scrollTo(container, stick, 0);
+		expect(stick.farFromBottom).toBe(true);
+
+		scrollTo(container, stick, bottom);
+		expect(stick.farFromBottom).toBe(false);
+		expect(stick.atBottom).toBe(true);
+	});
+});
diff --git a/tools/ui/tests/debug/ViewportProbe.svelte b/tools/ui/tests/debug/ViewportProbe.svelte
new file mode 100644
index 000000000..aa3f7f2f2
--- /dev/null
+++ b/tools/ui/tests/debug/ViewportProbe.svelte
@@ -0,0 +1,74 @@
+<script lang="ts">
+	/**
+	 * Debug readout of the viewport's heights, for checking layout on a real phone, where each
+	 * browser and the installed app size the viewport units differently. Lists the elements
+	 * that reach past the bottom of the window, which is what makes a page scroll.
+	 * Mounted only in a dev build with VITE_DEBUG set.
+	 */
+	import { onMount } from 'svelte';
+
+	const REFRESH_MS = 1000;
+	const OVERFLOW_ROWS = 4;
+	const CLASS_PREVIEW_LENGTH = 40;
+
+	let lines = $state<string[]>([]);
+
+	/** Resolves a CSS length to px by laying out a hidden box of that height. */
+	function toPx(length: string): number {
+		const el = document.createElement('div');
+
+		el.style.cssText = `position: absolute; visibility: hidden; height: ${length}`;
+		document.body.append(el);
+
+		const height = el.getBoundingClientRect().height;
+
+		el.remove();
+
+		return Math.round(height);
+	}
+
+	/** The deepest elements in the flow that end below the window; fixed ones never scroll it. */
+	function overflowingElements(): string[] {
+		const limit = window.innerHeight;
+
+		return [...document.querySelectorAll<HTMLElement>('body *')]
+			.filter(
+				(el) =>
+					getComputedStyle(el).position !== 'fixed' &&
+					el.getBoundingClientRect().bottom > limit + 0.5
+			)
+			.slice(-OVERFLOW_ROWS)
+			.map((el) => {
+				const { bottom, top } = el.getBoundingClientRect();
+				const classes = (el.getAttribute('class') ?? '').slice(0, CLASS_PREVIEW_LENGTH);
+
+				return `${el.tagName.toLowerCase()} ${Math.round(top)}-${Math.round(bottom)} ${classes}`;
+			});
+	}
+
+	function measure() {
+		const root = document.documentElement;
+
+		lines = [
+			`inner ${window.innerHeight} vv ${Math.round(window.visualViewport?.height ?? 0)} screen ${screen.height}`,
+			`dvh ${toPx('100dvh')} svh ${toPx('100svh')} lvh ${toPx('100lvh')}`,
+			`safeTop ${toPx('env(safe-area-inset-top, 0px)')} safeBottom ${toPx('env(safe-area-inset-bottom, 0px)')}`,
+			`html ${root.clientHeight} scroll ${root.scrollHeight}`,
+			...overflowingElements()
+		];
+	}
+
+	onMount(() => {
+		measure();
+
+		const id = setInterval(measure, REFRESH_MS);
+
+		return () => clearInterval(id);
+	});
+</script>
+
+<pre
+	aria-hidden="true"
+	class="pointer-events-none fixed top-1/3 left-2 z-[99999] rounded bg-black/80 p-2 text-[10px] leading-tight whitespace-pre-wrap text-lime-300">{lines.join(
+		'\n'
+	)}</pre>