fix(frontend): bound notification bodies by code points (#7474)

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>
This commit is contained in:
一片一片 2026-10-25 15:13:16 +08:00 committed by GitHub
parent db7f919118
commit 6213fee192
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 75 additions and 22 deletions

View File

@ -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,
),
});
}
},
});

View File

@ -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,
),
});
}
},
});

View File

@ -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;
}

View File

@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
test("quotes the last message when it fits", () => {
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);
});