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");
|
||||
|
||||
Reference in New Issue
Block a user