Suggestions: stop re-nagging resolved edits + fix typographic anchoring

Two fixes for the "accept Petal's change, then it nags about the same
sentence moments later" report:

- replacePending now suppresses any freshly-generated suggestion whose
  original->replacement matches one the user already accepted or
  dismissed for that doc. The model has no memory between passes, so
  without this it re-proposes the identical edit on the next checkpoint.

- findRange anchored suggestions by exact string match, which missed
  whenever the model echoed an `original` with plain ASCII (straight
  quotes, --, ...) while the document held the Typography-converted
  glyphs (curly quotes, em-dash, single-char ellipsis). The miss meant
  no highlight AND a silent no-op on accept, which then fed the re-nag
  above. foldTypography canonicalizes those variants (with a source
  index map for length changes) so matching survives the mismatch.

Covered by a server-side regression test (accept+dismiss then re-check
returns nothing) and frontend unit tests for the fold and anchoring.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 10:35:57 -07:00
parent 3ac6382696
commit 6783ce7a51
4 changed files with 213 additions and 6 deletions

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { foldTypography, findRange } from './SuggestionHighlight'
import { Schema, type Node as PMNode } from '@tiptap/pm/model'
// A minimal doc/paragraph/text schema, enough to exercise findRange's textblock
// walk without pulling in the full editor.
const schema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
text: { group: 'inline' },
},
})
// Build a single-paragraph doc with the given plaintext.
const para = (text: string): PMNode => schema.node('doc', null, [schema.node('paragraph', null, text ? [schema.text(text)] : [])])
describe('foldTypography', () => {
it('folds curly quotes back to straight ASCII', () => {
expect(foldTypography('“Hes here,” she said').folded).toBe('"He\'s here," she said')
})
it('folds dashes and ellipsis, keeping a source-index map across length changes', () => {
const { folded, map } = foldTypography('wait—really...')
expect(folded).toBe('wait—really…')
// The em dash is already one glyph; the `...` collapsed 3 source chars to 1,
// so the trailing sentinel still points just past the last source char.
expect(map[map.length - 1]).toBe('wait—really...'.length)
})
it('treats `--` and `...` typed-but-unconverted as their canonical glyphs', () => {
expect(foldTypography('a--b...c').folded).toBe('a—b…c')
})
})
describe('findRange typographic anchoring', () => {
it('anchors a model echo with straight quotes onto curly-quote document text', () => {
const doc = para('She said “hello” to me')
const range = findRange(doc, '“hello”'.replace(/[“”]/g, '"')) // model echoed "hello"
expect(range).not.toBeNull()
// The matched span must cover the curly-quoted region in the real document.
expect(doc.textBetween(range!.from - 1, range!.to - 1)).toContain('hello')
})
it('anchors an ASCII `--` echo onto an em-dash in the document', () => {
const doc = para('I waited—forever')
const range = findRange(doc, 'waited--forever')
expect(range).not.toBeNull()
})
it('still returns null when the text genuinely is not present', () => {
expect(findRange(para('nothing to see'), 'absent phrase')).toBeNull()
})
})

View File

@@ -42,21 +42,87 @@ export function mapOffset(block: PMNode, blockPos: number, targetOffset: number)
return result
}
// foldTypography canonicalizes the typographic variants that the editor's
// Typography input rules introduce (curly quotes, em/en dashes, single-glyph
// ellipsis) and the unicode spaces a paste can carry. The model echoes a
// suggestion's `original` from the same text but often normalizes these back to
// plain ASCII — straight quotes, `--`, `...` — so an exact match would miss and
// the suggestion could be neither highlighted nor applied.
//
// It returns the folded string plus `map`, where map[k] is the source index at
// which folded character k begins. A multi-character source run (`...`, `--`)
// folds to one glyph, so lengths differ; map projects a match in folded space
// back onto real document offsets. map has a trailing sentinel (= source length)
// so map[start + folded.length] yields the offset just past the match.
export function foldTypography(text: string): { folded: string; map: number[] } {
let folded = ''
const map: number[] = []
let i = 0
while (i < text.length) {
// Multi-character ASCII sequences the Typography rules would have replaced.
if (text.startsWith('...', i)) {
folded += '…'
map.push(i)
i += 3
continue
}
if (text[i] === '-' && text[i + 1] === '-') {
folded += '—'
map.push(i)
i += 2
continue
}
folded += foldChar(text[i])
map.push(i)
i++
}
map.push(text.length)
return { folded, map }
}
// foldChar maps a single character onto its canonical form (length-preserving).
function foldChar(c: string): string {
switch (c) {
case '':
case '':
case '':
case '':
return "'"
case '“':
case '”':
case '„':
case '‟':
return '"'
case '': // en dash → em dash, the canonical form `--` also folds to
return '—'
case ' ': // non-breaking space
case '': // thin space
case '': // narrow no-break space
return ' '
default:
return c
}
}
// findRange locates the first occurrence of `search` within a single textblock
// and returns its ProseMirror range, or null if the string isn't present (the
// user may have edited or removed it since the checkpoint ran). Exported so the
// accept flow can resolve the same span to replace.
// user may have edited or removed it since the checkpoint ran). Matching is done
// in canonical typographic space (see foldTypography) so curly-quote/dash/ellipsis
// differences between the model's echo and the live document don't strand the
// anchor. Exported so the accept flow can resolve the same span to replace.
export function findRange(doc: PMNode, search: string): { from: number; to: number } | null {
if (!search) return null
const needle = foldTypography(search).folded
if (!needle) return null
let result: { from: number; to: number } | null = null
doc.descendants((node, pos) => {
if (result) return false
if (!node.isTextblock) return true // keep descending to the textblock
const idx = node.textContent.indexOf(search)
const { folded, map } = foldTypography(node.textContent)
const idx = folded.indexOf(needle)
if (idx >= 0) {
result = {
from: mapOffset(node, pos, idx),
to: mapOffset(node, pos, idx + search.length),
from: mapOffset(node, pos, map[idx]),
to: mapOffset(node, pos, map[idx + needle.length]),
}
}
return false // never descend into a textblock's inline children