diff --git a/web/src/components/AssistantChat/RichComposerInput.segments.test.ts b/web/src/components/AssistantChat/RichComposerInput.segments.test.ts
index af5304d3..3d957d29 100644
--- a/web/src/components/AssistantChat/RichComposerInput.segments.test.ts
+++ b/web/src/components/AssistantChat/RichComposerInput.segments.test.ts
@@ -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
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
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 = '
one
two
'
+ 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 @Peer A 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 @Peer A two @Peer B 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 = 'four
'
+ 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 = 'one
two
'
+
+ 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 = 'one
'
+ expect(mirrorComposerSegments(segmentsFromEditor(trailingEmpty))).toBe('one')
+ expect(mirrorOffsetFromPoint(trailingEmpty, trailingEmpty.children[1]!, 0)).toBe(3)
+
+ const leadingEmpty = document.createElement('div')
+ leadingEmpty.innerHTML = 'two
'
+ expect(mirrorOffsetFromPoint(leadingEmpty, leadingEmpty.children[0]!, 0)).toBe(0)
+
+ const nestedEmpty = document.createElement('div')
+ nestedEmpty.innerHTML = ''
+ const nested = nestedEmpty.firstChild as HTMLElement
+ expect(mirrorOffsetFromPoint(nestedEmpty, nested.children[0]!, 0)).toBe(4)
+
+ const sectionWrapped = document.createElement('div')
+ sectionWrapped.innerHTML = 'one
two
'
+ 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 = 'one
two
'
+ 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 = 'onetwo'
+ 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)
+ })
})
diff --git a/web/src/components/AssistantChat/RichComposerInput.tsx b/web/src/components/AssistantChat/RichComposerInput.tsx
index 9450186b..2f297ef4 100644
--- a/web/src/components/AssistantChat/RichComposerInput.tsx
+++ b/web/src/components/AssistantChat/RichComposerInput.tsx
@@ -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
+}
+
+/**
+ * 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()
+ 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 or 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 one
two
.
+ 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
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) {
diff --git a/web/src/lib/composerSegments.test.ts b/web/src/lib/composerSegments.test.ts
index 9c09521b..32f3983d 100644
--- a/web/src/lib/composerSegments.test.ts
+++ b/web/src/lib/composerSegments.test.ts
@@ -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', () => {
diff --git a/web/src/lib/composerSegments.ts b/web/src/lib/composerSegments.ts
index cfe4396f..b251020a 100644
--- a/web/src/lib/composerSegments.ts
+++ b/web/src/lib/composerSegments.ts
@@ -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 {
diff --git a/web/src/lib/graphemes.test.ts b/web/src/lib/graphemes.test.ts
new file mode 100644
index 00000000..96698f91
--- /dev/null
+++ b/web/src/lib/graphemes.test.ts
@@ -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
+ }
+ }
+ })
+})
diff --git a/web/src/lib/graphemes.ts b/web/src/lib/graphemes.ts
new file mode 100644
index 00000000..bac3f4cc
--- /dev/null
+++ b/web/src/lib/graphemes.ts
@@ -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
+}
diff --git a/web/src/lib/sessionReference.test.ts b/web/src/lib/sessionReference.test.ts
index d40ac043..524ad99e 100644
--- a/web/src/lib/sessionReference.test.ts
+++ b/web/src/lib/sessionReference.test.ts
@@ -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', () => {
diff --git a/web/src/lib/sessionReference.ts b/web/src/lib/sessionReference.ts
index d3dc5653..fbccee0a 100644
--- a/web/src/lib/sessionReference.ts
+++ b/web/src/lib/sessionReference.ts
@@ -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). */