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
18 changes: 16 additions & 2 deletions apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import {
composerPlainText,
deleteChipBeforeCaret,
deleteSelectionInEditor,
insertPlainTextAtCaret,
insertComposerContentsAtCaret,
normalizeComposerEditorDom,
RICH_INPUT_SLOT
} from './rich-editor'
Expand All @@ -60,6 +60,7 @@ import { extractClipboardImageBlobs } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
import { UrlDialog } from './url-dialog'
import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs'
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'

export function ChatBar({
Expand Down Expand Up @@ -402,7 +403,11 @@ export function ChatBar({
}

event.preventDefault()
insertPlainTextAtCaret(event.currentTarget, pastedText)

// Links in the paste land as `@url:` chips rather than a wall of URL text —
// the same reference the "Add URL" dialog inserts, parsed in place so a link
// mid-sentence keeps its position.
insertComposerContentsAtCaret(event.currentTarget, linkifyUrls(pastedText))
scheduleFlushEditorToDraft(event.currentTarget)
}

Expand Down Expand Up @@ -441,6 +446,15 @@ export function ChatBar({
return
}

// A typed link finished with a space chips like a pasted one — the space
// itself rides along inside the insert.
if (chipTypedUrlOnSpace(event)) {
event.preventDefault()
flushEditorToDraft(event.currentTarget)

return
}

// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
// reserved for the global command palette.
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
Expand Down
73 changes: 69 additions & 4 deletions apps/desktop/src/app/chat/composer/rich-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { insertInlineRefsIntoEditor } from './inline-refs'
import {
composerPlainText,
deleteSelectionInEditor,
insertPlainTextAtCaret,
insertComposerContentsAtCaret,
normalizeComposerEditorDom,
refChipElement,
renderComposerContents,
replaceBeforeCaret,
RICH_INPUT_SLOT
} from './rich-editor'

Expand Down Expand Up @@ -72,14 +73,14 @@ describe('insertInlineRefsIntoEditor', () => {
})
})

describe('insertPlainTextAtCaret', () => {
describe('insertComposerContentsAtCaret', () => {
it('inserts multiline text as text nodes + br', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)

insertPlainTextAtCaret(editor, 'one\ntwo\nthree')
insertComposerContentsAtCaret(editor, 'one\ntwo\nthree')

expect(editor.querySelectorAll('br').length).toBe(2)
expect(composerPlainText(editor)).toBe('one\ntwo\nthree')
Expand All @@ -102,12 +103,76 @@ describe('insertPlainTextAtCaret', () => {
selection.removeAllRanges()
selection.addRange(range)

insertPlainTextAtCaret(editor, 'cd')
insertComposerContentsAtCaret(editor, 'cd')

expect(composerPlainText(editor)).toBe('abcdef')

editor.remove()
})

it('lands directives in the text as chips', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)

insertComposerContentsAtCaret(editor, 'read @url:`https://example.dev/a` now')

expect(editor.querySelectorAll('[data-ref-kind="url"]').length).toBe(1)
expect(composerPlainText(editor)).toBe('read @url:`https://example.dev/a` now')

editor.remove()
})
})

describe('replaceBeforeCaret', () => {
it('swaps the token before the caret and leaves the caret after the insert', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'see foo'
document.body.append(editor)

const text = editor.firstChild!
const selection = window.getSelection()!
const range = document.createRange()

range.setStart(text, 7)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)

const fragment = document.createDocumentFragment()
fragment.append(refChipElement('file', '`src/foo.ts`'), document.createTextNode(' '))

expect(replaceBeforeCaret(editor, 3, fragment)).toBe(true)
expect(composerPlainText(editor)).toBe('see @file:`src/foo.ts` ')
expect(selection.getRangeAt(0).collapsed).toBe(true)

editor.remove()
})

it('leaves the editor alone when the caret has no room for the token', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'hi'
document.body.append(editor)

const selection = window.getSelection()!
const range = document.createRange()

range.setStart(editor.firstChild!, 2)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)

const fragment = document.createDocumentFragment()
fragment.append(document.createTextNode('x'))

expect(replaceBeforeCaret(editor, 20, fragment)).toBe(false)
expect(composerPlainText(editor)).toBe('hi')

editor.remove()
})
})

describe('deleteSelectionInEditor', () => {
Expand Down
59 changes: 46 additions & 13 deletions apps/desktop/src/app/chat/composer/rich-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import {
directiveIconElement,
directiveIconSvg,
formatRefValue,
refChipLabel,
slashChipClass,
type SlashChipKind,
slashIconElement
} from '@/components/assistant-ui/directive-text'
import { sessionRefFallbackLabel } from '@/lib/session-refs'

export const RICH_INPUT_SLOT = 'composer-rich-input'

Expand All @@ -35,10 +35,6 @@ export function unquoteRef(raw: string) {
return quoted ? raw.slice(1, -1) : raw.replace(/[,.;!?]+$/, '')
}

export function refLabel(id: string) {
return id.split(/[\\/]/).filter(Boolean).pop() || id
}

/** Always-quote variant of formatRefValue — chips need a fence even for safe values. */
export function quoteRefValue(value: string) {
if (!value.includes('`')) {
Expand All @@ -60,9 +56,9 @@ export function refChipHtml(kind: string, rawValue: string, displayLabel?: strin
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`

const label = displayLabel || (kind === 'session' ? sessionRefFallbackLabel(id) : refLabel(id))
const label = displayLabel || refChipLabel(kind, id)

return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(label)}</span></span>`
return `<span contenteditable="false" title="${escapeHtml(id)}" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(label)}</span></span>`
}

export function refChipElement(kind: string, rawValue: string, displayLabel?: string) {
Expand All @@ -72,12 +68,13 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st
const label = document.createElement('span')

chip.contentEditable = 'false'
chip.title = id
chip.dataset.refText = text
chip.dataset.refId = id
chip.dataset.refKind = kind
chip.className = DIRECTIVE_CHIP_CLASS
label.className = 'truncate'
label.textContent = displayLabel || (kind === 'session' ? sessionRefFallbackLabel(id) : refLabel(id))
label.textContent = displayLabel || refChipLabel(kind, id)
chip.append(directiveIconElement(kind), label)

return chip
Expand Down Expand Up @@ -147,14 +144,15 @@ function composerSelectionRange(editor: HTMLElement) {
return { range, selection }
}

/** Insert plain text at the caret (replacing any selection). Pastes use this
* instead of `execCommand('insertText')` — Chromium's editing pipeline is
* ~O(n²) on large multiline blobs. */
export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
/** 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. */
export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) {
const hit = composerSelectionRange(editor)
const fragment = document.createDocumentFragment()

appendTextWithBreaks(fragment, text)
appendComposerContents(fragment, text)

const tail = fragment.lastChild

Expand All @@ -175,6 +173,41 @@ export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
}
}

/** Swap the `length` characters immediately before a collapsed caret for
* `fragment`, leaving the caret after it. Returns whether it ran — a caret that
* isn't inside a text node holding the whole token is left alone. */
export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment: DocumentFragment) {
const hit = composerSelectionRange(editor)

if (!hit?.range.collapsed) {
return false
}

const { startContainer, startOffset } = hit.range

if (startContainer.nodeType !== Node.TEXT_NODE || startOffset < length) {
return false
}

const range = document.createRange()
const tail = fragment.lastChild

range.setStart(startContainer, startOffset - length)
range.setEnd(startContainer, startOffset)
range.deleteContents()
range.insertNode(fragment)

if (tail) {
range.setStartAfter(tail)
}

range.collapse(true)
hit.selection.removeAllRanges()
hit.selection.addRange(range)

return true
}

/** Backspace at a collapsed caret immediately after a chip: delete the chip AND
* the single trailing space we auto-insert after it, atomically — so removing a
* directive never strands an orphaned space (the contenteditable-driven cleanup
Expand Down
98 changes: 98 additions & 0 deletions apps/desktop/src/app/chat/composer/url-refs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { KeyboardEvent } from 'react'
import { describe, expect, it } from 'vitest'

import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor'
import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs'

/** An editor holding `text` with a collapsed caret at `caret`, plus the space
* keydown the composer would hand `chipTypedUrlOnSpace`. */
const spaceOn = (text: string, caret: number) => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = text
document.body.append(editor)

const selection = window.getSelection()!
const range = document.createRange()

range.setStart(editor.firstChild!, caret)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)

return { editor, event: { currentTarget: editor, key: ' ' } as KeyboardEvent<HTMLDivElement> }
}

describe('linkifyUrls', () => {
it('rewrites a bare link as a url directive', () => {
expect(linkifyUrls('https://example.dev/a/b')).toBe('@url:`https://example.dev/a/b`')
})

it('keeps the link in place mid-sentence and leaves its punctuation behind', () => {
expect(linkifyUrls('read https://example.dev/a. then stop')).toBe('read @url:`https://example.dev/a`. then stop')
})

it('keeps balanced parens but drops the one that closed the sentence', () => {
expect(linkifyUrls('(see https://en.wikipedia.org/wiki/A_(b))')).toBe(
'(see @url:`https://en.wikipedia.org/wiki/A_(b)`)'
)
})

it('rewrites every link in a multi-link paste', () => {
expect(linkifyUrls('http://a.dev and https://b.dev')).toBe('@url:`http://a.dev` and @url:`https://b.dev`')
})

it('leaves a link that is already a directive alone', () => {
expect(linkifyUrls('@url:`https://example.dev`')).toBe('@url:`https://example.dev`')
})

it('leaves text without a scheme alone', () => {
expect(linkifyUrls('example.dev/a and src/foo.ts')).toBe('example.dev/a and src/foo.ts')
})
})

describe('chipTypedUrlOnSpace', () => {
it('chips a link typed right before the caret and adds the space', () => {
const { editor, event } = spaceOn('see https://example.dev/a', 25)

expect(chipTypedUrlOnSpace(event)).toBe(true)
expect(composerPlainText(editor)).toBe('see @url:`https://example.dev/a` ')

editor.remove()
})

it('keeps sentence punctuation outside the chip', () => {
const { editor, event } = spaceOn('https://example.dev.', 20)

expect(chipTypedUrlOnSpace(event)).toBe(true)
expect(composerPlainText(editor)).toBe('@url:`https://example.dev`. ')

editor.remove()
})

it('ignores a caret that is not sitting on a link', () => {
const { editor, event } = spaceOn('https://example.dev is nice', 27)

expect(chipTypedUrlOnSpace(event)).toBe(false)
expect(composerPlainText(editor)).toBe('https://example.dev is nice')

editor.remove()
})

it('ignores a scheme with no host yet', () => {
const { editor, event } = spaceOn('https://', 8)

expect(chipTypedUrlOnSpace(event)).toBe(false)

editor.remove()
})

it('leaves a modified space alone', () => {
const { editor, event } = spaceOn('https://example.dev', 19)

expect(chipTypedUrlOnSpace({ ...event, altKey: true })).toBe(false)
expect(composerPlainText(editor)).toBe('https://example.dev')

editor.remove()
})
})
Loading
Loading