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)