feat: include approved Code sidebar actions in combined trial source
This commit is contained in:
1 parent
16b10c44f1
commit
18e4be66da
12 files changed
+596
-61
No files matched your search
@@ -523,7 +523,15 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
run: { status: 'running', runId: 'run-1' } });
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
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: '对话操作:新对话' });
|
||||
@@ -533,7 +541,8 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
await screen.findByRole('heading', { name: '今天,你想创造什么?' });
|
||||
expect(projectApi.create).not.toHaveBeenCalled();
|
||||
expect(conversationApi.abort).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看归档对话' }));
|
||||
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();
|
||||
@@ -541,6 +550,92 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: '恢复对话' })).not.toBeInTheDocument());
|
||||
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(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',
|
||||
};
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
projectApi.conversations.mockResolvedValue([conversation, archivedConversation]);
|
||||
projectApi.patch.mockResolvedValue({ ...archivedConversation, archivedAt: null });
|
||||
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 { 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 />);
|
||||
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(codingConversationStore.getState().selectedConversationId).toBe(conversation.id);
|
||||
expect(conversationApi.snapshot).not.toHaveBeenCalledWith(archivedConversation.id);
|
||||
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: '新建的对话' };
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
projectApi.conversations.mockResolvedValue([archivedConversation]);
|
||||
projectApi.create.mockResolvedValue(createdConversation);
|
||||
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');
|
||||
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();
|
||||
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 () => {
|
||||
@@ -959,7 +1054,7 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
expect(screen.queryByText('metadata rejected')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps project settings only in the Conversation list and removes legacy Header actions', async () => {
|
||||
it('removes project toolbar actions and legacy Header actions while keeping conversation creation', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
|
||||
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
|
||||
@@ -986,10 +1081,12 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
expect(within(header).queryByRole('button', { name: /标为(已读|未读)/ })).not.toBeInTheDocument();
|
||||
expect(within(header).queryByRole('button', { name: '归档' })).not.toBeInTheDocument();
|
||||
expect(within(header).queryByRole('button', { name: '伙伴设置' })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: '项目设置' })).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目设置' }));
|
||||
expect(navigate).toHaveBeenCalledWith('/project-config');
|
||||
expect(screen.queryByRole('button', { name: '项目设置' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '查看归档对话' })).not.toBeInTheDocument();
|
||||
const list = within(screen.getByTestId('project-conversations'));
|
||||
expect(list.getByRole('button', { name: '新建对话', exact: true })).toHaveTextContent('新对话');
|
||||
expect(list.getByRole('button', { name: `对话操作:${conversation.title}` })).toBeInTheDocument();
|
||||
expect(onOpenProjectSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('remounts pending interaction state when switching Conversations', async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
type PeekProps = {
|
||||
sidebarPeekOpen?: boolean;
|
||||
onSidebarPeekChange?: (open: boolean, source: 'toggle' | 'focus' | 'sidebar' | 'titlebar') => void;
|
||||
onSidebarPeekChange?: (open: boolean, source: 'toggle' | 'focus' | 'sidebar' | 'titlebar' | 'menu') => void;
|
||||
forceExpanded?: boolean;
|
||||
};
|
||||
|
||||
@@ -42,7 +42,10 @@ vi.mock('@/components/layout/Sidebar', () => ({
|
||||
data-force-expanded={forceExpanded ? 'true' : 'false'}
|
||||
onPointerEnter={() => onSidebarPeekChange?.(true, 'sidebar')}
|
||||
onPointerLeave={() => onSidebarPeekChange?.(false, 'sidebar')}
|
||||
/>
|
||||
>
|
||||
<button onClick={() => onSidebarPeekChange?.(true, 'menu')}>打开项目菜单</button>
|
||||
<button onClick={() => onSidebarPeekChange?.(false, 'menu')}>关闭项目菜单</button>
|
||||
</aside>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -124,6 +127,20 @@ describe('MainLayout sidebar hover preview', () => {
|
||||
expect(screen.getByTestId('peek-trigger')).toHaveAttribute('data-peek-open', 'false');
|
||||
});
|
||||
|
||||
it('keeps the preview open for a project menu after the pointer leaves the sidebar', () => {
|
||||
renderLayout();
|
||||
const sidebar = screen.getByTestId('peek-sidebar');
|
||||
fireEvent.pointerEnter(sidebar);
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开项目菜单' }));
|
||||
fireEvent.pointerLeave(sidebar);
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(sidebar).toHaveAttribute('data-peek-open', 'true');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭项目菜单' }));
|
||||
act(() => vi.advanceTimersByTime(180));
|
||||
expect(sidebar).toHaveAttribute('data-peek-open', 'false');
|
||||
});
|
||||
|
||||
it('closes once after leaving the only hover zone', () => {
|
||||
renderLayout();
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
|
||||
const projects = vi.hoisted(() => ['A', 'B'].map((name) => ({
|
||||
id: `project-${name.toLowerCase()}`,
|
||||
name,
|
||||
createdAt: '2026-10-13T00:00:00.000Z',
|
||||
updatedAt: '2026-10-13T00:00:00.000Z',
|
||||
lastOpenedAt: '2026-10-13T00:00:00.000Z',
|
||||
})));
|
||||
const codingState = vi.hoisted(() => ({
|
||||
projects,
|
||||
activeProjectId: projects[0].id,
|
||||
activeProject: projects[0],
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
setActiveProject: vi.fn(),
|
||||
removeProject: vi.fn(),
|
||||
createProject: vi.fn(),
|
||||
}));
|
||||
const projectConfigState = vi.hoisted(() => ({
|
||||
load: vi.fn<(projectId: string) => Promise<unknown>>(),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
const providerState = vi.hoisted(() => ({
|
||||
refreshProviderSnapshot: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }),
|
||||
}));
|
||||
vi.mock('@/lib/api-client', () => ({ invokeIpc: vi.fn() }));
|
||||
vi.mock('@/hooks/use-current-user-profile', () => ({
|
||||
useCurrentUserProfile: () => ({
|
||||
userProfile: { displayName: '测试用户', avatarUrl: null },
|
||||
profileRequired: false,
|
||||
profileSyncError: null,
|
||||
syncProfileNow: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: null, logout: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/stores/settings', () => ({
|
||||
useSettingsStore: (selector: (state: unknown) => unknown) => selector({ sidebarCollapsed: false }),
|
||||
}));
|
||||
vi.mock('@/stores/coding-workspace', () => ({
|
||||
codingWorkspaceStore: { getState: () => codingState },
|
||||
useCodingWorkspaceStore: (selector: (state: typeof codingState) => unknown) => selector(codingState),
|
||||
}));
|
||||
vi.mock('@/stores/project-config', () => ({
|
||||
useProjectConfigStore: (selector: (state: typeof projectConfigState) => unknown) => selector(projectConfigState),
|
||||
}));
|
||||
vi.mock('@/stores/providers', () => ({
|
||||
useProviderStore: (selector: (state: typeof providerState) => unknown) => selector(providerState),
|
||||
}));
|
||||
vi.mock('@/pages/Chat/CodingProjectConversations', () => ({
|
||||
CodingProjectConversations: ({ archived }: { archived: boolean }) => (
|
||||
<div data-testid="conversation-list-mode">{archived ? 'archived' : 'recent'}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('@/components/account/PointWallet', () => ({ PointWallet: () => null }));
|
||||
vi.mock('@/components/community/CommunityLink', () => ({ CommunityLink: () => null }));
|
||||
vi.mock('@/components/layout/ModuleSwitcher', () => ({ ModuleSwitcher: () => null }));
|
||||
vi.mock('@/components/layout/ImageWorkspaceSidebar', () => ({ ImageWorkspaceSidebar: () => null }));
|
||||
vi.mock('@/components/layout/SidebarUpdateButton', () => ({ SidebarUpdateButton: () => null }));
|
||||
vi.mock('@/components/profile/UserProfileDialog', () => ({ UserProfileDialog: () => null }));
|
||||
|
||||
function LocationProbe() {
|
||||
return <div data-testid="location">{useLocation().pathname}</div>;
|
||||
}
|
||||
|
||||
async function renderSidebar() {
|
||||
render(<MemoryRouter initialEntries={['/chat']}><Sidebar /><LocationProbe /></MemoryRouter>);
|
||||
await act(async () => undefined);
|
||||
projectConfigState.load.mockClear();
|
||||
}
|
||||
|
||||
async function selectProjectAction(projectName: string, action: string) {
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: `项目操作:${projectName}` }), { key: 'Enter' });
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: action }));
|
||||
}
|
||||
|
||||
describe('Sidebar project actions', () => {
|
||||
beforeEach(() => {
|
||||
codingState.activeProjectId = projects[0].id;
|
||||
codingState.activeProject = projects[0];
|
||||
codingState.setActiveProject.mockReset();
|
||||
codingState.setActiveProject.mockImplementation(async (projectId: string) => {
|
||||
const project = projects.find((item) => item.id === projectId)!;
|
||||
codingState.activeProjectId = projectId;
|
||||
codingState.activeProject = project;
|
||||
return project;
|
||||
});
|
||||
projectConfigState.load.mockReset();
|
||||
projectConfigState.load.mockResolvedValue({ status: 'valid', config: {} });
|
||||
});
|
||||
|
||||
it.each(['项目设置', '已归档对话'])('%s for the current project supersedes an earlier pending project open', async (action) => {
|
||||
await renderSidebar();
|
||||
let resolveProjectB!: (value: unknown) => void;
|
||||
const pendingProjectB = new Promise((resolve) => { resolveProjectB = resolve; });
|
||||
projectConfigState.load.mockImplementation((projectId) => projectId === projects[1].id
|
||||
? pendingProjectB
|
||||
: Promise.resolve({ status: 'valid', config: {} }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '进入项目 B' }));
|
||||
expect(projectConfigState.load).toHaveBeenCalledWith(projects[1].id);
|
||||
expect(codingState.setActiveProject).not.toHaveBeenCalled();
|
||||
await selectProjectAction('A', action);
|
||||
await waitFor(() => {
|
||||
if (action === '项目设置') expect(screen.getByTestId('location')).toHaveTextContent('/project-config');
|
||||
else expect(screen.getByTestId('conversation-list-mode')).toHaveTextContent('archived');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveProjectB({ status: 'valid', config: {} });
|
||||
await pendingProjectB;
|
||||
});
|
||||
|
||||
expect(codingState.activeProjectId).toBe(projects[0].id);
|
||||
expect(codingState.setActiveProject).not.toHaveBeenCalledWith(projects[1].id);
|
||||
if (action === '项目设置') expect(screen.getByTestId('location')).toHaveTextContent('/project-config');
|
||||
else {
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/chat');
|
||||
expect(screen.getByTestId('conversation-list-mode')).toHaveTextContent('archived');
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['项目设置', '已归档对话'])('rejects %s for an inactive project with invalid configuration', async (action) => {
|
||||
await renderSidebar();
|
||||
projectConfigState.load.mockResolvedValue({ status: 'invalid', config: null, error: '配置文件无效' });
|
||||
|
||||
await selectProjectAction('B', action);
|
||||
|
||||
expect(await screen.findByRole('alertdialog')).toHaveTextContent('配置文件无效');
|
||||
expect(projectConfigState.load).toHaveBeenCalledWith(projects[1].id);
|
||||
expect(codingState.setActiveProject).not.toHaveBeenCalled();
|
||||
expect(codingState.activeProjectId).toBe(projects[0].id);
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/chat');
|
||||
expect(screen.getByTestId('conversation-list-mode')).toHaveTextContent('recent');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user