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} />}
+118
View File
@@ -0,0 +1,118 @@
import type { AgentMessage } from "@/domain/design";
type ErrorScope = "image" | "partial-image" | "image-action" | "text-extraction" | "mockup-model" | "layer-merge";
export type FriendlyAgentError = {
title: string;
content: string;
};
export function friendlyAgentErrorMessage(message: Pick<AgentMessage, "role" | "title" | "content" | "name" | "toolHint" | "status">): FriendlyAgentError {
const rawTitle = cleanErrorText(message.title);
const rawContent = cleanErrorText(message.content);
const combined = `${rawTitle}\n${rawContent}`;
const scope = inferErrorScope(message, combined);
const title = friendlyErrorTitle(scope);
if (message.role === "error" && rawContent && !isInternalFailureText(combined)) {
return { title, content: rawContent };
}
return {
title,
content: fallbackErrorContent(scope, combined)
};
}
export function friendlyProjectFailureMessage(messages: AgentMessage[]) {
const failure = [...messages].reverse().find((message) => message.role === "error" || message.status === "failed");
if (!failure) return "";
const friendly = friendlyAgentErrorMessage(failure);
return friendly.content || friendly.title;
}
function inferErrorScope(message: Pick<AgentMessage, "title" | "content" | "name" | "toolHint">, text: string): ErrorScope {
const normalized = text.toLowerCase();
const toolName = `${message.name || ""} ${message.toolHint || ""}`.toLowerCase();
if (text.includes("部分")) return "partial-image";
if (text.includes("文字") || toolName.includes("text_extraction")) return "text-extraction";
if (text.includes("Mockup") || text.includes("mockup") || toolName.includes("mockup_model")) return "mockup-model";
if (text.includes("合并图层") || normalized.includes("merge")) return "layer-merge";
if (text.includes("图片处理") || toolName.includes("canvas_action")) return "image-action";
return "image";
}
function friendlyErrorTitle(scope: ErrorScope) {
switch (scope) {
case "partial-image":
return "图片生成没有全部完成";
case "image-action":
return "图片处理没有完成";
case "text-extraction":
return "文字提取没有完成";
case "mockup-model":
return "Mockup 模型没有完成";
case "layer-merge":
return "图层合并没有完成";
default:
return "图片生成没有完成";
}
}
function fallbackErrorContent(scope: ErrorScope, text: string) {
const timeout = isTimeoutLikeFailure(text);
const temporary = isTemporaryFailure(text);
switch (scope) {
case "partial-image":
if (timeout) return "部分图片已经生成完成,剩余图片耗时较久没有返回。已保留成功结果,可以只重试失败的部分。";
return "部分图片已经生成完成,剩余图片没有成功生成。已保留成功结果,可以只重试失败的部分。";
case "image-action":
if (timeout) return "图片处理耗时较久,这次没有成功完成。原图已保留,可以稍后重试。";
if (temporary) return "图片处理服务暂时繁忙。原图已保留,可以稍后重试。";
return "图片处理没有成功完成。原图已保留,可以重新发起处理。";
case "text-extraction":
if (timeout) return "文字提取耗时较久,这次没有成功完成。图片状态已恢复,可以稍后重试。";
return "文字提取没有成功完成。图片状态已恢复,可以稍后重试。";
case "mockup-model":
if (timeout) return "Mockup 模型生成耗时较久,这次没有成功完成。原图已保留,可以稍后重试。";
return "Mockup 模型没有成功生成。原图已保留,可以稍后重试。";
case "layer-merge":
if (timeout) return "图层合并耗时较久,这次没有成功完成。原图层已保留,可以稍后重试。";
return "图层合并没有成功完成。原图层已保留,可以稍后重试。";
default:
if (timeout) return "图片生成耗时较久,这次没有成功完成。画布内容已保留,可以直接重试,或先降低图片数量/尺寸后再生成。";
if (temporary) return "图片生成服务暂时繁忙。画布内容已保留,可以稍后重试。";
return "图片生成没有成功完成。画布内容已保留,可以重新发起生成。";
}
}
function isInternalFailureText(text: string) {
const normalized = text.toLowerCase();
return (
/https?:\/\//i.test(text) ||
/\b\d{1,3}(?:\.\d{1,3}){3}\b/.test(text) ||
normalized.includes("/v1/images/") ||
normalized.includes("client.timeout") ||
normalized.includes("context deadline") ||
normalized.includes("awaiting headers") ||
normalized.includes("upstream") ||
normalized.includes("failed:") ||
normalized.includes(" post ") ||
normalized.includes(" get ")
);
}
function isTimeoutLikeFailure(text: string) {
const normalized = text.toLowerCase();
return normalized.includes("deadline") || normalized.includes("timeout") || normalized.includes("timed out") || normalized.includes("awaiting headers") || normalized.includes("lease expired") || text.includes("超时") || text.includes("耗时");
}
function isTemporaryFailure(text: string) {
const normalized = text.toLowerCase();
return normalized.includes("overload") || normalized.includes("too many requests") || normalized.includes("rate limit") || normalized.includes("temporar") || normalized.includes("service unavailable") || normalized.includes("bad gateway") || normalized.includes("gateway") || text.includes("繁忙");
}
function cleanErrorText(value: string) {
return String(value || "").trim();
}
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, type Dispatch, type RefObject, type Set
import type { AgentChatPayload, AgentMessage, Project } from "@/domain/design";
import { designGateway } from "@/infrastructure/designGateway";
import { useUserMessage } from "@/ui/hooks/useUserMessage";
import { friendlyProjectFailureMessage } from "@/ui/lib/friendlyAgentErrors";
import { generatorTaskPollIntervalMs } from "@/ui/pages/CanvasWorkspace/canvas/generatorTaskPolling";
export type GenerationChannel = "agent" | "image-generator" | "canvas-action";
@@ -404,6 +405,5 @@ function isActiveCanvasWorkNode(node: Project["nodes"][number]) {
}
function projectFailureMessage(project: Project) {
const failure = [...project.messages].reverse().find((message) => message.role === "error" || message.status === "failed");
return failure?.content || failure?.title || "";
return friendlyProjectFailureMessage(project.messages);
}
@@ -8,6 +8,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import type { AgentContent, AgentMessage, AgentThreadSummary, CanvasNode, CanvasViewport, ExtractedTextLayer, ExtractNodeTextResponse, MockupModel, MockupModelMap, MockupPlacement, MockupSurface, NodeActionRequest, Project } from "@/domain/design";
import { useI18n, type Locale } from "@/i18n/i18n";
import { designGateway } from "@/infrastructure/designGateway";
import { httpStatusOf } from "@/infrastructure/userMessages";
import { useAuth } from "@/ui/auth/AuthProvider";
import { AccountManagementDialog } from "@/ui/components/AccountManagementDialog";
import { ArtboardPreview } from "@/ui/components/ArtboardPreview";
@@ -29,6 +30,7 @@ import { TextStyleToolbar, type TextStylePatch } from "@/ui/components/TextStyle
import { loadMotevaFontCatalog } from "@/ui/components/TextStyleToolbar/fontCatalog";
import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle";
import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments";
import { friendlyProjectFailureMessage } from "@/ui/lib/friendlyAgentErrors";
import { canvasImageUrl } from "@/ui/lib/imageDelivery";
import { visiblePromptText } from "@/ui/lib/promptImageReferences";
import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/agentMessageFilters";
@@ -257,6 +259,11 @@ function shouldBypassCanvasWheel(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest(canvasWheelGuardSelector));
}
function isProjectAccessDenied(err: unknown) {
const status = httpStatusOf(err);
return status === 403 || status === 404;
}
function expandTargetGeometryForNode(node: CanvasNode, options: ExpandOptions) {
const ratio = expandRatioValue(options.ratio, node.width / Math.max(node.height, 1));
const sourceRatio = node.width / Math.max(node.height, 1);
@@ -795,7 +802,14 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}
}
})
.catch((err: unknown) => setError(toUserMessageText(err, "generateError")));
.catch((err: unknown) => {
if (isProjectAccessDenied(err)) {
showToast("你没有访问该项目权限", "error");
openHome();
return;
}
setError(toUserMessageText(err, "generateError"));
});
}, [applyProject, projectId, startProjectEvents]);
useEffect(() => {
@@ -5330,8 +5344,7 @@ function projectHasWebSearchEnabled(project: Project) {
}
function projectFailureMessage(project: Project) {
const failure = [...project.messages].reverse().find((message) => message.role === "error" || message.status === "failed");
return failure?.content || failure?.title || "";
return friendlyProjectFailureMessage(project.messages);
}
function runningCanvasActionThreadIds(project: Project) {