Prechádzať zdrojové kódy

fix(web): carry the composer edit range from the pre-edit selection

Ordinary typing supplied no edit range, so the machine recovered one by
scanning the two drafts for a common prefix and suffix. That reading is
ambiguous whenever the inserted text repeats what it lands against, and the
greedy scan always slides the edit as late as the characters allow. A
reference renders as '@' plus its label, so typing '@' immediately before one
reads as an insertion inside the reference; reconcile then applies its
intersect rule and drops the occurrence. Submission takes the occurrence-free
path and sends the human-facing label instead of the owner's model form, past
the serialization guard that only fires when an occurrence survives.

InputBar now records the textarea's selection during beforeinput and passes
the resulting range to setDraft, which the machine already prefers over its
own scan. A record that cannot describe the reported change yields no range
and the scan still runs.
Yichen Jiang 1 mesiac pred
rodič
commit
795d36d68f

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.md
+2026-08-20-composer-edit-range-from-selection.md: 0edca61dfd078dd813daf37d56e7b280e904fa9e
+2026-08-20-composer-edit-range-from-selection.zh.md: 8b250ad853ac3585db12160bf72f4ca9f8bd815c

+ 41 - 0
.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.md

@@ -0,0 +1,41 @@
+# Agent Note: Composer edits carry the range they applied to
+
+Status: implemented
+
+English | [中文](2026-08-20-composer-edit-range-from-selection.zh.md)
+
+## Problem
+
+The input machine keeps its reference occurrences aligned by reconciling them against one edit range: entries before the range shift, entries after it hold, and an entry the range intersects loses its structured identity and stays behind as ordinary draft text. That last rule is the deliberate meaning of editing inside a reference.
+
+Ordinary typing supplied no range. A controlled textarea's change event carries only the resulting string, so the machine recovered the range by scanning the two drafts for a common prefix and suffix. That recovery is ambiguous whenever the inserted text repeats the text it lands against, and the greedy scan always resolves the ambiguity the same way: it slides the edit as late as the characters allow.
+
+A reference renders as `@` followed by its label, so typing `@` immediately before one produces exactly that collision. The user inserts at the reference's own offset; the scan reports an insertion one character later, inside the reference; reconcile applies the intersect rule and drops the occurrence. Deleting a character in front of such a reference slides the same way.
+
+The draft then still reads correctly to the eye while carrying no structured reference, and submission takes the occurrence-free path that sends the draft verbatim. The host receives the human-facing label instead of the owner's model form and resolves nothing. The serialization guard that exists to prevent exactly this downgrade never runs, because it only fires when an occurrence survives to be serialized.
+
+This became reachable when references [became literal inline text](../feature/2026-07-27-web-file-and-session-references.md). A reference previously occupied one `U+FFFC`, a character no keystroke produces, so the scan had nothing to collide with.
+
+## Decision
+
+`InputBar` records the textarea's selection during `beforeinput` and passes the resulting range to `setDraft`, which the machine already accepts and prefers over its own scan. `beforeinput` fires while the element still holds the selection the edit is about to replace, and that selection is the range; the inserted length is whatever the draft grew by once the replaced range is accounted for. A textarea exposes this no other way — `getTargetRanges()` is empty for form controls.
+
+The record is consumed once and cleared. A record whose draft length disagrees with the draft the change reports, an inverted selection, a selection past the draft, or a negative inserted length all yield no range, and the machine falls back to its scan. Paste and the boundary Backspace and Delete gestures already supplied their own ranges and are untouched.
+
+## Testing
+
+Component tests type the trigger character immediately before a reference and delete the character in front of one, asserting in both cases that the occurrence survives at the shifted offset. Both fail against the scan-recovered range.
+
+## Alternatives considered
+
+**Disambiguate the scan with the post-edit caret.** The caret pins which of the textually equivalent readings happened, and the change event already carries it. Rejected because it keeps a reconstruction where an exact fact is available, and it cannot separate the deleted and inserted halves of a replaced selection at all.
+
+**Give references a leading marker no keystroke produces.** A private-use character in place of the literal `@` removes the collision at the representation level, and the reject list for pasted text already names that range. Rejected because it re-adds a character that every serialization, selection, and accessibility path has to strip, to buy what an exact range buys directly, and it would leave ordinary typing reconstructing its range for every other reason.
+
+**Widen reconcile to keep an occurrence when the range only touches its edge.** Rejected because the misattributed offset lands strictly inside the reference, not on its boundary, so the rule change would not reach this defect while making the intersect rule vaguer.
+
+## Consequences
+
+Every draft mutation the composer performs now names the range it applied to, so occurrence offsets follow the edit that actually happened rather than one consistent with the resulting characters. The machine keeps its scan for callers that cannot supply a range.
+
+The composer now depends on `beforeinput` preceding each value change. Any future edit path that mutates the value without it silently returns to the scan rather than breaking, which keeps the failure mode the old behavior instead of a wrong range.

+ 41 - 0
.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md

@@ -0,0 +1,41 @@
+# Agent Note: 输入框的编辑自带它所作用的范围
+
+Status: implemented
+
+[English](2026-08-20-composer-edit-range-from-selection.md) | 中文
+
+## 问题
+
+输入机器靠一个编辑范围来对齐引用 occurrence:范围之前的条目右移,之后的条目不动,被范围相交的条目失去结构化身份、以普通草稿文本留在原地。最后一条是"在引用内部编辑"的刻意含义。
+
+普通打字不提供范围。受控 textarea 的 change 事件只带结果字符串,于是机器靠扫描两份草稿的公共前后缀来还原范围。只要插入的文本与它落点处的文本重复,这个还原就是有歧义的,而贪心扫描永远以同一种方式消解歧义:把编辑尽量往后滑。
+
+引用渲染为 `@` 加标签,所以紧挨着引用前面打一个 `@` 恰好构成这种撞车。用户在引用自身的偏移处插入;扫描报告的插入位置晚一个字符,落在引用内部;reconcile 执行相交规则,删掉这个 occurrence。删除这类引用前面的一个字符会以同样方式滑动。
+
+此时草稿看上去仍然正确,却已不携带任何结构化引用,提交走的是无 occurrence 的那条路,把草稿原样发出。宿主收到的是给人看的标签而不是所有者的模型形式,什么也解析不出来。专为阻止这种降级而存在的序列化守卫从不运行,因为它只在还有 occurrence 需要序列化时才触发。
+
+这条路径是在引用[变成字面内联文本](../feature/2026-07-27-web-file-and-session-references.md)之后才可达的。此前一个引用占据一个 `U+FFFC`——任何按键都打不出的字符,扫描无从撞车。
+
+## 决策
+
+`InputBar` 在 `beforeinput` 期间记录 textarea 的 selection,并把由此得到的范围传给 `setDraft`;机器本就接受该参数,并优先于自身的扫描。`beforeinput` 触发时元素仍持有即将被替换的那段 selection,这段 selection 就是范围;插入长度则是扣除被替换范围后草稿增长的量。textarea 没有别的途径暴露这个信息——`getTargetRanges()` 对表单控件返回空。
+
+记录只消费一次即清空。记录的草稿长度与 change 报告的草稿不符、selection 反向、selection 越过草稿末尾、推导出的插入长度为负,这几种情况都不产出范围,机器回落到扫描。粘贴以及边界处的 Backspace 与 Delete 手势本就自带范围,未受影响。
+
+## 测试
+
+组件测试在引用正前方输入触发字符,以及删除引用前面的一个字符,两种情况都断言 occurrence 在右移后的偏移处存活。两者在扫描还原的范围下都失败。
+
+## 备选方案
+
+**用编辑后的光标位置消解扫描的歧义。** 光标能钉住若干文本等价读法中真正发生的那一种,而 change 事件本就携带它。拒绝:在已有精确事实可用时仍保留一次重建,而且它根本无法把"替换一段选区"拆成删除与插入两半。
+
+**给引用一个按键打不出的前导标记。** 用私有区字符取代字面 `@`,在表示层消除撞车,而粘贴文本的剔除名单本就点名了那个区段。拒绝:为了换取一个精确范围本就能直接换到的东西,却重新引入一个必须在所有序列化、选择和无障碍路径上剥离的字符,而且普通打字仍会因为其他原因继续重建自己的范围。
+
+**放宽 reconcile,让范围只触及边缘时保留 occurrence。** 拒绝:误判出的偏移严格落在引用内部而非边界上,规则放宽够不到这个缺陷,只会让相交规则本身更含糊。
+
+## 后果
+
+输入框执行的每一次草稿变更现在都指明它作用的范围,因此 occurrence 偏移跟随的是真实发生的编辑,而不是某个与结果字符一致的编辑。机器为无法提供范围的调用方保留扫描。
+
+输入框由此依赖 `beforeinput` 先于每次取值变更发生。未来任何绕过它改写取值的编辑路径会静默回落到扫描而不是出错,也就是说失效模式退回旧行为,而不是一个错误的范围。

+ 50 - 1
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -24,6 +24,7 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ComposerBarProps } from '../contract/slots.ts'
 import { deriveDecorations } from '../input/decorations.ts'
 import type { DraftDecorations } from '../input/decorations.ts'
+import type { EditRange } from '../input/contract.ts'
 import { attachmentErrorText, imageSizeText } from '../image-labels.ts'
 import { ReferenceIcon } from '../reference/ReferenceIcon.tsx'
 import { ContextMeter } from './ContextMeter.tsx'
@@ -34,6 +35,31 @@ import css from './InputBar.module.css'
 /** Decoration product of the no-session state (no machine, empty draft). */
 const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
 
+/** The selection a `beforeinput` recorded, with the draft length it applied to. */
+interface PendingEdit {
+  readonly start: number
+  readonly end: number
+  readonly draftLength: number
+}
+
+/**
+ * Resolve one edit's range from the selection recorded before it applied.
+ * The recorded selection IS the replaced range, so the inserted length is
+ * whatever the draft grew by once that range is accounted for.
+ * @param pending - selection recorded at `beforeinput`, null when none was seen.
+ * @param prevLength - length of the draft the edit applied to.
+ * @param nextLength - length of the resulting draft.
+ * @returns the exact range, or undefined when the record cannot describe this
+ * edit and the machine's diff scan has to recover it.
+ */
+function editRangeOf(pending: PendingEdit | null, prevLength: number, nextLength: number): EditRange | undefined {
+  if (pending === null || pending.draftLength !== prevLength) return undefined
+  const { start, end } = pending
+  if (start > end || end > prevLength) return undefined
+  const insertedLength = nextLength - prevLength + (end - start)
+  return insertedLength < 0 ? undefined : { start, end, insertedLength }
+}
+
 export type InputBarProps = ComposerBarProps
 
 export function InputBar({
@@ -277,6 +303,27 @@ export function InputBar({
   })
   /* oxlint-enable typescript/no-unnecessary-condition */
 
+  // The machine's occurrence math needs the edit's real range, and a controlled
+  // textarea's change event carries only the resulting string. `beforeinput`
+  // fires while the element still holds the pre-edit selection, which is
+  // exactly the range about to be replaced; a textarea exposes it no other way
+  // (`getTargetRanges()` is empty for form controls). Recovering the range by
+  // diffing the two drafts instead is ambiguous whenever the typed text repeats
+  // what it lands against — typing the trigger char before a reference reads as
+  // landing inside that reference, which drops it. One lifetime, like the wheel
+  // listener above: the textarea is never unmounted.
+  const pendingEditRef = useRef<PendingEdit | null>(null)
+  useEffect(() => {
+    const el = inputRef.current
+    if (el === null) return
+    const onBeforeInput = (): void => {
+      const { start, end } = selectionOf(el)
+      pendingEditRef.current = { start, end, draftLength: el.value.length }
+    }
+    el.addEventListener('beforeinput', onBeforeInput)
+    return () => { el.removeEventListener('beforeinput', onBeforeInput) }
+  }, [])
+
   const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
     if (workspaceTrigger) {
       if (e.key === 'Enter' || e.key === ' ') {
@@ -371,8 +418,10 @@ export function InputBar({
     if (keyboard === undefined || locked) return // disabled/read-only states cannot edit the draft
     if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
     const next = e.target.value
+    const pending = pendingEditRef.current
+    pendingEditRef.current = null
     safariNativeShrinkRef.current = safari && next.length < draft.length
-    keyboard.setDraft(next)
+    keyboard.setDraft(next, editRangeOf(pending, draft.length, next.length))
     // selectionStart is number|null in lib.dom; the type-aware lint program narrows it.
     // oxlint-disable-next-line typescript/no-unnecessary-condition
     keyboard.track(next, e.target.selectionStart ?? next.length)

+ 49 - 0
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -214,6 +214,15 @@ function bench(over?: BenchOptions) {
   }
 }
 
+/**
+ * Dispatch the native `beforeinput` the composer reads the pre-edit selection
+ * from. The DOM event carries no range for a textarea (`getTargetRanges()` is
+ * empty there), so the element's own selection at dispatch time is the signal.
+ */
+function beforeInput(el: HTMLTextAreaElement): void {
+  el.dispatchEvent(new Event('beforeinput', { bubbles: true, cancelable: true }))
+}
+
 function attachmentOwner(slotCalls: readonly { key: string; owner: unknown }[]): ComposerAttachmentsOwnerProps {
   for (let i = slotCalls.length - 1; i >= 0; i -= 1) {
     const call = slotCalls[i]
@@ -1226,6 +1235,46 @@ describe('decorations', () => {
     expect(forwardDelete.shell.snapshot).toMatchObject({ draft: '前  后', occurrences: [] })
   })
 
+  it('typing the trigger char immediately before a reference keeps it structured', () => {
+    const { shell, textarea } = bench()
+    act(() => {
+      shell.setDraft('@w1')
+      shell.insertReference({
+        source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
+      }, { start: 0, end: 3, draftRev: shell.snapshot.draftRev })
+    })
+    expect(shell.snapshot.draft).toBe('@会话一 ')
+    // The inserted char equals the reference's own leading trigger, so the two
+    // drafts alone cannot say whether it landed before or after that trigger.
+    textarea.setSelectionRange(0, 0)
+    act(() => {
+      beforeInput(textarea)
+      fireEvent.change(textarea, { target: { value: '@@会话一 ' } })
+    })
+    expect(shell.snapshot.draft).toBe('@@会话一 ')
+    expect(shell.snapshot.occurrences).toHaveLength(1)
+    expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 1, length: 4 })
+  })
+
+  it('deleting the char before a reference keeps it structured when both are the trigger', () => {
+    const { shell, textarea } = bench()
+    act(() => {
+      shell.setDraft('@@w1')
+      shell.insertReference({
+        source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
+      }, { start: 1, end: 4, draftRev: shell.snapshot.draftRev })
+    })
+    expect(shell.snapshot.draft).toBe('@@会话一 ')
+    textarea.setSelectionRange(0, 1)
+    act(() => {
+      beforeInput(textarea)
+      fireEvent.change(textarea, { target: { value: '@会话一 ' } })
+    })
+    expect(shell.snapshot.draft).toBe('@会话一 ')
+    expect(shell.snapshot.occurrences).toHaveLength(1)
+    expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 0, length: 4 })
+  })
+
   it('copy and cut expand a partial reference selection to its structured range', () => {
     const { shell, textarea } = bench()
     act(() => {