Skip to content

Cut a roster line between graphemes, not between code points - #455

Merged
davidmckayv merged 2 commits into
CopilotKit:mainfrom
kevin9327:fix/one-line-grapheme-cut
Sep 9, 2026
Merged

Cut a roster line between graphemes, not between code points#455
davidmckayv merged 2 commits into
CopilotKit:mainfrom
kevin9327:fix/one-line-grapheme-cut

Conversation

@kevin9327

Copy link
Copy Markdown
Contributor

What is wrong

oneLine in server/src/channels/text.ts says in its own docstring that it cuts "on code points so
an emoji is never split". That holds only for an emoji that is one code point. Most modern emoji
are not:

input oneLine(input, cap) on main what it renders as
🇰🇷 report, cap 2 "\u{1F1F0}…" a lone regional indicator — a boxed K
👨‍👩‍👧 hello, cap 3 "\u{1F468}\u{200D}…" the man, then a dangling zero-width joiner
👍🏽 ok, cap 2 "\u{1F44D}…" the thumb with its skin tone dropped
1️⃣ first, cap 2 "1…" a bare digit, no keycap

Every one of those was measured against main.

That line is not incidental. It is:

  • what the sidebar draws under a channel (previewOf, MAX_ACTIVITY_CODE_POINTS),
  • the generated channel title (summariseOne, MAX_SUMMARY_CODE_POINTS),
  • and the excerpt the titler is shown (openingOf, MAX_EXCERPT_CODE_POINTS).

So a message that opens with a flag renders as a letter in a box in all three places.

The change

Segment with Intl.Segmenter(undefined, { granularity: "grapheme" }) and cut between clusters, so
what a person sees as one character is kept or dropped whole.

A string can never hold more grapheme clusters than UTF-16 units, so a line short enough to be under
the cap by .length is under it by clusters too and returns without segmenting at all. That is
nearly every line a roster draws, which keeps the expensive part off the common path.

The three caller constants were named ..._CODE_POINTS. The unit changed, so the names did — that
is the whole of the diff outside text.ts.

Verification

bun test server/tests/channel-one-line.test.ts

  • With server/src/channels/text.ts reverted to main and only the new test applied: 5 fail, 4 pass.
  • With the change: 9 pass, 0 fail.

The four that pass before and after are the guard against over-correcting: short text returned
untouched, control characters flattened and whitespace collapsed as before, plain text cut in exactly
the same place with the same ellipsis, and a single-code-point emoji kept whole. If this change had
moved the cut for ordinary text, those four would say so.

The emoji cases do not assert one hand-picked boundary. They sweep every cap from 1 past the end of
the line and assert that what survives is a whole number of the input's own clusters taken from the
front — a property no half-emoji can satisfy.

bun run --filter server typecheck and bunx biome check on all four touched files are clean.

bun test server/tests/channel-routes.test.ts server/tests/channel-titler.test.ts server/tests/channel-activity-input.test.ts gives 58 pass / 29 fail both on main and on this branch — identical. The 29 are Postgres connection failures on a machine with no database, not a regression.

Note

The CHANGELOG.md entry lands at the top of ## Unreleased, the same anchor as #450 and #452, so
whichever merges last needs a one-line rebase. Happy to do it on request.

`oneLine` promises in its own docstring that "an emoji is never split", and
cuts with `Array.from`, which walks code points. That holds only for an emoji
that is one code point. Measured on `main`:

  oneLine("<KR flag> report", 2)      -> a lone regional indicator, boxed "K"
  oneLine("<family> hello", 3)        -> the man, then a dangling zero-width joiner
  oneLine("<thumb+tone> ok", 2)       -> the thumb, its skin tone dropped
  oneLine("<keycap 1> first", 2)      -> a bare "1"

The line is what the sidebar draws under a channel, what the generated title is
trimmed to, and what the titler is shown as an excerpt, so a message opening
with a flag rendered as a letter in a box in all three.

Cut between grapheme clusters instead, via `Intl.Segmenter`. A string cannot
hold more clusters than UTF-16 units, so a line short enough to be under the cap
returns without segmenting at all, which is nearly every line a roster draws.

The three caller constants said `CODE_POINTS`; the unit changed, so the names
did.

New tests: 5 of 9 fail against `main`. The other four are the guard against
over-correcting -- short text untouched, control characters flattened, plain
text cut in the same place, a single-code-point emoji kept whole -- and they
pass both before and after.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep-reviewed against live code (correctness, governance, no vendor/secret/scale issues). Composed build+tests green. CHANGELOG/format rebase on CI-validated substance.

@davidmckayv
davidmckayv merged commit 5eea77e into CopilotKit:main Sep 9, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants