feat: improve project agent onboarding

This commit is contained in:
2026-09-06 13:54:20 +08:00
parent a4fc3a5251
commit df02680f9d
15 changed files with 826 additions and 74 deletions

View File

@@ -35,7 +35,11 @@ test.describe('Project configuration skills', () => {
await expect(page.getByTestId('project-configuration-actions')).toBeVisible();
const actionBarBottomGap = await page.getByTestId('project-configuration-actions').evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom);
expect(actionBarBottomGap).toBeLessThan(40);
await expect(page.getByRole('button', { name: '创建智能体' })).toBeVisible();
const setupCallout = page.getByTestId('project-agent-setup-callout');
await expect(setupCallout).toContainText('项目已创建,还差 1 步');
await expect(setupCallout).toContainText('必需');
await expect(page.getByTestId('project-agent-setup-primary')).toHaveText('先配置模型');
await expect(page.getByTestId('project-configuration-back-button')).toHaveAccessibleName('稍后设置');
for (const [resourceId, title] of [
['resource-card-models', '可用模型'],
@@ -48,6 +52,7 @@ test.describe('Project configuration skills', () => {
if (resourceId === 'resource-card-models') {
await expect(drawer.getByRole('combobox')).toHaveCount(0);
await expect(drawer.locator('[data-testid="project-model-list"], [data-testid="project-model-empty-state"]')).toHaveCount(1);
await expect(drawer.getByRole('button', { name: '前往模型设置' })).toBeVisible();
}
const closeButton = drawer.getByRole('button', { name: '关闭' });
const closeGeometry = await closeButton.evaluate((button) => {
@@ -73,6 +78,15 @@ test.describe('Project configuration skills', () => {
const now = new Date().toISOString();
await mkdir(path.join(parentPath, '.makelore'), { recursive: true });
await writeFile(path.join(parentPath, '.makelore', 'project.json'), JSON.stringify({
...createCodingProjectConfigV2(now, 'interactive_ai_app'),
initialized: true,
agents: [],
}, null, 2));
await page.evaluate(() => { window.location.hash = '#/chat'; });
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByTestId('project-agent-setup-callout')).toBeVisible();
await writeFile(path.join(parentPath, '.makelore', 'project.json'), JSON.stringify({
...createCodingProjectConfigV2(now, 'interactive_ai_app'),
initialized: true,
@@ -96,6 +110,11 @@ test.describe('Project configuration skills', () => {
}, null, 2));
await page.reload();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByTestId('project-configuration-back-button')).toHaveAccessibleName('返回对话');
await page.getByTestId('project-configuration-back-button').click();
await expect(page.getByTestId('chat-operation-page')).toBeVisible();
await page.evaluate(() => { window.location.hash = '#/project-config'; });
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await page.getByTestId('resource-card-skills').click();
await expect(page.getByTestId('superpowers-card')).toHaveCount(0);
@@ -163,6 +182,7 @@ test.describe('Project configuration skills', () => {
await expect(avatarDialog).toHaveCount(0);
await expect(maintenanceDialog.getByAltText('当前上传头像')).toBeVisible();
await expect(page.getByText('完成配置')).toHaveCount(0);
await maintenanceDialog.getByRole('button', { name: '取消' }).click();
} finally {
await closeElectronApp(app);
await rm(parentPath, { recursive: true, force: true });

View File

@@ -6,6 +6,10 @@ import { useAuthStore } from '@/stores/auth';
import { useProviderStore } from '@/stores/providers';
import { useSettingsStore } from '@/stores/settings';
import { useUserSyncStore } from '@/stores/user-sync';
import { codingWorkspaceStore } from '@/stores/coding-workspace';
import { useProjectConfigStore } from '@/stores/project-config';
import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config';
import type { CodingProjectAgent } from '@/types/coding-project';
vi.mock('@/components/layout/MainLayout', () => ({
MainLayout: () => (
@@ -39,11 +43,41 @@ vi.mock('@/pages/Settings', () => ({
Settings: () => <div>Global settings</div>,
}));
vi.mock('@/pages/ProjectConfiguration', () => ({
ProjectConfiguration: () => <div>Project configuration</div>,
}));
const readyAgent: CodingProjectAgent = {
id: 'agent-1',
avatarId: 'avatar-01',
roleName: '项目智能体',
name: 'Builder',
builtIn: false,
enabled: true,
model: { accountId: 'account-1', modelId: 'model-1', thinkingLevel: 'off' },
modelResolution: 'resolved',
skillIds: [],
responsibility: {
mission: '完成项目工作',
owns: [],
boundaries: [],
collaborators: [],
principles: [],
},
prompt: '',
archivedAt: null,
pinned: false,
createdAt: '2026-09-06T00:00:00.000Z',
updatedAt: '2026-09-06T00:00:00.000Z',
};
describe('App programming provider initialization gate', () => {
const initProviders = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
codingWorkspaceStore.setState(codingWorkspaceStore.getInitialState(), true);
useProjectConfigStore.setState(useProjectConfigStore.getInitialState(), true);
useSettingsStore.setState({
setupComplete: true,
init: vi.fn(),
@@ -95,6 +129,56 @@ describe('App programming provider initialization gate', () => {
await waitFor(() => expect(initProviders).toHaveBeenCalledTimes(1));
});
it('redirects a legacy initialized project without a usable Agent to configuration', async () => {
const project = {
id: 'local-project',
path: '/tmp/local-project',
name: 'local-project',
createdAt: '',
updatedAt: '',
lastOpenedAt: '',
};
const config = { ...createCodingProjectConfigV2(), initialized: true, agents: [] };
codingWorkspaceStore.setState({
activeProjectId: project.id,
activeProject: project,
load: vi.fn().mockResolvedValue(undefined),
});
useProjectConfigStore.setState({
load: vi.fn().mockResolvedValue({ status: 'valid', config, knowledgeFiles: [] }),
});
await renderAt('/chat');
expect(await screen.findByText('Project configuration')).toBeInTheDocument();
expect(screen.queryByText('Programming workspace')).not.toBeInTheDocument();
});
it('opens chat when the project has a fully configured Agent', async () => {
const project = {
id: 'local-project',
path: '/tmp/local-project',
name: 'local-project',
createdAt: '',
updatedAt: '',
lastOpenedAt: '',
};
const config = { ...createCodingProjectConfigV2(), initialized: true, agents: [readyAgent] };
codingWorkspaceStore.setState({
activeProjectId: project.id,
activeProject: project,
load: vi.fn().mockResolvedValue(undefined),
});
useProjectConfigStore.setState({
load: vi.fn().mockResolvedValue({ status: 'valid', config, knowledgeFiles: [] }),
});
await renderAt('/chat');
expect(await screen.findByText('Programming workspace')).toBeInTheDocument();
expect(screen.queryByText('Project configuration')).not.toBeInTheDocument();
});
it('redirects an orphaned token without a user identity away from a direct workspace route', async () => {
useAuthStore.setState({
initialized: true,

View File

@@ -80,8 +80,8 @@ const agent: CodingProjectAgent = {
pinned: true,
createdAt: '2026-08-23T00:00:00.000Z',
updatedAt: '2026-08-23T00:00:00.000Z',
model: null,
modelResolution: 'required',
model: { accountId: 'account-1', modelId: 'model-1', thinkingLevel: 'off' },
modelResolution: 'resolved',
};
const config: CodingProjectConfig = {
schemaVersion: 2,
@@ -199,6 +199,24 @@ describe('CodingChatPanel first Conversation', () => {
vi.resetModules();
});
it('shows an actionable setup state when the active project has no usable Agent', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config: configForAgents([]) });
projectApi.conversations.mockResolvedValue([]);
const onOpenProjectSettings = vi.fn();
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
render(<CodingChatPanel onOpenProjectSettings={onOpenProjectSettings} />);
expect(await screen.findByTestId('coding-chat-agent-setup-empty-state')).toBeVisible();
expect(screen.getByText('创建项目智能体后开始对话')).toBeVisible();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(projectApi.create).not.toHaveBeenCalled();
expect(conversationApi.snapshot).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '创建或恢复项目智能体' }));
expect(onOpenProjectSettings).toHaveBeenCalledOnce();
});
it('makes the first-Conversation textarea editable while runtime metadata is held', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import type {
CodingProjectAgent,
CodingProjectConfig,
} from '../../shared/coding-project-contracts';
import {
hasReadyCodingProjectAgent,
isCodingProjectAgentReady,
isCodingProjectConversationReady,
} from '../../shared/coding-project-contracts';
const createdAt = '2026-09-06T00:00:00.000Z';
function createAgent(overrides: Partial<CodingProjectAgent> = {}): CodingProjectAgent {
return {
id: 'agent-1',
avatarId: 'spark',
roleName: '编程助手',
name: '小洛',
builtIn: false,
enabled: true,
skillIds: [],
responsibility: {
mission: '帮助完成项目',
owns: [],
boundaries: [],
collaborators: [],
principles: [],
},
prompt: '',
archivedAt: null,
pinned: false,
model: {
accountId: 'account-1',
modelId: 'model-1',
thinkingLevel: 'off',
},
modelResolution: 'resolved',
createdAt,
updatedAt: createdAt,
...overrides,
};
}
function createConfig(overrides: Partial<CodingProjectConfig> = {}): CodingProjectConfig {
return {
schemaVersion: 2,
projectType: 'custom',
initialized: true,
agents: [createAgent()],
knowledgeDirectory: 'knowledge',
createdAt,
updatedAt: createdAt,
...overrides,
};
}
describe('coding project Conversation readiness', () => {
it('accepts an enabled, unarchived, fully configured Agent', () => {
const agent = createAgent();
const config = createConfig({ agents: [agent] });
expect(isCodingProjectAgentReady(agent)).toBe(true);
expect(hasReadyCodingProjectAgent(config)).toBe(true);
expect(isCodingProjectConversationReady(config)).toBe(true);
});
it('rejects a project with no Agents', () => {
const config = createConfig({ agents: [] });
expect(hasReadyCodingProjectAgent(config)).toBe(false);
expect(isCodingProjectConversationReady(config)).toBe(false);
});
it.each([
['disabled', { enabled: false }],
['archived', { archivedAt: createdAt }],
] satisfies Array<[string, Partial<CodingProjectAgent>]>)('rejects a %s Agent', (_label, overrides) => {
const agent = createAgent(overrides);
expect(isCodingProjectAgentReady(agent)).toBe(false);
});
it('rejects an Agent whose model still requires resolution', () => {
const agent = createAgent({ model: null, modelResolution: 'required' });
expect(isCodingProjectAgentReady(agent)).toBe(false);
});
it('rejects an Agent with a resolved but missing model', () => {
const agent = createAgent({ model: null });
expect(isCodingProjectAgentReady(agent)).toBe(false);
});
it('rejects an Agent with an empty responsibility mission', () => {
const agent = createAgent({
responsibility: {
...createAgent().responsibility,
mission: ' ',
},
});
expect(isCodingProjectAgentReady(agent)).toBe(false);
});
it('requires the project to be initialized', () => {
const config = createConfig({ initialized: false });
expect(hasReadyCodingProjectAgent(config)).toBe(true);
expect(isCodingProjectConversationReady(config)).toBe(false);
});
});

View File

@@ -6,9 +6,34 @@ import { codingWorkspaceStore } from '@/stores/coding-workspace';
import { useProjectConfigStore } from '@/stores/project-config';
import { useSettingsStore } from '@/stores/settings';
import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config';
import type { CodingProjectAgent } from '@/types/coding-project';
const learningIpcMock = vi.hoisted(() => vi.fn());
const readyAgent: CodingProjectAgent = {
id: 'agent-1',
avatarId: 'avatar-01',
roleName: '项目智能体',
name: 'Builder',
builtIn: false,
enabled: true,
model: { accountId: 'account-1', modelId: 'model-1', thinkingLevel: 'off' },
modelResolution: 'resolved',
skillIds: [],
responsibility: {
mission: '完成项目工作',
owns: [],
boundaries: [],
collaborators: [],
principles: [],
},
prompt: '',
archivedAt: null,
pinned: false,
createdAt: '2026-09-06T00:00:00.000Z',
updatedAt: '2026-09-06T00:00:00.000Z',
};
vi.mock('@/components/layout/Sidebar', () => ({
Sidebar: ({ sidebarCollapsedOverride }: { sidebarCollapsedOverride?: boolean }) => (
<aside data-testid="sidebar-stub" data-sidebar-collapsed={sidebarCollapsedOverride === undefined ? 'unset' : String(sidebarCollapsedOverride)} />
@@ -105,6 +130,34 @@ describe('MainLayout module isolation', () => {
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false');
});
it('keeps the gate when a legacy config says initialized but has no usable Agent', () => {
const project = codingWorkspaceStore.getState().activeProject!;
useProjectConfigStore.setState({
configsByProjectId: {
[project.id]: { ...createCodingProjectConfigV2(), initialized: true, agents: [] },
},
});
renderLayout('/chat');
expect(screen.getByTestId('project-initialization-gate')).toBeVisible();
expect(screen.getByText('项目还差一步')).toBeVisible();
});
it('allows programming routes when the project has a fully configured Agent', () => {
const project = codingWorkspaceStore.getState().activeProject!;
useProjectConfigStore.setState({
configsByProjectId: {
[project.id]: { ...createCodingProjectConfigV2(), initialized: true, agents: [readyAgent] },
},
});
renderLayout('/chat');
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();
expect(screen.getByTestId('route-content')).toBeVisible();
});
it('keeps the canonical plugin workspace outside the local project initialization gate', () => {
renderLayout('/plugins');

View File

@@ -0,0 +1,211 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ProjectConfiguration } from '@/pages/ProjectConfiguration';
import { codingWorkspaceStore } from '@/stores/coding-workspace';
import { useProjectConfigStore } from '@/stores/project-config';
import { useProviderStore } from '@/stores/providers';
import type { ProviderAccount } from '@/lib/providers';
import type { CodingProjectAgent, CodingProjectConfig, CodingProjectSummary } from '@/types/coding-project';
import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config';
const getCodingSkillsMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/coding-product-tools', () => ({
getCodingSkills: (...args: unknown[]) => getCodingSkillsMock(...args),
}));
const NOW = '2026-09-06T00:00:00.000Z';
const project: CodingProjectSummary = {
id: 'project-1',
path: 'D:/projects/project-1',
name: 'Onboarding project',
createdAt: NOW,
updatedAt: NOW,
lastOpenedAt: NOW,
};
const configuredAccount: ProviderAccount = {
id: 'account-1',
vendorId: 'custom',
label: 'Test account',
authMode: 'api_key',
model: 'model-1',
fallbackModels: [],
enabled: true,
isDefault: true,
createdAt: NOW,
updatedAt: NOW,
};
const readyAgent: CodingProjectAgent = {
id: 'agent-1',
avatarId: 'avatar-01',
roleName: '项目智能体',
name: 'Builder',
builtIn: false,
enabled: true,
model: { accountId: configuredAccount.id, modelId: configuredAccount.model!, thinkingLevel: 'off' },
modelResolution: 'resolved',
skillIds: [],
responsibility: {
mission: '完成项目工作',
owns: [],
boundaries: [],
collaborators: [],
principles: [],
},
prompt: '',
archivedAt: null,
pinned: false,
createdAt: NOW,
updatedAt: NOW,
};
function LocationProbe() {
const location = useLocation();
return <output data-testid="location-path">{location.pathname}</output>;
}
function renderConfiguration(config: CodingProjectConfig) {
useProjectConfigStore.setState({
configsByProjectId: { [project.id]: config },
knowledgeByProjectId: { [project.id]: [] },
});
return render(
<MemoryRouter initialEntries={['/project-config']}>
<Routes>
<Route path="/project-config" element={<ProjectConfiguration />} />
<Route path="/models" element={<LocationProbe />} />
<Route path="/chat" element={<LocationProbe />} />
<Route path="/module-select" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
);
}
function setProviderModels() {
useProviderStore.setState({
accounts: [configuredAccount],
vendors: [],
});
}
describe('project configuration onboarding', () => {
beforeEach(() => {
vi.clearAllMocks();
getCodingSkillsMock.mockResolvedValue({ skills: [] });
const load = vi.fn().mockResolvedValue(undefined);
const reloadWorkspace = vi.fn().mockResolvedValue(undefined);
const refreshProviderSnapshot = vi.fn().mockResolvedValue(undefined);
codingWorkspaceStore.setState({
activeProject: project,
projects: [project],
conversations: [],
load: reloadWorkspace,
});
useProjectConfigStore.setState({ load });
useProviderStore.setState({
accounts: [],
vendors: [],
refreshProviderSnapshot,
});
});
it('strongly guides a project without models to model settings', async () => {
renderConfiguration(createCodingProjectConfigV2(NOW));
expect(await screen.findByTestId('project-agent-setup-callout')).toHaveTextContent('必需');
expect(screen.getByTestId('project-agent-setup-primary')).toHaveTextContent('先配置模型');
expect(screen.getByTestId('project-configuration-back-button')).toHaveAccessibleName('稍后设置');
fireEvent.click(screen.getByTestId('project-agent-setup-primary'));
expect(await screen.findByTestId('location-path')).toHaveTextContent('/models');
});
it('saves the first Agent as initialized and enters chat after creation', async () => {
setProviderModels();
const savedConfig = vi.fn((_projectId: string, nextConfig: CodingProjectConfig) => Promise.resolve(nextConfig));
const reloadWorkspace = vi.fn().mockResolvedValue(undefined);
useProjectConfigStore.setState({ save: savedConfig });
codingWorkspaceStore.setState({ load: reloadWorkspace });
renderConfiguration(createCodingProjectConfigV2(NOW));
fireEvent.click(await screen.findByTestId('project-agent-setup-primary'));
const dialog = await screen.findByRole('dialog', { name: '创建项目智能体' });
fireEvent.change(within(dialog).getByLabelText(/智能体名称/), { target: { value: 'Builder' } });
fireEvent.change(within(dialog).getByLabelText(/职责说明/), { target: { value: '完成项目工作' } });
fireEvent.click(within(dialog).getByRole('button', { name: '创建并进入对话' }));
await waitFor(() => expect(savedConfig).toHaveBeenCalledTimes(1));
expect(savedConfig).toHaveBeenCalledWith(project.id, expect.objectContaining({
initialized: true,
agents: [expect.objectContaining({
name: 'Builder',
modelResolution: 'resolved',
})],
}));
expect(reloadWorkspace).toHaveBeenCalledTimes(1);
expect(await screen.findByTestId('location-path')).toHaveTextContent('/chat');
});
it('labels the back action and returns a complete project to chat', async () => {
setProviderModels();
renderConfiguration({
...createCodingProjectConfigV2(NOW),
initialized: true,
agents: [readyAgent],
});
const backButton = await screen.findByTestId('project-configuration-back-button');
expect(backButton).toHaveAccessibleName('返回对话');
fireEvent.click(backButton);
expect(await screen.findByTestId('location-path')).toHaveTextContent('/chat');
});
it('offers a new Agent when the project contains only disabled Agents', async () => {
setProviderModels();
renderConfiguration({
...createCodingProjectConfigV2(NOW),
initialized: false,
agents: [{ ...readyAgent, enabled: false }],
});
const setupButton = await screen.findByTestId('project-agent-setup-primary');
expect(setupButton).toHaveTextContent('创建项目智能体');
fireEvent.click(setupButton);
expect(await screen.findByRole('dialog', { name: '创建项目智能体' })).toBeVisible();
});
it('confirms before the labelled back action discards Agent edits', async () => {
setProviderModels();
renderConfiguration({
...createCodingProjectConfigV2(NOW),
initialized: true,
agents: [readyAgent],
});
fireEvent.click(await screen.findByTestId(`project-agent-${readyAgent.id}`));
const editor = await screen.findByRole('dialog', { name: `${readyAgent.name} · 编辑智能体` });
fireEvent.change(within(editor).getByLabelText(/智能体名称/), { target: { value: 'Builder 2' } });
fireEvent.click(within(editor).getByRole('button', { name: '保存智能体' }));
await waitFor(() => expect(editor).not.toBeInTheDocument());
fireEvent.click(screen.getByTestId('project-configuration-back-button'));
const confirm = await screen.findByRole('dialog', { name: '放弃未保存的项目配置?' });
fireEvent.click(within(confirm).getByRole('button', { name: '返回对话' }));
expect(await screen.findByTestId('location-path')).toHaveTextContent('/chat');
});
});