feat: remove Code project prompt and Skills controls

This commit is contained in:
鲨鱼辣椒 committed 2026-10-14 10:44:54 +08:00
1 parent 350f7d740b
commit 140f6be8db
6 files changed
+240 -37

No files matched your search

@@ -0,0 +1,67 @@
# Task: Remove project prompt and Skills editing controls
## Identity
- Task ID: 20261014-remove-project-agent-fields-6c2e8b14
- Mode: Feature
- Branch: codex/20261014-remove-project-agent-fields-6c2e8b14-remove-project-agent-fields
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-remove-project-agent-fields-6c2e8b14
- Base commit: 350f7d740b0559a43c7b3f8ab6cd32401cc6f677
- Owner: codex
- Status: Ready for Integration
## Scope
- Remove Code project prompt and project Skills controls from the configuration page and the unused Skill-list request; clarify default-model copy.
## Intent And Constraints
- User explicitly requested deleting both visible edit sections and asks how historical conversations can be affected. Explain past messages/results are immutable; later turns may use refreshed resources. No advanced replacement entry. Existing backend schema/Agent resources remain compatible; saving model settings must not silently reset hidden metadata. No live-app update, merge, push or worktree retirement requested.
## Project Context Loaded
Task context:
- Official task identity, feature mode, branch, absolute worktree and base350f7d7 verified against registry and Git.
- All59 peer records/177 Scope, Intent and Promotion sections read; no conflict. Child-language placeholder scope remains unknown without concrete dependency. New preview/gallery scopes do not own this configuration form.
Read:
- Own record, AGENTS, entry/read-before-planning/memory-index/planning-gate. Positioning/current-state/decision-index/overview, community boundary, domain/success and evidence/reflection/commitment context retained from just-completed assessment on identical base; canonical diff is empty.
- Current ProjectConfiguration and affected unit/Electron tests, README and prior runtime assessment.
Relevant understanding:
- Project-owned local Agent metadata is authoritative; runtime prompt/Skill contracts are separate from visible edit controls and teacher cloud configuration.
- User has now approved UI deletion, superseding the assessment suggestion of an advanced entry. No runtime resource migration or historical-message rewrite is requested.
- Files: ProjectConfiguration/index.tsx, README and focused configuration tests. Model save should preserve existing prompt/skillIds in the cloned draft.
- Unknown: live temporary-app revision; no update attempted.
Gate result: Passed.
## Plan
1. Delete prompt/Skills controls and unused Skill-fetch state; narrow the explanation to model defaults.
2. Verify model-only saving preserves configuration data, and desktop settings contain no prompt/Skills edit controls.
3. Run focused checks/build, document and commit the result.
## Outcome
- Deleted the project prompt textarea and project Skills assignment section entirely, with no advanced replacement. Deleted the now-unused SkillInfo type, list state, fetch callback and reload requests.
- Default programming settings now expose only the model selector. Copy explicitly states new conversations use the default model while existing conversation models/messages remain unchanged; the no-project state no longer advertises prompt editing.
- README and existing desktop assertions updated. Historical messages and executed results are not rewritten by configuration refresh; the earlier assessment's 'affected history' phrasing meant future turns in that conversation, not past messages.
## Verification
- Product typecheck, build:vite, product/legacy-spec scoped ESLint and whitespace checks passed.
- Existing coding-feature-ui and project-config-store suites: 15 tests passed.
- Focused project-configuration-controls suite: 2 tests passed, covering default and historical Agent routes. Model saves preserve exact hidden prompt/skillIds and untouched Agent metadata without mutating the loaded config; no Skill-list request occurs.
- Isolated Electron project-menu/configuration scenario passed1/1 against fresh build: correct inactive target project/model-b, enabled model/save controls, new copy, no prompt/Skills inputs. Reviewed settings screenshot. Initial run hit an existing modal-close/pointer-leave race in the test; synchronized that transition with menu closure/archive readiness before rerunning, without product changes.
- Evidence: `/tmp/remove-project-agent-fields-electron-6c2e8b14-rerun/run.log` and `results/pi-coding-first-chat-proje-cfb75-gned-at-every-sidebar-width/project-settings-model-only.png`. Generated main entry restored byte-for-byte; isolated processes exited. No real account, project or running application modified.
- Updated existing legacy project-configuration-skills assertions without running that broader historical scenario. No full-suite claim; targeted scope verified.
- Independent read-only product review passed. Final changed-file ESLint/typecheck and task-aware document drift check passed.
## Follow-ups
- Running application update, integration, push and cleanup remain separate actions.
## Promotion Candidates
- Target: current-state / Code configuration summary. Proposal: Code default programming configuration only exposes model selection; prompt and project Skills fields are not user-editable in that page. Evidence: user directive, removed controls/request and focused verification. Future impact: do not reintroduce these controls or imply historical messages are rewritten. No conflict; user explicitly confirmed this behavior. Canonical promotion only during future integration.
+1 -1
View File
@@ -156,7 +156,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
- 新项目自动建立一个内部默认编程智能体,`defaultAgentId` 固定其身份;创建项目不再要求创建或选择智能体。有可用的全局默认模型时复制精确账号与模型,没有时保留待选择状态。旧项目优先保留原默认或置顶/首个可用智能体,保留所有历史绑定;全部停用时新增默认配置,不复活旧配置。 - 新项目自动建立一个内部默认编程智能体,`defaultAgentId` 固定其身份;创建项目不再要求创建或选择智能体。有可用的全局默认模型时复制精确账号与模型,没有时保留待选择状态。旧项目优先保留原默认或置顶/首个可用智能体,保留所有历史绑定;全部停用时新增默认配置,不复活旧配置。
- Makelore 为每个项目智能体附加平台管理的中文语言规则:自然语言默认使用简体中文,包括可见的思考过程、进度说明、工具调用说明和最终回复;代码、命令、路径、日志、错误原文与必要引用保留原始语言。用户或项目智能体系统指令明确要求其他语言时按该要求执行;模型仍不遵循时视为 Provider 模型行为。 - Makelore 为每个项目智能体附加平台管理的中文语言规则:自然语言默认使用简体中文,包括可见的思考过程、进度说明、工具调用说明和最终回复;代码、命令、路径、日志、错误原文与必要引用保留原始语言。用户或项目智能体系统指令明确要求其他语言时按该要求执行;模型仍不遵循时视为 Provider 模型行为。
- 有效项目直接进入会话工作区。打开项目不创建空会话;首次发送或点击项目下的新建入口才创建本地元数据。首条消息无需等待运行时 Snapshot 返回。 - 有效项目直接进入会话工作区。打开项目不创建空会话;首次发送或点击项目下的新建入口才创建本地元数据。首条消息无需等待运行时 Snapshot 返回。
- 项目设置直接编辑默认编程配置的模型、系统指令和需要分配的 Skill;会话菜单可进入该历史会话绑定的编程配置。默认模型只影响新对话,现有对话独立切换模型与思考选项,原有配置编辑不会改写其他对话。 - 项目设置仅提供默认编程配置的模型选择,不展示项目提示词或项目 Skills 编辑入口;知识文件与插件仍在各自入口管理。会话菜单可进入该历史会话绑定的编程配置。默认模型只影响新对话,现有对话独立切换模型与思考选项,已有消息记录不会被改写;保存模型配置保留原有 Agent 元数据。
- 项目智能体只保存自身的精确默认模型;未完成模型选择的智能体保持待配置状态,不从项目级 `defaultModel` 或运行时私有配置回退。 - 项目智能体只保存自身的精确默认模型;未完成模型选择的智能体保持待配置状态,不从项目级 `defaultModel` 或运行时私有配置回退。
- 一个智能体可以拥有多条互相独立的 Pi Session;智能体和对话元数据分别由 `.makelore/project.json` 与 `.makelore/conversations.json` 保存,稳定的智能体与对话 id 保持本地历史连续。 - 一个智能体可以拥有多条互相独立的 Pi Session;智能体和对话元数据分别由 `.makelore/project.json` 与 `.makelore/conversations.json` 保存,稳定的智能体与对话 id 保持本地历史连续。
- 会话列表直接挂在左侧项目下,不展示中间智能体列;同一项目内不同历史智能体的会话统一列出,保留归档、改名、未读与运行状态。快速切换项目和会话时,迟到的请求与回复保持原归属。 - 会话列表直接挂在左侧项目下,不展示中间智能体列;同一项目内不同历史智能体的会话统一列出,保留归档、改名、未读与运行状态。快速切换项目和会话时,迟到的请求与回复保持原归属。
+3 -33
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { ArrowLeft, Check, Cpu, Files, Puzzle, Trash2, Upload, X } from 'lucide-react'; import { ArrowLeft, Check, Cpu, Files, Puzzle, Trash2, Upload, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -10,7 +10,6 @@ import { ProjectPublishAction } from '@/components/works/ProjectPublishAction';
import { Plugins } from '@/pages/Plugins'; import { Plugins } from '@/pages/Plugins';
import { PROJECT_SETTINGS_PLUGINS_PATH } from '@/pages/Plugins/legacy-plugin-redirect'; import { PROJECT_SETTINGS_PLUGINS_PATH } from '@/pages/Plugins/legacy-plugin-redirect';
import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options'; import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options';
import { getCodingSkills } from '@/lib/coding-product-tools';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { useProjectConfigStore } from '@/stores/project-config'; import { useProjectConfigStore } from '@/stores/project-config';
import { useProviderStore } from '@/stores/providers'; import { useProviderStore } from '@/stores/providers';
@@ -18,13 +17,6 @@ import type { CodingProjectAgent, CodingProjectConfig } from '@/types/coding-pro
const EMPTY_FILES: string[] = []; const EMPTY_FILES: string[] = [];
type DrawerMode = 'models' | 'knowledge' | null; type DrawerMode = 'models' | 'knowledge' | null;
type SkillInfo = {
id: string;
name: string;
description: string;
available: boolean;
effective: boolean;
};
const THINKING_LEVEL_LABELS = { const THINKING_LEVEL_LABELS = {
off: '关闭', off: '关闭',
@@ -122,24 +114,11 @@ export function ProjectConfiguration() {
const [draft, setDraft] = useState<CodingProjectConfig | null>(null); const [draft, setDraft] = useState<CodingProjectConfig | null>(null);
const [drawerMode, setDrawerMode] = useState<DrawerMode>(null); const [drawerMode, setDrawerMode] = useState<DrawerMode>(null);
const [activeAgentId, setActiveAgentId] = useState<string | null>(null); const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const knowledgeInputRef = useRef<HTMLInputElement | null>(null); const knowledgeInputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const loadSkills = useCallback(() => {
void getCodingSkills()
.then((result) => setSkills(result.skills.map((skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
available: skill.available,
effective: skill.effective,
}))))
.catch(() => setSkills([]));
}, []);
useEffect(() => { if (activeProject) void load(activeProject.id); }, [activeProject, load]); useEffect(() => { if (activeProject) void load(activeProject.id); }, [activeProject, load]);
useEffect(() => { if (stored) setDraft(structuredClone(stored)); }, [stored]); useEffect(() => { if (stored) setDraft(structuredClone(stored)); }, [stored]);
useEffect(() => { useEffect(() => {
@@ -147,7 +126,6 @@ export function ProjectConfiguration() {
if (!requestedAgentId || !draft?.agents.some((agent) => agent.id === requestedAgentId)) return; if (!requestedAgentId || !draft?.agents.some((agent) => agent.id === requestedAgentId)) return;
setActiveAgentId(requestedAgentId); setActiveAgentId(requestedAgentId);
}, [draft, location.search]); }, [draft, location.search]);
useEffect(() => { loadSkills(); }, [loadSkills]);
useEffect(() => { void refreshProviderSnapshot(); }, [refreshProviderSnapshot]); useEffect(() => { void refreshProviderSnapshot(); }, [refreshProviderSnapshot]);
const modelOptions = useMemo( const modelOptions = useMemo(
() => buildCodingModelOptions(providerAccounts, providerVendors), () => buildCodingModelOptions(providerAccounts, providerVendors),
@@ -162,11 +140,10 @@ export function ProjectConfiguration() {
search: '?scope=all&source=all&state=available', search: '?scope=all&source=all&state=available',
}); });
const closePluginWorkspace = () => { const closePluginWorkspace = () => {
loadSkills();
navigate('/project-config', { replace: true }); navigate('/project-config', { replace: true });
}; };
if (!activeProject) return <><main data-testid="project-configuration-empty-state" className="min-h-full bg-background p-8 text-foreground"><div className="mx-auto w-full max-w-7xl"><h1 className="text-xl font-semibold">项目配置</h1><p className="mt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p><p className="mt-2 text-sm font-medium text-muted-foreground">新建或打开一个项目后即可配置模型、提示词、知识与项目能力;插件目录仍可在这里查看。</p><div className="mt-6 max-w-sm"><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="查看可选与已安装能力" onClick={openPluginWorkspace} /></div></div></main><PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} /></>; if (!activeProject) return <><main data-testid="project-configuration-empty-state" className="min-h-full bg-background p-8 text-foreground"><div className="mx-auto w-full max-w-7xl"><h1 className="text-xl font-semibold">项目配置</h1><p className="mt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p><p className="mt-2 text-sm font-medium text-muted-foreground">新建或打开一个项目后即可配置模型、知识与项目能力;插件目录仍可在这里查看。</p><div className="mt-6 max-w-sm"><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="查看可选与已安装能力" onClick={openPluginWorkspace} /></div></div></main><PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} /></>;
if (!draft) return <div className="p-8 text-center text-sm font-medium">正在读取项目配置…</div>; if (!draft) return <div className="p-8 text-center text-sm font-medium">正在读取项目配置…</div>;
const updateAgent = (next: CodingProjectAgent) => setDraft((current) => current ? { ...current, agents: current.agents.map((item) => item.id === next.id ? { ...next, updatedAt: new Date().toISOString() } : item) } : current); const updateAgent = (next: CodingProjectAgent) => setDraft((current) => current ? { ...current, agents: current.agents.map((item) => item.id === next.id ? { ...next, updatedAt: new Date().toISOString() } : item) } : current);
@@ -202,19 +179,12 @@ export function ProjectConfiguration() {
<section data-testid="project-resource-row" className="grid grid-cols-3 gap-3"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} /><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="管理云端与本机能力" onClick={openPluginWorkspace} /></section> <section data-testid="project-resource-row" className="grid grid-cols-3 gap-3"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} /><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="管理云端与本机能力" onClick={openPluginWorkspace} /></section>
{activeAgent && <section className="surface-card rounded-2xl border border-border/70 p-5" data-testid="project-default-settings"> {activeAgent && <section className="surface-card rounded-2xl border border-border/70 p-5" data-testid="project-default-settings">
<h2 className="text-lg font-semibold">{activeAgent.id === draft.defaultAgentId ? '默认编程配置' : '历史会话编程配置'}</h2> <h2 className="text-lg font-semibold">{activeAgent.id === draft.defaultAgentId ? '默认编程配置' : '历史会话编程配置'}</h2>
<p className="mt-1 text-sm text-muted-foreground">新会话使用项目默认配置;历史会话保留原来的配置与记录。</p> <p className="mt-1 text-sm text-muted-foreground">默认模型用于新对话;已有对话的模型和消息记录保持不变。</p>
<label className="mt-4 block text-sm">默认模型<select className="mt-2 block w-full rounded-lg border bg-background p-2" <label className="mt-4 block text-sm">默认模型<select className="mt-2 block w-full rounded-lg border bg-background p-2"
value={activeAgent.model ? JSON.stringify([activeAgent.model.accountId, activeAgent.model.modelId]) : ''} value={activeAgent.model ? JSON.stringify([activeAgent.model.accountId, activeAgent.model.modelId]) : ''}
onChange={event => { const model = parseCodingModelKey(event.target.value); updateAgent({...activeAgent, model: model ? {...model, thinkingLevel: 'off', reasoningChoice: {mode: 'default'}} : null, modelResolution: model ? 'resolved' : 'required'}); }}> onChange={event => { const model = parseCodingModelKey(event.target.value); updateAgent({...activeAgent, model: model ? {...model, thinkingLevel: 'off', reasoningChoice: {mode: 'default'}} : null, modelResolution: model ? 'resolved' : 'required'}); }}>
<option value="">发送前选择模型</option>{modelOptions.map(model => <option key={model.key} value={model.key}>{model.modelId}</option>)} <option value="">发送前选择模型</option>{modelOptions.map(model => <option key={model.key} value={model.key}>{model.modelId}</option>)}
</select></label> </select></label>
<label className="mt-4 block text-sm">项目提示词<textarea className="mt-2 min-h-36 w-full rounded-lg border bg-background p-3" value={activeAgent.prompt} onChange={event => updateAgent({...activeAgent,prompt:event.target.value})} /></label>
<h3 className="mt-4 text-sm font-medium">项目 Skills</h3>
<div className="mt-2 space-y-2">{skills.map(skill => <label key={skill.id} className="flex items-start gap-2 text-sm">
<input type="checkbox" checked={activeAgent.skillIds.includes(skill.id)} disabled={!skill.available}
onChange={event => updateAgent({...activeAgent,skillIds:event.target.checked ? [...activeAgent.skillIds,skill.id] : activeAgent.skillIds.filter(id=>id!==skill.id)})} />
<span>{skill.name}<span className="block text-xs text-muted-foreground">{skill.description}</span></span>
</label>)}</div>
</section>} </section>}
<div data-testid="project-configuration-actions" className="glass-surface config-motion-actions sticky bottom-3 z-10 mt-auto flex shrink-0 flex-col gap-2 rounded-2xl border border-border/70 bg-background/85 p-2 shadow-float sm:flex-row sm:items-start sm:justify-between"> <div data-testid="project-configuration-actions" className="glass-surface config-motion-actions sticky bottom-3 z-10 mt-auto flex shrink-0 flex-col gap-2 rounded-2xl border border-border/70 bg-background/85 p-2 shadow-float sm:flex-row sm:items-start sm:justify-between">
<Button type="button" variant="outline" size="sm" onClick={() => setDeleteDialogOpen(true)} className="h-9 w-full border-brand/25 bg-background px-3 font-semibold text-foreground shadow-none hover:border-brand/40 hover:bg-brand-soft sm:w-auto"><Trash2 className="mr-2 h-4 w-4" />删除项目</Button> <Button type="button" variant="outline" size="sm" onClick={() => setDeleteDialogOpen(true)} className="h-9 w-full border-brand/25 bg-background px-3 font-semibold text-foreground shadow-none hover:border-brand/40 hover:bg-brand-soft sm:w-auto"><Trash2 className="mr-2 h-4 w-4" />删除项目</Button>
+19 -3
View File
@@ -633,7 +633,8 @@ async function installCodingFirstChatHost(
} }
if (multipleProjects && path === `/api/coding/projects/config?projectId=${otherProject.id}`) if (multipleProjects && path === `/api/coding/projects/config?projectId=${otherProject.id}`)
return respond({ snapshot: { project: otherProject, return respond({ snapshot: { project: otherProject,
config: { ...config, defaultAgentId: agent.id, agents: [{ ...agent, prompt: 'Settings for Another project' }] }, knowledgeFiles: [] } }); 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}`) 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: [{ ...secondConversation, id: 'conversation-other-project', title: 'Other project conversation' }] });
if (multipleProjects && path === '/api/coding/conversations/conversation-other-project/snapshot') if (multipleProjects && path === '/api/coding/conversations/conversation-other-project/snapshot')
@@ -1114,6 +1115,10 @@ test('project menus target their project and keep new conversations aligned at e
await archivedMenuItem.click({ trial: true }); await archivedMenuItem.click({ trial: true });
await page.screenshot({ path: test.info().outputPath('sidebar-project-menu-peek.png') }); await page.screenshot({ path: test.info().outputPath('sidebar-project-menu-peek.png') });
await archivedMenuItem.click(); await archivedMenuItem.click();
await expect(archivedMenuItem).toHaveCount(0);
await expect(conversations.getByRole('button', { name: '返回最近对话', exact: true })).toBeVisible();
// Resume pointer tracking after the modal portal has released the page.
await primaryMenu.hover();
await page.mouse.move(800, 500); await page.mouse.move(800, 500);
await expect(rail).toHaveAttribute('aria-hidden', 'true'); await expect(rail).toHaveAttribute('aria-hidden', 'true');
await sidebarToggle.hover(); await sidebarToggle.hover();
@@ -1137,9 +1142,20 @@ test('project menus target their project and keep new conversations aligned at e
await expect(otherMenu).toBeFocused(); await expect(otherMenu).toBeFocused();
await otherMenu.click(); await otherMenu.click();
await page.getByRole('menuitem', { name: '项目设置', exact: true }).click(); await page.getByRole('menuitem', { name: '项目设置', exact: true }).click();
await expect(page.getByTestId('project-configuration-page')).toBeVisible(); const configuration = page.getByTestId('project-configuration-page');
await expect(page.getByRole('textbox', { name: '项目提示词', exact: true })).toHaveValue('Settings for Another project'); const defaultSettings = configuration.getByTestId('project-default-settings');
await expect(configuration).toBeVisible();
await expect(configuration.getByRole('heading', { name: '项目配置', exact: true })).toBeVisible();
await expect(otherProject).toHaveAttribute('aria-current', 'page'); await expect(otherProject).toHaveAttribute('aria-current', 'page');
const defaultModel = defaultSettings.getByRole('combobox', { name: '默认模型', exact: true });
await expect(defaultModel).toHaveValue(JSON.stringify(['account-e2e', 'model-b']));
await expect(defaultModel).toBeEnabled();
await expect(configuration.getByRole('button', { name: '保存项目配置', exact: true })).toBeEnabled();
await expect(defaultSettings).toContainText('默认模型用于新对话;已有对话的模型和消息记录保持不变。');
await expect(configuration.getByText('项目提示词', { exact: true })).toHaveCount(0);
await expect(configuration.getByText(/项目\s*Skills/)).toHaveCount(0);
await expect(defaultSettings.locator('textarea, input[type="checkbox"]')).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath('project-settings-model-only.png') });
expect((await activationRequests()).at(-1)?.body).toEqual({ projectId: 'project-pi-other' }); expect((await activationRequests()).at(-1)?.body).toEqual({ projectId: 'project-pi-other' });
// The archive action also validates and activates an inactive project. // The archive action also validates and activates an inactive project.
@@ -132,6 +132,8 @@ test.describe('Project configuration plugin capabilities', () => {
}, null, 2)); }, null, 2));
await page.reload(); await page.reload();
await expect(page.getByTestId('project-configuration-page')).toBeVisible(); await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByRole('textbox', { name: '项目提示词', exact: true })).toHaveCount(0);
await expect(page.getByRole('heading', { name: '项目 Skills', exact: true })).toHaveCount(0);
await expect(page.getByTestId('project-identity-card')).toHaveCount(0); await expect(page.getByTestId('project-identity-card')).toHaveCount(0);
await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0); await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0);
await expect(page.getByText(/项目 ID|项目身份|独立副本/u)).toHaveCount(0); await expect(page.getByText(/项目 ID|项目身份|独立副本/u)).toHaveCount(0);
@@ -0,0 +1,148 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ProjectConfiguration } from '@/pages/ProjectConfiguration';
import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers';
import type { CodingProjectAgent, CodingProjectConfig, CodingProjectSummary } from '@/types/coding-project';
const codingState = vi.hoisted(() => ({
activeProject: null as CodingProjectSummary | null,
load: vi.fn().mockResolvedValue(undefined),
removeProject: vi.fn(),
}));
const configState = vi.hoisted(() => ({
configsByProjectId: {} as Record<string, CodingProjectConfig>,
knowledgeByProjectId: {} as Record<string, string[]>,
load: vi.fn().mockResolvedValue(undefined),
save: vi.fn<(projectId: string, config: CodingProjectConfig) => Promise<void>>().mockResolvedValue(undefined),
uploadKnowledge: vi.fn(),
remove: vi.fn(),
}));
const providerState = vi.hoisted(() => ({
accounts: [] as ProviderAccount[],
vendors: [] as ProviderVendorInfo[],
refreshProviderSnapshot: vi.fn().mockResolvedValue(undefined),
}));
const getCodingSkills = vi.hoisted(() => vi.fn().mockResolvedValue({ skills: [] }));
vi.mock('@/stores/coding-workspace', () => ({
useCodingWorkspaceStore: (selector: (state: typeof codingState) => unknown) => selector(codingState),
}));
vi.mock('@/stores/project-config', () => ({
useProjectConfigStore: (selector: (state: typeof configState) => unknown) => selector(configState),
}));
vi.mock('@/stores/providers', () => ({
useProviderStore: (selector: (state: typeof providerState) => unknown) => selector(providerState),
}));
vi.mock('@/lib/coding-product-tools', () => ({ getCodingSkills }));
vi.mock('@/pages/Plugins', () => ({ Plugins: () => null }));
vi.mock('@/components/works/ProjectPublishAction', () => ({ ProjectPublishAction: () => null }));
const now = '2026-10-14T00:00:00.000Z';
const project: CodingProjectSummary = {
id: 'project-config-controls',
name: 'Local project',
createdAt: now,
updatedAt: now,
lastOpenedAt: now,
};
const defaultAgent: CodingProjectAgent = {
id: 'default-agent',
avatarId: 'default',
roleName: '伙伴',
name: 'Builder',
builtIn: false,
enabled: true,
skillIds: ['project-skill', 'unknown-legacy-skill'],
responsibility: { mission: 'Build', owns: [], boundaries: [], collaborators: [], principles: [] },
prompt: ' 原有项目提示词\n保留换行与空格。 ',
archivedAt: null,
pinned: true,
model: { accountId: 'account-1', modelId: 'model-default', thinkingLevel: 'off' },
modelResolution: 'resolved',
createdAt: now,
updatedAt: now,
};
const historicalAgent: CodingProjectAgent = {
...defaultAgent,
id: 'historical-agent',
name: 'Historical builder',
prompt: '历史会话专用提示词\n不要替换为默认项目提示词。',
skillIds: ['historical-only-skill'],
model: { accountId: 'account-1', modelId: 'model-history', thinkingLevel: 'high' },
archivedAt: '2026-10-13T00:00:00.000Z',
pinned: false,
};
const config: CodingProjectConfig = {
schemaVersion: 2,
projectType: 'custom',
initialized: true,
defaultAgentId: defaultAgent.id,
agents: [defaultAgent, historicalAgent],
knowledgeDirectory: 'knowledge',
createdAt: now,
updatedAt: now,
};
describe('ProjectConfiguration model-only controls', () => {
beforeEach(() => {
vi.clearAllMocks();
codingState.activeProject = project;
configState.configsByProjectId = { [project.id]: structuredClone(config) };
providerState.accounts = [{
id: 'account-1',
vendorId: 'custom',
label: 'Work account',
authMode: 'api_key',
model: 'model-default',
fallbackModels: ['model-history', 'model-new'],
enabled: true,
isDefault: true,
createdAt: now,
updatedAt: now,
}];
});
it.each([
{ route: '/project-config', heading: '默认编程配置', target: defaultAgent },
{ route: '/project-config?agent=historical-agent', heading: '历史会话编程配置', target: historicalAgent },
])('changes only the selected Agent model at $route and preserves hidden configuration', async ({ route, heading, target }) => {
render(<MemoryRouter initialEntries={[route]}><ProjectConfiguration /></MemoryRouter>);
expect(await screen.findByRole('heading', { name: heading })).toBeVisible();
const settings = within(screen.getByTestId('project-default-settings'));
const model = settings.getByRole('combobox', { name: '默认模型' });
const save = screen.getByRole('button', { name: '保存项目配置' });
expect(model).toBeEnabled();
expect(model).toHaveValue(JSON.stringify([target.model!.accountId, target.model!.modelId]));
expect(save).toBeEnabled();
expect(settings.getByText('默认模型用于新对话;已有对话的模型和消息记录保持不变。')).toBeVisible();
expect(settings.getAllByRole('combobox')).toHaveLength(1);
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
expect(screen.queryByText('项目提示词')).not.toBeInTheDocument();
expect(screen.queryByText('项目 Skills')).not.toBeInTheDocument();
expect(getCodingSkills).not.toHaveBeenCalled();
fireEvent.change(model, { target: { value: JSON.stringify(['account-1', 'model-new']) } });
fireEvent.click(save);
await waitFor(() => expect(configState.save).toHaveBeenCalledOnce());
expect(configState.save).toHaveBeenCalledWith(project.id, {
...config,
agents: config.agents.map((agent) => agent.id === target.id ? {
...agent,
model: {
accountId: 'account-1',
modelId: 'model-new',
thinkingLevel: 'off',
reasoningChoice: { mode: 'default' },
},
updatedAt: expect.any(String),
} : agent),
});
await waitFor(() => expect(save).toBeEnabled());
expect(codingState.load).toHaveBeenCalledOnce();
expect(configState.configsByProjectId[project.id]).toEqual(config);
expect(getCodingSkills).not.toHaveBeenCalled();
});
});