Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@
const editor = editorRef.current

if (editor) {
renderComposerContents(editor, next)
renderComposerContents(editor, next, { trailingCommitted: true })
placeCaretEnd(editor)
}

Expand Down Expand Up @@ -265,7 +265,7 @@
const editor = editorRef.current

if (editor && document.activeElement !== editor && composerPlainText(editor) !== text) {
renderComposerContents(editor, text)
renderComposerContents(editor, text, { trailingCommitted: true })
}

if (isBrowsingHistory(sessionIdRef.current) || queueEditRef.current) {
Expand Down Expand Up @@ -297,7 +297,7 @@
unsubscribe()
window.clearTimeout(draftPersistTimerRef.current)
}
}, [composerRuntime, queueEditRef])

Check warning on line 300 in apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

React Hook useEffect has a missing dependency: 'stashAt'. Either include it or remove the dependency array

const insertText = (text: string) => {
const base = draftRef.current
Expand Down Expand Up @@ -401,7 +401,7 @@
window.removeEventListener('pagehide', flushPendingDraftPersist)
flushPendingDraftPersist()
}
}, [syncDraftFromEditor])

Check warning on line 404 in apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

React Hook useEffect has a missing dependency: 'stashAt'. Either include it or remove the dependency array

return {
activeQueueSessionKeyRef,
Expand Down
76 changes: 76 additions & 0 deletions apps/desktop/src/app/chat/composer/rich-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,82 @@ describe('insertComposerContentsAtCaret', () => {

editor.remove()
})

// A directive typed by hand chips; the same directive pasted has to chip too,
// or copy/pasting a prompt silently drops every command in it.
it('chips a pasted slash command, including one that ends the paste', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)

insertComposerContentsAtCaret(editor, '/some-skill')

expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/some-skill')
// Committed pills carry the trailing space the typed path appends, so a
// later full re-render doesn't read the token as half-typed.
expect(composerPlainText(editor)).toBe('/some-skill ')

editor.remove()
})

it('chips a skill named mid-paste alongside a ref', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)

insertComposerContentsAtCaret(editor, 'clean @file:`a.ts` with /some-skill then ship')

expect(editor.querySelectorAll('[data-slash-kind]').length).toBe(1)
expect(editor.querySelectorAll('[data-ref-kind="file"]').length).toBe(1)
expect(composerPlainText(editor)).toBe('clean @file:`a.ts` with /some-skill then ship')

editor.remove()
})

it('leaves a pasted path alone — /usr/local is not a command', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)

insertComposerContentsAtCaret(editor, 'see /usr/local/bin and /goal ship it')

expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('see /usr/local/bin and /goal ship it')

editor.remove()
})

it('does not chip a command pasted against a word — foo/clean is not a command', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'foo'
document.body.append(editor)
caretIn(editor)

insertComposerContentsAtCaret(editor, '/some-skill')

expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('foo/some-skill')

editor.remove()
})

it('chips a command pasted right after an existing chip', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(refChipElement('file', '`a.ts`'))
document.body.append(editor)
caretIn(editor)

insertComposerContentsAtCaret(editor, '/some-skill')

expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()

editor.remove()
})
})

describe('replaceBeforeCaret', () => {
Expand Down
153 changes: 110 additions & 43 deletions apps/desktop/src/app/chat/composer/rich-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }

export function escapeHtml(value: string) {
Expand Down Expand Up @@ -123,42 +114,59 @@ function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: stri
})
}

export function appendComposerContents(target: DocumentFragment | HTMLElement, text: string) {
/** Every span of `text` that renders as a chip, in source order. */
function chipSpans(text: string, options: SlashCommandScanOptions) {
REF_RE.lastIndex = 0

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

REF_RE.lastIndex = 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
}

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
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. */
Expand All @@ -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 <br> 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)
Expand Down
45 changes: 45 additions & 0 deletions apps/desktop/src/app/chat/composer/slash-refs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'

import { slashCommandMatches } from './slash-refs'

const commands = (text: string, options?: Parameters<typeof slashCommandMatches>[1]) =>
slashCommandMatches(text, options).map(match => `${match.kind}:${match.command}`)

describe('slashCommandMatches', () => {
it('recognizes a leading command and a skill named mid-prose', () => {
expect(commands('/some-skill clean this with /other-skill please')).toEqual([
'skill:/some-skill',
'skill:/other-skill'
])
})

it('leaves a path alone — /usr/local/bin is not a command', () => {
expect(commands('see /usr/local/bin ')).toEqual([])
})

it('holds a trailing token as still-typed unless the text is inert', () => {
expect(commands('/some-skill')).toEqual([])
expect(commands('/some-skill', { trailingCommitted: true })).toEqual(['skill:/some-skill'])
})

it('leaves an arg-taking command as text — its tail may be prose', () => {
expect(commands('/goal ship the redesign')).toEqual([])
})

it('leaves a command with no desktop surface as text', () => {
expect(commands('/exit now')).toEqual([])
})

it('offers a built-in only as an invocation, never mid-message', () => {
// Mirrors what the popover offers: `/new` acts on the app, so it means
// nothing dropped into a sentence, while a skill reads as "handle this
// part with X".
expect(commands('/new ')).toEqual(['command:/new'])
expect(commands('start over with /new ')).toEqual([])
expect(commands('start over with /some-skill ')).toEqual(['skill:/some-skill'])
})

it('disqualifies a leading token when the text lands mid-word', () => {
expect(commands('/some-skill ', { boundaryBefore: false })).toEqual([])
})
})
Loading
Loading