diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md
index aa00c6f9e..526d9c791 100644
--- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md
+++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md
@@ -60,6 +60,7 @@
- 对话框与左侧素材 / 图层侧栏**不互斥**,允许同时展开,便于在对话中选取和核对画布素材;左侧栏切换不改变 Agent 面板开关状态。
- 桌面端对话框固定宽约 360–400px;移动端抽屉式全宽覆盖;收起态为胶囊/圆形入口按钮。
- 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。
+- 快速切换会话或会话轮询刷新产生并发详情请求时,前端只允许最后发起的请求更新当前会话、消息、错误和加载态;旧响应不得覆盖用户最新选择。
- 收起对话框只是隐藏面板,不卸载当前会话 hook;普通 JSON 消息请求的等待态和外部生成任务状态必须在收起 / 重新打开之间保持一致。
## 附件
diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx
new file mode 100644
index 000000000..257892551
--- /dev/null
+++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx
@@ -0,0 +1,53 @@
+/* @vitest-environment jsdom */
+
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { EditorAgentMessage } from '@/packages/shared/src/contracts';
+
+import { MessageBubble } from './MessageBubble.tsx';
+
+function renderMessage(message: EditorAgentMessage) {
+ return render(
+ ,
+ );
+}
+
+describe('MessageBubble', () => {
+ it('shows prefixed system errors as red Agent errors without the wire prefix', () => {
+ renderMessage({
+ id: 2,
+ role: 'system',
+ text: 'ERROR planning failed',
+ attachments: [],
+ toolCall: null,
+ createdAt: '2026-07-16T00:00:00Z',
+ });
+
+ const error = screen.getByLabelText('Agent错误');
+ expect(error.textContent).toContain('planning failed');
+ expect(error.textContent).not.toContain('ERROR');
+ expect(error.firstElementChild?.classList.contains('bg-red-50')).toBe(true);
+ expect(error.firstElementChild?.classList.contains('text-red-700')).toBe(
+ true,
+ );
+ });
+
+ it('continues to hide internal system messages without the error prefix', () => {
+ const { container } = renderMessage({
+ id: 3,
+ role: 'system',
+ text: 'internal attachment bookkeeping',
+ attachments: [],
+ toolCall: null,
+ createdAt: '2026-07-16T00:00:00Z',
+ });
+
+ expect(container.childElementCount).toBe(0);
+ });
+});
diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx
index 45d4b07ae..f7e8730d4 100644
--- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx
+++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx
@@ -4,6 +4,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
+ EditorAgentConversationDetail,
EditorAgentMessage,
EditorAgentMessageResponse,
} from '../../../../packages/shared/src/contracts/editorAgent.ts';
@@ -173,6 +174,93 @@ describe('useEditorAgentConversation', () => {
);
});
+ it('keeps the latest conversation when detail responses arrive out of order', async () => {
+ const client = createClient();
+ let resolveConversation2!: (detail: EditorAgentConversationDetail) => void;
+ let resolveConversation3!: (detail: EditorAgentConversationDetail) => void;
+ const conversation2Promise = new Promise(
+ (resolve) => {
+ resolveConversation2 = resolve;
+ },
+ );
+ const conversation3Promise = new Promise(
+ (resolve) => {
+ resolveConversation3 = resolve;
+ },
+ );
+ const { result } = renderHook(() =>
+ useEditorAgentConversation({ projectId: 'project-1', client }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.activeConversationId).toBe('conversation-1');
+ });
+ vi.mocked(client.getConversation).mockImplementation((conversationId) => {
+ if (conversationId === 'conversation-2') {
+ return conversation2Promise;
+ }
+ if (conversationId === 'conversation-3') {
+ return conversation3Promise;
+ }
+ throw new Error(`Unexpected conversation: ${conversationId}`);
+ });
+
+ let conversation2Load!: Promise;
+ let conversation3Load!: Promise;
+ act(() => {
+ conversation2Load = result.current.selectConversation('conversation-2');
+ conversation3Load = result.current.selectConversation('conversation-3');
+ });
+
+ await act(async () => {
+ resolveConversation3({
+ conversationId: 'conversation-3',
+ projectId: 'project-1',
+ title: '第三个会话',
+ messages: [
+ {
+ id: 3,
+ role: 'assistant',
+ text: '第三个会话消息',
+ attachments: [],
+ toolCall: null,
+ createdAt: '2026-07-03T00:03:00.000Z',
+ },
+ ],
+ createdAt: '2026-07-03T00:03:00.000Z',
+ updatedAt: '2026-07-03T00:03:00.000Z',
+ });
+ await conversation3Load;
+ });
+ expect(result.current.activeConversationId).toBe('conversation-3');
+ expect(result.current.messages[0]?.text).toBe('第三个会话消息');
+ expect(result.current.isLoadingMessages).toBe(false);
+
+ await act(async () => {
+ resolveConversation2({
+ conversationId: 'conversation-2',
+ projectId: 'project-1',
+ title: '第二个会话',
+ messages: [
+ {
+ id: 2,
+ role: 'assistant',
+ text: '第二个会话消息',
+ attachments: [],
+ toolCall: null,
+ createdAt: '2026-07-03T00:02:00.000Z',
+ },
+ ],
+ createdAt: '2026-07-03T00:02:00.000Z',
+ updatedAt: '2026-07-03T00:02:00.000Z',
+ });
+ await conversation2Load;
+ });
+ expect(result.current.activeConversationId).toBe('conversation-3');
+ expect(result.current.messages[0]?.text).toBe('第三个会话消息');
+ expect(result.current.isLoadingMessages).toBe(false);
+ });
+
it('appends a lazily reconciled tool message delta', async () => {
const client = createClient();
const pendingMessage: EditorAgentMessage = {
diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts
index ed18503ad..747dbf69e 100644
--- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts
+++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts
@@ -170,6 +170,7 @@ export function useEditorAgentConversation({
const activeRequestAbortControllerRef = useRef(null);
const activeConversationIdRef = useRef(null);
const activeToolCallActionRef = useRef(null);
+ const conversationLoadRequestIdRef = useRef(0);
useEffect(() => {
activeConversationIdRef.current = activeConversationId;
@@ -205,6 +206,8 @@ export function useEditorAgentConversation({
conversationId: string,
options: { showLoading?: boolean } = {},
) => {
+ const requestId = conversationLoadRequestIdRef.current + 1;
+ conversationLoadRequestIdRef.current = requestId;
const showLoading = options.showLoading ?? true;
if (showLoading) {
setIsLoadingMessages(true);
@@ -212,15 +215,19 @@ export function useEditorAgentConversation({
setErrorMessage(null);
try {
const detail = await client.getConversation(conversationId);
- applyConversationDetail(detail);
+ if (conversationLoadRequestIdRef.current === requestId) {
+ applyConversationDetail(detail);
+ }
return detail;
} catch (error) {
- setErrorMessage(
- error instanceof Error ? error.message : '读取画布 Agent 会话失败',
- );
+ if (conversationLoadRequestIdRef.current === requestId) {
+ setErrorMessage(
+ error instanceof Error ? error.message : '读取画布 Agent 会话失败',
+ );
+ }
throw error;
} finally {
- if (showLoading) {
+ if (conversationLoadRequestIdRef.current === requestId) {
setIsLoadingMessages(false);
}
}
@@ -229,6 +236,8 @@ export function useEditorAgentConversation({
);
useEffect(() => {
+ conversationLoadRequestIdRef.current += 1;
+ setIsLoadingMessages(false);
if (!normalizedProjectId) {
setConversations([]);
setActiveConversationId(null);