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