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");