From 71072dbe4bd925ac57bdb21bc600bd2b85597f62 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:33:53 +0800 Subject: [PATCH] fix(agents): pace streaming reasoning, cut only between graphemes, cap at 60 Hz - Reasoning in view streams at the reply's pace instead of in bursts. - The painted end of a streaming text falls between graphemes (Intl.Segmenter): an emoji with joiners or a skin tone, a flag or a letter with combining marks no longer flashes in parts. A chunk ending mid-cluster holds that fragment back until the rest arrives. - The reveal commits at most at 60 Hz, so a 120 Hz display doesn't re-render the Markdown twice as often. --- src/features/agents/SessionEditor.tsx | 9 +++-- src/features/agents/reveal.test.tsx | 44 ++++++++++++++++++++--- src/features/agents/reveal.ts | 50 +++++++++++++++++++++------ 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 642b7e7b6..2acd2a5e8 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -1185,8 +1185,13 @@ function ThoughtEntry({ working: boolean; onDiagramDrawn: () => void; }) { - const { thinking } = useAgentSettings(); + const { thinking, smoothText } = useAgentSettings(); const open = thinking === "expanded" || (thinking === "auto" && working); + // Streaming reasoning in view is revealed at the reply's pace; folded, nothing is painted. + const shown = useRevealedText(text, working && smoothText && open); + useLayoutEffect(() => { + if (working && open) onDiagramDrawn(); + }, [shown, working, open, onDiagramDrawn]); return (
- +
diff --git a/src/features/agents/reveal.test.tsx b/src/features/agents/reveal.test.tsx index e5c63212f..8c05edf01 100644 --- a/src/features/agents/reveal.test.tsx +++ b/src/features/agents/reveal.test.tsx @@ -1,6 +1,6 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { REVEAL_HORIZON_MS, revealStep, useRevealedText } from "./reveal"; +import { REVEAL_HORIZON_MS, revealBoundary, revealStep, useRevealedText, withoutBrokenTail } from "./reveal"; describe("revealStep", () => { it("drains the backlog over the horizon, at least a character a frame", () => { @@ -13,6 +13,25 @@ describe("revealStep", () => { }); }); +describe("revealBoundary", () => { + it("cuts only between graphemes", () => { + expect(revealBoundary("a👋b", 2)).toBe(1); + expect(revealBoundary("ae\u0301", 2)).toBe(1); + expect(revealBoundary("a🇯🇵", 3)).toBe(1); + expect(revealBoundary("abc", 2)).toBe(2); + }); +}); + +describe("withoutBrokenTail", () => { + it("holds back a cluster's first half until the rest arrives", () => { + expect(withoutBrokenTail("a\ud83d")).toBe("a"); + expect(withoutBrokenTail("a👨\u200d")).toBe("a👨"); + expect(withoutBrokenTail("a🇯")).toBe("a"); + expect(withoutBrokenTail("a🇯🇵")).toBe("a🇯🇵"); + expect(withoutBrokenTail("done")).toBe("done"); + }); +}); + describe("useRevealedText", () => { let frames: FrameRequestCallback[] = []; let now = 0; @@ -48,13 +67,28 @@ describe("useRevealedText", () => { expect(result.current.length).toBe(102); }); - it("never paints half an emoji", () => { + it("never paints part of a grapheme", () => { + const family = "👨‍👩‍👧"; const { result, rerender } = renderHook(({ text }) => useRevealedText(text, true), { initialProps: { text: "a" }, }); - rerender({ text: "a👋" }); - frame(1); - expect(result.current).toBe("a👋"); + rerender({ text: `a${family}` }); + for (let i = 0; i < 20 && result.current !== `a${family}`; i++) { + frame(16); + expect([`a`, `a${family}`]).toContain(result.current); + } + expect(result.current).toBe(`a${family}`); + }); + + it("commits at most at 60 Hz on a faster display", () => { + const { result, rerender } = renderHook(({ text }) => useRevealedText(text, true), { + initialProps: { text: "" }, + }); + rerender({ text: "x".repeat(1000) }); + frame(8); + expect(result.current).toBe(""); + frame(8); + expect(result.current.length).toBeGreaterThan(0); }); it("shows text whole once it stops streaming or is replaced", () => { diff --git a/src/features/agents/reveal.ts b/src/features/agents/reveal.ts index d1ae445cc..33511dad7 100644 --- a/src/features/agents/reveal.ts +++ b/src/features/agents/reveal.ts @@ -11,6 +11,11 @@ import { useEffect, useState } from "react"; export const REVEAL_HORIZON_MS = 150; /** A frame stalled longer than this (a background tab) counts as this long. */ const MAX_FRAME_MS = 250; +/** + * Commits at most at 60 Hz: a 120 Hz display would otherwise re-render the reply's Markdown + * twice as often for no visible gain. The slack absorbs frame jitter on a 60 Hz display. + */ +const FRAME_MS = 1000 / 60 - 2; /** Characters to reveal in a frame `elapsed` ms after the last, at least one while behind. */ export function revealStep(backlog: number, elapsed: number, horizon = REVEAL_HORIZON_MS): number { @@ -20,10 +25,36 @@ export function revealStep(backlog: number, elapsed: number, horizon = REVEAL_HO return Math.min(backlog, Math.max(1, Math.ceil((backlog * ms) / horizon))); } -/** `count` moved past a lone UTF-16 high surrogate, so an emoji never paints half. */ -function boundary(text: string, count: number): number { +const graphemes = + typeof Intl.Segmenter === "function" ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : null; + +/** + * `count` pulled back to the start of the grapheme it falls in, so a cluster (an emoji with + * its joiners or skin tone, a flag, a letter with its combining marks) never paints in parts. + * Only the painted end moves: the count still advances, so a long cluster can't stall. + */ +export function revealBoundary(text: string, count: number): number { + if (count <= 0 || count >= text.length) return Math.max(0, Math.min(count, text.length)); + if (graphemes) return graphemes.segment(text).containing(count)?.index ?? count; const c = text.charCodeAt(count - 1); - return c >= 0xd800 && c <= 0xdbff ? Math.min(count + 1, text.length) : count; + return c >= 0xd800 && c <= 0xdbff ? count - 1 : count; +} + +/** + * `text` without a trailing fragment that waits on the rest of its cluster: a chunk can end + * mid-cluster. A lone high surrogate, a trailing zero-width joiner, an unpaired regional + * indicator (half a flag). + */ +export function withoutBrokenTail(text: string): string { + const last = text.charCodeAt(text.length - 1); + if ((last >= 0xd800 && last <= 0xdbff) || last === 0x200d) return text.slice(0, -1); + let flags = 0; + for (let i = text.length; i >= 2; i -= 2) { + const cp = text.codePointAt(i - 2)!; + if (cp < 0x1f1e6 || cp > 0x1f1ff) break; + flags++; + } + return flags % 2 === 1 ? text.slice(0, -2) : text; } /** @@ -43,15 +74,14 @@ export function useRevealedText(text: string, live: boolean): string { if (!behind) return; let last = performance.now(); let frame = requestAnimationFrame(function tick(now) { - const elapsed = now - last; - last = now; - setState((s) => ({ - ...s, - count: boundary(s.text, s.count + revealStep(s.text.length - s.count, elapsed)), - })); frame = requestAnimationFrame(tick); + const elapsed = now - last; + if (elapsed < FRAME_MS) return; + last = now; + setState((s) => ({ ...s, count: s.count + revealStep(s.text.length - s.count, elapsed) })); }); return () => cancelAnimationFrame(frame); }, [behind]); - return behind ? text.slice(0, count) : text; + if (behind) return text.slice(0, revealBoundary(text, count)); + return live ? withoutBrokenTail(text) : text; }