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