feat(desktop): chip a link pasted or typed into the composer

A pasted link went in as raw URL text, wrapping across the composer and
staying inert. It now becomes the same `@url:` reference the "+ → Add URL"
dialog inserts — parsed in place, so a link mid-sentence keeps its position
and the punctuation that ended the sentence stays outside the chip. Typing
one and pressing space commits it the same way.

Both rich-editor surfaces get it: the composer and the message-edit box,
whose paste went through `execCommand` and could not produce a chip at all.
This commit is contained in:
Brooklyn Nicholson 2026-07-26 15:23:44 -05:00
parent 456b2f9c7d
commit adfaa95e3b
4 changed files with 231 additions and 3 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

@ -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

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