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 = '
one

two

three

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 = '
one
two
' + 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). */