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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user