diff --git a/frontend/src/ui/components/CanvasAgentTimeline/index.css b/frontend/src/ui/components/CanvasAgentTimeline/index.css
index cd7d870..d8066fa 100644
--- a/frontend/src/ui/components/CanvasAgentTimeline/index.css
+++ b/frontend/src/ui/components/CanvasAgentTimeline/index.css
@@ -205,17 +205,114 @@
line-height: 1.45;
}
-.agent-step-marker {
+.agent-thinking-card {
+ animation: agent-thinking-enter 220ms ease-out both;
+}
+
+.agent-thinking-title {
+ min-width: 0;
+ max-width: 100%;
+ height: 24px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ overflow: hidden;
+ color: #747982;
+ font-size: 13px;
+ font-weight: 400;
+ line-height: 18px;
+ user-select: none;
+}
+
+.agent-thinking-icon {
+ position: relative;
width: 16px;
height: 16px;
display: grid;
place-items: center;
- flex: 0 0 auto;
- color: #6c7078;
+ flex: 0 0 16px;
+ color: #747982;
}
-.agent-thinking-card.current .agent-step-marker {
- color: #2f80ff;
+.agent-brain-icon,
+.agent-chevron-icon {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ transition:
+ opacity 300ms ease-in-out,
+ transform 300ms ease-in-out;
+}
+
+.agent-brain-icon svg,
+.agent-chevron-icon svg {
+ width: 16px;
+ height: 16px;
+ display: block;
+}
+
+.agent-chevron-icon {
+ opacity: 0;
+ transform: rotate(90deg) scale(0.5);
+}
+
+.agent-thinking-title.is-thinking.is-expanded .agent-brain-icon {
+ opacity: 0;
+ transform: scale(0.5);
+}
+
+.agent-thinking-title.is-thinking.is-expanded .agent-chevron-icon {
+ opacity: 1;
+ transform: rotate(90deg) scale(1);
+}
+
+.agent-thinking-title.is-complete .agent-brain-icon {
+ opacity: 1;
+ transform: scale(1);
+}
+
+.agent-thinking-title.is-complete .agent-chevron-icon {
+ opacity: 0;
+ transform: rotate(90deg) scale(0.5);
+}
+
+.agent-thinking-label {
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.agent-thinking-title.is-thinking .agent-thinking-label {
+ background-image: linear-gradient(90deg, #747982 0%, #747982 30%, #ffffff 50%, #747982 70%, #747982 100%);
+ background-size: 200% 100%;
+ background-clip: text;
+ color: transparent;
+ animation: agent-thinking-shimmer 1.45s linear infinite;
+ -webkit-background-clip: text;
+}
+
+.agent-thinking-card.has-content .agent-tool-content {
+ padding-top: 8px;
+ padding-bottom: 10px;
+ animation: agent-thinking-content-enter 240ms ease-out both;
+}
+
+.agent-final-response {
+ animation: agent-thinking-content-enter 240ms ease-out both;
+}
+
+.agent-final-content {
+ gap: 0;
+ padding-top: 8px;
+ padding-bottom: 20px;
+}
+
+.agent-final-content .message-bubble.assistant,
+.agent-final-content .moteva-response-card {
+ padding-top: 0;
}
.agent-step-copy {
@@ -231,13 +328,34 @@
line-height: 19px;
}
-.spin {
- animation: agent-spin 0.9s linear infinite;
+@keyframes agent-thinking-enter {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
-@keyframes agent-spin {
+@keyframes agent-thinking-content-enter {
+ from {
+ opacity: 0;
+ transform: translateY(-2px);
+ }
to {
- transform: rotate(360deg);
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes agent-thinking-shimmer {
+ from {
+ background-position: 120% 0;
+ }
+ to {
+ background-position: -80% 0;
}
}
@@ -480,11 +598,47 @@
.message-bubble.error {
width: 100%;
+ display: grid;
+ grid-template-columns: 22px minmax(0, 1fr);
+ align-items: start;
+ gap: 10px;
padding: 10px 12px;
- border: 1px solid #ffd7d3;
- border-radius: 8px;
- color: #9f1f17;
- background: #fff4f2;
+ border: 1px solid #ece4d7;
+ border-radius: 10px;
+ color: #4a3522;
+ background: #fffaf3;
+}
+
+.message-error-icon {
+ width: 22px;
+ height: 22px;
+ display: grid;
+ place-items: center;
+ border-radius: 999px;
+ color: #a15c1b;
+ background: #fff0d7;
+}
+
+.message-error-copy {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.message-bubble.error strong {
+ display: block;
+ color: #4a3522;
+ font-size: 14px;
+ font-weight: 650;
+ line-height: 20px;
+}
+
+.message-bubble.error p,
+.message-bubble.error .markdown-content {
+ color: #6a5746;
+ font-size: 13px;
+ line-height: 20px;
}
.message-reference-tag {
diff --git a/frontend/src/ui/components/CanvasAgentTimeline/index.tsx b/frontend/src/ui/components/CanvasAgentTimeline/index.tsx
index 61d6af2..89dd32d 100644
--- a/frontend/src/ui/components/CanvasAgentTimeline/index.tsx
+++ b/frontend/src/ui/components/CanvasAgentTimeline/index.tsx
@@ -1,10 +1,11 @@
"use client";
-import { Fragment, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
-import { Check, Copy, Download, Eye, Globe2, Heart, Loader2 } from "lucide-react";
+import { Fragment, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
+import { Check, CircleAlert, Copy, Download, Eye, Globe2, Heart } from "lucide-react";
import type { AgentMessage, CanvasNode } from "@/domain/design";
import { useI18n } from "@/i18n/i18n";
import { MarkdownContent } from "@/ui/components/MarkdownContent";
+import { friendlyAgentErrorMessage } from "@/ui/lib/friendlyAgentErrors";
import { canvasImageUrl, thumbnailImageUrl } from "@/ui/lib/imageDelivery";
import { parsePromptImageReferences, serializePromptImageReferences, type PromptImageReference } from "@/ui/lib/promptImageReferences";
import { isImageUrl } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps";
@@ -62,7 +63,25 @@ export function CanvasAgentTimeline({ messages, nodes, active }: { messages: Age
const finalMessages = turn.messages.filter(isAssistantFinalMessage);
const errorMessages = turn.messages.filter((message) => message.role === "error");
const artifacts = turn.messages.flatMap(parseImageArtifacts);
- const showSkillProgress = (progressMessages.length > 0 || hasResearchProgress) && errorMessages.length === 0;
+ const showSkillProgress = (turnActive || progressMessages.length > 0 || hasResearchProgress) && errorMessages.length === 0;
+ const responseItems: ReactNode[] = finalMessages.map((message) => {
+ const imageResponse = artifacts.length > 0 || isImageGenerationMessage(message) || isToolArtifactMessage(message);
+ const artifactBatch = imageResponse && artifacts.length > 0 ? artifacts : [];
+ const previewBatch =
+ imageResponse && artifactBatch.length === 0
+ ? completedNodes.slice(generatedNodeIndex, generatedNodeIndex + 1)
+ : completedNodes.slice(generatedNodeIndex, generatedNodeIndex + artifactBatch.length);
+ if (imageResponse) generatedNodeIndex += Math.max(1, artifactBatch.length);
+
+ return imageResponse ? (
+
+ ) : (
+
+ );
+ });
+ if (finalMessages.length === 0 && artifacts.length > 0) {
+ responseItems.push();
+ }
return (
@@ -73,22 +92,14 @@ export function CanvasAgentTimeline({ messages, nodes, active }: { messages: Age
)}
{showSkillProgress &&
}
- {finalMessages.map((message) => {
- const imageResponse = artifacts.length > 0 || isImageGenerationMessage(message) || isToolArtifactMessage(message);
- const artifactBatch = imageResponse && artifacts.length > 0 ? artifacts : [];
- const previewBatch =
- imageResponse && artifactBatch.length === 0
- ? completedNodes.slice(generatedNodeIndex, generatedNodeIndex + 1)
- : completedNodes.slice(generatedNodeIndex, generatedNodeIndex + artifactBatch.length);
- if (imageResponse) generatedNodeIndex += Math.max(1, artifactBatch.length);
-
- return imageResponse ? (
-
- ) : (
-
- );
- })}
- {finalMessages.length === 0 && artifacts.length > 0 &&
}
+ {showSkillProgress && responseItems.length > 0 ? (
+
+ ) : (
+ responseItems
+ )}
{errorMessages.map((message) => (
))}
@@ -221,13 +232,16 @@ function MotevaDateDivider({ value }: { value: string }) {
}
function SkillProgress({ messages, active }: { messages: AgentMessage[]; active: boolean }) {
- const { t } = useI18n();
+ const { locale, t } = useI18n();
const research = latestResearchPayload(messages);
const progressSteps = compactAgentProgressSteps(
messages.filter((message) => isProgressMessage(message) && !isResearchMessage(message) && !isToolArtifactMessage(message) && !isHeartbeatProgressMessage(message) && !isMutedProgressMessage(message))
);
const currentStep = preferredThinkingMessage(messages, progressSteps, active);
const hasResearchResults = (research?.results.length ?? 0) > 0;
+ const thinkingContent = currentStep ? cleanMessageContent(currentStep.content) : "";
+ const showThinking = Boolean(currentStep || active);
+ const thinkingLabel = active ? localizedThinkingLabel(locale, "running") : localizedThinkingLabel(locale, "done");
const researchTitle = research?.status === "running" ? t("searchingWeb") : hasResearchResults ? t("searchComplete") : research?.error ? t("searchFailed") : t("searchComplete");
@@ -258,19 +272,14 @@ function SkillProgress({ messages, active }: { messages: AgentMessage[]; active:
)}
- {currentStep && (
-
-
-
- {active ? : }
-
- {currentStep.title}
-
- {cleanMessageContent(currentStep.content) && (
+ {showThinking && (
+
+
+ {thinkingContent && (
-
{cleanMessageContent(currentStep.content)}
+
{thinkingContent}
)}
@@ -280,6 +289,51 @@ function SkillProgress({ messages, active }: { messages: AgentMessage[]; active:
);
}
+function AgentThinkingTitle({ label, active, expanded }: { label: string; active: boolean; expanded: boolean }) {
+ return (
+
+
+
+
+
+
+
+
+
+ {label}
+
+ );
+}
+
+function BrainGlyph() {
+ return (
+
+ );
+}
+
+function ChevronGlyph() {
+ return (
+
+ );
+}
+
+function localizedThinkingLabel(locale: string, state: "running" | "done") {
+ if (locale === "en-US") return state === "running" ? "Thinking..." : "Thinking complete";
+ return state === "running" ? "思考中..." : "思考完成";
+}
+
function preferredThinkingMessage(messages: AgentMessage[], progressSteps: AgentMessage[], active: boolean) {
if (active) {
const runtimeMessage = [...progressSteps].reverse().find((message) => cleanMessageContent(message.content) || message.title.trim());
@@ -397,7 +451,10 @@ function MessageBubble({ message }: { message: AgentMessage }) {
const [copied, setCopied] = useState(false);
const [copyButtonVisible, setCopyButtonVisible] = useState(false);
const hideCopyButtonTimerRef = useRef(null);
- const content = cleanMessageContent(message.content);
+ const rawContent = cleanMessageContent(message.content);
+ const friendlyError = message.role === "error" ? friendlyAgentErrorMessage({ ...message, content: rawContent }) : null;
+ const content = friendlyError?.content || rawContent;
+ const title = friendlyError?.title || message.title;
useEffect(() => {
return () => {
@@ -440,9 +497,23 @@ function MessageBubble({ message }: { message: AgentMessage }) {
);
}
+ if (message.role === "error") {
+ return (
+
+
+
+
+
+ {title}
+
+
+
+ );
+ }
+
return (
- {message.title && {message.title}}
+ {title && {title}}