Răsfoiți Sursa

fix(web): derive the caret-delete range from what the draft lost

A caret Backspace or Delete replaces no selection, so `beforeinput` reports
the bare caret and the previous derivation produced a negative inserted
length and fell back to the ambiguous scan. The delete half of the defect
survived, and the component test missed it by pre-expanding the selection to
the span the engines never report.

The range for a caret delete now comes from the direction `inputType` names
and the number of characters the draft actually lost, measured rather than
assumed to be one, so a grapheme, word, or line deletion sizes correctly.
Only the insert and delete families are recorded; a history replay reports
wherever the caret sits and would name a wrong span while passing every check.

Component tests cover the caret Backspace, Delete, and word-delete gestures,
and an assembled browser scenario drives them as real key presses, which is
the only place an engine's reported range is observable.
Yichen Jiang 3 săptămâni în urmă
părinte
comite
b2c9c3ce37

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

@@ -2,5 +2,5 @@
 # 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
+2026-08-20-composer-edit-range-from-selection.md: f836fe4de297746d35f7343cd215e8522a0116d0
+2026-08-20-composer-edit-range-from-selection.zh.md: 56f7044ee7f60b72d32226451d663b3f18371c69

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

@@ -18,13 +18,19 @@ This became reachable when references [became literal inline text](../feature/20
 
 ## 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.
+`InputBar` records the textarea's selection and `inputType` during `beforeinput` and passes the resulting range to `setDraft`, which the machine already accepts and prefers over its own scan. A textarea exposes the edit 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.
+An edit that replaces a selection reports that selection, and it is the range outright; the inserted length is whatever the draft grew by once the replaced range is accounted for. A caret delete replaces nothing and reports the bare caret, so its range comes from the direction `inputType` names and the number of characters the draft actually lost. The count is measured rather than assumed to be one, because a single caret gesture removes a multi-unit grapheme, a word, or a line just as readily. Chromium, WebKit, and Firefox all report the collapsed caret for `deleteContentBackward` and `deleteContentForward`, and all three derive the same range from it.
+
+Only the `insert` and `delete` families are recorded. A history replay reports wherever the caret happens to sit, which would survive every check while naming the wrong span; ignoring it leaves that path on the scan.
+
+The record is consumed once and cleared. A record whose draft length disagrees with the draft the change reports, a selection past the draft, a shrinking edit over a selection the caret cannot explain, or an undirected delete 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.
+Component tests cover the trigger character typed in front of a reference, a caret Backspace, a caret Delete, a caret word delete, and a delete over a selection, asserting in each case that the occurrence survives at the shifted offset. The caret cases fail against the scan-recovered range, and the word case fails against a fixed one-character step.
+
+An assembled browser scenario drives the same gestures as real key presses against the shipped composition, which is the only place the range an engine reports for them can be observed; its golden projects the backdrop's segments, since the decoration layer is aria-hidden and the accessibility tree cannot see the chip. A real composition driven through the browser's IME path reports the composing segment as the selection at every intermediate state, and the reference survives each one.
 
 ## Alternatives considered
 
@@ -36,6 +42,6 @@ Component tests type the trigger character immediately before a reference and de
 
 ## 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.
+Every native textarea edit that reaches `onChange` now names the range it applied to, so occurrence offsets follow the edit that actually happened rather than one merely consistent with the resulting characters. Draft writes that originate in the facade rather than the DOM — `insertText` and the command-token splices among them — still carry no range and keep the scan, as do the fallbacks above.
 
-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.
+The composer now depends on `beforeinput` preceding each value change, and on `inputType` naming the direction of a caret delete. Any future edit path that mutates the value without either silently returns to the scan rather than breaking, which keeps the failure mode the old behavior instead of a wrong range.

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

@@ -18,13 +18,19 @@ Status: implemented
 
 ## 决策
 
-`InputBar` 在 `beforeinput` 期间记录 textarea 的 selection,并把由此得到的范围传给 `setDraft`;机器本就接受该参数,并优先于自身的扫描。`beforeinput` 触发时元素仍持有即将被替换的那段 selection,这段 selection 就是范围;插入长度则是扣除被替换范围后草稿增长的量。textarea 没有别的途径暴露这个信息——`getTargetRanges()` 对表单控件返回空。
+`InputBar` 在 `beforeinput` 期间记录 textarea 的 selection 与 `inputType`,并把由此得到的范围传给 `setDraft`;机器本就接受该参数,并优先于自身的扫描。textarea 没有别的途径暴露这次编辑——`getTargetRanges()` 对表单控件返回空。
 
-记录只消费一次即清空。记录的草稿长度与 change 报告的草稿不符、selection 反向、selection 越过草稿末尾、推导出的插入长度为负,这几种情况都不产出范围,机器回落到扫描。粘贴以及边界处的 Backspace 与 Delete 手势本就自带范围,未受影响。
+替换一段选区的编辑会报告那段 selection,它直接就是范围;插入长度是扣除被替换范围后草稿增长的量。折叠光标的删除不替换任何东西、只报告光标本身,因此它的范围来自 `inputType` 指明的方向加上草稿实际减少的字符数。这个数量是**测量**得到而非假定为 1,因为一次折叠手势同样可能删掉一个多码元字形、一个词或一整行。Chromium、WebKit 与 Firefox 对 `deleteContentBackward` 和 `deleteContentForward` 都报告折叠光标,三者由此推导出相同的范围。
+
+只有 `insert` 与 `delete` 两族会被记录。历史回放报告的是光标当时碰巧所在的位置,那会通过全部校验却指向错误区间;忽略它即让该路径留在扫描上。
+
+记录只消费一次即清空。记录的草稿长度与 change 报告的草稿不符、selection 越过草稿末尾、选区之上出现光标无法解释的收缩、以及方向不明的删除,这几种情况都不产出范围,机器回落到扫描。粘贴以及边界处的 Backspace 与 Delete 手势本就自带范围,未受影响。
 
 ## 测试
 
-组件测试在引用正前方输入触发字符,以及删除引用前面的一个字符,两种情况都断言 occurrence 在右移后的偏移处存活。两者在扫描还原的范围下都失败。
+组件测试覆盖在引用正前方输入触发字符、折叠 Backspace、折叠 Delete、折叠整词删除,以及选区替换式删除,每种都断言 occurrence 在右移后的偏移处存活。折叠类用例在扫描还原的范围下失败,整词用例在固定一字符步长的实现下失败。
+
+组装层浏览器场景以真实按键对已发布组合驱动同样的手势——那是唯一能观察到引擎为这些手势报告何种范围的地方;其 golden 投影的是 backdrop 的分段,因为装饰层是 aria-hidden 的,无障碍树看不到 chip。经浏览器 IME 路径驱动的真实组合在每个中间态都把组合段报告为 selection,引用在每一步都存活。
 
 ## 备选方案
 
@@ -36,6 +42,6 @@ Status: implemented
 
 ## 后果
 
-输入框执行的每一次草稿变更现在都指明它作用的范围,因此 occurrence 偏移跟随的是真实发生的编辑,而不是某个与结果字符一致的编辑。机器为无法提供范围的调用方保留扫描
+每一次经 `onChange` 到达的原生 textarea 编辑现在都指明它作用的范围,因此 occurrence 偏移跟随的是真实发生的编辑,而不是某个仅仅与结果字符一致的编辑。源自 facade 而非 DOM 的草稿写入——`insertText` 和命令 token 的替换等——仍不带范围并保留扫描,上述各类回落同样如此
 
-输入框由此依赖 `beforeinput` 先于每次取值变更发生。未来任何绕过它改写取值的编辑路径会静默回落到扫描而不是出错,也就是说失效模式退回旧行为,而不是一个错误的范围。
+输入框由此依赖 `beforeinput` 先于每次取值变更发生,并依赖 `inputType` 指明折叠删除的方向。未来任何绕过其中之一改写取值的编辑路径会静默回落到扫描而不是出错,也就是说失效模式退回旧行为,而不是一个错误的范围。

+ 61 - 1
apps/web/tests/reference-composer.e2e.ts

@@ -30,6 +30,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/reference-composer', import.meta.url))
 const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
 const ORDER_EXPECTED = join(SNAPSHOT_DIR, 'order.expected.md')
+const CARET_EXPECTED = join(SNAPSHOT_DIR, 'caret-edits.expected.md')
 const MODE = webSnapshotMode()
 const SOURCE_SESSION_ID = 'reference-source-session'
 const TARGET_SESSION_ID = 'reference-order-target-session'
@@ -110,6 +111,29 @@ function targetSessionFixture(): string {
   ].join('\n')
 }
 
+/**
+ * Project the composer backdrop into one stable block: the draft it paints and
+ * each segment in draft order, with the decoration a segment carries.
+ * @param page - the assembled app page.
+ * @returns the golden text for the composer's decoration layer.
+ */
+async function composerSegments(page: Page): Promise<string> {
+  return page.evaluate(() => {
+    const backdrop = document.querySelector('[data-input-backdrop]')
+    const textarea = document.querySelector('textarea')
+    if (backdrop === null || textarea === null) return 'composer absent'
+    const rows = [...backdrop.childNodes].map((node) => {
+      if (!(node instanceof HTMLElement)) return `plain    ${JSON.stringify(node.textContent ?? '')}`
+      const decoration = node.dataset['decoration'] ?? 'unknown'
+      const appearance = node.dataset['referenceAppearance']
+      const icons = node.querySelectorAll('svg').length
+      return `${decoration.padEnd(8)} ${JSON.stringify(node.textContent ?? '')}`
+        + `${appearance === undefined ? '' : ` appearance=${appearance}`} icons=${icons}`
+    })
+    return [`draft ${JSON.stringify(textarea.value)}`, ...rows].join('\n')
+  })
+}
+
 describe.skipIf(MODE === 'record')('web e2e: file and session references through the real host', () => {
   let scaffold: WebScaffold
   let browser: Browser
@@ -168,6 +192,42 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
     expect(tripwire.warnings).toEqual([])
   })
 
+  it('keeps a structured reference across caret edits in front of it', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-caret-edits'))
+    const input = page.locator('textarea').first()
+    const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
+    const sessionReference = page.locator('[data-reference-appearance="session"]')
+
+    await input.fill('@Research')
+    await menu.getByRole('option', { name: /Session \u00b7 Research notes/ }).click()
+    await expect.poll(() => input.inputValue()).toBe('@Research notes ')
+
+    // Only the caret is placed programmatically; both edits below are real key
+    // presses, which is the whole point — the range a textarea reports for them
+    // is what the composer has to read, and no synthetic event can stand in.
+    await input.evaluate((el: HTMLTextAreaElement) => { el.focus(); el.setSelectionRange(0, 0) })
+    await input.press('@')
+    await expect.poll(() => input.inputValue()).toBe('@@Research notes ')
+    await expect.poll(() => sessionReference.count()).toBe(1)
+    await expect.poll(() => sessionReference.textContent()).toBe('@Research notes')
+    await expect.poll(() => sessionReference.locator('svg').count()).toBe(1)
+
+    // The decoration layer is aria-hidden, so the accessibility tree cannot see
+    // the chip; the golden projects the backdrop's own segments instead, which
+    // is where the surviving reference is observable at all.
+    await compareOrRefreshGolden(CARET_EXPECTED, await composerSegments(page), MODE)
+
+    // The caret sits after the typed trigger; this Backspace removes it with a
+    // collapsed selection, the gesture the reported range cannot describe alone.
+    await input.press('Backspace')
+    await expect.poll(() => input.inputValue()).toBe('@Research notes ')
+    await expect.poll(() => sessionReference.count()).toBe(1)
+    await expect.poll(() => sessionReference.textContent()).toBe('@Research notes')
+
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+
   it('renders the durable direct-message then recall order', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-order'))
     const group = page.getByRole('treeitem', { name: /Ungrouped/ })
@@ -184,6 +244,6 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
     expect(snapshot.indexOf('Research notes what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research notes'))
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md', 'order.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['caret-edits.expected.md', 'menu.expected.md', 'order.expected.md'])
   })
 })

+ 4 - 0
apps/web/tests/snapshots/reference-composer/caret-edits.expected.md

@@ -0,0 +1,4 @@
+draft "@@Research notes "
+plain    "@"
+chip     "@Research notes" appearance=session icons=1
+plain    " "

+ 30 - 9
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -35,18 +35,21 @@ 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. */
+/** The selection and edit family a `beforeinput` recorded, with the draft length it applied to. */
 interface PendingEdit {
   readonly start: number
   readonly end: number
   readonly draftLength: number
+  readonly inputType: string
 }
 
 /**
- * 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.
+ * Resolve one edit's range from the record taken before it applied.
+ * A selection the edit replaces is the range outright. A caret delete replaces
+ * nothing and reports the bare caret, so the removed span is whatever the draft
+ * lost, on the side `inputType` names — measured, because one caret gesture can
+ * remove a multi-unit grapheme, a word, or a line.
+ * @param pending - record taken 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
@@ -54,10 +57,21 @@ interface PendingEdit {
  */
 function editRangeOf(pending: PendingEdit | null, prevLength: number, nextLength: number): EditRange | undefined {
   if (pending === null || pending.draftLength !== prevLength) return undefined
-  const { start, end } = pending
+  const { start, end, inputType } = pending
+  // A DOM selection cannot invert; the check keeps that a precondition of the
+  // math below rather than an assumption about the element.
   if (start > end || end > prevLength) return undefined
   const insertedLength = nextLength - prevLength + (end - start)
-  return insertedLength < 0 ? undefined : { start, end, insertedLength }
+  if (insertedLength >= 0) return { start, end, insertedLength }
+  if (start !== end) return undefined
+  const removed = prevLength - nextLength
+  if (inputType.endsWith('Backward')) {
+    return removed <= start ? { start: start - removed, end: start, insertedLength: 0 } : undefined
+  }
+  if (inputType.endsWith('Forward')) {
+    return start + removed <= prevLength ? { start, end: start + removed, insertedLength: 0 } : undefined
+  }
+  return undefined
 }
 
 export type InputBarProps = ComposerBarProps
@@ -316,9 +330,16 @@ export function InputBar({
   useEffect(() => {
     const el = inputRef.current
     if (el === null) return
-    const onBeforeInput = (): void => {
+    const onBeforeInput = (e: InputEvent): void => {
+      // Only the families whose reported selection describes the edit. A
+      // history replay reports wherever the caret happens to sit, which would
+      // survive every check in editRangeOf while naming the wrong span.
+      if (!e.inputType.startsWith('insert') && !e.inputType.startsWith('delete')) {
+        pendingEditRef.current = null
+        return
+      }
       const { start, end } = selectionOf(el)
-      pendingEditRef.current = { start, end, draftLength: el.value.length }
+      pendingEditRef.current = { start, end, draftLength: el.value.length, inputType: e.inputType }
     }
     el.addEventListener('beforeinput', onBeforeInput)
     return () => { el.removeEventListener('beforeinput', onBeforeInput) }

+ 66 - 5
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -217,10 +217,12 @@ 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.
+ * empty there), so the element's own selection plus `inputType` is the signal.
+ * The selection each gesture leaves is the engine-observed one: a delete over a
+ * selection reports that selection, a caret delete reports the bare caret.
  */
-function beforeInput(el: HTMLTextAreaElement): void {
-  el.dispatchEvent(new Event('beforeinput', { bubbles: true, cancelable: true }))
+function beforeInput(el: HTMLTextAreaElement, inputType = 'insertText'): void {
+  el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType }))
 }
 
 function attachmentOwner(slotCalls: readonly { key: string; owner: unknown }[]): ComposerAttachmentsOwnerProps {
@@ -1256,7 +1258,7 @@ describe('decorations', () => {
     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', () => {
+  it('a selection-replacing delete before a reference keeps it structured', () => {
     const { shell, textarea } = bench()
     act(() => {
       shell.setDraft('@@w1')
@@ -1267,7 +1269,66 @@ describe('decorations', () => {
     expect(shell.snapshot.draft).toBe('@@会话一 ')
     textarea.setSelectionRange(0, 1)
     act(() => {
-      beforeInput(textarea)
+      beforeInput(textarea, 'deleteContentBackward')
+      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('a caret Backspace 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: 1, end: 4, draftRev: shell.snapshot.draftRev })
+    })
+    expect(shell.snapshot.draft).toBe('@@会话一 ')
+    // A caret delete reports the bare caret, never the character it removes.
+    textarea.setSelectionRange(1, 1)
+    act(() => {
+      beforeInput(textarea, 'deleteContentBackward')
+      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('a caret Delete 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: 1, end: 4, draftRev: shell.snapshot.draftRev })
+    })
+    textarea.setSelectionRange(0, 0)
+    act(() => {
+      beforeInput(textarea, 'deleteContentForward')
+      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('a caret word delete before a reference keeps it structured', () => {
+    const { shell, textarea } = bench()
+    act(() => {
+      shell.setDraft('word @w1')
+      shell.insertReference({
+        source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
+      }, { start: 5, end: 8, draftRev: shell.snapshot.draftRev })
+    })
+    expect(shell.snapshot.draft).toBe('word @会话一 ')
+    // One caret gesture can remove more than one character; the deleted span
+    // is whatever the draft lost, never a fixed step.
+    textarea.setSelectionRange(5, 5)
+    act(() => {
+      beforeInput(textarea, 'deleteWordBackward')
       fireEvent.change(textarea, { target: { value: '@会话一 ' } })
     })
     expect(shell.snapshot.draft).toBe('@会话一 ')