mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
4fb4d78989
commit
130cc0b859
4 changed files with 42 additions and 20 deletions
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue