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) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 12:07:05 +08:00
parent d13a4c9a48
commit 92343ef1f6
5 changed files with 406 additions and 50 deletions
@@ -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 {
@@ -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 ? (
<MotevaGeneratedResponse key={message.id} message={message} nodes={previewBatch} artifacts={artifactBatch} />
) : (
<MessageBubble key={message.id} message={message} />
);
});
if (finalMessages.length === 0 && artifacts.length > 0) {
responseItems.push(<MotevaGeneratedResponse key={`${turn.id}-artifacts`} artifacts={artifacts} />);
}
return (
<div className="agent-chat-session" key={turn.id} data-testid={`chat-session-${turn.id}`}>
@@ -73,22 +92,14 @@ export function CanvasAgentTimeline({ messages, nodes, active }: { messages: Age
)}
<div className="agent-chat-messages" data-testid="agent-chat-messages">
{showSkillProgress && <SkillProgress messages={turnMessages} active={turnActive} />}
{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 ? (
<MotevaGeneratedResponse key={message.id} message={message} nodes={previewBatch} artifacts={artifactBatch} />
) : (
<MessageBubble key={message.id} message={message} />
);
})}
{finalMessages.length === 0 && artifacts.length > 0 && <MotevaGeneratedResponse key={`${turn.id}-artifacts`} artifacts={artifacts} />}
{showSkillProgress && responseItems.length > 0 ? (
<div className="agent-tool-secondary agent-final-response">
<div className="agent-tool-line" aria-hidden="true" />
<div className="agent-tool-content agent-final-content">{responseItems}</div>
</div>
) : (
responseItems
)}
{errorMessages.map((message) => (
<MessageBubble key={message.id} message={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:
</div>
</section>
)}
{currentStep && (
<section className={`agent-tool-group agent-thinking-card ${active ? "current" : ""}`}>
<div className="agent-tool-title">
<span className="agent-step-marker" aria-hidden="true">
{active ? <Loader2 className="spin" size={14} /> : <Check size={13} />}
</span>
<span>{currentStep.title}</span>
</div>
{cleanMessageContent(currentStep.content) && (
{showThinking && (
<section className={`agent-tool-group agent-thinking-card ${active ? "current" : "complete"} ${thinkingContent ? "has-content" : ""}`}>
<AgentThinkingTitle label={thinkingLabel} active={active} expanded={Boolean(thinkingContent)} />
{thinkingContent && (
<div className="agent-tool-secondary">
<div className="agent-tool-line" aria-hidden="true" />
<div className="agent-tool-content agent-step-copy">
<p>{cleanMessageContent(currentStep.content)}</p>
<p>{thinkingContent}</p>
</div>
</div>
)}
@@ -280,6 +289,51 @@ function SkillProgress({ messages, active }: { messages: AgentMessage[]; active:
);
}
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">
<span className="agent-thinking-icon" aria-hidden="true">
<span className="agent-brain-icon">
<BrainGlyph />
</span>
<span className="agent-chevron-icon">
<ChevronGlyph />
</span>
</span>
<span className="agent-thinking-label">{label}</span>
</div>
);
}
function BrainGlyph() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" focusable="false">
<path
fill="currentColor"
fillOpacity="0.9"
d="M20.5 9.43a2.85 2.85 0 0 0-2.538-2.83l-.52-.056-.127-.507a3.363 3.363 0 0 0-4.565-2.274V7a2.5 2.5 0 0 0 2.5 2.5.75.75 0 0 1 0 1.5c-.946 0-1.815-.33-2.5-.879v9.923a3.85 3.85 0 0 0 1.82.456 3.88 3.88 0 0 0 3.726-2.807l.1-.35.336-.138a2.85 2.85 0 0 0 .913-4.669L19.1 12l.546-.536A2.84 2.84 0 0 0 20.5 9.43m-17 0c0 .797.326 1.516.854 2.034l.547.536-.547.536a2.849 2.849 0 0 0 .913 4.669l.337.138.1.35A3.88 3.88 0 0 0 9.431 20.5a3.85 3.85 0 0 0 1.819-.456V17a2.5 2.5 0 0 0-2.5-2.5.75.75 0 0 1 0-1.5c.946 0 1.815.329 2.5.878V3.763a3.363 3.363 0 0 0-4.565 2.274l-.127.507-.52.056A2.85 2.85 0 0 0 3.5 9.43m18.5 0c0 .962-.314 1.85-.843 2.57a4.345 4.345 0 0 1-1.53 6.44A5.375 5.375 0 0 1 12 21.344a5.376 5.376 0 0 1-7.629-2.903A4.345 4.345 0 0 1 2.841 12a4.348 4.348 0 0 1 2.536-6.808A4.86 4.86 0 0 1 12 2.458a4.861 4.861 0 0 1 6.621 2.734A4.35 4.35 0 0 1 22 9.431"
/>
</svg>
);
}
function ChevronGlyph() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" focusable="false">
<path
fill="currentColor"
fillOpacity="0.9"
d="M9.53 20.53a.75.75 0 1 1-1.06-1.06l6.586-6.586a1.25 1.25 0 0 0 0-1.768L8.47 4.53a.75.75 0 1 1 1.06-1.06l6.586 6.586a2.75 2.75 0 0 1 0 3.888z"
/>
</svg>
);
}
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<number | null>(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 (
<article className="message-bubble error" role="status">
<span className="message-error-icon" aria-hidden="true">
<CircleAlert size={16} />
</span>
<div className="message-error-copy">
<strong>{title}</strong>
<MarkdownContent content={content} />
</div>
</article>
);
}
return (
<article className={`message-bubble ${message.role} copyable ${copyButtonVisible ? "show-copy" : ""}`} onMouseEnter={showCopyButton} onMouseLeave={scheduleHideCopyButton}>
{message.title && <strong>{message.title}</strong>}
{title && <strong>{title}</strong>}
<MarkdownContent content={content} streaming={message.status === "streaming"} />
<button className="message-copy-button" type="button" aria-label="Copy message" onClick={() => void copyMessage()}>
{copied ? <Check size={14} /> : <Copy size={14} />}