ui: Refactor data-attrs constants, enum for bool strings (#27002)

* refactor: Data-attribute constants + boolean string enum

* refactor: Use CSS class string constants

* refactor: Address review comments
This commit is contained in:
Aleksander Grygier
2026-08-13 20:01:12 +02:00
committed by GitHub
parent fa4ec4590c
commit bdffafa5df
40 changed files with 280 additions and 189 deletions
@@ -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);
@@ -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',
@@ -109,7 +109,7 @@
}: 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;
@@ -125,11 +125,11 @@
$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);
@@ -241,16 +241,15 @@
}
$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();
});
@@ -314,7 +313,7 @@
// 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)) {
@@ -507,9 +506,9 @@
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();
}
}
@@ -614,7 +613,7 @@
onPaste={handlePaste}
{disabled}
{placeholder}
{useContenteditable}
{useRichInput}
/>
{#if mcpResourceStore.hasAttachments}
@@ -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';
@@ -120,7 +120,7 @@
});
useScrollActiveRow({
dataIndex: 'result',
dataAttr: UI_DATA_ATTRS.RESULT_INDEX,
getContainer: () => listContainer,
getCount: () => queryResults.length,
getIndex: () => nav.hoveredIndex,
@@ -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'
@@ -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();
@@ -30,32 +30,30 @@
// 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}
@@ -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;
@@ -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);
@@ -127,7 +127,9 @@
}
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);
}
}
@@ -151,7 +153,10 @@
}
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)) {
@@ -189,11 +194,13 @@
* (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);
@@ -311,7 +318,7 @@
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();
@@ -404,7 +411,10 @@
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);
@@ -462,7 +472,11 @@
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();
@@ -483,7 +497,7 @@
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);
@@ -507,7 +521,11 @@
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 =
@@ -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,
@@ -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}
@@ -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)
@@ -25,14 +25,12 @@
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';
@@ -486,13 +484,19 @@
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);
}
}
@@ -508,7 +512,7 @@
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);
}
}
@@ -691,7 +695,7 @@
// 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;
@@ -738,7 +742,7 @@
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);
@@ -765,11 +769,11 @@
// 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
@@ -869,13 +873,16 @@
: ''}"
>
{#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>
@@ -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);
}
};
@@ -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');
@@ -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,
@@ -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
@@ -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';
@@ -38,13 +38,15 @@
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);
@@ -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);
@@ -116,7 +114,7 @@
if (browser) {
localStorage.setItem(
DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
dismissed ? BOOL_TRUE_STRING : BOOL_FALSE_STRING
dismissed ? BooleanString.TRUE : BooleanString.FALSE
);
}
}
@@ -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';
@@ -138,7 +139,7 @@
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)}
>
@@ -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';
@@ -154,14 +154,13 @@
});
</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}
@@ -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);
@@ -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 = '"';
@@ -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([
@@ -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 = {
@@ -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';
@@ -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',
@@ -0,0 +1,5 @@
/** String representation of a boolean used in data attributes and persisted values. */
export enum BooleanString {
TRUE = 'true',
FALSE = 'false'
}
+2
View 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 {
@@ -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;
@@ -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' });
});
@@ -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;
}
+1 -1
View 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
@@ -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;
+3 -3
View 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,
+1 -1
View 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
@@ -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';
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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();