From b23efaa2ef147f547ee75cbf0c621d61904de80e Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Sun, 20 Sep 2026 07:59:43 +0200 Subject: [PATCH] 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 , 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 --- .../ChatAttachmentsListItem.svelte | 5 +- ...atAttachmentsListItemThumbnailImage.svelte | 2 +- .../ChatMessageToolCallBlock.svelte | 2 +- .../ChatMessageToolCallBlockEditFile.svelte | 18 +-- .../ChatMessageToolCallBlockGetInfo.svelte | 106 +++++++++++++----- .../ChatMessageToolCallBlockReadFile.svelte | 18 +-- .../ChatMessageToolCallBlockReadMedia.svelte | 6 +- .../ChatMessageToolCallBlockWriteFile.svelte | 18 +-- .../ChatMessageUserBubble.svelte | 7 +- .../content/CollapsibleContentBlock.svelte | 7 +- .../content/CollapsibleTerminalBlock.svelte | 7 +- .../MarkdownContent/markdown-content.css | 23 +++- .../MarkdownContent/markdown-processor.ts | 2 + .../plugins/rehype/enhance-tables.ts | 34 ++++++ tools/ui/src/routes/+layout.svelte | 5 +- .../a11y/ChatScreenForm.a11y.stories.svelte | 19 ++++ 16 files changed, 214 insertions(+), 65 deletions(-) create mode 100644 tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-tables.ts 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 05bd733a2c..754856d60b 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} {#if onclick}
+ + - - {:else if infoMeta.errorMessage} - Runtime info  + + - - {infoMeta.errorMessage} - {:else if infoMeta.os || infoMeta.cwd} - Runtime info  + + {#if infoMeta.os} + + - {#if infoMeta.os} - {infoMeta.os} + + + {/if} + + {#if infoMeta.cwd} + + + + + + {/if} + +
+ os + +
+ {infoMeta.os} +
+
+ cwd + +
+ {cwdDisplay} +
+
+ {:else if section.toolResult} +
+ {section.toolResult} +
+ {:else} +
+ Waiting for runtime info... +
{/if} - - {#if infoMeta.cwd} - {cwdDisplay} - {/if} - {:else} - Runtime info - {/if} - + {/snippet} + 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 13b4402228..1134c8c0fe 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 @@ {#snippet titleSnippet()} - Read file + + Read file - {readFileMeta?.fileName} + + {readFileMeta?.fileName} - {#if readFileMeta?.lineRange} -  (lines {readFileMeta.lineRange.start}-{readFileMeta.lineRange.end}) - {/if} + {#if readFileMeta?.lineRange} + + (lines {readFileMeta.lineRange.start}-{readFileMeta.lineRange.end}) + + {/if} + + {/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 93d8990184..0948bf623b 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 @@ {#snippet titleSnippet()} - Read media + + Read media - {readMediaMeta?.fileName} + {readMediaMeta?.fileName} + {/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 cafa5280bc..ac2a8e6576 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 @@ {#snippet titleSnippet()} - Write file + + Write file - {abbreviateHome(writeFileMeta?.filePath ?? '', home)} + + + {abbreviateHome(writeFileMeta?.filePath ?? '', home)} + - {#if writeFileMeta?.errorMessage} - (failed) - {/if} + {#if writeFileMeta?.errorMessage} + (failed) + {/if} + + {/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 65818c64bd..569737ac3b 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}
- +
{/if} diff --git a/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte b/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte index c54b981cde..ecdd75bda5 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 @@ {/if} - + {#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 0ad6ea61fc..610923b9cf 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 @@ {/if} - + {#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 cada489ca9..b0ca884ae0 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 e973a6a4b5..57ded3e99a 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.
,
    ) 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
    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 0000000000..b08bedba07
    --- /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  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 38b6562715..625035d716 100644
    --- a/tools/ui/src/routes/+layout.svelte
    +++ b/tools/ui/src/routes/+layout.svelte
    @@ -325,7 +325,10 @@
     			}}
     		/>
     
    -		
    + +
    {@render children?.()}
    diff --git a/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte b/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte index 6fa5924e08..826e0d4aa1 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();