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

+90 -50
View File
@@ -120,6 +120,9 @@ async function installCodingFirstChatHost(
releaseSnapshot: (() => void) | null;
snapshotPending: boolean;
snapshotSettled: boolean;
deletedConversationIds: string[];
deleteFailureId?: string;
legacyArchivedConversationId?: string;
workReady?: boolean;
workFailed?: boolean;
workGeneration?: number;
@@ -136,6 +139,7 @@ async function installCodingFirstChatHost(
releaseSnapshot: null,
snapshotPending: false,
snapshotSettled: false,
deletedConversationIds: [],
teacherCatalog: { items: [] },
teacherCatalogFailure: false,
holdTeacherCatalog: false,
@@ -525,6 +529,20 @@ async function installCodingFirstChatHost(
at: Date.now(),
});
if (/^\/api\/coding\/conversations\/[^/]+$/.test(path) && method === 'DELETE') {
const id = decodeURIComponent(path.slice(path.lastIndexOf('/') + 1));
const target = [conversation.id, secondConversation.id, historyConversation.id,
...(multipleProjects ? ['conversation-other-project'] : [])].includes(id);
if (!target || state.deletedConversationIds.includes(id)) return respond({ error: '对话不存在' }, 404);
if (state.deleteFailureId === id) return respond({ error: '删除失败,请重试' }, 500);
state.deletedConversationIds.push(id);
metadata.delete(id);
return respond({ success: true });
}
if (state.deletedConversationIds.some(id => path === `/api/coding/conversations/${id}/snapshot`)) {
return respond({ error: '对话不存在' }, 404);
}
if (path === '/api/coding/attachments'
|| /^\/api\/coding\/attachments\/[^/]+\/content$/.test(path)) {
const response = await fetch(`${connection.baseUrl}${path}`, {
@@ -636,7 +654,8 @@ async function installCodingFirstChatHost(
config: { ...config, defaultAgentId: agent.id, agents: [{ ...agent,
model: { ...configuredModel, modelId: 'model-b' }, prompt: 'Settings for Another project' }] }, knowledgeFiles: [] } });
if (multipleProjects && path === `/api/coding/projects/conversations?projectId=${otherProject.id}`)
return respond({ conversations: [{ ...secondConversation, id: 'conversation-other-project', title: 'Other project conversation' }] });
return respond({ conversations: state.deletedConversationIds.includes('conversation-other-project') ? []
: [{ ...secondConversation, id: 'conversation-other-project', title: 'Other project conversation' }] });
if (multipleProjects && path === '/api/coding/conversations/conversation-other-project/snapshot')
return respond({ snapshot: { ...secondSnapshot, conversation: { ...secondSnapshot.conversation,
id: 'conversation-other-project', projectId: otherProject.id } } });
@@ -674,7 +693,8 @@ async function installCodingFirstChatHost(
? [conversation, secondConversation, historyConversation]
: state.conversationCreated
? [conversation]
: []).map((item) => ({ ...item, ...metadata.get(item.id) })),
: []).filter(item => !state.deletedConversationIds.includes(item.id)).map((item) => ({ ...item,
...(state.legacyArchivedConversationId === item.id ? { archivedAt: now } : {}), ...metadata.get(item.id) })),
});
}
if (path.startsWith('/api/agent-browser/') && mainGlobal.__makeloreNativeBrowserE2E) {
@@ -945,17 +965,20 @@ test('saved Game Audio offers click-only playable local preview in Electron', as
async function readState(electronApp: ElectronApplication): Promise<{
captured: CapturedRequest[];
snapshotPending: boolean;
deletedConversationIds: string[];
}> {
return await electronApp.evaluate(() => {
const mainGlobal = globalThis as typeof globalThis & {
__makelorePiFirstChatE2E?: {
captured: CapturedRequest[];
snapshotPending: boolean;
deletedConversationIds: string[];
};
};
return structuredClone({
captured: mainGlobal.__makelorePiFirstChatE2E?.captured ?? [],
snapshotPending: mainGlobal.__makelorePiFirstChatE2E?.snapshotPending ?? false,
deletedConversationIds: mainGlobal.__makelorePiFirstChatE2E?.deletedConversationIds ?? [],
});
});
}
@@ -980,7 +1003,7 @@ async function settleSnapshot(electronApp: ElectronApplication): Promise<void> {
});
}
test('conversation menus rename, archive and restore without selecting or stopping the target', async ({ launchElectronApp }) => {
test('conversation menus rename and delete exact targets while preserving other conversations and failed deletions', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({
@@ -988,7 +1011,10 @@ test('conversation menus rename, archive and restore without selecting or stoppi
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, connection, true);
await settleSnapshot(electronApp);
await electronApp.evaluate(() => {
const state = (globalThis as typeof globalThis & { __makelorePiFirstChatE2E: { legacyArchivedConversationId: string } }).__makelorePiFirstChatE2E;
state.legacyArchivedConversationId = 'conversation-pi-history';
});
await disableCodingEventSource(page);
try {
await page.reload();
@@ -997,12 +1023,15 @@ test('conversation menus rename, archive and restore without selecting or stoppi
await page.evaluate(() => { window.location.hash = '/chat'; });
const sidebar = page.getByTestId('project-conversations');
const header = page.getByTestId('coding-conversation-header');
const projectMenu = page.getByRole('button', { name: '项目操作:PI first chat', exact: true });
const current = sidebar.getByRole('button', { name: '新对话', exact: true });
const legacy = sidebar.getByRole('button', { name: 'History and quota', exact: true });
await expect(header).toContainText('新对话');
await expect(sidebar.getByRole('button', { name: '查看归档对话' })).toHaveCount(0);
await expect(sidebar.getByRole('button', { name: '项目设置', exact: true })).toHaveCount(0);
await expect(current.getByLabel('对话正在运行')).toBeVisible();
await expect(legacy).toBeVisible();
await sidebar.getByRole('button', { name: '对话操作:Second Conversation' }).focus();
await page.keyboard.press('Enter');
await expect(page.getByRole('menuitem')).toHaveText(['重命名', '删除对话']);
await page.screenshot({ path: test.info().outputPath('conversation-actions-delete.png'), animations: 'disabled' });
await page.getByRole('menuitem', { name: '重命名', exact: true }).click();
const title = page.getByRole('textbox', { name: '对话标题' });
await expect(title).toHaveValue('Second Conversation');
@@ -1012,35 +1041,51 @@ test('conversation menus rename, archive and restore without selecting or stoppi
await expect(header).toContainText('新对话');
await sidebar.getByRole('button', { name: '对话操作:重命名后的会话' }).focus();
await page.keyboard.press('Enter');
await page.getByRole('menuitem', { name: '归档', exact: true }).click();
await page.getByRole('menuitem', { name: '删除对话', exact: true }).click();
await expect(sidebar.getByRole('button', { name: '重命名后的会话', exact: true })).toHaveCount(0);
await projectMenu.focus();
await page.keyboard.press('Enter');
await page.getByRole('menuitem', { name: '已归档对话', exact: true }).click();
await expect(sidebar.getByText('已归档对话', { exact: true })).toBeVisible();
await expect(sidebar.getByRole('button', { name: '返回最近对话', exact: true })).toBeVisible();
await expect(header).toContainText('新对话');
await projectMenu.click();
await expect(page.getByRole('menuitem', { name: '最近对话', exact: true })).toBeVisible();
await page.keyboard.press('Escape');
await sidebar.getByRole('button', { name: '重命名后的会话', exact: true }).click();
await expect(page.getByRole('button', { name: '恢复对话', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '发送', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: '恢复对话', exact: true }).click();
await expect(header).toContainText('重命名后的会话');
await expect(sidebar.getByRole('button', { name: '返回最近对话', exact: true })).toHaveCount(0);
await expect(sidebar.getByRole('button', { name: '重命名后的会话', exact: true })).toBeVisible();
await projectMenu.click();
await page.getByRole('menuitem', { name: '已归档对话', exact: true }).click();
await sidebar.getByRole('button', { name: '返回最近对话', exact: true }).click();
await expect(sidebar.getByRole('button', { name: '重命名后的会话', exact: true })).toBeVisible();
const requests = (await readState(electronApp)).captured;
expect(requests.some((request) => request.path.endsWith('/abort'))).toBe(false);
expect(requests.filter((request) => request.method === 'PATCH' && request.path.endsWith('/conversation-pi-second'))
.map((request) => request.body)).toEqual([
{ title: '重命名后的会话' }, { archived: true }, { archived: false },
await expect(current.getByLabel('对话正在运行')).toBeVisible();
await expect(page.getByRole('dialog')).toHaveCount(0);
expect((await readState(electronApp)).deletedConversationIds).toEqual(['conversation-pi-second']);
await electronApp.evaluate(() => {
const state = (globalThis as typeof globalThis & { __makelorePiFirstChatE2E: { deleteFailureId: string } }).__makelorePiFirstChatE2E;
state.deleteFailureId = 'conversation-pi-history';
});
await sidebar.getByRole('button', { name: '对话操作:History and quota' }).click();
await page.getByRole('menuitem', { name: '删除对话', exact: true }).click();
await expect(page.getByText('删除失败,请重试', { exact: true })).toBeVisible();
await expect(legacy).toBeVisible();
await expect(header).toContainText('新对话');
expect((await readState(electronApp)).deletedConversationIds).toEqual(['conversation-pi-second']);
// Legacy records remain in the normal list and become editable on open.
await legacy.click();
await expect(header).toContainText('History and quota');
await expect(page.getByRole('button', { name: '恢复对话', exact: true })).toHaveCount(0);
expect((await readState(electronApp)).captured.filter(request => request.method === 'PATCH'
&& request.path.endsWith('/conversation-pi-history')).map(request => request.body)).toEqual([{ archived: false }]);
await current.click();
await expect(current.getByLabel('对话正在运行')).toBeVisible();
await sidebar.getByRole('button', { name: '对话操作:新对话' }).click();
await page.getByRole('menuitem', { name: '删除对话', exact: true }).click();
await expect(page.getByRole('heading', { name: '今天,你想创造什么?' })).toBeVisible();
await expect(current).toHaveCount(0);
await expect(legacy).toBeVisible();
await expect(legacy).not.toHaveAttribute('aria-current', 'page');
await expect(page.getByRole('dialog')).toHaveCount(0);
const state = await readState(electronApp);
expect(state.deletedConversationIds).toEqual(['conversation-pi-second', 'conversation-pi-first-chat']);
expect(state.captured.filter(request => request.method === 'DELETE').map(request => request.path)).toEqual([
'/api/coding/conversations/conversation-pi-second', '/api/coding/conversations/conversation-pi-history',
'/api/coding/conversations/conversation-pi-first-chat',
]);
await page.screenshot({ path: test.info().outputPath('coding-session-controls.png') });
expect(state.captured.filter(request => request.method === 'PATCH' && request.path.endsWith('/conversation-pi-second'))
.map(request => request.body)).toEqual([{ title: '重命名后的会话' }]);
expect(state.captured.some(request => request.path.endsWith('/abort'))).toBe(false);
expect(state.captured.some(request => request.method === 'POST' && request.path === '/api/coding/projects/conversations')).toBe(false);
await expect(page.getByText('删除失败,请重试', { exact: true })).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath('deleted-current-conversation.png'), animations: 'disabled' });
} finally { await releaseSnapshot(electronApp); }
});
@@ -1101,8 +1146,8 @@ test('project menus target their project and keep new conversations aligned at e
await sidebarToggle.hover();
await expect(rail).toHaveAttribute('aria-hidden', 'false');
await primaryMenu.click();
const archivedMenuItem = page.getByRole('menuitem', { name: '已归档对话', exact: true });
await expect(archivedMenuItem).toBeVisible();
const settingsMenuItem = page.getByRole('menuitem', { name: '项目设置', exact: true });
await expect(settingsMenuItem).toBeVisible();
await page.mouse.move(800, 500);
// Stay outside the rail longer than its 180ms peek-close delay. An open
// portal menu must keep the preview available and remain above it.
@@ -1111,18 +1156,16 @@ test('project menus target their project and keep new conversations aligned at e
// menu is open, so measure the rail's visual state here instead.
await expect(rail).toHaveCSS('width', '256px');
await expect(rail).toHaveCSS('opacity', '1');
await expect(archivedMenuItem).toBeVisible();
await archivedMenuItem.click({ trial: true });
await expect(settingsMenuItem).toBeVisible();
await settingsMenuItem.click({ trial: true });
await page.screenshot({ path: test.info().outputPath('sidebar-project-menu-peek.png') });
await archivedMenuItem.click();
await expect(archivedMenuItem).toHaveCount(0);
await expect(conversations.getByRole('button', { name: '返回最近对话', exact: true })).toBeVisible();
await page.keyboard.press('Escape');
await expect(settingsMenuItem).toHaveCount(0);
// Resume pointer tracking after the modal portal has released the page.
await primaryMenu.hover();
await page.mouse.move(800, 500);
await expect(rail).toHaveAttribute('aria-hidden', 'true');
await sidebarToggle.hover();
await conversations.getByRole('button', { name: '返回最近对话', exact: true }).click();
await sidebarToggle.click();
await expect(rail).toHaveCSS('width', '256px');
await expect(resizer).toBeVisible();
@@ -1132,8 +1175,7 @@ test('project menus target their project and keep new conversations aligned at e
const activationsBeforeMenu = await activationRequests();
await otherMenu.focus();
await page.keyboard.press('Enter');
await expect(page.getByRole('menuitem', { name: '已归档对话', exact: true })).toBeVisible();
await expect(page.getByRole('menuitem', { name: '项目设置', exact: true })).toBeVisible();
await expect(page.getByRole('menuitem')).toHaveText(['项目设置']);
await expect(primaryProject).toHaveAttribute('aria-current', 'page');
await expect(otherProject).not.toHaveAttribute('aria-current', 'page');
await expect(page.getByTestId('coding-conversation-header')).toContainText('新对话');
@@ -1158,16 +1200,14 @@ test('project menus target their project and keep new conversations aligned at e
await page.screenshot({ path: test.info().outputPath('project-settings-model-only.png') });
expect((await activationRequests()).at(-1)?.body).toEqual({ projectId: 'project-pi-other' });
// The archive action also validates and activates an inactive project.
// Opening settings for another project keeps the action project-scoped.
await primaryMenu.click();
await page.getByRole('menuitem', { name: '已归档对话', exact: true }).click();
await expect(page.getByTestId('coding-conversation-header')).toBeVisible();
await page.getByRole('menuitem', { name: '项目设置', exact: true }).click();
await expect(configuration).toBeVisible();
await expect(primaryProject).toHaveAttribute('aria-current', 'page');
await expect(conversations.getByText('已归档对话', { exact: true })).toBeVisible();
expect((await activationRequests()).at(-1)?.body).toEqual({ projectId: 'project-pi-first-chat' });
await primaryMenu.click();
await page.getByRole('menuitem', { name: '最近对话', exact: true }).click();
await expect(conversations.getByRole('button', { name: '返回最近对话', exact: true })).toHaveCount(0);
await primaryProject.click();
await expect(page.getByTestId('coding-conversation-header')).toBeVisible();
await expect(conversations.getByRole('button', { name: '新对话', exact: true })).toBeVisible();
expect((await readState(electronApp)).captured.filter(request => request.method === 'POST'
&& /\/(prompt|messages|abort)$/.test(request.path))).toHaveLength(0);