From 92343ef1f6eaeb278215e1d4bb3a13a37d455ddd Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 8 Jul 2026 12:07:05 +0800 Subject: [PATCH] feat(ui): surface friendly agent and project failure messages Add friendlyAgentErrors to map raw agent/image errors to user-facing copy, and use it for timeline error bubbles and project failure banners. Redirect home with a toast when a project returns 403/404 instead of showing a generic error, and render the final agent response inline under skill progress. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/CanvasAgentTimeline/index.css | 180 ++++++++++++++++-- .../components/CanvasAgentTimeline/index.tsx | 135 +++++++++---- frontend/src/ui/lib/friendlyAgentErrors.ts | 118 ++++++++++++ .../canvas/hooks/useGenerationStream.ts | 4 +- .../src/ui/pages/CanvasWorkspace/index.tsx | 19 +- 5 files changed, 406 insertions(+), 50 deletions(-) create mode 100644 frontend/src/ui/lib/friendlyAgentErrors.ts 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 && ( -
-
- - {currentStep.title} -
- {cleanMessageContent(currentStep.content) && ( + {showThinking && ( +
+ + {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}}