feat: replace Code conversation archive with deletion

This commit is contained in:
鲨鱼辣椒 committed 2026-10-14 11:09:51 +08:00
1 parent 140f6be8db
commit bf101c1e52
19 files changed
+1001 -292

No files matched your search

+98 -94
View File
@@ -133,6 +133,7 @@ const projectApi = vi.hoisted(() => ({
conversations: vi.fn(),
create: vi.fn(),
patch: vi.fn(),
deleteConversation: vi.fn(),
open: vi.fn(),
createProject: vi.fn(),
setActive: vi.fn(),
@@ -159,6 +160,7 @@ vi.mock('@/lib/coding-projects', () => ({
listCodingProjectConversations: projectApi.conversations,
createCodingProjectConversation: projectApi.create,
patchCodingProjectConversation: projectApi.patch,
deleteCodingProjectConversation: projectApi.deleteConversation,
openCodingProject: projectApi.open,
createCodingProject: projectApi.createProject,
setActiveCodingProject: projectApi.setActive,
@@ -510,132 +512,134 @@ describe('CodingChatPanel first Conversation', () => {
expect(conversationApi.submit).not.toHaveBeenCalled();
});
it('archives the last conversation without creating another, then restores the same running conversation', async () => {
it('deletes the selected running conversation only after success and leaves an empty composer without selecting or creating another', async () => {
const other = { ...conversation, id: 'other-conversation', title: '保留的对话', updatedAt: '2025-01-01T00:00:00Z' };
const removal = deferred<void>();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
projectApi.patch.mockImplementation(async (_id, patch) => ({
...conversation, archivedAt: patch.archived ? '2026-09-20T00:00:00Z' : null,
}));
projectApi.conversations.mockResolvedValue([conversation, other]);
projectApi.deleteConversation.mockReturnValue(removal.promise);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue({ ...createLocalConversationSnapshot(project.id, conversation),
run: { status: 'running', runId: 'run-1' } });
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const { useState } = await import('react');
const CodingChatPanel = () => {
const [archived, setArchived] = useState(false);
return <>
<button onClick={() => setArchived(true)}>打开归档列表</button>
<CodingProjectConversations projectId={project.id} archived={archived} onArchivedChange={setArchived} />
<CoreChatPanel />
</>;
};
const { codingConversationStore } = await import('@/stores/coding-conversations');
render(<CodingChatPanel />);
const menu = await screen.findByRole('button', { name: '对话操作:新对话' });
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id));
fireEvent.keyDown(menu, { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitem', { name: '归档(任务继续运行)' }));
await screen.findByRole('heading', { name: '今天,你想创造什么?' });
expect(projectApi.create).not.toHaveBeenCalled();
expect(conversationApi.abort).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '打开归档列表' }));
expect(screen.getByText('已归档对话')).toBeVisible();
fireEvent.click(within(screen.getByTestId('project-conversations')).getByRole('button', { name: '新对话', exact: true }));
await screen.findByText('此对话已归档,任务仍在运行。');
expect(screen.queryByRole('button', { name: '发送', exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '恢复对话' }));
await waitFor(() => expect(screen.queryByRole('button', { name: '恢复对话' })).not.toBeInTheDocument());
const { codingWorkspaceStore } = await import('@/stores/coding-workspace');
render(<><CodingProjectConversations projectId={project.id} /><CodingChatPanel /></>);
const list = within(await screen.findByTestId('project-conversations'));
await waitFor(() => expect(codingConversationStore.getState().entriesByConversationId[conversation.id]?.reducer.snapshot?.run.status).toBe('running'));
fireEvent.keyDown(list.getByRole('button', { name: '对话操作:新对话' }), { key: 'Enter' });
expect((await screen.findAllByRole('menuitem')).map(item => item.textContent?.trim())).toEqual(['重命名', '删除对话']);
fireEvent.click(screen.getByRole('menuitem', { name: '删除对话', exact: true }));
await waitFor(() => expect(projectApi.deleteConversation).toHaveBeenCalledWith(conversation.id));
expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id);
expect(projectApi.patch).toHaveBeenLastCalledWith(conversation.id, { archived: false });
expect(screen.queryByText('已归档对话')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '返回最近对话' })).not.toBeInTheDocument();
expect(within(screen.getByTestId('project-conversations')).getByRole('button', { name: '新对话', exact: true })).toHaveAttribute('aria-current', 'page');
expect(list.getByRole('button', { name: conversation.title, exact: true })).toBeVisible();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// Main can publish the refreshed list before the DELETE response reaches the renderer.
await act(async () => codingWorkspaceStore.setState({ conversations: [other] }));
expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id);
expect(conversationApi.snapshot).not.toHaveBeenCalledWith(other.id);
expect(list.getByRole('button', { name: other.title, exact: true })).not.toHaveAttribute('aria-current', 'page');
await act(async () => removal.resolve());
await screen.findByRole('heading', { name: '今天,你想创造什么?' });
expect(codingConversationStore.getState().selectedConversationId).toBeNull();
expect(codingConversationStore.getState().entriesByConversationId[conversation.id]).toBeUndefined();
expect(list.queryByRole('button', { name: conversation.title, exact: true })).not.toBeInTheDocument();
expect(list.getByRole('button', { name: other.title, exact: true })).toBeVisible();
expect(conversationApi.snapshot).not.toHaveBeenCalledWith(other.id);
expect(projectApi.create).not.toHaveBeenCalled();
expect(projectApi.patch).not.toHaveBeenCalled();
expect(conversationApi.abort).not.toHaveBeenCalled();
});
it('returns to recent conversations when restoring an unselected archived conversation from its menu', async () => {
const archivedConversation = {
...conversation,
id: 'archived-conversation',
title: '归档中的对话',
archivedAt: '2026-09-20T00:00:00Z',
};
it.each([null, '2026-09-20T00:00:00Z'])('deletes an unselected conversation without changing the current conversation (legacy archived=%s)', async (archivedAt) => {
const other = { ...conversation, id: 'unselected-delete', title: '后台对话', archivedAt, updatedAt: '2025-01-01T00:00:00Z' };
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation, archivedConversation]);
projectApi.patch.mockResolvedValue({ ...archivedConversation, archivedAt: null });
projectApi.conversations.mockResolvedValue([conversation, other]);
projectApi.deleteConversation.mockResolvedValue(undefined);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { useState } = await import('react');
const CodingChatPanel = () => {
const [archived, setArchived] = useState(true);
return <>
<CodingProjectConversations projectId={project.id} archived={archived} onArchivedChange={setArchived} />
<CoreChatPanel />
</>;
};
render(<CodingChatPanel />);
render(<><CodingProjectConversations projectId={project.id} /><CodingChatPanel /></>);
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id));
const list = within(screen.getByTestId('project-conversations'));
expect(list.getByText('已归档对话')).toBeVisible();
expect(list.queryByRole('button', { name: conversation.title, exact: true })).not.toBeInTheDocument();
fireEvent.keyDown(list.getByRole('button', { name: `对话操作:${archivedConversation.title}` }), { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitem', { name: '恢复到最近对话' }));
await waitFor(() => expect(list.queryByText('已归档对话')).not.toBeInTheDocument());
expect(projectApi.patch).toHaveBeenCalledWith(archivedConversation.id, { archived: false });
expect(list.getByRole('button', { name: archivedConversation.title, exact: true })).toBeVisible();
expect(list.getByRole('button', { name: conversation.title, exact: true })).toHaveAttribute('aria-current', 'page');
expect(list.getByRole('button', { name: other.title, exact: true })).toBeVisible();
fireEvent.keyDown(list.getByRole('button', { name: `对话操作:${other.title}` }), { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitem', { name: '删除对话', exact: true }));
await waitFor(() => expect(list.queryByRole('button', { name: other.title, exact: true })).not.toBeInTheDocument());
expect(projectApi.deleteConversation).toHaveBeenCalledExactlyOnceWith(other.id);
expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id);
expect(conversationApi.snapshot).not.toHaveBeenCalledWith(archivedConversation.id);
expect(list.getByRole('button', { name: conversation.title, exact: true })).toHaveAttribute('aria-current', 'page');
expect(codingConversationStore.getState().entriesByConversationId[other.id]).toBeUndefined();
expect(conversationApi.snapshot).not.toHaveBeenCalledWith(other.id);
expect(projectApi.patch).not.toHaveBeenCalled();
expect(projectApi.create).not.toHaveBeenCalled();
expect(conversationApi.abort).not.toHaveBeenCalled();
});
it('keeps the conversation and selection and reports a failed deletion', async () => {
const { toast } = await import('sonner');
const toastError = vi.spyOn(toast, 'error');
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
projectApi.deleteConversation.mockRejectedValue(new Error('删除失败,请重试'));
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const { codingConversationStore } = await import('@/stores/coding-conversations');
render(<><CodingProjectConversations projectId={project.id} /><CodingChatPanel /></>);
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id));
fireEvent.keyDown(screen.getByRole('button', { name: '对话操作:新对话' }), { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitem', { name: '删除对话', exact: true }));
await waitFor(() => expect(toastError).toHaveBeenCalledWith('删除失败,请重试'));
expect(projectApi.deleteConversation).toHaveBeenCalledExactlyOnceWith(conversation.id);
expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id);
expect(codingConversationStore.getState().entriesByConversationId[conversation.id]).toBeDefined();
expect(within(screen.getByTestId('project-conversations')).getByRole('button', { name: conversation.title, exact: true })).toHaveAttribute('aria-current', 'page');
expect(projectApi.create).not.toHaveBeenCalled();
});
it('keeps the new-conversation label visible in compact archive view and returns to recent conversations after creation', async () => {
const archivedConversation = { ...conversation, archivedAt: '2026-09-20T00:00:00Z' };
const createdConversation = { ...conversation, id: 'created-conversation', title: '新建的对话' };
it('lists legacy archived conversations and restores one before selecting it', async () => {
const legacy = { ...conversation, id: 'legacy-conversation', title: '以前的对话', archivedAt: '2026-09-20T00:00:00Z' };
const restored = { ...legacy, archivedAt: null };
const restoreFlight = deferred<CodingConversationMetadata>();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([archivedConversation]);
projectApi.create.mockResolvedValue(createdConversation);
projectApi.conversations.mockResolvedValue([conversation, legacy]);
projectApi.patch.mockReturnValue(restoreFlight.promise);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, createdConversation));
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
conversationApi.snapshot.mockImplementation(async id => createLocalConversationSnapshot(project.id, id === legacy.id ? restored : conversation));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { useState } = await import('react');
const CodingChatPanel = () => {
const [archived, setArchived] = useState(true);
return <>
<CodingProjectConversations projectId={project.id} compact archived={archived} onArchivedChange={setArchived} />
<CoreChatPanel />
</>;
};
render(<CodingChatPanel />);
const list = within(await screen.findByTestId('project-conversations'));
const create = list.getByRole('button', { name: '新建对话', exact: true });
expect(create).toHaveTextContent('新对话');
expect(list.getByText('已归档对话')).toBeVisible();
render(<><CodingProjectConversations projectId={project.id} compact /><CodingChatPanel /></>);
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id));
const list = within(screen.getByTestId('project-conversations'));
expect(list.getByRole('button', { name: '新建对话', exact: true })).toHaveTextContent('新对话');
fireEvent.click(list.getByRole('button', { name: legacy.title, exact: true }));
await waitFor(() => expect(projectApi.patch).toHaveBeenCalledWith(legacy.id, { archived: false }));
expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id);
expect(conversationApi.snapshot).not.toHaveBeenCalledWith(legacy.id);
await act(async () => restoreFlight.resolve(restored));
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(legacy.id));
expect(list.getByRole('button', { name: legacy.title, exact: true })).toHaveAttribute('aria-current', 'page');
expect(screen.queryByRole('button', { name: '恢复对话' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '返回最近对话' })).not.toBeInTheDocument();
expect(projectApi.deleteConversation).not.toHaveBeenCalled();
expect(projectApi.create).not.toHaveBeenCalled();
fireEvent.click(create);
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(createdConversation.id));
expect(projectApi.create).toHaveBeenCalledOnce();
expect(list.queryByText('已归档对话')).not.toBeInTheDocument();
expect(list.queryByRole('button', { name: '返回最近对话' })).not.toBeInTheDocument();
expect(list.queryByRole('button', { name: archivedConversation.title, exact: true })).not.toBeInTheDocument();
expect(list.getByRole('button', { name: createdConversation.title, exact: true })).toHaveAttribute('aria-current', 'page');
expect(conversationApi.submit).not.toHaveBeenCalled();
});
it('renames an unselected conversation from its menu without preparing that conversation', async () => {