From 4dee52f82dc455a035e900fed6a40cb45cd7a454 Mon Sep 17 00:00:00 2001 From: Pascal Date: Mon, 10 Aug 2026 13:32:51 +0200 Subject: [PATCH] ui: UI/chat form follow ups (#26743) * 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. --- .../ChatForm/ChatFormContenteditable.svelte | 2 +- .../ChatMessageSystem.svelte | 2 +- .../ChatMessageUserBubble.svelte | 10 +-- .../ChatMessageReasoningBlock.svelte | 2 +- .../app/content/MentionBadge.svelte | 36 ++++++++++ .../components/app/content/MentionText.svelte | 17 +++++ .../src/lib/components/app/content/index.ts | 14 ++++ tools/ui/src/lib/constants/mention-badge.ts | 3 + tools/ui/src/lib/constants/path-display.ts | 3 + tools/ui/src/lib/constants/settings-keys.ts | 3 +- .../ui/src/lib/constants/settings-registry.ts | 16 +++-- .../ui/src/lib/services/migration.service.ts | 34 +++++++++- tools/ui/src/lib/stores/settings.svelte.ts | 26 -------- tools/ui/src/lib/utils/index.ts | 1 + tools/ui/src/lib/utils/mention-badge.ts | 44 ++++++++++++- ...settings-raw-text-migration.svelte.test.ts | 66 ------------------- ...tings-render-keys-migration.svelte.test.ts | 64 ++++++++++++++++++ tools/ui/tests/unit/mention-segments.test.ts | 48 ++++++++++++++ 18 files changed, 282 insertions(+), 109 deletions(-) create mode 100644 tools/ui/src/lib/components/app/content/MentionBadge.svelte create mode 100644 tools/ui/src/lib/components/app/content/MentionText.svelte delete mode 100644 tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts create mode 100644 tools/ui/tests/client/settings-render-keys-migration.svelte.test.ts create mode 100644 tools/ui/tests/unit/mention-segments.test.ts diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte index 98b082c891..35977dfc39 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte @@ -797,7 +797,7 @@ 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);" 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 5a1e4f2138..30605ba9f1 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 @@ -164,7 +164,7 @@ ? `max-height: ${MAX_HEIGHT}px;` : 'max-height: none;'} > - {#if !currentConfig.renderContentAsRawText} + {#if currentConfig.renderUserContentAsMarkdown}
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 a2e91b94c6..f5a3ff6f98 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 @@ -1,5 +1,5 @@ + + + + + {#each getMentionBadgeIconPaths(path) as d (d)} + + {/each} + + + {label} + 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 index 0000000000..0a4bc0eebe --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MentionText.svelte @@ -0,0 +1,17 @@ + + + + +{#each segments as segment, index (index)}{#if segment.mention}{:else}{segment.text}{/if}{/each} diff --git a/tools/ui/src/lib/components/app/content/index.ts b/tools/ui/src/lib/components/app/content/index.ts index 5cfdd1b9c1..9b43fe9cca 100644 --- a/tools/ui/src/lib/components/app/content/index.ts +++ b/tools/ui/src/lib/components/app/content/index.ts @@ -31,6 +31,20 @@ */ 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 + * + * ``` + */ +export { default as MentionText } from './MentionText.svelte'; + /** * **SyntaxHighlightedCode** - Code syntax highlighting * diff --git a/tools/ui/src/lib/constants/mention-badge.ts b/tools/ui/src/lib/constants/mention-badge.ts index 5de8270140..a9ef963c39 100644 --- a/tools/ui/src/lib/constants/mention-badge.ts +++ b/tools/ui/src/lib/constants/mention-badge.ts @@ -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 diff --git a/tools/ui/src/lib/constants/path-display.ts b/tools/ui/src/lib/constants/path-display.ts index 95877d741e..fd10017613 100644 --- a/tools/ui/src/lib/constants/path-display.ts +++ b/tools/ui/src/lib/constants/path-display.ts @@ -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 diff --git a/tools/ui/src/lib/constants/settings-keys.ts b/tools/ui/src/lib/constants/settings-keys.ts index 8388f2b759..b53d11048d 100644 --- a/tools/ui/src/lib/constants/settings-keys.ts +++ b/tools/ui/src/lib/constants/settings-keys.ts @@ -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', diff --git a/tools/ui/src/lib/constants/settings-registry.ts b/tools/ui/src/lib/constants/settings-registry.ts index 37f7c8f310..ada029a33f 100644 --- a/tools/ui/src/lib/constants/settings-registry.ts +++ b/tools/ui/src/lib/constants/settings-registry.ts @@ -230,10 +230,18 @@ const SETTINGS_REGISTRY: Record = { 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 }, diff --git a/tools/ui/src/lib/services/migration.service.ts b/tools/ui/src/lib/services/migration.service.ts index b2626c794a..ffe4bd8467 100644 --- a/tools/ui/src/lib/services/migration.service.ts +++ b/tools/ui/src/lib/services/migration.service.ts @@ -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 { + 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 = { diff --git a/tools/ui/src/lib/stores/settings.svelte.ts b/tools/ui/src/lib/stores/settings.svelte.ts index f413638efa..c8b9be7a78 100644 --- a/tools/ui/src/lib/stores/settings.svelte.ts +++ b/tools/ui/src/lib/stores/settings.svelte.ts @@ -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)[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) { diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts index 6703da373a..5762e8e4bb 100644 --- a/tools/ui/src/lib/utils/index.ts +++ b/tools/ui/src/lib/utils/index.ts @@ -240,6 +240,7 @@ export { MENTION_BADGE_FOLDER_ICON_PATHS, getMentionBadgeIconPaths, getMentionBadgeLabel, + splitMentionSegments, buildMentionInsertion } from './mention-badge'; diff --git a/tools/ui/src/lib/utils/mention-badge.ts b/tools/ui/src/lib/utils/mention-badge.ts index 7b76a22800..5b1b7d1fe5 100644 --- a/tools/ui/src/lib/utils/mention-badge.ts +++ b/tools/ui/src/lib/utils/mention-badge.ts @@ -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 index 7e60ccfb9c..0000000000 --- a/tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts +++ /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) { - localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored)); - settingsStore.initialize(); -} - -function persisted(): Record { - 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 index 0000000000..380a2e74d0 --- /dev/null +++ b/tools/ui/tests/client/settings-render-keys-migration.svelte.test.ts @@ -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) { + 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 { + 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 index 0000000000..51bb46d1e4 --- /dev/null +++ b/tools/ui/tests/unit/mention-segments.test.ts @@ -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 + ); + }); +});