fix(frontend): count code points when previewing table cells and memory facts (#7503)

* 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)
This commit is contained in:
YongxinFu 2026-10-26 08:23:49 +08:00 committed by GitHub
parent ce0fa4c114
commit 8337a80ae6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 93 additions and 10 deletions

View File

@ -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({
</th>
{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 (
<td
key={column}
@ -217,7 +223,7 @@ function TablePreview({
setCopyStatus("");
}}
>
{value.slice(0, 120)}
{truncateToCodePoints(value, 120)}
</button>
) : (
<span className="whitespace-pre" title={value}>

View File

@ -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) {

View File

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

View File

@ -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();

View File

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