Merge pull request #72201 from NousResearch/bb/composer-url-chips

feat(desktop): chip a link pasted or typed into the composer
This commit is contained in:
brooklyn! 2026-07-26 15:33:52 -05:00 committed by GitHub
commit 6fc571afe9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 359 additions and 28 deletions

View file

@ -49,7 +49,7 @@ import {
composerPlainText,
deleteChipBeforeCaret,
deleteSelectionInEditor,
insertPlainTextAtCaret,
insertComposerContentsAtCaret,
normalizeComposerEditorDom,
RICH_INPUT_SLOT
} from './rich-editor'
@ -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({
@ -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)
}
@ -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') {

View file

@ -4,10 +4,11 @@ import { insertInlineRefsIntoEditor } from './inline-refs'
import {
composerPlainText,
deleteSelectionInEditor,
insertPlainTextAtCaret,
insertComposerContentsAtCaret,
normalizeComposerEditorDom,
refChipElement,
renderComposerContents,
replaceBeforeCaret,
RICH_INPUT_SLOT
} from './rich-editor'
@ -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')
@ -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', () => {

View file

@ -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'
@ -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('`')) {
@ -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) {
@ -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
@ -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
@ -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

View file

@ -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()
})
})

View file

@ -0,0 +1,103 @@
/**
* Bare-link recognition for the composer. A link the user pastes or types is the
* same thing the "+ → Add URL" dialog inserts, so it becomes an `@url:`
* directive: a chip that truncates instead of a wall of URL text, and a
* reference the gateway resolves.
*/
import type { KeyboardEvent } from 'react'
import { quoteRefValue, REF_RE, refChipElement, replaceBeforeCaret } from './rich-editor'
import { textBeforeCaret } from './text-utils'
// An explicit scheme only — `example.com` bare is too easy to hit by accident
// (a filename, a version, a sentence). Brackets and quotes fence a URL in prose;
// parens don't, so they stay in and an unbalanced tail is trimmed below.
const URL_RE = /https?:\/\/[^\s<>[\]{}"'`]+/gi
const TYPED_URL_RE = /(?:^|\s)(https?:\/\/[^\s<>[\]{}"'`]+)$/i
/** A URL at the end of a sentence carries the punctuation that ended it. */
function splitUrlTail(raw: string) {
let url = raw.replace(/[,.;:!?]+$/, '')
while (url.endsWith(')') && url.split(')').length > url.split('(').length) {
url = url.slice(0, -1)
}
return { trailing: raw.slice(url.length), url }
}
/** A URL needs a host past the scheme to be worth chipping. */
const hasHost = (url: string) => /^https?:\/\/[^/\s]/i.test(url)
/** Rewrite bare links in `text` as `@url:` directives, leaving links that are
* already part of a directive alone. Returns `text` unchanged when there are
* none. */
export function linkifyUrls(text: string) {
REF_RE.lastIndex = 0
const fenced = Array.from(text.matchAll(REF_RE)).map(match => {
const start = match.index ?? 0
return { end: start + match[0].length, start }
})
let out = ''
let cursor = 0
for (const match of text.matchAll(URL_RE)) {
const start = match.index ?? 0
const { url } = splitUrlTail(match[0])
if (!hasHost(url) || fenced.some(span => start >= span.start && start < span.end)) {
continue
}
out += `${text.slice(cursor, start)}@url:${quoteRefValue(url)}`
cursor = start + url.length
}
return out + text.slice(cursor)
}
/** A plain space finishing a typed link commits it as a chip (followed by
* whatever punctuation ended it, then the space). Returns whether it ran, so a
* keydown handler can fall through on anything else. */
export function chipTypedUrlOnSpace(event: KeyboardEvent<HTMLDivElement>) {
if (event.key !== ' ' || event.metaKey || event.ctrlKey || event.altKey) {
return false
}
const editor = event.currentTarget
// Runs on every space, so bail on the cheap native read before paying for the
// caret range walk (same guard shape as the trigger detector).
if (!editor.textContent?.includes('://')) {
return false
}
const before = textBeforeCaret(editor)
const match = before ? TYPED_URL_RE.exec(before) : null
const token = match?.[1]
if (!token) {
return false
}
const { trailing, url } = splitUrlTail(token)
if (!hasHost(url)) {
return false
}
const fragment = document.createDocumentFragment()
fragment.append(refChipElement('url', quoteRefValue(url)))
if (trailing) {
fragment.append(document.createTextNode(trailing))
}
fragment.append(document.createTextNode(' '))
return replaceBeforeCaret(editor, token.length, fragment)
}

View file

@ -277,7 +277,7 @@ function parseDirectiveText(text: string): Unstable_DirectiveSegment[] {
start: match.index ?? 0,
end: (match.index ?? 0) + match[0].length,
type: match[1] || 'file',
label: shortLabel(match[1] as HermesRefType, id),
label: refChipLabel(match[1] || 'file', id),
id
}
}),
@ -320,25 +320,30 @@ function parseDirectiveText(text: string): Unstable_DirectiveSegment[] {
return segments
}
function shortLabel(type: HermesRefType, id: string): string {
/** Display text for a `@kind:value` chip. Shared with the composer's
* contenteditable chips so a link reads the same before and after send: the
* host leads (scheme and `www.` are noise) and the path rides along for the
* chip's `truncate` to cut — a bare hostname can't tell two links apart. */
export function refChipLabel(type: string, id: string): string {
if (type === 'terminal') {
return id || 'terminal'
}
if (type === 'session') {
return sessionRefFallbackLabel(id)
}
if (type === 'url') {
try {
const parsed = new URL(id)
const { hostname, pathname, search } = new URL(id)
const path = `${pathname}${search}`.replace(/\/$/, '')
return parsed.hostname || id
return `${hostname.replace(/^www\./i, '')}${path}` || id
} catch {
return id
}
}
if (type === 'session') {
return sessionRefFallbackLabel(id)
}
const tail = id.split(/[\\/]/).filter(Boolean).pop()
return tail || id

View file

@ -31,6 +31,7 @@ import {
} from '@/app/chat/composer/inline-refs'
import {
composerPlainText,
insertComposerContentsAtCaret,
placeCaretEnd,
refChipElement,
renderComposerContents,
@ -38,6 +39,7 @@ import {
} from '@/app/chat/composer/rich-editor'
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import { chipTypedUrlOnSpace, linkifyUrls } from '@/app/chat/composer/url-refs'
import {
extractDroppedFiles,
HERMES_PATHS_MIME,
@ -503,7 +505,9 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
event.preventDefault()
rememberInitialDraft()
document.execCommand('insertText', false, pastedText)
// Links land as `@url:` chips, same as the main composer.
insertComposerContentsAtCaret(event.currentTarget, linkifyUrls(pastedText))
syncDraftFromEditor(event.currentTarget)
}
@ -602,6 +606,15 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
return
}
// A typed link finished with a space chips like a pasted one.
if (chipTypedUrlOnSpace(event)) {
event.preventDefault()
rememberInitialDraft()
syncDraftFromEditor(event.currentTarget)
return
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submitEdit(event.currentTarget)