mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat(ai-chat): collapse big pastes, cap input height, escape HTML (#9487)
* feat(ai-chat): collapse big pastes, cap input height, escape HTML Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(ai-chat): concentrate paste expand/render in a ChatDraft module Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ai-chat): step caret over paste chips as one unit Arrow-Left/Right now jump edge-to-edge across a collapsed-paste chip instead of crawling through the invisible token characters, and snap the caret out if it lands inside a token. Shift extends the selection across the whole chip; word/line jumps (alt/cmd/ctrl) are left to the browser. Mirrors the existing atomic-deletion behavior so traversal and deletion both treat the chip as a single object. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ai-chat): atomic chip deletion on overlapping selections + plural label A selection that partially overlapped a paste token previously hit the collapsed-caret early-return, so the browser deleted a partial token and left an orphaned zero-width run plus a dangling pastes registry entry. handlePasteDeletion now widens the deletion range to cover each overlapped token whole and drops all affected pastes entries (shared removePasteRange helper). Strict overlap, so abutting a chip edge doesn't pull it in. Also route line-count pluralization through a shared lineCount() helper so a 1-line paste reads "1 line" in the conversation bubble too, not "1 lines". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ai-chat): full chip atomicity via beforeinput + expand on copy/cut Addresses review nits on PR #9487: - Chip atomicity is no longer limited to Backspace/Delete keydown. A new beforeinput handler takes over any selection-spanning edit that overlaps a paste chip — typing over a selection, paste, drag-and-drop, and Backspace/Delete over a selection — and applies it to the whole token(s) via a shared replacePasteRange, so a partial edit can no longer leave an orphaned zero-width run or a dangling pastes registry entry. handlePaste is likewise widened so a large paste onto a chip replaces it whole. The keydown handler now only covers the collapsed-caret-at-boundary case beforeinput can't see. - Copy/cut of a selection containing a chip now puts the expanded content on the clipboard instead of the chip label + its zero-width run; cut also removes the chip whole. This also removes the duplicate-token re-paste vector (the clipboard never carries a raw token anymore). - Rename lineCount -> lineCountLabel: it returns a formatted string, not a count, so the name shouldn't read like an accessor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ai-chat): address cubic + claude review findings on the paste feature cubic-dev-ai findings: - autosize: clear inline overflow-y on the uncapped path so a capped-> uncapped toggle can't leave a stale `auto`/`hidden`. - pasteTokens: new countLines() ignores a single trailing newline, so a 10-line paste with a trailing \n no longer counts as 11 (off-by-one in shouldCollapsePaste and the chip's reported line count). - ContextTextarea: the @-mention picker anchor now subtracts the textarea's own scrollTop/Left and is recomputed on internal scroll, so it stays pinned once the input is capped at 40vh and scrolls. claude re-review findings: - ContextTextarea: drag-and-drop of a selection containing a chip now puts the expanded content on the drag payload via ondragstart (mirroring copy/cut), so a dragged chip no longer orphans its token and loses the pasted content (or leaks the label to an external target). - ContextTextarea: handlePasteBeforeInput now guards on e.cancelable and a HANDLED_INPUT_TYPES whitelist, so historyUndo/Redo (Ctrl+Z) and non-cancelable insertCompositionText (IME) are left to the browser instead of being reinterpreted as a delete / rewritten mid-composition. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,28 @@ import { tick } from 'svelte'
|
||||
|
||||
type TextArea = HTMLTextAreaElement
|
||||
|
||||
export const autosize = (node: TextArea) => {
|
||||
/**
|
||||
* Optional parameters for the `autosize` action.
|
||||
*
|
||||
* `maxHeight` caps how tall the textarea may grow. Once the content exceeds it,
|
||||
* the textarea stops growing and scrolls internally (overflow-y: auto) instead.
|
||||
* Accepts a number (px) or a CSS-ish string ending in `vh`/`px` (e.g. `'40vh'`).
|
||||
* When omitted the textarea grows without bound (the historical behaviour).
|
||||
*/
|
||||
export type AutosizeParams = { maxHeight?: number | string } | undefined
|
||||
|
||||
/** Resolve a `maxHeight` param to a pixel value, or null when uncapped/invalid. */
|
||||
function resolveMaxHeight(maxHeight: number | string | undefined): number | null {
|
||||
if (maxHeight == null) return null
|
||||
if (typeof maxHeight === 'number') return maxHeight
|
||||
const s = maxHeight.trim()
|
||||
const v = parseFloat(s)
|
||||
if (isNaN(v)) return null
|
||||
if (s.endsWith('vh')) return (v / 100) * window.innerHeight
|
||||
return v // 'px' or bare number → pixels
|
||||
}
|
||||
|
||||
export const autosize = (node: TextArea, params?: AutosizeParams) => {
|
||||
/* ------------------------------------------------------------------
|
||||
* Constants
|
||||
* ---------------------------------------------------------------- */
|
||||
@@ -11,13 +32,32 @@ export const autosize = (node: TextArea) => {
|
||||
const EXTRA = 2 // px added to scrollHeight
|
||||
|
||||
let width = 0
|
||||
let maxHeight = params?.maxHeight
|
||||
let capped = maxHeight != null
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Core resize routine
|
||||
* ---------------------------------------------------------------- */
|
||||
const resize = () => {
|
||||
node.style.height = 'auto'
|
||||
node.style.height = `${Math.max(node.scrollHeight, MIN_HEIGHT) + EXTRA}px`
|
||||
let height = Math.max(node.scrollHeight, MIN_HEIGHT) + EXTRA
|
||||
|
||||
const maxPx = resolveMaxHeight(maxHeight)
|
||||
if (maxPx != null) {
|
||||
if (height > maxPx) {
|
||||
height = maxPx
|
||||
node.style.overflowY = 'auto'
|
||||
} else {
|
||||
node.style.overflowY = 'hidden'
|
||||
}
|
||||
} else {
|
||||
// Uncapped — including after a capped→uncapped toggle: drop any inline
|
||||
// overflow we set while capped so the textarea returns to its default
|
||||
// (class-driven) behaviour rather than keeping a stale `auto`/`hidden`.
|
||||
node.style.overflowY = ''
|
||||
}
|
||||
|
||||
node.style.height = `${height}px`
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
@@ -46,6 +86,13 @@ export const autosize = (node: TextArea) => {
|
||||
node.addEventListener('input', onInput)
|
||||
node.addEventListener('update', resize)
|
||||
|
||||
// A `vh`-based cap depends on the viewport height, so recompute on window
|
||||
// resize. Only attached when a cap is configured to avoid adding listeners
|
||||
// for the many uncapped textareas across the app.
|
||||
if (capped) {
|
||||
window.addEventListener('resize', resize)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Inline styling
|
||||
* ---------------------------------------------------------------- */
|
||||
@@ -79,10 +126,24 @@ export const autosize = (node: TextArea) => {
|
||||
* Action lifecycle
|
||||
* ---------------------------------------------------------------- */
|
||||
return {
|
||||
update(newParams?: AutosizeParams) {
|
||||
maxHeight = newParams?.maxHeight
|
||||
const nowCapped = maxHeight != null
|
||||
if (nowCapped && !capped) {
|
||||
window.addEventListener('resize', resize)
|
||||
} else if (!nowCapped && capped) {
|
||||
window.removeEventListener('resize', resize)
|
||||
}
|
||||
capped = nowCapped
|
||||
resize()
|
||||
},
|
||||
destroy() {
|
||||
ro.disconnect()
|
||||
node.removeEventListener('input', onInput)
|
||||
node.removeEventListener('update', resize)
|
||||
if (capped) {
|
||||
window.removeEventListener('resize', resize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
import { ArrowUp, Square } from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { type PasteAttachment } from './pasteTokens'
|
||||
import { chatDraft, expanded } from './chatDraft'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
@@ -23,6 +25,7 @@
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
initialInstructions?: string
|
||||
initialPastes?: PasteAttachment[]
|
||||
editingMessageIndex?: number | null
|
||||
onEditEnd?: () => void
|
||||
className?: string
|
||||
@@ -47,6 +50,7 @@
|
||||
isFirstMessage = false,
|
||||
placeholder,
|
||||
initialInstructions = '',
|
||||
initialPastes = undefined,
|
||||
editingMessageIndex = null,
|
||||
onEditEnd = () => {},
|
||||
className = '',
|
||||
@@ -113,6 +117,8 @@
|
||||
let contextTextareaComponent: ContextTextarea | undefined = $state()
|
||||
let instructionsTextareaComponent: HTMLTextAreaElement | undefined = $state()
|
||||
let instructions = $state(untrack(() => initialInstructions))
|
||||
// Collapsed big-paste blobs referenced by tokens in `instructions`.
|
||||
let pastes = $state<PasteAttachment[]>(untrack(() => initialPastes ?? []))
|
||||
|
||||
// App mode @ mention state
|
||||
let showAppContextTooltip = $state(false)
|
||||
@@ -258,10 +264,10 @@
|
||||
return
|
||||
}
|
||||
if (editingMessageIndex !== null) {
|
||||
aiChatManager.restartGeneration(editingMessageIndex, instructions)
|
||||
aiChatManager.restartGeneration(editingMessageIndex, instructions, pastes)
|
||||
onEditEnd()
|
||||
} else {
|
||||
aiChatManager.sendRequest({ instructions })
|
||||
aiChatManager.sendRequest({ instructions, pastes })
|
||||
// clearForSend() pre-zaps the textarea's mention-sync so the wipe
|
||||
// doesn't drop `selectedContext` before `AIChatManager.beforeSend`
|
||||
// snapshots it. Only mounted in SCRIPT/FLOW/GLOBAL — APP and the
|
||||
@@ -269,6 +275,18 @@
|
||||
// reset (no `@`-mention state to coordinate).
|
||||
contextTextareaComponent?.clearForSend()
|
||||
instructions = ''
|
||||
pastes = []
|
||||
}
|
||||
}
|
||||
|
||||
// A custom `onSendRequest` consumer (e.g. the inline ⌘K widget) has no chip
|
||||
// display, so it gets the fully expanded text; the default path keeps tokens
|
||||
// for the conversation bubble and expands them for the LLM inside the manager.
|
||||
function submitRequest() {
|
||||
if (onSendRequest) {
|
||||
onSendRequest(expanded(chatDraft(instructions, pastes)))
|
||||
} else {
|
||||
sendRequest()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,7 +503,7 @@
|
||||
if (isLoading) {
|
||||
onCancel ? onCancel() : aiChatManager.cancel()
|
||||
} else if (!sendDisabled) {
|
||||
onSendRequest ? onSendRequest(instructions) : sendRequest()
|
||||
submitRequest()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -529,6 +547,7 @@
|
||||
<ContextTextarea
|
||||
bind:this={contextTextareaComponent}
|
||||
bind:value={instructions}
|
||||
bind:pastes
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
placeholder={modePlaceholder}
|
||||
@@ -542,7 +561,7 @@
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
onSendRequest ? onSendRequest(instructions) : sendRequest()
|
||||
submitRequest()
|
||||
}}
|
||||
{disabled}
|
||||
{onKeyDown}
|
||||
@@ -561,7 +580,7 @@
|
||||
<textarea
|
||||
bind:this={instructionsTextareaComponent}
|
||||
bind:value={instructions}
|
||||
use:autosize
|
||||
use:autosize={{ maxHeight: '40vh' }}
|
||||
oninput={handleAppInput}
|
||||
onblur={() => {
|
||||
setTimeout(() => {
|
||||
@@ -625,7 +644,7 @@
|
||||
<textarea
|
||||
bind:this={instructionsTextareaComponent}
|
||||
bind:value={instructions}
|
||||
use:autosize
|
||||
use:autosize={{ maxHeight: '40vh' }}
|
||||
onkeydown={(e) => {
|
||||
if (onKeyDown) {
|
||||
onKeyDown(e)
|
||||
|
||||
@@ -39,6 +39,8 @@ import { sendUserToast } from '$lib/toast'
|
||||
import { getModelContextWindow, workspaceAIClients } from '../lib'
|
||||
import { dfs } from '$lib/components/flows/previousResults'
|
||||
import { getStringError } from './utils'
|
||||
import { type PasteAttachment } from './pasteTokens'
|
||||
import { chatDraft, expanded } from './chatDraft'
|
||||
import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState'
|
||||
import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types'
|
||||
import { untrack } from 'svelte'
|
||||
@@ -519,8 +521,7 @@ export class AIChatManager {
|
||||
this.tools = globalToolsFor({ sessionPreview: this.isSessionChat })
|
||||
this.helpers = {
|
||||
...(this.isSessionChat ? { sessionId: this.sessionId } : {}),
|
||||
testActiveFlow: async (args?: Record<string, any>) =>
|
||||
this.flowAiChatHelpers?.testFlow(args)
|
||||
testActiveFlow: async (args?: Record<string, any>) => this.flowAiChatHelpers?.testFlow(args)
|
||||
} satisfies GlobalToolHelpers
|
||||
} else if (mode === AIMode.APP) {
|
||||
const customPrompt = getCombinedCustomPrompt(mode)
|
||||
@@ -831,6 +832,7 @@ export class AIChatManager {
|
||||
removeDiff?: boolean
|
||||
addBackCode?: boolean
|
||||
instructions?: string
|
||||
pastes?: PasteAttachment[]
|
||||
mode?: AIMode
|
||||
lang?: ScriptLang | 'bunnative'
|
||||
isPreprocessor?: boolean
|
||||
@@ -906,6 +908,7 @@ export class AIChatManager {
|
||||
snapshot = { type: 'app', value: this.appAiChatHelpers!.snapshot() }
|
||||
}
|
||||
|
||||
const pastes = options.pastes ?? []
|
||||
this.displayMessages = [
|
||||
...this.displayMessages,
|
||||
{
|
||||
@@ -915,11 +918,14 @@ export class AIChatManager {
|
||||
this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW || this.mode === AIMode.GLOBAL
|
||||
? oldSelectedContext
|
||||
: undefined,
|
||||
pastes: pastes.length > 0 ? pastes : undefined,
|
||||
snapshot,
|
||||
index: this.messages.length // matching with actual messages index. not -1 because it's not yet added to the messages array
|
||||
}
|
||||
]
|
||||
const oldInstructions = this.instructions
|
||||
// The LLM gets the full pasted content; the display message above keeps
|
||||
// the compact tokens + registry so the bubble can render/expand chips.
|
||||
const oldInstructions = expanded(chatDraft(this.instructions, pastes))
|
||||
this.instructions = ''
|
||||
|
||||
if (this.mode === AIMode.SCRIPT && !this.scriptEditorOptions && !options.lang) {
|
||||
@@ -1100,7 +1106,11 @@ export class AIChatManager {
|
||||
this.inlineAbortController?.abort(cancelReason)
|
||||
}
|
||||
|
||||
restartGeneration = (displayMessageIndex: number, newContent?: string) => {
|
||||
restartGeneration = (
|
||||
displayMessageIndex: number,
|
||||
newContent?: string,
|
||||
pastes?: PasteAttachment[]
|
||||
) => {
|
||||
const userMessage = this.displayMessages[displayMessageIndex]
|
||||
|
||||
if (!userMessage || userMessage.role !== 'user') {
|
||||
@@ -1121,7 +1131,7 @@ export class AIChatManager {
|
||||
|
||||
// Resend the request with the same instructions
|
||||
this.instructions = newContent ?? userMessage.content
|
||||
this.sendRequest()
|
||||
this.sendRequest({ pastes: pastes ?? userMessage.pastes })
|
||||
}
|
||||
|
||||
fix = () => {
|
||||
|
||||
@@ -9,9 +9,21 @@
|
||||
import AIChatInput from './AIChatInput.svelte'
|
||||
import type { ContextElement } from './context'
|
||||
import ToolExecutionDisplay from './ToolExecutionDisplay.svelte'
|
||||
import { messageDraft, segments } from './chatDraft'
|
||||
import { lineCountLabel } from './pasteTokens'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
// Per-message expand/collapse state for paste chips shown in the bubble.
|
||||
let expandedPastes = $state<Set<number>>(new Set())
|
||||
|
||||
function togglePaste(e: MouseEvent, id: number) {
|
||||
e.stopPropagation() // don't trigger edit-message on the bubble
|
||||
const next = new Set(expandedPastes)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
expandedPastes = next
|
||||
}
|
||||
|
||||
interface Props {
|
||||
availableContext: ContextElement[]
|
||||
selectedContext: ContextElement[]
|
||||
@@ -63,6 +75,7 @@
|
||||
{availableContext}
|
||||
bind:selectedContext
|
||||
initialInstructions={message.content}
|
||||
initialPastes={message.pastes}
|
||||
{editingMessageIndex}
|
||||
onClickOutside={() => (editingMessageIndex = null)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -83,7 +96,19 @@
|
||||
<div
|
||||
class="text-xs px-3 py-2 w-fit max-w-[min(32rem,100%)] bg-surface-accent-selected text-accent rounded-lg relative group break-words"
|
||||
>
|
||||
<span class="whitespace-pre-wrap">{message.content}</span>
|
||||
{#each segments(messageDraft(message)) as seg}{#if seg.type === 'text'}<span
|
||||
class="whitespace-pre-wrap">{seg.value}</span
|
||||
>{:else if expandedPastes.has(seg.att.id)}<button
|
||||
type="button"
|
||||
class="my-0.5 px-1.5 py-0.5 rounded bg-surface-secondary text-secondary text-2xs"
|
||||
onclick={(e) => togglePaste(e, seg.att.id)}
|
||||
>{lineCountLabel(seg.att.lines)} · click to collapse</button
|
||||
><span class="block whitespace-pre-wrap mt-1">{seg.att.content}</span>{:else}<button
|
||||
type="button"
|
||||
class="px-1.5 py-0.5 rounded bg-surface-secondary text-secondary text-2xs"
|
||||
onclick={(e) => togglePaste(e, seg.att.id)}
|
||||
>Pasted {lineCountLabel(seg.att.lines)} · click to expand</button
|
||||
>{/if}{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import autosize from '$lib/autosize'
|
||||
import { tick } from 'svelte'
|
||||
import type { ContextElement } from './context'
|
||||
import ChatContextPicker from './ChatContextPicker.svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
@@ -8,9 +9,19 @@
|
||||
import { CHAT_INPUT_PADDING } from './aiChatManagerContext'
|
||||
import { createFloatingActions, createVirtualElement } from 'svelte-floating-ui'
|
||||
import { flip, offset, shift } from 'svelte-floating-ui/dom'
|
||||
import {
|
||||
type PasteAttachment,
|
||||
countLines,
|
||||
expandPasteTokens,
|
||||
makePasteToken,
|
||||
nextPasteId,
|
||||
pasteTokenRegex,
|
||||
shouldCollapsePaste
|
||||
} from './pasteTokens'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
pastes?: PasteAttachment[]
|
||||
availableContext: ContextElement[]
|
||||
selectedContext: ContextElement[]
|
||||
placeholder: string
|
||||
@@ -27,6 +38,7 @@
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
pastes = $bindable(),
|
||||
availableContext,
|
||||
selectedContext,
|
||||
placeholder,
|
||||
@@ -82,6 +94,10 @@
|
||||
// `referenceAction` / `setupVirtualElementObserver` in dist/index.js.
|
||||
floatingRef(anchorRef)
|
||||
|
||||
// Mirrors the textarea's vertical scroll so the highlight overlay stays
|
||||
// aligned once the input is capped (max-height) and scrolls internally.
|
||||
let scrollTop = $state(0)
|
||||
|
||||
// Properties to copy for caret position calculation
|
||||
const properties = [
|
||||
'direction',
|
||||
@@ -196,8 +212,26 @@
|
||||
return coordinates
|
||||
}
|
||||
|
||||
function escapeHtml(text: string) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function getHighlightedText(text: string) {
|
||||
return text.replace(/@[\w/.\-\[\]]+/g, (match) => {
|
||||
let html = escapeHtml(text)
|
||||
// Wrap collapsed-paste tokens as clickable chips. The span keeps the exact
|
||||
// token text (label + zero-width id) so its width matches the underlying
|
||||
// transparent textarea text and the caret stays aligned.
|
||||
html = html.replace(pasteTokenRegex(), (match, zw: string) => {
|
||||
const att = pastes?.find((p) => p.id === zw.length)
|
||||
if (!att) return match
|
||||
return `<span data-paste-id="${att.id}" class="rounded bg-surface-secondary text-secondary cursor-pointer pointer-events-auto">${match}</span>`
|
||||
})
|
||||
html = html.replace(/@[\w/.\-\[\]]+/g, (match) => {
|
||||
const title = match.slice(1)
|
||||
const inContext =
|
||||
availableContext.find((c) => c.title === title) ||
|
||||
@@ -207,6 +241,255 @@
|
||||
}
|
||||
return match
|
||||
})
|
||||
return html
|
||||
}
|
||||
|
||||
// Find the paste tokens that overlap a [start, end) selection, returning the
|
||||
// range widened to cover each overlapped token whole (so a chip is never cut
|
||||
// mid-token) plus the ids to drop from the registry. Strict overlap — merely
|
||||
// abutting a token edge doesn't pull it in.
|
||||
function tokensOverlapping(start: number, end: number) {
|
||||
let from = start
|
||||
let to = end
|
||||
const ids: number[] = []
|
||||
for (const m of value.matchAll(pasteTokenRegex())) {
|
||||
if (m.index === undefined) continue
|
||||
const tokenStart = m.index
|
||||
const tokenEnd = m.index + m[0].length
|
||||
if (tokenStart < end && tokenEnd > start) {
|
||||
from = Math.min(from, tokenStart)
|
||||
to = Math.max(to, tokenEnd)
|
||||
ids.push(m[1].length)
|
||||
}
|
||||
}
|
||||
return { from, to, ids }
|
||||
}
|
||||
|
||||
// On a large paste, register the blob and insert a compact token instead of
|
||||
// the raw lines (see pasteTokens.ts). Smaller pastes fall through to default
|
||||
// (and beforeinput keeps any overlapped chip atomic). The insertion range is
|
||||
// widened over overlapped tokens so pasting onto a chip replaces it whole.
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
const text = e.clipboardData?.getData('text/plain') ?? ''
|
||||
if (!text || !shouldCollapsePaste(text)) return
|
||||
e.preventDefault()
|
||||
const ta = e.currentTarget as HTMLTextAreaElement
|
||||
const { from, to, ids } = tokensOverlapping(
|
||||
ta.selectionStart ?? value.length,
|
||||
ta.selectionEnd ?? value.length
|
||||
)
|
||||
const att: PasteAttachment = {
|
||||
id: nextPasteId(pastes ?? []),
|
||||
lines: countLines(text),
|
||||
content: text
|
||||
}
|
||||
const token = makePasteToken(att)
|
||||
value = value.slice(0, from) + token + value.slice(to)
|
||||
pastes = [...(pastes ?? []).filter((p) => !ids.includes(p.id)), att]
|
||||
const caret = from + token.length
|
||||
tick().then(() => ta.setSelectionRange(caret, caret))
|
||||
}
|
||||
|
||||
// Click on a chip in the input expands it back to its raw lines (one-way).
|
||||
function expandPasteInInput(id: number) {
|
||||
const att = pastes?.find((p) => p.id === id)
|
||||
if (!att) return
|
||||
const token = makePasteToken(att)
|
||||
const idx = value.indexOf(token)
|
||||
if (idx === -1) return
|
||||
value = value.slice(0, idx) + att.content + value.slice(idx + token.length)
|
||||
pastes = (pastes ?? []).filter((p) => p.id !== id)
|
||||
const caret = idx + att.content.length
|
||||
tick().then(() => {
|
||||
textarea?.focus()
|
||||
textarea?.setSelectionRange(caret, caret)
|
||||
})
|
||||
}
|
||||
|
||||
// Delegated as an action (not an inline onclick) so the pointer-events-none
|
||||
// overlay div doesn't trip a11y static-interaction lints; only the chip spans
|
||||
// inside set pointer-events: auto, and their clicks bubble here.
|
||||
function chipClickDelegate(node: HTMLElement) {
|
||||
const handler = (e: Event) => {
|
||||
const chip = (e.target as HTMLElement).closest('[data-paste-id]')
|
||||
if (!chip) return
|
||||
expandPasteInInput(Number(chip.getAttribute('data-paste-id')))
|
||||
}
|
||||
node.addEventListener('click', handler)
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('click', handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the [from, to) range with `insert`, drop the given paste ids, and
|
||||
// place the caret after the inserted text — keeping value, the pastes
|
||||
// registry, and the caret consistent.
|
||||
function replacePasteRange(from: number, to: number, ids: number[], insert = '') {
|
||||
value = value.slice(0, from) + insert + value.slice(to)
|
||||
pastes = (pastes ?? []).filter((p) => !ids.includes(p.id))
|
||||
const caret = from + insert.length
|
||||
tick().then(() => textarea?.setSelectionRange(caret, caret))
|
||||
}
|
||||
|
||||
// Keep chip deletion atomic for Backspace/Delete with a collapsed caret at a
|
||||
// token boundary (the one case beforeinput can't see, since nothing is
|
||||
// selected). Selection-spanning edits — including Backspace/Delete over a
|
||||
// selection — are handled uniformly in handlePasteBeforeInput.
|
||||
function handlePasteDeletion(e: KeyboardEvent): boolean {
|
||||
if ((e.key !== 'Backspace' && e.key !== 'Delete') || !textarea) return false
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
if (start === null || start !== end) return false
|
||||
for (const m of value.matchAll(pasteTokenRegex())) {
|
||||
if (m.index === undefined) continue
|
||||
const tokenStart = m.index
|
||||
const tokenEnd = m.index + m[0].length
|
||||
const atEnd = e.key === 'Backspace' && start === tokenEnd
|
||||
const atStart = e.key === 'Delete' && start === tokenStart
|
||||
if (atEnd || atStart) {
|
||||
e.preventDefault()
|
||||
replacePasteRange(tokenStart, tokenEnd, [m[1].length])
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The content-mutating inputTypes we know how to reproduce. We intercept ONLY
|
||||
// these — never history (historyUndo/Redo, which we'd turn into a deletion)
|
||||
// nor composition (insertCompositionText is non-cancelable, so preventDefault
|
||||
// is a no-op while we'd still rewrite value mid-IME).
|
||||
const HANDLED_INPUT_TYPES = new Set([
|
||||
'insertText',
|
||||
'insertReplacementText',
|
||||
'insertFromYank',
|
||||
'insertFromPaste',
|
||||
'insertFromDrop',
|
||||
'insertLineBreak',
|
||||
'insertParagraph',
|
||||
'deleteContentBackward',
|
||||
'deleteContentForward',
|
||||
'deleteContent',
|
||||
'deleteByCut',
|
||||
'deleteByDrag',
|
||||
'deleteWordBackward',
|
||||
'deleteWordForward',
|
||||
'deleteSoftLineBackward',
|
||||
'deleteSoftLineForward',
|
||||
'deleteHardLineBackward',
|
||||
'deleteHardLineForward'
|
||||
])
|
||||
|
||||
// Any selection-spanning input (typing over a selection, paste, cut,
|
||||
// drag-and-drop, Backspace/Delete over a selection) that overlaps a chip is
|
||||
// taken over and applied to the whole token(s), so a partial edit can never
|
||||
// leave an orphaned zero-width run or a dangling pastes entry. Backspace/
|
||||
// Delete at a collapsed boundary is handled earlier in keydown.
|
||||
function handlePasteBeforeInput(e: InputEvent) {
|
||||
// Skip non-cancelable events (e.g. IME composition) — we can't suppress
|
||||
// them, and unknown inputTypes (history) we mustn't reinterpret as a delete.
|
||||
if (!textarea || !e.cancelable || !HANDLED_INPUT_TYPES.has(e.inputType)) return
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
if (start === null || end === null || start === end) return
|
||||
const { from, to, ids } = tokensOverlapping(start, end)
|
||||
if (ids.length === 0) return
|
||||
e.preventDefault()
|
||||
replacePasteRange(from, to, ids, insertedText(e))
|
||||
}
|
||||
|
||||
// The text a (whitelisted) beforeinput event will insert, by inputType.
|
||||
// Deletions insert nothing; line breaks insert a newline; paste/drop/yank/
|
||||
// replacement carry their payload on dataTransfer, plain typing on `data`.
|
||||
function insertedText(e: InputEvent): string {
|
||||
const t = e.inputType
|
||||
if (t.startsWith('delete')) return ''
|
||||
if (t === 'insertLineBreak' || t === 'insertParagraph') return '\n'
|
||||
if (
|
||||
t === 'insertFromPaste' ||
|
||||
t === 'insertFromDrop' ||
|
||||
t === 'insertReplacementText' ||
|
||||
t === 'insertFromYank'
|
||||
) {
|
||||
return e.dataTransfer?.getData('text/plain') ?? e.data ?? ''
|
||||
}
|
||||
return e.data ?? ''
|
||||
}
|
||||
|
||||
// Dragging a selection that contains a chip carries the *expanded* content on
|
||||
// the drag payload (mirroring copy/cut), so dropping it — inside the input or
|
||||
// onto an external target — yields the real text, never the chip label + its
|
||||
// zero-width id run (which, once its registry entry is gone, can't resolve).
|
||||
function handlePasteDragStart(e: DragEvent) {
|
||||
if (!textarea || !e.dataTransfer) return
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
if (start === null || end === null || start === end) return
|
||||
const { from, to, ids } = tokensOverlapping(start, end)
|
||||
if (ids.length === 0) return
|
||||
e.dataTransfer.setData('text/plain', expandPasteTokens(value.slice(from, to), pastes ?? []))
|
||||
}
|
||||
|
||||
// Copy/cut of a selection containing a chip puts the *expanded* content on the
|
||||
// clipboard instead of the chip label + its invisible zero-width run; cut also
|
||||
// removes the chip whole. Selections without a chip use the browser default.
|
||||
function handlePasteCopyCut(e: ClipboardEvent) {
|
||||
if (!textarea || !e.clipboardData) return
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
if (start === null || end === null || start === end) return
|
||||
const { from, to, ids } = tokensOverlapping(start, end)
|
||||
if (ids.length === 0) return
|
||||
e.preventDefault()
|
||||
e.clipboardData.setData('text/plain', expandPasteTokens(value.slice(from, to), pastes ?? []))
|
||||
if (e.type === 'cut') {
|
||||
replacePasteRange(from, to, ids)
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow-Left/Right step over a paste chip as one unit, so the caret jumps
|
||||
// edge-to-edge instead of crawling through the (invisible) token characters.
|
||||
// Also snaps the caret out if it somehow lands inside a token. Shift extends
|
||||
// the selection across the whole chip; word/line jumps (alt/cmd/ctrl) are
|
||||
// left to the browser.
|
||||
function handlePasteCaretSkip(e: KeyboardEvent): boolean {
|
||||
if ((e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') || !textarea) return false
|
||||
if (e.altKey || e.metaKey || e.ctrlKey) return false
|
||||
const right = e.key === 'ArrowRight'
|
||||
const collapsed = textarea.selectionStart === textarea.selectionEnd
|
||||
// The end the caret is moving; for a non-collapsed selection that's the
|
||||
// side opposite the anchor (given by selectionDirection).
|
||||
const caret =
|
||||
!collapsed && textarea.selectionDirection === 'backward'
|
||||
? textarea.selectionStart
|
||||
: textarea.selectionEnd
|
||||
const anchor = collapsed
|
||||
? caret
|
||||
: textarea.selectionDirection === 'backward'
|
||||
? textarea.selectionEnd
|
||||
: textarea.selectionStart
|
||||
for (const m of value.matchAll(pasteTokenRegex())) {
|
||||
if (m.index === undefined) continue
|
||||
const s = m.index
|
||||
const en = m.index + m[0].length
|
||||
const target =
|
||||
right && caret >= s && caret < en ? en : !right && caret > s && caret <= en ? s : null
|
||||
if (target === null) continue
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
textarea.setSelectionRange(
|
||||
Math.min(anchor, target),
|
||||
Math.max(anchor, target),
|
||||
target < anchor ? 'backward' : 'forward'
|
||||
)
|
||||
} else {
|
||||
textarea.setSelectionRange(target, target)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function addContextToSelection(contextElement: ContextElement) {
|
||||
@@ -237,7 +520,15 @@
|
||||
const atIndex = value.length - contextTooltipWord.length
|
||||
const coords = getCaretCoordinates(textarea, atIndex)
|
||||
const rect = textarea.getBoundingClientRect()
|
||||
anchorRect = new DOMRect(rect.left + coords.left, rect.top + coords.top, 1, coords.height)
|
||||
// getCaretCoordinates returns content-relative coords; subtract the
|
||||
// textarea's own scroll so the anchor tracks the `@` once the input is
|
||||
// capped (max-height) and scrolls internally.
|
||||
anchorRect = new DOMRect(
|
||||
rect.left + coords.left - textarea.scrollLeft,
|
||||
rect.top + coords.top - textarea.scrollTop,
|
||||
1,
|
||||
coords.height
|
||||
)
|
||||
// Re-prime the virtual ref then kick floating-ui (autoUpdate only fires
|
||||
// on scroll/resize, not on text changes inside the textarea).
|
||||
anchorRef.update({ getBoundingClientRect: anchorRect })
|
||||
@@ -273,6 +564,11 @@
|
||||
onKeyDown(e)
|
||||
}
|
||||
|
||||
// Atomic chip deletion takes precedence over the default char delete.
|
||||
if (handlePasteDeletion(e)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (showContextTooltip) {
|
||||
// Forward navigation keys to the picker so the textarea-focused
|
||||
// user can drive it. The picker preventDefault/stopPropagation's
|
||||
@@ -298,6 +594,11 @@
|
||||
return
|
||||
}
|
||||
|
||||
// Step the caret over a paste chip as one unit (picker closed only).
|
||||
if (handlePasteCaretSkip(e)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSendRequest()
|
||||
@@ -358,22 +659,35 @@
|
||||
<div class="relative w-full scroll-pb-2 bg-surface">
|
||||
<div
|
||||
class={twMerge(
|
||||
'textarea-input absolute top-0 left-0 pointer-events-none',
|
||||
'textarea-input absolute inset-0 overflow-hidden pointer-events-none',
|
||||
CHAT_INPUT_PADDING,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span class="break-words">
|
||||
{@html getHighlightedText(value)}
|
||||
</span>
|
||||
<div style="transform: translateY({-scrollTop}px)" use:chipClickDelegate>
|
||||
<span class="break-words">
|
||||
{@html getHighlightedText(value)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
bind:this={textarea}
|
||||
onkeydown={handleKeyDown}
|
||||
bind:value
|
||||
use:autosize
|
||||
use:autosize={{ maxHeight: '40vh' }}
|
||||
rows={1}
|
||||
oninput={handleInput}
|
||||
onpaste={handlePaste}
|
||||
onbeforeinput={handlePasteBeforeInput}
|
||||
oncopy={handlePasteCopyCut}
|
||||
oncut={handlePasteCopyCut}
|
||||
ondragstart={handlePasteDragStart}
|
||||
onscroll={(e) => {
|
||||
scrollTop = e.currentTarget.scrollTop
|
||||
// Keep the `@` picker pinned to its anchor while the input scrolls
|
||||
// internally (autoUpdate can't observe a virtual ref's scroll).
|
||||
if (showContextTooltip) updateAnchorRect()
|
||||
}}
|
||||
onblur={() => {
|
||||
setTimeout(() => {
|
||||
// Don't close if focus moved to inside the tooltip (e.g., search input)
|
||||
@@ -389,7 +703,7 @@
|
||||
CHAT_INPUT_PADDING,
|
||||
className
|
||||
)}
|
||||
style={value.length > 0 ? 'color: transparent; -webkit-text-fill-color: transparent;' : ''}
|
||||
class:transparent-text={value.length > 0}
|
||||
{disabled}
|
||||
></textarea>
|
||||
</div>
|
||||
@@ -437,4 +751,12 @@
|
||||
width: 100%;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
/* Hide the textarea's own glyphs (the highlight overlay renders the text)
|
||||
while keeping the caret visible. Toggled via a class rather than an inline
|
||||
`style` so it never clobbers the inline height set by the autosize action. */
|
||||
.transparent-text {
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { openDB, type DBSchema as IDBSchema, type IDBPDatabase } from 'idb'
|
||||
import type { DisplayMessage } from './shared'
|
||||
import { expanded, messageDraft } from './chatDraft'
|
||||
import { createLongHash } from '$lib/editorLangUtils'
|
||||
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
|
||||
interface ChatSchema extends IDBSchema {
|
||||
@@ -106,6 +107,9 @@ export default class HistoryManager {
|
||||
|
||||
async saveChat(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) {
|
||||
if (displayMessages.length > 0) {
|
||||
// Expand any collapsed-paste tokens so the title is readable text, not
|
||||
// the chip label + its zero-width id chars.
|
||||
const title = expanded(messageDraft(displayMessages[0])).slice(0, 50)
|
||||
// we don't want to save the snapshot in the history
|
||||
const updatedChat = {
|
||||
actualMessages: $state.snapshot(messages),
|
||||
@@ -113,7 +117,7 @@ export default class HistoryManager {
|
||||
...m,
|
||||
snapshot: undefined
|
||||
})),
|
||||
title: displayMessages[0].content.slice(0, 50),
|
||||
title,
|
||||
id: this.currentChatId,
|
||||
lastModified: Date.now(),
|
||||
...(this.sessionId ? { sessionId: this.sessionId } : {})
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { chatDraft, expanded, messageDraft, segments } from './chatDraft'
|
||||
import { type PasteAttachment, makePasteToken } from './pasteTokens'
|
||||
|
||||
const att = (id: number, lines: number, content: string): PasteAttachment => ({
|
||||
id,
|
||||
lines,
|
||||
content
|
||||
})
|
||||
|
||||
describe('expanded', () => {
|
||||
it('expands paste tokens to their full content', () => {
|
||||
const a = att(1, 12, 'line1\nline2')
|
||||
const d = chatDraft(`before ${makePasteToken(a)} after`, [a])
|
||||
expect(expanded(d)).toBe('before line1\nline2 after')
|
||||
})
|
||||
|
||||
it('is a no-op with no pastes', () => {
|
||||
expect(expanded(chatDraft('plain text'))).toBe('plain text')
|
||||
expect(expanded(chatDraft('plain text', []))).toBe('plain text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('segments', () => {
|
||||
it('splits into text and paste segments in order', () => {
|
||||
const a = att(1, 12, 'AAA')
|
||||
expect(segments(chatDraft(`hi ${makePasteToken(a)} bye`, [a]))).toEqual([
|
||||
{ type: 'text', value: 'hi ' },
|
||||
{ type: 'paste', att: a },
|
||||
{ type: 'text', value: ' bye' }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a single text segment with no pastes', () => {
|
||||
expect(segments(chatDraft('plain'))).toEqual([{ type: 'text', value: 'plain' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageDraft', () => {
|
||||
it('builds from a {content, pastes} message', () => {
|
||||
const a = att(1, 12, 'AAA')
|
||||
const token = makePasteToken(a)
|
||||
const d = messageDraft({ content: `x ${token}`, pastes: [a] })
|
||||
expect(expanded(d)).toBe('x AAA')
|
||||
})
|
||||
|
||||
it('tolerates a message with no pastes (e.g. non-user roles)', () => {
|
||||
const d = messageDraft({ content: 'just text' })
|
||||
expect(d.pastes).toEqual([])
|
||||
expect(expanded(d)).toBe('just text')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
type PasteAttachment,
|
||||
type PasteSegment,
|
||||
expandPasteTokens,
|
||||
splitPasteTokens
|
||||
} from './pasteTokens'
|
||||
|
||||
/**
|
||||
* A composed-but-not-yet-sent chat message: the editable text plus the
|
||||
* collapsed-paste registry its tokens point into (see pasteTokens.ts).
|
||||
*
|
||||
* The two travel together because paste tokens live *inside* the text — read it
|
||||
* through {@link expanded} (for the model / title) or {@link segments} (for chip
|
||||
* rendering) so no caller can leak raw tokens by forgetting to expand.
|
||||
*
|
||||
* Deliberately excludes `selectedContext` (the `@`-mention registry): that is
|
||||
* owned by ContextManager and travels *beside* the text, not inside it.
|
||||
*/
|
||||
export type ChatDraft = { text: string; pastes: PasteAttachment[] }
|
||||
|
||||
export function chatDraft(text: string, pastes?: PasteAttachment[]): ChatDraft {
|
||||
return { text, pastes: pastes ?? [] }
|
||||
}
|
||||
|
||||
/** Build a draft from a stored message. Structural param (not a DisplayMessage
|
||||
* import) to keep this module free of a cycle with shared.ts. */
|
||||
export function messageDraft(m: { content: string; pastes?: PasteAttachment[] }): ChatDraft {
|
||||
return chatDraft(m.content, m.pastes)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONLY way to read the model-bound / flattened text: paste tokens expanded
|
||||
* to their full content. Used by the LLM prepare* path, the inline ⌘K path, and
|
||||
* the saved-chat title.
|
||||
*/
|
||||
export function expanded(d: ChatDraft): string {
|
||||
return expandPasteTokens(d.text, d.pastes)
|
||||
}
|
||||
|
||||
/** Text / paste-chip segments, for rendering the draft (input overlay, bubble). */
|
||||
export function segments(d: ChatDraft): PasteSegment[] {
|
||||
return splitPasteTokens(d.text, d.pastes)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
type PasteAttachment,
|
||||
countLines,
|
||||
expandPasteTokens,
|
||||
lineCountLabel,
|
||||
makePasteToken,
|
||||
nextPasteId,
|
||||
shouldCollapsePaste,
|
||||
splitPasteTokens
|
||||
} from './pasteTokens'
|
||||
|
||||
const att = (id: number, lines: number, content: string): PasteAttachment => ({
|
||||
id,
|
||||
lines,
|
||||
content
|
||||
})
|
||||
|
||||
describe('lineCountLabel', () => {
|
||||
it('pluralizes by count', () => {
|
||||
expect(lineCountLabel(1)).toBe('1 line')
|
||||
expect(lineCountLabel(0)).toBe('0 lines')
|
||||
expect(lineCountLabel(13)).toBe('13 lines')
|
||||
})
|
||||
})
|
||||
|
||||
describe('countLines', () => {
|
||||
it('counts display lines', () => {
|
||||
expect(countLines('a')).toBe(1)
|
||||
expect(countLines('a\nb\nc')).toBe(3)
|
||||
})
|
||||
it('ignores a single trailing newline', () => {
|
||||
expect(countLines('a\nb\n')).toBe(2)
|
||||
expect(countLines('a\n')).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldCollapsePaste', () => {
|
||||
it('collapses past the line threshold', () => {
|
||||
expect(shouldCollapsePaste('a\n'.repeat(11))).toBe(true)
|
||||
expect(shouldCollapsePaste('a\n'.repeat(9))).toBe(false)
|
||||
})
|
||||
it('does not collapse a 10-line paste with a trailing newline', () => {
|
||||
// 10 lines + trailing newline must not read as 11 (the off-by-one).
|
||||
expect(shouldCollapsePaste(Array(10).fill('x').join('\n') + '\n')).toBe(false)
|
||||
expect(shouldCollapsePaste(Array(11).fill('x').join('\n') + '\n')).toBe(true)
|
||||
})
|
||||
it('collapses very long single-line blobs', () => {
|
||||
expect(shouldCollapsePaste('x'.repeat(1001))).toBe(true)
|
||||
expect(shouldCollapsePaste('x'.repeat(500))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('token round-trip', () => {
|
||||
it('expands a token back to its full content', () => {
|
||||
const a = att(1, 12, 'line1\nline2')
|
||||
const text = `before ${makePasteToken(a)} after`
|
||||
expect(expandPasteTokens(text, [a])).toBe('before line1\nline2 after')
|
||||
})
|
||||
|
||||
it('maps duplicate-label tokens to the right blob via the zero-width id', () => {
|
||||
const a = att(1, 12, 'AAA')
|
||||
const b = att(2, 12, 'BBB') // same line count → identical visible label
|
||||
const text = `${makePasteToken(a)} and ${makePasteToken(b)}`
|
||||
expect(expandPasteTokens(text, [a, b])).toBe('AAA and BBB')
|
||||
})
|
||||
|
||||
it('leaves unknown tokens untouched', () => {
|
||||
const a = att(1, 12, 'AAA')
|
||||
const token = makePasteToken(a)
|
||||
expect(expandPasteTokens(token, [])).toBe(token)
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitPasteTokens', () => {
|
||||
it('splits into text and paste segments in order', () => {
|
||||
const a = att(1, 12, 'AAA')
|
||||
const segs = splitPasteTokens(`hi ${makePasteToken(a)} bye`, [a])
|
||||
expect(segs).toEqual([
|
||||
{ type: 'text', value: 'hi ' },
|
||||
{ type: 'paste', att: a },
|
||||
{ type: 'text', value: ' bye' }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a single text segment when there are no pastes', () => {
|
||||
expect(splitPasteTokens('plain', [])).toEqual([{ type: 'text', value: 'plain' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextPasteId', () => {
|
||||
it('is unique and monotonic', () => {
|
||||
expect(nextPasteId([])).toBe(1)
|
||||
expect(nextPasteId([att(1, 1, 'a'), att(3, 1, 'b')])).toBe(4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Big-paste collapsing for the AI chat input.
|
||||
*
|
||||
* When a user pastes a large block into a `ContextTextarea`, instead of dumping
|
||||
* every line into the input we register the blob and insert a compact *token*
|
||||
* into the text. The token's visible characters are exactly the chip label, so
|
||||
* the transparent textarea text and the highlight overlay stay the same width
|
||||
* (caret alignment is preserved). A run of zero-width characters appended to the
|
||||
* label encodes the attachment id (its length == the id) so duplicate labels map
|
||||
* back unambiguously while staying invisible and zero-width in both layers.
|
||||
*
|
||||
* The token lives in the message text; on send the tokens are expanded back to
|
||||
* the full content for the LLM, while the displayed message keeps the tokens +
|
||||
* a `PasteAttachment[]` registry so the chip can render and toggle everywhere.
|
||||
*/
|
||||
|
||||
export type PasteAttachment = {
|
||||
id: number
|
||||
lines: number
|
||||
content: string
|
||||
}
|
||||
|
||||
/** Zero-width space; `id` copies are appended after the label to encode the id. */
|
||||
const ZW = String.fromCharCode(0x200b)
|
||||
|
||||
/** Collapse a paste when it has more than this many lines… */
|
||||
export const PASTE_LINE_THRESHOLD = 10
|
||||
/** …or more than this many characters (catches giant single-line blobs). */
|
||||
export const PASTE_CHAR_THRESHOLD = 1000
|
||||
|
||||
/** Number of display lines, ignoring a single trailing newline — line-based
|
||||
* copies usually include one, which would otherwise inflate the count by one
|
||||
* (e.g. a 10-line selection counting as 11). */
|
||||
export function countLines(text: string): number {
|
||||
return (text.endsWith('\n') ? text.slice(0, -1) : text).split('\n').length
|
||||
}
|
||||
|
||||
export function shouldCollapsePaste(text: string): boolean {
|
||||
return countLines(text) > PASTE_LINE_THRESHOLD || text.length > PASTE_CHAR_THRESHOLD
|
||||
}
|
||||
|
||||
/** "1 line" / "N lines" — the single source of line-count pluralization. */
|
||||
export function lineCountLabel(lines: number): string {
|
||||
return `${lines} ${lines === 1 ? 'line' : 'lines'}`
|
||||
}
|
||||
|
||||
export function pasteLabel(lines: number): string {
|
||||
return `Pasted ${lineCountLabel(lines)} · click to expand`
|
||||
}
|
||||
|
||||
/** The text inserted into the input for a collapsed paste. */
|
||||
export function makePasteToken(att: PasteAttachment): string {
|
||||
return pasteLabel(att.lines) + ZW.repeat(att.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh regex each call — the global flag carries `lastIndex` state, so a shared
|
||||
* instance would desync across `replace`/`matchAll`/`exec` callers. Group 1 is
|
||||
* the zero-width run whose length is the attachment id.
|
||||
*/
|
||||
export function pasteTokenRegex(): RegExp {
|
||||
return new RegExp(`Pasted \\d+ lines? · click to expand(${ZW}+)`, 'gu')
|
||||
}
|
||||
|
||||
export function nextPasteId(pastes: PasteAttachment[]): number {
|
||||
return pastes.reduce((max, p) => Math.max(max, p.id), 0) + 1
|
||||
}
|
||||
|
||||
/** Replace every recognized token with its full content (for the LLM). */
|
||||
export function expandPasteTokens(text: string, pastes: PasteAttachment[] | undefined): string {
|
||||
if (!pastes?.length) return text
|
||||
return text.replace(pasteTokenRegex(), (match, zw: string) => {
|
||||
const att = pastes.find((p) => p.id === zw.length)
|
||||
return att ? att.content : match
|
||||
})
|
||||
}
|
||||
|
||||
export type PasteSegment = { type: 'text'; value: string } | { type: 'paste'; att: PasteAttachment }
|
||||
|
||||
/** Split text into plain-text and paste-chip segments (for rendering). */
|
||||
export function splitPasteTokens(
|
||||
text: string,
|
||||
pastes: PasteAttachment[] | undefined
|
||||
): PasteSegment[] {
|
||||
if (!pastes?.length) return text ? [{ type: 'text', value: text }] : []
|
||||
const segments: PasteSegment[] = []
|
||||
let last = 0
|
||||
for (const m of text.matchAll(pasteTokenRegex())) {
|
||||
const att = pastes.find((p) => p.id === m[1].length)
|
||||
if (!att || m.index === undefined) continue
|
||||
if (m.index > last) segments.push({ type: 'text', value: text.slice(last, m.index) })
|
||||
segments.push({ type: 'paste', att })
|
||||
last = m.index + m[0].length
|
||||
}
|
||||
if (last < text.length) segments.push({ type: 'text', value: text.slice(last) })
|
||||
return segments
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const SPECIAL_MODULE_IDS = {
|
||||
FAILURE: 'failure'
|
||||
} as const
|
||||
import { get } from 'svelte/store'
|
||||
import type { PasteAttachment } from './pasteTokens'
|
||||
import type { CodePieceElement, ContextElement, FlowModuleCodePieceElement } from './context'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
|
||||
@@ -456,6 +457,9 @@ export type UserDisplayMessage = BaseDisplayMessage & {
|
||||
role: 'user'
|
||||
index: number // Used to match index with actual chat messages
|
||||
error?: boolean
|
||||
// Collapsed big-paste blobs referenced by tokens in `content`. Lets the
|
||||
// bubble render/expand chips; the LLM message stores the expanded text.
|
||||
pastes?: PasteAttachment[]
|
||||
}
|
||||
|
||||
export type CreatedResourceTriggerKind =
|
||||
|
||||
Reference in New Issue
Block a user