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.
This commit is contained in:
@@ -797,7 +797,7 @@
|
|||||||
data-placeholder={placeholder}
|
data-placeholder={placeholder}
|
||||||
tabindex={disabled ? -1 : 0}
|
tabindex={disabled ? -1 : 0}
|
||||||
class={[
|
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'
|
disabled && 'cursor-not-allowed'
|
||||||
]}
|
]}
|
||||||
style="max-height: var(--max-message-height);"
|
style="max-height: var(--max-message-height);"
|
||||||
|
|||||||
+1
-1
@@ -164,7 +164,7 @@
|
|||||||
? `max-height: ${MAX_HEIGHT}px;`
|
? `max-height: ${MAX_HEIGHT}px;`
|
||||||
: 'max-height: none;'}
|
: 'max-height: none;'}
|
||||||
>
|
>
|
||||||
{#if !currentConfig.renderContentAsRawText}
|
{#if currentConfig.renderUserContentAsMarkdown}
|
||||||
<div bind:this={messageElement} class={isExpanded ? 'cursor-text' : ''}>
|
<div bind:this={messageElement} class={isExpanded ? 'cursor-text' : ''}>
|
||||||
<MarkdownContent class="markdown-system-content" content={message.content} />
|
<MarkdownContent class="markdown-system-content" content={message.content} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-5
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 { Card } from '$lib/components/ui/card';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||||
@@ -64,14 +64,14 @@
|
|||||||
data-multiline={isMultiline ? '' : undefined}
|
data-multiline={isMultiline ? '' : undefined}
|
||||||
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
|
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
|
||||||
>
|
>
|
||||||
{#if renderMarkdown && !currentConfig.renderContentAsRawText}
|
{#if renderMarkdown && currentConfig.renderUserContentAsMarkdown}
|
||||||
<div bind:this={messageElement}>
|
<div bind:this={messageElement}>
|
||||||
<MarkdownContent class="markdown-user-content" {content} />
|
<MarkdownContent class="markdown-user-content" {content} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<span bind:this={messageElement} class="text-md whitespace-pre-wrap">
|
<span bind:this={messageElement} class="text-md whitespace-pre-wrap"
|
||||||
{content}
|
><MentionText {content} /></span
|
||||||
</span>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
class:is-streaming={isPending}
|
class:is-streaming={isPending}
|
||||||
onscroll={handleScrollEvent}
|
onscroll={handleScrollEvent}
|
||||||
>
|
>
|
||||||
{#if !currentConfig.renderContentAsRawText}
|
{#if currentConfig.renderThinkingAsMarkdown}
|
||||||
<MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
|
<MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -31,6 +31,20 @@
|
|||||||
*/
|
*/
|
||||||
export { default as MarkdownContent } from './MarkdownContent/MarkdownContent.svelte';
|
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
|
* **SyntaxHighlightedCode** - Code syntax highlighting
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export const MENTION_BADGE_CLASSNAME =
|
|||||||
|
|
||||||
export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';
|
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.
|
* SVG attributes shared by the DOM-built and hast-built badge icons.
|
||||||
* The tokenizer applies them via `setAttribute`, the rehype plugin
|
* The tokenizer applies them via `setAttribute`, the rehype plugin
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { UrlProtocol } from '$lib/enums';
|
|||||||
export const CWD_CHANGED_PREFIX = 'Set working directory to ';
|
export const CWD_CHANGED_PREFIX = 'Set working directory to ';
|
||||||
export const CWD_CLEARED_TEXT = 'Working directory cleared';
|
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 = '~';
|
||||||
export const HOME_TILDE_PREFIX = '~/'; // tilde plus path separator
|
export const HOME_TILDE_PREFIX = '~/'; // tilde plus path separator
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export const SETTINGS_KEYS = {
|
|||||||
// Performance
|
// Performance
|
||||||
PRE_ENCODE_CONVERSATION: 'preEncodeConversation',
|
PRE_ENCODE_CONVERSATION: 'preEncodeConversation',
|
||||||
PRESENCE_PENALTY: 'presence_penalty',
|
PRESENCE_PENALTY: 'presence_penalty',
|
||||||
RENDER_CONTENT_AS_RAW_TEXT: 'renderContentAsRawText',
|
RENDER_THINKING_AS_MARKDOWN: 'renderThinkingAsMarkdown',
|
||||||
|
RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
|
||||||
// Penalties
|
// Penalties
|
||||||
REPEAT_LAST_N: 'repeat_last_n',
|
REPEAT_LAST_N: 'repeat_last_n',
|
||||||
REPEAT_PENALTY: 'repeat_penalty',
|
REPEAT_PENALTY: 'repeat_penalty',
|
||||||
|
|||||||
@@ -230,10 +230,18 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
|||||||
type: SettingsFieldType.CHECKBOX
|
type: SettingsFieldType.CHECKBOX
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defaultValue: false,
|
defaultValue: true,
|
||||||
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.',
|
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_CONTENT_AS_RAW_TEXT,
|
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
|
||||||
label: 'Render content as raw text',
|
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,
|
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||||
type: SettingsFieldType.CHECKBOX
|
type: SettingsFieldType.CHECKBOX
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -629,6 +629,37 @@ const configTypesMigration: Migration = {
|
|||||||
console.log(`[Migration] Config types: coerced string booleans (changed=${changed})`);
|
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_LEGACY_KEY = `${STORAGE_APP_NAME}.mcpDefaultServerOverrides`;
|
||||||
const MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID = 'mcp-default-overrides-merge-v1';
|
const MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID = 'mcp-default-overrides-merge-v1';
|
||||||
/**
|
/**
|
||||||
@@ -722,7 +753,8 @@ const migrations: Migration[] = [
|
|||||||
customJsonKeyMigration,
|
customJsonKeyMigration,
|
||||||
mcpDefaultEnabledMigration,
|
mcpDefaultEnabledMigration,
|
||||||
mcpDefaultOverridesMergeMigration,
|
mcpDefaultOverridesMergeMigration,
|
||||||
configTypesMigration
|
configTypesMigration,
|
||||||
|
renderKeysMigration
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MigrationService = {
|
export const MigrationService = {
|
||||||
|
|||||||
@@ -135,32 +135,6 @@ class SettingsStore {
|
|||||||
...savedVal
|
...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
|
// Default sendOnEnter to false on mobile when the user has no saved preference
|
||||||
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
|
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
|
||||||
if (isMobile.current) {
|
if (isMobile.current) {
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ export {
|
|||||||
MENTION_BADGE_FOLDER_ICON_PATHS,
|
MENTION_BADGE_FOLDER_ICON_PATHS,
|
||||||
getMentionBadgeIconPaths,
|
getMentionBadgeIconPaths,
|
||||||
getMentionBadgeLabel,
|
getMentionBadgeLabel,
|
||||||
|
splitMentionSegments,
|
||||||
buildMentionInsertion
|
buildMentionInsertion
|
||||||
} from './mention-badge';
|
} from './mention-badge';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { abbreviateHome, lastPathSegment } from './path-display';
|
import { abbreviateHome, lastPathSegment } from './path-display';
|
||||||
import { FILE_URI_PREFIX } from '$lib/constants';
|
import { DIRECTORY_PATH_SUFFIX, FILE_URI_PREFIX } from '$lib/constants';
|
||||||
import {
|
import {
|
||||||
MENTION_BADGE_FILE_ICON_PATHS,
|
MENTION_BADGE_FILE_ICON_PATHS,
|
||||||
MENTION_BADGE_FOLDER_ICON_PATHS
|
MENTION_BADGE_FOLDER_ICON_PATHS,
|
||||||
|
MENTION_LINK_SCAN_FLAGS
|
||||||
} from '$lib/constants/mention-badge';
|
} from '$lib/constants/mention-badge';
|
||||||
import { FileMentionEntryType } from '$lib/enums';
|
import { FileMentionEntryType } from '$lib/enums';
|
||||||
import type { FileMentionEntry } from '$lib/types';
|
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[] {
|
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(
|
export function getMentionBadgeLabel(
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user