feat(ui): add typewriter effect for streaming agent text

Add a useTypewriterText hook that reveals streamed content character by
character and reconciles when the target text changes. Apply it to
markdown content and agent step copy, with a blinking caret while
streaming.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 15:08:21 +08:00
parent d7300ed0f6
commit 0eed208bbf
4 changed files with 147 additions and 3 deletions
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useEffect, useRef, useState } from "react";
const typewriterIntervalMs = 18;
export function useTypewriterText(text: string, active: boolean) {
const [visibleText, setVisibleText] = useState(() => (active ? "" : text));
const [settling, setSettling] = useState(false);
const targetTextRef = useRef(text);
const visibleTextRef = useRef(visibleText);
const animatedRef = useRef(active);
useEffect(() => {
visibleTextRef.current = visibleText;
}, [visibleText]);
useEffect(() => {
targetTextRef.current = text;
if (active) {
animatedRef.current = true;
setSettling(true);
setVisibleText((current) => {
const next = reconcileVisibleText(current, text);
visibleTextRef.current = next;
return next;
});
return;
}
if (!animatedRef.current) {
visibleTextRef.current = text;
setVisibleText(text);
setSettling(false);
return;
}
const next = reconcileVisibleText(visibleTextRef.current, text);
if (next !== visibleTextRef.current) {
visibleTextRef.current = next;
setVisibleText(next);
}
if (next === text) {
animatedRef.current = false;
setSettling(false);
return;
}
setSettling(true);
}, [active, text]);
useEffect(() => {
if (!active && !settling) return;
const tick = () => {
const target = targetTextRef.current;
const current = visibleTextRef.current;
if (current === target) {
if (!active) {
animatedRef.current = false;
setSettling(false);
}
return;
}
const next = nextTypewriterText(current, target);
visibleTextRef.current = next;
setVisibleText(next);
if (next === target && !active) {
animatedRef.current = false;
setSettling(false);
}
};
const timer = window.setInterval(tick, typewriterIntervalMs);
tick();
return () => window.clearInterval(timer);
}, [active, settling]);
if (!active && !settling && !animatedRef.current) return text;
return visibleText;
}
function nextTypewriterText(current: string, target: string) {
const base = reconcileVisibleText(current, target);
const nextCharacter = Array.from(target.slice(base.length))[0] ?? "";
return `${base}${nextCharacter}`;
}
function reconcileVisibleText(current: string, target: string) {
if (!current || target.startsWith(current)) return current;
if (current.startsWith(target)) return target;
return commonPrefix(current, target);
}
function commonPrefix(left: string, right: string) {
let index = 0;
while (index < left.length && index < right.length && left[index] === right[index]) {
index += 1;
}
if (index > 0 && isHighSurrogate(right.charCodeAt(index - 1))) {
index -= 1;
}
return right.slice(0, index);
}
function isHighSurrogate(code: number) {
return code >= 0xd800 && code <= 0xdbff;
}