diff --git a/frontend/src/core/scheduled-tasks/format.ts b/frontend/src/core/scheduled-tasks/format.ts index b1f95e9b22..4ff2e5f22c 100644 --- a/frontend/src/core/scheduled-tasks/format.ts +++ b/frontend/src/core/scheduled-tasks/format.ts @@ -270,14 +270,22 @@ export function displayTimeZone( * A run summary short enough to quote mid-sentence: its first sentence, no * trailing sentence punctuation (the template adds its own), at most `max` * characters with an ellipsis. The full text stays in the linked run. + * + * The budget counts code points, so an emoji at the cut is never split into a + * lone surrogate (`conversation-outline.ts` and `tool-detail-preview.ts` + * follow the same rule for their own limits). */ export function briefReason(summary: string, max = 120): string { const text = summary.replace(/\s+/g, " ").trim(); const sentenceEnd = /[。!?]|[.!?](?=\s|$)/.exec(text); const first = sentenceEnd ? text.slice(0, sentenceEnd.index + 1) : text; const bare = first.replace(/[\s。..!!??;;,,、::]+$/u, ""); - if (bare.length <= max) { + const characters = Array.from(bare); + if (characters.length <= max) { return bare; } - return `${bare.slice(0, max - 1).trimEnd()}…`; + return `${characters + .slice(0, Math.max(0, max - 1)) + .join("") + .trimEnd()}…`; } diff --git a/frontend/tests/unit/core/scheduled-tasks/format.test.ts b/frontend/tests/unit/core/scheduled-tasks/format.test.ts index 806f182df7..4c305391a9 100644 --- a/frontend/tests/unit/core/scheduled-tasks/format.test.ts +++ b/frontend/tests/unit/core/scheduled-tasks/format.test.ts @@ -199,4 +199,17 @@ describe("briefReason", () => { expect(reason).toHaveLength(120); expect(reason.endsWith("…")).toBe(true); }); + + it("counts code points so a cut never splits an emoji", () => { + // 120 code points but 121 UTF-16 units: the whole summary fits. + const fits = `${"a".repeat(118)}😀b`; + expect(briefReason(fits)).toBe(fits); + + // Cut lands before the extra text, after the whole emoji: no lone surrogate. + const overflow = `${"a".repeat(118)}😀bc`; + expect(briefReason(overflow)).toBe(`${"a".repeat(118)}😀…`); + expect(briefReason(overflow)).not.toMatch( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/, + ); + }); });