From 42688692d49211511308fee71aeabaf7b2e6bd47 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 23:06:52 +0000 Subject: [PATCH 1/2] test(web): screen-reader announcement cutoffs keep emoji whole Failing tests for #35670: the 2,000-unit message cutoff and the 120-unit step cutoff in turnDetailAnnouncement split a surrogate pair. AI-assisted; verified with the tests and tools listed above. --- .../components/chat/turnDetailAnnouncement.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/src/components/chat/turnDetailAnnouncement.test.ts b/apps/web/src/components/chat/turnDetailAnnouncement.test.ts index c5eaa080ff921..c247e97d26ddf 100644 --- a/apps/web/src/components/chat/turnDetailAnnouncement.test.ts +++ b/apps/web/src/components/chat/turnDetailAnnouncement.test.ts @@ -133,6 +133,11 @@ describe("turnDetail", () => { expect(turnDetail(entries, undefined, null).activity).toBe(activity); }); + it("cuts a long step without splitting an emoji", () => { + const edit = work("edit", { itemType: "file_change", changedFiles: [`${"a".repeat(111)}😀z`] }); + expect(turnDetail([prompt, edit], undefined, null).activity?.isWellFormed()).toBe(true); + }); + it.each<{ name: string; entries: ReadonlyArray; @@ -297,6 +302,12 @@ describe("nextTurnDetailAnnouncement", () => { ).toEqual(["T3 Code: Tests pass in web. See the docs"]); }); + it("cuts a long message without splitting an emoji", () => { + const long = { ...said("long", true, true), text: `${"a".repeat(1999)}😀z` }; + const [spoken] = speak("messages", [frame(null), frame(null, [long])]); + expect(spoken?.isWellFormed()).toBe(true); + }); + it("says a step again after waiting on the user", () => { expect( speak("everything", [ From fe6b1a42da6a633a04d937b5bc4f0f4cdb098da0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 23:12:47 +0000 Subject: [PATCH 2/2] fix(web): long screen-reader announcements keep emoji whole at the cutoff Messages over 2,000 characters and steps over 120 were cut with String.slice on UTF-16 units, so an emoji at the limit left a lone surrogate in the live region. Cut on code points instead, as the shared truncate helper does. Fixes pingdotgg/t3code#35670 AI-assisted; verified with the tests and tools listed above. --- .../chat/turnDetailAnnouncement.test.ts | 4 ++-- .../components/chat/turnDetailAnnouncement.ts | 21 +++++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/chat/turnDetailAnnouncement.test.ts b/apps/web/src/components/chat/turnDetailAnnouncement.test.ts index c247e97d26ddf..99fcfcd4fd864 100644 --- a/apps/web/src/components/chat/turnDetailAnnouncement.test.ts +++ b/apps/web/src/components/chat/turnDetailAnnouncement.test.ts @@ -135,7 +135,7 @@ describe("turnDetail", () => { it("cuts a long step without splitting an emoji", () => { const edit = work("edit", { itemType: "file_change", changedFiles: [`${"a".repeat(111)}😀z`] }); - expect(turnDetail([prompt, edit], undefined, null).activity?.isWellFormed()).toBe(true); + expect(turnDetail([prompt, edit], undefined, null).activity).not.toMatch(/[\uD800-\uDFFF]/u); }); it.each<{ @@ -305,7 +305,7 @@ describe("nextTurnDetailAnnouncement", () => { it("cuts a long message without splitting an emoji", () => { const long = { ...said("long", true, true), text: `${"a".repeat(1999)}😀z` }; const [spoken] = speak("messages", [frame(null), frame(null, [long])]); - expect(spoken?.isWellFormed()).toBe(true); + expect(spoken).not.toMatch(/[\uD800-\uDFFF]/u); }); it("says a step again after waiting on the user", () => { diff --git a/apps/web/src/components/chat/turnDetailAnnouncement.ts b/apps/web/src/components/chat/turnDetailAnnouncement.ts index 0728674ad8744..acebdfb9f7003 100644 --- a/apps/web/src/components/chat/turnDetailAnnouncement.ts +++ b/apps/web/src/components/chat/turnDetailAnnouncement.ts @@ -34,6 +34,13 @@ const ACTIVITY_MAX_LENGTH = 120; // Parsing is synchronous, and nobody listens to a longer message through a live region. const MESSAGE_MAX_LENGTH = 2_000; +/** The first `maxLength` code points of `text`, or null when it has no more than that. */ +function cutText(text: string, maxLength: number): string | null { + if (text.length <= maxLength) return null; + const characters = Array.from(text); + return characters.length > maxLength ? characters.slice(0, maxLength).join("") : null; +} + // On screen these labels are a path or a query beside an icon. function withVerb(work: WorkLogEntry, label: string) { if (work.itemType === "file_change") return `Editing ${label}`; @@ -66,7 +73,8 @@ function stepLabel(work: WorkLogEntry, workspaceRoot: string | undefined): strin const label = liveWorkEntryLabel(work, workspaceRoot, true).replace(/\s+/g, " ").trim(); if (label === "") return null; const spoken = withVerb(work, label); - return spoken.length > ACTIVITY_MAX_LENGTH ? `${spoken.slice(0, ACTIVITY_MAX_LENGTH)}…` : spoken; + const cut = cutText(spoken, ACTIVITY_MAX_LENGTH); + return cut === null ? spoken : `${cut}…`; } /** @@ -189,11 +197,12 @@ export function nextTurnDetailAnnouncement( previous.messages.get(message.id) !== true && (message.fresh || previous.messages.has(message.id)), ) - .map((message) => - message.text.length > MESSAGE_MAX_LENGTH - ? `${markdownPlainText(message.text.slice(0, MESSAGE_MAX_LENGTH))} More in the thread.` - : markdownPlainText(message.text), - ) + .map((message) => { + const cut = cutText(message.text, MESSAGE_MAX_LENGTH); + return cut === null + ? markdownPlainText(message.text) + : `${markdownPlainText(cut)} More in the thread.`; + }) .filter((text) => text !== "") .map((text) => `${MESSAGE_AUTHOR}: ${text}`), activity: