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,
|
ChatAttachmentsPreviewNavButtons,
|
||||||
ChatAttachmentsPreviewThumbnailStrip
|
ChatAttachmentsPreviewThumbnailStrip
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
|
import { UI_DATA_ATTRS } from '$lib/constants';
|
||||||
import { modelsStore } from '$lib/stores';
|
import { modelsStore } from '$lib/stores';
|
||||||
import {
|
import {
|
||||||
createBase64DataUrl,
|
createBase64DataUrl,
|
||||||
@@ -90,7 +91,7 @@
|
|||||||
const index = currentIndex;
|
const index = currentIndex;
|
||||||
|
|
||||||
setTimeout(() => {
|
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' });
|
thumbnail?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { FileText, Music, Video } from '@lucide/svelte';
|
import { FileText, Music, Video } from '@lucide/svelte';
|
||||||
import { HorizontalScrollCarousel } from '$lib/components/app/misc';
|
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 {
|
interface PreviewItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<HorizontalScrollCarousel class="max-w-full">
|
<HorizontalScrollCarousel class="max-w-full">
|
||||||
{#each items as item, index (item.id)}
|
{#each items as item, index (item.id)}
|
||||||
<button
|
<button
|
||||||
data-thumbnail-index={index}
|
{...{ [UI_DATA_ATTRS.THUMBNAIL_INDEX]: index }}
|
||||||
class={[
|
class={[
|
||||||
'relative flex-shrink-0 cursor-pointer overflow-hidden rounded border-2 bg-black/80 backdrop-blur-sm transition-all hover:opacity-90',
|
'relative flex-shrink-0 cursor-pointer overflow-hidden rounded border-2 bg-black/80 backdrop-blur-sm transition-all hover:opacity-90',
|
||||||
index === currentIndex ? 'border-white' : 'border-transparent opacity-60',
|
index === currentIndex ? 'border-white' : 'border-transparent opacity-60',
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// Component References
|
// 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 = {
|
type ChatInputHandle = {
|
||||||
focus(): void;
|
focus(): void;
|
||||||
resetHeight(): void;
|
resetHeight(): void;
|
||||||
@@ -125,11 +125,11 @@
|
|||||||
$state(undefined);
|
$state(undefined);
|
||||||
let inputRef: ChatInputHandle | undefined = $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
|
// while the buffer carries a `file://` mention link or a complete code
|
||||||
// span (badges and code chips need a DOM the textarea cannot provide).
|
// span (badges and code chips need a DOM the textarea cannot provide).
|
||||||
// Demotes back once neither remains.
|
// Demotes back once neither remains.
|
||||||
let useContenteditable = $state(false);
|
let useRichInput = $state(false);
|
||||||
|
|
||||||
// Audio Recording State
|
// Audio Recording State
|
||||||
let isRecording = $state(false);
|
let isRecording = $state(false);
|
||||||
@@ -241,16 +241,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const wantContenteditable =
|
const wantRichInput = containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
|
||||||
containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
|
|
||||||
|
|
||||||
if (useContenteditable === wantContenteditable) return;
|
if (useRichInput === wantRichInput) return;
|
||||||
|
|
||||||
if (!caretOffsetPinned) {
|
if (!caretOffsetPinned) {
|
||||||
pendingCaretOffset = inputRef?.getCaretOffset() ?? (value ?? '').length;
|
pendingCaretOffset = inputRef?.getCaretOffset() ?? (value ?? '').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
useContenteditable = wantContenteditable;
|
useRichInput = wantRichInput;
|
||||||
queueCaretRestore();
|
queueCaretRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -314,7 +313,7 @@
|
|||||||
|
|
||||||
// Caret inside a fenced code block (closed, or still open
|
// Caret inside a fenced code block (closed, or still open
|
||||||
// while being typed): Enter adds a line, never submits. The
|
// 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
|
// covers the plain textarea, where skipping submit lets the
|
||||||
// native newline through.
|
// native newline through.
|
||||||
if (!isModifier && isOffsetInCodeBlock(value ?? '', inputRef?.getCaretOffset() ?? 0)) {
|
if (!isModifier && isOffsetInCodeBlock(value ?? '', inputRef?.getCaretOffset() ?? 0)) {
|
||||||
@@ -507,9 +506,9 @@
|
|||||||
value = built.newValue;
|
value = built.newValue;
|
||||||
onValueChange?.(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.
|
// effect's caret restore never runs.
|
||||||
if (useContenteditable) {
|
if (useRichInput) {
|
||||||
queueCaretRestore();
|
queueCaretRestore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -614,7 +613,7 @@
|
|||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
{disabled}
|
{disabled}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
{useContenteditable}
|
{useRichInput}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if mcpResourceStore.hasAttachments}
|
{#if mcpResourceStore.hasAttachments}
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
import { FolderOpen } from '@lucide/svelte';
|
import { FolderOpen } from '@lucide/svelte';
|
||||||
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
|
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
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 { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
|
||||||
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
|
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
|
||||||
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
useScrollActiveRow({
|
useScrollActiveRow({
|
||||||
dataIndex: 'result',
|
dataAttr: UI_DATA_ATTRS.RESULT_INDEX,
|
||||||
getContainer: () => listContainer,
|
getContainer: () => listContainer,
|
||||||
getCount: () => queryResults.length,
|
getCount: () => queryResults.length,
|
||||||
getIndex: () => nav.hoveredIndex,
|
getIndex: () => nav.hoveredIndex,
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Folder } from '@lucide/svelte';
|
import { Folder } from '@lucide/svelte';
|
||||||
import { cn } from '$lib/components/ui/utils';
|
import { cn } from '$lib/components/ui/utils';
|
||||||
|
import { UI_DATA_ATTRS } from '$lib/constants';
|
||||||
import { highlightMatch } from '$lib/utils';
|
import { highlightMatch } from '$lib/utils';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
{#each results as path, index (path)}
|
{#each results as path, index (path)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-result-index={index}
|
{...{ [UI_DATA_ATTRS.RESULT_INDEX]: index }}
|
||||||
data-highlighted={index === hoveredIndex ? '' : undefined}
|
data-highlighted={index === hoveredIndex ? '' : undefined}
|
||||||
class={cn(
|
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'
|
'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;
|
onPaste?: (event: ClipboardEvent) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
useContenteditable?: boolean;
|
useRichInput?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
onKeydown,
|
onKeydown,
|
||||||
onPaste,
|
onPaste,
|
||||||
placeholder = 'Ask anything...',
|
placeholder = 'Ask anything...',
|
||||||
useContenteditable = false,
|
useRichInput = false,
|
||||||
value = $bindable('')
|
value = $bindable('')
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -30,32 +30,30 @@
|
|||||||
// The two renderers share one imperative handle (focus/caret/height), so
|
// The two renderers share one imperative handle (focus/caret/height), so
|
||||||
// the parent can drive whichever variant is mounted through this one.
|
// the parent can drive whichever variant is mounted through this one.
|
||||||
export function getElement() {
|
export function getElement() {
|
||||||
return useContenteditable ? richRef?.getElement() : basicRef?.getElement();
|
return useRichInput ? richRef?.getElement() : basicRef?.getElement();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function focus() {
|
export function focus() {
|
||||||
if (useContenteditable) richRef?.focus();
|
if (useRichInput) richRef?.focus();
|
||||||
else basicRef?.focus();
|
else basicRef?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetHeight() {
|
export function resetHeight() {
|
||||||
if (useContenteditable) richRef?.resetHeight();
|
if (useRichInput) richRef?.resetHeight();
|
||||||
else basicRef?.resetHeight();
|
else basicRef?.resetHeight();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCaretOffset(): number {
|
export function getCaretOffset(): number {
|
||||||
return useContenteditable
|
return useRichInput ? (richRef?.getCaretOffset() ?? 0) : (basicRef?.getCaretOffset() ?? 0);
|
||||||
? (richRef?.getCaretOffset() ?? 0)
|
|
||||||
: (basicRef?.getCaretOffset() ?? 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setCaretOffset(offset: number) {
|
export function setCaretOffset(offset: number) {
|
||||||
if (useContenteditable) richRef?.setCaretOffset(offset);
|
if (useRichInput) richRef?.setCaretOffset(offset);
|
||||||
else basicRef?.setCaretOffset(offset);
|
else basicRef?.setCaretOffset(offset);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if useContenteditable}
|
{#if useRichInput}
|
||||||
<ChatFormInputRich
|
<ChatFormInputRich
|
||||||
bind:this={richRef}
|
bind:this={richRef}
|
||||||
class={className}
|
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.
|
// the picker/paste flows can address either renderer through one handle.
|
||||||
export function getCaretOffset(): number {
|
export function getCaretOffset(): number {
|
||||||
if (!textareaElement) return 0;
|
if (!textareaElement) return 0;
|
||||||
|
|||||||
+32
-14
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CODE_BLOCK } from '$lib/constants';
|
import { CODE_BLOCK, CODE_TOKEN_ATTR, UI_DATA_ATTRS } from '$lib/constants';
|
||||||
import { ColorMode } from '$lib/enums';
|
import { BooleanString, ChatFormInputRichTokenKind, ColorMode } from '$lib/enums';
|
||||||
import { isMobile } from '$lib/stores';
|
import { isMobile } from '$lib/stores';
|
||||||
import type { ChatFormInputRichToken } from '$lib/types';
|
import type { ChatFormInputRichToken } from '$lib/types';
|
||||||
import type { SourceHistoryEntry } from '$lib/utils';
|
import type { SourceHistoryEntry } from '$lib/utils';
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
// browser's native undo stack.
|
// browser's native undo stack.
|
||||||
const history = new SourceHistory();
|
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
|
// `<div><br></div>`, or nothing), so emptiness is decided by the
|
||||||
// serialized source, not the DOM shape.
|
// serialized source, not the DOM shape.
|
||||||
function syncEmptyState(serialized?: string) {
|
function syncEmptyState(serialized?: string) {
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
|
|
||||||
const source = serialized ?? serializeContent(rootElement);
|
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[]) {
|
function renderTokens(tokens: ChatFormInputRichToken[]) {
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
|
|
||||||
const caret = rangeToTextOffset(rootElement, safeRange());
|
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));
|
rootElement.replaceChildren(buildFragment(tokens));
|
||||||
|
|
||||||
syncCodeBlockHatches(rootElement);
|
syncCodeBlockHatches(rootElement);
|
||||||
@@ -127,7 +127,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function highlightCodeBlocks(root: HTMLElement) {
|
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);
|
highlightCodeBlockElement(el);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,7 +153,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
while (node && node !== rootElement) {
|
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);
|
const caret = rangeToTextOffset(rootElement, range);
|
||||||
|
|
||||||
if (highlightCodeBlockElement(node)) {
|
if (highlightCodeBlockElement(node)) {
|
||||||
@@ -189,11 +194,13 @@
|
|||||||
* (deduped via the data attribute) swapped on mode change.
|
* (deduped via the data attribute) swapped on mode change.
|
||||||
*/
|
*/
|
||||||
function loadHighlightTheme(isDark: boolean) {
|
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');
|
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;
|
style.textContent = isDark ? githubDarkCss : githubLightCss;
|
||||||
|
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
@@ -311,7 +318,7 @@
|
|||||||
source[source.length - 2] !== '\n' &&
|
source[source.length - 2] !== '\n' &&
|
||||||
last?.nodeType === Node.TEXT_NODE
|
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'));
|
rootElement.appendChild(document.createTextNode('\n'));
|
||||||
restoreCaret(source.length);
|
restoreCaret(source.length);
|
||||||
resizeHeight();
|
resizeHeight();
|
||||||
@@ -404,7 +411,10 @@
|
|||||||
let node: Node | null = container.parentNode;
|
let node: Node | null = container.parentNode;
|
||||||
|
|
||||||
while (node && node !== rootElement) {
|
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();
|
const tail = document.createRange();
|
||||||
|
|
||||||
tail.setStart(container, offset);
|
tail.setStart(container, offset);
|
||||||
@@ -462,7 +472,11 @@
|
|||||||
|
|
||||||
const first = rootElement.firstChild;
|
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();
|
const range = safeRange();
|
||||||
|
|
||||||
@@ -483,7 +497,7 @@
|
|||||||
if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
|
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'));
|
rootElement.prepend(document.createElement('br'));
|
||||||
restoreCaret(0, extend);
|
restoreCaret(0, extend);
|
||||||
|
|
||||||
@@ -507,7 +521,11 @@
|
|||||||
|
|
||||||
const second = first.nextSibling;
|
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 range = safeRange();
|
||||||
const onHatch =
|
const onHatch =
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" generics="T">
|
<script lang="ts" generics="T">
|
||||||
import { SearchInput } from '$lib/components/app';
|
import { SearchInput } from '$lib/components/app';
|
||||||
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
|
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 { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
// selectedIndex/items.length are untracked so hover and result replacement
|
// 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.
|
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
|
||||||
useScrollActiveRow({
|
useScrollActiveRow({
|
||||||
dataIndex: 'picker',
|
dataAttr: UI_DATA_ATTRS.PICKER_INDEX,
|
||||||
getContainer: () => listContainer,
|
getContainer: () => listContainer,
|
||||||
getCount: () => items.length,
|
getCount: () => items.length,
|
||||||
getIndex: () => selectedIndex,
|
getIndex: () => selectedIndex,
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { UI_DATA_ATTRS } from '$lib/constants';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-picker-index={dataIndex}
|
{...{ [UI_DATA_ATTRS.PICKER_INDEX]: dataIndex }}
|
||||||
{disabled}
|
{disabled}
|
||||||
{onclick}
|
{onclick}
|
||||||
{onmouseenter}
|
{onmouseenter}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/
|
|||||||
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
|
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
|
||||||
*
|
*
|
||||||
* **Architecture:**
|
* **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
|
* messages with file mention links), ChatFormActions, and ChatFormPickerMcpPrompts
|
||||||
* - Manages file upload state via `uploadedFiles` bindable prop
|
* - Manages file upload state via `uploadedFiles` bindable prop
|
||||||
* - Integrates with ModelsSelectorDropdown for model selection in router mode
|
* - 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,
|
* 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
|
* inline chips (keeping the value as the markdown source string) once a
|
||||||
* mention link lands in the buffer. The variant is selected via the
|
* 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';
|
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).
|
* tool, scoped to the conversation cwd (or server home when unset).
|
||||||
* Selection splices a `[name](file:///<abs path>)` link into the input.
|
* 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
|
* `/`-triggered slash-command picker. Lists the available slash commands
|
||||||
* (`/prompt`, `/cwd`, `/model`) filtered by the typed query; selection
|
* (`/prompt`, `/cwd`, `/model`) filtered by the typed query; selection
|
||||||
* hands the command to the parent for dispatch.
|
* 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)
|
* Hosts the chat-form pickers (slash-command, MCP prompt, file mention)
|
||||||
|
|||||||
@@ -25,14 +25,12 @@
|
|||||||
DialogMermaidPreview
|
DialogMermaidPreview
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import {
|
import {
|
||||||
BOOL_TRUE_STRING,
|
|
||||||
CODE_BLOCK_CLASS,
|
CODE_BLOCK_CLASS,
|
||||||
DATA_ERROR_BOUND_ATTR,
|
|
||||||
DATA_ERROR_HANDLED_ATTR,
|
|
||||||
DIAGRAM_VIEW_MODE_ATTR,
|
DIAGRAM_VIEW_MODE_ATTR,
|
||||||
DIAGRAM_VIEW_RENDERED,
|
DIAGRAM_VIEW_RENDERED,
|
||||||
DIAGRAM_VIEW_SOURCE,
|
DIAGRAM_VIEW_SOURCE,
|
||||||
IMAGE_NOT_ERROR_BOUND_SELECTOR,
|
IMAGE_NOT_ERROR_BOUND_SELECTOR,
|
||||||
|
MARKDOWN_DATA_ATTRS,
|
||||||
MERMAID_BLOCK_CLASS,
|
MERMAID_BLOCK_CLASS,
|
||||||
MERMAID_LANGUAGE,
|
MERMAID_LANGUAGE,
|
||||||
MERMAID_RENDERED_ATTR,
|
MERMAID_RENDERED_ATTR,
|
||||||
@@ -42,7 +40,7 @@
|
|||||||
SVG,
|
SVG,
|
||||||
TOGGLE_SOURCE_BTN_CLASS
|
TOGGLE_SOURCE_BTN_CLASS
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
import { ColorMode, UrlProtocol } from '$lib/enums';
|
import { BooleanString, ColorMode, UrlProtocol } from '$lib/enums';
|
||||||
import { FileTypeText } from '$lib/enums/files.enums';
|
import { FileTypeText } from '$lib/enums/files.enums';
|
||||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
||||||
import { settingsStore } from '$lib/stores';
|
import { settingsStore } from '$lib/stores';
|
||||||
@@ -486,13 +484,19 @@
|
|||||||
const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
|
const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
|
||||||
const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
|
const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
|
||||||
|
|
||||||
if (copyButton && copyButton.dataset.listenerBound !== 'true') {
|
if (
|
||||||
copyButton.dataset.listenerBound = 'true';
|
copyButton &&
|
||||||
|
copyButton.getAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND) !== BooleanString.TRUE
|
||||||
|
) {
|
||||||
|
copyButton.setAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND, BooleanString.TRUE);
|
||||||
copyButton.addEventListener('click', handleCopyClick);
|
copyButton.addEventListener('click', handleCopyClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previewButton && previewButton.dataset.listenerBound !== 'true') {
|
if (
|
||||||
previewButton.dataset.listenerBound = 'true';
|
previewButton &&
|
||||||
|
previewButton.getAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND) !== BooleanString.TRUE
|
||||||
|
) {
|
||||||
|
previewButton.setAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND, BooleanString.TRUE);
|
||||||
previewButton.addEventListener('click', handlePreviewClick);
|
previewButton.addEventListener('click', handlePreviewClick);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -508,7 +512,7 @@
|
|||||||
const images = containerRef.querySelectorAll<HTMLImageElement>(IMAGE_NOT_ERROR_BOUND_SELECTOR);
|
const images = containerRef.querySelectorAll<HTMLImageElement>(IMAGE_NOT_ERROR_BOUND_SELECTOR);
|
||||||
|
|
||||||
for (const img of images) {
|
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);
|
img.addEventListener('error', handleImageError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -691,7 +695,7 @@
|
|||||||
|
|
||||||
// Mark nodes immediately to prevent duplicate renders if called again during streaming.
|
// Mark nodes immediately to prevent duplicate renders if called again during streaming.
|
||||||
// This avoids needing a guard that would block node discovery.
|
// 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.
|
// Read mode before await so Svelte tracks it reactively.
|
||||||
const isDark = mode.current === ColorMode.DARK;
|
const isDark = mode.current === ColorMode.DARK;
|
||||||
@@ -738,7 +742,7 @@
|
|||||||
if (nodes.length === 0) return;
|
if (nodes.length === 0) return;
|
||||||
|
|
||||||
nodes.forEach((node) => {
|
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 source = node.getAttribute(SVG.SOURCE_ATTR) ?? node.textContent ?? '';
|
||||||
const clean = sanitizeSvg(source);
|
const clean = sanitizeSvg(source);
|
||||||
@@ -765,11 +769,11 @@
|
|||||||
// Don't handle data URLs or already-handled images
|
// Don't handle data URLs or already-handled images
|
||||||
if (
|
if (
|
||||||
img.src.startsWith(UrlProtocol.DATA) ||
|
img.src.startsWith(UrlProtocol.DATA) ||
|
||||||
img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING
|
img.getAttribute(MARKDOWN_DATA_ATTRS.ERROR_HANDLED) === BooleanString.TRUE
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
img.dataset[DATA_ERROR_HANDLED_ATTR] = BOOL_TRUE_STRING;
|
img.setAttribute(MARKDOWN_DATA_ATTRS.ERROR_HANDLED, BooleanString.TRUE);
|
||||||
|
|
||||||
const src = img.src;
|
const src = img.src;
|
||||||
// Create fallback element
|
// Create fallback element
|
||||||
@@ -869,13 +873,16 @@
|
|||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
{#each renderedBlocks as block (block.id)}
|
{#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}
|
{@html block.html}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if unstableBlockHtml}
|
{#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 -->
|
<!-- eslint-disable-next-line no-at-html-tags -->
|
||||||
{@html unstableBlockHtml}
|
{@html unstableBlockHtml}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,14 @@
|
|||||||
* Uses dependency injection pattern to avoid direct component state access.
|
* 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';
|
import { copyCodeToClipboard, copyToClipboard } from '$lib/utils';
|
||||||
|
|
||||||
export interface PreviewState {
|
export interface PreviewState {
|
||||||
@@ -40,11 +47,11 @@ export function createHandleCopyClick() {
|
|||||||
|
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|
||||||
const wrapper = target.closest('.code-block-wrapper');
|
const wrapper = target.closest(`.${CODE_BLOCK_CLASS.WRAPPER}`);
|
||||||
|
|
||||||
if (!wrapper) return;
|
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 (!codeElement) return;
|
||||||
|
|
||||||
@@ -86,16 +93,16 @@ export function createHandlePreviewClick(previewState: PreviewState) {
|
|||||||
|
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|
||||||
const wrapper = target.closest('.code-block-wrapper');
|
const wrapper = target.closest(`.${CODE_BLOCK_CLASS.WRAPPER}`);
|
||||||
|
|
||||||
if (!wrapper) return;
|
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 (!codeElement) return;
|
||||||
|
|
||||||
const rawCode = codeElement.textContent ?? '';
|
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';
|
const language = languageLabel?.textContent?.trim() || 'text';
|
||||||
|
|
||||||
previewState.setPreviewCode(rawCode);
|
previewState.setPreviewCode(rawCode);
|
||||||
@@ -112,8 +119,8 @@ export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
|
|||||||
return async function handleMermaidClick(event: MouseEvent) {
|
return async function handleMermaidClick(event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
// Check if clicking on copy or preview button in mermaid block
|
// Check if clicking on copy or preview button in mermaid block
|
||||||
const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .copy-code-btn`);
|
const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .${CODE_BLOCK_CLASS.COPY_BTN}`);
|
||||||
const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .preview-code-btn`);
|
const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .${CODE_BLOCK_CLASS.PREVIEW_BTN}`);
|
||||||
|
|
||||||
if (copyBtn || previewBtn) {
|
if (copyBtn || previewBtn) {
|
||||||
const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
|
const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
|
||||||
@@ -189,15 +196,17 @@ export function createHandleMermaidPreviewOpenChange(mermaidState: MermaidPrevie
|
|||||||
export function createHandleImageError(
|
export function createHandleImageError(
|
||||||
renderedBlocksState: RenderedBlocksState,
|
renderedBlocksState: RenderedBlocksState,
|
||||||
IMAGE_NOT_ERROR_BOUND_SELECTOR: string,
|
IMAGE_NOT_ERROR_BOUND_SELECTOR: string,
|
||||||
DATA_ERROR_BOUND_ATTR: string,
|
errorBoundAttr: string,
|
||||||
BOOL_TRUE_STRING: string
|
booleanString: BooleanString
|
||||||
) {
|
) {
|
||||||
return async function handleImageError(event: Event) {
|
return async function handleImageError(event: Event) {
|
||||||
const img = event.target as HTMLImageElement;
|
const img = event.target as HTMLImageElement;
|
||||||
|
|
||||||
if (!img) return;
|
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 (!blockId) return;
|
||||||
|
|
||||||
@@ -206,19 +215,22 @@ export function createHandleImageError(
|
|||||||
if (!block) return;
|
if (!block) return;
|
||||||
|
|
||||||
// Skip if already handled
|
// 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
|
// 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-icon">⚠️</span>
|
||||||
<span class="image-error-text">Failed to load image</span>
|
<span class="image-error-text">Failed to load image</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
// Replace the img element with fallback in the block's HTML
|
// Replace the img element with fallback in the block's HTML
|
||||||
const newHtml = block.html.replace(/img[^>]*src=["']([^"']*)[^>]*>/g, (match, src) => {
|
const newHtml = block.html.replace(/img[^>]*src=["']([^"']*)[^>]*>/g, (match, src) => {
|
||||||
if (src === img.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 match;
|
||||||
@@ -243,19 +255,27 @@ export function createSetupCodeBlockActions(
|
|||||||
return function setupCodeBlockActions(containerRef: HTMLElement | null) {
|
return function setupCodeBlockActions(containerRef: HTMLElement | null) {
|
||||||
if (!containerRef) return;
|
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) {
|
for (const wrapper of wrappers) {
|
||||||
const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
|
const copyButton = wrapper.querySelector<HTMLButtonElement>(`.${CODE_BLOCK_CLASS.COPY_BTN}`);
|
||||||
const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
|
const previewButton = wrapper.querySelector<HTMLButtonElement>(
|
||||||
|
`.${CODE_BLOCK_CLASS.PREVIEW_BTN}`
|
||||||
|
);
|
||||||
|
|
||||||
if (copyButton && copyButton.dataset.listenerBound !== 'true') {
|
if (
|
||||||
copyButton.dataset.listenerBound = 'true';
|
copyButton &&
|
||||||
|
copyButton.getAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND) !== BooleanString.TRUE
|
||||||
|
) {
|
||||||
|
copyButton.setAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND, BooleanString.TRUE);
|
||||||
copyButton.addEventListener('click', handleCopyClick);
|
copyButton.addEventListener('click', handleCopyClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previewButton && previewButton.dataset.listenerBound !== 'true') {
|
if (
|
||||||
previewButton.dataset.listenerBound = 'true';
|
previewButton &&
|
||||||
|
previewButton.getAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND) !== BooleanString.TRUE
|
||||||
|
) {
|
||||||
|
previewButton.setAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND, BooleanString.TRUE);
|
||||||
previewButton.addEventListener('click', handlePreviewClick);
|
previewButton.addEventListener('click', handlePreviewClick);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,8 +289,8 @@ export function createSetupCodeBlockActions(
|
|||||||
export function createSetupImageErrorHandlers(
|
export function createSetupImageErrorHandlers(
|
||||||
handleImageError: (event: Event) => void,
|
handleImageError: (event: Event) => void,
|
||||||
IMAGE_NOT_ERROR_BOUND_SELECTOR: string,
|
IMAGE_NOT_ERROR_BOUND_SELECTOR: string,
|
||||||
DATA_ERROR_BOUND_ATTR: string,
|
errorBoundAttr: string,
|
||||||
BOOL_TRUE_STRING: string
|
booleanString: BooleanString
|
||||||
) {
|
) {
|
||||||
return function setupImageErrorHandlers(containerRef: HTMLElement | null) {
|
return function setupImageErrorHandlers(containerRef: HTMLElement | null) {
|
||||||
if (!containerRef) return;
|
if (!containerRef) return;
|
||||||
@@ -278,7 +298,7 @@ export function createSetupImageErrorHandlers(
|
|||||||
const images = containerRef.querySelectorAll<HTMLImageElement>(IMAGE_NOT_ERROR_BOUND_SELECTOR);
|
const images = containerRef.querySelectorAll<HTMLImageElement>(IMAGE_NOT_ERROR_BOUND_SELECTOR);
|
||||||
|
|
||||||
for (const img of images) {
|
for (const img of images) {
|
||||||
img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING;
|
img.setAttribute(errorBoundAttr, booleanString);
|
||||||
img.addEventListener('error', handleImageError);
|
img.addEventListener('error', handleImageError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Utility functions for markdown processing in MarkdownContent component.
|
* Utility functions for markdown processing in MarkdownContent component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { MARKDOWN_DATA_ATTRS } from '$lib/constants';
|
||||||
import type { RootContent as HastRootContent } from 'hast';
|
import type { RootContent as HastRootContent } from 'hast';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,7 +70,7 @@ export function getCodeInfoFromTarget(target: HTMLElement): CodeInfo | null {
|
|||||||
return 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) {
|
if (!codeElement) {
|
||||||
console.error('No code element found in wrapper');
|
console.error('No code element found in wrapper');
|
||||||
|
|||||||
+7
-5
@@ -17,7 +17,7 @@ import {
|
|||||||
createWrapper,
|
createWrapper,
|
||||||
generateBlockId
|
generateBlockId
|
||||||
} from './code-block-utils';
|
} 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 { Element, ElementContent, Root } from 'hast';
|
||||||
import type { Plugin } from 'unified';
|
import type { Plugin } from 'unified';
|
||||||
import { visit } from 'unist-util-visit';
|
import { visit } from 'unist-util-visit';
|
||||||
@@ -65,16 +65,18 @@ export const rehypeEnhanceCodeBlocks: Plugin<[], Root> = () => {
|
|||||||
|
|
||||||
codeElement.properties = {
|
codeElement.properties = {
|
||||||
...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') {
|
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(
|
const wrapper = createWrapper(
|
||||||
header,
|
header,
|
||||||
node,
|
node,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Rehype plugin that rewrites `file://` markdown anchors into the inline
|
* 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`.
|
* tokenizer via `$lib/constants`.
|
||||||
*
|
*
|
||||||
* The chip is presentational: `file://` navigation is blocked from
|
* The chip is presentational: `file://` navigation is blocked from
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants';
|
import { SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX, UI_DATA_ATTRS } from '$lib/constants';
|
||||||
import { ColorMode } from '$lib/enums';
|
import { BooleanString, ColorMode } from '$lib/enums';
|
||||||
import { highlightCode } from '$lib/utils';
|
import { highlightCode } from '$lib/utils';
|
||||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||||
@@ -38,13 +38,15 @@
|
|||||||
function loadHighlightTheme(isDark: boolean) {
|
function loadHighlightTheme(isDark: boolean) {
|
||||||
if (!browser) return;
|
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());
|
existingThemes.forEach((style) => style.remove());
|
||||||
|
|
||||||
const style = document.createElement('style');
|
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;
|
style.textContent = isDark ? githubDarkCss : githubLightCss;
|
||||||
|
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
|||||||
@@ -4,14 +4,12 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import {
|
import {
|
||||||
BOOL_FALSE_STRING,
|
|
||||||
BOOL_TRUE_STRING,
|
|
||||||
DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
|
DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
|
||||||
HEADERS,
|
HEADERS,
|
||||||
MCP_SERVER_ID_PREFIX,
|
MCP_SERVER_ID_PREFIX,
|
||||||
RECOMMENDED_MCP_SERVERS
|
RECOMMENDED_MCP_SERVERS
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
import { HealthCheckStatus } from '$lib/enums';
|
import { BooleanString, HealthCheckStatus } from '$lib/enums';
|
||||||
import { conversationsStore, mcpStore } from '$lib/stores';
|
import { conversationsStore, mcpStore } from '$lib/stores';
|
||||||
import { canonicalizeServerUrl, parseHeadersToArray, uuid } from '$lib/utils';
|
import { canonicalizeServerUrl, parseHeadersToArray, uuid } from '$lib/utils';
|
||||||
|
|
||||||
@@ -97,9 +95,9 @@
|
|||||||
|
|
||||||
if (!raw) return false;
|
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 {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
@@ -116,7 +114,7 @@
|
|||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
|
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 { Button } from '$lib/components/ui/button';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
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 { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
@@ -138,7 +139,7 @@
|
|||||||
class="cursor-pointer border-b transition-colors hover:bg-muted/50 {checked
|
class="cursor-pointer border-b transition-colors hover:bg-muted/50 {checked
|
||||||
? 'bg-muted/75'
|
? 'bg-muted/75'
|
||||||
: ''}"
|
: ''}"
|
||||||
data-conversation-row={conv.id}
|
{...{ [UI_DATA_ATTRS.CONVERSATION_ROW]: conv.id }}
|
||||||
onmousedown={(event) => marquee.rowMouseDown(conv.id, event)}
|
onmousedown={(event) => marquee.rowMouseDown(conv.id, event)}
|
||||||
onclick={(event) => marquee.rowClick(conv.id, event.shiftKey)}
|
onclick={(event) => marquee.rowClick(conv.id, event.shiftKey)}
|
||||||
>
|
>
|
||||||
|
|||||||
+2
-3
@@ -15,7 +15,7 @@
|
|||||||
import { TruncatedText } from '$lib/components/app';
|
import { TruncatedText } from '$lib/components/app';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
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 { RouterService } from '$lib/services/router.service';
|
||||||
import { chatStore, conversationsStore } from '$lib/stores';
|
import { chatStore, conversationsStore } from '$lib/stores';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -154,14 +154,13 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
|
||||||
<button
|
<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
|
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'
|
? 'bg-foreground/5 text-accent-foreground'
|
||||||
: ''} {isSelected ? 'bg-primary/10 hover:bg-primary/15' : ''} {isSelectionMode
|
: ''} {isSelected ? 'bg-primary/10 hover:bg-primary/15' : ''} {isSelectionMode
|
||||||
? 'is-selection-mode'
|
? 'is-selection-mode'
|
||||||
: ''} px-2"
|
: ''} px-2"
|
||||||
data-conversation-row={conversation.id}
|
{...{ [UI_DATA_ATTRS.CONVERSATION_ROW]: conversation.id }}
|
||||||
onclick={(e) => handleSelect(e)}
|
onclick={(e) => handleSelect(e)}
|
||||||
onmouseover={handleMouseOver}
|
onmouseover={handleMouseOver}
|
||||||
onmouseleave={handleMouseLeave}
|
onmouseleave={handleMouseLeave}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ChevronLeft, ChevronRight, Settings } from '@lucide/svelte';
|
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 { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
|
||||||
import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
|
import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
@@ -20,7 +21,9 @@
|
|||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
if (carousel.scrollContainer) {
|
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) {
|
if (activeTab instanceof HTMLElement) {
|
||||||
carousel.scrollToCenter(activeTab);
|
carousel.scrollToCenter(activeTab);
|
||||||
@@ -66,7 +69,7 @@
|
|||||||
)
|
)
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground'
|
||||||
: 'text-muted-foreground'}"
|
: 'text-muted-foreground'}"
|
||||||
data-active={isActive(section)}
|
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
|
||||||
href={getHref(section)}
|
href={getHref(section)}
|
||||||
onclick={(e: MouseEvent) => {
|
onclick={(e: MouseEvent) => {
|
||||||
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
||||||
@@ -82,7 +85,7 @@
|
|||||||
)
|
)
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground'
|
||||||
: 'text-muted-foreground'}"
|
: 'text-muted-foreground'}"
|
||||||
data-active={isActive(section)}
|
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
|
||||||
onclick={(e: MouseEvent) => {
|
onclick={(e: MouseEvent) => {
|
||||||
onSectionChange?.(section.title);
|
onSectionChange?.(section.title);
|
||||||
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
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 INITIAL_FILE_SIZE = 0;
|
||||||
export const PROMPT_CONTENT_SEPARATOR = '\n\n';
|
export const PROMPT_CONTENT_SEPARATOR = '\n\n';
|
||||||
export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
|
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. */
|
/** HTTP header handling for API and MCP requests. */
|
||||||
export const HEADERS = {
|
export const HEADERS = {
|
||||||
/** Canonical casing for the Authorization header (RFC 7235) */
|
/** Canonical casing for the Authorization header (RFC 7235) */
|
||||||
@@ -7,7 +10,7 @@ export const HEADERS = {
|
|||||||
/** Content-Type HTTP header name */
|
/** Content-Type HTTP header name */
|
||||||
CONTENT_TYPE: 'Content-Type',
|
CONTENT_TYPE: 'Content-Type',
|
||||||
/** Partial-redaction rules for MCP headers: header name -> visible trailing chars */
|
/** 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 */
|
/** Header names whose values should be redacted in diagnostic logs */
|
||||||
REDACTED: new Set([
|
REDACTED: new Set([
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
export const IMAGE_NOT_ERROR_BOUND_SELECTOR = 'img:not([data-error-bound])';
|
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';
|
/** Data attributes for the markdown renderer DOM contract. */
|
||||||
export const BOOL_TRUE_STRING = 'true';
|
export const MARKDOWN_DATA_ATTRS = {
|
||||||
export const BOOL_FALSE_STRING = 'false';
|
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. */
|
/** Markdown structural markers used by `looksLikeMarkdown`. Inline / line-level. */
|
||||||
export const MARKDOWN = {
|
export const MARKDOWN = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Shared visual contract between the two DOM-only badge paths (the
|
* 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,
|
* 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
|
* so both emit the badge with the same class string literal; Tailwind's
|
||||||
* scanner picks it up in both sources.
|
* 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';
|
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. */
|
/** 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 MENTION_LINK_SCAN_FLAGS = 'g';
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ import type { DesktopIconStripItem } from '$lib/types';
|
|||||||
export const FORK_TREE_DEPTH_PADDING = 8;
|
export const FORK_TREE_DEPTH_PADDING = 8;
|
||||||
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
|
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 = {
|
export const TOOL_GROUP_LABELS = {
|
||||||
[ToolSource.BUILTIN]: 'Built-in',
|
[ToolSource.BUILTIN]: 'Built-in',
|
||||||
[ToolSource.CUSTOM]: 'JSON Schema',
|
[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 { SessionRecordType } from './conversation-import.enums';
|
||||||
|
|
||||||
|
export { BooleanString } from './boolean-string.enums';
|
||||||
|
|
||||||
export { ReasoningEffort } from './reasoning-effort.enums';
|
export { ReasoningEffort } from './reasoning-effort.enums';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* matches what the user sees on screen.
|
* matches what the user sees on screen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { UI_DATA_ATTRS } from '$lib/constants';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
interface UseMarqueeSelectionOptions {
|
interface UseMarqueeSelectionOptions {
|
||||||
@@ -18,8 +19,8 @@ interface UseMarqueeSelectionOptions {
|
|||||||
orderedIds: () => string[];
|
orderedIds: () => string[];
|
||||||
/** Document listeners attach only while the getter returns true. */
|
/** Document listeners attach only while the getter returns true. */
|
||||||
enabled: () => boolean;
|
enabled: () => boolean;
|
||||||
/** DOM attribute key (after the `data-` prefix) that marks selectable rows. */
|
/** Full `data-*` attribute that marks selectable rows. */
|
||||||
attributeName?: () => string;
|
dataAttr?: () => string;
|
||||||
/** Minimum pixel distance before a press becomes a marquee drag. */
|
/** Minimum pixel distance before a press becomes a marquee drag. */
|
||||||
dragThresholdPx?: number;
|
dragThresholdPx?: number;
|
||||||
}
|
}
|
||||||
@@ -36,16 +37,8 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
|
|||||||
let dragMode: 'add' | 'remove' | null = null;
|
let dragMode: 'add' | 'remove' | null = null;
|
||||||
let suppressNextClick = false;
|
let suppressNextClick = false;
|
||||||
|
|
||||||
function resolveAttributeName(): string {
|
function resolveDataAttr(): string {
|
||||||
return options.attributeName?.() ?? 'conversation-row';
|
return options.dataAttr?.() ?? UI_DATA_ATTRS.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 decideDragMode(startingRowId: string | null, currentlySelected: ReadonlySet<string>) {
|
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 {
|
function findRowAtPoint(x: number, y: number): string | null {
|
||||||
const attr = resolveAttributeName();
|
const attr = resolveDataAttr();
|
||||||
const selector = `[data-${attr}]`;
|
const selector = `[${attr}]`;
|
||||||
const key = datasetKey(attr);
|
|
||||||
|
|
||||||
let bestMatch: HTMLElement | null = null;
|
let bestMatch: HTMLElement | null = null;
|
||||||
let bestCenterDistance = Infinity;
|
let bestCenterDistance = Infinity;
|
||||||
@@ -89,7 +81,7 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
|
|||||||
const rect = row.getBoundingClientRect();
|
const rect = row.getBoundingClientRect();
|
||||||
|
|
||||||
if (y >= rect.top && y <= rect.bottom && x >= rect.left && x <= rect.right) {
|
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) {
|
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) {
|
function updateMarqueeRect(currentX: number, currentY: number) {
|
||||||
const attr = resolveAttributeName();
|
const attr = resolveDataAttr();
|
||||||
const selector = `[data-${attr}]`;
|
const selector = `[${attr}]`;
|
||||||
const key = datasetKey(attr);
|
|
||||||
const selected = options.selectedIds();
|
const selected = options.selectedIds();
|
||||||
const left = Math.min(dragStartX, currentX);
|
const left = Math.min(dragStartX, currentX);
|
||||||
const top = Math.min(dragStartY, currentY);
|
const top = Math.min(dragStartY, currentY);
|
||||||
@@ -117,7 +108,7 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
|
|||||||
const visibleIds = new SvelteSet(options.orderedIds());
|
const visibleIds = new SvelteSet(options.orderedIds());
|
||||||
|
|
||||||
for (const row of document.querySelectorAll<HTMLElement>(selector)) {
|
for (const row of document.querySelectorAll<HTMLElement>(selector)) {
|
||||||
const id = row.dataset[key];
|
const id = row.getAttribute(attr);
|
||||||
|
|
||||||
if (!id || !visibleIds.has(id)) continue;
|
if (!id || !visibleIds.has(id)) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export interface UseScrollActiveRowOptions {
|
|||||||
getContainer: () => HTMLDivElement | null;
|
getContainer: () => HTMLDivElement | null;
|
||||||
getIndex: () => number;
|
getIndex: () => number;
|
||||||
getCount: () => number;
|
getCount: () => number;
|
||||||
/** Attribute prefix, e.g. 'picker' for `[data-picker-index="0"]`. */
|
/** Full data attribute marking the row, e.g. `data-picker-index`. */
|
||||||
dataIndex: string;
|
dataAttr: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useScrollActiveRow(opts: UseScrollActiveRowOptions) {
|
export function useScrollActiveRow(opts: UseScrollActiveRowOptions) {
|
||||||
@@ -41,9 +41,7 @@ export function useScrollActiveRow(opts: UseScrollActiveRowOptions) {
|
|||||||
|
|
||||||
if (!container || index < 0 || index >= opts.getCount()) return;
|
if (!container || index < 0 || index >= opts.getCount()) return;
|
||||||
|
|
||||||
const row = container.querySelector(
|
const row = container.querySelector(`[${opts.dataAttr}="${index}"]`) as HTMLElement | null;
|
||||||
`[data-${opts.dataIndex}-index="${index}"]`
|
|
||||||
) as HTMLElement | null;
|
|
||||||
|
|
||||||
row?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
row?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
STORAGE_APP_NAME,
|
STORAGE_APP_NAME,
|
||||||
STORAGE_APP_NAME_DEPRECATED
|
STORAGE_APP_NAME_DEPRECATED
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
import { MessageRole } from '$lib/enums';
|
import { BooleanString, MessageRole } from '$lib/enums';
|
||||||
import Dexie from 'dexie';
|
import Dexie from 'dexie';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@@ -613,10 +613,10 @@ const configTypesMigration: Migration = {
|
|||||||
// schema rejects them. No config string field holds exactly "true"/"false", so the
|
// schema rejects them. No config string field holds exactly "true"/"false", so the
|
||||||
// match is unambiguous.
|
// match is unambiguous.
|
||||||
for (const key of Object.keys(config)) {
|
for (const key of Object.keys(config)) {
|
||||||
if (config[key] === 'true') {
|
if (config[key] === BooleanString.TRUE) {
|
||||||
config[key] = true;
|
config[key] = true;
|
||||||
changed = true;
|
changed = true;
|
||||||
} else if (config[key] === 'false') {
|
} else if (config[key] === BooleanString.FALSE) {
|
||||||
config[key] = false;
|
config[key] = false;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export type {
|
|||||||
GlobSearchChildResult
|
GlobSearchChildResult
|
||||||
} from './glob';
|
} from './glob';
|
||||||
|
|
||||||
// Contenteditable token types (chat form)
|
// ChatFormInputRich token types (chat form)
|
||||||
export type { ChatFormInputRichToken } from './chat-form-input-rich';
|
export type { ChatFormInputRichToken } from './chat-form-input-rich';
|
||||||
|
|
||||||
// Agentic types
|
// Agentic types
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ import {
|
|||||||
getMentionBadgeLabel
|
getMentionBadgeLabel
|
||||||
} from './mention-badge';
|
} from './mention-badge';
|
||||||
import {
|
import {
|
||||||
|
CODE_TOKEN_ATTR,
|
||||||
MENTION_BADGE_CLASSNAME,
|
MENTION_BADGE_CLASSNAME,
|
||||||
|
MENTION_BADGE_DATA_ATTRS,
|
||||||
MENTION_BADGE_ICON_CLASSNAME,
|
MENTION_BADGE_ICON_CLASSNAME,
|
||||||
MENTION_BADGE_SVG_ATTRIBUTES,
|
MENTION_BADGE_SVG_ATTRIBUTES,
|
||||||
SETTINGS_KEYS
|
SETTINGS_KEYS
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
import { ChatFormInputRichTokenKind } from '$lib/enums';
|
import { BooleanString, ChatFormInputRichTokenKind } from '$lib/enums';
|
||||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||||
import type { ChatFormInputRichToken } from '$lib/types/chat-form-input-rich';
|
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 {
|
function isCodeBlockElement(node: Node | null): node is HTMLElement {
|
||||||
return (
|
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;
|
const el = child as HTMLElement;
|
||||||
|
|
||||||
if (el.dataset.mentionBadge === 'true') {
|
if (el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE) {
|
||||||
const name = el.dataset.mentionName ?? '';
|
const name = el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '';
|
||||||
const path = el.dataset.mentionPath ?? '';
|
const path = el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? '';
|
||||||
|
|
||||||
if (name && path) {
|
if (name && path) {
|
||||||
if (pendingBlockBoundary) {
|
if (pendingBlockBoundary) {
|
||||||
@@ -227,8 +230,10 @@ export function serializeContent(root: HTMLElement): string {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (el.dataset.codeToken !== undefined) {
|
const codeToken = el.getAttribute(CODE_TOKEN_ATTR);
|
||||||
const isBlock = el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
|
|
||||||
|
if (codeToken !== null) {
|
||||||
|
const isBlock = codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
|
||||||
|
|
||||||
if (isBlock && (pendingBlockBoundary || !first)) out += '\n';
|
if (isBlock && (pendingBlockBoundary || !first)) out += '\n';
|
||||||
|
|
||||||
@@ -297,8 +302,8 @@ export function domMatchesTokens(root: HTMLElement, tokens: ChatFormInputRichTok
|
|||||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||||
|
|
||||||
const el = child as HTMLElement;
|
const el = child as HTMLElement;
|
||||||
const isBadge = el.dataset.mentionBadge === 'true';
|
const isBadge = el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE;
|
||||||
const isCode = el.dataset.codeToken !== undefined;
|
const isCode = el.getAttribute(CODE_TOKEN_ATTR) !== null;
|
||||||
|
|
||||||
if (!isBadge && !isCode) {
|
if (!isBadge && !isCode) {
|
||||||
if (!walk(el)) return false;
|
if (!walk(el)) return false;
|
||||||
@@ -313,15 +318,15 @@ export function domMatchesTokens(root: HTMLElement, tokens: ChatFormInputRichTok
|
|||||||
if (isBadge) {
|
if (isBadge) {
|
||||||
if (token.kind !== ChatFormInputRichTokenKind.BADGE) return false;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeKind: ChatFormInputRichTokenKind =
|
const codeKind: ChatFormInputRichTokenKind =
|
||||||
el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK
|
el.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
|
||||||
? ChatFormInputRichTokenKind.CODE_BLOCK
|
? ChatFormInputRichTokenKind.CODE_BLOCK
|
||||||
: ChatFormInputRichTokenKind.CODE_INLINE;
|
: ChatFormInputRichTokenKind.CODE_INLINE;
|
||||||
|
|
||||||
@@ -430,8 +435,11 @@ export function rangeToTextOffset(root: HTMLElement, range: Range | null): numbe
|
|||||||
total += 1;
|
total += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (el.dataset.mentionBadge === 'true') {
|
if (el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE) {
|
||||||
const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
|
const len = badgeSourceLength(
|
||||||
|
el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '',
|
||||||
|
el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
if (len === 0) continue;
|
if (len === 0) continue;
|
||||||
|
|
||||||
@@ -447,8 +455,10 @@ export function rangeToTextOffset(root: HTMLElement, range: Range | null): numbe
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (el.dataset.codeToken !== undefined) {
|
const codeToken = el.getAttribute(CODE_TOKEN_ATTR);
|
||||||
const isBlock = el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
|
|
||||||
|
if (codeToken !== null) {
|
||||||
|
const isBlock = codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
|
||||||
|
|
||||||
if (isBlock && !first) {
|
if (isBlock && !first) {
|
||||||
if (!atOrBeforeCaret(el, 0)) {
|
if (!atOrBeforeCaret(el, 0)) {
|
||||||
@@ -562,7 +572,7 @@ export function buildFragment(tokens: ChatFormInputRichToken[]): DocumentFragmen
|
|||||||
) {
|
) {
|
||||||
const code = document.createElement('code');
|
const code = document.createElement('code');
|
||||||
|
|
||||||
code.dataset.codeToken = token.kind;
|
code.setAttribute(CODE_TOKEN_ATTR, token.kind);
|
||||||
code.textContent = token.text;
|
code.textContent = token.text;
|
||||||
fragment.appendChild(code);
|
fragment.appendChild(code);
|
||||||
|
|
||||||
@@ -578,9 +588,9 @@ export function buildFragment(tokens: ChatFormInputRichToken[]): DocumentFragmen
|
|||||||
|
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
|
|
||||||
badge.dataset.mentionBadge = 'true';
|
badge.setAttribute(MENTION_BADGE_DATA_ATTRS.BADGE, BooleanString.TRUE);
|
||||||
badge.dataset.mentionName = token.name;
|
badge.setAttribute(MENTION_BADGE_DATA_ATTRS.NAME, token.name);
|
||||||
badge.dataset.mentionPath = token.path;
|
badge.setAttribute(MENTION_BADGE_DATA_ATTRS.PATH, token.path);
|
||||||
badge.title = decodeFileLinkPath(token.path);
|
badge.title = decodeFileLinkPath(token.path);
|
||||||
badge.className = MENTION_BADGE_CLASSNAME;
|
badge.className = MENTION_BADGE_CLASSNAME;
|
||||||
badge.contentEditable = 'false';
|
badge.contentEditable = 'false';
|
||||||
@@ -876,8 +886,11 @@ export function textOffsetToRange(root: HTMLElement, offset: number): Range {
|
|||||||
|
|
||||||
const el = child as HTMLElement;
|
const el = child as HTMLElement;
|
||||||
|
|
||||||
if (el.dataset.mentionBadge === 'true') {
|
if (el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE) {
|
||||||
const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
|
const len = badgeSourceLength(
|
||||||
|
el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '',
|
||||||
|
el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
if (len === 0) continue;
|
if (len === 0) continue;
|
||||||
|
|
||||||
@@ -915,8 +928,10 @@ export function textOffsetToRange(root: HTMLElement, offset: number): Range {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (el.dataset.codeToken !== undefined) {
|
const codeToken = el.getAttribute(CODE_TOKEN_ATTR);
|
||||||
const isBlock = el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
|
|
||||||
|
if (codeToken !== null) {
|
||||||
|
const isBlock = codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
|
||||||
|
|
||||||
if (isBlock && (pendingBlockBoundary || !first)) {
|
if (isBlock && (pendingBlockBoundary || !first)) {
|
||||||
pendingBlockBoundary = false;
|
pendingBlockBoundary = false;
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export {
|
|||||||
type CommandDismissSnapshot
|
type CommandDismissSnapshot
|
||||||
} from './command-token';
|
} 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 {
|
export {
|
||||||
tokenizeContent,
|
tokenizeContent,
|
||||||
containsCodeSpan,
|
containsCodeSpan,
|
||||||
@@ -223,10 +223,10 @@ export {
|
|||||||
leadingBadgeEdgeOffset
|
leadingBadgeEdgeOffset
|
||||||
} from './chat-form-input-rich-tokenizer';
|
} 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';
|
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)
|
// DOM paths that build the same chip without a Svelte mount)
|
||||||
export {
|
export {
|
||||||
containsFileMentionLink,
|
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.
|
* imperative DOM rebuilds destroy the browser's native undo stack.
|
||||||
* Entries record the state BEFORE an edit; edits within `groupWindowMs`
|
* Entries record the state BEFORE an edit; edits within `groupWindowMs`
|
||||||
* extend the open group so a typing burst undoes as a unit, while
|
* 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
|
// fenced-code-block flow: while the caret sits inside a fenced
|
||||||
// block region - closed, or still OPEN while the user is typing
|
// block region - closed, or still OPEN while the user is typing
|
||||||
// one - plain Enter adds a line instead of submitting the message.
|
// 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).
|
// consumes the same case locally; see chat-form-input-rich).
|
||||||
|
|
||||||
import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
|
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
|
// restructure the flat DOM on Enter (`<div>` wrappers, `<br>` shapes) and
|
||||||
// serialization must fold those back into `\n` so the emitted value never
|
// serialization must fold those back into `\n` so the emitted value never
|
||||||
// diverges from what is on screen.
|
// diverges from what is on screen.
|
||||||
@@ -13,7 +13,7 @@ const SOURCE = 'see [docs](file:///a/b) here';
|
|||||||
function editableIn(container: HTMLElement): HTMLElement {
|
function editableIn(container: HTMLElement): HTMLElement {
|
||||||
const el = container.querySelector('[role="textbox"]');
|
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;
|
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
|
// 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
|
// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
|
||||||
// keyboard trap), matching the plain textarea.
|
// keyboard trap), matching the plain textarea.
|
||||||
@@ -13,7 +13,7 @@ const SOURCE = 'see [docs](file:///a/b)';
|
|||||||
function editableIn(container: HTMLElement): HTMLElement {
|
function editableIn(container: HTMLElement): HTMLElement {
|
||||||
const el = container.querySelector('[role="textbox"]');
|
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;
|
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
|
// copy/cut expose the markdown SOURCE of the selection (each badge
|
||||||
// contributes its full `[name](file://...)` link) and pasting such
|
// contributes its full `[name](file://...)` link) and pasting such
|
||||||
// markdown re-renders the badges.
|
// markdown re-renders the badges.
|
||||||
@@ -16,7 +16,7 @@ const BADGE_SELECTOR = '[data-mention-badge="true"]';
|
|||||||
function editableIn(container: HTMLElement): HTMLElement {
|
function editableIn(container: HTMLElement): HTMLElement {
|
||||||
const el = container.querySelector('[role="textbox"]');
|
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;
|
return el;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// it, the picker still opens but explains why instead of firing searches
|
// it, the picker still opens but explains why instead of firing searches
|
||||||
// that would only fail with "Search failed".
|
// 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 { DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||||
import { BuiltInTool } from '$lib/enums';
|
import { BuiltInTool } from '$lib/enums';
|
||||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||||
@@ -25,7 +25,7 @@ function setBuiltinTools(defs: OpenAIToolDefinition[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderPicker() {
|
function renderPicker() {
|
||||||
return render(ChatFormMentionPicker, {
|
return render(ChatFormPickerMention, {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
onClose: () => {},
|
onClose: () => {},
|
||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
@@ -39,7 +39,7 @@ afterEach(() => {
|
|||||||
localStorage.removeItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
|
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 () => {
|
it('explains that file search is unavailable when the server has no tools', async () => {
|
||||||
setBuiltinTools([]);
|
setBuiltinTools([]);
|
||||||
renderPicker();
|
renderPicker();
|
||||||
|
|||||||
Reference in New Issue
Block a user