Commit b23efaa2e for llama.cpp

commit b23efaa2ef147f547ee75cbf0c621d61904de80e
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date:   Sun Sep 20 07:59:43 2026 +0200

    ui: Fix mobile breakpoint + content overflow issues (#29108)

    * ui : let the chat column shrink below its content width

    The chat column is a flex item, so its automatic minimum size kept it as wide as the widest row inside it. Message rows cap at max-w-3xl plus padding, so a narrower window pushed a page-level horizontal scrollbar.

    Set min-w-0 on the column so the inner scroll containers take over.

    Assisted-by: pi:deepseek-ai/DeepSeek-V4.1-Flash

    * ui : wrap markdown tables in a scroll container

    Markdown tables render as a bare <table>, which keeps its content-driven minimum width and can stretch the chat column past the window. The table-wrapper CSS already existed, but nothing produced the wrapper.

    Add a rehype plugin that wraps each table in div.table-wrapper, following the existing enhance-* plugins.

    Assisted-by: pi:deepseek-ai/DeepSeek-V4.1-Flash

    * ui : scroll long inline content inside markdown blocks

    Long unbreakable content (inline code, paths, hashes) widened the message row and spilled over the neighbour elements. Give each markdown block a horizontal scroll container, and the content root one as well, since the trailing block renders with display: contents and has no box of its own.

    Assisted-by: pi:deepseek-ai/DeepSeek-V4.1-Flash

    * ui : use exact transition properties for markdown images

    transition: all repainted every property and 300ms felt sluggish. Name transform and box-shadow at 200ms ease-out, and gate the hover scale behind (hover: hover) and (pointer: fine) so touch taps do not trigger it.

    Assisted-by: pi:deepseek-ai/DeepSeek-V4.1-Flash

    * ui : fit wide image attachments to the message width

    Attachment thumbnails used a fixed height with w-auto, so a wide image kept its aspect-driven width and, being flex-shrink-0 in a right-aligned bubble, overflowed to the left of the message row.

    Cap the thumbnail with max-height and max-width instead of a fixed height so it scales down proportionally, and let it shrink outside the single-row carousel.

    Assisted-by: pi:deepseek-ai/DeepSeek-V4.1-Flash

    * ui : keep long tool call titles inside the message row

    A tool title could not shrink below its content, so a long path escaped the message row. Let the title span shrink and scroll, and for the file tools put the value on its own line only when it does not fit, with the value as the only scroll container.

    Assisted-by: pi:deepseek-ai/DeepSeek-V4.1-Flash

    * ui : render get info as a collapsible block with a table

    get_info rendered its own always-open row with the values trailing the label. Use the shared ToolCallBlock chrome so it collapses like the other tools, and list os and cwd as table rows with the key as a row header.

    The error and pending states now show inside the body, including the plain-string errors the server tools path produces.

    Assisted-by: pi:deepseek-ai/DeepSeek-V4.1-Flash

    * test : pin the server mode in the add menu a11y story

    The story asserts the add menu's first enabled item is the reasoning submenu, which is mounted only outside router mode. The vitest dev server proxies /props to whichever server is running, so the assertion depended on the machine's server mode and failed whenever a router was up.

    Pin the mode in the story, including props.role so a re-detection cannot flip it back.

    Assisted-by: pi:deepseek-ai/DeepSeek-V4.1-Flash

    * ui: wrap long markdown tokens instead of scrolling every block

    Making each markdown block and the content root a horizontal scroll
    container turns any hover transform into a scrollbar: the blockquote
    translate and the image zoom overflow their block and flash a scrollbar
    under it. Each block also becomes a block formatting context, so the
    paragraph margins stop collapsing across blocks and the spacing doubles.

    Drop both overflow-x rules and let long unbreakable tokens wrap with
    overflow-wrap: break-word on the content root. break-word leaves the
    min-content width untouched, so wide tables and code blocks keep
    scrolling inside their own containers.

    ---------

    Co-authored-by: Pascal <admin@serveurperso.com>

diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte
index 05bd733a2..754856d60 100644
--- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte
@@ -41,6 +41,9 @@
 	}: Props = $props();

 	const scrollClasses = $derived(limitToSingleRow ? 'first:ml-4 last:mr-4' : '');
+	// Carousel items must keep their width; wrapped attachments (message bubbles)
+	// shrink so wide images fit the bubble instead of overflowing it
+	const layoutClasses = $derived(limitToSingleRow ? 'flex-shrink-0' : 'min-w-0');

 	function toMcpResourceAttachment(
 		extra: DatabaseMessageExtraMcpResource,
@@ -92,7 +95,7 @@
 	/>
 {:else if item.isImage && item.preview}
 	<ChatAttachmentsListItemThumbnailImage
-		class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
+		class="{layoutClasses} cursor-pointer {className} {scrollClasses}"
 		height={imageHeight}
 		id={item.id}
 		{imageClass}
diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte
index 34db43339..79a8f115b 100644
--- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte
@@ -34,7 +34,7 @@
 {/snippet}

 <div
-	class="group relative overflow-hidden rounded-lg bg-muted shadow-lg dark:border dark:border-muted {className}"
+	class="group relative min-w-0 overflow-hidden rounded-lg bg-muted shadow-lg dark:border dark:border-muted {className}"
 >
 	{#if onclick}
 		<button
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte
index cc2b4a562..6b0b57328 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte
@@ -48,7 +48,7 @@
 {:else if section.toolName === BuiltInTool.BROWSER_GET_DATETIME}
 	<ChatMessageToolCallBlockGetDatetime {isStreaming} {section} />
 {:else if section.toolName === BuiltInTool.SERVER_GET_INFO}
-	<ChatMessageToolCallBlockGetInfo {isStreaming} {section} />
+	<ChatMessageToolCallBlockGetInfo {isStreaming} {onToggle} {open} {section} />
 {:else if section.toolName === BuiltInTool.SERVER_READ_FILE}
 	<ChatMessageToolCallBlockReadFile {isStreaming} {onToggle} {open} {section} />
 {:else if section.toolName === BuiltInTool.BROWSER_READ_MEDIA}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte
index 22ffc256b..e187ec640 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte
@@ -29,15 +29,19 @@

 <ToolCallBlock {isStreaming} meta={editFileMeta} {onToggle} {open} {section}>
 	{#snippet titleSnippet()}
-		<span class="text-muted-foreground">Edit file </span>
+		<span class="flex min-w-0 flex-wrap items-baseline gap-x-1">
+			<span class="shrink-0 text-muted-foreground">Edit file</span>

-		<span class="font-mono" title={editFileMeta?.filePath}
-			>{abbreviateHome(editFileMeta?.filePath ?? '', home)}</span
-		>
+			<span class="flex min-w-0 items-baseline gap-1.5">
+				<span class="min-w-0 overflow-x-auto font-mono" title={editFileMeta?.filePath}>
+					{abbreviateHome(editFileMeta?.filePath ?? '', home)}
+				</span>

-		{#if editFileMeta?.errorMessage}
-			<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
-		{/if}
+				{#if editFileMeta?.errorMessage}
+					<span class="shrink-0 text-xs italic text-muted-foreground/70">(failed)</span>
+				{/if}
+			</span>
+		</span>
 	{/snippet}

 	{#snippet children(meta, _ctx)}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetInfo.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetInfo.svelte
index bd46b76dc..225d9def9 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetInfo.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetInfo.svelte
@@ -1,20 +1,18 @@
 <script lang="ts">
-	import { Info, Loader2 } from '@lucide/svelte';
-	import { AgenticSectionType } from '$lib/enums';
+	import ToolCallBlock from './ToolCallBlock.svelte';
+	import { XCircle } from '@lucide/svelte';
 	import { toolsStore } from '$lib/stores';
 	import type { AgenticSection } from '$lib/types';
 	import { abbreviateHome } from '$lib/utils';

 	interface Props {
 		section: AgenticSection;
-		isStreaming?: boolean;
+		open: boolean;
+		isStreaming: boolean;
+		onToggle?: () => void;
 	}

-	let { isStreaming = false, section }: Props = $props();
-
-	const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
-	const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
-	const showSpinner = $derived(isPending || (isStreamingCall && isStreaming));
+	let { isStreaming, onToggle, open, section }: Props = $props();

 	type GetInfoMeta = {
 		os?: string;
@@ -50,29 +48,79 @@
 	const cwdDisplay = $derived(abbreviateHome(infoMeta.cwd ?? '', home));
 </script>

-<div class="text-muted-foreground flex items-center gap-2 py-1.5">
-	<Info class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
-
-	{#if showSpinner}
-		<span class="text-foreground/80 text-sm font-medium">Runtime info</span>
-
-		<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
-	{:else if infoMeta.errorMessage}
-		<span class="text-foreground/80 text-sm font-medium">Runtime info&nbsp;</span>
-
-		<span class="text-red-600 text-xs italic dark:text-red-400">-&nbsp;{infoMeta.errorMessage}</span
-		>
-	{:else if infoMeta.os || infoMeta.cwd}
-		<span class="text-foreground/80 text-sm font-medium">Runtime info&nbsp;</span>
-
-		{#if infoMeta.os}
-			<span class="font-mono text-foreground/90 text-sm">{infoMeta.os}</span>
-		{/if}
-
-		{#if infoMeta.cwd}
-			<span class="font-mono text-foreground/90 text-sm" title={infoMeta.cwd}>{cwdDisplay}</span>
+<ToolCallBlock
+	{isStreaming}
+	meta={infoMeta}
+	{onToggle}
+	{open}
+	{section}
+	spinIconWhenActive
+	title="Runtime info"
+>
+	{#snippet children(meta, _ctx)}
+		{#if meta?.errorMessage}
+			<div
+				class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
+			>
+				<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
+
+				<span>{meta.errorMessage}</span>
+			</div>
+		{:else if infoMeta.os || infoMeta.cwd}
+			<table class="w-full table-fixed border-collapse text-sm">
+				<colgroup>
+					<col class="w-12" />
+
+					<col />
+				</colgroup>
+
+				<tbody class="divide-y divide-border/50">
+					{#if infoMeta.os}
+						<tr>
+							<th
+								class="py-1 pr-3 text-left align-baseline text-[11px] font-medium tracking-wide text-muted-foreground/60 uppercase"
+								scope="row"
+							>
+								os
+							</th>
+
+							<td class="py-1 align-baseline">
+								<div class="min-w-0 overflow-x-auto font-mono text-foreground/90">
+									{infoMeta.os}
+								</div>
+							</td>
+						</tr>
+					{/if}
+
+					{#if infoMeta.cwd}
+						<tr>
+							<th
+								class="py-1 pr-3 text-left align-baseline text-[11px] font-medium tracking-wide text-muted-foreground/60 uppercase"
+								scope="row"
+							>
+								cwd
+							</th>
+
+							<td class="py-1 align-baseline">
+								<div
+									class="min-w-0 overflow-x-auto font-mono text-foreground/90"
+									title={infoMeta.cwd}
+								>
+									{cwdDisplay}
+								</div>
+							</td>
+						</tr>
+					{/if}
+				</tbody>
+			</table>
+		{:else if section.toolResult}
+			<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
+				{section.toolResult}
+			</div>
+		{:else}
+			<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
+				Waiting for runtime info...
+			</div>
 		{/if}
-	{:else}
-		<span class="text-foreground/80 text-sm font-medium">Runtime info</span>
-	{/if}
-</div>
+	{/snippet}
+</ToolCallBlock>
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadFile.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadFile.svelte
index 13b440222..1134c8c0f 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadFile.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadFile.svelte
@@ -19,15 +19,19 @@

 <ToolCallBlock {isStreaming} meta={readFileMeta} {onToggle} {open} {section}>
 	{#snippet titleSnippet()}
-		<span class="text-muted-foreground">Read file </span>
+		<span class="flex min-w-0 flex-wrap items-baseline gap-x-1">
+			<span class="shrink-0 text-muted-foreground">Read file</span>

-		<span class="font-mono">{readFileMeta?.fileName}</span>
+			<span class="flex min-w-0 items-baseline gap-1.5">
+				<span class="min-w-0 overflow-x-auto font-mono">{readFileMeta?.fileName}</span>

-		{#if readFileMeta?.lineRange}
-			<span class="text-muted-foreground"
-				>&nbsp;(lines {readFileMeta.lineRange.start}-{readFileMeta.lineRange.end})</span
-			>
-		{/if}
+				{#if readFileMeta?.lineRange}
+					<span class="shrink-0 text-muted-foreground">
+						(lines {readFileMeta.lineRange.start}-{readFileMeta.lineRange.end})
+					</span>
+				{/if}
+			</span>
+		</span>
 	{/snippet}

 	{#snippet children(_meta, _ctx)}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadMedia.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadMedia.svelte
index 93d899018..0948bf623 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadMedia.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadMedia.svelte
@@ -45,9 +45,11 @@

 <ToolCallBlock {isStreaming} meta={readMediaMeta} {onToggle} {open} {section}>
 	{#snippet titleSnippet()}
-		<span class="text-muted-foreground">Read media </span>
+		<span class="flex min-w-0 flex-wrap items-baseline gap-x-1">
+			<span class="shrink-0 text-muted-foreground">Read media</span>

-		<span class="font-mono">{readMediaMeta?.fileName}</span>
+			<span class="min-w-0 overflow-x-auto font-mono">{readMediaMeta?.fileName}</span>
+		</span>
 	{/snippet}

 	{#snippet children(_meta, _ctx)}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte
index cafa5280b..ac2a8e657 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte
@@ -27,15 +27,19 @@

 <ToolCallBlock {isStreaming} meta={writeFileMeta} {onToggle} {open} {section}>
 	{#snippet titleSnippet()}
-		<span class="text-muted-foreground">Write file </span>
+		<span class="flex min-w-0 flex-wrap items-baseline gap-x-1">
+			<span class="shrink-0 text-muted-foreground">Write file</span>

-		<span class="font-mono" title={writeFileMeta?.filePath}
-			>{abbreviateHome(writeFileMeta?.filePath ?? '', home)}</span
-		>
+			<span class="flex min-w-0 items-baseline gap-1.5">
+				<span class="min-w-0 overflow-x-auto font-mono" title={writeFileMeta?.filePath}>
+					{abbreviateHome(writeFileMeta?.filePath ?? '', home)}
+				</span>

-		{#if writeFileMeta?.errorMessage}
-			<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
-		{/if}
+				{#if writeFileMeta?.errorMessage}
+					<span class="shrink-0 text-xs italic text-muted-foreground/70">(failed)</span>
+				{/if}
+			</span>
+		</span>
 	{/snippet}

 	{#snippet children(meta, ctx)}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte
index 65818c64b..569737ac3 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte
@@ -54,7 +54,12 @@

 {#if attachments && attachments.length > 0}
 	<div class="mb-2 max-w-[80%]">
-		<ChatAttachmentsList {attachments} imageHeight="h-40" readonly />
+		<ChatAttachmentsList
+			{attachments}
+			imageHeight="max-h-40"
+			imageWidth="w-auto max-w-full"
+			readonly
+		/>
 	</div>
 {/if}

diff --git a/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte b/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte
index c54b981cd..ecdd75bda 100644
--- a/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte
+++ b/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte
@@ -65,7 +65,12 @@
 				<IconComponent class={cn('shrink-0 text-muted-foreground/60 mt-0.75', iconClass)} />
 			{/if}

-			<span class={cn('text-sm font-medium', shimmerTitle ? 'shimmer-text' : 'text-foreground/80')}>
+			<span
+				class={cn(
+					'min-w-0 overflow-x-auto text-sm font-medium',
+					shimmerTitle ? 'shimmer-text' : 'text-foreground/80'
+				)}
+			>
 				{#if titleSnippet}
 					{@render titleSnippet()}
 				{:else}
diff --git a/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte b/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte
index 0ad6ea61f..610923b9c 100644
--- a/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte
+++ b/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte
@@ -66,7 +66,12 @@
 				<IconComponent class={cn('shrink-0 text-muted-foreground/60 mt-0.5', iconClass)} />
 			{/if}

-			<span class={cn('text-sm font-medium', shimmerTitle ? 'shimmer-text' : 'text-foreground/80')}>
+			<span
+				class={cn(
+					'min-w-0 overflow-x-auto text-sm font-medium',
+					shimmerTitle ? 'shimmer-text' : 'text-foreground/80'
+				)}
+			>
 				{#if titleSnippet}
 					{@render titleSnippet()}
 				{:else}
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 cada489ca..b0ca884ae 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
@@ -1,3 +1,10 @@
+/* Long unbreakable content (inline code, paths, hashes) wraps inside the
+   column; break-word keeps the min-content width intact, so wide tables and
+   code blocks still scroll in their own containers. */
+.markdown-content {
+	overflow-wrap: break-word;
+}
+
 .markdown-block--unstable {
 	display: contents;
 }
@@ -429,15 +436,19 @@ div.markdown-user-content :global(.table-wrapper) {

 /* Enhanced images */
 .markdown-content :global(img) {
-	transition: all 0.3s ease;
+	transition:
+		transform 200ms ease-out,
+		box-shadow 200ms ease-out;
 	cursor: pointer;
 }

-.markdown-content :global(img:hover) {
-	transform: scale(1.02);
-	box-shadow:
-		0 10px 15px -3px rgb(0 0 0 / 0.1),
-		0 4px 6px -4px rgb(0 0 0 / 0.1);
+@media (hover: hover) and (pointer: fine) {
+	.markdown-content :global(img:hover) {
+		transform: scale(1.02);
+		box-shadow:
+			0 10px 15px -3px rgb(0 0 0 / 0.1),
+			0 4px 6px -4px rgb(0 0 0 / 0.1);
+	}
 }

 /* Image zoom overlay */
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-processor.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-processor.ts
index e973a6a4b..57ded3e99 100644
--- a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-processor.ts
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-processor.ts
@@ -11,6 +11,7 @@ import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
 import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
 import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
 import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks';
+import { rehypeEnhanceTables } from './plugins/rehype/enhance-tables';
 import { rehypeFileBadge } from './plugins/rehype/file-badge';
 import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
 import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
@@ -73,6 +74,7 @@ function buildPipeline({
 			languages: lowlightAll
 		}) // Add syntax highlighting
 		.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g. <br>, <ul>) inside Markdown tables
+		.use(rehypeEnhanceTables) // Wrap tables in a horizontal scroll container
 		.use(rehypeEnhanceLinks) // Add target="_blank" to links
 		.use(rehypeFileBadge) // Render file:// anchors as inline badge chips
 		.use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-tables.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-tables.ts
new file mode 100644
index 000000000..b08bedba0
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-tables.ts
@@ -0,0 +1,34 @@
+/**
+ * Rehype plugin to wrap tables in a horizontal scroll container.
+ *
+ * A bare <table> keeps its content-driven minimum width, which propagates up
+ * the layout and can stretch the chat column past the window. Wrapping in
+ * div.table-wrapper makes the wrapper the scroll container (styled in
+ * markdown-content.css), so wide tables scroll in place instead.
+ */
+
+import type { Element, ElementContent, Root } from 'hast';
+import type { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
+
+export const rehypeEnhanceTables: Plugin<[], Root> = () => {
+	return (tree: Root) => {
+		visit(tree, 'element', (node: Element, index, parent) => {
+			if (node.tagName !== 'table' || !parent || index === undefined) return;
+
+			// already wrapped (e.g. nested tables in raw HTML input)
+			const parentClass = parent.type === 'element' ? parent.properties?.className : undefined;
+
+			if (Array.isArray(parentClass) && parentClass.includes('table-wrapper')) return;
+
+			const wrapper: Element = {
+				children: [node as ElementContent],
+				properties: { className: ['table-wrapper'] },
+				tagName: 'div',
+				type: 'element'
+			};
+
+			parent.children[index] = wrapper;
+		});
+	};
+};
diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte
index 38b656271..625035d71 100644
--- a/tools/ui/src/routes/+layout.svelte
+++ b/tools/ui/src/routes/+layout.svelte
@@ -325,7 +325,10 @@
 			}}
 		/>

-		<div class="flex-1">
+		<!-- 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">
 			{@render children?.()}
 		</div>
 	</div>
diff --git a/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte b/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
index 6fa5924e0..826e0d4aa 100644
--- a/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
+++ b/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
@@ -2,8 +2,25 @@
 	import { defineMeta } from '@storybook/addon-svelte-csf';
 	import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
 	import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants';
+	import { ServerRole } from '$lib/enums';
+	import { serverStore } from '$lib/stores';
+	import type { ApiLlamaCppServerProps } from '$lib/types';
 	import { expect, screen, waitFor } from 'storybook/test';

+	/**
+	 * The add menu mounts the reasoning submenu only outside router mode, and the
+	 * dev server proxies /props to whichever server happens to be running, so pin
+	 * the mode this story asserts instead of inheriting it from the environment.
+	 */
+	function pinSingleModelMode(): void {
+		serverStore.props = {
+			...(serverStore.props ?? {}),
+			role: ServerRole.MODEL
+		} as ApiLlamaCppServerProps;
+
+		serverStore.role = ServerRole.MODEL;
+	}
+
 	const { Story } = defineMeta({
 		component: ChatScreenForm,
 		parameters: {
@@ -38,6 +55,8 @@
 	args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
 	name="AddDropdownFocusesFirstEnabled"
 	play={async ({ canvas, userEvent }) => {
+		pinSingleModelMode();
+
 		const trigger = await canvas.findByRole('button', { name: ATTACHMENT_TOOLTIP_TEXT });

 		trigger.focus();