fix: prevent silent refresh from overwriting newly created conversations in EditorAgentConversation
This commit is contained in:
@@ -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 式单实例交互:任一菜单已打开时,下一次右键必须先关闭旧菜单;新落点是消息正文或素材时再在新位置打开对应菜单,新落点没有右键动作时仅收起旧菜单,不允许多个消息菜单并存。复制、引用或下载成功后自动关闭菜单;失败时保留菜单和失败状态,避免错误无提示消失。
|
||||
|
||||
|
||||
+92
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user