From 97dca873a147f0b86a02b9a965239c61dcf3972c Mon Sep 17 00:00:00 2001
From: paisley <8197966+su8su@users.noreply.github.com>
Date: Fri, 29 May 2026 12:10:04 +0800
Subject: [PATCH] Fix image generate failed (#1080)
---
src/i18n/locales/en/chat.json | 4 +-
src/i18n/locales/ja/chat.json | 4 +-
src/i18n/locales/ru/chat.json | 4 +-
src/i18n/locales/zh/chat.json | 4 +-
src/pages/Chat/ChatMessage.tsx | 40 ++--
src/pages/Chat/index.tsx | 16 +-
src/pages/Chat/task-visualization.ts | 10 +
src/stores/chat.ts | 169 ++++++++++------
src/stores/chat/helpers.ts | 176 +++++++++++------
src/stores/chat/types.ts | 1 +
tests/e2e/chat-run-state-events.spec.ts | 75 ++++++++
tests/unit/chat-helpers-enrichment.test.ts | 76 ++++++++
tests/unit/chat-message.test.tsx | 45 +++++
tests/unit/chat-page-execution-graph.test.tsx | 180 +++++++++++++++++-
tests/unit/task-visualization.test.ts | 55 +++++-
15 files changed, 720 insertions(+), 139 deletions(-)
diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json
index df6328e..355cf9f 100644
--- a/src/i18n/locales/en/chat.json
+++ b/src/i18n/locales/en/chat.json
@@ -164,7 +164,9 @@
"collapseAction": "Collapse execution graph"
},
"imageGeneration": {
- "generating": "Generating image, please wait…"
+ "generating": "Generating image, please wait…",
+ "previewLoading": "Loading image preview…",
+ "previewUnavailable": "Image preview is temporarily unavailable"
},
"composer": {
"attachFiles": "Attach files",
diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json
index 9e49804..68dcb20 100644
--- a/src/i18n/locales/ja/chat.json
+++ b/src/i18n/locales/ja/chat.json
@@ -164,7 +164,9 @@
"collapseAction": "実行グラフを折りたたむ"
},
"imageGeneration": {
- "generating": "画像を生成しています。少々お待ちください…"
+ "generating": "画像を生成しています。少々お待ちください…",
+ "previewLoading": "画像プレビューを読み込んでいます…",
+ "previewUnavailable": "画像プレビューを一時的に表示できません"
},
"composer": {
"attachFiles": "ファイルを添付",
diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json
index 42bdfff..2056ad0 100644
--- a/src/i18n/locales/ru/chat.json
+++ b/src/i18n/locales/ru/chat.json
@@ -164,7 +164,9 @@
"collapseAction": "Свернуть граф выполнения"
},
"imageGeneration": {
- "generating": "Изображение генерируется, подождите…"
+ "generating": "Изображение генерируется, подождите…",
+ "previewLoading": "Загрузка предпросмотра изображения…",
+ "previewUnavailable": "Предпросмотр изображения временно недоступен"
},
"composer": {
"attachFiles": "Прикрепить файлы",
diff --git a/src/i18n/locales/zh/chat.json b/src/i18n/locales/zh/chat.json
index 4a77c2d..b0e1678 100644
--- a/src/i18n/locales/zh/chat.json
+++ b/src/i18n/locales/zh/chat.json
@@ -164,7 +164,9 @@
"collapseAction": "收起执行关系图"
},
"imageGeneration": {
- "generating": "图片生成中,请稍候…"
+ "generating": "图片生成中,请稍候…",
+ "previewLoading": "正在载入图片预览…",
+ "previewUnavailable": "图片预览暂时不可用"
},
"composer": {
"attachFiles": "添加文件",
diff --git a/src/pages/Chat/ChatMessage.tsx b/src/pages/Chat/ChatMessage.tsx
index 3276d5e..b8fb56a 100644
--- a/src/pages/Chat/ChatMessage.tsx
+++ b/src/pages/Chat/ChatMessage.tsx
@@ -7,6 +7,7 @@
*/
import { useState, useCallback, useEffect, memo } from 'react';
import { Sparkles, Copy, Check, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
@@ -407,12 +408,7 @@ export const ChatMessage = memo(function ChatMessage({
onPreview={() => setLightboxImg({ src: file.preview!, fileName: file.fileName, filePath: file.filePath, mimeType: file.mimeType })}
/>
) : (
-
-
-
+
);
}
// Non-image files → file card
@@ -469,11 +465,7 @@ export const ChatMessage = memo(function ChatMessage({
);
}
if (isImage && !file.preview) {
- return (
-
-
-
- );
+ return ;
}
return ;
})}
@@ -758,6 +750,32 @@ function FileCard({ file, onOpen }: { file: AttachedFileMeta; onOpen?: (file: At
);
}
+function ImagePreviewPlaceholder({ file }: { file: AttachedFileMeta }) {
+ const { t } = useTranslation('chat');
+ const unavailable = file.previewStatus === 'unavailable';
+ const label = unavailable
+ ? t('imageGeneration.previewUnavailable')
+ : t('imageGeneration.previewLoading');
+
+ return (
+
+ {unavailable ? (
+
+ ) : (
+
+ )}
+
{label}
+
+ );
+}
+
// ── Image Thumbnail (user bubble — square crop with zoom hint) ──
function ImageThumbnail({
diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx
index 7471c7f..9a2c85f 100644
--- a/src/pages/Chat/index.tsx
+++ b/src/pages/Chat/index.tsx
@@ -325,6 +325,10 @@ export function Chat() {
const hasRunningStreamToolStatus = streamingTools.some((tool) => tool.status === 'running');
const shouldRenderStreaming = sending && (hasStreamText || hasStreamTools || hasStreamImages || hasStreamToolStatus);
const hasAnyStreamContent = hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus;
+ const hasHistoryCompletionBlockingStream = hasStreamText
+ || hasStreamImages
+ || hasRunningStreamToolStatus
+ || streamTools.length > 0;
const isEmpty = messages.length === 0 && !sending;
const showScrollToLatest = !isEmpty && !isAtBottom;
@@ -417,10 +421,12 @@ export function Chat() {
// History may already contain the final answer while lifecycle flags are
// still armed (missing Gateway terminal phase, blocked chat.send RPC, etc.).
// Treat the run as closed for graph/input UI when the transcript is done
- // and nothing is actively streaming. Require prior tool activity so an early
- // narration-only history snapshot does not collapse the graph mid-chain.
+ // and no user-visible reply/tool stream is active. Require prior tool activity
+ // so an early narration-only history snapshot does not collapse the graph
+ // mid-chain. Thinking-only stale stream content should not keep image
+ // generation runs open after history already contains the final media.
const runCompletedInHistory = hasFinalReply
- && !hasAnyStreamContent
+ && !hasHistoryCompletionBlockingStream
&& (hasToolActivity || !sending);
const isLatestOpenRun = isLatestRunSegment
&& !runError
@@ -635,7 +641,7 @@ export function Chat() {
streamingReplyText,
suppressThinking,
}];
- }, [messages, subagentCompletionInfos, currentSessionKey, streamingMessage, streamingTools, pendingFinal, sending, hasAnyStreamContent, hasStreamText, hasStreamImages, streamText, streamTools.length, hasRunningStreamToolStatus, childTranscripts, currentAgentId, agents, sessionLabels, graphStepCache, runError, isRunTrigger]);
+ }, [messages, subagentCompletionInfos, currentSessionKey, streamingMessage, streamingTools, pendingFinal, sending, hasAnyStreamContent, hasStreamText, hasStreamImages, streamText, streamTools.length, hasRunningStreamToolStatus, hasHistoryCompletionBlockingStream, childTranscripts, currentAgentId, agents, sessionLabels, graphStepCache, runError, isRunTrigger]);
const hasActiveExecutionGraph = userRunCards.some((card) => card.active);
let latestRunSegmentCompletion = { hasFinalReply: false, hasToolActivity: false };
let pendingImageGeneration = false;
@@ -656,7 +662,7 @@ export function Chat() {
break;
}
const runSettledInHistory = latestRunSegmentCompletion.hasFinalReply
- && !hasAnyStreamContent
+ && !hasHistoryCompletionBlockingStream
&& (latestRunSegmentCompletion.hasToolActivity || !sending);
const inputRunActive = (sending || hasActiveExecutionGraph) && !runSettledInHistory;
const replyTextOverrides = useMemo(() => {
diff --git a/src/pages/Chat/task-visualization.ts b/src/pages/Chat/task-visualization.ts
index 664e7ea..98b0f89 100644
--- a/src/pages/Chat/task-visualization.ts
+++ b/src/pages/Chat/task-visualization.ts
@@ -1,4 +1,5 @@
import {
+ extractImages,
extractText,
extractTextSegments,
extractToolUse,
@@ -46,6 +47,7 @@ export function findReplyMessageIndex(messages: RawMessage[], hasStreamingReply:
const message = messages[idx];
if (!message || message.role !== 'assistant') continue;
const replyText = extractText(message).trim();
+ if (messageHasUserVisibleImage(message)) return idx;
if (replyText.length === 0 || isInternalAssistantReplyText(replyText)) continue;
if (isGeneratingStatusNarration(replyText)) continue;
return idx;
@@ -53,6 +55,13 @@ export function findReplyMessageIndex(messages: RawMessage[], hasStreamingReply:
return -1;
}
+function messageHasUserVisibleImage(message: RawMessage): boolean {
+ if ((message._attachedFiles ?? []).some((file) => file.mimeType.startsWith('image/'))) {
+ return true;
+ }
+ return extractImages(message).length > 0;
+}
+
/**
* When true, assistant history in the run segment should be folded into the
* execution graph because the live answer is (or will be) shown via streaming.
@@ -159,6 +168,7 @@ export function segmentHasFinalReply(segmentMessages: RawMessage[]): boolean {
return segmentMessages.some((message, index) => {
if (index <= lastToolUseOffset) return false;
if (message.role !== 'assistant') return false;
+ if (messageHasUserVisibleImage(message)) return true;
const replyText = extractText(message).trim();
if (replyText.length === 0 || isInternalAssistantReplyText(replyText)) return false;
if (isGeneratingStatusNarration(replyText)) return false;
diff --git a/src/stores/chat.ts b/src/stores/chat.ts
index 0d0812a..8a39039 100644
--- a/src/stores/chat.ts
+++ b/src/stores/chat.ts
@@ -1327,16 +1327,15 @@ function enrichWithCachedImages(messages: RawMessage[]): RawMessage[] {
});
}
-/**
- * Async: load missing previews from disk via IPC for messages that have
- * _attachedFiles with null previews. Updates messages in-place and triggers re-render.
- * Handles both [media attached: ...] patterns and raw filePath entries.
- */
-async function loadMissingPreviews(messages: RawMessage[]): Promise {
- // See helpers.ts loadMissingPreviews for the canonical comment block —
- // this monolithic copy is kept in sync so legacy chat.ts callers also
- // resolve Gateway-injected outgoing media URLs into local previews.
- type PreviewRef = { filePath?: string; gatewayUrl?: string; mimeType: string };
+type PreviewRef = { filePath?: string; gatewayUrl?: string; mimeType: string };
+
+const IMAGE_PREVIEW_RETRY_DELAYS_MS = [300, 900, 1800];
+
+function waitForPreviewRetry(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function collectMissingPreviewRefs(messages: RawMessage[]): PreviewRef[] {
const needPreview: PreviewRef[] = [];
const seenKeys = new Set();
@@ -1348,7 +1347,7 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise {
const key = file.filePath || file.gatewayUrl;
if (!key || seenKeys.has(key)) continue;
const needsLoad = file.mimeType.startsWith('image/')
- ? !file.preview
+ ? !file.preview && file.previewStatus !== 'unavailable'
: file.fileSize === 0;
if (!needsLoad) continue;
seenKeys.add(key);
@@ -1367,7 +1366,9 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise {
const file = msg._attachedFiles[i];
const ref = refs[i];
if (!file || !ref || seenKeys.has(ref.filePath)) continue;
- const needsLoad = ref.mimeType.startsWith('image/') ? !file.preview : file.fileSize === 0;
+ const needsLoad = ref.mimeType.startsWith('image/')
+ ? !file.preview && file.previewStatus !== 'unavailable'
+ : file.fileSize === 0;
if (needsLoad) {
seenKeys.add(ref.filePath);
needPreview.push({ filePath: ref.filePath, mimeType: ref.mimeType });
@@ -1376,61 +1377,115 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise {
}
}
- if (needPreview.length === 0) {
- return false;
- }
+ return needPreview;
+}
- try {
- const thumbnails = await hostApiFetch>(
- '/api/files/thumbnails',
- {
- method: 'POST',
- body: JSON.stringify({ paths: needPreview }),
- },
- );
+function applyPreviewResults(
+ messages: RawMessage[],
+ thumbnails: Record,
+): boolean {
+ let updated = false;
+ for (const msg of messages) {
+ if (!msg._attachedFiles) continue;
- let updated = false;
- for (const msg of messages) {
- if (!msg._attachedFiles) continue;
+ // Update files that have filePath OR gatewayUrl
+ for (const file of msg._attachedFiles) {
+ const key = file.filePath || file.gatewayUrl;
+ if (!key) continue;
+ const thumb = thumbnails[key];
+ if (thumb && (thumb.preview || thumb.fileSize)) {
+ if (thumb.preview) file.preview = thumb.preview;
+ if (thumb.fileSize) file.fileSize = thumb.fileSize;
+ delete file.previewStatus;
+ if (file.filePath) {
+ _imageCache.set(file.filePath, { ...file });
+ }
+ updated = true;
+ }
+ }
- // Update files that have filePath OR gatewayUrl
- for (const file of msg._attachedFiles) {
- const key = file.filePath || file.gatewayUrl;
- if (!key) continue;
- const thumb = thumbnails[key];
+ // Legacy: update by index for [media attached: ...] refs
+ if (msg.role === 'user') {
+ const text = getMessageText(msg.content);
+ const refs = extractMediaRefs(text);
+ for (let i = 0; i < refs.length; i++) {
+ const file = msg._attachedFiles[i];
+ const ref = refs[i];
+ if (!file || !ref || file.filePath) continue; // skip if already handled via filePath
+ const thumb = thumbnails[ref.filePath];
if (thumb && (thumb.preview || thumb.fileSize)) {
if (thumb.preview) file.preview = thumb.preview;
if (thumb.fileSize) file.fileSize = thumb.fileSize;
- if (file.filePath) {
- _imageCache.set(file.filePath, { ...file });
- }
+ delete file.previewStatus;
+ _imageCache.set(ref.filePath, { ...file });
updated = true;
}
}
-
- // Legacy: update by index for [media attached: ...] refs
- if (msg.role === 'user') {
- const text = getMessageText(msg.content);
- const refs = extractMediaRefs(text);
- for (let i = 0; i < refs.length; i++) {
- const file = msg._attachedFiles[i];
- const ref = refs[i];
- if (!file || !ref || file.filePath) continue; // skip if already handled via filePath
- const thumb = thumbnails[ref.filePath];
- if (thumb && (thumb.preview || thumb.fileSize)) {
- if (thumb.preview) file.preview = thumb.preview;
- if (thumb.fileSize) file.fileSize = thumb.fileSize;
- _imageCache.set(ref.filePath, { ...file });
- updated = true;
- }
- }
- }
}
- if (updated) saveImageCache(_imageCache);
- return updated;
- } catch (err) {
- console.warn('[loadMissingPreviews] Failed:', err);
- return false;
+ }
+
+ if (updated) saveImageCache(_imageCache);
+ return updated;
+}
+
+function markMissingImagePreviewsUnavailable(messages: RawMessage[]): boolean {
+ let updated = false;
+ for (const msg of messages) {
+ if (!msg._attachedFiles) continue;
+ for (const file of msg._attachedFiles) {
+ if (!file.mimeType.startsWith('image/')) continue;
+ if (file.preview || file.previewStatus === 'unavailable') continue;
+ if (!file.filePath && !file.gatewayUrl) continue;
+ file.previewStatus = 'unavailable';
+ updated = true;
+ }
+ }
+ return updated;
+}
+
+/**
+ * Async: load missing previews from disk via IPC for messages that have
+ * _attachedFiles with null previews. Updates messages in-place and triggers re-render.
+ * Handles both [media attached: ...] patterns and raw filePath entries.
+ */
+async function loadMissingPreviews(messages: RawMessage[]): Promise {
+ // See helpers.ts loadMissingPreviews for the canonical comment block —
+ // this monolithic copy is kept in sync so legacy chat.ts callers also
+ // resolve Gateway-injected outgoing media URLs into local previews.
+ let updatedAny = false;
+ let attempt = 0;
+
+ while (true) {
+ const needPreview = collectMissingPreviewRefs(messages);
+ if (needPreview.length === 0) return updatedAny;
+ if (attempt > 0) {
+ const delayMs = IMAGE_PREVIEW_RETRY_DELAYS_MS[attempt - 1];
+ if (delayMs) await waitForPreviewRetry(delayMs);
+ }
+
+ try {
+ const thumbnails = await hostApiFetch>(
+ '/api/files/thumbnails',
+ {
+ method: 'POST',
+ body: JSON.stringify({ paths: needPreview }),
+ },
+ );
+ if (applyPreviewResults(messages, thumbnails)) {
+ updatedAny = true;
+ }
+ } catch (err) {
+ console.warn('[loadMissingPreviews] Failed:', err);
+ return updatedAny;
+ }
+
+ if (!collectMissingPreviewRefs(messages).some((ref) => ref.mimeType.startsWith('image/'))) {
+ return updatedAny;
+ }
+ if (attempt >= IMAGE_PREVIEW_RETRY_DELAYS_MS.length) {
+ return markMissingImagePreviewsUnavailable(messages) || updatedAny;
+ }
+ attempt += 1;
}
}
diff --git a/src/stores/chat/helpers.ts b/src/stores/chat/helpers.ts
index 296b4a3..f6d5fce 100644
--- a/src/stores/chat/helpers.ts
+++ b/src/stores/chat/helpers.ts
@@ -1096,20 +1096,22 @@ function enrichWithCachedImages(messages: RawMessage[]): RawMessage[] {
});
}
-/**
- * Async: load missing previews from disk via IPC for messages that have
- * _attachedFiles with null previews. Updates messages in-place and triggers re-render.
- * Handles both [media attached: ...] patterns and raw filePath entries.
- */
-async function loadMissingPreviews(messages: RawMessage[]): Promise {
- // Collect all image refs that need previews. The IPC handler accepts:
- // - { filePath, mimeType } — local on-disk files
- // - { gatewayUrl, mimeType } — Gateway-injected outgoing media; the
- // handler resolves the URL to a local file
- // via `~/.openclaw/media/outgoing/records/`.
- // We use `filePath || gatewayUrl` as the dedupe / lookup key on the way
- // back; a file always carries at most one of the two.
- type PreviewRef = { filePath?: string; gatewayUrl?: string; mimeType: string };
+type PreviewRef = { filePath?: string; gatewayUrl?: string; mimeType: string };
+
+const IMAGE_PREVIEW_RETRY_DELAYS_MS = [300, 900, 1800];
+
+function waitForPreviewRetry(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+// Collect all image refs that need previews. The IPC handler accepts:
+// - { filePath, mimeType } — local on-disk files
+// - { gatewayUrl, mimeType } — Gateway-injected outgoing media; the
+// handler resolves the URL to a local file
+// via `~/.openclaw/media/outgoing/records/`.
+// We use `filePath || gatewayUrl` as the dedupe / lookup key on the way
+// back; a file always carries at most one of the two.
+function collectMissingPreviewRefs(messages: RawMessage[]): PreviewRef[] {
const needPreview: PreviewRef[] = [];
const seenKeys = new Set();
@@ -1122,7 +1124,7 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise {
if (!key || seenKeys.has(key)) continue;
// Images: need preview. Non-images: need file size (for FileCard display).
const needsLoad = file.mimeType.startsWith('image/')
- ? !file.preview
+ ? !file.preview && file.previewStatus !== 'unavailable'
: file.fileSize === 0;
if (!needsLoad) continue;
seenKeys.add(key);
@@ -1141,7 +1143,9 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise {
const file = msg._attachedFiles[i];
const ref = refs[i];
if (!file || !ref || seenKeys.has(ref.filePath)) continue;
- const needsLoad = ref.mimeType.startsWith('image/') ? !file.preview : file.fileSize === 0;
+ const needsLoad = ref.mimeType.startsWith('image/')
+ ? !file.preview && file.previewStatus !== 'unavailable'
+ : file.fileSize === 0;
if (needsLoad) {
seenKeys.add(ref.filePath);
needPreview.push({ filePath: ref.filePath, mimeType: ref.mimeType });
@@ -1150,59 +1154,113 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise {
}
}
- if (needPreview.length === 0) return false;
+ return needPreview;
+}
- try {
- const thumbnails = await invokeIpc(
- 'media:getThumbnails',
- needPreview,
- ) as Record;
+function applyPreviewResults(
+ messages: RawMessage[],
+ thumbnails: Record,
+): boolean {
+ let updated = false;
- let updated = false;
- for (const msg of messages) {
- if (!msg._attachedFiles) continue;
+ for (const msg of messages) {
+ if (!msg._attachedFiles) continue;
- // Update files that have filePath OR gatewayUrl
- for (const file of msg._attachedFiles) {
- const key = file.filePath || file.gatewayUrl;
- if (!key) continue;
- const thumb = thumbnails[key];
+ // Update files that have filePath OR gatewayUrl
+ for (const file of msg._attachedFiles) {
+ const key = file.filePath || file.gatewayUrl;
+ if (!key) continue;
+ const thumb = thumbnails[key];
+ if (thumb && (thumb.preview || thumb.fileSize)) {
+ if (thumb.preview) file.preview = thumb.preview;
+ if (thumb.fileSize) file.fileSize = thumb.fileSize;
+ delete file.previewStatus;
+ // Only persist local-path entries to the localStorage cache.
+ // Gateway outgoing URLs are tied to a specific session/attachment
+ // id and can be stale across runs, so caching is harmful.
+ if (file.filePath) {
+ _imageCache.set(file.filePath, { ...file });
+ }
+ updated = true;
+ }
+ }
+
+ // Legacy: update by index for [media attached: ...] refs
+ if (msg.role === 'user') {
+ const text = getMessageText(msg.content);
+ const refs = extractMediaRefs(text);
+ for (let i = 0; i < refs.length; i++) {
+ const file = msg._attachedFiles[i];
+ const ref = refs[i];
+ if (!file || !ref || file.filePath) continue; // skip if already handled via filePath
+ const thumb = thumbnails[ref.filePath];
if (thumb && (thumb.preview || thumb.fileSize)) {
if (thumb.preview) file.preview = thumb.preview;
if (thumb.fileSize) file.fileSize = thumb.fileSize;
- // Only persist local-path entries to the localStorage cache.
- // Gateway outgoing URLs are tied to a specific session/attachment
- // id and can be stale across runs, so caching is harmful.
- if (file.filePath) {
- _imageCache.set(file.filePath, { ...file });
- }
+ delete file.previewStatus;
+ _imageCache.set(ref.filePath, { ...file });
updated = true;
}
}
-
- // Legacy: update by index for [media attached: ...] refs
- if (msg.role === 'user') {
- const text = getMessageText(msg.content);
- const refs = extractMediaRefs(text);
- for (let i = 0; i < refs.length; i++) {
- const file = msg._attachedFiles[i];
- const ref = refs[i];
- if (!file || !ref || file.filePath) continue; // skip if already handled via filePath
- const thumb = thumbnails[ref.filePath];
- if (thumb && (thumb.preview || thumb.fileSize)) {
- if (thumb.preview) file.preview = thumb.preview;
- if (thumb.fileSize) file.fileSize = thumb.fileSize;
- _imageCache.set(ref.filePath, { ...file });
- updated = true;
- }
- }
- }
}
- if (updated) saveImageCache(_imageCache);
- return updated;
- } catch (err) {
- console.warn('[loadMissingPreviews] Failed:', err);
- return false;
+ }
+
+ if (updated) saveImageCache(_imageCache);
+ return updated;
+}
+
+function markMissingImagePreviewsUnavailable(messages: RawMessage[]): boolean {
+ let updated = false;
+ for (const msg of messages) {
+ if (!msg._attachedFiles) continue;
+ for (const file of msg._attachedFiles) {
+ if (!file.mimeType.startsWith('image/')) continue;
+ if (file.preview || file.previewStatus === 'unavailable') continue;
+ if (!file.filePath && !file.gatewayUrl) continue;
+ file.previewStatus = 'unavailable';
+ updated = true;
+ }
+ }
+ return updated;
+}
+
+/**
+ * Async: load missing previews from disk via IPC for messages that have
+ * _attachedFiles with null previews. Updates messages in-place and triggers re-render.
+ * Handles both [media attached: ...] patterns and raw filePath entries.
+ */
+async function loadMissingPreviews(messages: RawMessage[]): Promise {
+ let updatedAny = false;
+ let attempt = 0;
+
+ while (true) {
+ const needPreview = collectMissingPreviewRefs(messages);
+ if (needPreview.length === 0) return updatedAny;
+ if (attempt > 0) {
+ const delayMs = IMAGE_PREVIEW_RETRY_DELAYS_MS[attempt - 1];
+ if (delayMs) await waitForPreviewRetry(delayMs);
+ }
+
+ try {
+ const thumbnails = await invokeIpc(
+ 'media:getThumbnails',
+ needPreview,
+ ) as Record;
+ if (applyPreviewResults(messages, thumbnails)) {
+ updatedAny = true;
+ }
+ } catch (err) {
+ console.warn('[loadMissingPreviews] Failed:', err);
+ return updatedAny;
+ }
+
+ if (!collectMissingPreviewRefs(messages).some((ref) => ref.mimeType.startsWith('image/'))) {
+ return updatedAny;
+ }
+ if (attempt >= IMAGE_PREVIEW_RETRY_DELAYS_MS.length) {
+ return markMissingImagePreviewsUnavailable(messages) || updatedAny;
+ }
+ attempt += 1;
}
}
diff --git a/src/stores/chat/types.ts b/src/stores/chat/types.ts
index 6bee019..52a145c 100644
--- a/src/stores/chat/types.ts
+++ b/src/stores/chat/types.ts
@@ -4,6 +4,7 @@ export interface AttachedFileMeta {
mimeType: string;
fileSize: number;
preview: string | null;
+ previewStatus?: 'unavailable';
filePath?: string;
source?: 'user-upload' | 'tool-result' | 'message-ref' | 'gateway-media';
/**
diff --git a/tests/e2e/chat-run-state-events.spec.ts b/tests/e2e/chat-run-state-events.spec.ts
index d45d763..ae5349d 100644
--- a/tests/e2e/chat-run-state-events.spec.ts
+++ b/tests/e2e/chat-run-state-events.spec.ts
@@ -97,4 +97,79 @@ test.describe('ClawX chat run state events', () => {
await closeElectronApp(app);
}
});
+
+ test('shows clear image preview states for generated media while hydration retries', async ({ launchElectronApp }) => {
+ const app = await launchElectronApp({ skipSetup: true });
+ const gatewayUrl = '/api/chat/media/outgoing/agent%3Amain%3Aimage-preview/image-1/full';
+ const history = [
+ {
+ role: 'assistant',
+ id: 'generated-image',
+ timestamp: Date.now() / 1000,
+ content: [{
+ type: 'image',
+ url: gatewayUrl,
+ mimeType: 'image/png',
+ alt: 'generated.png',
+ }],
+ },
+ ];
+
+ try {
+ await installIpcMocks(app, {
+ gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
+ gatewayRpc: {
+ [stableStringify(['sessions.list', { includeDerivedTitles: true, includeLastMessage: true }])]: {
+ success: true,
+ result: {
+ sessions: [{ key: MAIN_SESSION_KEY, displayName: 'main' }],
+ },
+ },
+ [stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
+ success: true,
+ result: { messages: history },
+ },
+ },
+ hostApi: {
+ [stableStringify(['/api/gateway/status', 'GET'])]: {
+ ok: true,
+ data: {
+ status: 200,
+ ok: true,
+ json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
+ },
+ },
+ [stableStringify(['/api/agents', 'GET'])]: {
+ ok: true,
+ data: {
+ status: 200,
+ ok: true,
+ json: { success: true, agents: [{ id: 'main', name: 'Main' }] },
+ },
+ },
+ [stableStringify(['/api/files/thumbnails', 'POST'])]: {
+ ok: true,
+ data: {
+ status: 200,
+ ok: true,
+ json: { [gatewayUrl]: { preview: null, fileSize: 0 } },
+ },
+ },
+ },
+ });
+
+ const page = await getStableWindow(app);
+ try {
+ await page.reload();
+ } catch (error) {
+ if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
+ throw error;
+ }
+ }
+
+ await expect(page.getByTestId('image-preview-unavailable')).toBeVisible({ timeout: 10_000 });
+ } finally {
+ await closeElectronApp(app);
+ }
+ });
});
diff --git a/tests/unit/chat-helpers-enrichment.test.ts b/tests/unit/chat-helpers-enrichment.test.ts
index 42767ee..9269831 100644
--- a/tests/unit/chat-helpers-enrichment.test.ts
+++ b/tests/unit/chat-helpers-enrichment.test.ts
@@ -3,7 +3,9 @@ import {
enrichWithToolResultFiles,
enrichWithToolCallAttachments,
enrichWithCachedImages,
+ loadMissingPreviews,
} from '@/stores/chat/helpers';
+import { invokeIpc } from '@/lib/api-client';
import type { RawMessage } from '@/stores/chat';
vi.mock('@/lib/api-client', () => ({
@@ -371,3 +373,77 @@ describe('enrichWithToolCallAttachments', () => {
expect(enriched[0]?._attachedFiles?.[0]?.mimeType).toBe('image/png');
});
});
+
+describe('loadMissingPreviews', () => {
+ it('retries image preview hydration when Gateway media records are not ready yet', async () => {
+ vi.useFakeTimers();
+ try {
+ const gatewayUrl = '/api/chat/media/outgoing/agent%3Amain%3As-1/generated/full';
+ const messages: RawMessage[] = [
+ {
+ role: 'assistant',
+ content: [],
+ _attachedFiles: [{
+ fileName: 'generated.png',
+ mimeType: 'image/png',
+ fileSize: 0,
+ preview: null,
+ gatewayUrl,
+ source: 'gateway-media',
+ }],
+ },
+ ];
+
+ vi.mocked(invokeIpc)
+ .mockResolvedValueOnce({ [gatewayUrl]: { preview: null, fileSize: 0 } })
+ .mockResolvedValueOnce({ [gatewayUrl]: { preview: 'data:image/png;base64,ok', fileSize: 42 } });
+
+ const result = loadMissingPreviews(messages);
+ expect(invokeIpc).toHaveBeenCalledTimes(1);
+
+ await vi.advanceTimersByTimeAsync(300);
+ await expect(result).resolves.toBe(true);
+
+ expect(invokeIpc).toHaveBeenCalledTimes(2);
+ expect(messages[0]?._attachedFiles?.[0]).toMatchObject({
+ preview: 'data:image/png;base64,ok',
+ fileSize: 42,
+ });
+ expect(messages[0]?._attachedFiles?.[0]?.previewStatus).toBeUndefined();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('marks image previews unavailable after retry exhaustion', async () => {
+ vi.useFakeTimers();
+ try {
+ const gatewayUrl = '/api/chat/media/outgoing/agent%3Amain%3As-1/missing/full';
+ const messages: RawMessage[] = [
+ {
+ role: 'assistant',
+ content: [],
+ _attachedFiles: [{
+ fileName: 'missing.png',
+ mimeType: 'image/png',
+ fileSize: 0,
+ preview: null,
+ gatewayUrl,
+ source: 'gateway-media',
+ }],
+ },
+ ];
+
+ vi.mocked(invokeIpc).mockResolvedValue({ [gatewayUrl]: { preview: null, fileSize: 0 } });
+
+ const result = loadMissingPreviews(messages);
+ await vi.advanceTimersByTimeAsync(300 + 900 + 1800);
+ await expect(result).resolves.toBe(true);
+
+ expect(invokeIpc).toHaveBeenCalledTimes(4);
+ expect(messages[0]?._attachedFiles?.[0]?.previewStatus).toBe('unavailable');
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/tests/unit/chat-message.test.tsx b/tests/unit/chat-message.test.tsx
index 15c0e01..0de35fa 100644
--- a/tests/unit/chat-message.test.tsx
+++ b/tests/unit/chat-message.test.tsx
@@ -77,6 +77,51 @@ describe('ChatMessage attachment dedupe', () => {
expect(screen.getByAltText('desktop_screenshot.png')).toBeInTheDocument();
});
+ it('shows an explicit loading state for image artifacts before preview hydration finishes', () => {
+ const message: RawMessage = {
+ role: 'assistant',
+ content: 'Image generated.',
+ _attachedFiles: [
+ {
+ fileName: 'generated.png',
+ mimeType: 'image/png',
+ fileSize: 0,
+ preview: null,
+ gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/generated/full',
+ source: 'gateway-media',
+ },
+ ],
+ };
+
+ render();
+
+ expect(screen.getByTestId('image-preview-loading')).toBeInTheDocument();
+ expect(screen.queryByTestId('image-preview-unavailable')).not.toBeInTheDocument();
+ });
+
+ it('shows an unavailable state after image preview hydration gives up', () => {
+ const message: RawMessage = {
+ role: 'assistant',
+ content: 'Image generated.',
+ _attachedFiles: [
+ {
+ fileName: 'generated.png',
+ mimeType: 'image/png',
+ fileSize: 0,
+ preview: null,
+ previewStatus: 'unavailable',
+ gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/generated/full',
+ source: 'gateway-media',
+ },
+ ],
+ };
+
+ render();
+
+ expect(screen.getByTestId('image-preview-unavailable')).toBeInTheDocument();
+ expect(screen.queryByTestId('image-preview-loading')).not.toBeInTheDocument();
+ });
+
it('keeps message-ref image artifacts visible alongside reply text when process attachments are suppressed', () => {
const message: RawMessage = {
role: 'assistant',
diff --git a/tests/unit/chat-page-execution-graph.test.tsx b/tests/unit/chat-page-execution-graph.test.tsx
index fae51f2..195d1da 100644
--- a/tests/unit/chat-page-execution-graph.test.tsx
+++ b/tests/unit/chat-page-execution-graph.test.tsx
@@ -21,6 +21,19 @@ vi.mock('@/stores/agents', () => ({
useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState),
}));
+vi.mock('@/stores/artifact-panel', () => {
+ const state = {
+ open: false,
+ widthPct: 45,
+ openChanges: vi.fn(),
+ openPreview: vi.fn(),
+ close: vi.fn(),
+ };
+ return {
+ useArtifactPanel: (selector: (value: typeof state) => unknown) => selector(state),
+ };
+});
+
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
@@ -74,7 +87,17 @@ vi.mock('@/pages/Chat/ChatInput', () => ({
}));
vi.mock('@/pages/Chat/ChatMessage', () => ({
- ChatMessage: ({ message, textOverride }: { message: { content?: unknown }; textOverride?: string }) => {
+ ChatMessage: ({
+ message,
+ textOverride,
+ isStreaming,
+ suppressAssistantText,
+ }: {
+ message: { content?: unknown };
+ textOverride?: string;
+ isStreaming?: boolean;
+ suppressAssistantText?: boolean;
+ }) => {
const text = typeof textOverride === 'string'
? textOverride
: typeof message?.content === 'string'
@@ -86,7 +109,11 @@ vi.mock('@/pages/Chat/ChatMessage', () => ({
.map((block) => block.text)
.join(' ')
: '';
- return {text}
;
+ return (
+
+ {suppressAssistantText ? '' : text}
+
+ );
},
}));
@@ -352,6 +379,66 @@ describe('Chat execution graph lifecycle', () => {
expect(screen.getAllByText('404 Resource not found').length).toBeGreaterThan(0);
});
+ it('keeps history final reply folded while matching streamed text is still active', async () => {
+ const finalText = 'History final answer is already recorded.';
+ const { useChatStore } = await import('@/stores/chat');
+ useChatStore.setState({
+ messages: [
+ {
+ role: 'user',
+ content: 'Summarize the run',
+ },
+ {
+ role: 'assistant',
+ id: 'tool-turn',
+ content: [
+ { type: 'text', text: 'Reading the source data.' },
+ { type: 'tool_use', id: 'read-1', name: 'read_file', input: { path: '/tmp/source.txt' } },
+ ],
+ },
+ {
+ role: 'assistant',
+ id: 'final-turn',
+ content: [{ type: 'text', text: finalText }],
+ },
+ ],
+ loading: false,
+ error: null,
+ runError: null,
+ sending: true,
+ activeRunId: 'run-history-stream-race',
+ streamingText: '',
+ streamingMessage: {
+ role: 'assistant',
+ content: [{ type: 'text', text: finalText }],
+ },
+ streamingTools: [],
+ pendingFinal: false,
+ lastUserMessageAt: Date.now(),
+ pendingToolImages: [],
+ sessions: [{ key: 'agent:main:main' }],
+ currentSessionKey: 'agent:main:main',
+ currentAgentId: 'main',
+ sessionLabels: {},
+ sessionLastActivity: {},
+ thinkingLevel: null,
+ });
+
+ const { Chat } = await import('@/pages/Chat/index');
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('chat-execution-graph')).toHaveAttribute('data-collapsed', 'false');
+ });
+
+ expect(screen.getByTestId('mock-streaming-message')).toHaveTextContent(finalText);
+ expect(
+ screen.getAllByTestId('mock-chat-message')
+ .filter((element) => element.textContent === finalText),
+ ).toHaveLength(0);
+ });
+
it('stops trailing thinking when history already contains the final reply but sending is stuck', async () => {
const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({
@@ -407,6 +494,95 @@ describe('Chat execution graph lifecycle', () => {
expect(screen.queryByTestId('chat-activity-indicator')).not.toBeInTheDocument();
});
+ it('stops trailing thinking when generated image media arrives but session wake is missed', async () => {
+ const { useChatStore } = await import('@/stores/chat');
+ useChatStore.setState({
+ messages: [
+ {
+ role: 'user',
+ content: '生成一个小麦',
+ },
+ {
+ role: 'assistant',
+ id: 'image-tool-turn',
+ content: [
+ {
+ type: 'toolCall',
+ id: 'image-1',
+ name: 'image_generate',
+ arguments: { prompt: 'golden wheat' },
+ },
+ ],
+ },
+ {
+ role: 'assistant',
+ id: 'message-tool-turn',
+ content: [
+ {
+ type: 'toolCall',
+ id: 'message-1',
+ name: 'message',
+ arguments: {
+ action: 'send',
+ attachments: [{ path: '/tmp/wheat.png' }],
+ },
+ },
+ ],
+ },
+ {
+ role: 'assistant',
+ id: 'generated-image',
+ content: [{
+ type: 'image',
+ url: '/api/chat/media/outgoing/agent%3Amain%3As-1/image-1/full',
+ mimeType: 'image/png',
+ alt: 'wheat.png',
+ }],
+ _attachedFiles: [{
+ fileName: 'wheat.png',
+ mimeType: 'image/png',
+ fileSize: 42,
+ preview: 'data:image/png;base64,ok',
+ gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/image-1/full',
+ source: 'gateway-media',
+ }],
+ },
+ ],
+ loading: false,
+ error: null,
+ runError: null,
+ sending: true,
+ activeRunId: 'run-stuck-image',
+ streamingText: '',
+ streamingMessage: {
+ role: 'assistant',
+ content: [{ type: 'thinking', thinking: '等待图片生成完成。' }],
+ },
+ streamingTools: [],
+ pendingFinal: false,
+ lastUserMessageAt: Date.now(),
+ pendingToolImages: [],
+ sessions: [{ key: 'agent:main:main' }],
+ currentSessionKey: 'agent:main:main',
+ currentAgentId: 'main',
+ sessionLabels: {},
+ sessionLastActivity: {},
+ thinkingLevel: null,
+ });
+
+ const { Chat } = await import('@/pages/Chat/index');
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('chat-execution-graph')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByTestId('chat-execution-step-thinking-trailing')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('chat-typing-indicator')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('chat-activity-indicator')).not.toBeInTheDocument();
+ });
+
it('keeps the run active when narration landed in history before tools finished', async () => {
const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({
diff --git a/tests/unit/task-visualization.test.ts b/tests/unit/task-visualization.test.ts
index 3680cc5..53c722b 100644
--- a/tests/unit/task-visualization.test.ts
+++ b/tests/unit/task-visualization.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
-import { deriveTaskSteps, parseSubagentCompletionInfo } from '@/pages/Chat/task-visualization';
+import {
+ deriveTaskSteps,
+ findReplyMessageIndex,
+ parseSubagentCompletionInfo,
+ segmentHasFinalReply,
+} from '@/pages/Chat/task-visualization';
import { stripProcessMessagePrefix } from '@/pages/Chat/message-utils';
import type { RawMessage, ToolStatus } from '@/stores/chat';
@@ -366,3 +371,51 @@ status: completed successfully`,
});
});
});
+
+describe('run completion detection', () => {
+ it('treats delivered image attachments as a final reply after image generation tools', () => {
+ const messages: RawMessage[] = [
+ {
+ role: 'assistant',
+ content: [{
+ type: 'toolCall',
+ id: 'tool-image',
+ name: 'image_generate',
+ arguments: { prompt: 'wheat' },
+ }],
+ },
+ {
+ role: 'assistant',
+ content: [{
+ type: 'toolCall',
+ id: 'tool-message',
+ name: 'message',
+ arguments: {
+ action: 'send',
+ attachments: [{ path: '/tmp/wheat.png' }],
+ },
+ }],
+ },
+ {
+ role: 'assistant',
+ content: [{
+ type: 'image',
+ url: '/api/chat/media/outgoing/agent%3Amain%3As-1/image-1/full',
+ mimeType: 'image/png',
+ alt: 'wheat.png',
+ }],
+ _attachedFiles: [{
+ fileName: 'wheat.png',
+ mimeType: 'image/png',
+ fileSize: 42,
+ preview: 'data:image/png;base64,ok',
+ gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/image-1/full',
+ source: 'gateway-media',
+ }],
+ },
+ ];
+
+ expect(segmentHasFinalReply(messages)).toBe(true);
+ expect(findReplyMessageIndex(messages, false)).toBe(2);
+ });
+});