mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
ce0fa4c114
commit
8337a80ae6
@ -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}>
|
||||
|
||||
@ -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) {
|
||||
|
||||
27
frontend/src/core/utils/text.ts
Normal file
27
frontend/src/core/utils/text.ts
Normal 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("");
|
||||
}
|
||||
@ -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();
|
||||
|
||||
34
frontend/tests/unit/core/utils/text.test.ts
Normal file
34
frontend/tests/unit/core/utils/text.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user