diff --git a/frontend/src/lib/autosize.ts b/frontend/src/lib/autosize.ts index b47a2e57b7..f9c1e21152 100644 --- a/frontend/src/lib/autosize.ts +++ b/frontend/src/lib/autosize.ts @@ -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) + } } } } diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 2780930b79..f6cd996310 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -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(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 @@ { setTimeout(() => { @@ -625,7 +644,7 @@ @@ -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; + } diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts index d07f6943bd..a2400b88df 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts @@ -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 } : {}) diff --git a/frontend/src/lib/components/copilot/chat/chatDraft.test.ts b/frontend/src/lib/components/copilot/chat/chatDraft.test.ts new file mode 100644 index 0000000000..8137cfa97c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/chatDraft.test.ts @@ -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') + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/chatDraft.ts b/frontend/src/lib/components/copilot/chat/chatDraft.ts new file mode 100644 index 0000000000..78bf38cdc2 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/chatDraft.ts @@ -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) +} diff --git a/frontend/src/lib/components/copilot/chat/pasteTokens.test.ts b/frontend/src/lib/components/copilot/chat/pasteTokens.test.ts new file mode 100644 index 0000000000..297873ac2e --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/pasteTokens.test.ts @@ -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) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/pasteTokens.ts b/frontend/src/lib/components/copilot/chat/pasteTokens.ts new file mode 100644 index 0000000000..15dd3d5a65 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/pasteTokens.ts @@ -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 +} diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 7764b85f2f..dcd4f9f317 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -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 =