fix(coding): isolate async conversation actions
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -8,6 +8,15 @@ const interactionApi = vi.hoisted(() => ({
|
||||
compact: vi.fn(),
|
||||
model: vi.fn(),
|
||||
thinking: vi.fn(),
|
||||
diagnostics: vi.fn(),
|
||||
}));
|
||||
const inspectorApi = vi.hoisted(() => ({
|
||||
changes: vi.fn(),
|
||||
commands: vi.fn(),
|
||||
content: vi.fn(),
|
||||
files: vi.fn(),
|
||||
find: vi.fn(),
|
||||
skills: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/coding-conversations', async (importOriginal) => ({
|
||||
@@ -17,6 +26,16 @@ vi.mock('@/lib/coding-conversations', async (importOriginal) => ({
|
||||
compactCodingConversation: interactionApi.compact,
|
||||
setCodingConversationModel: interactionApi.model,
|
||||
setCodingConversationThinking: interactionApi.thinking,
|
||||
getCodingRuntimeDiagnostics: interactionApi.diagnostics,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/coding-product-tools', () => ({
|
||||
getCodingConversationChanges: inspectorApi.changes,
|
||||
getCodingConversationCommands: inspectorApi.commands,
|
||||
getCodingFileContent: inspectorApi.content,
|
||||
getCodingFileStatus: inspectorApi.files,
|
||||
findCodingFiles: inspectorApi.find,
|
||||
getCodingSkills: inspectorApi.skills,
|
||||
}));
|
||||
|
||||
describe('PI-130 feature-complete Coding UI', () => {
|
||||
@@ -217,4 +236,56 @@ describe('PI-130 feature-complete Coding UI', () => {
|
||||
await waitFor(() => expect(callbacks.fork).toHaveBeenCalledOnce());
|
||||
expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not let an old tools load overwrite the newly selected Conversation inspector', async () => {
|
||||
let resolveOld!: (value: { commands: Array<{ name: string; title: string; description: string; source: 'makelore' }> }) => void;
|
||||
const oldCommands = new Promise<{ commands: Array<{ name: string; title: string; description: string; source: 'makelore' }> }>((resolve) => {
|
||||
resolveOld = resolve;
|
||||
});
|
||||
inspectorApi.changes.mockResolvedValue({ changes: null });
|
||||
inspectorApi.files.mockResolvedValue({ files: [] });
|
||||
inspectorApi.skills.mockResolvedValue({ skills: [] });
|
||||
inspectorApi.commands.mockImplementation((conversationId: string) => (
|
||||
conversationId === 'conversation-old'
|
||||
? oldCommands
|
||||
: Promise.resolve({ commands: [{ name: 'new-command', title: 'New command', description: 'New', source: 'makelore' as const }] })
|
||||
));
|
||||
interactionApi.diagnostics.mockResolvedValue({ revision: { provider: 1, resources: 1 }, workers: [] });
|
||||
const { CodingWorkspaceInspector } = await import('@/pages/Chat/CodingWorkspaceInspector');
|
||||
const view = render(
|
||||
<CodingWorkspaceInspector
|
||||
key="conversation-old"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
conversationId="conversation-old"
|
||||
agentId="agent-old"
|
||||
snapshot={null}
|
||||
onUseCommand={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(inspectorApi.commands).toHaveBeenCalledWith('conversation-old'));
|
||||
|
||||
view.rerender(
|
||||
<CodingWorkspaceInspector
|
||||
key="conversation-new"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
conversationId="conversation-new"
|
||||
agentId="agent-new"
|
||||
snapshot={null}
|
||||
onUseCommand={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(inspectorApi.commands).toHaveBeenCalledWith('conversation-new'));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled());
|
||||
const commandsTab = screen.getByRole('tab', { name: '命令' });
|
||||
fireEvent.mouseDown(commandsTab, { button: 0, ctrlKey: false });
|
||||
await waitFor(() => expect(commandsTab).toHaveAttribute('aria-selected', 'true'));
|
||||
expect(await screen.findByRole('button', { name: /\/new-command/ })).toBeInTheDocument();
|
||||
|
||||
resolveOld({ commands: [{ name: 'old-command', title: 'Old command', description: 'Old', source: 'makelore' }] });
|
||||
await oldCommands;
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: /\/old-command/ })).not.toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: /\/new-command/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user