From 8337a80ae6530ec2c3971b58753bcada9f8db92e Mon Sep 17 00:00:00 2001
From: YongxinFu <2037436957@qq.com>
Date: Mon, 26 Oct 2026 08:23:49 +0800
Subject: [PATCH] fix(frontend): count code points when previewing table cells
and memory facts (#7503)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(frontend): count code points when previewing table cells and memory facts
artifact-table-preview cut a cell at 120 UTF-16 code units and
memory-settings-page's truncateFactPreview measured and cut fact previews
at 140, so an emoji spanning two units could be split at the cut — the
lone half renders as U+FFFD — and a preview that already fit the
documented code-point budget was still shortened. Both previews now share
truncateToCodePoints in core/utils/text, which measures and cuts by code
points, the same rule briefReason in core/scheduled-tasks/format.ts
already follows.
Add unit tests for the helper and a DOM regression test covering both the
fits-exactly and the cut-inside-an-emoji cases.
* fix(frontend): count code points in the artifact-cell expand gate too
The expandable gate still measured value.length (UTF-16 units), so a
120-code-point cell ending in an astral character opened the expand
control even though the inline preview already showed the whole cell —
the same units-vs-code-points mismatch the preview cut fixed.
The gate now counts Array.from(value), and the boundary test asserts
the fits cell renders as a plain cell with no expand control while the
overflows cell keeps its button and whole-emoji cut.
Suggested-by: gdut4140 (port of the #7509 expandable gate)
---
.../artifacts/artifact-table-preview.tsx | 10 ++++--
.../settings/memory-settings-page.tsx | 10 ++----
frontend/src/core/utils/text.ts | 27 +++++++++++++++
.../artifact-table-preview.dom.test.tsx | 22 ++++++++++++
frontend/tests/unit/core/utils/text.test.ts | 34 +++++++++++++++++++
5 files changed, 93 insertions(+), 10 deletions(-)
create mode 100644 frontend/src/core/utils/text.ts
create mode 100644 frontend/tests/unit/core/utils/text.test.ts
diff --git a/frontend/src/components/workspace/artifacts/artifact-table-preview.tsx b/frontend/src/components/workspace/artifacts/artifact-table-preview.tsx
index cff642ab0c..d27d204ddf 100644
--- a/frontend/src/components/workspace/artifacts/artifact-table-preview.tsx
+++ b/frontend/src/components/workspace/artifacts/artifact-table-preview.tsx
@@ -19,6 +19,7 @@ import {
import { useDelimitedPreview } from "@/core/artifacts/use-delimited-preview";
import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks";
+import { truncateToCodePoints } from "@/core/utils/text";
const PAGE_SIZE = 50;
const MAX_DATA_ROWS = 200;
@@ -184,9 +185,14 @@ function TablePreview({
{columns.map((column) => {
const value = row[column];
+ // Count code points so a 120-code-point cell that ends in
+ // an astral character stays inline instead of getting a
+ // pointless expand control.
+ const characters =
+ value === undefined ? [] : Array.from(value);
const expandable =
value !== undefined &&
- (value.length > 120 || /[\r\n]/.test(value));
+ (characters.length > 120 || /[\r\n]/.test(value));
return (
- {value.slice(0, 120)}
+ {truncateToCodePoints(value, 120)}
) : (
diff --git a/frontend/src/components/workspace/settings/memory-settings-page.tsx b/frontend/src/components/workspace/settings/memory-settings-page.tsx
index 501c392402..47332aeca6 100644
--- a/frontend/src/components/workspace/settings/memory-settings-page.tsx
+++ b/frontend/src/components/workspace/settings/memory-settings-page.tsx
@@ -47,6 +47,7 @@ import {
import { streamdownPlugins } from "@/core/streamdown/plugins";
import { pathOfThread } from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
+import { truncateToCodePoints } from "@/core/utils/text";
import { SettingsSection } from "./settings-section";
@@ -216,14 +217,7 @@ function isMemorySummaryEmpty(memory: UserMemory) {
function truncateFactPreview(content: string, maxLength = 140) {
const normalized = content.replace(/\s+/g, " ").trim();
- if (normalized.length <= maxLength) {
- return normalized;
- }
- const ellipsis = "...";
- if (maxLength <= ellipsis.length) {
- return normalized.slice(0, maxLength);
- }
- return `${normalized.slice(0, maxLength - ellipsis.length)}${ellipsis}`;
+ return truncateToCodePoints(normalized, maxLength, "...");
}
function upperFirst(str: string) {
diff --git a/frontend/src/core/utils/text.ts b/frontend/src/core/utils/text.ts
new file mode 100644
index 0000000000..628ece8b72
--- /dev/null
+++ b/frontend/src/core/utils/text.ts
@@ -0,0 +1,27 @@
+/**
+ * Truncate `value` to at most `maxLength` code points.
+ *
+ * `String.prototype.length` and `slice` count UTF-16 code units, so a
+ * unit-based cut can split a surrogate pair (the lone half renders as
+ * U+FFFD) and can shorten a value that already fits a code-point budget —
+ * the same seam `briefReason` in `core/scheduled-tasks/format.ts` counts by
+ * code points for. A value that fits is returned unchanged.
+ *
+ * When truncating with an `ellipsis`, the marker takes the last
+ * `ellipsis.length` code points of the budget; without one — or when the
+ * budget cannot hold the marker — the result is a plain code-point cut.
+ */
+export function truncateToCodePoints(
+ value: string,
+ maxLength: number,
+ ellipsis = "",
+): string {
+ const characters = Array.from(value);
+ if (characters.length <= maxLength) {
+ return value;
+ }
+ if (ellipsis && maxLength > ellipsis.length) {
+ return characters.slice(0, maxLength - ellipsis.length).join("") + ellipsis;
+ }
+ return characters.slice(0, maxLength).join("");
+}
diff --git a/frontend/tests/unit/components/workspace/artifacts/artifact-table-preview.dom.test.tsx b/frontend/tests/unit/components/workspace/artifacts/artifact-table-preview.dom.test.tsx
index d4d92b903b..a6aeaf9016 100644
--- a/frontend/tests/unit/components/workspace/artifacts/artifact-table-preview.dom.test.tsx
+++ b/frontend/tests/unit/components/workspace/artifacts/artifact-table-preview.dom.test.tsx
@@ -84,6 +84,28 @@ describe("ArtifactTablePreview", () => {
}).value,
).toBe(longText);
});
+ it("cuts a long cell by code points so an emoji never splits", () => {
+ // 120 code points but 121 UTF-16 units: the cell fits and keeps its last
+ // character instead of losing it to a unit-based cut.
+ const fits = `${"a".repeat(118)}😀b`;
+ // 122 code points: the cut lands after the whole emoji, never inside it.
+ const overflows = `${"a".repeat(119)}😀bc`;
+ state.result.rows = [
+ ["ID", "Note"],
+ ["00123", fits],
+ ["00456", overflows],
+ ];
+ mount();
+ // 120 code points is inside the budget: no expand control at all.
+ expect(screen.getByRole("cell", { name: fits })).toBeTruthy();
+ expect(
+ screen.queryByRole("button", { name: "View cell: row 1, column 2" }),
+ ).toBeNull();
+ expect(
+ screen.getByRole("button", { name: "View cell: row 2, column 2" })
+ .textContent,
+ ).toBe(`${"a".repeat(119)}😀`);
+ });
it("offers an explicit retry without displaying stale data on failure", () => {
state.status = "error";
mount();
diff --git a/frontend/tests/unit/core/utils/text.test.ts b/frontend/tests/unit/core/utils/text.test.ts
new file mode 100644
index 0000000000..5842744c3c
--- /dev/null
+++ b/frontend/tests/unit/core/utils/text.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "@rstest/core";
+
+import { truncateToCodePoints } from "@/core/utils/text";
+
+const loneSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/;
+
+describe("truncateToCodePoints", () => {
+ it("returns a value that fits the code-point budget unchanged", () => {
+ // 120 code points but 121 UTF-16 units: the whole value fits.
+ const fits = `${"a".repeat(118)}😀b`;
+ expect(truncateToCodePoints(fits, 120)).toBe(fits);
+ });
+
+ it("never splits a surrogate pair at the cut", () => {
+ // 122 code points / 123 UTF-16 units: the cut lands after the whole emoji.
+ const overflow = `${"a".repeat(119)}😀bc`;
+ const truncated = truncateToCodePoints(overflow, 120);
+ expect(truncated).toBe(`${"a".repeat(119)}😀`);
+ expect(truncated).not.toMatch(loneSurrogate);
+ });
+
+ it("spends the ellipsis inside the budget", () => {
+ const overflow = `${"a".repeat(139)}😀x`;
+ const truncated = truncateToCodePoints(overflow, 140, "...");
+ expect(truncated).toBe(`${"a".repeat(137)}...`);
+ expect(truncated.length).toBeLessThanOrEqual(140);
+ expect(truncated).not.toMatch(loneSurrogate);
+ });
+
+ it("falls back to a plain cut when the budget cannot hold the ellipsis", () => {
+ expect(truncateToCodePoints("abcd", 2, "...")).toBe("ab");
+ expect(truncateToCodePoints("abcd", 3, "...")).toBe("abc");
+ });
+});
|