fix(desktop): pasting into an open @url: scope no longer doubles the directive

A pasted link linkifies into an `@url:` directive, so pasting one while an
@url: scope was already open stacked a second directive on the first and
submitted `@url:@url:` around the link. The leftover prefix rendered as
literal text in front of the chip.

insertComposerContentsAtCaret takes a consumeBefore length; both composers
pass the open scope's span so the scope is consumed by the paste rather than
left sitting in front of the chip.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 02:14:30 -05:00
parent 4fb4d78989
commit 130cc0b859
4 changed files with 42 additions and 20 deletions

View file

@ -67,7 +67,7 @@ import {
import { useComposerScope } from './scope'
import { ComposerStatusStack } from './status-stack'
import { CodingStatusRow } from './status-stack/coding-row'
import { extractClipboardImageBlobs } from './text-utils'
import { extractClipboardImageBlobs, openDirectiveScope } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
import { isRedoShortcut, isUndoShortcut } from './undo-history'
@ -491,8 +491,13 @@ export function ChatBar({
// 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. Bare `@path` tokens promote the same way.
// A paste into an open `@url:`/`@file:` scope CONSUMES that scope instead of
// stacking on it — the scope is the browse mode the user is pasting into,
// not text they typed and want to keep (`@url:@url:\`https://…\``).
const scope = openDirectiveScope(event.currentTarget)
recordUndoPoint()
insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText)))
insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText)), scope)
scheduleFlushEditorToDraft(event.currentTarget)
}
@ -1138,6 +1143,7 @@ export function ChatBar({
loading={triggerLoading}
onHover={setTriggerActive}
onPick={replaceTriggerWithChip}
scope={trigger.scope}
/>
)}
{!poppedOut && (

View file

@ -228,8 +228,24 @@ function atTokenBoundary(editor: HTMLElement, range: Range | null): boolean {
* Chromium's editing pipeline is ~O(n²) on large multiline blobs.
*
* The text arrives whole rather than typed, so a `/command` ending it is
* complete rather than half-written and chips like the rest. */
export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) {
* complete rather than half-written and chips like the rest.
*
* `consumeBefore` characters immediately before the caret are swallowed by the
* insert. That's how a paste into an open `@url:` scope replaces the scope
* instead of stacking on it (`@url:@url:\`https://…\``). */
export function insertComposerContentsAtCaret(editor: HTMLElement, text: string, consumeBefore = 0) {
const scoped = consumeBefore > 0 ? rangeBeforeCaret(editor, consumeBefore) : null
if (scoped) {
scoped.deleteContents()
scoped.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(scoped)
}
const hit = composerSelectionRange(editor)
const fragment = document.createDocumentFragment()

View file

@ -164,18 +164,13 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
return serializeTextBefore(editor, range.startContainer, range.startOffset)
}
/** A directive scope the caret is sitting inside (`@url:` with nothing typed
* after it), and how many characters it occupies. A paste or a picked value
* lands INTO that scope: the scope text is consumed rather than left in front
* of the chip as leftover syntax. */
export function openDirectiveScope(editor: HTMLDivElement): { length: number; scope: DirectiveScope } | null {
/** How many characters of directive scope the caret is sitting inside (`@url:`
* with nothing typed after it), or 0. A paste lands INTO that scope: the scope
* text is consumed rather than left in front of the chip as leftover syntax. */
export function openDirectiveScope(editor: HTMLDivElement): number {
const trigger = detectTrigger(textBeforeCaret(editor) ?? '')
if (trigger?.kind !== '@' || !trigger.scope || trigger.value) {
return null
}
return { length: trigger.tokenLength, scope: trigger.scope }
return trigger?.kind === '@' && trigger.scope && !trigger.value ? trigger.tokenLength : 0
}
export function detectTrigger(textBefore: string): TriggerState | null {

View file

@ -23,6 +23,7 @@ import {
releaseActiveComposer
} from '@/app/chat/composer/focus'
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
import { rebuildAroundCaret } from '@/app/chat/composer/hooks/use-composer-trigger'
import { useComposerUndo } from '@/app/chat/composer/hooks/use-composer-undo'
import { useEmojiCompletions } from '@/app/chat/composer/hooks/use-emoji-completions'
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
@ -42,7 +43,7 @@ import {
replaceBeforeCaret,
RICH_INPUT_SLOT
} from '@/app/chat/composer/rich-editor'
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
import { detectTrigger, openDirectiveScope, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import { isRedoShortcut, isUndoShortcut } from '@/app/chat/composer/undo-history'
import { chipTypedUrlOnSpace, linkifyUrls } from '@/app/chat/composer/url-refs'
@ -352,9 +353,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
: fragment.append(document.createTextNode(text))
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
const current = composerPlainText(editor)
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
placeCaretEnd(editor)
rebuildAroundCaret(editor, trigger.tokenLength, text)
}
finish()
@ -555,8 +554,14 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
rememberInitialDraft()
recordUndoPoint()
// Links land as `@url:` chips, same as the main composer.
insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText)))
// Links land as `@url:` chips, same as the main composer — including
// consuming an open `@url:` scope rather than stacking a second directive
// in front of the chip.
insertComposerContentsAtCaret(
event.currentTarget,
pathifyRefs(linkifyUrls(pastedText)),
openDirectiveScope(event.currentTarget)
)
syncDraftFromEditor(event.currentTarget)
}