From 0eed208bbf6b09cb7ac058fa3b95e93c9bcf707d Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 8 Jul 2026 15:08:21 +0800 Subject: [PATCH] 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) --- .../components/CanvasAgentTimeline/index.css | 16 +++ .../components/CanvasAgentTimeline/index.tsx | 15 ++- .../ui/components/MarkdownContent/index.tsx | 8 +- frontend/src/ui/hooks/useTypewriterText.ts | 111 ++++++++++++++++++ 4 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 frontend/src/ui/hooks/useTypewriterText.ts diff --git a/frontend/src/ui/components/CanvasAgentTimeline/index.css b/frontend/src/ui/components/CanvasAgentTimeline/index.css index d8066fa..17ee24a 100644 --- a/frontend/src/ui/components/CanvasAgentTimeline/index.css +++ b/frontend/src/ui/components/CanvasAgentTimeline/index.css @@ -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; diff --git a/frontend/src/ui/components/CanvasAgentTimeline/index.tsx b/frontend/src/ui/components/CanvasAgentTimeline/index.tsx index 89dd32d..01c6fb5 100644 --- a/frontend/src/ui/components/CanvasAgentTimeline/index.tsx +++ b/frontend/src/ui/components/CanvasAgentTimeline/index.tsx @@ -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 && (
- {thinkingContent && ( + {currentStep && thinkingContent && (
)} @@ -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 ( +

+ {visibleContent} + {streaming &&

+ ); +} + function AgentThinkingTitle({ label, active, expanded }: { label: string; active: boolean; expanded: boolean }) { return (
diff --git a/frontend/src/ui/components/MarkdownContent/index.tsx b/frontend/src/ui/components/MarkdownContent/index.tsx index a8a7316..e3b94ae 100644 --- a/frontend/src/ui/components/MarkdownContent/index.tsx +++ b/frontend/src/ui/components/MarkdownContent/index.tsx @@ -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 (
{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"); diff --git a/frontend/src/ui/hooks/useTypewriterText.ts b/frontend/src/ui/hooks/useTypewriterText.ts new file mode 100644 index 0000000..d90f520 --- /dev/null +++ b/frontend/src/ui/hooks/useTypewriterText.ts @@ -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; +}