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); +});