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;
}