]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: UI/chat form follow ups (#26743)
authorPascal <redacted>
Mon, 10 Aug 2026 11:32:51 +0000 (13:32 +0200)
committerGitHub <redacted>
Mon, 10 Aug 2026 11:32:51 +0000 (13:32 +0200)
* ui: split the markdown rendering setting per surface

User content and thinking get their own toggle again, so turning off
markdown for a message leaves reasoning blocks formatted. Both default
to markdown. A stored renderContentAsRawText unfolds onto the user key
and is dropped from the config.

File mentions render as badges in the raw text path too, through a
narrow pass over [name](file://path) that leaves everything else
untouched.

* ui: let the rich chat input scroll past its max height

The contenteditable renderer caps its height with max-height but had no
overflow rule, so a long buffer overflowed into the input area wrapper
and got clipped by its overflow-hidden, leaving no way to reach the
bottom of the message. The textarea renderer scrolls natively and was
never affected.

* ui: apply the new lint and format config

* ui: move the render keys unfolding into the migration service

Address review from @allozaur: the settings store no longer rewrites
persisted config on load, the raw text toggle now unfolds onto the
per-surface render keys in migration.service.ts, next to the other
config migrations. The mention scanner flag and the directory path
suffix become named constants.

18 files changed:
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte
tools/ui/src/lib/components/app/content/MentionBadge.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/content/MentionText.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/content/index.ts
tools/ui/src/lib/constants/mention-badge.ts
tools/ui/src/lib/constants/path-display.ts
tools/ui/src/lib/constants/settings-keys.ts
tools/ui/src/lib/constants/settings-registry.ts
tools/ui/src/lib/services/migration.service.ts
tools/ui/src/lib/stores/settings.svelte.ts
tools/ui/src/lib/utils/index.ts
tools/ui/src/lib/utils/mention-badge.ts
tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts [deleted file]
tools/ui/tests/client/settings-render-keys-migration.svelte.test.ts [new file with mode: 0644]
tools/ui/tests/unit/mention-segments.test.ts [new file with mode: 0644]

index 98b082c89109dc5cbd6666e9c740215bbe9dce01..35977dfc394f11dc1ded9fc177af123dd91b2b52 100644 (file)
                data-placeholder={placeholder}
                tabindex={disabled ? -1 : 0}
                class={[
-                       'chat-form-contenteditable text-md min-h-12 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
+                       'chat-form-contenteditable text-md min-h-12 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
                        disabled && 'cursor-not-allowed'
                ]}
                style="max-height: var(--max-message-height);"
index 5a1e4f21386e9946afc6bb0c6b5595e297a1ffb3..30605ba9f1e44d141231a4deffaeb821dba5455d 100644 (file)
                                                                ? `max-height: ${MAX_HEIGHT}px;`
                                                                : 'max-height: none;'}
                                                >
-                                                       {#if !currentConfig.renderContentAsRawText}
+                                                       {#if currentConfig.renderUserContentAsMarkdown}
                                                                <div bind:this={messageElement} class={isExpanded ? 'cursor-text' : ''}>
                                                                        <MarkdownContent class="markdown-system-content" content={message.content} />
                                                                </div>
index a2e91b94c696394f5bfc4dc2cc49b0a67ca122ff..f5a3ff6f98d4569e0dd7d1657b9f856cd2a742d8 100644 (file)
@@ -1,5 +1,5 @@
 <script lang="ts">
-       import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
+       import { ChatAttachmentsList, MarkdownContent, MentionText } from '$lib/components/app';
        import { Card } from '$lib/components/ui/card';
        import { config } from '$lib/stores/settings.svelte';
        import type { DatabaseMessageExtra } from '$lib/types/database';
                data-multiline={isMultiline ? '' : undefined}
                style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
        >
-               {#if renderMarkdown && !currentConfig.renderContentAsRawText}
+               {#if renderMarkdown && currentConfig.renderUserContentAsMarkdown}
                        <div bind:this={messageElement}>
                                <MarkdownContent class="markdown-user-content" {content} />
                        </div>
                {:else}
-                       <span bind:this={messageElement} class="text-md whitespace-pre-wrap">
-                               {content}
-                       </span>
+                       <span bind:this={messageElement} class="text-md whitespace-pre-wrap"
+                               ><MentionText {content} /></span
+                       >
                {/if}
        </Card>
 {/if}
index b44f0428fd1810bc4a673996057d6fdf4127c3b0..ea3743e6c45cd6bc7cf1f10bc13385ff5f996383 100644 (file)
                class:is-streaming={isPending}
                onscroll={handleScrollEvent}
        >
-               {#if !currentConfig.renderContentAsRawText}
+               {#if currentConfig.renderThinkingAsMarkdown}
                        <MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
                {:else}
                        <div
diff --git a/tools/ui/src/lib/components/app/content/MentionBadge.svelte b/tools/ui/src/lib/components/app/content/MentionBadge.svelte
new file mode 100644 (file)
index 0000000..1af1262
--- /dev/null
@@ -0,0 +1,36 @@
+<script lang="ts">
+       import { SETTINGS_KEYS } from '$lib/constants';
+       import { settingsStore } from '$lib/stores/settings.svelte';
+       import { toolsStore } from '$lib/stores/tools.svelte';
+       import {
+               getMentionBadgeIconPaths,
+               getMentionBadgeLabel,
+               MENTION_BADGE_CLASSNAME,
+               MENTION_BADGE_ICON_CLASSNAME,
+               MENTION_BADGE_SVG_ATTRIBUTES
+       } from '$lib/utils';
+
+       interface Props {
+               name: string;
+               path: string;
+       }
+
+       let { name, path }: Props = $props();
+
+       let showFullPath = $derived(
+               settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS) as boolean
+       );
+       let label = $derived(getMentionBadgeLabel(name, path, showFullPath, toolsStore.serverHome));
+</script>
+
+<!-- The chip is a flex container, so template whitespace between its
+     children collapses away and the icon keeps its `gap-1` spacing. -->
+<span class={MENTION_BADGE_CLASSNAME} title={path}>
+       <svg {...MENTION_BADGE_SVG_ATTRIBUTES} class={MENTION_BADGE_ICON_CLASSNAME}>
+               {#each getMentionBadgeIconPaths(path) as d (d)}
+                       <path {d} />
+               {/each}
+       </svg>
+
+       <span class="shrink-0 truncate">{label}</span>
+</span>
diff --git a/tools/ui/src/lib/components/app/content/MentionText.svelte b/tools/ui/src/lib/components/app/content/MentionText.svelte
new file mode 100644 (file)
index 0000000..0a4bc0e
--- /dev/null
@@ -0,0 +1,17 @@
+<script lang="ts">
+       import MentionBadge from './MentionBadge.svelte';
+       import { splitMentionSegments } from '$lib/utils';
+
+       interface Props {
+               content: string;
+       }
+
+       let { content }: Props = $props();
+
+       let segments = $derived(splitMentionSegments(content));
+</script>
+
+<!-- Segments sit in a `whitespace-pre-wrap` parent, so the markup stays
+     glued: any newline between the tags below would print as a space. -->
+<!-- prettier-ignore -->
+{#each segments as segment, index (index)}{#if segment.mention}<MentionBadge name={segment.mention.name} path={segment.mention.path} />{:else}{segment.text}{/if}{/each}
index 5cfdd1b9c1e0bece0b0ed9fc16e735190c994ab0..9b43fe9ccaebba907ee39d67694401e6fb91be7d 100644 (file)
  */
 export { default as MarkdownContent } from './MarkdownContent/MarkdownContent.svelte';
 
+/**
+ * **MentionText** - Plain text with file mention badges
+ *
+ * Renders a message verbatim, turning only `[name](file://path)` links
+ * into the same badge chips the markdown path draws. Nothing else is
+ * interpreted, so pasted code keeps its `#` comments and underscores.
+ *
+ * @example
+ * ```svelte
+ * <span class="whitespace-pre-wrap"><MentionText content={message.content} /></span>
+ * ```
+ */
+export { default as MentionText } from './MentionText.svelte';
+
 /**
  * **SyntaxHighlightedCode** - Code syntax highlighting
  *
index 5de8270140a7d774ecc5a258019477d70c1de00b..a9ef963c3935c3fe002cff980cbc64f8488ae84b 100644 (file)
@@ -10,6 +10,9 @@ export const MENTION_BADGE_CLASSNAME =
 
 export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';
 
+/** Regex flag that makes the mention scanner walk every link in a message instead of the first. */
+export const MENTION_LINK_SCAN_FLAGS = 'g';
+
 /**
  * SVG attributes shared by the DOM-built and hast-built badge icons.
  * The tokenizer applies them via `setAttribute`, the rehype plugin
index 95877d741e5e499818be8fb982af265fa91886aa..fd10017613a6b613b68643ca746813bec4e222ff 100644 (file)
@@ -12,6 +12,9 @@ import { UrlProtocol } from '$lib/enums';
 export const CWD_CHANGED_PREFIX = 'Set working directory to ';
 export const CWD_CLEARED_TEXT = 'Working directory cleared';
 
+/** Trailing separator that marks a path as a directory. */
+export const DIRECTORY_PATH_SUFFIX = '/';
+
 export const HOME_TILDE = '~';
 export const HOME_TILDE_PREFIX = '~/'; // tilde plus path separator
 
index 8388f2b759e05e47e3a2d27e09a374d4b3bcc5c0..b53d11048d1cf9bc538ec57f51cf715deaf65ade 100644 (file)
@@ -41,7 +41,8 @@ export const SETTINGS_KEYS = {
        // Performance
        PRE_ENCODE_CONVERSATION: 'preEncodeConversation',
        PRESENCE_PENALTY: 'presence_penalty',
-       RENDER_CONTENT_AS_RAW_TEXT: 'renderContentAsRawText',
+       RENDER_THINKING_AS_MARKDOWN: 'renderThinkingAsMarkdown',
+       RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
        // Penalties
        REPEAT_LAST_N: 'repeat_last_n',
        REPEAT_PENALTY: 'repeat_penalty',
index 37f7c8f310ac58cf491ff4f90d94e1a1eac3d0be..ada029a33ff5493192d4f09b7c06ba88faf69b9c 100644 (file)
@@ -230,10 +230,18 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
                                type: SettingsFieldType.CHECKBOX
                        },
                        {
-                               defaultValue: false,
-                               help: 'Display user, system and thinking content as plain text instead of formatted Markdown. Markdown is the default so that @-mention badges render in sent messages.',
-                               key: SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT,
-                               label: 'Render content as raw text',
+                               defaultValue: true,
+                               help: 'Render user messages using markdown formatting in the chat. Turn this off to keep a message exactly as typed; @-mention badges show either way.',
+                               key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
+                               label: 'Render user content as Markdown',
+                               section: SETTINGS_SECTION_SLUGS.DISPLAY,
+                               type: SettingsFieldType.CHECKBOX
+                       },
+                       {
+                               defaultValue: true,
+                               help: 'Render the reasoning/thinking block content as formatted Markdown instead of plain text.',
+                               key: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN,
+                               label: 'Render thinking as Markdown',
                                section: SETTINGS_SECTION_SLUGS.DISPLAY,
                                type: SettingsFieldType.CHECKBOX
                        },
index b2626c794aa8edb119956b0902322abc083f61fd..ffe4bd846799d49c5d2c9d6dab3e426f4cc97e95 100644 (file)
@@ -629,6 +629,37 @@ const configTypesMigration: Migration = {
                        console.log(`[Migration] Config types: coerced string booleans (changed=${changed})`);
        }
 };
+const RENDER_KEYS_MIGRATION_ID = 'render-keys-unfold-v1';
+const LEGACY_RENDER_RAW_TEXT_KEY = 'renderContentAsRawText';
+const renderKeysMigration: Migration = {
+       description: 'Unfold the single raw text render toggle onto the per-surface render keys',
+       id: RENDER_KEYS_MIGRATION_ID,
+
+       async run(): Promise<void> {
+               const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
+
+               if (configRaw === null) return;
+
+               const config = JSON.parse(configRaw);
+
+               if (!(LEGACY_RENDER_RAW_TEXT_KEY in config)) return;
+
+               // The toggle carried user content and thinking at once and cannot say which surface
+               // was chosen, so it only restores the user key and thinking keeps its own default.
+               if (!(SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN in config)) {
+                       config[SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN] =
+                               config[LEGACY_RENDER_RAW_TEXT_KEY] !== true;
+               }
+
+               // Dropped rather than preserved: the two render keys and the toggle describe the same
+               // surfaces, so leaving it behind would let a stale value fight the restored one.
+               delete config[LEGACY_RENDER_RAW_TEXT_KEY];
+               localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
+
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                       console.log('[Migration] Render keys: unfolded the raw text toggle');
+       }
+};
 const MCP_DEFAULT_OVERRIDES_LEGACY_KEY = `${STORAGE_APP_NAME}.mcpDefaultServerOverrides`;
 const MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID = 'mcp-default-overrides-merge-v1';
 /**
@@ -722,7 +753,8 @@ const migrations: Migration[] = [
        customJsonKeyMigration,
        mcpDefaultEnabledMigration,
        mcpDefaultOverridesMergeMigration,
-       configTypesMigration
+       configTypesMigration,
+       renderKeysMigration
 ];
 
 export const MigrationService = {
index f413638efa09cb568da8a5e978b009e2437cf07a..c8b9be7a781c9ea7ecdd63e9e024e17db0b040e0 100644 (file)
@@ -135,32 +135,6 @@ class SettingsStore {
                                ...savedVal
                        };
 
-                       // Migrate the legacy render keys into `renderContentAsRawText`
-                       // (inverted semantics: the old keys opted INTO markdown). Any
-                       // explicit raw-text preference wins when the legacy keys disagree.
-                       const LEGACY_MARKDOWN_KEYS = ['renderUserContentAsMarkdown', 'renderThinkingAsMarkdown'];
-                       const LEGACY_RAW_TEXT_KEY = 'renderUserContentAsRawText'; // this branch's intermediate key
-                       const legacyKeys = [...LEGACY_MARKDOWN_KEYS, LEGACY_RAW_TEXT_KEY].filter(
-                               (key) => key in savedVal
-                       );
-
-                       if (legacyKeys.length > 0) {
-                               if (!(SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT in savedVal)) {
-                                       if (LEGACY_RAW_TEXT_KEY in savedVal) {
-                                               this.config[SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT] = savedVal[LEGACY_RAW_TEXT_KEY];
-                                       } else {
-                                               this.config[SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT] = LEGACY_MARKDOWN_KEYS.filter(
-                                                       (key) => key in savedVal
-                                               ).some((key) => savedVal[key] === false);
-                                       }
-                               }
-
-                               for (const key of legacyKeys) {
-                                       delete (this.config as Record<string, unknown>)[key];
-                               }
-                               this.saveConfig();
-                       }
-
                        // Default sendOnEnter to false on mobile when the user has no saved preference
                        if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
                                if (isMobile.current) {
index 6703da373a153eadc30ddb33d7308017610da813..5762e8e4bb92e47c31e60ab678e395ea3a4c5f74 100644 (file)
@@ -240,6 +240,7 @@ export {
        MENTION_BADGE_FOLDER_ICON_PATHS,
        getMentionBadgeIconPaths,
        getMentionBadgeLabel,
+       splitMentionSegments,
        buildMentionInsertion
 } from './mention-badge';
 
index 7b76a22800e596c0609fe1786dcd6f3fbc6df37b..5b1b7d1fe5aa08b05632c0158f35e868f2be5ba0 100644 (file)
@@ -1,8 +1,9 @@
 import { abbreviateHome, lastPathSegment } from './path-display';
-import { FILE_URI_PREFIX } from '$lib/constants';
+import { DIRECTORY_PATH_SUFFIX, FILE_URI_PREFIX } from '$lib/constants';
 import {
        MENTION_BADGE_FILE_ICON_PATHS,
-       MENTION_BADGE_FOLDER_ICON_PATHS
+       MENTION_BADGE_FOLDER_ICON_PATHS,
+       MENTION_LINK_SCAN_FLAGS
 } from '$lib/constants/mention-badge';
 import { FileMentionEntryType } from '$lib/enums';
 import type { FileMentionEntry } from '$lib/types';
@@ -48,8 +49,45 @@ export function decodeFileLinkPath(path: string): string {
        }
 }
 
+export interface MentionTextSegment {
+       text: string;
+       mention: { name: string; path: string } | null;
+}
+
+/**
+ * Split raw text into plain runs and `[name](file://path)` mentions.
+ * The raw-text renderers walk these segments to draw badges without
+ * handing the message to the markdown parser, so a `#` stays a `#`.
+ */
+export function splitMentionSegments(value: string): MentionTextSegment[] {
+       const linkRe = fileMentionLinkRe(MENTION_LINK_SCAN_FLAGS);
+       const segments: MentionTextSegment[] = [];
+
+       let cursor = 0;
+       let match: RegExpExecArray | null;
+
+       while ((match = linkRe.exec(value)) !== null) {
+               if (match.index > cursor) {
+                       segments.push({ mention: null, text: value.slice(cursor, match.index) });
+               }
+
+               segments.push({
+                       mention: { name: match[1], path: decodeFileLinkPath(match[2]) },
+                       text: match[0]
+               });
+
+               cursor = match.index + match[0].length;
+       }
+
+       if (cursor < value.length) segments.push({ mention: null, text: value.slice(cursor) });
+
+       return segments;
+}
+
 export function getMentionBadgeIconPaths(path: string): readonly string[] {
-       return path.endsWith('/') ? MENTION_BADGE_FOLDER_ICON_PATHS : MENTION_BADGE_FILE_ICON_PATHS;
+       return path.endsWith(DIRECTORY_PATH_SUFFIX)
+               ? MENTION_BADGE_FOLDER_ICON_PATHS
+               : MENTION_BADGE_FILE_ICON_PATHS;
 }
 
 export function getMentionBadgeLabel(
diff --git a/tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts b/tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts
deleted file mode 100644 (file)
index 7e60ccf..0000000
+++ /dev/null
@@ -1,66 +0,0 @@
-// Guards the legacy render-key migration: `renderUserContentAsMarkdown`
-// and `renderThinkingAsMarkdown` (opt-INTO markdown) fold into the single
-// `renderContentAsRawText` setting, with any explicit raw-text preference
-// winning when the legacy keys disagree. Legacy keys are removed from the
-// persisted config so they do not stay orphaned in localStorage.
-
-import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
-import { config, settingsStore } from '$lib/stores/settings.svelte';
-import { beforeEach, describe, expect, it } from 'vitest';
-
-function seedConfig(stored: Record<string, unknown>) {
-       localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
-       settingsStore.initialize();
-}
-
-function persisted(): Record<string, unknown> {
-       return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
-}
-
-describe('renderContentAsRawText migration', () => {
-       beforeEach(() => {
-               localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
-               settingsStore.initialize();
-       });
-
-       it('maps renderUserContentAsMarkdown=false to raw text', () => {
-               seedConfig({ renderUserContentAsMarkdown: false });
-               expect(config().renderContentAsRawText).toBe(true);
-       });
-
-       it('maps renderUserContentAsMarkdown=true to markdown', () => {
-               seedConfig({ renderUserContentAsMarkdown: true });
-               expect(config().renderContentAsRawText).toBe(false);
-       });
-
-       it('maps renderThinkingAsMarkdown=false to raw text', () => {
-               seedConfig({ renderThinkingAsMarkdown: false });
-               expect(config().renderContentAsRawText).toBe(true);
-       });
-
-       it('lets any explicit raw-text preference win when the legacy keys disagree', () => {
-               seedConfig({ renderThinkingAsMarkdown: false, renderUserContentAsMarkdown: true });
-               expect(config().renderContentAsRawText).toBe(true);
-       });
-
-       it('honors the intermediate renderUserContentAsRawText key from the PR branch', () => {
-               seedConfig({ renderUserContentAsRawText: true });
-               expect(config().renderContentAsRawText).toBe(true);
-       });
-
-       it('keeps an already-migrated value and cleans up the legacy keys', () => {
-               seedConfig({ renderContentAsRawText: false, renderUserContentAsMarkdown: false });
-               expect(config().renderContentAsRawText).toBe(false);
-
-               const stored = persisted();
-
-               expect(stored.renderUserContentAsMarkdown).toBeUndefined();
-               expect(stored.renderThinkingAsMarkdown).toBeUndefined();
-               expect(stored.renderUserContentAsRawText).toBeUndefined();
-       });
-
-       it('defaults to markdown when no legacy key exists', () => {
-               seedConfig({});
-               expect(config().renderContentAsRawText).toBe(false);
-       });
-});
diff --git a/tools/ui/tests/client/settings-render-keys-migration.svelte.test.ts b/tools/ui/tests/client/settings-render-keys-migration.svelte.test.ts
new file mode 100644 (file)
index 0000000..380a2e7
--- /dev/null
@@ -0,0 +1,64 @@
+// Guards the unfolding of `renderContentAsRawText` back onto the two
+// per-surface render keys. The single toggle carried user content and
+// thinking at once, so only the user key is restored from it and thinking
+// returns to its own default. The toggle is removed from the persisted
+// config so it does not stay orphaned in localStorage.
+
+import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
+import { MigrationService } from '$lib/services/migration.service';
+import { config, settingsStore } from '$lib/stores/settings.svelte';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+const RENDER_KEYS_MIGRATION_ID = 'render-keys-unfold-v1';
+
+async function seedConfig(stored: Record<string, unknown>) {
+       localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
+
+       const migration = MigrationService.getMigrations().find((m) => m.id === RENDER_KEYS_MIGRATION_ID);
+
+       await migration?.run();
+       settingsStore.initialize();
+}
+
+function persisted(): Record<string, unknown> {
+       return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
+}
+
+describe('renderContentAsRawText unfolding', () => {
+       beforeEach(() => {
+               localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
+               MigrationService.resetState();
+               settingsStore.initialize();
+       });
+
+       it('maps raw text to user content as plain text', async () => {
+               await seedConfig({ renderContentAsRawText: true });
+               expect(config().renderUserContentAsMarkdown).toBe(false);
+       });
+
+       it('maps markdown to user content as markdown', async () => {
+               await seedConfig({ renderContentAsRawText: false });
+               expect(config().renderUserContentAsMarkdown).toBe(true);
+       });
+
+       it('leaves thinking on its own default', async () => {
+               await seedConfig({ renderContentAsRawText: true });
+               expect(config().renderThinkingAsMarkdown).toBe(true);
+       });
+
+       it('keeps an explicit user preference over the toggle', async () => {
+               await seedConfig({ renderContentAsRawText: true, renderUserContentAsMarkdown: true });
+               expect(config().renderUserContentAsMarkdown).toBe(true);
+       });
+
+       it('drops the toggle from the persisted config', async () => {
+               await seedConfig({ renderContentAsRawText: true });
+               expect(persisted().renderContentAsRawText).toBeUndefined();
+       });
+
+       it('leaves both surfaces on markdown when nothing is stored', async () => {
+               await seedConfig({});
+               expect(config().renderUserContentAsMarkdown).toBe(true);
+               expect(config().renderThinkingAsMarkdown).toBe(true);
+       });
+});
diff --git a/tools/ui/tests/unit/mention-segments.test.ts b/tools/ui/tests/unit/mention-segments.test.ts
new file mode 100644 (file)
index 0000000..51bb46d
--- /dev/null
@@ -0,0 +1,48 @@
+import { splitMentionSegments } from '$lib/utils/mention-badge';
+import { describe, expect, it } from 'vitest';
+
+describe('splitMentionSegments', () => {
+       it('returns a single plain run when there is no mention', () => {
+               const segments = splitMentionSegments('# not a heading here');
+
+               expect(segments).toEqual([{ mention: null, text: '# not a heading here' }]);
+       });
+
+       it('splits text around a mention', () => {
+               const segments = splitMentionSegments('look at [main.c](file:///src/main.c) please');
+
+               expect(segments.map((segment) => segment.text)).toEqual([
+                       'look at ',
+                       '[main.c](file:///src/main.c)',
+                       ' please'
+               ]);
+               expect(segments[1].mention).toEqual({ name: 'main.c', path: '/src/main.c' });
+       });
+
+       it('decodes percent-encoded paths', () => {
+               const segments = splitMentionSegments('[a b.txt](file:///tmp/a%20b.txt)');
+
+               expect(segments[0].mention?.path).toBe('/tmp/a b.txt');
+       });
+
+       it('keeps the directory marker so the folder icon is picked', () => {
+               const segments = splitMentionSegments('[src](file:///repo/src/)');
+
+               expect(segments[0].mention?.path).toBe('/repo/src/');
+       });
+
+       it('handles adjacent mentions with no text between them', () => {
+               const segments = splitMentionSegments('[a](file:///a)[b](file:///b)');
+
+               expect(segments).toHaveLength(2);
+               expect(segments.every((segment) => segment.mention !== null)).toBe(true);
+       });
+
+       it('preserves the exact source when segments are joined back', () => {
+               const source = 'see [a](file:///a) and [b](file:///b/) done';
+
+               expect(splitMentionSegments(source).reduce((acc, segment) => acc + segment.text, '')).toBe(
+                       source
+               );
+       });
+});