fix(site): count preview graphemes and keep task and footnote markers

- The 100-character preview limit now counts grapheme clusters instead
  of UTF-16 code units, so emoji no longer halve it.
- Task list items keep their [ ] or [x] state in the preview.
- Footnote references keep their [^label] text instead of disappearing
  when the reasoning also defines the footnote.
This commit is contained in:
Michael Suchacz
2026-10-02 20:57:57 +00:00
parent 0065833854
commit 93cf4c32b8
3 changed files with 48 additions and 17 deletions
@@ -359,6 +359,17 @@ export function sliceAtGraphemeBoundary(
return text.slice(0, safeEnd);
}
/**
* Slice a string to its first {@link maxGraphemes} grapheme clusters,
* counting codepoints when the `Intl.Segmenter` API is unavailable.
*/
export function sliceGraphemes(text: string, maxGraphemes: number): string {
const graphemes = graphemeSegmenter
? Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment)
: Array.from(text);
return graphemes.slice(0, maxGraphemes).join("");
}
export function useSmoothStreamingText(
options: UseSmoothStreamingTextOptions,
): UseSmoothStreamingTextResult {
@@ -18,14 +18,16 @@ describe("getThinkingDisclosureDisplay", () => {
});
});
it("truncates the title at a grapheme boundary", () => {
const text = `${"a".repeat(99)}e\u0301 more`;
expect(getThinkingDisclosureDisplay(text, { isStreaming: false })).toEqual({
title: `${"a".repeat(99)}…`,
ariaLabel: `Thinking: ${"a".repeat(99)}…`,
body: text,
});
});
it.each(["\u{1F600}", "e\u0301"])(
"counts the title length in graphemes: %j",
(grapheme) => {
const text = grapheme.repeat(101);
const title = `${grapheme.repeat(100)}…`;
expect(
getThinkingDisclosureDisplay(text, { isStreaming: false }),
).toEqual({ title, ariaLabel: `Thinking: ${title}`, body: text });
},
);
it("ends the title with an ellipsis when the bounded source is cut", () => {
// The emoji straddles the 400-character source bound.
@@ -259,6 +261,20 @@ describe("getThinkingDisclosureDisplay", () => {
});
});
it.each([
[
"- [ ] Verify migration\n- [x] Run tests",
"- [ ] Verify migration - [x] Run tests",
],
["Compare A[^1]B first.\n\n[^1]: note", "Compare A[^1]B first. note"],
])("keeps task states and footnote references: %j", (text, title) => {
expect(getThinkingDisclosureDisplay(text, { isStreaming: false })).toEqual({
title,
ariaLabel: `Thinking: ${title}`,
body: text,
});
});
it.each([
`# Plan ${"step ".repeat(100)}`,
`Plan ${"step ".repeat(100)}\n---`,
@@ -3,13 +3,13 @@ import remarkParse from "remark-parse";
import remend from "remend";
import { defaultRemarkPlugins } from "streamdown";
import { unified } from "unified";
import { sliceAtGraphemeBoundary } from "./SmoothText";
import { sliceAtGraphemeBoundary, sliceGraphemes } from "./SmoothText";
const DEFAULT_THINKING_TITLE = "Thinking";
const PREVIEW_TITLE_MAX_LENGTH = 100;
const PREVIEW_TITLE_MAX_GRAPHEMES = 100;
// Bounds the Markdown parsing, which reruns for every streamed chunk of
// reasoning that can grow to many kilobytes.
const PARSED_SOURCE_MAX_LENGTH = PREVIEW_TITLE_MAX_LENGTH * 4;
const PARSED_SOURCE_MAX_LENGTH = PREVIEW_TITLE_MAX_GRAPHEMES * 4;
// Parses like the Streamdown body so titles keep exactly the text it renders.
const markdownParser = unified()
@@ -53,12 +53,18 @@ const getNodeText = (node: Nodes): string => {
return node.alt ?? "";
case "break":
return " ";
case "footnoteReference":
return `[^${node.label ?? node.identifier}]`;
case "list": {
const start = node.start ?? 1;
return node.children
.map((item, index) => {
const marker = node.ordered ? `${start + index}.` : "-";
return `${marker} ${getNodeText(item)}`;
const checkbox =
typeof item.checked === "boolean"
? `[${item.checked ? "x" : " "}] `
: "";
return `${marker} ${checkbox}${getNodeText(item)}`;
})
.join(" ");
}
@@ -290,13 +296,11 @@ const getPreviewTitle = (text: string, isStreaming: boolean): string => {
const preview = getPlainText(
markdownParser.parse(isStreaming || isSourceCut ? remend(source) : source),
);
if (
!preview ||
(preview.length <= PREVIEW_TITLE_MAX_LENGTH && !isSourceCut)
) {
const title = sliceGraphemes(preview, PREVIEW_TITLE_MAX_GRAPHEMES);
if (!preview || (title === preview && !isSourceCut)) {
return preview;
}
return `${sliceAtGraphemeBoundary(preview, PREVIEW_TITLE_MAX_LENGTH).trimEnd()}…`;
return `${title.trimEnd()}…`;
};
export const getThinkingDisclosureDisplay = (