mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
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:
parent
456b2f9c7d
commit
adfaa95e3b
4 changed files with 231 additions and 3 deletions
|
|
@ -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') {
|
||||
|
|
|
|||
98
apps/desktop/src/app/chat/composer/url-refs.test.ts
Normal file
98
apps/desktop/src/app/chat/composer/url-refs.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
103
apps/desktop/src/app/chat/composer/url-refs.ts
Normal file
103
apps/desktop/src/app/chat/composer/url-refs.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue