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