From 6213fee192d4ae1035fd068ac3825686189bd904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E7=89=87=E4=B8=80=E7=89=87?= <3037678834@qq.com> Date: Sun, 25 Oct 2026 15:13:16 +0800 Subject: [PATCH] fix(frontend): bound notification bodies by code points (#7474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both chat pages capped the "conversation finished" notification body with String#length / substring, which count UTF-16 code units: a cut inside an astral character's surrogate pair rendered U+FFFD, and a body that fits 200 code points was shortened early. Extract notificationBody() so both pages share the code-point bound and the "Conversation finished" fallback, and add boundary regressions. Co-authored-by: 一片一片 <192845873+gdut4140@users.noreply.github.com> --- .../[agent_name]/chats/[thread_id]/page.tsx | 17 ++++----- .../components/workspace/chats/chat-page.tsx | 17 ++++----- frontend/src/core/notification/body.ts | 27 ++++++++++++++ .../tests/unit/core/notification/body.test.ts | 36 +++++++++++++++++++ 4 files changed, 75 insertions(+), 22 deletions(-) create mode 100644 frontend/src/core/notification/body.ts create mode 100644 frontend/tests/unit/core/notification/body.test.ts diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 49134d7aa4..c08f8161d9 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -67,6 +67,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils"; import { modelTaskInput } from "@/core/models/control"; import { useModels } from "@/core/models/hooks"; import { useModelControl } from "@/core/models/use-model-control"; +import { notificationBody } from "@/core/notification/body"; import { useNotification } from "@/core/notification/hooks"; import { useThreadScheduledTaskEvents } from "@/core/scheduled-tasks/events"; import { useLocalSettings, useThreadSettings } from "@/core/settings"; @@ -221,18 +222,12 @@ export default function AgentChatPage() { // it is open: it has been read. markThreadRead(); if (document.hidden || !document.hasFocus()) { - let body = "Conversation finished"; const lastMessage = state.messages[state.messages.length - 1]; - if (lastMessage) { - const textContent = textOfMessage(lastMessage); - if (textContent) { - body = - textContent.length > 200 - ? textContent.substring(0, 200) + "..." - : textContent; - } - } - showNotification(state.title, { body }); + showNotification(state.title, { + body: notificationBody( + lastMessage ? textOfMessage(lastMessage) : null, + ), + }); } }, }); diff --git a/frontend/src/components/workspace/chats/chat-page.tsx b/frontend/src/components/workspace/chats/chat-page.tsx index bfed5e25db..c7bbaa4d75 100644 --- a/frontend/src/components/workspace/chats/chat-page.tsx +++ b/frontend/src/components/workspace/chats/chat-page.tsx @@ -62,6 +62,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils"; import { modelTaskInput } from "@/core/models/control"; import { useModels } from "@/core/models/hooks"; import { useModelControl } from "@/core/models/use-model-control"; +import { notificationBody } from "@/core/notification/body"; import { useNotification } from "@/core/notification/hooks"; import { useProject } from "@/core/projects"; import { useThreadScheduledTaskEvents } from "@/core/scheduled-tasks/events"; @@ -231,18 +232,12 @@ export default function ChatPage() { // it is open: it has been read. markThreadRead(); if (document.hidden || !document.hasFocus()) { - let body = "Conversation finished"; const lastMessage = state.messages.at(-1); - if (lastMessage) { - const textContent = textOfMessage(lastMessage); - if (textContent) { - body = - textContent.length > 200 - ? textContent.substring(0, 200) + "..." - : textContent; - } - } - showNotification(state.title, { body }); + showNotification(state.title, { + body: notificationBody( + lastMessage ? textOfMessage(lastMessage) : null, + ), + }); } }, }); diff --git a/frontend/src/core/notification/body.ts b/frontend/src/core/notification/body.ts new file mode 100644 index 0000000000..ecf0ec472f --- /dev/null +++ b/frontend/src/core/notification/body.ts @@ -0,0 +1,27 @@ +/** Longest notification body, counted in code points. */ +export const MAX_NOTIFICATION_BODY_CODE_POINTS = 200; + +/** Ellipsis appended to a shortened notification body. */ +const ELLIPSIS = "..."; + +/** + * Body for a "finished in the background" notification. + * + * The text is bounded by code points rather than UTF-16 code units: a + * `String.prototype.length` / `substring` cut can land between the two halves + * of an astral character (an emoji) and leave a lone surrogate, which the + * notification renders as U+FFFD. Summaries that fit the budget are returned + * whole even when they contain astral characters. + */ +export function notificationBody( + text: string | null | undefined, + fallback = "Conversation finished", +): string { + if (!text) { + return fallback; + } + const characters = Array.from(text); + return characters.length > MAX_NOTIFICATION_BODY_CODE_POINTS + ? `${characters.slice(0, MAX_NOTIFICATION_BODY_CODE_POINTS).join("")}${ELLIPSIS}` + : text; +} diff --git a/frontend/tests/unit/core/notification/body.test.ts b/frontend/tests/unit/core/notification/body.test.ts new file mode 100644 index 0000000000..405d647470 --- /dev/null +++ b/frontend/tests/unit/core/notification/body.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@rstest/core"; + +import { + MAX_NOTIFICATION_BODY_CODE_POINTS, + notificationBody, +} from "@/core/notification/body"; + +const LONE_SURROGATE = + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + expect(notificationBody("Done in 3 steps.")).toBe("Done in 3 steps."); +}); + +test("falls back when there is no text to quote", () => { + expect(notificationBody(null)).toBe("Conversation finished"); + expect(notificationBody(undefined)).toBe("Conversation finished"); + expect(notificationBody("")).toBe("Conversation finished"); + expect(notificationBody("", "Custom fallback")).toBe("Custom fallback"); +}); + +test("a body that fits the code-point budget keeps its trailing emoji", () => { + // 200 code points but 201 UTF-16 units: UTF-16 counting would cut this. + const text = `${"a".repeat(MAX_NOTIFICATION_BODY_CODE_POINTS - 1)}πŸ˜€`; + expect(notificationBody(text)).toBe(text); +}); + +test("a truncated body never ends in half an emoji", () => { + // 201 code points; the old UTF-16 cut landed inside the surrogate pair. + const text = `${"a".repeat(MAX_NOTIFICATION_BODY_CODE_POINTS - 1)}πŸ˜€b`; + const body = notificationBody(text); + expect(body).toBe( + `${"a".repeat(MAX_NOTIFICATION_BODY_CODE_POINTS - 1)}πŸ˜€...`, + ); + expect(body).not.toMatch(LONE_SURROGATE); +});