From ccca952b92be9cb9b887518e795d4fcef1252174 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 01:39:20 -0500 Subject: [PATCH] feat(desktop): chip pasted directives in the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `appendComposerContents` — the one builder every paste goes through — only ever chipped `@kind:value` refs. Slash commands had a single leading-token special case in `renderComposerContents`, which paste doesn't call, so a pasted `/clean` landed as dead text while the same text typed by hand became a pill. Both directive kinds now hydrate from one ordered span walk, with `@` refs winning a tie so a slash inside a quoted ref value stays part of that value. Paste additionally scans as inert text: a command ending the paste is complete rather than half-typed, and the insertion point's own token boundary decides the leading token, so `foo` + `/clean` stays `foo/clean`. `textBeforeCaret`'s chip-atomic serialization moves to rich-editor as `serializeTextBefore` — the paste path needs the same "a chip edge is a token boundary" reading that trigger detection does. --- .../src/app/chat/composer/rich-editor.ts | 155 +++++++++++++----- .../src/app/chat/composer/text-utils.ts | 20 +-- 2 files changed, 114 insertions(+), 61 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index bc6a1f26bc8..9958c3a4b09 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -16,22 +16,13 @@ import { type SlashChipKind, slashIconElement } from '@/components/assistant-ui/directive-text' -import { - desktopSlashCommandArgumentMode, - isDesktopSlashCommand, - resolveDesktopCommand -} from '@/lib/desktop-slash-commands' + +import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs' export const RICH_INPUT_SLOT = 'composer-rich-input' export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g -/** A committed leading slash command: `/name` followed by whitespace. The - * whitespace requirement is what separates a committed command (chips always - * serialize with their auto-inserted trailing space) from one still being - * typed, which must stay editable text. */ -const LEADING_SLASH_COMMAND_RE = /^\/[a-zA-Z][\w-]*(?=\s)/ - const ESC: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } export function escapeHtml(value: string) { @@ -123,42 +114,59 @@ function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: stri }) } -export function appendComposerContents(target: DocumentFragment | HTMLElement, text: string) { - let cursor = 0 - +/** Every span of `text` that renders as a chip, in source order. */ +function chipSpans(text: string, options: SlashCommandScanOptions) { REF_RE.lastIndex = 0 - for (const match of text.matchAll(REF_RE)) { - const index = match.index ?? 0 - appendTextWithBreaks(target, text.slice(cursor, index)) - target.append(refChipElement(match[1] || 'file', match[2] || '')) - cursor = index + match[0].length + const refs = Array.from(text.matchAll(REF_RE)).map(match => { + const start = match.index ?? 0 + + return { end: start + match[0].length, node: () => refChipElement(match[1] || 'file', match[2] || ''), start } + }) + + const commands = slashCommandMatches(text, options).map(match => ({ + end: match.end, + node: () => slashChipElement(match.command, match.kind), + start: match.start + })) + + return [...refs, ...commands].sort((a, b) => a.start - b.start) +} + +/** Build the chip/text DOM for `text`. Directives hydrate back to their pills — + * `@kind:value` refs and `/command` invocations both — so text that arrives + * whole (a paste, a restored draft, an undo step, a rebuilt line) carries the + * same chips the typed path would have committed. */ +export function appendComposerContents( + target: DocumentFragment | HTMLElement, + text: string, + options: SlashCommandScanOptions = {} +) { + let cursor = 0 + + for (const span of chipSpans(text, options)) { + // A `@` ref wins an overlap: a command token can't contain an `@`, so the + // only way spans collide is a slash inside a quoted ref value + // (`` @url:`a /clean` ``), which belongs to that value. + if (span.start < cursor) { + continue + } + + appendTextWithBreaks(target, text.slice(cursor, span.start)) + target.append(span.node()) + cursor = span.end } appendTextWithBreaks(target, text.slice(cursor)) } -export function renderComposerContents(target: HTMLElement, text: string) { +export function renderComposerContents(target: HTMLElement, text: string, options?: SlashCommandScanOptions) { target.replaceChildren() - // A leading `/command` hydrates back to its pill — parity with REF_RE for - // `@` refs, so a full re-render from serialized text (draft restore, undo, - // the trigger commit fallback) doesn't demote a committed command chip to - // plain text. Only commands with NO argument stage qualify (skills, quick - // commands, no-arg built-ins): their committed pill is exactly the bare - // `/name`, so the boundary is unambiguous. Arg-taking commands (`/goal ship - // it`, `/personality alice`) stay text — their tail may be prose that was - // never committed. The trailing whitespace is load-bearing too: a committed - // pill always serializes with its auto-inserted space, while a half-typed - // `/wor` must stay editable text. - const command = LEADING_SLASH_COMMAND_RE.exec(text)?.[0] - - if (command && isDesktopSlashCommand(command) && desktopSlashCommandArgumentMode(command) === null) { - target.append(slashChipElement(command, resolveDesktopCommand(command) ? 'command' : 'skill')) - text = text.slice(command.length) - } - - appendComposerContents(target, text) + // Defaults to live editing, where a token ending the text is still being + // typed (`/wor`) and must stay editable. Callers repainting inert text (a + // restored draft, a sent message opened for edit) pass `trailingCommitted`. + appendComposerContents(target, text, options) } /** Caret range when the selection lives inside `editor`; else null. */ @@ -173,20 +181,79 @@ function composerSelectionRange(editor: HTMLElement) { return { range, selection } } -/** Insert text at the caret (replacing any selection), with any `@kind:value` - * directives in it landing as chips. Pastes use this instead of - * `execCommand('insertText')` — Chromium's editing pipeline is ~O(n²) on large - * multiline blobs. */ +/** Serialized text from the editor's start up to (`container`, `offset`). + * + * Chips are ATOMIC here: each contributes an object-replacement placeholder + * rather than leaking its label text, and a
contributes a newline. That + * makes a chip edge read as a token boundary, which is what both trigger + * detection and directive recognition need. */ +export function serializeTextBefore(editor: HTMLElement, container: Node, offset: number): string { + const probe = document.createRange() + + probe.selectNodeContents(editor) + probe.setEnd(container, offset) + + const scratch = document.createElement('div') + + scratch.append(probe.cloneContents()) + + for (const chip of scratch.querySelectorAll('[data-ref-text]')) { + chip.replaceWith('\uFFFC') + } + + for (const br of scratch.querySelectorAll('br')) { + br.replaceWith('\n') + } + + return scratch.textContent ?? '' +} + +/** True when the insertion point starts a token — the editor's start, or after + * whitespace or a chip. `foo` + a pasted `/clean` is `foo/clean`, not a + * command; `foo ` + the same paste is. */ +function atTokenBoundary(editor: HTMLElement, range: Range | null): boolean { + // No caret means the insert lands at the end, so the question is about the + // editor's last character either way. + const before = range + ? serializeTextBefore(editor, range.startContainer, range.startOffset) + : serializeTextBefore(editor, editor, editor.childNodes.length) + + const last = before.slice(-1) + + return !last || /[\s\uFFFC]/.test(last) +} + +/** Insert text at the caret (replacing any selection), with any directives in + * it landing as chips. Pastes use this instead of `execCommand('insertText')` + * — Chromium's editing pipeline is ~O(n²) on large multiline blobs. + * + * The text arrives whole rather than typed, so a `/command` ending it is + * complete rather than half-written and chips like the rest. */ export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) { const hit = composerSelectionRange(editor) const fragment = document.createDocumentFragment() - appendComposerContents(fragment, text) + // Before measuring the boundary — a replaced selection puts the insertion + // point where the selection started, not where it ended. + if (hit) { + hit.range.deleteContents() + } + + appendComposerContents(fragment, text, { + boundaryBefore: atTokenBoundary(editor, hit?.range ?? null), + trailingCommitted: true + }) + + // A slash pill ending the insert gets the trailing space the typed commit + // path appends, or the next full re-render reads it as a half-typed token + // and demotes it. `@` refs need no marker — REF_RE re-chips them either way. + if ((fragment.lastChild as HTMLElement | null)?.dataset?.slashKind) { + fragment.append(document.createTextNode(' ')) + } const tail = fragment.lastChild if (hit) { - hit.range.deleteContents() hit.range.insertNode(fragment) } else { editor.append(fragment) diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index 19dfa8ce0d9..3224716b27c 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -1,6 +1,8 @@ import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images' import { $reactionsEnabled } from '@/store/reactions-enabled' +import { serializeTextBefore } from './rich-editor' + export interface TriggerState { /** True for a `/` typed mid-message — an inline skill/command reference in * prose rather than a command invocation. Arg completion doesn't apply. */ @@ -141,23 +143,7 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null { return null } - const before = range.cloneRange() - before.selectNodeContents(editor) - before.setEnd(range.startContainer, range.startOffset) - - const scratch = document.createElement('div') - - scratch.append(before.cloneContents()) - - for (const chip of scratch.querySelectorAll('[data-ref-text]')) { - chip.replaceWith('\uFFFC') - } - - for (const br of scratch.querySelectorAll('br')) { - br.replaceWith('\n') - } - - return scratch.textContent ?? '' + return serializeTextBefore(editor, range.startContainer, range.startOffset) } export function detectTrigger(textBefore: string): TriggerState | null {