diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index 1ce110f4e098..a29dcb1cd7e3 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -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') {
diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts
index 12e3e9613eff..aa4e80aedb9c 100644
--- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts
+++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts
@@ -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', () => {
diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts
index 21f0286c9f82..738201fc3944 100644
--- a/apps/desktop/src/app/chat/composer/rich-editor.ts
+++ b/apps/desktop/src/app/chat/composer/rich-editor.ts
@@ -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 `${directiveIconSvg(kind)}${escapeHtml(label)}`
+ return `${directiveIconSvg(kind)}${escapeHtml(label)}`
}
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
diff --git a/apps/desktop/src/app/chat/composer/url-refs.test.ts b/apps/desktop/src/app/chat/composer/url-refs.test.ts
new file mode 100644
index 000000000000..febbd533ce43
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/url-refs.test.ts
@@ -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 }
+}
+
+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()
+ })
+})
diff --git a/apps/desktop/src/app/chat/composer/url-refs.ts b/apps/desktop/src/app/chat/composer/url-refs.ts
new file mode 100644
index 000000000000..580abdc1c6fb
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/url-refs.ts
@@ -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) {
+ 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)
+}
diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx
index 317489a96f82..312c7d9414d8 100644
--- a/apps/desktop/src/components/assistant-ui/directive-text.tsx
+++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx
@@ -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
diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
index b29e13493ef5..0da6e1dea8a8 100644
--- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
@@ -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 = ({ 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 = ({ 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)