From ee09162e4529c019889ad1e74129b0cd1d230f23 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 15:23:35 -0500 Subject: [PATCH 1/3] fix(desktop): label a url chip with its host and path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer labeled a `@url:` chip with `refLabel`, which takes the last path segment — three PR links all read as their number. The transcript had its own better labeler that stopped at the hostname, so the same reference read differently before and after send. One `refChipLabel` now serves both: host without `www.`, path riding along for the chip's existing truncate to cut, and the full value on the chip's title so a cut-off link is still readable on hover. --- .../src/app/chat/composer/rich-editor.ts | 13 +++++------- .../assistant-ui/directive-text.tsx | 21 ++++++++++++------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 21f0286c9f82..8978d6f15207 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 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 From 456b2f9c7db2fbbaa184d03bdcf607ca7959b5be Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 15:23:39 -0500 Subject: [PATCH 2/3] refactor(desktop): give the rich editor a chip-aware caret insert `insertPlainTextAtCaret` dropped its text in verbatim, so a caller with directives in hand had no way to land them as chips. It becomes `insertComposerContentsAtCaret`, sharing the parse `renderComposerContents` already uses, and gains a sibling `replaceBeforeCaret` for swapping a just-typed token for a chip. --- .../src/app/chat/composer/rich-editor.test.ts | 73 ++++++++++++++++++- .../src/app/chat/composer/rich-editor.ts | 46 ++++++++++-- 2 files changed, 110 insertions(+), 9 deletions(-) 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 8978d6f15207..738201fc3944 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -144,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 @@ -172,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 From adfaa95e3bc6ad5196de55d7737f7d933005d336 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 15:23:44 -0500 Subject: [PATCH 3/3] feat(desktop): chip a link pasted or typed into the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src/app/chat/composer/index.tsx | 18 ++- .../src/app/chat/composer/url-refs.test.ts | 98 +++++++++++++++++ .../desktop/src/app/chat/composer/url-refs.ts | 103 ++++++++++++++++++ .../thread/user-edit-composer.tsx | 15 ++- 4 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/url-refs.test.ts create mode 100644 apps/desktop/src/app/chat/composer/url-refs.ts 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)