refactor: Naming (#27001)
This commit is contained in:
@@ -3,12 +3,11 @@
|
|||||||
import {
|
import {
|
||||||
ChatAttachmentsList,
|
ChatAttachmentsList,
|
||||||
ChatFormActions,
|
ChatFormActions,
|
||||||
ChatFormContentEditable,
|
ChatFormCurrentWorkingDirectory,
|
||||||
ChatFormFileInputInvisible,
|
ChatFormInput,
|
||||||
|
ChatFormInputFileInputInvisible,
|
||||||
ChatFormMcpResourcesList,
|
ChatFormMcpResourcesList,
|
||||||
ChatFormPickers,
|
ChatFormPickers,
|
||||||
ChatFormTextarea,
|
|
||||||
ChatFormWorkingDirectory,
|
|
||||||
DialogMcpResourcesBrowser
|
DialogMcpResourcesBrowser
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import {
|
import {
|
||||||
@@ -121,7 +120,7 @@
|
|||||||
|
|
||||||
let audioRecorder: AudioRecorder | undefined;
|
let audioRecorder: AudioRecorder | undefined;
|
||||||
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
|
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
|
||||||
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
|
let fileInputRef: ChatFormInputFileInputInvisible | undefined = $state(undefined);
|
||||||
let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
|
let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
|
||||||
$state(undefined);
|
$state(undefined);
|
||||||
let inputRef: ChatInputHandle | undefined = $state(undefined);
|
let inputRef: ChatInputHandle | undefined = $state(undefined);
|
||||||
@@ -544,7 +543,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
|
<ChatFormInputFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
|
||||||
|
|
||||||
<form
|
<form
|
||||||
class="relative grid {className}"
|
class="relative grid {className}"
|
||||||
@@ -603,22 +602,7 @@
|
|||||||
<div
|
<div
|
||||||
class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
|
class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
|
||||||
>
|
>
|
||||||
{#if useContenteditable}
|
<ChatFormInput
|
||||||
<ChatFormContentEditable
|
|
||||||
class="px-5 py-1.5 md:pt-0 mb-0.5"
|
|
||||||
bind:this={inputRef}
|
|
||||||
bind:value
|
|
||||||
onKeydown={handleKeydown}
|
|
||||||
onInput={() => {
|
|
||||||
pickers.handleInput();
|
|
||||||
onValueChange?.(value);
|
|
||||||
}}
|
|
||||||
onPaste={handlePaste}
|
|
||||||
{disabled}
|
|
||||||
{placeholder}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<ChatFormTextarea
|
|
||||||
class="px-5 py-1.5 md:pt-0"
|
class="px-5 py-1.5 md:pt-0"
|
||||||
bind:this={inputRef}
|
bind:this={inputRef}
|
||||||
bind:value
|
bind:value
|
||||||
@@ -630,8 +614,8 @@
|
|||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
{disabled}
|
{disabled}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
|
{useContenteditable}
|
||||||
/>
|
/>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if mcpResourceStore.hasAttachments}
|
{#if mcpResourceStore.hasAttachments}
|
||||||
<ChatFormMcpResourcesList
|
<ChatFormMcpResourcesList
|
||||||
@@ -667,7 +651,7 @@
|
|||||||
<ContextGaugePopup />
|
<ContextGaugePopup />
|
||||||
|
|
||||||
{#if toolsStore.hasEnabledCwdTools}
|
{#if toolsStore.hasEnabledCwdTools}
|
||||||
<ChatFormWorkingDirectory
|
<ChatFormCurrentWorkingDirectory
|
||||||
directory={cwd}
|
directory={cwd}
|
||||||
isOpen={pickers.isWorkingDirectoryPickerOpen}
|
isOpen={pickers.isWorkingDirectoryPickerOpen}
|
||||||
bind:query={pickers.workingDirectoryQuery}
|
bind:query={pickers.workingDirectoryQuery}
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte';
|
import ChatFormCurrentWorkingDirectoryChip from './ChatFormCurrentWorkingDirectoryChip.svelte';
|
||||||
import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.svelte';
|
import ChatFormCurrentWorkingDirectoryResultsList from './ChatFormCurrentWorkingDirectoryResultsList.svelte';
|
||||||
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';
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
// user cancelled - silently ignore; other errors are logged
|
// user cancelled - silently ignore; other errors are logged
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||||
|
|
||||||
console.error('[ChatFormWorkingDirectory] showDirectoryPicker failed:', err);
|
console.error('[ChatFormCurrentWorkingDirectory] showDirectoryPicker failed:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +331,7 @@
|
|||||||
onclick={onOpen}
|
onclick={onOpen}
|
||||||
{disabled}
|
{disabled}
|
||||||
>
|
>
|
||||||
<ChatFormWorkingDirectoryChip
|
<ChatFormCurrentWorkingDirectoryChip
|
||||||
{directory}
|
{directory}
|
||||||
{homeBase}
|
{homeBase}
|
||||||
{disabled}
|
{disabled}
|
||||||
@@ -372,7 +372,7 @@
|
|||||||
{#if !fileSearchEnabled}
|
{#if !fileSearchEnabled}
|
||||||
<div class="px-2 py-1.5 text-sm text-muted-foreground">{searchUnavailableMessage}</div>
|
<div class="px-2 py-1.5 text-sm text-muted-foreground">{searchUnavailableMessage}</div>
|
||||||
{:else if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
|
{:else if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
|
||||||
<ChatFormWorkingDirectoryResultsList
|
<ChatFormCurrentWorkingDirectoryResultsList
|
||||||
results={queryResults}
|
results={queryResults}
|
||||||
hoveredIndex={nav.hoveredIndex}
|
hoveredIndex={nav.hoveredIndex}
|
||||||
isSearching={search.isSearching}
|
isSearching={search.isSearching}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChatFormInputBasic from './ChatFormInputBasic.svelte';
|
||||||
|
import ChatFormInputRich from './ChatFormInputRich.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onInput?: () => void;
|
||||||
|
onKeydown?: (event: KeyboardEvent) => void;
|
||||||
|
onPaste?: (event: ClipboardEvent) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
value?: string;
|
||||||
|
useContenteditable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = '',
|
||||||
|
disabled = false,
|
||||||
|
onInput,
|
||||||
|
onKeydown,
|
||||||
|
onPaste,
|
||||||
|
placeholder = 'Ask anything...',
|
||||||
|
useContenteditable = false,
|
||||||
|
value = $bindable('')
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let basicRef: ChatFormInputBasic | undefined = $state();
|
||||||
|
let richRef: ChatFormInputRich | undefined = $state();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focus() {
|
||||||
|
if (useContenteditable) richRef?.focus();
|
||||||
|
else basicRef?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetHeight() {
|
||||||
|
if (useContenteditable) richRef?.resetHeight();
|
||||||
|
else basicRef?.resetHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCaretOffset(): number {
|
||||||
|
return useContenteditable
|
||||||
|
? (richRef?.getCaretOffset() ?? 0)
|
||||||
|
: (basicRef?.getCaretOffset() ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCaretOffset(offset: number) {
|
||||||
|
if (useContenteditable) richRef?.setCaretOffset(offset);
|
||||||
|
else basicRef?.setCaretOffset(offset);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if useContenteditable}
|
||||||
|
<ChatFormInputRich
|
||||||
|
bind:this={richRef}
|
||||||
|
class={className}
|
||||||
|
{disabled}
|
||||||
|
{onInput}
|
||||||
|
{onKeydown}
|
||||||
|
{onPaste}
|
||||||
|
{placeholder}
|
||||||
|
bind:value
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<ChatFormInputBasic
|
||||||
|
bind:this={basicRef}
|
||||||
|
class={className}
|
||||||
|
{disabled}
|
||||||
|
{onInput}
|
||||||
|
{onKeydown}
|
||||||
|
{onPaste}
|
||||||
|
{placeholder}
|
||||||
|
bind:value
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
+13
-13
@@ -2,7 +2,7 @@
|
|||||||
import { CODE_BLOCK } from '$lib/constants';
|
import { CODE_BLOCK } from '$lib/constants';
|
||||||
import { ColorMode } from '$lib/enums';
|
import { ColorMode } from '$lib/enums';
|
||||||
import { isMobile } from '$lib/stores';
|
import { isMobile } from '$lib/stores';
|
||||||
import type { ContentEditableToken } from '$lib/types';
|
import type { ChatFormInputRichToken } from '$lib/types';
|
||||||
import type { SourceHistoryEntry } from '$lib/utils';
|
import type { SourceHistoryEntry } from '$lib/utils';
|
||||||
import {
|
import {
|
||||||
badgeAwareWordJump,
|
badgeAwareWordJump,
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
rootElement.dataset.empty = source.length === 0 ? 'true' : 'false';
|
rootElement.dataset.empty = source.length === 0 ? 'true' : 'false';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTokens(tokens: ContentEditableToken[]) {
|
function renderTokens(tokens: ChatFormInputRichToken[]) {
|
||||||
if (!rootElement) return;
|
if (!rootElement) return;
|
||||||
|
|
||||||
const caret = rangeToTextOffset(rootElement, safeRange());
|
const caret = rangeToTextOffset(rootElement, safeRange());
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function highlightCodeBlocks(root: HTMLElement) {
|
function highlightCodeBlocks(root: HTMLElement) {
|
||||||
for (const el of root.querySelectorAll<HTMLElement>('code[data-code-token="block"]')) {
|
for (const el of root.querySelectorAll<HTMLElement>('code[data-code-token="code_block"]')) {
|
||||||
highlightCodeBlockElement(el);
|
highlightCodeBlockElement(el);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
while (node && node !== rootElement) {
|
while (node && node !== rootElement) {
|
||||||
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
|
if (node instanceof HTMLElement && node.dataset.codeToken === 'code_block') {
|
||||||
const caret = rangeToTextOffset(rootElement, range);
|
const caret = rangeToTextOffset(rootElement, range);
|
||||||
|
|
||||||
if (highlightCodeBlockElement(node)) {
|
if (highlightCodeBlockElement(node)) {
|
||||||
@@ -404,7 +404,7 @@
|
|||||||
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 === 'block') {
|
if (node instanceof HTMLElement && node.dataset.codeToken === 'code_block') {
|
||||||
const tail = document.createRange();
|
const tail = document.createRange();
|
||||||
|
|
||||||
tail.setStart(container, offset);
|
tail.setStart(container, offset);
|
||||||
@@ -462,7 +462,7 @@
|
|||||||
|
|
||||||
const first = rootElement.firstChild;
|
const first = rootElement.firstChild;
|
||||||
|
|
||||||
if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'block') return false;
|
if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'code_block') return false;
|
||||||
|
|
||||||
const range = safeRange();
|
const range = safeRange();
|
||||||
|
|
||||||
@@ -507,7 +507,7 @@
|
|||||||
|
|
||||||
const second = first.nextSibling;
|
const second = first.nextSibling;
|
||||||
|
|
||||||
if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'block') return;
|
if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'code_block') return;
|
||||||
|
|
||||||
const range = safeRange();
|
const range = safeRange();
|
||||||
const onHatch =
|
const onHatch =
|
||||||
@@ -787,7 +787,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex-1 {className}">
|
<div class="flex-1 {className} mb-0.5">
|
||||||
<div
|
<div
|
||||||
bind:this={rootElement}
|
bind:this={rootElement}
|
||||||
contenteditable={!disabled}
|
contenteditable={!disabled}
|
||||||
@@ -798,7 +798,7 @@
|
|||||||
data-placeholder={placeholder}
|
data-placeholder={placeholder}
|
||||||
tabindex={disabled ? -1 : 0}
|
tabindex={disabled ? -1 : 0}
|
||||||
class={[
|
class={[
|
||||||
'chat-form-contenteditable text-md min-h-12 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
'chat-form-input-rich text-md min-h-12 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||||
disabled && 'cursor-not-allowed'
|
disabled && 'cursor-not-allowed'
|
||||||
]}
|
]}
|
||||||
style="max-height: var(--max-message-height);"
|
style="max-height: var(--max-message-height);"
|
||||||
@@ -815,18 +815,18 @@
|
|||||||
<style>
|
<style>
|
||||||
/* pre-wrap is load-bearing: without it Chromium collapses \n in
|
/* pre-wrap is load-bearing: without it Chromium collapses \n in
|
||||||
text nodes and converts them to spaces while typing */
|
text nodes and converts them to spaces while typing */
|
||||||
.chat-form-contenteditable {
|
.chat-form-input-rich {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-form-contenteditable:global([data-empty='true'])::before {
|
.chat-form-input-rich:global([data-empty='true'])::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline code - mirrors markdown-content.css */
|
/* Inline code - mirrors markdown-content.css */
|
||||||
.chat-form-contenteditable :global(code[data-code-token='inline']) {
|
.chat-form-input-rich :global(code[data-code-token='code_inline']) {
|
||||||
background: var(--muted);
|
background: var(--muted);
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
padding: 0.125rem 0.375rem;
|
padding: 0.125rem 0.375rem;
|
||||||
@@ -835,7 +835,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Fenced code block - mirrors .code-block-wrapper in markdown-content.css */
|
/* Fenced code block - mirrors .code-block-wrapper in markdown-content.css */
|
||||||
.chat-form-contenteditable :global(code[data-code-token='block']) {
|
.chat-form-input-rich :global(code[data-code-token='code_block']) {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
+6
-6
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ChatFormCommandPicker from './ChatFormCommandPicker.svelte';
|
import ChatFormPickerCommand from './ChatFormPickerCommand.svelte';
|
||||||
import ChatFormMentionPicker from './ChatFormMentionPicker.svelte';
|
|
||||||
import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
|
import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
|
||||||
|
import ChatFormPickerMention from './ChatFormPickerMention.svelte';
|
||||||
import type {
|
import type {
|
||||||
ChatFormCommand,
|
ChatFormCommand,
|
||||||
FileMentionEntry,
|
FileMentionEntry,
|
||||||
@@ -55,9 +55,9 @@
|
|||||||
scopePath
|
scopePath
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let commandPickerRef: ChatFormCommandPicker | undefined = $state(undefined);
|
let commandPickerRef: ChatFormPickerCommand | undefined = $state(undefined);
|
||||||
let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
|
let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
|
||||||
let mentionPickerRef: ChatFormMentionPicker | undefined = $state(undefined);
|
let mentionPickerRef: ChatFormPickerMention | undefined = $state(undefined);
|
||||||
|
|
||||||
/** Delegate keyboard events to the active picker child; true if handled. */
|
/** Delegate keyboard events to the active picker child; true if handled. */
|
||||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ChatFormCommandPicker
|
<ChatFormPickerCommand
|
||||||
bind:this={commandPickerRef}
|
bind:this={commandPickerRef}
|
||||||
isOpen={isCommandPickerOpen ?? false}
|
isOpen={isCommandPickerOpen ?? false}
|
||||||
query={commandQuery ?? ''}
|
query={commandQuery ?? ''}
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
{onPromptLoadError}
|
{onPromptLoadError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatFormMentionPicker
|
<ChatFormPickerMention
|
||||||
bind:this={mentionPickerRef}
|
bind:this={mentionPickerRef}
|
||||||
isOpen={isMentionPickerOpen ?? false}
|
isOpen={isMentionPickerOpen ?? false}
|
||||||
query={mentionQuery ?? ''}
|
query={mentionQuery ?? ''}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export { default as ChatAttachmentsListItemThumbnailImage } from './ChatAttachme
|
|||||||
* preview without carousel, or a gallery/carousel view when multiple items exist.
|
* preview without carousel, or a gallery/carousel view when multiple items exist.
|
||||||
* Uses ChatAttachmentPreviewSingle internally for each item's content.
|
* Uses ChatAttachmentPreviewSingle internally for each item's content.
|
||||||
*/
|
*/
|
||||||
export { default as ChatAttachmentsPreview } from './ChatAttachments/ChatAttachmentsPreview.svelte';
|
export { default as ChatAttachmentsPreview } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte';
|
||||||
export { default as ChatAttachmentsPreviewNavButtons } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewNavButtons.svelte';
|
export { default as ChatAttachmentsPreviewNavButtons } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewNavButtons.svelte';
|
||||||
export { default as ChatAttachmentsPreviewFileInfo } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewFileInfo.svelte';
|
export { default as ChatAttachmentsPreviewFileInfo } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewFileInfo.svelte';
|
||||||
export { default as ChatAttachmentsPreviewThumbnailStrip } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte';
|
export { default as ChatAttachmentsPreviewThumbnailStrip } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte';
|
||||||
@@ -120,8 +120,8 @@ 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 ChatFormTextarea (or ChatFormContentEditable for messages with
|
* - Composes ChatFormInput (a plain textarea, or a contenteditable for
|
||||||
* 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
|
||||||
* - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
|
* - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
|
||||||
@@ -258,7 +258,7 @@ export { default as ChatFormContextGauge } from './ChatForm/ChatFormContextGauge
|
|||||||
/**
|
/**
|
||||||
* Hidden file input element for programmatic file selection.
|
* Hidden file input element for programmatic file selection.
|
||||||
*/
|
*/
|
||||||
export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileInputInvisible.svelte';
|
export { default as ChatFormInputFileInputInvisible } from './ChatForm/ChatFormInput/ChatFormInputFileInputInvisible.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays MCP Resource attachments as a horizontal carousel.
|
* Displays MCP Resource attachments as a horizontal carousel.
|
||||||
@@ -267,18 +267,13 @@ export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileIn
|
|||||||
export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte';
|
export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-resizing contenteditable input that renders `[name](file://...)`
|
* The message editor. Renders a plain auto-resizing textarea by default,
|
||||||
* mention links as inline chips while keeping the value as the markdown
|
* or a contenteditable that renders `[name](file://...)` mention links as
|
||||||
* source string. ChatForm swaps it in once a mention link lands in the
|
* inline chips (keeping the value as the markdown source string) once a
|
||||||
* buffer. Shares the focus()/resetHeight()/caret handle with the textarea.
|
* mention link lands in the buffer. The variant is selected via the
|
||||||
|
* `useContenteditable` prop; both share one imperative handle.
|
||||||
*/
|
*/
|
||||||
export { default as ChatFormContentEditable } from './ChatForm/ChatFormContentEditable.svelte';
|
export { default as ChatFormInput } from './ChatForm/ChatFormInput/ChatFormInput.svelte';
|
||||||
|
|
||||||
/**
|
|
||||||
* Plain auto-resizing textarea with IME composition support. Default input
|
|
||||||
* renderer inside ChatForm until a file mention lands.
|
|
||||||
*/
|
|
||||||
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Working directory selector for agent mode. Renders a chip below the chat
|
* Working directory selector for agent mode. Renders a chip below the chat
|
||||||
@@ -288,7 +283,7 @@ export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte'
|
|||||||
* synthetic "Set working directory to ..." user message into chat history
|
* synthetic "Set working directory to ..." user message into chat history
|
||||||
* and is enforced on tool calls via the `x-tool-cwd` request header.
|
* and is enforced on tool calls via the `x-tool-cwd` request header.
|
||||||
*/
|
*/
|
||||||
export { default as ChatFormWorkingDirectory } from './ChatForm/ChatFormWorkingDirectory.svelte';
|
export { default as ChatFormCurrentWorkingDirectory } from './ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* **ChatFormPickerMcpPrompts** - MCP prompt selection interface
|
* **ChatFormPickerMcpPrompts** - MCP prompt selection interface
|
||||||
@@ -359,14 +354,14 @@ export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/Cha
|
|||||||
* Generic scrollable list for picker popovers. Provides search input,
|
* Generic scrollable list for picker popovers. Provides search input,
|
||||||
* scroll-into-view for keyboard navigation, loading skeletons, empty state,
|
* scroll-into-view for keyboard navigation, loading skeletons, empty state,
|
||||||
* and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering.
|
* and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering.
|
||||||
* Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
|
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMention.
|
||||||
*/
|
*/
|
||||||
export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte';
|
export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic button wrapper for picker list items. Provides consistent styling,
|
* Generic button wrapper for picker list items. Provides consistent styling,
|
||||||
* hover/selected states, and data-picker-index attribute for scroll-into-view.
|
* hover/selected states, and data-picker-index attribute for scroll-into-view.
|
||||||
* Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
|
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMention.
|
||||||
*/
|
*/
|
||||||
export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte';
|
export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte';
|
||||||
|
|
||||||
@@ -389,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/ChatFormMentionPicker.svelte';
|
export { default as ChatFormMentionPicker } 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/ChatFormCommandPicker.svelte';
|
export { default as ChatFormCommandPicker } 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)
|
||||||
|
|||||||
@@ -91,11 +91,11 @@ export enum FileMentionEntryType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kinds of tokens the chat-form contenteditable produces.
|
* Kinds of tokens the chat-form-input-rich produces.
|
||||||
*/
|
*/
|
||||||
export enum ContentEditableTokenKind {
|
export enum ChatFormInputRichTokenKind {
|
||||||
TEXT = 'text',
|
TEXT = 'text',
|
||||||
BADGE = 'badge',
|
BADGE = 'badge',
|
||||||
INLINE_CODE = 'inlineCode',
|
CODE_INLINE = 'code_inline',
|
||||||
CODE_BLOCK = 'codeBlock'
|
CODE_BLOCK = 'code_block'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export {
|
|||||||
ReasoningFormat,
|
ReasoningFormat,
|
||||||
ChatFormCommandAction,
|
ChatFormCommandAction,
|
||||||
FileMentionEntryType,
|
FileMentionEntryType,
|
||||||
ContentEditableTokenKind
|
ChatFormInputRichTokenKind
|
||||||
} from './chat.enums';
|
} from './chat.enums';
|
||||||
|
|
||||||
export { SessionRecordType } from './conversation-import.enums';
|
export { SessionRecordType } from './conversation-import.enums';
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ChatFormInputRichTokenKind } from '$lib/enums';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single token produced by the chat-form-input-rich tokenizer:
|
||||||
|
* plain text, a file/folder mention badge, or an inline/fenced code span.
|
||||||
|
*/
|
||||||
|
export type ChatFormInputRichToken =
|
||||||
|
| { kind: ChatFormInputRichTokenKind.TEXT; text: string }
|
||||||
|
| { kind: ChatFormInputRichTokenKind.BADGE; name: string; path: string }
|
||||||
|
| { kind: ChatFormInputRichTokenKind.CODE_INLINE; text: string }
|
||||||
|
| { kind: ChatFormInputRichTokenKind.CODE_BLOCK; text: string };
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
import { ContentEditableTokenKind } from '$lib/enums';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single token produced by the chat-form contenteditable tokenizer:
|
|
||||||
* plain text, a file/folder mention badge, or an inline/fenced code span.
|
|
||||||
*/
|
|
||||||
export type ContentEditableToken =
|
|
||||||
| { kind: ContentEditableTokenKind.TEXT; text: string }
|
|
||||||
| { kind: ContentEditableTokenKind.BADGE; name: string; path: string }
|
|
||||||
| { kind: ContentEditableTokenKind.INLINE_CODE; text: string }
|
|
||||||
| { kind: ContentEditableTokenKind.CODE_BLOCK; text: string };
|
|
||||||
@@ -183,7 +183,7 @@ export type {
|
|||||||
} from './glob';
|
} from './glob';
|
||||||
|
|
||||||
// Contenteditable token types (chat form)
|
// Contenteditable token types (chat form)
|
||||||
export type { ContentEditableToken } from './contenteditable';
|
export type { ChatFormInputRichToken } from './chat-form-input-rich';
|
||||||
|
|
||||||
// Agentic types
|
// Agentic types
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
+43
-39
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Maps between the chat-form contenteditable's markdown source and the
|
* Maps between the chat-form-input-rich's markdown source and the
|
||||||
* badge/code/text token stream the DOM is built from. A badge is one
|
* badge/code/text token stream the DOM is built from. A badge is one
|
||||||
* opaque source contribution (`[name](file://path)`); its own subtree
|
* opaque source contribution (`[name](file://path)`); its own subtree
|
||||||
* is never walked, and the caret cannot land inside it, so offsets
|
* is never walked, and the caret cannot land inside it, so offsets
|
||||||
@@ -35,10 +35,10 @@ import {
|
|||||||
MENTION_BADGE_SVG_ATTRIBUTES,
|
MENTION_BADGE_SVG_ATTRIBUTES,
|
||||||
SETTINGS_KEYS
|
SETTINGS_KEYS
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
import { ContentEditableTokenKind } from '$lib/enums';
|
import { 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 { ContentEditableToken } from '$lib/types/contenteditable';
|
import type { ChatFormInputRichToken } from '$lib/types/chat-form-input-rich';
|
||||||
|
|
||||||
// Block wrappers browsers insert for newlines; each folds back into a
|
// Block wrappers browsers insert for newlines; each folds back into a
|
||||||
// single `\n` during serialization.
|
// single `\n` during serialization.
|
||||||
@@ -66,7 +66,7 @@ const CODE_SPAN_RE = /(```[\s\S]*?```)|(`[^`\n]+`)/g;
|
|||||||
/**
|
/**
|
||||||
* Cheap gate check for `ChatForm`: does the buffer contain a
|
* Cheap gate check for `ChatForm`: does the buffer contain a
|
||||||
* complete code span (inline or fenced)? Used to promote the plain
|
* complete code span (inline or fenced)? Used to promote the plain
|
||||||
* textarea to the contenteditable renderer.
|
* textarea to the chat-form-input-rich renderer.
|
||||||
*/
|
*/
|
||||||
export function containsCodeSpan(value: string): boolean {
|
export function containsCodeSpan(value: string): boolean {
|
||||||
CODE_SPAN_RE.lastIndex = 0;
|
CODE_SPAN_RE.lastIndex = 0;
|
||||||
@@ -102,14 +102,14 @@ export function isOffsetInCodeBlock(source: string, offset: number): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Tokenize a markdown source value into the segments the
|
* Tokenize a markdown source value into the segments the
|
||||||
* contenteditable will render. Code spans are carved out first
|
* chat-form-input-rich will render. Code spans are carved out first
|
||||||
* (their content is literal - a `file://` link inside backticks
|
* (their content is literal - a `file://` link inside backticks
|
||||||
* must NOT render as a badge), then plain text and badges
|
* must NOT render as a badge), then plain text and badges
|
||||||
* interleave in the remaining gaps. Any whitespace after a badge
|
* interleave in the remaining gaps. Any whitespace after a badge
|
||||||
* stays in a plain text token so the round trip is byte-exact.
|
* stays in a plain text token so the round trip is byte-exact.
|
||||||
*/
|
*/
|
||||||
export function tokenizeContent(input: string): ContentEditableToken[] {
|
export function tokenizeContent(input: string): ChatFormInputRichToken[] {
|
||||||
const tokens: ContentEditableToken[] = [];
|
const tokens: ChatFormInputRichToken[] = [];
|
||||||
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
|
||||||
@@ -126,8 +126,8 @@ export function tokenizeContent(input: string): ContentEditableToken[] {
|
|||||||
|
|
||||||
tokens.push(
|
tokens.push(
|
||||||
match[1] !== undefined
|
match[1] !== undefined
|
||||||
? { kind: ContentEditableTokenKind.CODE_BLOCK, text: match[1] }
|
? { kind: ChatFormInputRichTokenKind.CODE_BLOCK, text: match[1] }
|
||||||
: { kind: ContentEditableTokenKind.INLINE_CODE, text: match[2] }
|
: { kind: ChatFormInputRichTokenKind.CODE_INLINE, text: match[2] }
|
||||||
);
|
);
|
||||||
cursor = start + match[0].length;
|
cursor = start + match[0].length;
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ export function tokenizeContent(input: string): ContentEditableToken[] {
|
|||||||
/**
|
/**
|
||||||
* Tokenize a code-free segment into text and badge tokens.
|
* Tokenize a code-free segment into text and badge tokens.
|
||||||
*/
|
*/
|
||||||
function pushTextAndBadgeTokens(input: string, tokens: ContentEditableToken[]) {
|
function pushTextAndBadgeTokens(input: string, tokens: ChatFormInputRichToken[]) {
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
|
||||||
MENTION_BADGE_RE.lastIndex = 0;
|
MENTION_BADGE_RE.lastIndex = 0;
|
||||||
@@ -154,24 +154,26 @@ function pushTextAndBadgeTokens(input: string, tokens: ContentEditableToken[]) {
|
|||||||
const start = match.index;
|
const start = match.index;
|
||||||
|
|
||||||
if (start > cursor) {
|
if (start > cursor) {
|
||||||
tokens.push({ kind: ContentEditableTokenKind.TEXT, text: input.slice(cursor, start) });
|
tokens.push({ kind: ChatFormInputRichTokenKind.TEXT, text: input.slice(cursor, start) });
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens.push({ kind: ContentEditableTokenKind.BADGE, name, path });
|
tokens.push({ kind: ChatFormInputRichTokenKind.BADGE, name, path });
|
||||||
cursor = start + whole.length;
|
cursor = start + whole.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cursor < input.length) {
|
if (cursor < input.length) {
|
||||||
tokens.push({ kind: ContentEditableTokenKind.TEXT, text: input.slice(cursor) });
|
tokens.push({ kind: ChatFormInputRichTokenKind.TEXT, text: input.slice(cursor) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCodeBlockElement(node: Node | null): node is HTMLElement {
|
function isCodeBlockElement(node: Node | null): node is HTMLElement {
|
||||||
return node instanceof HTMLElement && node.dataset.codeToken === 'block';
|
return (
|
||||||
|
node instanceof HTMLElement && node.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize a contenteditable subtree back to source. `<br>` and block
|
* Serialize a chat-form-input-rich subtree back to source. `<br>` and block
|
||||||
* wrappers the browser inserted for newlines fold back into `\n` (a
|
* wrappers the browser inserted for newlines fold back into `\n` (a
|
||||||
* trailing `<br>` is the browser's caret placeholder, not a newline);
|
* trailing `<br>` is the browser's caret placeholder, not a newline);
|
||||||
* any other element is transparent. Code spans serialize their
|
* any other element is transparent. Code spans serialize their
|
||||||
@@ -226,7 +228,7 @@ export function serializeContent(root: HTMLElement): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (el.dataset.codeToken !== undefined) {
|
if (el.dataset.codeToken !== undefined) {
|
||||||
const isBlock = el.dataset.codeToken === 'block';
|
const isBlock = el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
|
||||||
|
|
||||||
if (isBlock && (pendingBlockBoundary || !first)) out += '\n';
|
if (isBlock && (pendingBlockBoundary || !first)) out += '\n';
|
||||||
|
|
||||||
@@ -285,8 +287,8 @@ export function serializeContent(root: HTMLElement): string {
|
|||||||
* A mismatch means token boundaries shifted (a code span was just
|
* A mismatch means token boundaries shifted (a code span was just
|
||||||
* completed or broken) and the DOM needs a rebuild to restyle.
|
* completed or broken) and the DOM needs a rebuild to restyle.
|
||||||
*/
|
*/
|
||||||
export function domMatchesTokens(root: HTMLElement, tokens: ContentEditableToken[]): boolean {
|
export function domMatchesTokens(root: HTMLElement, tokens: ChatFormInputRichToken[]): boolean {
|
||||||
const expected = tokens.filter((token) => token.kind !== ContentEditableTokenKind.TEXT);
|
const expected = tokens.filter((token) => token.kind !== ChatFormInputRichTokenKind.TEXT);
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
|
||||||
@@ -309,7 +311,7 @@ export function domMatchesTokens(root: HTMLElement, tokens: ContentEditableToken
|
|||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
|
|
||||||
if (isBadge) {
|
if (isBadge) {
|
||||||
if (token.kind !== ContentEditableTokenKind.BADGE) return false;
|
if (token.kind !== ChatFormInputRichTokenKind.BADGE) return false;
|
||||||
|
|
||||||
if (token.name !== (el.dataset.mentionName ?? '')) return false;
|
if (token.name !== (el.dataset.mentionName ?? '')) return false;
|
||||||
|
|
||||||
@@ -318,16 +320,16 @@ export function domMatchesTokens(root: HTMLElement, tokens: ContentEditableToken
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeKind: ContentEditableTokenKind =
|
const codeKind: ChatFormInputRichTokenKind =
|
||||||
el.dataset.codeToken === 'block'
|
el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK
|
||||||
? ContentEditableTokenKind.CODE_BLOCK
|
? ChatFormInputRichTokenKind.CODE_BLOCK
|
||||||
: ContentEditableTokenKind.INLINE_CODE;
|
: ChatFormInputRichTokenKind.CODE_INLINE;
|
||||||
|
|
||||||
if (token.kind !== codeKind) return false;
|
if (token.kind !== codeKind) return false;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
token.kind === ContentEditableTokenKind.INLINE_CODE ||
|
token.kind === ChatFormInputRichTokenKind.CODE_INLINE ||
|
||||||
token.kind === ContentEditableTokenKind.CODE_BLOCK
|
token.kind === ChatFormInputRichTokenKind.CODE_BLOCK
|
||||||
) {
|
) {
|
||||||
if (token.text !== (el.textContent ?? '')) return false;
|
if (token.text !== (el.textContent ?? '')) return false;
|
||||||
}
|
}
|
||||||
@@ -446,7 +448,7 @@ export function rangeToTextOffset(root: HTMLElement, range: Range | null): numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (el.dataset.codeToken !== undefined) {
|
if (el.dataset.codeToken !== undefined) {
|
||||||
const isBlock = el.dataset.codeToken === 'block';
|
const isBlock = el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
|
||||||
|
|
||||||
if (isBlock && !first) {
|
if (isBlock && !first) {
|
||||||
if (!atOrBeforeCaret(el, 0)) {
|
if (!atOrBeforeCaret(el, 0)) {
|
||||||
@@ -521,26 +523,29 @@ export function rangeToTextOffset(root: HTMLElement, range: Range | null): numbe
|
|||||||
* string + inline SVG are shared with the rehype plugin via
|
* string + inline SVG are shared with the rehype plugin via
|
||||||
* `$lib/constants`.
|
* `$lib/constants`.
|
||||||
*/
|
*/
|
||||||
export function buildFragment(tokens: ContentEditableToken[]): DocumentFragment {
|
export function buildFragment(tokens: ChatFormInputRichToken[]): DocumentFragment {
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
for (let index = 0; index < tokens.length; index++) {
|
for (let index = 0; index < tokens.length; index++) {
|
||||||
const token = tokens[index];
|
const token = tokens[index];
|
||||||
|
|
||||||
if (token.kind === ContentEditableTokenKind.TEXT) {
|
if (token.kind === ChatFormInputRichTokenKind.TEXT) {
|
||||||
let text = token.text;
|
let text = token.text;
|
||||||
|
|
||||||
// The separator \n at a fenced-block boundary is synthesized
|
// The separator \n at a fenced-block boundary is synthesized
|
||||||
// at serialization time; keeping it in the DOM would render a
|
// at serialization time; keeping it in the DOM would render a
|
||||||
// phantom empty line next to the block.
|
// phantom empty line next to the block.
|
||||||
if (
|
if (
|
||||||
tokens[index - 1]?.kind === ContentEditableTokenKind.CODE_BLOCK &&
|
tokens[index - 1]?.kind === ChatFormInputRichTokenKind.CODE_BLOCK &&
|
||||||
text.startsWith('\n')
|
text.startsWith('\n')
|
||||||
) {
|
) {
|
||||||
text = text.slice(1);
|
text = text.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokens[index + 1]?.kind === ContentEditableTokenKind.CODE_BLOCK && text.endsWith('\n')) {
|
if (
|
||||||
|
tokens[index + 1]?.kind === ChatFormInputRichTokenKind.CODE_BLOCK &&
|
||||||
|
text.endsWith('\n')
|
||||||
|
) {
|
||||||
text = text.slice(0, -1);
|
text = text.slice(0, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,13 +557,12 @@ export function buildFragment(tokens: ContentEditableToken[]): DocumentFragment
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
token.kind === ContentEditableTokenKind.INLINE_CODE ||
|
token.kind === ChatFormInputRichTokenKind.CODE_INLINE ||
|
||||||
token.kind === ContentEditableTokenKind.CODE_BLOCK
|
token.kind === ChatFormInputRichTokenKind.CODE_BLOCK
|
||||||
) {
|
) {
|
||||||
const code = document.createElement('code');
|
const code = document.createElement('code');
|
||||||
|
|
||||||
code.dataset.codeToken =
|
code.dataset.codeToken = token.kind;
|
||||||
token.kind === ContentEditableTokenKind.CODE_BLOCK ? 'block' : 'inline';
|
|
||||||
code.textContent = token.text;
|
code.textContent = token.text;
|
||||||
fragment.appendChild(code);
|
fragment.appendChild(code);
|
||||||
|
|
||||||
@@ -734,14 +738,14 @@ export function badgeAwareWordJump(
|
|||||||
|
|
||||||
for (const token of tokenizeContent(source)) {
|
for (const token of tokenizeContent(source)) {
|
||||||
const len =
|
const len =
|
||||||
token.kind === ContentEditableTokenKind.BADGE
|
token.kind === ChatFormInputRichTokenKind.BADGE
|
||||||
? badgeSourceLength(token.name, token.path)
|
? badgeSourceLength(token.name, token.path)
|
||||||
: token.text.length;
|
: token.text.length;
|
||||||
|
|
||||||
if (token.kind === ContentEditableTokenKind.BADGE)
|
if (token.kind === ChatFormInputRichTokenKind.BADGE)
|
||||||
badgeSpans.push([masked.length, masked.length + len]);
|
badgeSpans.push([masked.length, masked.length + len]);
|
||||||
|
|
||||||
masked += token.kind === ContentEditableTokenKind.BADGE ? 'a'.repeat(len) : token.text;
|
masked += token.kind === ChatFormInputRichTokenKind.BADGE ? 'a'.repeat(len) : token.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (badgeSpans.length === 0) return null;
|
if (badgeSpans.length === 0) return null;
|
||||||
@@ -804,7 +808,7 @@ export function badgeAwareWordJump(
|
|||||||
export function leadingBadgeEdgeOffset(source: string, caret: number): number | null {
|
export function leadingBadgeEdgeOffset(source: string, caret: number): number | null {
|
||||||
const [first] = tokenizeContent(source);
|
const [first] = tokenizeContent(source);
|
||||||
|
|
||||||
if (!first || first.kind !== ContentEditableTokenKind.BADGE) return null;
|
if (!first || first.kind !== ChatFormInputRichTokenKind.BADGE) return null;
|
||||||
|
|
||||||
return caret === badgeSourceLength(first.name, first.path) ? 0 : null;
|
return caret === badgeSourceLength(first.name, first.path) ? 0 : null;
|
||||||
}
|
}
|
||||||
@@ -912,7 +916,7 @@ export function textOffsetToRange(root: HTMLElement, offset: number): Range {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (el.dataset.codeToken !== undefined) {
|
if (el.dataset.codeToken !== undefined) {
|
||||||
const isBlock = el.dataset.codeToken === 'block';
|
const isBlock = el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
|
||||||
|
|
||||||
if (isBlock && (pendingBlockBoundary || !first)) {
|
if (isBlock && (pendingBlockBoundary || !first)) {
|
||||||
pendingBlockBoundary = false;
|
pendingBlockBoundary = false;
|
||||||
@@ -221,7 +221,7 @@ export {
|
|||||||
textOffsetToRange,
|
textOffsetToRange,
|
||||||
badgeAwareWordJump,
|
badgeAwareWordJump,
|
||||||
leadingBadgeEdgeOffset
|
leadingBadgeEdgeOffset
|
||||||
} from './contenteditable-tokenizer';
|
} from './chat-form-input-rich-tokenizer';
|
||||||
|
|
||||||
// Source-space undo/redo history for the chat-form contenteditable
|
// Source-space undo/redo history for the chat-form contenteditable
|
||||||
export { SourceHistory, type SourceHistoryEntry } from './source-history';
|
export { SourceHistory, type SourceHistoryEntry } from './source-history';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// 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 contenteditable
|
||||||
// consumes the same case locally; see chat-form-contenteditable).
|
// consumes the same case locally; see chat-form-input-rich).
|
||||||
|
|
||||||
import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
|
import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
|
||||||
import { SETTINGS_KEYS } from '$lib/constants';
|
import { SETTINGS_KEYS } from '$lib/constants';
|
||||||
|
|||||||
+9
-9
@@ -3,7 +3,7 @@
|
|||||||
// 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.
|
||||||
|
|
||||||
import ChatFormContentEditableHarness from './components/ChatFormContentEditableHarness.svelte';
|
import ChatFormInputRichHarness from './components/ChatFormInputRichHarness.svelte';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { render } from 'vitest-browser-svelte';
|
import { render } from 'vitest-browser-svelte';
|
||||||
@@ -35,9 +35,9 @@ function setCaret(node: Node, offset: number) {
|
|||||||
selection.addRange(range);
|
selection.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ChatFormContentEditable browser newline shapes', () => {
|
describe('ChatFormInputRich browser newline shapes', () => {
|
||||||
it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
|
it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
|
||||||
const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
|
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ describe('ChatFormContentEditable browser newline shapes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
|
it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
|
||||||
const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
|
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ describe('ChatFormContentEditable browser newline shapes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('serializes a <br> as a newline', async () => {
|
it('serializes a <br> as a newline', async () => {
|
||||||
const screen = render(ChatFormContentEditableHarness, { value: 'here' });
|
const screen = render(ChatFormInputRichHarness, { value: 'here' });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ describe('ChatFormContentEditable browser newline shapes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ignores a trailing <br> (browser caret placeholder)', async () => {
|
it('ignores a trailing <br> (browser caret placeholder)', async () => {
|
||||||
const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
|
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ describe('ChatFormContentEditable browser newline shapes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('serializes one newline per empty-line <div><br></div>', async () => {
|
it('serializes one newline per empty-line <div><br></div>', async () => {
|
||||||
const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
|
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ describe('ChatFormContentEditable browser newline shapes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
|
it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
|
||||||
const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
|
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ describe('ChatFormContentEditable browser newline shapes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('maps the caret across block boundaries in both directions', async () => {
|
it('maps the caret across block boundaries in both directions', async () => {
|
||||||
const screen = render(ChatFormContentEditableHarness, { value: 'abc\ndef' });
|
const screen = render(ChatFormInputRichHarness, { value: 'abc\ndef' });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
+10
-10
@@ -3,7 +3,7 @@
|
|||||||
// 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.
|
||||||
|
|
||||||
import ChatFormContentEditableHarness from './components/ChatFormContentEditableHarness.svelte';
|
import ChatFormInputRichHarness from './components/ChatFormInputRichHarness.svelte';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { render } from 'vitest-browser-svelte';
|
import { render } from 'vitest-browser-svelte';
|
||||||
@@ -31,9 +31,9 @@ function keydown(root: HTMLElement, init: KeyboardEventInit) {
|
|||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ChatFormContentEditable undo/redo', () => {
|
describe('ChatFormInputRich undo/redo', () => {
|
||||||
it('undoes and redoes an edit across a badge-containing buffer', async () => {
|
it('undoes and redoes an edit across a badge-containing buffer', async () => {
|
||||||
const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
|
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ describe('ChatFormContentEditable undo/redo', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('redoes with Ctrl+Y as well', async () => {
|
it('redoes with Ctrl+Y as well', async () => {
|
||||||
const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
|
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ describe('ChatFormContentEditable undo/redo', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('coalesces a typing burst into one undo step', async () => {
|
it('coalesces a typing burst into one undo step', async () => {
|
||||||
const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
|
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ describe('ChatFormContentEditable undo/redo', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a newline as its own undo step', async () => {
|
it('keeps a newline as its own undo step', async () => {
|
||||||
const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
|
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ describe('ChatFormContentEditable undo/redo', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is a no-op when there is nothing to undo', async () => {
|
it('is a no-op when there is nothing to undo', async () => {
|
||||||
const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
|
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ describe('ChatFormContentEditable undo/redo', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('abandons the redo branch after a fresh edit', async () => {
|
it('abandons the redo branch after a fresh edit', async () => {
|
||||||
const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
|
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -147,9 +147,9 @@ describe('ChatFormContentEditable undo/redo', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ChatFormContentEditable Tab key', () => {
|
describe('ChatFormInputRich Tab key', () => {
|
||||||
it('does not trap Tab (focus can leave the editable)', async () => {
|
it('does not trap Tab (focus can leave the editable)', async () => {
|
||||||
const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
|
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
+44
-44
@@ -3,7 +3,7 @@
|
|||||||
// 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.
|
||||||
|
|
||||||
import ChatFormContentEditable from '$lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte';
|
import ChatFormInputRich from '$lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte';
|
||||||
import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
|
import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
@@ -43,9 +43,9 @@ function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') {
|
|||||||
return { data, event };
|
return { data, event };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ChatFormContentEditable clipboard', () => {
|
describe('ChatFormInputRich clipboard', () => {
|
||||||
it('copy exposes the markdown source of the selection', async () => {
|
it('copy exposes the markdown source of the selection', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: SOURCE });
|
const { container } = render(ChatFormInputRich, { value: SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ describe('ChatFormContentEditable clipboard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('cut exposes the markdown source and removes the slice', async () => {
|
it('cut exposes the markdown source and removes the slice', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: SOURCE });
|
const { container } = render(ChatFormInputRich, { value: SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ describe('ChatFormContentEditable clipboard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('paste of markdown mention links re-renders badges', async () => {
|
it('paste of markdown mention links re-renders badges', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: 'hello ' });
|
const { container } = render(ChatFormInputRich, { value: 'hello ' });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ describe('ChatFormContentEditable clipboard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('paste without mention links keeps the DOM untouched', async () => {
|
it('paste without mention links keeps the DOM untouched', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: 'hello ' });
|
const { container } = render(ChatFormInputRich, { value: 'hello ' });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -138,14 +138,14 @@ describe('ChatFormContentEditable clipboard', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ChatFormContentEditable code spans', () => {
|
describe('ChatFormInputRich code spans', () => {
|
||||||
it('renders inline code from the initial value', async () => {
|
it('renders inline code from the initial value', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: 'run `npm test` now' });
|
const { container } = render(ChatFormInputRich, { value: 'run `npm test` now' });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
const root = editableIn(container);
|
const root = editableIn(container);
|
||||||
const code = root.querySelector('code[data-code-token="inline"]');
|
const code = root.querySelector('code[data-code-token="code_inline"]');
|
||||||
|
|
||||||
expect(code).not.toBeNull();
|
expect(code).not.toBeNull();
|
||||||
expect(code!.textContent).toBe('`npm test`');
|
expect(code!.textContent).toBe('`npm test`');
|
||||||
@@ -153,12 +153,12 @@ describe('ChatFormContentEditable code spans', () => {
|
|||||||
|
|
||||||
it('renders a fenced code block with a language', async () => {
|
it('renders a fenced code block with a language', async () => {
|
||||||
const source = 'before\n```js\nconst a = 1;\n```\nafter';
|
const source = 'before\n```js\nconst a = 1;\n```\nafter';
|
||||||
const { container } = render(ChatFormContentEditable, { value: source });
|
const { container } = render(ChatFormInputRich, { value: source });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
const root = editableIn(container);
|
const root = editableIn(container);
|
||||||
const code = root.querySelector('code[data-code-token="block"]');
|
const code = root.querySelector('code[data-code-token="code_block"]');
|
||||||
|
|
||||||
expect(code).not.toBeNull();
|
expect(code).not.toBeNull();
|
||||||
expect(code!.textContent).toBe('```js\nconst a = 1;\n```');
|
expect(code!.textContent).toBe('```js\nconst a = 1;\n```');
|
||||||
@@ -166,7 +166,7 @@ describe('ChatFormContentEditable code spans', () => {
|
|||||||
|
|
||||||
it('copy exposes the markdown source of a selection spanning code', async () => {
|
it('copy exposes the markdown source of a selection spanning code', async () => {
|
||||||
const source = 'run `npm test` now';
|
const source = 'run `npm test` now';
|
||||||
const { container } = render(ChatFormContentEditable, { value: source });
|
const { container } = render(ChatFormInputRich, { value: source });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ describe('ChatFormContentEditable code spans', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('paste of a code span renders the styled element', async () => {
|
it('paste of a code span renders the styled element', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: 'run ' });
|
const { container } = render(ChatFormInputRich, { value: 'run ' });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ describe('ChatFormContentEditable code spans', () => {
|
|||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
expect(event.defaultPrevented).toBe(true);
|
expect(event.defaultPrevented).toBe(true);
|
||||||
const code = root.querySelector('code[data-code-token="inline"]');
|
const code = root.querySelector('code[data-code-token="code_inline"]');
|
||||||
|
|
||||||
expect(code).not.toBeNull();
|
expect(code).not.toBeNull();
|
||||||
expect(code!.textContent).toBe('`npm test`');
|
expect(code!.textContent).toBe('`npm test`');
|
||||||
@@ -210,12 +210,12 @@ describe('ChatFormContentEditable code spans', () => {
|
|||||||
|
|
||||||
it('highlights a fenced block content and stays byte-exact', async () => {
|
it('highlights a fenced block content and stays byte-exact', async () => {
|
||||||
const source = '```js\nconst a = 1;\n```';
|
const source = '```js\nconst a = 1;\n```';
|
||||||
const { container } = render(ChatFormContentEditable, { value: source });
|
const { container } = render(ChatFormInputRich, { value: source });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
const root = editableIn(container);
|
const root = editableIn(container);
|
||||||
const code = root.querySelector('code[data-code-token="block"]');
|
const code = root.querySelector('code[data-code-token="code_block"]');
|
||||||
|
|
||||||
expect(code).not.toBeNull();
|
expect(code).not.toBeNull();
|
||||||
expect(code!.querySelector('.hljs-keyword')).not.toBeNull();
|
expect(code!.querySelector('.hljs-keyword')).not.toBeNull();
|
||||||
@@ -223,7 +223,7 @@ describe('ChatFormContentEditable code spans', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not highlight inline code', async () => {
|
it('does not highlight inline code', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: 'run `const` now' });
|
const { container } = render(ChatFormInputRich, { value: 'run `const` now' });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -233,9 +233,9 @@ describe('ChatFormContentEditable code spans', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ChatFormContentEditable code block escape hatches', () => {
|
describe('ChatFormInputRich code block escape hatches', () => {
|
||||||
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
|
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
|
||||||
const BLOCK_SELECTOR = 'code[data-code-token="block"]';
|
const BLOCK_SELECTOR = 'code[data-code-token="code_block"]';
|
||||||
|
|
||||||
function blockIn(root: HTMLElement): HTMLElement {
|
function blockIn(root: HTMLElement): HTMLElement {
|
||||||
const el = root.querySelector(BLOCK_SELECTOR);
|
const el = root.querySelector(BLOCK_SELECTOR);
|
||||||
@@ -273,7 +273,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('pads a trailing code block with a br hatch that stays invisible to copy', async () => {
|
it('pads a trailing code block with a br hatch that stays invisible to copy', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
|
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -292,7 +292,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('escapes a trailing code block with ArrowDown and types after it', async () => {
|
it('escapes a trailing code block with ArrowDown and types after it', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
|
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -318,7 +318,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('escapes a leading code block with ArrowUp and types before it', async () => {
|
it('escapes a leading code block with ArrowUp and types before it', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
|
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -343,7 +343,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('escapes a leading code block with ArrowLeft from its first character', async () => {
|
it('escapes a leading code block with ArrowLeft from its first character', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
|
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -358,7 +358,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('removes the transient leading hatch when the caret moves back into the block', async () => {
|
it('removes the transient leading hatch when the caret moves back into the block', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
|
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -378,7 +378,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('extends the selection out of the block with Shift+ArrowDown', async () => {
|
it('extends the selection out of the block with Shift+ArrowDown', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
|
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -397,7 +397,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('line-separates text typed right after the closing fence', async () => {
|
it('line-separates text typed right after the closing fence', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
|
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -419,7 +419,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not double the newline when Shift+Enter already added one', async () => {
|
it('does not double the newline when Shift+Enter already added one', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
|
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -437,7 +437,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('moves a caret stuck before the inserted newline onto the new line', async () => {
|
it('moves a caret stuck before the inserted newline onto the new line', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, {
|
const { container } = render(ChatFormInputRich, {
|
||||||
value: BLOCK_SOURCE + '\ntext after the code block'
|
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -469,7 +469,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('appends the artificial trailing newline when the browser did not add one', async () => {
|
it('appends the artificial trailing newline when the browser did not add one', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, {
|
const { container } = render(ChatFormInputRich, {
|
||||||
value: BLOCK_SOURCE + '\ntext after the code block'
|
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -501,7 +501,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('lands the caret on the new line with a single Shift+Enter after text below a block', async () => {
|
it('lands the caret on the new line with a single Shift+Enter after text below a block', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, {
|
const { container } = render(ChatFormInputRich, {
|
||||||
value: BLOCK_SOURCE + '\ntext after the code block'
|
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -534,7 +534,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('lets Backspace at the text start move into the block without a source fight', async () => {
|
it('lets Backspace at the text start move into the block without a source fight', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
|
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -560,7 +560,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('lets forward Delete eat the text after a block normally', async () => {
|
it('lets forward Delete eat the text after a block normally', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
|
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -581,7 +581,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders text after a block without a phantom empty line', async () => {
|
it('renders text after a block without a phantom empty line', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, {
|
const { container } = render(ChatFormInputRich, {
|
||||||
value: BLOCK_SOURCE + '\nhello'
|
value: BLOCK_SOURCE + '\nhello'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -594,7 +594,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps an intentional blank line after a block out of the separator', async () => {
|
it('keeps an intentional blank line after a block out of the separator', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, {
|
const { container } = render(ChatFormInputRich, {
|
||||||
value: BLOCK_SOURCE + '\n\nhello'
|
value: BLOCK_SOURCE + '\n\nhello'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -607,7 +607,7 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('re-highlights while typing inside a block and keeps the caret', async () => {
|
it('re-highlights while typing inside a block and keeps the caret', async () => {
|
||||||
const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
|
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
@@ -639,12 +639,12 @@ describe('ChatFormContentEditable code block escape hatches', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ChatFormContentEditable Enter in code blocks', () => {
|
describe('ChatFormInputRich Enter in code blocks', () => {
|
||||||
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
|
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
|
||||||
|
|
||||||
it('adds a line instead of submitting on plain Enter inside a block', async () => {
|
it('adds a line instead of submitting on plain Enter inside a block', async () => {
|
||||||
const onKeydown = vi.fn();
|
const onKeydown = vi.fn();
|
||||||
const { container } = render(ChatFormContentEditable, {
|
const { container } = render(ChatFormInputRich, {
|
||||||
onKeydown,
|
onKeydown,
|
||||||
value: BLOCK_SOURCE
|
value: BLOCK_SOURCE
|
||||||
});
|
});
|
||||||
@@ -670,7 +670,7 @@ describe('ChatFormContentEditable Enter in code blocks', () => {
|
|||||||
expect(onKeydown).not.toHaveBeenCalled();
|
expect(onKeydown).not.toHaveBeenCalled();
|
||||||
expect(serializeContent(root)).toBe('```js\n\nconst a = 1;\n```');
|
expect(serializeContent(root)).toBe('```js\n\nconst a = 1;\n```');
|
||||||
|
|
||||||
const code = root.querySelector('code[data-code-token="block"]');
|
const code = root.querySelector('code[data-code-token="code_block"]');
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
|
|
||||||
expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
|
expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
|
||||||
@@ -679,7 +679,7 @@ describe('ChatFormContentEditable Enter in code blocks', () => {
|
|||||||
|
|
||||||
it('adds a line after a still-open fence (no closing ``` yet)', async () => {
|
it('adds a line after a still-open fence (no closing ``` yet)', async () => {
|
||||||
const onKeydown = vi.fn();
|
const onKeydown = vi.fn();
|
||||||
const { container } = render(ChatFormContentEditable, {
|
const { container } = render(ChatFormInputRich, {
|
||||||
onKeydown,
|
onKeydown,
|
||||||
value: '```js\nconst a = 1;'
|
value: '```js\nconst a = 1;'
|
||||||
});
|
});
|
||||||
@@ -707,7 +707,7 @@ describe('ChatFormContentEditable Enter in code blocks', () => {
|
|||||||
|
|
||||||
it('forwards plain Enter to the parent when the caret is outside a block', async () => {
|
it('forwards plain Enter to the parent when the caret is outside a block', async () => {
|
||||||
const onKeydown = vi.fn();
|
const onKeydown = vi.fn();
|
||||||
const { container } = render(ChatFormContentEditable, {
|
const { container } = render(ChatFormInputRich, {
|
||||||
onKeydown,
|
onKeydown,
|
||||||
value: BLOCK_SOURCE + '\nafter'
|
value: BLOCK_SOURCE + '\nafter'
|
||||||
});
|
});
|
||||||
@@ -730,7 +730,7 @@ describe('ChatFormContentEditable Enter in code blocks', () => {
|
|||||||
|
|
||||||
it('forwards plain Enter on the trailing hatch line after a block', async () => {
|
it('forwards plain Enter on the trailing hatch line after a block', async () => {
|
||||||
const onKeydown = vi.fn();
|
const onKeydown = vi.fn();
|
||||||
const { container } = render(ChatFormContentEditable, {
|
const { container } = render(ChatFormInputRich, {
|
||||||
onKeydown,
|
onKeydown,
|
||||||
value: BLOCK_SOURCE
|
value: BLOCK_SOURCE
|
||||||
});
|
});
|
||||||
@@ -753,7 +753,7 @@ describe('ChatFormContentEditable Enter in code blocks', () => {
|
|||||||
|
|
||||||
it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
|
it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
|
||||||
const onKeydown = vi.fn();
|
const onKeydown = vi.fn();
|
||||||
const { container } = render(ChatFormContentEditable, {
|
const { container } = render(ChatFormInputRich, {
|
||||||
onKeydown,
|
onKeydown,
|
||||||
value: BLOCK_SOURCE
|
value: BLOCK_SOURCE
|
||||||
});
|
});
|
||||||
@@ -780,7 +780,7 @@ describe('ChatFormContentEditable Enter in code blocks', () => {
|
|||||||
|
|
||||||
it('forwards Enter inside an inline code span', async () => {
|
it('forwards Enter inside an inline code span', async () => {
|
||||||
const onKeydown = vi.fn();
|
const onKeydown = vi.fn();
|
||||||
const { container } = render(ChatFormContentEditable, {
|
const { container } = render(ChatFormInputRich, {
|
||||||
onKeydown,
|
onKeydown,
|
||||||
value: 'run `npm test` now'
|
value: 'run `npm test` now'
|
||||||
});
|
});
|
||||||
@@ -790,7 +790,7 @@ describe('ChatFormContentEditable Enter in code blocks', () => {
|
|||||||
const root = editableIn(container);
|
const root = editableIn(container);
|
||||||
|
|
||||||
root.focus();
|
root.focus();
|
||||||
const code = root.querySelector('code[data-code-token="inline"]')!;
|
const code = root.querySelector('code[data-code-token="code_inline"]')!;
|
||||||
|
|
||||||
setSelection(root, (range) => {
|
setSelection(root, (range) => {
|
||||||
range.setStart(code.firstChild!, 3);
|
range.setStart(code.firstChild!, 3);
|
||||||
@@ -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/ChatFormMentionPicker.svelte';
|
import ChatFormMentionPicker 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';
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ChatFormContentEditable from '$lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte';
|
import ChatFormInputRich from '$lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
let { value: initial = '' }: Props = $props();
|
let { value: initial = '' }: Props = $props();
|
||||||
|
|
||||||
let value = $state(untrack(() => initial));
|
let value = $state(untrack(() => initial));
|
||||||
let inputRef: ChatFormContentEditable | undefined = $state(undefined);
|
let inputRef: ChatFormInputRich | undefined = $state(undefined);
|
||||||
|
|
||||||
export function getValue() {
|
export function getValue() {
|
||||||
return value;
|
return value;
|
||||||
@@ -24,4 +24,4 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ChatFormContentEditable bind:this={inputRef} bind:value />
|
<ChatFormInputRich bind:this={inputRef} bind:value />
|
||||||
+7
-7
@@ -97,7 +97,7 @@ describe('tokenizeContent', () => {
|
|||||||
it('tokenizes inline code with the backticks included', () => {
|
it('tokenizes inline code with the backticks included', () => {
|
||||||
expect(tokenizeContent('run `npm test` now')).toEqual([
|
expect(tokenizeContent('run `npm test` now')).toEqual([
|
||||||
{ kind: 'text', text: 'run ' },
|
{ kind: 'text', text: 'run ' },
|
||||||
{ kind: 'inlineCode', text: '`npm test`' },
|
{ kind: 'code_inline', text: '`npm test`' },
|
||||||
{ kind: 'text', text: ' now' }
|
{ kind: 'text', text: ' now' }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -107,7 +107,7 @@ describe('tokenizeContent', () => {
|
|||||||
|
|
||||||
expect(tokenizeContent(source)).toEqual([
|
expect(tokenizeContent(source)).toEqual([
|
||||||
{ kind: 'text', text: 'before\n' },
|
{ kind: 'text', text: 'before\n' },
|
||||||
{ kind: 'codeBlock', text: '```\nconst a = 1;\n```' },
|
{ kind: 'code_block', text: '```\nconst a = 1;\n```' },
|
||||||
{ kind: 'text', text: '\nafter' }
|
{ kind: 'text', text: '\nafter' }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -116,15 +116,15 @@ describe('tokenizeContent', () => {
|
|||||||
const source = '```js\nconst a = 1;\n```';
|
const source = '```js\nconst a = 1;\n```';
|
||||||
|
|
||||||
expect(tokenizeContent(source)).toEqual([
|
expect(tokenizeContent(source)).toEqual([
|
||||||
{ kind: 'codeBlock', text: '```js\nconst a = 1;\n```' }
|
{ kind: 'code_block', text: '```js\nconst a = 1;\n```' }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers the fenced block over inline spans at triple backticks', () => {
|
it('prefers the fenced block over inline spans at triple backticks', () => {
|
||||||
expect(tokenizeContent('```a``` ```b```')).toEqual([
|
expect(tokenizeContent('```a``` ```b```')).toEqual([
|
||||||
{ kind: 'codeBlock', text: '```a```' },
|
{ kind: 'code_block', text: '```a```' },
|
||||||
{ kind: 'text', text: ' ' },
|
{ kind: 'text', text: ' ' },
|
||||||
{ kind: 'codeBlock', text: '```b```' }
|
{ kind: 'code_block', text: '```b```' }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ describe('tokenizeContent', () => {
|
|||||||
|
|
||||||
it('does not recognize badges inside code spans', () => {
|
it('does not recognize badges inside code spans', () => {
|
||||||
expect(tokenizeContent('`[a](file:///p)`')).toEqual([
|
expect(tokenizeContent('`[a](file:///p)`')).toEqual([
|
||||||
{ kind: 'inlineCode', text: '`[a](file:///p)`' }
|
{ kind: 'code_inline', text: '`[a](file:///p)`' }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ describe('tokenizeContent', () => {
|
|||||||
expect(tokenizeContent('[a](file:///p) `x`')).toEqual([
|
expect(tokenizeContent('[a](file:///p) `x`')).toEqual([
|
||||||
{ kind: 'badge', name: 'a', path: '/p' },
|
{ kind: 'badge', name: 'a', path: '/p' },
|
||||||
{ kind: 'text', text: ' ' },
|
{ kind: 'text', text: ' ' },
|
||||||
{ kind: 'inlineCode', text: '`x`' }
|
{ kind: 'code_inline', text: '`x`' }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user