From 461faef584791e6d1d44aa3da23842f59d58cab9 Mon Sep 17 00:00:00 2001 From: Koi Date: Thu, 12 Nov 2026 17:54:45 -0500 Subject: [PATCH] fix(web): reply labels no longer cut an emoji in half Replying to an assistant response sliced the label to 64 UTF-16 units before sanitizing it, so an emoji at that boundary left a lone high surrogate in the label, the sent reference and the provider prompt. Back off one unit when the cut lands inside a surrogate pair. Refs #35660 --- .../src/lib/composerContextRecords.test.ts | 22 +++++++++++++++++++ apps/web/src/lib/composerContextRecords.ts | 10 ++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/composerContextRecords.test.ts b/apps/web/src/lib/composerContextRecords.test.ts index 4f119f1b45573..dff3f83a59554 100644 --- a/apps/web/src/lib/composerContextRecords.test.ts +++ b/apps/web/src/lib/composerContextRecords.test.ts @@ -4,6 +4,7 @@ import { MessageId, OrchestrationMessageContext, ThreadId, + TurnItemId, type PreviewAnnotationPayload, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; @@ -24,6 +25,7 @@ import { previewAnnotationFromRecord, pullRequestContextKindLabel, resolveUserMessageContext, + responseContextRecord, reviewCommentContextLabel, selectedMessageContextFragment, reviewCommentContextRecord, @@ -227,6 +229,26 @@ describe("composerContextRecords", () => { expect(record.label).toBe(`${"a".repeat(46)}…`); }); + it("shortens a long reply label without splitting an emoji", () => { + const text = `${"a".repeat(63)}šŸ˜€ trailing`; + const record = responseContextRecord({ + citation: { + version: 1, + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-1"), + text, + start: 0, + end: text.length, + prefix: "", + suffix: "", + }, + itemId: TurnItemId.make("item-1"), + subagents: [], + }); + expect(record.label).toBe("a".repeat(63)); + }); + it("restores multiple edits on one target without parsing display strings", () => { const styleChanges = [ ...annotation.styleChanges, diff --git a/apps/web/src/lib/composerContextRecords.ts b/apps/web/src/lib/composerContextRecords.ts index ac4256da894cf..a7ae316b5c865 100644 --- a/apps/web/src/lib/composerContextRecords.ts +++ b/apps/web/src/lib/composerContextRecords.ts @@ -48,6 +48,7 @@ import { import type { ReviewCommentContext } from "~/reviewCommentContext"; const PREVIEW_LABEL_MAX_CHARS = 48; +const RESPONSE_LABEL_MAX_CHARS = 64; // Truncate selections to wire bounds before persistence to avoid send-time encoding failures. const TRUNCATION_MARKER = "\n… truncated …"; @@ -178,6 +179,13 @@ export function responseContextReference(record: ResponseContextRecord): Compose return { kind: "response", contextId: record.contextId, label: record.label }; } +function responseContextLabel(text: string): string { + const last = text.charCodeAt(RESPONSE_LABEL_MAX_CHARS - 1); + const end = + last >= 0xd800 && last <= 0xdbff ? RESPONSE_LABEL_MAX_CHARS - 1 : RESPONSE_LABEL_MAX_CHARS; + return sanitizeComposerContextLabel(text.slice(0, end), "response"); +} + /** One record per response, so replying to it again reuses the same reference. */ export function responseContextRecord( input: Pick & { @@ -192,7 +200,7 @@ export function responseContextRecord( version: 1, kind: "response", contextId: toKindScopedComposerContextId("response", input.citation.messageId), - label: sanitizeComposerContextLabel(input.citation.text.slice(0, 64), "response"), + label: responseContextLabel(input.citation.text), citation: input.citation, itemId: input.itemId, ...(input.subagents.length > 0