mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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:
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user