mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
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:
commit
6fc571afe9
7 changed files with 359 additions and 28 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') {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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