diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 19b71ed6543..2a957564066 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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 && ( diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 9958c3a4b09..60da3fb9721 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -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() diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index c0378d09d47..e8828fe890b 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -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 { 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 fe7b917afbe..16ec52cf0d2 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 @@ -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 = ({ 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 = ({ 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) }