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