fix(ui): keep workboard capture text truncation UTF-16 safe (#102544)

* fix(ui): keep workboard capture text truncation UTF-16 safe

Replace raw .slice(0, N - 3) with truncateUtf16Safe in
clampSessionCaptureText and clampSessionCaptureTitle to prevent
lone surrogates when emoji cross the truncation boundary.

Completes the UTF-16 hardening started in #101685, which added the
import but only covered buildCardSessionLabel.

* test(ui): cover workboard UTF-16 boundaries

* docs(changelog): note Workboard UTF-16 fix

* docs(changelog): leave release notes to release flow

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
maweibin
2026-07-09 17:42:34 +08:00
committed by GitHub
parent bb1fa4012e
commit 9bf212765f
2 changed files with 14 additions and 8 deletions
+2 -2
View File
@@ -2909,7 +2909,7 @@ function clampSessionCaptureText(value: string): string {
if (compact.length <= SESSION_CAPTURE_TEXT_MAX_CHARS) {
return compact;
}
return `${compact.slice(0, SESSION_CAPTURE_TEXT_MAX_CHARS - 3).trimEnd()}...`;
return `${truncateUtf16Safe(compact, SESSION_CAPTURE_TEXT_MAX_CHARS - 3).trimEnd()}...`;
}
function clampSessionCaptureTitle(value: string): string {
@@ -2917,7 +2917,7 @@ function clampSessionCaptureTitle(value: string): string {
if (compact.length <= WORKBOARD_CAPTURE_TITLE_MAX_CHARS) {
return compact;
}
return `${compact.slice(0, WORKBOARD_CAPTURE_TITLE_MAX_CHARS - 3).trimEnd()}...`;
return `${truncateUtf16Safe(compact, WORKBOARD_CAPTURE_TITLE_MAX_CHARS - 3).trimEnd()}...`;
}
function sessionTitle(session: GatewaySessionRow, recentUserText: string | null): string {
+12 -6
View File
@@ -3440,18 +3440,21 @@ describe("workboard controller", () => {
);
});
it("clamps long session labels before creating captured cards", async () => {
it("clamps captured session fields without splitting surrogate pairs", async () => {
const host = {};
const longLabel = "x".repeat(220);
const titlePrefix = "x".repeat(176);
const textPrefix = "y".repeat(696);
const client = createClient((method) => {
if (method === "workboard.cards.list") {
return { cards: [], statuses: ["todo"] };
}
if (method === "chat.history") {
return { messages: [] };
return {
messages: [{ role: "user", content: [{ type: "text", text: `${textPrefix}😀tail` }] }],
};
}
if (method === "workboard.cards.create") {
return { card: { ...sampleCard, title: `${"x".repeat(177)}...` } };
return { card: { ...sampleCard, title: `${titlePrefix}...` } };
}
return {};
});
@@ -3459,14 +3462,17 @@ describe("workboard controller", () => {
await captureSessionToWorkboard({
host,
client: client as never,
session: { ...sampleSession, label: longLabel },
session: { ...sampleSession, label: `${titlePrefix}😀tail` },
});
expect(client.request).toHaveBeenNthCalledWith(
3,
"workboard.cards.create",
expect.objectContaining({
title: `${"x".repeat(177)}...`,
title: `${titlePrefix}...`,
notes: [`Session: ${sampleSession.key}`, "", `Recent user prompt: ${textPrefix}...`].join(
"\n",
),
}),
);
});