fix(web): align rich composer DOM and Unicode boundaries (#1325)

* fix(web): align rich composer DOM offsets

* fix(web): truncate session titles safely

* fix(web): preserve session title size bounds

* fix(web): normalize empty wrapper offsets
This commit is contained in:
KorenKrita
2026-08-03 09:25:25 +08:00
committed by GitHub
parent fb3988a81f
commit 52dda73045
8 changed files with 469 additions and 67 deletions
@@ -1,8 +1,9 @@
import { afterEach, describe, expect, it } from 'vitest'
import { serializeComposerSegments } from '@/lib/composerSegments'
import { mirrorComposerSegments, serializeComposerSegments } from '@/lib/composerSegments'
import {
insertLineBreakAtCaret,
mirrorOffsetFromPoint,
mirrorSelectionFromPoints,
segmentsFromEditor,
} from './RichComposerInput'
@@ -152,10 +153,141 @@ describe('mirrorOffsetFromPoint', () => {
it('matches segmentsFromEditor length for br-separated lines', () => {
const root = document.createElement('div')
root.innerHTML = 'a<br>b'
const mirrorLen = serializeComposerSegments(segmentsFromEditor(root)).length
const mirrorLen = mirrorComposerSegments(segmentsFromEditor(root)).length
// caret after 'b' → end of second text node
const b = root.childNodes[2] as Text
expect(b.nodeType).toBe(Node.TEXT_NODE)
expect(mirrorOffsetFromPoint(root, b, b.textContent!.length)).toBe(mirrorLen)
})
it('keeps a direct br point at its explicit newline boundary', () => {
const root = document.createElement('div')
root.innerHTML = 'a<br>b'
const br = root.childNodes[1] as HTMLElement
expect(mirrorOffsetFromPoint(root, br, 0)).toBe(1)
expect(mirrorOffsetFromPoint(root, root, 2)).toBe(2)
})
it('includes implicit block newlines for text and root-anchored points', () => {
const root = document.createElement('div')
root.innerHTML = '<div>one</div><div>two</div>'
const two = root.lastChild!.firstChild as Text
expect(mirrorComposerSegments(segmentsFromEditor(root))).toBe('one\ntwo')
// The boundary before the second block is the visual start of "two",
// after the implicit newline rather than after "one".
expect(mirrorOffsetFromPoint(root, root, 1)).toBe(4)
expect(mirrorOffsetFromPoint(root, two, two.length)).toBe(7)
expect(mirrorOffsetFromPoint(root, root, root.childNodes.length)).toBe(7)
})
it('normalizes a text descendant of a session chip to atom boundaries', () => {
const root = document.createElement('div')
root.innerHTML =
'before <span contenteditable="false" data-composer-mention="session" data-session-id="aaa" data-session-title="Peer A"><strong>@Peer A</strong></span> after'
const chip = root.children[0] as HTMLElement
const visibleChipText = chip.firstChild!.firstChild as Text
const mirror = mirrorComposerSegments(segmentsFromEditor(root))
expect(mirror).toBe('before \uFFFC after')
expect(mirrorOffsetFromPoint(root, visibleChipText, 0)).toBe(7)
expect(mirrorOffsetFromPoint(root, visibleChipText, visibleChipText.length)).toBe(8)
expect(mirrorOffsetFromPoint(root, visibleChipText, visibleChipText.length)).not.toBe(mirror.length)
})
it('keeps forward and reverse cross-chip selections in one mirror coordinate space', () => {
const root = document.createElement('div')
root.innerHTML =
'one <span contenteditable="false" data-composer-mention="session" data-session-id="aaa" data-session-title="Peer A">@Peer A</span> two <span contenteditable="false" data-composer-mention="session" data-session-id="bbb" data-session-title="Peer B"><em>@Peer B</em></span> three'
const firstChipText = root.children[0]!.firstChild as Text
const secondChipText = root.children[1]!.firstChild!.firstChild as Text
const forward = mirrorSelectionFromPoints(
root,
firstChipText,
0,
secondChipText,
secondChipText.length
)
const reverse = mirrorSelectionFromPoints(
root,
secondChipText,
secondChipText.length,
firstChipText,
0
)
expect(forward).toEqual({ start: 4, end: 11 })
expect(reverse).toEqual(forward)
})
it('uses the same offsets across nested non-empty block wrappers', () => {
const root = document.createElement('div')
root.innerHTML = '<div>one<div><p>two</p><p>three</p></div></div><div>four</div>'
const one = root.firstChild!.firstChild as Text
const two = (root.firstChild!.childNodes[1] as HTMLElement).firstChild!.firstChild as Text
const four = root.lastChild!.firstChild as Text
const mirror = mirrorComposerSegments(segmentsFromEditor(root))
expect(mirror).toBe('one\ntwo\nthree\nfour')
expect(mirrorOffsetFromPoint(root, two, 0)).toBe(4)
expect(mirrorOffsetFromPoint(root, root, 1)).toBe(14)
expect(mirrorSelectionFromPoints(root, one, 1, four, four.length)).toEqual({
start: 1,
end: mirror.length,
})
})
it('keeps bare empty block wrappers out of the wire mirror', () => {
const root = document.createElement('div')
root.innerHTML = '<div>one</div><div></div><div>two</div>'
expect(mirrorComposerSegments(segmentsFromEditor(root))).toBe('one\ntwo')
// Both boundaries around a bare empty wrapper normalize to the next
// visible block start; the wrapper itself creates no new mirror slot.
expect(mirrorOffsetFromPoint(root, root, 1)).toBe(4)
expect(mirrorOffsetFromPoint(root, root, 2)).toBe(4)
const empty = root.children[1] as HTMLElement
expect(mirrorOffsetFromPoint(root, empty, 0)).toBe(4)
const trailingEmpty = document.createElement('div')
trailingEmpty.innerHTML = '<div>one</div><div></div>'
expect(mirrorComposerSegments(segmentsFromEditor(trailingEmpty))).toBe('one')
expect(mirrorOffsetFromPoint(trailingEmpty, trailingEmpty.children[1]!, 0)).toBe(3)
const leadingEmpty = document.createElement('div')
leadingEmpty.innerHTML = '<div></div><div>two</div>'
expect(mirrorOffsetFromPoint(leadingEmpty, leadingEmpty.children[0]!, 0)).toBe(0)
const nestedEmpty = document.createElement('div')
nestedEmpty.innerHTML = '<div>one<div></div><div>two</div></div>'
const nested = nestedEmpty.firstChild as HTMLElement
expect(mirrorOffsetFromPoint(nestedEmpty, nested.children[0]!, 0)).toBe(4)
const sectionWrapped = document.createElement('div')
sectionWrapped.innerHTML = '<div>one</div><section><div></div></section><div>two</div>'
const section = sectionWrapped.children[1] as HTMLElement
const sectionInner = section.firstElementChild!
expect(mirrorOffsetFromPoint(sectionWrapped, sectionWrapped, 1)).toBe(4)
expect(mirrorOffsetFromPoint(sectionWrapped, sectionWrapped, 2)).toBe(4)
expect(mirrorOffsetFromPoint(sectionWrapped, section, 0)).toBe(4)
expect(mirrorOffsetFromPoint(sectionWrapped, sectionInner, 0)).toBe(4)
const listWrapped = document.createElement('div')
listWrapped.innerHTML = '<div>one</div><ul><li></li></ul><div>two</div>'
const list = listWrapped.children[1] as HTMLElement
const listItem = list.firstElementChild!
expect(mirrorOffsetFromPoint(listWrapped, listWrapped, 1)).toBe(4)
expect(mirrorOffsetFromPoint(listWrapped, listWrapped, 2)).toBe(4)
expect(mirrorOffsetFromPoint(listWrapped, list, 0)).toBe(4)
expect(mirrorOffsetFromPoint(listWrapped, listItem, 0)).toBe(4)
const inline = document.createElement('div')
inline.innerHTML = '<span></span>one<span></span>two'
expect(mirrorComposerSegments(segmentsFromEditor(inline))).toBe('onetwo')
expect(mirrorOffsetFromPoint(inline, inline.children[0]!, 0)).toBe(0)
expect(mirrorOffsetFromPoint(inline, inline.children[1]!, 0)).toBe(3)
expect(mirrorOffsetFromPoint(inline, inline, 2)).toBe(3)
})
})
@@ -111,15 +111,37 @@ function stripCaretPad(text: string): string {
return text.replaceAll(CARET_PAD, '')
}
/** Exported for unit tests — maps contenteditable DOM → composer segments. */
export function segmentsFromEditor(root: HTMLElement): ComposerSegment[] {
type ComposerDomSpan = {
/** Mirror offset at the point inside this node where its visible content begins. */
start: number
/** Mirror offset after this node's visible content. */
end: number
/** The node caused some serialized mirror output, including a pending block break. */
producesOutput: boolean
}
type ComposerDomMapping = {
segments: ComposerSegment[]
mirrorLength: number
spans: Map<Node, ComposerDomSpan>
}
/**
* One DOM traversal is the source of truth for both serialized segments and
* DOM-point offsets. In particular, a block break is emitted immediately
* before the next visible node, so its offset belongs to that node's start.
*/
function mapComposerEditorDom(root: HTMLElement): ComposerDomMapping {
const segments: ComposerSegment[] = []
const spans = new Map<Node, ComposerDomSpan>()
let mirrorLength = 0
let pendingBlockBreak = false
const pushText = (text: string) => {
const cleaned = stripCaretPad(text)
if (!cleaned) return
segments.push({ type: 'text', text: cleaned })
mirrorLength += cleaned.length
}
const pushNewlineIfNeeded = () => {
@@ -133,9 +155,18 @@ export function segmentsFromEditor(root: HTMLElement): ComposerSegment[] {
}
const walk = (node: Node) => {
const before = mirrorLength
if (node.nodeType === Node.TEXT_NODE) {
pushNewlineIfNeeded()
const start = mirrorLength
pushText(node.textContent ?? '')
spans.set(node, {
start,
end: mirrorLength,
// An empty text node can still materialize a pending block
// newline, matching the existing serializer behavior.
producesOutput: mirrorLength > before,
})
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
@@ -144,6 +175,7 @@ export function segmentsFromEditor(root: HTMLElement): ComposerSegment[] {
// that would strip the id from the agent prompt on send.
if (el.dataset.composerMention === 'session') {
pushNewlineIfNeeded()
const start = mirrorLength
const id = el.dataset.sessionId?.trim()
if (id) {
segments.push({
@@ -151,13 +183,25 @@ export function segmentsFromEditor(root: HTMLElement): ComposerSegment[] {
id,
title: el.dataset.sessionTitle || id.slice(0, 8),
})
mirrorLength += 1
}
// Orphan chip (missing id): drop it rather than emit title-only.
spans.set(node, {
start,
end: mirrorLength,
producesOutput: mirrorLength > before,
})
return
}
if (el.tagName === 'BR') {
pushNewlineIfNeeded()
const start = mirrorLength
pushText('\n')
spans.set(node, {
start,
end: mirrorLength,
producesOutput: mirrorLength > before,
})
return
}
const isBlock = BLOCK_TAGS.has(el.tagName)
@@ -172,11 +216,36 @@ export function segmentsFromEditor(root: HTMLElement): ComposerSegment[] {
if (isBlock) {
pendingBlockBreak = true
}
const firstOutputChild = Array.from(el.childNodes)
.map((child) => spans.get(child))
.find((span) => span?.producesOutput)
spans.set(node, {
// A nested block's start is after its implicit preceding newline.
// This makes parent-anchored points at that block's start agree
// with the text point at the beginning of the block.
start: firstOutputChild?.start ?? before,
end: mirrorLength,
producesOutput: mirrorLength > before,
})
}
for (const child of Array.from(root.childNodes)) {
walk(child)
}
return coalesceComposerSegments(segments)
spans.set(root, {
start: 0,
end: mirrorLength,
producesOutput: mirrorLength > 0,
})
return {
segments: coalesceComposerSegments(segments),
mirrorLength,
spans,
}
}
/** Exported for unit tests — maps contenteditable DOM → composer segments. */
export function segmentsFromEditor(root: HTMLElement): ComposerSegment[] {
return mapComposerEditorDom(root).segments
}
/**
@@ -249,78 +318,147 @@ function renderSegmentsToEditor(
}
}
function containingSessionMention(root: HTMLElement, node: Node): HTMLElement | null {
let current: Node | null = node
while (current && current !== root) {
if (
current.nodeType === Node.ELEMENT_NODE
&& (current as HTMLElement).dataset.composerMention === 'session'
) {
return current as HTMLElement
}
current = current.parentNode
}
return null
}
/** True only for a DOM point at the structural start of a session atom. */
function isPointAtSessionMentionStart(
mention: HTMLElement,
container: Node,
offset: number
): boolean {
if (offset !== 0) return false
if (container === mention) return true
let current: Node | null = container
while (current && current !== mention) {
const parent: ParentNode | null = current.parentNode
if (!parent || parent.firstChild !== current) return false
current = parent
}
return current === mention
}
function mirrorOffsetWithinElement(
container: HTMLElement,
offset: number,
mapping: ComposerDomMapping
): number {
const span = mapping.spans.get(container)
if (!span) return mapping.mirrorLength
// Any zero-output element has no mirror position of its own. Normalize it
// to the boundary immediately after that element in its parent; this
// deliberately climbs only to strict parents, including empty non-block
// wrappers such as <section> or <ul> around an empty block.
if (!span.producesOutput) {
const parent = container.parentElement
if (parent) {
const index = Array.from(parent.childNodes).indexOf(container)
if (index >= 0) return mirrorOffsetWithinElement(parent, index + 1, mapping)
}
}
const children = Array.from(container.childNodes)
const childOffset = Math.max(0, Math.min(offset, children.length))
// A parent boundary before a later block belongs at that block's visible
// start, including its implicit newline. This is what root-anchored
// selection points need for <div>one</div><div>two</div>.
for (let i = childOffset; i < children.length; i++) {
const childSpan = mapping.spans.get(children[i]!)
if (childSpan?.producesOutput) return childSpan.start
}
return span.end
}
function mirrorOffsetFromMappedPoint(
root: HTMLElement,
endContainer: Node,
endOffset: number,
mapping: ComposerDomMapping
): number {
const mention = containingSessionMention(root, endContainer)
if (mention) {
const span = mapping.spans.get(mention)
if (!span) return mapping.mirrorLength
return isPointAtSessionMentionStart(mention, endContainer, endOffset)
? span.start
: span.end
}
if (endContainer.nodeType === Node.TEXT_NODE) {
const span = mapping.spans.get(endContainer)
if (!span) return mapping.mirrorLength
const raw = endContainer.textContent ?? ''
const rawOffset = Math.max(0, Math.min(endOffset, raw.length))
return Math.min(span.end, span.start + stripCaretPad(raw.slice(0, rawOffset)).length)
}
if (endContainer.nodeType === Node.ELEMENT_NODE) {
const element = endContainer as HTMLElement
// A Range point directly on <br> has no child boundary. Treat it as
// the position before the explicit newline, as browsers do for a
// parent boundary immediately before that node.
if (element.tagName === 'BR') {
return mapping.spans.get(element)?.start ?? mapping.mirrorLength
}
return mirrorOffsetWithinElement(element, endOffset, mapping)
}
return mapping.mirrorLength
}
/** Exported for unit tests — maps a DOM caret point into mirror-string offset. */
export function mirrorOffsetFromPoint(root: HTMLElement, endContainer: Node, endOffset: number): number {
let count = 0
const mapping = mapComposerEditorDom(root)
return mirrorOffsetFromMappedPoint(root, endContainer, endOffset, mapping)
}
const visit = (n: Node): boolean => {
if (n === endContainer && n.nodeType === Node.TEXT_NODE) {
const raw = n.textContent ?? ''
count += stripCaretPad(raw.slice(0, endOffset)).length
return true
}
if (n.nodeType === Node.TEXT_NODE) {
count += stripCaretPad(n.textContent ?? '').length
return false
}
if (n.nodeType !== Node.ELEMENT_NODE) return false
const el = n as HTMLElement
if (el.dataset.composerMention === 'session') {
if (n === endContainer) {
count += endOffset > 0 ? 1 : 0
return true
}
count += 1
return false
}
if (el.tagName === 'BR') {
if (n === endContainer) return true
count += 1
return false
}
if (n === endContainer) {
const children = Array.from(n.childNodes)
for (let i = 0; i < endOffset && i < children.length; i++) {
if (visit(children[i]!)) return true
}
return true
}
for (const child of Array.from(n.childNodes)) {
if (visit(child)) return true
}
return false
/**
* Maps either direction of a DOM selection into the ordered mirror range.
* Exported for unit tests; production selection reads use the same mapping.
*/
export function mirrorSelectionFromPoints(
root: HTMLElement,
startContainer: Node,
startOffset: number,
endContainer: Node,
endOffset: number
): ComposerSelection {
const mapping = mapComposerEditorDom(root)
if (!root.contains(startContainer) || !root.contains(endContainer)) {
return { start: mapping.mirrorLength, end: mapping.mirrorLength }
}
// Root-anchored ranges (caret before a leading chip, select-all) report
// endContainer === root; visit only children of that offset.
if (endContainer === root) {
const children = Array.from(root.childNodes)
for (let i = 0; i < endOffset && i < children.length; i++) {
visit(children[i]!)
}
return count
}
for (const child of Array.from(root.childNodes)) {
if (visit(child)) break
}
return count
const start = mirrorOffsetFromMappedPoint(root, startContainer, startOffset, mapping)
const end = mirrorOffsetFromMappedPoint(root, endContainer, endOffset, mapping)
return { start: Math.min(start, end), end: Math.max(start, end) }
}
function getMirrorSelection(root: HTMLElement): ComposerSelection {
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) {
const len = mirrorComposerSegments(segmentsFromEditor(root)).length
const len = mapComposerEditorDom(root).mirrorLength
return { start: len, end: len }
}
const range = sel.getRangeAt(0)
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) {
const len = mirrorComposerSegments(segmentsFromEditor(root)).length
return { start: len, end: len }
}
const start = mirrorOffsetFromPoint(root, range.startContainer, range.startOffset)
const end = mirrorOffsetFromPoint(root, range.endContainer, range.endOffset)
return { start: Math.min(start, end), end: Math.max(start, end) }
return mirrorSelectionFromPoints(
root,
range.startContainer,
range.startOffset,
range.endContainer,
range.endOffset
)
}
function setMirrorSelection(root: HTMLElement, selection: ComposerSelection) {
+14
View File
@@ -59,6 +59,19 @@ describe('serializeComposerSegments', () => {
{ type: 'text', text: ' after' },
])).toBe('before after')
})
it('keeps the 120 UTF-16-unit mention title limit without splitting an emoji', () => {
const title = `${'a'.repeat(119)}😀x`
const expectedTitle = 'a'.repeat(119)
expect(serializeComposerSegments([
{ type: 'session', id: 'emoji-session', title },
])).toBe(`[${expectedTitle}](/sessions/emoji-session)`)
const fittingTitle = `${'a'.repeat(118)}😀x`
expect(serializeComposerSegments([
{ type: 'session', id: 'emoji-session', title: fittingTitle },
])).toBe(`[${'a'.repeat(118)}😀](/sessions/emoji-session)`)
})
})
describe('parseComposerSegments', () => {
@@ -226,6 +239,7 @@ describe('serializeComposerSelection', () => {
const segments: ComposerSegment[] = [{ type: 'text', text: 'abc' }]
expect(serializeComposerSelection(segments, { start: 1, end: 1 })).toBeNull()
})
})
describe('insertSegmentsInComposerSegments', () => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { buildSessionReferencePath, parseSessionPathHref } from '@/lib/sessionReference'
import { truncateGraphemes } from '@/lib/graphemes'
import { findActiveWord } from '@/utils/findActiveWord'
/** Object Replacement Character — one mirror slot per session atom. */
@@ -23,7 +24,7 @@ export type ComposerSelection = {
}
function sanitizeMentionTitle(title: string): string {
return title.replace(/\s+/g, ' ').trim().slice(0, 120)
return truncateGraphemes(title.replace(/\s+/g, ' ').trim(), 120)
}
function escapeMarkdownLinkLabel(title: string): string {
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { truncateGraphemes } from './graphemes'
const BOUNDARY_SAMPLES = [
'😀',
'e\u0301',
'👨\u200D👩\u200D👧\u200D👦',
]
const CODE_POINT_FALLBACK_SAMPLES = [
...BOUNDARY_SAMPLES,
'한',
'क्\u200Dष',
'a\u200Db',
]
function expectGraphemeHardLimit(): void {
for (const grapheme of BOUNDARY_SAMPLES) {
const fittingPrefix = 'a'.repeat(120 - grapheme.length)
const overflowingPrefix = 'a'.repeat(121 - grapheme.length)
expect(truncateGraphemes(`${fittingPrefix}${grapheme}x`, 120)).toBe(
`${fittingPrefix}${grapheme}`
)
expect(truncateGraphemes(`${overflowingPrefix}${grapheme}x`, 120)).toBe(
overflowingPrefix
)
}
}
function codePointBounded(value: string, maxLength: number): string {
let result = ''
for (const codePoint of Array.from(value)) {
if (result.length + codePoint.length > maxLength) break
result += codePoint
}
return result
}
describe('truncateGraphemes', () => {
it('keeps only whole 120-UTF-16-unit graphemes', () => {
expectGraphemeHardLimit()
expect(truncateGraphemes(`${'a'.repeat(119)}😀`, 120)).toBe('a'.repeat(119))
expect(truncateGraphemes(`${'a'.repeat(118)}😀`, 120)).toBe(`${'a'.repeat(118)}😀`)
expect(truncateGraphemes('👨\u200D👩\u200D👧\u200D👦', 2)).toBe('')
expect(truncateGraphemes('abc', 0)).toBe('')
})
it('uses a bounded code-point fallback when Intl.Segmenter is unavailable', () => {
const descriptor = Object.getOwnPropertyDescriptor(Intl, 'Segmenter')
Object.defineProperty(Intl, 'Segmenter', { configurable: true, value: undefined })
try {
for (const grapheme of CODE_POINT_FALLBACK_SAMPLES) {
const source = `${'a'.repeat(119)}${grapheme}x`
const result = truncateGraphemes(source, 120)
expect(result).toBe(codePointBounded(source, 120))
expect(result.length).toBeLessThanOrEqual(120)
expect(result).not.toMatch(/[\uD800-\uDBFF]$/)
}
expect(truncateGraphemes('abc', 0)).toBe('')
} finally {
if (descriptor) {
Object.defineProperty(Intl, 'Segmenter', descriptor)
} else {
const mutableIntl = Intl as { Segmenter?: unknown }
delete mutableIntl.Segmenter
}
}
})
})
+26
View File
@@ -0,0 +1,26 @@
/**
* Truncate by user-perceived grapheme clusters when the platform provides the
* Unicode segmenter. The fallback is code-point-safe rather than a complete
* UAX grapheme implementation, so it never creates a lone surrogate. Both
* paths retain the original 120 UTF-16-unit title limit.
*/
export function truncateGraphemes(value: string, maxLength: number): string {
if (maxLength <= 0 || !value) return ''
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
let result = ''
for (const entry of segmenter.segment(value)) {
if (result.length + entry.segment.length > maxLength) break
result += entry.segment
}
return result
}
let result = ''
for (const codePoint of Array.from(value)) {
if (result.length + codePoint.length > maxLength) break
result += codePoint
}
return result
}
+21
View File
@@ -58,6 +58,27 @@ describe('buildSessionReferenceText', () => {
'See HAPI session /sessions/abc-def for context'
)
})
it('keeps combining and ZWJ title graphemes only when they fit the UTF-16 limit', () => {
const prefix = 'a'.repeat(119)
const combining = `${prefix}e\u0301x`
const family = `${prefix}👨\u200D👩\u200D👧\u200D👦x`
expect(buildSessionReferenceText(combining, 'combining')).toBe(
`See session ${JSON.stringify(prefix)} (/sessions/combining) for context`
)
expect(buildSessionReferenceText(family, 'family')).toBe(
`See session ${JSON.stringify(prefix)} (/sessions/family) for context`
)
const fittingFamilyPrefix = 'a'.repeat(120 - '👨\u200D👩\u200D👧\u200D👦'.length)
expect(buildSessionReferenceText(
`${fittingFamilyPrefix}👨\u200D👩\u200D👧\u200D👦x`,
'fitting-family'
)).toBe(
`See session ${JSON.stringify(`${fittingFamilyPrefix}👨\u200D👩\u200D👧\u200D👦`)} (/sessions/fitting-family) for context`
)
})
})
describe('matchSessionsForMention', () => {
+2 -1
View File
@@ -1,5 +1,6 @@
import type { SessionSummary } from '@/types/api'
import { normalizeSearch, sessionMatchesQuery } from '@/components/SessionList'
import { truncateGraphemes } from '@/lib/graphemes'
import { getSessionTitle } from '@/lib/sessionTitle'
export function buildSessionReferencePath(sessionId: string): string {
@@ -9,7 +10,7 @@ export function buildSessionReferencePath(sessionId: string): string {
}
function sanitizeSessionReferenceTitle(sessionTitle: string): string {
return sessionTitle.replace(/\s+/g, ' ').trim().slice(0, 120)
return truncateGraphemes(sessionTitle.replace(/\s+/g, ' ').trim(), 120)
}
/** Clipboard text for citing this session in another HAPI chat (not a public share link). */