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
@@ -328,6 +328,16 @@
line-height: 19px;
}
.agent-step-caret {
width: 6px;
height: 1em;
display: inline-block;
margin-left: 2px;
background: currentColor;
vertical-align: -0.15em;
animation: agent-step-caret 1s steps(1) infinite;
}
@keyframes agent-thinking-enter {
from {
opacity: 0;
@@ -350,6 +360,12 @@
}
}
@keyframes agent-step-caret {
50% {
opacity: 0;
}
}
@keyframes agent-thinking-shimmer {
from {
background-position: 120% 0;
@@ -5,6 +5,7 @@ import { Check, CircleAlert, Copy, Download, Eye, Globe2, Heart } from "lucide-r
import type { AgentMessage, CanvasNode } from "@/domain/design";
import { useI18n } from "@/i18n/i18n";
import { MarkdownContent } from "@/ui/components/MarkdownContent";
import { useTypewriterText } from "@/ui/hooks/useTypewriterText";
import { friendlyAgentErrorMessage } from "@/ui/lib/friendlyAgentErrors";
import { canvasImageUrl, thumbnailImageUrl } from "@/ui/lib/imageDelivery";
import { parsePromptImageReferences, serializePromptImageReferences, type PromptImageReference } from "@/ui/lib/promptImageReferences";
@@ -275,11 +276,11 @@ function SkillProgress({ messages, active }: { messages: AgentMessage[]; active:
{showThinking && (
<section className={`agent-tool-group agent-thinking-card ${active ? "current" : "complete"} ${thinkingContent ? "has-content" : ""}`}>
<AgentThinkingTitle label={thinkingLabel} active={active} expanded={Boolean(thinkingContent)} />
{thinkingContent && (
{currentStep && thinkingContent && (
<div className="agent-tool-secondary">
<div className="agent-tool-line" aria-hidden="true" />
<div className="agent-tool-content agent-step-copy">
<p>{thinkingContent}</p>
<AgentStepCopy key={currentStep.id} content={thinkingContent} streaming={active} />
</div>
</div>
)}
@@ -289,6 +290,16 @@ function SkillProgress({ messages, active }: { messages: AgentMessage[]; active:
);
}
function AgentStepCopy({ content, streaming }: { content: string; streaming: boolean }) {
const visibleContent = useTypewriterText(content, streaming);
return (
<p>
{visibleContent}
{streaming && <span className="agent-step-caret" aria-hidden="true" />}
</p>
);
}
function AgentThinkingTitle({ label, active, expanded }: { label: string; active: boolean; expanded: boolean }) {
return (
<div className={`agent-thinking-title ${active ? "is-thinking" : "is-complete"} ${expanded ? "is-expanded" : ""}`} data-testid="agent-chat-session-title">
@@ -1,6 +1,7 @@
"use client";
import { Fragment, type ReactNode } from "react";
import { useTypewriterText } from "@/ui/hooks/useTypewriterText";
import "./index.css";
type MarkdownBlock =
@@ -9,7 +10,8 @@ type MarkdownBlock =
| { type: "ol"; items: string[] };
export function MarkdownContent({ content, streaming = false }: { content: string; streaming?: boolean }) {
const blocks = parseMarkdownBlocks(content);
const visibleContent = useTypewriterText(content, streaming);
const blocks = parseMarkdownBlocks(normalizeEmojiSectionBreaks(visibleContent));
return (
<div className={`markdown-content ${streaming ? "streaming" : ""}`}>
{blocks.map((block, index) => {
@@ -38,6 +40,10 @@ export function MarkdownContent({ content, streaming = false }: { content: strin
);
}
function normalizeEmojiSectionBreaks(content: string) {
return content.replace(/([^\s\n])\s*((?:[\p{Extended_Pictographic}\p{Emoji_Presentation}]\uFE0F?(?:\u200D[\p{Extended_Pictographic}\p{Emoji_Presentation}]\uFE0F?)*)+)(?=[\p{L}\p{N}])/gu, "$1\n$2");
}
function parseMarkdownBlocks(content: string) {
const blocks: MarkdownBlock[] = [];
const lines = content.replace(/\r\n/g, "\n").split("\n");
+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;
}