fix(coding): isolate async conversation actions

This commit is contained in:
2026-08-24 09:22:31 +08:00
parent 863f005203
commit da92eb1957
4 changed files with 219 additions and 9 deletions

View File

@@ -257,6 +257,127 @@ describe('CodingChatPanel first Conversation', () => {
expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id);
});
it('does not let a pending fork steal selection or Header busy state after a Conversation switch', async () => {
const forkFlight = deferred<CodingConversationMetadata>();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.fork.mockReturnValue(forkFlight.promise);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
createLocalConversationSnapshot(
project.id,
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
)
));
render(<CodingChatPanel />);
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(conversation.id));
fireEvent.click(screen.getByRole('button', { name: '创建分支' }));
await waitFor(() => expect(conversationApi.fork).toHaveBeenCalledWith(conversation.id, undefined));
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(reviewerConversation.id));
expect(screen.getByRole('button', { name: '创建分支' })).toBeEnabled();
await act(async () => {
forkFlight.resolve({
...conversation,
id: 'conversation-forked',
title: 'Feature branch',
});
await forkFlight.promise;
});
expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id);
});
it('does not clear a newly selected Conversation when an old archive request finishes', async () => {
const archiveFlight = deferred<CodingConversationMetadata>();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
projectApi.patch.mockReturnValue(archiveFlight.promise);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
createLocalConversationSnapshot(
project.id,
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
)
));
render(<CodingChatPanel />);
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(conversation.id));
fireEvent.click(screen.getByRole('button', { name: '归档' }));
await waitFor(() => expect(projectApi.patch).toHaveBeenCalledWith(conversation.id, { archived: true }));
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(reviewerConversation.id));
expect(screen.getByRole('button', { name: '归档' })).toBeEnabled();
await act(async () => {
archiveFlight.resolve({ ...conversation, archivedAt: '2026-08-24T01:00:00.000Z' });
await archiveFlight.promise;
});
expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id);
});
it('remounts pending interaction state when switching Conversations', async () => {
const respondFlight = deferred<void>();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.respond.mockReturnValue(respondFlight.promise);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockImplementation(async (conversationId: string) => {
const metadata = conversationId === reviewerConversation.id ? reviewerConversation : conversation;
return {
...createLocalConversationSnapshot(project.id, metadata),
pendingInteractions: [{
id: `interaction-${conversationId}`,
conversationId,
runId: `run-${conversationId}`,
kind: 'confirm' as const,
title: `Confirm ${conversationId}`,
status: 'pending' as const,
}],
};
});
render(<CodingChatPanel />);
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(conversation.id));
fireEvent.click(await screen.findByRole('button', { name: '确认' }));
await waitFor(() => expect(conversationApi.respond).toHaveBeenCalledWith(conversation.id, {
interactionId: `interaction-${conversation.id}`,
confirmed: true,
}));
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(reviewerConversation.id));
expect(await screen.findByText(`Confirm ${reviewerConversation.id}`)).toBeInTheDocument();
expect(screen.getByRole('button', { name: '确认' })).toBeEnabled();
await act(async () => {
respondFlight.resolve();
await respondFlight.promise;
});
expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id);
});
it('keeps submission state and rejection scoped to the originating Conversation', async () => {
const pendingSubmit = deferred<never>();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });