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:
@@ -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} />}
|
||||
|
||||
Reference in New Issue
Block a user