ChatAttachmentsPreviewNavButtons,
ChatAttachmentsPreviewThumbnailStrip
} from '$lib/components/app';
+ import { UI_DATA_ATTRS } from '$lib/constants';
import { modelsStore } from '$lib/stores';
import {
createBase64DataUrl,
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);
<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;
<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',
}: 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}
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,
<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';
{#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'
onPaste?: (event: ClipboardEvent) => void;
placeholder?: string;
value?: string;
- useContenteditable?: boolean;
+ useRichInput?: boolean;
}
let {
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}
}
}
- // 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;
<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';
// 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) {
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[]) {
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 =
<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';
// 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,
<script lang="ts">
+ import { UI_DATA_ATTRS } from '$lib/constants';
import type { Snippet } from 'svelte';
interface Props {
<button
type="button"
- data-picker-index={dataIndex}
+ {...{ [UI_DATA_ATTRS.PICKER_INDEX]: dataIndex }}
{disabled}
{onclick}
{onmouseenter}
* 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
/**
* 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';
* 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)
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,
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>
* 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 {
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;
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);
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}`);
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;
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;
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);
}
}
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;
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);
}
};
* Utility functions for markdown processing in MarkdownContent component.
*/
+import { MARKDOWN_DATA_ATTRS } from '$lib/constants';
import type { RootContent as HastRootContent } from 'hast';
/**
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');
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';
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,
/**
* 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
<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);
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';
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
);
}
}
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)}
>
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}
<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';
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);
)
? '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);
)
? '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);
+/** 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 = '"';
+/** 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) */
/** 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([
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 = {
/**
* 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.
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';
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',
--- /dev/null
+/** String representation of a boolean used in data attributes and persisted values. */
+export enum BooleanString {
+ TRUE = 'true',
+ FALSE = 'false'
+}
export { SessionRecordType } from './conversation-import.enums';
+export { BooleanString } from './boolean-string.enums';
+
export { ReasoningEffort } from './reasoning-effort.enums';
export {
* matches what the user sees on screen.
*/
+import { UI_DATA_ATTRS } from '$lib/constants';
import { SvelteSet } from 'svelte/reactivity';
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;
}
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>) {
}
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;
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) {
}
}
- 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);
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;
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) {
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' });
});
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
// 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;
}
GlobSearchChildResult
} from './glob';
-// Contenteditable token types (chat form)
+// ChatFormInputRich token types (chat form)
export type { ChatFormInputRichToken } from './chat-form-input-rich';
// Agentic types
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';
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
);
}
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) {
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';
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;
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;
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;
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)) {
) {
const code = document.createElement('code');
- code.dataset.codeToken = token.kind;
+ code.setAttribute(CODE_TOKEN_ATTR, token.kind);
code.textContent = token.text;
fragment.appendChild(code);
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';
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;
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;
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,
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,
/**
- * 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
// 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';
-// 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.
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;
}
-// 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.
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;
}
-// 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.
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;
}
// 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';
}
function renderPicker() {
- return render(ChatFormMentionPicker, {
+ return render(ChatFormPickerMention, {
isOpen: true,
onClose: () => {},
onSelect: () => {},
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();