feat: replace Code conversation archive with deletion
This commit is contained in:
1 parent
140f6be8db
commit
bf101c1e52
19 files changed
+1001
-292
No files matched your search
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user