]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: Refactor data-attrs constants, enum for bool strings (#27002)
authorAleksander Grygier <redacted>
Thu, 13 Aug 2026 18:01:12 +0000 (20:01 +0200)
committerGitHub <redacted>
Thu, 13 Aug 2026 18:01:12 +0000 (20:01 +0200)
* refactor: Data-attribute constants + boolean string enum

* refactor: Use CSS class string constants

* refactor: Address review comments

40 files changed:
tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte
tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectoryResultsList.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputBasic.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte
tools/ui/src/lib/components/app/chat/index.ts
tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts
tools/ui/src/lib/components/app/content/MarkdownContent/markdown-utils.ts
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-code-blocks.ts
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts
tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte
tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte
tools/ui/src/lib/components/app/misc/ConversationSelection.svelte
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
tools/ui/src/lib/components/app/settings/SettingsChatMobileHeader.svelte
tools/ui/src/lib/constants/chat-form.constants.ts
tools/ui/src/lib/constants/headers.constants.ts
tools/ui/src/lib/constants/markdown.constants.ts
tools/ui/src/lib/constants/mention-badge.constants.ts
tools/ui/src/lib/constants/ui.constants.ts
tools/ui/src/lib/enums/boolean-string.enums.ts [new file with mode: 0644]
tools/ui/src/lib/enums/index.ts
tools/ui/src/lib/hooks/use-marquee-selection.svelte.ts
tools/ui/src/lib/hooks/use-scroll-active-row.svelte.ts
tools/ui/src/lib/services/migration.service.ts
tools/ui/src/lib/types/index.ts
tools/ui/src/lib/utils/chat-form-input-rich-tokenizer.ts
tools/ui/src/lib/utils/index.ts
tools/ui/src/lib/utils/source-history.ts
tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts
tools/ui/tests/client/chat-form-input-rich-blocks.svelte.test.ts
tools/ui/tests/client/chat-form-input-rich-undo.svelte.test.ts
tools/ui/tests/client/chat-form-input-rich.svelte.test.ts
tools/ui/tests/client/chat-form-mention-picker-gate.svelte.test.ts

index 0efe052b1f19bf0b40f616e36d6ebeee4607f5a6..8e894917237575fb7fa2c22f0481a6490567fdf5 100644 (file)
@@ -5,6 +5,7 @@
                ChatAttachmentsPreviewNavButtons,
                ChatAttachmentsPreviewThumbnailStrip
        } from '$lib/components/app';
+       import { UI_DATA_ATTRS } from '$lib/constants';
        import { modelsStore } from '$lib/stores';
        import {
                createBase64DataUrl,
@@ -90,7 +91,7 @@
                const index = currentIndex;
 
                setTimeout(() => {
-                       const thumbnail = document.querySelector(`[data-thumbnail-index="${index}"]`);
+                       const thumbnail = document.querySelector(`[${UI_DATA_ATTRS.THUMBNAIL_INDEX}="${index}"]`);
 
                        thumbnail?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
                }, 0);
index 60b72077d884031b0a1f7795bb7ba4d1051c991f..366c8372b992b8d5f7e605cbe3534dd45fbbed00 100644 (file)
@@ -1,7 +1,7 @@
 <script lang="ts">
        import { FileText, Music, Video } from '@lucide/svelte';
        import { HorizontalScrollCarousel } from '$lib/components/app/misc';
-       import { ICON_CLASS_DEFAULT } from '$lib/constants';
+       import { ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
 
        interface PreviewItem {
                id: string;
@@ -36,7 +36,7 @@
                <HorizontalScrollCarousel class="max-w-full">
                        {#each items as item, index (item.id)}
                                <button
-                                       data-thumbnail-index={index}
+                                       {...{ [UI_DATA_ATTRS.THUMBNAIL_INDEX]: index }}
                                        class={[
                                                'relative flex-shrink-0 cursor-pointer overflow-hidden rounded border-2 bg-black/80 backdrop-blur-sm transition-all hover:opacity-90',
                                                index === currentIndex ? 'border-white' : 'border-transparent opacity-60',
index 49c3dfe205fabef36663557c3d38f696cb3242a6..a9f721e47ffc22f964f8c72562609c2389ab6415 100644 (file)
        }: Props = $props();
 
        // Component References
-       // Shared handle of the two input renderers (textarea + contenteditable).
+       // Shared handle of the two input renderers (plain textarea + rich chat form input).
        type ChatInputHandle = {
                focus(): void;
                resetHeight(): void;
                $state(undefined);
        let inputRef: ChatInputHandle | undefined = $state(undefined);
 
-       // Render-mode gate: the plain textarea by default, the contenteditable
+       // Render-mode gate: the plain textarea by default, the rich chat form input
        // while the buffer carries a `file://` mention link or a complete code
        // span (badges and code chips need a DOM the textarea cannot provide).
        // Demotes back once neither remains.
-       let useContenteditable = $state(false);
+       let useRichInput = $state(false);
 
        // Audio Recording State
        let isRecording = $state(false);
        }
 
        $effect(() => {
-               const wantContenteditable =
-                       containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
+               const wantRichInput = containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
 
-               if (useContenteditable === wantContenteditable) return;
+               if (useRichInput === wantRichInput) return;
 
                if (!caretOffsetPinned) {
                        pendingCaretOffset = inputRef?.getCaretOffset() ?? (value ?? '').length;
                }
 
-               useContenteditable = wantContenteditable;
+               useRichInput = wantRichInput;
                queueCaretRestore();
        });
 
 
                        // Caret inside a fenced code block (closed, or still open
                        // while being typed): Enter adds a line, never submits. The
-                       // contenteditable consumes this case locally; this gate
+                       // rich chat form input consumes this case locally; this gate
                        // covers the plain textarea, where skipping submit lets the
                        // native newline through.
                        if (!isModifier && isOffsetInCodeBlock(value ?? '', inputRef?.getCaretOffset() ?? 0)) {
                value = built.newValue;
                onValueChange?.(built.newValue);
 
-               // Already in contenteditable mode: no renderer flip, so the swap
+               // Already in rich chat form input mode: no renderer flip, so the swap
                // effect's caret restore never runs.
-               if (useContenteditable) {
+               if (useRichInput) {
                        queueCaretRestore();
                }
        }
                                onPaste={handlePaste}
                                {disabled}
                                {placeholder}
-                               {useContenteditable}
+                               {useRichInput}
                        />
 
                        {#if mcpResourceStore.hasAttachments}
index 78e5b5cb99ca3a4668f60fb34a2a9677cc465988..856b05cb0208346f79fd13c3e5653a3a5d16bd4e 100644 (file)
@@ -4,7 +4,7 @@
        import { FolderOpen } from '@lucide/svelte';
        import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
        import * as Popover from '$lib/components/ui/popover';
-       import { DEFAULT_MOBILE_BREAKPOINT, HOME_TILDE, SEARCH } from '$lib/constants';
+       import { DEFAULT_MOBILE_BREAKPOINT, HOME_TILDE, SEARCH, UI_DATA_ATTRS } from '$lib/constants';
        import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
        import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
        import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
        });
 
        useScrollActiveRow({
-               dataIndex: 'result',
+               dataAttr: UI_DATA_ATTRS.RESULT_INDEX,
                getContainer: () => listContainer,
                getCount: () => queryResults.length,
                getIndex: () => nav.hoveredIndex,
index 32de06189e5925f9371a639ad760f57aee54f293..e8087d967ef170a199b5ec8e5433450cc63525b5 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
        import { Folder } from '@lucide/svelte';
        import { cn } from '$lib/components/ui/utils';
+       import { UI_DATA_ATTRS } from '$lib/constants';
        import { highlightMatch } from '$lib/utils';
        import { fly } from 'svelte/transition';
 
@@ -46,7 +47,7 @@
                {#each results as path, index (path)}
                        <button
                                type="button"
-                               data-result-index={index}
+                               {...{ [UI_DATA_ATTRS.RESULT_INDEX]: index }}
                                data-highlighted={index === hoveredIndex ? '' : undefined}
                                class={cn(
                                        'relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground'
index 2b92b30fec4afa0702b483781d5407fa1bf8ee61..b708ae04754648fedf413beb275029a7a7e0531c 100644 (file)
@@ -10,7 +10,7 @@
                onPaste?: (event: ClipboardEvent) => void;
                placeholder?: string;
                value?: string;
-               useContenteditable?: boolean;
+               useRichInput?: boolean;
        }
 
        let {
@@ -20,7 +20,7 @@
                onKeydown,
                onPaste,
                placeholder = 'Ask anything...',
-               useContenteditable = false,
+               useRichInput = false,
                value = $bindable('')
        }: Props = $props();
 
        // The two renderers share one imperative handle (focus/caret/height), so
        // the parent can drive whichever variant is mounted through this one.
        export function getElement() {
-               return useContenteditable ? richRef?.getElement() : basicRef?.getElement();
+               return useRichInput ? richRef?.getElement() : basicRef?.getElement();
        }
 
        export function focus() {
-               if (useContenteditable) richRef?.focus();
+               if (useRichInput) richRef?.focus();
                else basicRef?.focus();
        }
 
        export function resetHeight() {
-               if (useContenteditable) richRef?.resetHeight();
+               if (useRichInput) richRef?.resetHeight();
                else basicRef?.resetHeight();
        }
 
        export function getCaretOffset(): number {
-               return useContenteditable
-                       ? (richRef?.getCaretOffset() ?? 0)
-                       : (basicRef?.getCaretOffset() ?? 0);
+               return useRichInput ? (richRef?.getCaretOffset() ?? 0) : (basicRef?.getCaretOffset() ?? 0);
        }
 
        export function setCaretOffset(offset: number) {
-               if (useContenteditable) richRef?.setCaretOffset(offset);
+               if (useRichInput) richRef?.setCaretOffset(offset);
                else basicRef?.setCaretOffset(offset);
        }
 </script>
 
-{#if useContenteditable}
+{#if useRichInput}
        <ChatFormInputRich
                bind:this={richRef}
                class={className}
index 623744d8af4dadbd44206c9f4040839b660cc099..e0c08c721fdbd2049bf72bc6bae9e94089aa4896 100644 (file)
@@ -48,7 +48,7 @@
                }
        }
 
-       // Plain-text caret offsets, shared with the contenteditable variant so
+       // Plain-text caret offsets, shared with the rich chat form input variant so
        // the picker/paste flows can address either renderer through one handle.
        export function getCaretOffset(): number {
                if (!textareaElement) return 0;
index f25c3c95cd2eadc03bb2dde3fc599bc449ae9750..d87817adb7d8f2fbe9b5f47fcb774a988e12dd87 100644 (file)
@@ -1,6 +1,6 @@
 <script lang="ts">
-       import { CODE_BLOCK } from '$lib/constants';
-       import { ColorMode } from '$lib/enums';
+       import { CODE_BLOCK, CODE_TOKEN_ATTR, UI_DATA_ATTRS } from '$lib/constants';
+       import { BooleanString, ChatFormInputRichTokenKind, ColorMode } from '$lib/enums';
        import { isMobile } from '$lib/stores';
        import type { ChatFormInputRichToken } from '$lib/types';
        import type { SourceHistoryEntry } from '$lib/utils';
@@ -53,7 +53,7 @@
        // browser's native undo stack.
        const history = new SourceHistory();
 
-       // Browsers disagree on what an empty contenteditable contains (`<br>`,
+       // Browsers disagree on what an empty rich chat form input contains (`<br>`,
        // `<div><br></div>`, or nothing), so emptiness is decided by the
        // serialized source, not the DOM shape.
        function syncEmptyState(serialized?: string) {
@@ -61,7 +61,7 @@
 
                const source = serialized ?? serializeContent(rootElement);
 
-               rootElement.dataset.empty = source.length === 0 ? 'true' : 'false';
+               rootElement.dataset.empty = source.length === 0 ? BooleanString.TRUE : BooleanString.FALSE;
        }
 
        function renderTokens(tokens: ChatFormInputRichToken[]) {
@@ -69,7 +69,7 @@
 
                const caret = rangeToTextOffset(rootElement, safeRange());
 
-               // eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
+               // eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
                rootElement.replaceChildren(buildFragment(tokens));
 
                syncCodeBlockHatches(rootElement);
        }
 
        function highlightCodeBlocks(root: HTMLElement) {
-               for (const el of root.querySelectorAll<HTMLElement>('code[data-code-token="code_block"]')) {
+               for (const el of root.querySelectorAll<HTMLElement>(
+                       `code[${CODE_TOKEN_ATTR}="${ChatFormInputRichTokenKind.CODE_BLOCK}"]`
+               )) {
                        highlightCodeBlockElement(el);
                }
        }
                }
 
                while (node && node !== rootElement) {
-                       if (node instanceof HTMLElement && node.dataset.codeToken === 'code_block') {
+                       if (
+                               node instanceof HTMLElement &&
+                               node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
+                       ) {
                                const caret = rangeToTextOffset(rootElement, range);
 
                                if (highlightCodeBlockElement(node)) {
         * (deduped via the data attribute) swapped on mode change.
         */
        function loadHighlightTheme(isDark: boolean) {
-               document.querySelectorAll('style[data-highlight-theme-preview]').forEach((s) => s.remove());
+               document
+                       .querySelectorAll(`style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`)
+                       .forEach((s) => s.remove());
 
                const style = document.createElement('style');
 
-               style.setAttribute('data-highlight-theme-preview', 'true');
+               style.setAttribute(UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW, BooleanString.TRUE);
                style.textContent = isDark ? githubDarkCss : githubLightCss;
 
                document.head.appendChild(style);
                                        source[source.length - 2] !== '\n' &&
                                        last?.nodeType === Node.TEXT_NODE
                                ) {
-                                       // eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
+                                       // eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
                                        rootElement.appendChild(document.createTextNode('\n'));
                                        restoreCaret(source.length);
                                        resizeHeight();
                        let node: Node | null = container.parentNode;
 
                        while (node && node !== rootElement) {
-                               if (node instanceof HTMLElement && node.dataset.codeToken === 'code_block') {
+                               if (
+                                       node instanceof HTMLElement &&
+                                       node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
+                               ) {
                                        const tail = document.createRange();
 
                                        tail.setStart(container, offset);
 
                const first = rootElement.firstChild;
 
-               if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'code_block') return false;
+               if (
+                       !(first instanceof HTMLElement) ||
+                       first.getAttribute(CODE_TOKEN_ATTR) !== ChatFormInputRichTokenKind.CODE_BLOCK
+               )
+                       return false;
 
                const range = safeRange();
 
                        if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
                }
 
-               // eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
+               // eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
                rootElement.prepend(document.createElement('br'));
                restoreCaret(0, extend);
 
 
                const second = first.nextSibling;
 
-               if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'code_block') return;
+               if (
+                       !(second instanceof HTMLElement) ||
+                       second.getAttribute(CODE_TOKEN_ATTR) !== ChatFormInputRichTokenKind.CODE_BLOCK
+               )
+                       return;
 
                const range = safeRange();
                const onHatch =
index 3ebb29b9b3c622697d7bfd877c665bda0a3502fb..160c14ce8e8a00c295fcf76db0a4b169000fa73f 100644 (file)
@@ -1,7 +1,7 @@
 <script lang="ts" generics="T">
        import { SearchInput } from '$lib/components/app';
        import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
-       import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
+       import { CHAT_FORM_POPOVER_MAX_HEIGHT, UI_DATA_ATTRS } from '$lib/constants';
        import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
        import type { Snippet } from 'svelte';
 
@@ -55,7 +55,7 @@
        // selectedIndex/items.length are untracked so hover and result replacement
        // never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
        useScrollActiveRow({
-               dataIndex: 'picker',
+               dataAttr: UI_DATA_ATTRS.PICKER_INDEX,
                getContainer: () => listContainer,
                getCount: () => items.length,
                getIndex: () => selectedIndex,
index 51a51f8080a515e577b156f780a56f2add1b0f66..045534f4882bfc01cf0e318a6e1380eb6cd5f3b1 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { UI_DATA_ATTRS } from '$lib/constants';
        import type { Snippet } from 'svelte';
 
        interface Props {
@@ -24,7 +25,7 @@
 
 <button
        type="button"
-       data-picker-index={dataIndex}
+       {...{ [UI_DATA_ATTRS.PICKER_INDEX]: dataIndex }}
        {disabled}
        {onclick}
        {onmouseenter}
index d9e408c96e5775604ece92482545b2e0a84ae65b..2de8a6ace1ba94b613b47e9247cfaecac1ac3024 100644 (file)
@@ -120,7 +120,7 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/
  * Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
  *
  * **Architecture:**
- * - Composes ChatFormInput (a plain textarea, or a contenteditable for
+ * - Composes ChatFormInput (a plain textarea, or a ChatFormInputRich for
  *   messages with file mention links), ChatFormActions, and ChatFormPickerMcpPrompts
  * - Manages file upload state via `uploadedFiles` bindable prop
  * - Integrates with ModelsSelectorDropdown for model selection in router mode
@@ -268,10 +268,10 @@ export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResou
 
 /**
  * The message editor. Renders a plain auto-resizing textarea by default,
- * or a contenteditable that renders `[name](file://...)` mention links as
+ * or a ChatFormInputRich that renders `[name](file://...)` mention links as
  * inline chips (keeping the value as the markdown source string) once a
  * mention link lands in the buffer. The variant is selected via the
- * `useContenteditable` prop; both share one imperative handle.
+ * `useRichInput` prop; both share one imperative handle.
  */
 export { default as ChatFormInput } from './ChatForm/ChatFormInput/ChatFormInput.svelte';
 
@@ -384,14 +384,14 @@ export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPi
  * tool, scoped to the conversation cwd (or server home when unset).
  * Selection splices a `[name](file:///<abs path>)` link into the input.
  */
-export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormPickerMention.svelte';
+export { default as ChatFormPickerMention } from './ChatForm/ChatFormPickers/ChatFormPickerMention.svelte';
 
 /**
  * `/`-triggered slash-command picker. Lists the available slash commands
  * (`/prompt`, `/cwd`, `/model`) filtered by the typed query; selection
  * hands the command to the parent for dispatch.
  */
-export { default as ChatFormCommandPicker } from './ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte';
+export { default as ChatFormPickerCommand } from './ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte';
 
 /**
  * Hosts the chat-form pickers (slash-command, MCP prompt, file mention)
index cadd9f210baa03ed42c4fc67c8bf5ed3e7533964..2a1d617ba24ba837238a887fb18b48dd3066c609 100644 (file)
                DialogMermaidPreview
        } from '$lib/components/app';
        import {
-               BOOL_TRUE_STRING,
                CODE_BLOCK_CLASS,
-               DATA_ERROR_BOUND_ATTR,
-               DATA_ERROR_HANDLED_ATTR,
                DIAGRAM_VIEW_MODE_ATTR,
                DIAGRAM_VIEW_RENDERED,
                DIAGRAM_VIEW_SOURCE,
                IMAGE_NOT_ERROR_BOUND_SELECTOR,
+               MARKDOWN_DATA_ATTRS,
                MERMAID_BLOCK_CLASS,
                MERMAID_LANGUAGE,
                MERMAID_RENDERED_ATTR,
@@ -42,7 +40,7 @@
                SVG,
                TOGGLE_SOURCE_BTN_CLASS
        } from '$lib/constants';
-       import { ColorMode, UrlProtocol } from '$lib/enums';
+       import { BooleanString, ColorMode, UrlProtocol } from '$lib/enums';
        import { FileTypeText } from '$lib/enums/files.enums';
        import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
        import { settingsStore } from '$lib/stores';
                        const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
                        const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
 
-                       if (copyButton && copyButton.dataset.listenerBound !== 'true') {
-                               copyButton.dataset.listenerBound = 'true';
+                       if (
+                               copyButton &&
+                               copyButton.getAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND) !== BooleanString.TRUE
+                       ) {
+                               copyButton.setAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND, BooleanString.TRUE);
                                copyButton.addEventListener('click', handleCopyClick);
                        }
 
-                       if (previewButton && previewButton.dataset.listenerBound !== 'true') {
-                               previewButton.dataset.listenerBound = 'true';
+                       if (
+                               previewButton &&
+                               previewButton.getAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND) !== BooleanString.TRUE
+                       ) {
+                               previewButton.setAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND, BooleanString.TRUE);
                                previewButton.addEventListener('click', handlePreviewClick);
                        }
                }
                const images = containerRef.querySelectorAll<HTMLImageElement>(IMAGE_NOT_ERROR_BOUND_SELECTOR);
 
                for (const img of images) {
-                       img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING;
+                       img.setAttribute(MARKDOWN_DATA_ATTRS.ERROR_BOUND, BooleanString.TRUE);
                        img.addEventListener('error', handleImageError);
                }
        }
 
                // Mark nodes immediately to prevent duplicate renders if called again during streaming.
                // This avoids needing a guard that would block node discovery.
-               nodes.forEach((node) => node.setAttribute(MERMAID_RENDERED_ATTR, 'true'));
+               nodes.forEach((node) => node.setAttribute(MERMAID_RENDERED_ATTR, BooleanString.TRUE));
 
                // Read mode before await so Svelte tracks it reactively.
                const isDark = mode.current === ColorMode.DARK;
                if (nodes.length === 0) return;
 
                nodes.forEach((node) => {
-                       node.setAttribute(SVG.RENDERED_ATTR, 'true');
+                       node.setAttribute(SVG.RENDERED_ATTR, BooleanString.TRUE);
 
                        const source = node.getAttribute(SVG.SOURCE_ATTR) ?? node.textContent ?? '';
                        const clean = sanitizeSvg(source);
                // Don't handle data URLs or already-handled images
                if (
                        img.src.startsWith(UrlProtocol.DATA) ||
-                       img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING
+                       img.getAttribute(MARKDOWN_DATA_ATTRS.ERROR_HANDLED) === BooleanString.TRUE
                )
                        return;
 
-               img.dataset[DATA_ERROR_HANDLED_ATTR] = BOOL_TRUE_STRING;
+               img.setAttribute(MARKDOWN_DATA_ATTRS.ERROR_HANDLED, BooleanString.TRUE);
 
                const src = img.src;
                // Create fallback element
                : ''}"
 >
        {#each renderedBlocks as block (block.id)}
-               <div class="markdown-block" data-block-id={block.id}>
+               <div class="markdown-block" {...{ [MARKDOWN_DATA_ATTRS.BLOCK_ID]: block.id }}>
                        {@html block.html}
                </div>
        {/each}
 
        {#if unstableBlockHtml}
-               <div class="markdown-block markdown-block--unstable" data-block-id="unstable">
+               <div
+                       class="markdown-block markdown-block--unstable"
+                       {...{ [MARKDOWN_DATA_ATTRS.BLOCK_ID]: 'unstable' }}
+               >
                        <!-- eslint-disable-next-line no-at-html-tags -->
                        {@html unstableBlockHtml}
                </div>
index bd6f28ee7f659518f5b53477916f0147e4f2486a..0a1db19093779a183cbfd2c3e586052ac2fc620d 100644 (file)
@@ -3,7 +3,14 @@
  * Uses dependency injection pattern to avoid direct component state access.
  */
 
-import { MERMAID_BLOCK_CLASS, MERMAID_SYNTAX_ATTR, MERMAID_WRAPPER_CLASS } from '$lib/constants';
+import {
+       CODE_BLOCK_CLASS,
+       MARKDOWN_DATA_ATTRS,
+       MERMAID_BLOCK_CLASS,
+       MERMAID_SYNTAX_ATTR,
+       MERMAID_WRAPPER_CLASS
+} from '$lib/constants';
+import { BooleanString } from '$lib/enums';
 import { copyCodeToClipboard, copyToClipboard } from '$lib/utils';
 
 export interface PreviewState {
@@ -40,11 +47,11 @@ export function createHandleCopyClick() {
 
                if (!target) return;
 
-               const wrapper = target.closest('.code-block-wrapper');
+               const wrapper = target.closest(`.${CODE_BLOCK_CLASS.WRAPPER}`);
 
                if (!wrapper) return;
 
-               const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
+               const codeElement = wrapper.querySelector<HTMLElement>(`code[${MARKDOWN_DATA_ATTRS.CODE_ID}]`);
 
                if (!codeElement) return;
 
@@ -86,16 +93,16 @@ export function createHandlePreviewClick(previewState: PreviewState) {
 
                if (!target) return;
 
-               const wrapper = target.closest('.code-block-wrapper');
+               const wrapper = target.closest(`.${CODE_BLOCK_CLASS.WRAPPER}`);
 
                if (!wrapper) return;
 
-               const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
+               const codeElement = wrapper.querySelector<HTMLElement>(`code[${MARKDOWN_DATA_ATTRS.CODE_ID}]`);
 
                if (!codeElement) return;
 
                const rawCode = codeElement.textContent ?? '';
-               const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
+               const languageLabel = wrapper.querySelector<HTMLElement>(`.${CODE_BLOCK_CLASS.LANGUAGE}`);
                const language = languageLabel?.textContent?.trim() || 'text';
 
                previewState.setPreviewCode(rawCode);
@@ -112,8 +119,8 @@ export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
        return async function handleMermaidClick(event: MouseEvent) {
                const target = event.target as HTMLElement;
                // Check if clicking on copy or preview button in mermaid block
-               const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .copy-code-btn`);
-               const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .preview-code-btn`);
+               const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .${CODE_BLOCK_CLASS.COPY_BTN}`);
+               const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .${CODE_BLOCK_CLASS.PREVIEW_BTN}`);
 
                if (copyBtn || previewBtn) {
                        const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
@@ -189,15 +196,17 @@ export function createHandleMermaidPreviewOpenChange(mermaidState: MermaidPrevie
 export function createHandleImageError(
        renderedBlocksState: RenderedBlocksState,
        IMAGE_NOT_ERROR_BOUND_SELECTOR: string,
-       DATA_ERROR_BOUND_ATTR: string,
-       BOOL_TRUE_STRING: string
+       errorBoundAttr: string,
+       booleanString: BooleanString
 ) {
        return async function handleImageError(event: Event) {
                const img = event.target as HTMLImageElement;
 
                if (!img) return;
 
-               const blockId = img.closest('[data-block-id]')?.getAttribute('data-block-id');
+               const blockId = img
+                       .closest(`[${MARKDOWN_DATA_ATTRS.BLOCK_ID}]`)
+                       ?.getAttribute(MARKDOWN_DATA_ATTRS.BLOCK_ID);
 
                if (!blockId) return;
 
@@ -206,19 +215,22 @@ export function createHandleImageError(
                if (!block) return;
 
                // Skip if already handled
-               if (img.dataset[DATA_ERROR_BOUND_ATTR] === BOOL_TRUE_STRING) return;
+               if (img.getAttribute(errorBoundAttr) === booleanString) return;
 
-               img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING;
+               img.setAttribute(errorBoundAttr, booleanString);
 
                // Get the fallback HTML and replace the image
-               const fallbackHtml = `<div class="image-error-placeholder" data-original-src="${img.src}">
+               const fallbackHtml = `<div class="image-error-placeholder" ${MARKDOWN_DATA_ATTRS.ORIGINAL_SRC}="${img.src}">
                        <span class="image-error-icon">⚠️</span>
                        <span class="image-error-text">Failed to load image</span>
                </div>`;
                // Replace the img element with fallback in the block's HTML
                const newHtml = block.html.replace(/img[^>]*src=["']([^"']*)[^>]*>/g, (match, src) => {
                        if (src === img.src) {
-                               return fallbackHtml.replace('data-original-src=""', `data-original-src="${src}"`);
+                               return fallbackHtml.replace(
+                                       `${MARKDOWN_DATA_ATTRS.ORIGINAL_SRC}=""`,
+                                       `${MARKDOWN_DATA_ATTRS.ORIGINAL_SRC}="${src}"`
+                               );
                        }
 
                        return match;
@@ -243,19 +255,27 @@ export function createSetupCodeBlockActions(
        return function setupCodeBlockActions(containerRef: HTMLElement | null) {
                if (!containerRef) return;
 
-               const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper');
+               const wrappers = containerRef.querySelectorAll<HTMLElement>(`.${CODE_BLOCK_CLASS.WRAPPER}`);
 
                for (const wrapper of wrappers) {
-                       const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
-                       const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
+                       const copyButton = wrapper.querySelector<HTMLButtonElement>(`.${CODE_BLOCK_CLASS.COPY_BTN}`);
+                       const previewButton = wrapper.querySelector<HTMLButtonElement>(
+                               `.${CODE_BLOCK_CLASS.PREVIEW_BTN}`
+                       );
 
-                       if (copyButton && copyButton.dataset.listenerBound !== 'true') {
-                               copyButton.dataset.listenerBound = 'true';
+                       if (
+                               copyButton &&
+                               copyButton.getAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND) !== BooleanString.TRUE
+                       ) {
+                               copyButton.setAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND, BooleanString.TRUE);
                                copyButton.addEventListener('click', handleCopyClick);
                        }
 
-                       if (previewButton && previewButton.dataset.listenerBound !== 'true') {
-                               previewButton.dataset.listenerBound = 'true';
+                       if (
+                               previewButton &&
+                               previewButton.getAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND) !== BooleanString.TRUE
+                       ) {
+                               previewButton.setAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND, BooleanString.TRUE);
                                previewButton.addEventListener('click', handlePreviewClick);
                        }
                }
@@ -269,8 +289,8 @@ export function createSetupCodeBlockActions(
 export function createSetupImageErrorHandlers(
        handleImageError: (event: Event) => void,
        IMAGE_NOT_ERROR_BOUND_SELECTOR: string,
-       DATA_ERROR_BOUND_ATTR: string,
-       BOOL_TRUE_STRING: string
+       errorBoundAttr: string,
+       booleanString: BooleanString
 ) {
        return function setupImageErrorHandlers(containerRef: HTMLElement | null) {
                if (!containerRef) return;
@@ -278,7 +298,7 @@ export function createSetupImageErrorHandlers(
                const images = containerRef.querySelectorAll<HTMLImageElement>(IMAGE_NOT_ERROR_BOUND_SELECTOR);
 
                for (const img of images) {
-                       img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING;
+                       img.setAttribute(errorBoundAttr, booleanString);
                        img.addEventListener('error', handleImageError);
                }
        };
index 96fcac6cec99b15c279040f1d70e349312e3f92e..9e2c0f4f8cc6ed5a1897eb982da4f636c8ab0eaa 100644 (file)
@@ -2,6 +2,7 @@
  * Utility functions for markdown processing in MarkdownContent component.
  */
 
+import { MARKDOWN_DATA_ATTRS } from '$lib/constants';
 import type { RootContent as HastRootContent } from 'hast';
 
 /**
@@ -69,7 +70,7 @@ export function getCodeInfoFromTarget(target: HTMLElement): CodeInfo | null {
                return null;
        }
 
-       const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
+       const codeElement = wrapper.querySelector<HTMLElement>(`code[${MARKDOWN_DATA_ATTRS.CODE_ID}]`);
 
        if (!codeElement) {
                console.error('No code element found in wrapper');
index 54d401c11666de30643828be47ae9a59fc0b2013..f42ab1c07caed00185976c5344891a9bd9fca88d 100644 (file)
@@ -17,7 +17,7 @@ import {
        createWrapper,
        generateBlockId
 } from './code-block-utils';
-import { CODE_BLOCK_CLASS } from '$lib/constants';
+import { CODE_BLOCK_CLASS, MARKDOWN_DATA_ATTRS } from '$lib/constants';
 import type { Element, ElementContent, Root } from 'hast';
 import type { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
@@ -65,16 +65,18 @@ export const rehypeEnhanceCodeBlocks: Plugin<[], Root> = () => {
 
                        codeElement.properties = {
                                ...codeElement.properties,
-                               'data-code-id': codeId
+                               [MARKDOWN_DATA_ATTRS.CODE_ID]: codeId
                        };
 
-                       const actions: Element[] = [createCopyButton(codeId, 'data-code-id', 'Copy code')];
+                       const actions: Element[] = [
+                               createCopyButton(codeId, MARKDOWN_DATA_ATTRS.CODE_ID, 'Copy code')
+                       ];
 
                        if (language.toLowerCase() === 'html') {
-                               actions.push(createPreviewButton(codeId, 'data-code-id', 'Preview code'));
+                               actions.push(createPreviewButton(codeId, MARKDOWN_DATA_ATTRS.CODE_ID, 'Preview code'));
                        }
 
-                       const header = createBlockHeader(language, codeId, 'data-code-id', actions);
+                       const header = createBlockHeader(language, codeId, MARKDOWN_DATA_ATTRS.CODE_ID, actions);
                        const wrapper = createWrapper(
                                header,
                                node,
index 7bc5ca3b8e3a10e7780784ca4d18f49a72f7f29a..ab50d437caa8abc13ded98354d687460aceac3b0 100644 (file)
@@ -1,6 +1,6 @@
 /**
  * Rehype plugin that rewrites `file://` markdown anchors into the inline
- * mention chip, sharing the class string with the contenteditable
+ * mention chip, sharing the class string with the ChatFormInputRich
  * tokenizer via `$lib/constants`.
  *
  * The chip is presentational: `file://` navigation is blocked from
index 829c8460344d5a9087569e10071b01ccb5bfea76..a879197a28cf2a8c98b050d40e227e7fd5b1c036 100644 (file)
@@ -1,7 +1,7 @@
 <script lang="ts">
        import { browser } from '$app/environment';
-       import { SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants';
-       import { ColorMode } from '$lib/enums';
+       import { SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX, UI_DATA_ATTRS } from '$lib/constants';
+       import { BooleanString, ColorMode } from '$lib/enums';
        import { highlightCode } from '$lib/utils';
        import githubLightCss from 'highlight.js/styles/github.css?inline';
        import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
        function loadHighlightTheme(isDark: boolean) {
                if (!browser) return;
 
-               const existingThemes = document.querySelectorAll('style[data-highlight-theme-preview]');
+               const existingThemes = document.querySelectorAll(
+                       `style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`
+               );
 
                existingThemes.forEach((style) => style.remove());
 
                const style = document.createElement('style');
 
-               style.setAttribute('data-highlight-theme-preview', 'true');
+               style.setAttribute(UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW, BooleanString.TRUE);
                style.textContent = isDark ? githubDarkCss : githubLightCss;
 
                document.head.appendChild(style);
index d8d6704253c962ff0951da018137d07770e412b6..9123dcef99e34c8e288199f33135f1223ff81e12 100644 (file)
@@ -4,14 +4,12 @@
        import { Button } from '$lib/components/ui/button';
        import * as Dialog from '$lib/components/ui/dialog';
        import {
-               BOOL_FALSE_STRING,
-               BOOL_TRUE_STRING,
                DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
                HEADERS,
                MCP_SERVER_ID_PREFIX,
                RECOMMENDED_MCP_SERVERS
        } from '$lib/constants';
-       import { HealthCheckStatus } from '$lib/enums';
+       import { BooleanString, HealthCheckStatus } from '$lib/enums';
        import { conversationsStore, mcpStore } from '$lib/stores';
        import { canonicalizeServerUrl, parseHeadersToArray, uuid } from '$lib/utils';
 
@@ -97,9 +95,9 @@
 
                if (!raw) return false;
 
-               if (raw === BOOL_TRUE_STRING) return true;
+               if (raw === BooleanString.TRUE) return true;
 
-               if (raw === BOOL_FALSE_STRING) return false;
+               if (raw === BooleanString.FALSE) return false;
 
                try {
                        const parsed = JSON.parse(raw);
                if (browser) {
                        localStorage.setItem(
                                DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
-                               dismissed ? BOOL_TRUE_STRING : BOOL_FALSE_STRING
+                               dismissed ? BooleanString.TRUE : BooleanString.FALSE
                        );
                }
        }
index a3096ffb332aebcd63390f7de4ecd71b183e6bd1..66f6b2cb899921e1ffbf139fa4d10f3fa2c73857 100644 (file)
@@ -3,6 +3,7 @@
        import { Button } from '$lib/components/ui/button';
        import { Checkbox } from '$lib/components/ui/checkbox';
        import { ScrollArea } from '$lib/components/ui/scroll-area';
+       import { UI_DATA_ATTRS } from '$lib/constants';
        import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
        import { SvelteSet } from 'svelte/reactivity';
 
                                                                class="cursor-pointer border-b transition-colors hover:bg-muted/50 {checked
                                                                        ? 'bg-muted/75'
                                                                        : ''}"
-                                                               data-conversation-row={conv.id}
+                                                               {...{ [UI_DATA_ATTRS.CONVERSATION_ROW]: conv.id }}
                                                                onmousedown={(event) => marquee.rowMouseDown(conv.id, event)}
                                                                onclick={(event) => marquee.rowClick(conv.id, event.shiftKey)}
                                                        >
index 5190badd67f2d0b994fc91350fea1fbf4f7fd7a7..a847e0aa92ce31a163e04a8411fe028fba49367c 100644 (file)
@@ -15,7 +15,7 @@
        import { TruncatedText } from '$lib/components/app';
        import { Checkbox } from '$lib/components/ui/checkbox';
        import * as Tooltip from '$lib/components/ui/tooltip';
-       import { FORK_TREE_DEPTH_PADDING, ICON_CLASS_DEFAULT } from '$lib/constants';
+       import { FORK_TREE_DEPTH_PADDING, ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
        import { RouterService } from '$lib/services/router.service';
        import { chatStore, conversationsStore } from '$lib/stores';
        import { onMount } from 'svelte';
        });
 </script>
 
-<!-- svelte-ignore a11y_mouse_events_have_key_events -->
 <button
        class="group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg py-1.5 text-left transition-colors hover:bg-foreground/10 {isActive
                ? 'bg-foreground/5 text-accent-foreground'
                : ''} {isSelected ? 'bg-primary/10 hover:bg-primary/15' : ''} {isSelectionMode
                ? 'is-selection-mode'
                : ''} px-2"
-       data-conversation-row={conversation.id}
+       {...{ [UI_DATA_ATTRS.CONVERSATION_ROW]: conversation.id }}
        onclick={(e) => handleSelect(e)}
        onmouseover={handleMouseOver}
        onmouseleave={handleMouseLeave}
index 7392b044605dfd41922b2503cd7961f38404a870..58617956a042a0d4e0eeb73f1c66deeb25ca25fd 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
        import { ChevronLeft, ChevronRight, Settings } from '@lucide/svelte';
-       import { ICON_CLASS_DEFAULT } from '$lib/constants';
+       import { ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
+       import { BooleanString } from '$lib/enums';
        import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
        import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
        import { onMount, tick } from 'svelte';
@@ -20,7 +21,9 @@
                await tick();
 
                if (carousel.scrollContainer) {
-                       const activeTab = carousel.scrollContainer.querySelector('[data-active="true"]');
+                       const activeTab = carousel.scrollContainer.querySelector(
+                               `[${UI_DATA_ATTRS.ACTIVE}="${BooleanString.TRUE}"]`
+                       );
 
                        if (activeTab instanceof HTMLElement) {
                                carousel.scrollToCenter(activeTab);
@@ -66,7 +69,7 @@
                                                                )
                                                                        ? 'bg-accent text-accent-foreground'
                                                                        : 'text-muted-foreground'}"
-                                                               data-active={isActive(section)}
+                                                               {...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
                                                                href={getHref(section)}
                                                                onclick={(e: MouseEvent) => {
                                                                        carousel.scrollToCenter(e.currentTarget as HTMLElement);
@@ -82,7 +85,7 @@
                                                                )
                                                                        ? 'bg-accent text-accent-foreground'
                                                                        : 'text-muted-foreground'}"
-                                                               data-active={isActive(section)}
+                                                               {...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
                                                                onclick={(e: MouseEvent) => {
                                                                        onSectionChange?.(section.title);
                                                                        carousel.scrollToCenter(e.currentTarget as HTMLElement);
index 6c413ed70a4dcfe17d40c3cf915590f3ab69d2b3..9fb786f927605ea76706d65dfa78a614f0a735a4 100644 (file)
@@ -1,3 +1,6 @@
+/** Data attribute that tags ChatFormInputRich code spans and blocks. */
+export const CODE_TOKEN_ATTR = 'data-code-token';
+
 export const INITIAL_FILE_SIZE = 0;
 export const PROMPT_CONTENT_SEPARATOR = '\n\n';
 export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
index f40df05710f3c8bbbab687c18844a9e87fdab2ef..d477fc87838adebe6371a72f2dfad5cf1ae34634 100644 (file)
@@ -1,3 +1,6 @@
+/** Number of trailing characters to keep visible when partially redacting mcp-session-id */
+const MCP_SESSION_ID_VISIBLE_CHARS = 5;
+
 /** HTTP header handling for API and MCP requests. */
 export const HEADERS = {
        /** Canonical casing for the Authorization header (RFC 7235) */
@@ -7,7 +10,7 @@ export const HEADERS = {
        /** Content-Type HTTP header name */
        CONTENT_TYPE: 'Content-Type',
        /** Partial-redaction rules for MCP headers: header name -> visible trailing chars */
-       PARTIAL_REDACT: new Map<string, number>([['mcp-session-id', 5]]),
+       PARTIAL_REDACT: new Map<string, number>([['mcp-session-id', MCP_SESSION_ID_VISIBLE_CHARS]]),
 
        /** Header names whose values should be redacted in diagnostic logs */
        REDACTED: new Set([
index c8289c39a1de542739fc4f33274c630c25c083ea..298a2f1108691469f79f6d1dd9e776aea77c8036 100644 (file)
@@ -1,8 +1,14 @@
 export const IMAGE_NOT_ERROR_BOUND_SELECTOR = 'img:not([data-error-bound])';
-export const DATA_ERROR_BOUND_ATTR = 'errorBound';
-export const DATA_ERROR_HANDLED_ATTR = 'errorHandled';
-export const BOOL_TRUE_STRING = 'true';
-export const BOOL_FALSE_STRING = 'false';
+
+/** Data attributes for the markdown renderer DOM contract. */
+export const MARKDOWN_DATA_ATTRS = {
+       BLOCK_ID: 'data-block-id',
+       CODE_ID: 'data-code-id',
+       ERROR_BOUND: 'data-error-bound',
+       ERROR_HANDLED: 'data-error-handled',
+       LISTENER_BOUND: 'data-listener-bound',
+       ORIGINAL_SRC: 'data-original-src'
+} as const;
 
 /** Markdown structural markers used by `looksLikeMarkdown`. Inline / line-level. */
 export const MARKDOWN = {
index a9ef963c3935c3fe002cff980cbc64f8488ae84b..e69211ec856bc27fa000d2ec3362c1e77c9a7e92 100644 (file)
@@ -1,6 +1,6 @@
 /**
  * Shared visual contract between the two DOM-only badge paths (the
- * contenteditable tokenizer + the rehype plugin). Svelte cannot be
+ * ChatFormInputRich tokenizer + the rehype plugin). Svelte cannot be
  * mounted at the per-keystroke tokenizer hot path nor from a hast tree,
  * so both emit the badge with the same class string literal; Tailwind's
  * scanner picks it up in both sources.
@@ -10,6 +10,13 @@ export const MENTION_BADGE_CLASSNAME =
 
 export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';
 
+/** Full `data-*` attribute names that tag ChatFormInputRich mention badges. */
+export const MENTION_BADGE_DATA_ATTRS = {
+       BADGE: 'data-mention-badge',
+       NAME: 'data-mention-name',
+       PATH: 'data-mention-path'
+} as const;
+
 /** Regex flag that makes the mention scanner walk every link in a message instead of the first. */
 export const MENTION_LINK_SCAN_FLAGS = 'g';
 
index 3374b6e0085cb58892eca14f5d31085f71d796b8..e4889649f4a9634b0e339b87faca88af620915a7 100644 (file)
@@ -7,6 +7,16 @@ import type { DesktopIconStripItem } from '$lib/types';
 export const FORK_TREE_DEPTH_PADDING = 8;
 export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
 
+/** Data attributes for app-level DOM contracts. */
+export const UI_DATA_ATTRS = {
+       ACTIVE: 'data-active',
+       CONVERSATION_ROW: 'data-conversation-row',
+       HIGHLIGHT_THEME_PREVIEW: 'data-highlight-theme-preview',
+       PICKER_INDEX: 'data-picker-index',
+       RESULT_INDEX: 'data-result-index',
+       THUMBNAIL_INDEX: 'data-thumbnail-index'
+} as const;
+
 export const TOOL_GROUP_LABELS = {
        [ToolSource.BUILTIN]: 'Built-in',
        [ToolSource.CUSTOM]: 'JSON Schema',
diff --git a/tools/ui/src/lib/enums/boolean-string.enums.ts b/tools/ui/src/lib/enums/boolean-string.enums.ts
new file mode 100644 (file)
index 0000000..d6b7ac8
--- /dev/null
@@ -0,0 +1,5 @@
+/** String representation of a boolean used in data attributes and persisted values. */
+export enum BooleanString {
+       TRUE = 'true',
+       FALSE = 'false'
+}
index 5d0884b9865e555fb6b46c77ddcc1edda70e9200..7ad7df7b8d4cb4f0f8c2cc534d056ce8f7d81895 100644 (file)
@@ -33,6 +33,8 @@ export {
 
 export { SessionRecordType } from './conversation-import.enums';
 
+export { BooleanString } from './boolean-string.enums';
+
 export { ReasoningEffort } from './reasoning-effort.enums';
 
 export {
index 05436ecc8d255c8513234f2acdb6afae5d57e628..800327c43ca852d9bf124a249d2d364c7408a3e7 100644 (file)
@@ -9,6 +9,7 @@
  * matches what the user sees on screen.
  */
 
+import { UI_DATA_ATTRS } from '$lib/constants';
 import { SvelteSet } from 'svelte/reactivity';
 
 interface UseMarqueeSelectionOptions {
@@ -18,8 +19,8 @@ interface UseMarqueeSelectionOptions {
        orderedIds: () => string[];
        /** Document listeners attach only while the getter returns true. */
        enabled: () => boolean;
-       /** DOM attribute key (after the `data-` prefix) that marks selectable rows. */
-       attributeName?: () => string;
+       /** Full `data-*` attribute that marks selectable rows. */
+       dataAttr?: () => string;
        /** Minimum pixel distance before a press becomes a marquee drag. */
        dragThresholdPx?: number;
 }
@@ -36,16 +37,8 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
        let dragMode: 'add' | 'remove' | null = null;
        let suppressNextClick = false;
 
-       function resolveAttributeName(): string {
-               return options.attributeName?.() ?? 'conversation-row';
-       }
-
-       /**
-        * `dataset` keys are camelCased. `data-conversation-row` -> `conversationRow`.
-        * We resolve the attribute name once per call and read via the camelCase key.
-        */
-       function datasetKey(key: string = resolveAttributeName()): string {
-               return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
+       function resolveDataAttr(): string {
+               return options.dataAttr?.() ?? UI_DATA_ATTRS.CONVERSATION_ROW;
        }
 
        function decideDragMode(startingRowId: string | null, currentlySelected: ReadonlySet<string>) {
@@ -78,9 +71,8 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
        }
 
        function findRowAtPoint(x: number, y: number): string | null {
-               const attr = resolveAttributeName();
-               const selector = `[data-${attr}]`;
-               const key = datasetKey(attr);
+               const attr = resolveDataAttr();
+               const selector = `[${attr}]`;
 
                let bestMatch: HTMLElement | null = null;
                let bestCenterDistance = Infinity;
@@ -89,7 +81,7 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
                        const rect = row.getBoundingClientRect();
 
                        if (y >= rect.top && y <= rect.bottom && x >= rect.left && x <= rect.right) {
-                               return row.dataset[key] ?? null;
+                               return row.getAttribute(attr);
                        }
 
                        if (x >= rect.left && x <= rect.right) {
@@ -102,13 +94,12 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
                        }
                }
 
-               return bestMatch ? (bestMatch.dataset[key] ?? null) : null;
+               return bestMatch ? bestMatch.getAttribute(attr) : null;
        }
 
        function updateMarqueeRect(currentX: number, currentY: number) {
-               const attr = resolveAttributeName();
-               const selector = `[data-${attr}]`;
-               const key = datasetKey(attr);
+               const attr = resolveDataAttr();
+               const selector = `[${attr}]`;
                const selected = options.selectedIds();
                const left = Math.min(dragStartX, currentX);
                const top = Math.min(dragStartY, currentY);
@@ -117,7 +108,7 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
                const visibleIds = new SvelteSet(options.orderedIds());
 
                for (const row of document.querySelectorAll<HTMLElement>(selector)) {
-                       const id = row.dataset[key];
+                       const id = row.getAttribute(attr);
 
                        if (!id || !visibleIds.has(id)) continue;
 
index 3e7cf0544044c0bbdb2456840a4a88873081991f..d353c93743ff4da125cdb595b9eafbae0a9add76 100644 (file)
@@ -11,8 +11,8 @@ export interface UseScrollActiveRowOptions {
        getContainer: () => HTMLDivElement | null;
        getIndex: () => number;
        getCount: () => number;
-       /** Attribute prefix, e.g. 'picker' for `[data-picker-index="0"]`. */
-       dataIndex: string;
+       /** Full data attribute marking the row, e.g. `data-picker-index`. */
+       dataAttr: string;
 }
 
 export function useScrollActiveRow(opts: UseScrollActiveRowOptions) {
@@ -41,9 +41,7 @@ export function useScrollActiveRow(opts: UseScrollActiveRowOptions) {
 
                        if (!container || index < 0 || index >= opts.getCount()) return;
 
-                       const row = container.querySelector(
-                               `[data-${opts.dataIndex}-index="${index}"]`
-                       ) as HTMLElement | null;
+                       const row = container.querySelector(`[${opts.dataAttr}="${index}"]`) as HTMLElement | null;
 
                        row?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
                });
index 3dcbbed8278194c44ec478eb5fe4bff91be85d98..2626a42b3c99f3890adb72c525edeb74d97facb4 100644 (file)
@@ -29,7 +29,7 @@ import {
        STORAGE_APP_NAME,
        STORAGE_APP_NAME_DEPRECATED
 } from '$lib/constants';
-import { MessageRole } from '$lib/enums';
+import { BooleanString, MessageRole } from '$lib/enums';
 import Dexie from 'dexie';
 
 // Types
@@ -613,10 +613,10 @@ const configTypesMigration: Migration = {
                // schema rejects them. No config string field holds exactly "true"/"false", so the
                // match is unambiguous.
                for (const key of Object.keys(config)) {
-                       if (config[key] === 'true') {
+                       if (config[key] === BooleanString.TRUE) {
                                config[key] = true;
                                changed = true;
-                       } else if (config[key] === 'false') {
+                       } else if (config[key] === BooleanString.FALSE) {
                                config[key] = false;
                                changed = true;
                        }
index 497bfd415e0c0ab9567e8ed0e2d1bbaf77bb01fe..562b63e9809539a212e105b3c1b45ad19aa4dd7e 100644 (file)
@@ -182,7 +182,7 @@ export type {
        GlobSearchChildResult
 } from './glob';
 
-// Contenteditable token types (chat form)
+// ChatFormInputRich token types (chat form)
 export type { ChatFormInputRichToken } from './chat-form-input-rich';
 
 // Agentic types
index 7af350b24d5fa2178982d3c577e4ed4e4eb100a0..c09afd018bcc48370a97ca2dbb65e2545bdd5967 100644 (file)
@@ -30,12 +30,14 @@ import {
        getMentionBadgeLabel
 } from './mention-badge';
 import {
+       CODE_TOKEN_ATTR,
        MENTION_BADGE_CLASSNAME,
+       MENTION_BADGE_DATA_ATTRS,
        MENTION_BADGE_ICON_CLASSNAME,
        MENTION_BADGE_SVG_ATTRIBUTES,
        SETTINGS_KEYS
 } from '$lib/constants';
-import { ChatFormInputRichTokenKind } from '$lib/enums';
+import { BooleanString, ChatFormInputRichTokenKind } from '$lib/enums';
 import { settingsStore } from '$lib/stores/settings.svelte';
 import { toolsStore } from '$lib/stores/tools.svelte';
 import type { ChatFormInputRichToken } from '$lib/types/chat-form-input-rich';
@@ -168,7 +170,8 @@ function pushTextAndBadgeTokens(input: string, tokens: ChatFormInputRichToken[])
 
 function isCodeBlockElement(node: Node | null): node is HTMLElement {
        return (
-               node instanceof HTMLElement && node.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK
+               node instanceof HTMLElement &&
+               node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
        );
 }
 
@@ -210,9 +213,9 @@ export function serializeContent(root: HTMLElement): string {
 
                        const el = child as HTMLElement;
 
-                       if (el.dataset.mentionBadge === 'true') {
-                               const name = el.dataset.mentionName ?? '';
-                               const path = el.dataset.mentionPath ?? '';
+                       if (el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE) {
+                               const name = el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '';
+                               const path = el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? '';
 
                                if (name && path) {
                                        if (pendingBlockBoundary) {
@@ -227,8 +230,10 @@ export function serializeContent(root: HTMLElement): string {
                                continue;
                        }
 
-                       if (el.dataset.codeToken !== undefined) {
-                               const isBlock = el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
+                       const codeToken = el.getAttribute(CODE_TOKEN_ATTR);
+
+                       if (codeToken !== null) {
+                               const isBlock = codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
 
                                if (isBlock && (pendingBlockBoundary || !first)) out += '\n';
 
@@ -297,8 +302,8 @@ export function domMatchesTokens(root: HTMLElement, tokens: ChatFormInputRichTok
                        if (child.nodeType !== Node.ELEMENT_NODE) continue;
 
                        const el = child as HTMLElement;
-                       const isBadge = el.dataset.mentionBadge === 'true';
-                       const isCode = el.dataset.codeToken !== undefined;
+                       const isBadge = el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE;
+                       const isCode = el.getAttribute(CODE_TOKEN_ATTR) !== null;
 
                        if (!isBadge && !isCode) {
                                if (!walk(el)) return false;
@@ -313,15 +318,15 @@ export function domMatchesTokens(root: HTMLElement, tokens: ChatFormInputRichTok
                        if (isBadge) {
                                if (token.kind !== ChatFormInputRichTokenKind.BADGE) return false;
 
-                               if (token.name !== (el.dataset.mentionName ?? '')) return false;
+                               if (token.name !== (el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '')) return false;
 
-                               if (token.path !== (el.dataset.mentionPath ?? '')) return false;
+                               if (token.path !== (el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? '')) return false;
 
                                continue;
                        }
 
                        const codeKind: ChatFormInputRichTokenKind =
-                               el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK
+                               el.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
                                        ? ChatFormInputRichTokenKind.CODE_BLOCK
                                        : ChatFormInputRichTokenKind.CODE_INLINE;
 
@@ -430,8 +435,11 @@ export function rangeToTextOffset(root: HTMLElement, range: Range | null): numbe
                                total += 1;
                        }
 
-                       if (el.dataset.mentionBadge === 'true') {
-                               const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
+                       if (el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE) {
+                               const len = badgeSourceLength(
+                                       el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '',
+                                       el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? ''
+                               );
 
                                if (len === 0) continue;
 
@@ -447,8 +455,10 @@ export function rangeToTextOffset(root: HTMLElement, range: Range | null): numbe
                                continue;
                        }
 
-                       if (el.dataset.codeToken !== undefined) {
-                               const isBlock = el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
+                       const codeToken = el.getAttribute(CODE_TOKEN_ATTR);
+
+                       if (codeToken !== null) {
+                               const isBlock = codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
 
                                if (isBlock && !first) {
                                        if (!atOrBeforeCaret(el, 0)) {
@@ -562,7 +572,7 @@ export function buildFragment(tokens: ChatFormInputRichToken[]): DocumentFragmen
                ) {
                        const code = document.createElement('code');
 
-                       code.dataset.codeToken = token.kind;
+                       code.setAttribute(CODE_TOKEN_ATTR, token.kind);
                        code.textContent = token.text;
                        fragment.appendChild(code);
 
@@ -578,9 +588,9 @@ export function buildFragment(tokens: ChatFormInputRichToken[]): DocumentFragmen
 
                const badge = document.createElement('span');
 
-               badge.dataset.mentionBadge = 'true';
-               badge.dataset.mentionName = token.name;
-               badge.dataset.mentionPath = token.path;
+               badge.setAttribute(MENTION_BADGE_DATA_ATTRS.BADGE, BooleanString.TRUE);
+               badge.setAttribute(MENTION_BADGE_DATA_ATTRS.NAME, token.name);
+               badge.setAttribute(MENTION_BADGE_DATA_ATTRS.PATH, token.path);
                badge.title = decodeFileLinkPath(token.path);
                badge.className = MENTION_BADGE_CLASSNAME;
                badge.contentEditable = 'false';
@@ -876,8 +886,11 @@ export function textOffsetToRange(root: HTMLElement, offset: number): Range {
 
                        const el = child as HTMLElement;
 
-                       if (el.dataset.mentionBadge === 'true') {
-                               const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
+                       if (el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE) {
+                               const len = badgeSourceLength(
+                                       el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '',
+                                       el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? ''
+                               );
 
                                if (len === 0) continue;
 
@@ -915,8 +928,10 @@ export function textOffsetToRange(root: HTMLElement, offset: number): Range {
                                continue;
                        }
 
-                       if (el.dataset.codeToken !== undefined) {
-                               const isBlock = el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
+                       const codeToken = el.getAttribute(CODE_TOKEN_ATTR);
+
+                       if (codeToken !== null) {
+                               const isBlock = codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
 
                                if (isBlock && (pendingBlockBoundary || !first)) {
                                        pendingBlockBoundary = false;
index abe21427242510c59882fb7f322169aa799094a7..3fb701b323bd82a5510f4507def051242b13ae84 100644 (file)
@@ -207,7 +207,7 @@ export {
        type CommandDismissSnapshot
 } from './command-token';
 
-// Tokenization for the chat-form contenteditable (mention links + code spans <-> chip DOM)
+// Tokenization for the ChatFormInputRich (mention links + code spans <-> chip DOM)
 export {
        tokenizeContent,
        containsCodeSpan,
@@ -223,10 +223,10 @@ export {
        leadingBadgeEdgeOffset
 } from './chat-form-input-rich-tokenizer';
 
-// Source-space undo/redo history for the chat-form contenteditable
+// Source-space undo/redo history for the ChatFormInputRich
 export { SourceHistory, type SourceHistoryEntry } from './source-history';
 
-// Mention-badge visual contract (used by the contenteditable / rehype
+// Mention-badge visual contract (used by the ChatFormInputRich / rehype
 // DOM paths that build the same chip without a Svelte mount)
 export {
        containsFileMentionLink,
index 12b273b243212c1ba9130d5653f8049934dccb43..32995ae034659b349ee2de1a7b0584cda7e6f19d 100644 (file)
@@ -1,5 +1,5 @@
 /**
- * Source-space undo/redo history for the chat-form contenteditable, whose
+ * Source-space undo/redo history for the ChatFormInputRich, whose
  * imperative DOM rebuilds destroy the browser's native undo stack.
  * Entries record the state BEFORE an edit; edits within `groupWindowMs`
  * extend the open group so a typing burst undoes as a unit, while
index e7c1779276b85723828e012bf94bb0a582c7c2b9..485dc39655a68722cef72dac35f8a9d135a7b97e 100644 (file)
@@ -2,7 +2,7 @@
 // fenced-code-block flow: while the caret sits inside a fenced
 // block region - closed, or still OPEN while the user is typing
 // one - plain Enter adds a line instead of submitting the message.
-// The textarea path is covered here end-to-end (the contenteditable
+// The textarea path is covered here end-to-end (the ChatFormInputRich
 // consumes the same case locally; see chat-form-input-rich).
 
 import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
index ab7fa01334e0b6735d7cd565a28b368c5a1a722a..7c59ff951c846bc937c3e684ce8934989fb315c7 100644 (file)
@@ -1,4 +1,4 @@
-// Guards the newline contract of the chat-form contenteditable: browsers
+// Guards the newline contract of the ChatFormInputRich: browsers
 // restructure the flat DOM on Enter (`<div>` wrappers, `<br>` shapes) and
 // serialization must fold those back into `\n` so the emitted value never
 // diverges from what is on screen.
@@ -13,7 +13,7 @@ const SOURCE = 'see [docs](file:///a/b) here';
 function editableIn(container: HTMLElement): HTMLElement {
        const el = container.querySelector('[role="textbox"]');
 
-       if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
+       if (!(el instanceof HTMLElement)) throw new Error('ChatFormInputRich not rendered');
 
        return el;
 }
index a89a888e023b61c1203ef6446f71bbb5874c28dc..00f07f48203781c5c61b93c716b55a7fe8b974dc 100644 (file)
@@ -1,4 +1,4 @@
-// Guards the editing-key contract of the chat-form contenteditable:
+// Guards the editing-key contract of the ChatFormInputRich:
 // undo/redo is replayed from source snapshots (the token rebuilds destroy
 // the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
 // keyboard trap), matching the plain textarea.
@@ -13,7 +13,7 @@ const SOURCE = 'see [docs](file:///a/b)';
 function editableIn(container: HTMLElement): HTMLElement {
        const el = container.querySelector('[role="textbox"]');
 
-       if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
+       if (!(el instanceof HTMLElement)) throw new Error('ChatFormInputRich not rendered');
 
        return el;
 }
index c5120fb198bc643e26d5024a60c383b7814f2f26..d3403030d5cd9361fba0e6b1b07d8bdf7feca30c 100644 (file)
@@ -1,4 +1,4 @@
-// Guards the clipboard contract of the chat-form contenteditable:
+// Guards the clipboard contract of the ChatFormInputRich:
 // copy/cut expose the markdown SOURCE of the selection (each badge
 // contributes its full `[name](file://...)` link) and pasting such
 // markdown re-renders the badges.
@@ -16,7 +16,7 @@ const BADGE_SELECTOR = '[data-mention-badge="true"]';
 function editableIn(container: HTMLElement): HTMLElement {
        const el = container.querySelector('[role="textbox"]');
 
-       if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
+       if (!(el instanceof HTMLElement)) throw new Error('ChatFormInputRich not rendered');
 
        return el;
 }
index a6ff29597a67b2ec26a166bb30b9bb81fb20007c..fc06e5d2c1e7299e3f15fb77ee63a06223ec22f8 100644 (file)
@@ -3,7 +3,7 @@
 // it, the picker still opens but explains why instead of firing searches
 // that would only fail with "Search failed".
 
-import ChatFormMentionPicker from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMention.svelte';
+import ChatFormPickerMention from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMention.svelte';
 import { DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY } from '$lib/constants';
 import { BuiltInTool } from '$lib/enums';
 import { toolsStore } from '$lib/stores/tools.svelte';
@@ -25,7 +25,7 @@ function setBuiltinTools(defs: OpenAIToolDefinition[]) {
 }
 
 function renderPicker() {
-       return render(ChatFormMentionPicker, {
+       return render(ChatFormPickerMention, {
                isOpen: true,
                onClose: () => {},
                onSelect: () => {},
@@ -39,7 +39,7 @@ afterEach(() => {
        localStorage.removeItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
 });
 
-describe('ChatFormMentionPicker file_glob_search gate', () => {
+describe('ChatFormPickerMention file_glob_search gate', () => {
        it('explains that file search is unavailable when the server has no tools', async () => {
                setBuiltinTools([]);
                renderPicker();