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.
This commit is contained in:
l0ng-ai
2026-11-11 15:33:53 +08:00
parent 18b286e7e0
commit 71072dbe4b
3 changed files with 86 additions and 17 deletions
+7 -2
View File
@@ -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 (
<div className={styles.process}>
<Fold
@@ -1206,7 +1211,7 @@ function ThoughtEntry({
}
>
<div className={styles.thoughtBody}>
<Markdown text={text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
<Markdown text={shown} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
</div>
</Fold>
</div>
+39 -5
View File
@@ -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", () => {
+40 -10
View File
@@ -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;
}