mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-08-15 20:18:33 +02:00
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:
+2
-1
@@ -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);
|
||||
|
||||
+2
-2
@@ -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}
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+32
-14
@@ -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 =
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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');
|
||||
|
||||
+7
-5
@@ -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
-1
@@ -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)}
|
||||
>
|
||||
|
||||
+2
-3
@@ -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'
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
Reference in New Issue
Block a user