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:
Guilhem
2026-06-10 16:58:01 +02:00
committed by GitHub
parent fddabe9c5c
commit 365e20410e
11 changed files with 756 additions and 23 deletions
+63 -2
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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 =