feat: Unify markdown/raw-text rendering under one setting with migration

This commit is contained in:
Aleksander Grygier
2026-08-07 18:45:57 +02:00
committed by Pascal
parent 9a9adc77d2
commit e25e47bf83
8 changed files with 99 additions and 20 deletions
@@ -164,7 +164,7 @@
? `max-height: ${MAX_HEIGHT}px;`
: 'max-height: none;'}
>
{#if currentConfig.renderUserContentAsMarkdown}
{#if !currentConfig.renderContentAsRawText}
<div bind:this={messageElement} class={isExpanded ? 'cursor-text' : ''}>
<MarkdownContent class="markdown-system-content" content={message.content} />
</div>
@@ -63,7 +63,7 @@
data-multiline={isMultiline ? '' : undefined}
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
{#if renderMarkdown && currentConfig.renderUserContentAsMarkdown}
{#if renderMarkdown && !currentConfig.renderContentAsRawText}
<div bind:this={messageElement}>
<MarkdownContent class="markdown-user-content" {content} />
</div>
@@ -41,7 +41,6 @@
let expandedStates: Record<number, boolean> = $state({});
const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean);
const showThoughtInProgress = $derived(Boolean(config().showThoughtInProgress));
const alwaysShowToolCallContent = $derived(Boolean(config().alwaysShowToolCallContent));
const showMessageStats = $derived(Boolean(config().showMessageStats));
@@ -186,7 +185,6 @@
{section}
open={isExpanded(index, section)}
{isStreaming}
{renderThinkingAsMarkdown}
{hasReasoningError}
attachments={message?.extra}
onToggle={() => toggleExpanded(index, section)}
@@ -3,6 +3,7 @@
import { CollapsibleContentBlock, MarkdownContent } from '$lib/components/app';
import { AgenticSectionType } from '$lib/enums';
import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
import { config } from '$lib/stores/settings.svelte';
import type { DatabaseMessageExtra } from '$lib/types';
import type { AgenticSection } from '$lib/utils';
@@ -10,7 +11,6 @@
section: AgenticSection;
open: boolean;
isStreaming: boolean;
renderThinkingAsMarkdown: boolean;
hasReasoningError?: boolean;
attachments?: DatabaseMessageExtra[];
onToggle?: () => void;
@@ -20,12 +20,13 @@
section,
open,
isStreaming,
renderThinkingAsMarkdown,
hasReasoningError = false,
attachments,
onToggle
}: Props = $props();
const currentConfig = config();
const REASONING_HEADER = 'Reasoning';
const REASONING_HEADER_PENDING = 'Reasoning...';
const REASONING_SUBTITLE_ERROR = 'Error';
@@ -128,7 +129,7 @@
class:is-streaming={isPending}
onscroll={handleScrollEvent}
>
{#if renderThinkingAsMarkdown}
{#if !currentConfig.renderContentAsRawText}
<MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
{:else}
<div
+1 -2
View File
@@ -23,7 +23,7 @@ export const SETTINGS_KEYS = {
SHOW_AGENTIC_TURN_STATS: 'showAgenticTurnStats',
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
RENDER_CONTENT_AS_RAW_TEXT: 'renderContentAsRawText',
DISABLE_AUTO_SCROLL: 'disableAutoScroll',
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
@@ -33,7 +33,6 @@ export const SETTINGS_KEYS = {
SHOW_BUILD_VERSION: 'showBuildVersion',
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
RENDER_THINKING_AS_MARKDOWN: 'renderThinkingAsMarkdown',
MENTION_SEARCH_MAX_DEPTH: 'mentionSearchMaxDepth',
// Sampling
TEMPERATURE: 'temperature',
@@ -233,21 +233,13 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
section: SETTINGS_SECTION_SLUGS.DISPLAY
},
{
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
label: 'Render user content as Markdown',
help: 'Render user messages using markdown formatting in the chat.',
key: SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT,
label: 'Render content as raw text',
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.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY
},
{
key: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN,
label: 'Render thinking as Markdown',
help: 'Render the reasoning/thinking block content as formatted Markdown instead of plain text.',
defaultValue: true,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY
},
{
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
label: 'Use full height code blocks',
@@ -135,6 +135,30 @@ 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) {
@@ -0,0 +1,65 @@
// 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 { beforeEach, describe, expect, it } from 'vitest';
import { settingsStore, config } from '$lib/stores/settings.svelte';
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
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({ renderUserContentAsMarkdown: true, renderThinkingAsMarkdown: false });
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);
});
});