fix: prevent silent refresh from overwriting newly created conversations in EditorAgentConversation

This commit is contained in:
2026-07-23 14:43:57 +08:00
parent d05ac49d07
commit 9ae0e93407
3 changed files with 104 additions and 14 deletions
@@ -62,7 +62,7 @@
- 桌面端对话框固定宽约 360–400px;移动端抽屉式全宽覆盖;收起态为胶囊/圆形入口按钮。
- 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。
- 快速切换会话或会话轮询刷新产生并发详情请求时,前端只允许最后发起的请求更新当前会话、消息、错误和加载态;旧响应不得覆盖用户最新选择。
- 普通 JSON 消息请求的回包必须绑定发送时的会话:用户在等待期间切换到其他会话后,只更新原会话的列表摘要,不得把原会话的 `deltaMessages` 、错误或画布刷新副作用应用到当前面板。用户主动停止后不显示发送失败、不重试 POST、不删除已写入 OSS 的用户消息;停止后的静默会话重读直接执行在 abort 分支内,`isAborting` 一直保持到 GET 结束,因此面板继续显示“停止中”,并由未结束的 `isWaiting` 阻止下一次发送。该阶段不显示“刷新中”或额外 loading;GET 结束后一次性退出“停止中”并恢复发送。
- 普通 JSON 消息请求的回包必须绑定发送时的会话:用户在等待期间切换到其他会话后,只更新原会话的列表摘要,不得把原会话的 `deltaMessages` 、错误或画布刷新副作用应用到当前面板。整轮发送从首次创建会话前就必须注册为可停止;若用户在新会话创建完成前停止,允许创建请求正常收口,但创建完成后不得继续发送规划请求。用户主动停止后不显示发送失败、不重试 POST、不删除已写入 OSS 的用户消息;停止后的静默会话重读直接执行在 abort 分支内,`isAborting` 一直保持到 GET 结束,因此面板继续显示“停止中”,并由未结束的 `isWaiting` 阻止下一次发送。该阶段不显示“刷新中”或额外 loading;GET 结束后一次性退出“停止中”并恢复发送。
- 收起对话框只是隐藏面板,不卸载当前会话 hook;普通 JSON 消息请求的等待态和外部生成任务状态必须在收起 / 重新打开之间保持一致。
## 附件
@@ -117,7 +117,7 @@
4. 消息内生成结果缩略图(纯预览,不显示名称,不点击聚焦图层);
5. 生成中的进行中动画;
6. 错误气泡(失败/余额不足,带原因);
7. 普通消息请求等待期间将“发送”切换为“停止”;停止通过 `AbortController` 取消 HTTP 请求并释放后端会话锁,不触发 POST transport retry,已持久化的用户消息保持为普通历史。未停止且超过 120 秒时,思考气泡显示“仍在处理中,请耐心等待”。
7. 普通消息请求等待期间将“发送”切换为“停止”;整轮发送在首次创建会话前即绑定 `AbortController`,创建完成前停止时不得继续发送规划请求,规划请求已发出时则取消 HTTP 请求并释放后端会话锁,不触发 POST transport retry,已持久化的用户消息保持为普通历史。未停止且超过 120 秒时,思考气泡显示“仍在处理中,请耐心等待”。
8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体和“引用”到当前输入区。引用复用附件去重、9 张上限和发送链路;
9. 消息右键菜单遵循 Canva 式单实例交互:任一菜单已打开时,下一次右键必须先关闭旧菜单;新落点是消息正文或素材时再在新位置打开对应菜单,新落点没有右键动作时仅收起旧菜单,不允许多个消息菜单并存。复制、引用或下载成功后自动关闭菜单;失败时保留菜单和失败状态,避免错误无提示消失。
@@ -450,6 +450,98 @@ describe('useEditorAgentConversation', () => {
);
});
it('stops the first send while conversation creation is pending', async () => {
const client = createClient();
let resolveCreate!: (detail: EditorAgentConversationDetail) => void;
vi.mocked(client.listConversations).mockResolvedValueOnce([]);
vi.mocked(client.createConversation).mockImplementationOnce(
() =>
new Promise<EditorAgentConversationDetail>((resolve) => {
resolveCreate = resolve;
}),
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.isLoadingConversations).toBe(false);
});
let sendPromise!: Promise<void>;
act(() => {
sendPromise = result.current.sendMessage('首次创建时停止');
});
await waitFor(() => {
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
});
expect(result.current.isWaiting).toBe(true);
act(() => {
result.current.stopCurrentTurn();
});
expect(result.current.isAborting).toBe(true);
await act(async () => {
resolveCreate({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '新对话',
messages: [],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:01:00.000Z',
});
await sendPromise;
});
expect(client.sendMessage).not.toHaveBeenCalled();
expect(result.current.activeConversationId).toBe('conversation-2');
expect(result.current.messages).toEqual([]);
expect(result.current.isWaiting).toBe(false);
expect(result.current.isAborting).toBe(false);
});
it('keeps a stopped first send silent when conversation creation fails', async () => {
const client = createClient();
let rejectCreate!: (error: Error) => void;
vi.mocked(client.listConversations).mockResolvedValueOnce([]);
vi.mocked(client.createConversation).mockImplementationOnce(
() =>
new Promise<EditorAgentConversationDetail>((_resolve, reject) => {
rejectCreate = reject;
}),
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.isLoadingConversations).toBe(false);
});
let sendPromise!: Promise<void>;
act(() => {
sendPromise = result.current.sendMessage('首次创建失败前停止');
});
await waitFor(() => {
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
});
act(() => {
result.current.stopCurrentTurn();
});
await act(async () => {
rejectCreate(new Error('创建会话失败'));
await sendPromise;
});
expect(client.sendMessage).not.toHaveBeenCalled();
expect(result.current.errorMessage).toBeNull();
expect(result.current.isWaiting).toBe(false);
expect(result.current.isAborting).toBe(false);
});
it('does not send or apply a stale conversation created after switching projects', async () => {
const client = createClient();
let resolveCreate!: (detail: EditorAgentConversationDetail) => void;
@@ -10,7 +10,6 @@ import type {
EditorAgentMessageResponse,
} from '@/packages/shared/src/contracts';
import { isAbortError } from '../../../services/apiClient.ts';
import {
cancelEditorAgentToolCall,
confirmEditorAgentToolCall,
@@ -61,8 +60,6 @@ export type EditorAgentToolCallActionState = {
type ActiveEditorAgentSend = {
requestId: number;
conversationId: string;
clientMessageId: string;
controller: AbortController;
};
@@ -447,7 +444,12 @@ export function useEditorAgentConversation({
isWaitingRef.current = true;
const requestedProjectId = normalizedProjectId;
const requestId = pendingSendRequestIdRef.current + 1;
const controller = new AbortController();
pendingSendRequestIdRef.current = requestId;
activeSendRef.current = {
requestId,
controller,
};
setErrorMessage(null);
setIsWaiting(true);
setPatienceNoticeConversationId(null);
@@ -458,12 +460,12 @@ export function useEditorAgentConversation({
conversationId = await ensureConversationForSend();
if (
pendingSendRequestIdRef.current !== requestId ||
normalizedProjectIdRef.current !== requestedProjectId
normalizedProjectIdRef.current !== requestedProjectId ||
controller.signal.aborted
) {
return;
}
const clientMessageId = createEditorAgentClientMessageId();
const controller = new AbortController();
const nextOptimisticMessage = createLocalUserMessage({
id: -1,
clientMessageId,
@@ -471,12 +473,6 @@ export function useEditorAgentConversation({
attachments,
});
optimisticMessage = nextOptimisticMessage;
activeSendRef.current = {
requestId,
conversationId,
clientMessageId,
controller,
};
setMessages((currentMessages) => [
...currentMessages,
nextOptimisticMessage,
@@ -516,8 +512,10 @@ export function useEditorAgentConversation({
}
} catch (error) {
const wasStoppedByUser =
stoppedSendRequestIdRef.current === requestId && isAbortError(error);
stoppedSendRequestIdRef.current === requestId &&
controller.signal.aborted;
if (wasStoppedByUser) {
setErrorMessage(null);
if (
conversationId &&
activeConversationIdRef.current === conversationId