feat: simplify code project creation

This commit is contained in:
inman
2026-09-07 10:06:37 +08:00
parent f8eee430f4
commit af13aca003
15 changed files with 351 additions and 486 deletions

View File

@@ -0,0 +1,123 @@
# Task: Remove project creation gate and template selection
## Identity
- Task ID: 20260906-remove-project-gate-7c31
- Mode: Feature
- Branch: codex/20260906-remove-project-gate-7c31-remove-project-gate-7c31
- Worktree: /Users/inmanx/Documents/makelore-task-remove-project-gate-7c31
- Base commit: f8eee430f4f4167dc2ecc682ece96c652cdde14b
- Owner: codex
- Status: Ready for integration
## Scope
- Remove the user-facing project creation identity/type choices from the Code
sidebar and use the existing safe defaults instead.
- Stop treating `config.initialized` as a navigation/UI gate for an otherwise
valid local Code project.
- Keep an empty project usable by presenting a non-blocking route to project
settings when no Agent exists.
- Keep the durable project ID as an internal service concern: remove it from the
normal Project Configuration UI and automatically assign one when a valid
legacy project lacks it.
- Update focused Renderer tests, the shared Electron project-creation flow, and
README product-state documentation.
## Intent And Constraints
- A new project receives a Main-generated local UUID and the existing internal
`interactive_ai_app` type without asking the user to choose either during
creation.
- Preserve `.makelore/project.json`, historical project-type normalization,
underlying identity compatibility endpoints, publish contracts, and existing
custom projects without exposing identity choices to young users.
- Keep the bundled Project Scaffold Skill available as an optional Plugin; it
must not be part of, or a prerequisite for, project creation.
- Missing or invalid project metadata remains an entry error. A valid legacy
config missing only `projectId` is repaired by Main with a generated UUID;
the legacy `initialized` Boolean no longer blocks a valid project.
- Renderer backend access continues through the existing Host API/store seams.
## Outcome
- Simplified the Code project creation dialog to directory selection only.
Main still generates the local project UUID, while the Renderer writes the
existing internal `interactive_ai_app` default without presenting identity,
type, or template choices.
- Removed the `initialized` navigation redirect and full-workspace overlay for
valid project metadata. Missing or invalid metadata still routes to Project
Configuration.
- Added an empty-Agent state in the coding chat so a newly created project opens
immediately and offers a direct, non-blocking route to create its first Agent.
- Reworded Project Configuration save affordances so the retained compatibility
write to `initialized` is no longer described as an initialization ceremony.
- Removed the Project Configuration identity card, raw UUID, bind form, and
independent-copy action. Main now serializes automatic UUID assignment for a
valid legacy project missing `projectId`, including concurrent reads, while
retaining the underlying compatibility endpoints and immutable schema rules.
- Replaced the last creation-dialog identity terminology with youth-readable
copy: only the folder choice is explained; all other setup is automatic.
- Preserved project schemas, historical project types, publishing contracts,
existing custom projects, and the optional bundled Project Scaffold
Plugin/Skill.
- Updated README product-state documentation and focused unit/Electron coverage.
- Diagnosed a post-verification UI mismatch as a development-runtime issue: the
visible Electron window had been launched from the separate Plugins/settings
task worktree, whose source still contained the former identity choices. The
stale process group was stopped and the verified feature worktree was started
on the standard development ports.
## Verification
- `corepack pnpm install --frozen-lockfile` — passed with the repository-pinned
pnpm 10.33.4.
- `corepack pnpm run typecheck` — passed.
- Focused ESLint across all changed source/test files — passed.
- Focused Renderer/Main tests — 60 passed across App routing, Main layout,
Sidebar, CodingChatPanel, Project Configuration, config stores, and serialized
automatic legacy identity assignment.
- Adjacent project-schema/store tests — 34 passed.
- `corepack pnpm run lint:check` — passed with zero errors; five pre-existing
warnings remain in unrelated Home/Makelore files.
- `corepack pnpm test` — passed: 1,862 ordinary tests passed, three skipped, and
the isolated pressure test passed 1/1.
- `corepack pnpm run build:vite` — passed for Renderer, Main, Preload, and the
release utility worker.
- `corepack pnpm exec playwright test tests/e2e/coding-project-identity.spec.ts tests/e2e/project-configuration-skills.spec.ts`
— passed (2/2), covering directory-only creation, direct chat entry, absence
of all identity terminology and controls, automatic legacy UUID repair, and
the empty-Agent settings route.
- Live runtime verification after restart — Vite PID 16968 and Electron PID
16975 both resolve to this task worktree; the active Renderer command reports
the same `app-path`, and the source served on port 5173 contains the automatic
setup copy while none of `项目身份`, `创建新的项目 ID`, or `绑定已有项目 ID`
is present.
- `git diff --check` — passed.
## Follow-ups
- Promote the project-creation workflow change into ADR-008 and the canonical
project architecture/domain documentation during a serialized Integration
task.
- Physically removing the optional bundled Project Scaffold Plugin/Skill, if
desired, should be a separate explicit decision because it affects packaged
resources and release coverage; this task only removes it from the creation
path.
## Promotion Candidates
- Target: ADR-008 and canonical project-creation architecture/domain documents.
Proposal: amend the user-facing creation workflow so identity and project type
are safe defaults instead of required choices, and treat `initialized` as a
compatibility field rather than a workspace navigation gate. Durable project
identity remains Main-owned and is generated automatically for new projects
and valid legacy configs that lack it; raw UUIDs, bind choices, and copy
semantics must not return to ordinary youth-facing UI. Existing custom
projects, type normalization, publishing authority, compatibility endpoints,
and the optional Scaffold Skill remain. Future impact: product documentation
and future creation/configuration UI must not restore these choices as
mandatory or visible setup. Human confirmation is represented by the user's
explicit statements that the product targets young users and service-required
identity should default automatically; canonical promotion still belongs to a
serialized Integration task.

View File

@@ -10,7 +10,7 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工
应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main再由 Main 调用 Works Square成功后回到入口选择页。已登录时Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与三个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“交互式 AI 应用”或“自定义项目”,创建服务只生成 `.makelore/project.json``knowledge/`;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取、启用并为智能体分配官方 bundled `makelore.project-scaffold` 插件后,明确调用 `makelore-project-scaffold` Skill以不覆盖既有路径的方式生成固定六文件 Vite 起步工程;该 Skill 不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录Main 自动生成内部项目 ID并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json``knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取、启用并为智能体分配官方 bundled `makelore.project-scaffold` 插件后,仍可按需明确调用 `makelore-project-scaffold` Skill以不覆盖既有路径的方式生成固定六文件 Vite 起步工程;该 Skill 不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。
## 当前产品状态

View File

@@ -211,21 +211,7 @@ export class CodingProjectService {
);
}
}
const config = await this.readCurrentConfig(projectPath);
if (!config) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_CONFIG_INVALID',
'Coding project configuration is unavailable',
);
}
if (!config.projectId) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_IDENTITY_REQUIRED',
'A durable coding project identity is required',
);
}
const config = (await this.getConfig(project.id)).config;
return { project, path: projectPath, projectId: config.projectId };
}
@@ -335,19 +321,10 @@ export class CodingProjectService {
async getConfig(projectId?: string): Promise<CodingProjectConfigSnapshot> {
const project = projectId ? await this.getProject(projectId) : await this.requireActiveProject();
const config = await this.readCurrentConfig(project.path);
if (!config) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_CONFIG_INVALID',
'Coding project configuration is unavailable',
);
}
return {
project,
config,
knowledgeFiles: await this.listKnowledgeFiles(project.path),
};
const config = await this.requireCurrentConfig(project);
return config.projectId
? await this.projectConfigSnapshot(project, config)
: await this.ensureProjectIdentity(project);
}
async saveConfig(projectId: string, value: unknown): Promise<CodingProjectConfigSnapshot> {
@@ -379,8 +356,8 @@ export class CodingProjectService {
): Promise<CodingProjectConfigSnapshot> {
const project = await this.getProject(localProjectId);
return await this.serializeIdentityTransition(project.path, async () => {
const current = await this.getConfig(project.id);
if (current.config.projectId !== undefined) {
const current = await this.requireCurrentConfig(project);
if (current.projectId !== undefined) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_IDENTITY_IMMUTABLE',
@@ -397,7 +374,7 @@ export class CodingProjectService {
nextProjectId,
);
const next = {
...current.config,
...current,
projectId: nextProjectId,
updatedAt: this.options.now?.() ?? new Date().toISOString(),
};
@@ -407,11 +384,7 @@ export class CodingProjectService {
storageFailure(error);
}
await this.options.onResourcesChanged?.(project);
return {
project: current.project,
config: next,
knowledgeFiles: await this.listKnowledgeFiles(current.project.path),
};
return await this.projectConfigSnapshot(project, next);
});
}
@@ -428,8 +401,8 @@ export class CodingProjectService {
}
const project = await this.getProject(localProjectId);
return await this.serializeIdentityTransition(project.path, async () => {
const current = await this.getConfig(project.id);
if (current.config.projectId === undefined) {
const current = await this.requireCurrentConfig(project);
if (current.projectId === undefined) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_IDENTITY_REQUIRED',
@@ -442,11 +415,11 @@ export class CodingProjectService {
);
await this.options.onProjectIdentityChanging?.(
project,
current.config.projectId,
current.projectId,
nextProjectId,
);
const next = {
...current.config,
...current,
projectId: nextProjectId,
updatedAt: this.options.now?.() ?? new Date().toISOString(),
};
@@ -456,11 +429,7 @@ export class CodingProjectService {
storageFailure(error);
}
await this.options.onResourcesChanged?.(project);
return {
project: current.project,
config: next,
knowledgeFiles: await this.listKnowledgeFiles(current.project.path),
};
return await this.projectConfigSnapshot(project, next);
});
}
@@ -540,6 +509,61 @@ export class CodingProjectService {
});
}
private async ensureProjectIdentity(
project: CodingProject,
): Promise<CodingProjectConfigSnapshot> {
return await this.serializeIdentityTransition(project.path, async () => {
const current = await this.requireCurrentConfig(project);
if (current.projectId) {
return await this.projectConfigSnapshot(project, current);
}
const nextProjectId = resolveProjectIdentity(
{ kind: 'create' },
this.options.createProjectId ?? randomUUID,
);
await this.options.onProjectIdentityChanging?.(
project,
undefined,
nextProjectId,
);
const next = {
...current,
projectId: nextProjectId,
updatedAt: this.options.now?.() ?? new Date().toISOString(),
};
try {
await (this.options.writeConfig ?? writeCodingProjectConfigV2)(project.path, next);
} catch (error) {
storageFailure(error);
}
await this.options.onResourcesChanged?.(project);
return await this.projectConfigSnapshot(project, next);
});
}
private async requireCurrentConfig(project: CodingProject): Promise<CodingProjectConfigV2> {
const config = await this.readCurrentConfig(project.path);
if (!config) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_CONFIG_INVALID',
'Coding project configuration is unavailable',
);
}
return config;
}
private async projectConfigSnapshot(
project: CodingProject,
config: CodingProjectConfigV2,
): Promise<CodingProjectConfigSnapshot> {
return {
project,
config,
knowledgeFiles: await this.listKnowledgeFiles(project.path),
};
}
private async listKnowledgeFiles(projectPath: string): Promise<string[]> {
try {
const entries = await readdir(path.join(projectPath, 'knowledge'), { withFileTypes: true });

View File

@@ -226,7 +226,7 @@ function ProjectChatRoute() {
const configResult = await loadProjectConfig(project.id);
if (!cancelled) {
setResolvedProjectKey(project.id);
setResolvedRoute(configResult.status === 'valid' && Boolean(configResult.config?.initialized) ? 'chat' : 'config');
setResolvedRoute(configResult.status === 'valid' && Boolean(configResult.config) ? 'chat' : 'config');
}
} catch {
if (!cancelled) {

View File

@@ -2,15 +2,11 @@
* Main Layout Component
* TitleBar at top, then sidebar + content below.
*/
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Outlet, useLocation } from 'react-router-dom';
import { useCallback, useEffect, useRef, useState } from 'react';
import { LockKeyhole, Settings2 } from 'lucide-react';
import { Sidebar } from './Sidebar';
import { TitleBar } from './TitleBar';
import { Button } from '@/components/ui/button';
import { getAiModuleForPath } from '@/lib/ai-modules';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { useProjectConfigStore } from '@/stores/project-config';
import { useSettingsStore } from '@/stores/settings';
import { cn } from '@/lib/utils';
import type { SidebarPeekSource } from './sidebar-peek';
@@ -18,12 +14,8 @@ import type { SidebarPeekSource } from './sidebar-peek';
const SIDEBAR_PEEK_CLOSE_DELAY_MS = 180;
export function MainLayout() {
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const config = useProjectConfigStore((state) => activeProject ? state.configsByProjectId[activeProject.id] : undefined);
const load = useProjectConfigStore((state) => state.load);
const location = useLocation();
const navigate = useNavigate();
const [sidebarPeekOpen, setSidebarPeekOpen] = useState(false);
const sidebarPeekCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sidebarPeekSourcesRef = useRef<Record<SidebarPeekSource, boolean>>({
@@ -33,13 +25,9 @@ export function MainLayout() {
titlebar: false,
});
const activeModule = getAiModuleForPath(location.pathname);
const isProgrammingModule = activeModule === 'programming';
const isPaintingModule = activeModule === 'painting';
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
const isChatWorkspace = location.pathname === '/chat';
const isInitializationSafeRoute = location.pathname === '/project-config'
|| location.pathname === '/plugins'
|| !isProgrammingModule;
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
if (!sidebarCollapsed) return;
@@ -84,11 +72,6 @@ export function MainLayout() {
if (sidebarPeekCloseTimerRef.current) clearTimeout(sidebarPeekCloseTimerRef.current);
}, []);
useEffect(() => {
if (activeProject && isProgrammingModule) void load(activeProject.id).catch(() => undefined);
}, [activeProject, isProgrammingModule, load]);
const initializationBlocked = Boolean(activeProject && (!config || !config.initialized) && !isInitializationSafeRoute);
return (
<div data-testid="main-layout" className="relative flex h-[100dvh] flex-col overflow-hidden bg-transparent font-sans">
{/* Title bar: drag region on macOS, icon + controls on Windows */}
@@ -117,16 +100,6 @@ export function MainLayout() {
)}
>
<Outlet />
{initializationBlocked ? (
<div data-testid="project-initialization-gate" className="glass-surface absolute inset-0 z-[100] flex items-center justify-center bg-background/90 p-6">
<div className="surface-card max-w-md rounded-2xl border border-border/80 bg-background p-8 text-center shadow-float">
<LockKeyhole className="mx-auto h-10 w-10" />
<h1 className="mt-4 text-2xl font-semibold"></h1>
<p className="mt-3 text-sm font-medium text-muted-foreground"> Agent </p>
<Button onClick={() => navigate('/project-config')} className="mt-6 border border-brand/20 bg-brand font-semibold text-primary-foreground"><Settings2 className="mr-2 h-4 w-4" /></Button>
</div>
</div>
) : null}
</main>
</div>
</div>

View File

@@ -48,11 +48,8 @@ import { useSettingsStore } from '@/stores/settings';
import { useAuthStore, type AuthUser } from '@/stores/auth';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { useProviderStore } from '@/stores/providers';
import { isCanonicalCodingProjectId } from '@/lib/coding-projects';
import type { CodingProjectSummary } from '@/types/coding-project';
import { useProjectConfigStore } from '@/stores/project-config';
import type { ProjectType } from '../../../shared/project-config';
import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts';
import { toast } from 'sonner';
type OpenDialogResult = {
@@ -72,7 +69,6 @@ type ProjectEntryError = {
};
type ProjectDirectoryMode = 'use-selected-directory' | 'create-child-directory';
type ProjectIdentityKind = ProjectIdentityChoice['kind'];
function getFolderName(pathValue: string): string {
const trimmed = pathValue.trim();
@@ -166,9 +162,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
const [newProjectName, setNewProjectName] = useState('');
const [newProjectSelectedPath, setNewProjectSelectedPath] = useState('');
const [newProjectDirectoryMode, setNewProjectDirectoryMode] = useState<ProjectDirectoryMode>('use-selected-directory');
const [newProjectType, setNewProjectType] = useState<ProjectType>('interactive_ai_app');
const [newProjectIdentityKind, setNewProjectIdentityKind] = useState<ProjectIdentityKind>('create');
const [newProjectIdentityProjectId, setNewProjectIdentityProjectId] = useState('');
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
const [creatingProject, setCreatingProject] = useState(false);
const [projectEntryError, setProjectEntryError] = useState<ProjectEntryError | null>(null);
@@ -186,7 +179,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
const isPaintingModule = activeModule === 'painting';
const isRobotModule = activeModule === 'robot';
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
const projectConfigPath = '/project-config';
const visibleProjects = projects;
const selectedProjectFolderName = getFolderName(newProjectSelectedPath);
const accountName = userProfile?.displayName?.trim() || getAuthUserDisplayName(authUser) || '未登录用户';
@@ -397,9 +389,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
setNewProjectName('');
setNewProjectSelectedPath('');
setNewProjectDirectoryMode('use-selected-directory');
setNewProjectType('interactive_ai_app');
setNewProjectIdentityKind('create');
setNewProjectIdentityProjectId('');
setCreateProjectError(null);
setCreateDialogOpen(true);
};
@@ -407,9 +396,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
const closeCreateProject = () => {
if (creatingProject) return;
setCreateDialogOpen(false);
setNewProjectType('interactive_ai_app');
setNewProjectIdentityKind('create');
setNewProjectIdentityProjectId('');
setCreateProjectError(null);
};
@@ -436,9 +422,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
return;
}
await setActiveProject(project.id);
navigate(result.config.initialized
? '/chat'
: projectConfigPath);
navigate('/chat');
};
const readAndEnterProject = async (project: CodingProjectSummary) => {
@@ -462,20 +446,12 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
setCreateProjectError('请选择项目路径');
return;
}
const boundProjectId = newProjectIdentityProjectId.trim();
if (newProjectIdentityKind === 'bind' && !isCanonicalCodingProjectId(boundProjectId)) {
setCreateProjectError('请输入有效的项目 ID小写 UUID。');
return;
}
setCreatingProject(true);
setCreateProjectError(null);
try {
const project = await createProject({
projectType: newProjectType,
identity: newProjectIdentityKind === 'create'
? { kind: 'create' }
: { kind: 'bind', projectId: boundProjectId },
projectType: 'interactive_ai_app',
identity: { kind: 'create' },
...(newProjectDirectoryMode === 'create-child-directory'
? { parentPath: selectedPath, projectName }
: { projectPath: selectedPath }),
@@ -756,7 +732,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
<DialogHeader className="flex-row items-start justify-between gap-3 space-y-0">
<div>
<DialogTitle className="text-xl"></DialogTitle>
<DialogDescription className="mt-1 text-xs">使</DialogDescription>
<DialogDescription className="mt-1 text-xs"></DialogDescription>
</div>
<button
type="button"
@@ -775,120 +751,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
void confirmCreateProject();
}}
>
<fieldset className="space-y-2">
<legend className="text-xs font-semibold"></legend>
<div className="grid gap-2 sm:grid-cols-2">
<label
className={cn(
'cursor-pointer rounded-xl border border-foreground/10 bg-white p-3 text-sm font-semibold',
newProjectIdentityKind === 'create' ? 'ring-2 ring-brand/35 shadow-float' : 'hover:shadow-float',
)}
>
<input
type="radio"
name="project-identity"
value="create"
aria-label="创建新的项目 ID"
checked={newProjectIdentityKind === 'create'}
onChange={() => {
setNewProjectIdentityKind('create');
setCreateProjectError(null);
}}
disabled={creatingProject}
/>
<span className="ml-2"> ID</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground">
UUID
</span>
</label>
<label
className={cn(
'cursor-pointer rounded-xl border border-foreground/10 bg-white p-3 text-sm font-semibold',
newProjectIdentityKind === 'bind' ? 'ring-2 ring-brand/35 shadow-float' : 'hover:shadow-float',
)}
>
<input
type="radio"
name="project-identity"
value="bind"
aria-label="绑定已有项目 ID"
checked={newProjectIdentityKind === 'bind'}
onChange={() => {
setNewProjectIdentityKind('bind');
setCreateProjectError(null);
}}
disabled={creatingProject}
/>
<span className="ml-2"> ID</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground">
UUID ID
</span>
</label>
</div>
{newProjectIdentityKind === 'bind' ? (
<div className="space-y-2 rounded-xl border border-foreground/10 bg-surface-subtle p-3">
<label className="block text-xs font-semibold" htmlFor="new-project-identity-id">
ID
</label>
<input
id="new-project-identity-id"
value={newProjectIdentityProjectId}
onChange={(event) => {
setNewProjectIdentityProjectId(event.target.value);
setCreateProjectError(null);
}}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
autoComplete="off"
spellCheck={false}
className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 font-mono text-sm font-medium outline-none focus:ring-2 focus:ring-brand/20"
disabled={creatingProject}
/>
<p className="text-[11px] font-medium leading-5 text-muted-foreground">
ID ID
</p>
</div>
) : null}
</fieldset>
<fieldset className="space-y-2">
<legend className="text-xs font-semibold"></legend>
<div className="grid gap-2 sm:grid-cols-2">
{([
['interactive_ai_app', '交互式 AI 应用', '创建可交互、可发布的 AI 应用项目;进入对话后可用项目初始化 Skill 生成工程骨架。'],
['custom', '自定义项目', '只创建项目空间,不配置默认发布方式。'],
] as const).map(([value, title, description]) => (
<label
key={value}
className={cn(
'min-h-28 cursor-pointer rounded-xl bg-white p-3 shadow-soft',
newProjectType === value
? 'ring-2 ring-brand/35 shadow-float'
: 'ring-1 ring-foreground/10 hover:shadow-float',
)}
>
<span className="flex items-center gap-2 text-sm font-semibold">
<input
type="radio"
name="project-type"
value={value}
aria-label={title}
checked={newProjectType === value}
onChange={() => {
setNewProjectType(value);
setCreateProjectError(null);
}}
disabled={creatingProject}
/>
{title}
</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground [text-wrap:pretty]">
{description}
</span>
</label>
))}
</div>
</fieldset>
<fieldset className="space-y-2">
<legend className="text-xs font-semibold"></legend>
<div className="grid gap-2 sm:grid-cols-2">

View File

@@ -681,6 +681,25 @@ export function CodingChatPanel({
}}
/>
)
: agents.length === 0
? (
<div className="flex min-h-0 flex-1 items-center justify-center p-6" data-testid="coding-chat-empty-agent">
<div className="max-w-sm text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-subtle shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
<Bot className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
</div>
<h1 className="mt-4 text-balance text-lg font-semibold"></h1>
<p className="mt-2 text-pretty text-sm leading-6 text-muted-foreground">
</p>
{onOpenProjectSettings ? (
<Button className="mt-5 min-h-10 rounded-xl" onClick={onOpenProjectSettings}>
</Button>
) : null}
</div>
</div>
)
: (
<div className="min-h-0 flex-1" data-testid="coding-conversation-pending" />
)}

View File

@@ -11,20 +11,16 @@ import { ProjectPublishAction } from '@/components/works/ProjectPublishAction';
import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options';
import { getCodingSkills } from '@/lib/coding-product-tools';
import { abortCodingConversation } from '@/lib/coding-conversations';
import { isCanonicalCodingProjectId } from '@/lib/coding-projects';
import { getSkillDisplayInfo } from '@/lib/skill-display';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { cn } from '@/lib/utils';
import { codingConversationStore } from '@/stores/coding-conversations';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { useProjectConfigStore } from '@/stores/project-config';
import { useProviderStore } from '@/stores/providers';
import type { CodingProjectAgent, CodingProjectConfig } from '@/types/coding-project';
import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts';
const EMPTY_FILES: string[] = [];
type DrawerMode = 'models' | 'knowledge' | 'skills' | null;
type ProjectIdentityKind = ProjectIdentityChoice['kind'];
type SkillStructureEntry = { path: string; type: 'file' | 'directory' };
type SkillInfo = {
id: string;
@@ -134,8 +130,6 @@ export function ProjectConfiguration() {
const knowledge = useProjectConfigStore((state) => activeProject ? state.knowledgeByProjectId[activeProject.id] ?? EMPTY_FILES : EMPTY_FILES);
const load = useProjectConfigStore((state) => state.load);
const save = useProjectConfigStore((state) => state.save);
const resolveIdentity = useProjectConfigStore((state) => state.resolveIdentity);
const makeIndependentCopy = useProjectConfigStore((state) => state.makeIndependentCopy);
const uploadKnowledge = useProjectConfigStore((state) => state.uploadKnowledge);
const removeProjectConfig = useProjectConfigStore((state) => state.remove);
const providerAccounts = useProviderStore((state) => state.accounts);
@@ -147,11 +141,6 @@ export function ProjectConfiguration() {
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [saving, setSaving] = useState(false);
const [identityKind, setIdentityKind] = useState<ProjectIdentityKind>('create');
const [identityProjectId, setIdentityProjectId] = useState('');
const [identitySaving, setIdentitySaving] = useState(false);
const [identityError, setIdentityError] = useState<string | null>(null);
const [independentCopyDialogOpen, setIndependentCopyDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [partnerDialogOpen, setPartnerDialogOpen] = useState(false);
const [archiveAgentId, setArchiveAgentId] = useState<string | null>(null);
@@ -174,11 +163,6 @@ export function ProjectConfiguration() {
useEffect(() => { if (activeProject) void load(activeProject.id); }, [activeProject, load]);
useEffect(() => { if (stored) setDraft(structuredClone(stored)); }, [stored]);
useEffect(() => {
setIdentityKind('create');
setIdentityProjectId('');
setIdentityError(null);
}, [activeProject?.id, draft?.projectId]);
useEffect(() => {
const requestedAgentId = new URLSearchParams(location.search).get('agent');
if (!requestedAgentId || !draft?.agents.some((agent) => agent.id === requestedAgentId)) return;
@@ -263,46 +247,13 @@ export function ProjectConfiguration() {
await save(activeProject.id, { ...draft, initialized: true });
await reloadWorkspace();
playSaveConfirmation();
toast.success(draft.initialized ? '项目配置已更新' : '项目初始化完成');
toast.success('项目配置已保存');
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setSaving(false);
}
};
const handleResolveIdentity = async () => {
const boundProjectId = identityProjectId.trim();
if (identityKind === 'bind' && !isCanonicalCodingProjectId(boundProjectId)) {
setIdentityError('请输入有效的项目 ID小写 UUID。');
return;
}
setIdentitySaving(true);
setIdentityError(null);
try {
await resolveIdentity(activeProject.id, identityKind === 'create'
? { kind: 'create' }
: { kind: 'bind', projectId: boundProjectId });
await reloadWorkspace();
toast.success('项目身份已设置,之后不可通过普通保存修改。');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setIdentityError(message);
toast.error(message);
} finally {
setIdentitySaving(false);
}
};
const handleIndependentCopy = async () => {
setIdentitySaving(true);
try {
await makeIndependentCopy(activeProject.id);
await reloadWorkspace();
setIndependentCopyDialogOpen(false);
toast.success('已设为独立副本,当前文件夹已获得新的项目 ID。');
} finally {
setIdentitySaving(false);
}
};
const handleKnowledge = async (file?: File) => { if (!file) return; try { await uploadKnowledge(activeProject.id, file); toast.success(` knowledge/${file.name}`); } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); } };
const handleDeleteProject = async () => {
await removeProject(activeProject.id);
@@ -341,120 +292,6 @@ export function ProjectConfiguration() {
</Button>
<h1 className="text-xl font-semibold">项目配置</h1>
</div>
<section data-testid="project-identity-card" className="surface-card rounded-2xl border border-border/70 bg-background p-5 shadow-soft">
{draft.projectId ? (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="text-base font-semibold">项目身份</h2>
<p className="mt-1 text-sm font-medium text-muted-foreground">当前项目 ID</p>
<code className="mt-2 block break-all rounded-lg border border-foreground/10 bg-surface-subtle px-3 py-2 text-xs font-semibold text-foreground" data-testid="project-identity-value">{draft.projectId}</code>
<p className="mt-2 text-xs font-medium leading-5 text-muted-foreground">
同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。
</p>
</div>
<Button
type="button"
variant="outline"
data-testid="independent-copy-button"
className="shrink-0 border-brand/25 bg-brand-soft text-foreground"
onClick={() => setIndependentCopyDialogOpen(true)}
disabled={identitySaving}
>
设为独立副本
</Button>
</div>
) : (
<form
data-testid="legacy-project-identity-card"
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void handleResolveIdentity();
}}
>
<div>
<h2 className="text-base font-semibold">为旧项目设置身份</h2>
<p className="mt-1 text-sm font-medium leading-5 text-muted-foreground">
这是一个没有项目 ID 的旧项目。此操作只会为当前文件夹设置一次身份;项目文件和云端数据都不会被复制。
</p>
</div>
<fieldset className="space-y-2">
<legend className="text-xs font-semibold">选择项目 ID</legend>
<div className="grid gap-2 sm:grid-cols-2">
<label className={cn(
'cursor-pointer rounded-xl border border-foreground/10 bg-white p-3 text-sm font-semibold',
identityKind === 'create' ? 'ring-2 ring-brand/35 shadow-float' : 'hover:shadow-float',
)}>
<input
type="radio"
name="legacy-project-identity"
value="create"
aria-label="创建新的项目 ID"
checked={identityKind === 'create'}
onChange={() => {
setIdentityKind('create');
setIdentityError(null);
}}
disabled={identitySaving}
/>
<span className="ml-2">创建新的项目 ID</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground">
生成新的小写 UUID不会复制云端开发数据。
</span>
</label>
<label className={cn(
'cursor-pointer rounded-xl border border-foreground/10 bg-white p-3 text-sm font-semibold',
identityKind === 'bind' ? 'ring-2 ring-brand/35 shadow-float' : 'hover:shadow-float',
)}>
<input
type="radio"
name="legacy-project-identity"
value="bind"
aria-label="绑定已有项目 ID"
checked={identityKind === 'bind'}
onChange={() => {
setIdentityKind('bind');
setIdentityError(null);
}}
disabled={identitySaving}
/>
<span className="ml-2">绑定已有项目 ID</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground">
只接受规范的小写 UUID。
</span>
</label>
</div>
</fieldset>
{identityKind === 'bind' ? (
<div className="space-y-2 rounded-xl border border-foreground/10 bg-surface-subtle p-3">
<label className="block text-xs font-semibold" htmlFor="legacy-project-identity-id">已有项目 ID</label>
<input
id="legacy-project-identity-id"
value={identityProjectId}
onChange={(event) => {
setIdentityProjectId(event.target.value);
setIdentityError(null);
}}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
autoComplete="off"
spellCheck={false}
className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 font-mono text-sm font-medium outline-none focus:ring-2 focus:ring-brand/20"
disabled={identitySaving}
/>
<p className="text-[11px] font-medium leading-5 text-muted-foreground">
同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。
</p>
</div>
) : null}
{identityError ? <p role="alert" data-testid="project-identity-error" className="rounded-md border border-foreground/15 bg-accent-soft px-3 py-2 text-xs font-semibold">{identityError}</p> : null}
<div className="flex justify-end">
<Button type="submit" data-testid="resolve-project-identity-button" className="border border-brand/20 bg-brand text-primary-foreground" disabled={identitySaving || (identityKind === 'bind' && !identityProjectId.trim())}>
{identitySaving ? '设置中…' : '设置项目身份'}
</Button>
</div>
</form>
)}
</section>
<section className="grid gap-3 md:grid-cols-3"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} `} onClick={() => setDrawerMode('models')} /><ResourceCard id="skills" icon={<Blocks />} title="可用技能" subtitle={`${skills.length} `} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} `} onClick={() => setDrawerMode('knowledge')} /></section>
<section className="config-motion-partners surface-card rounded-2xl border border-border/70 bg-background p-5 shadow-soft"><div className="mb-4 flex items-center justify-between gap-3"><div><h2 className="text-xl font-semibold">项目智能体</h2><p className="mt-1 text-sm font-medium text-muted-foreground">智能体配置仅属于当前项目;每个智能体可以拥有多条独立对话。</p></div><Button type="button" size="icon" className="h-8 w-8 rounded-full border border-brand/20 bg-brand-soft text-brand" aria-label="创建智能体" title="创建智能体" onClick={() => { if (modelOptions.length === 0) { toast.error('请先在模型设置中配置至少一个可用模型。'); setDrawerMode('models'); return; } setActiveAgentId(null); setPartnerDialogOpen(true); }}><Plus className="h-4 w-4" /></Button></div><div data-testid="agent-cards-scroll-region" className="-m-2 flex gap-4 overflow-auto p-2">{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => <AgentCard key={agent.id} agent={agent} onOpen={() => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)}{draft.agents.every((agent) => agent.archivedAt) ? <div className="w-full rounded-xl border border-dashed border-foreground/15 p-6 text-center text-sm font-medium text-muted-foreground">当前项目还没有智能体,点击右上角的 + 创建。</div> : null}</div>{draft.agents.some((agent) => agent.archivedAt) ? <div className="mt-4 border-t border-border/70 pt-4"><p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">已归档</p><div className="mt-2 flex flex-wrap gap-2">{draft.agents.filter((agent) => agent.archivedAt).map((agent) => <Button key={agent.id} type="button" variant="outline" className="border-border/70 bg-surface-subtle" onClick={() => updateAgent({ ...agent, archivedAt: null })}>{agent.name || '未命名智能体'} · 恢复</Button>)}</div></div> : null}</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">
@@ -469,7 +306,7 @@ export function ProjectConfiguration() {
buttonSize="sm"
/>
) : null}
<Button onClick={() => void submit()} disabled={saving} className="project-save-button h-10 w-full border border-brand/20 bg-brand px-4 font-semibold text-primary-foreground sm:w-auto"><Check className="mr-2 h-4 w-4" />{saving ? '保存中…' : draft.initialized ? '保存项目配置' : '确认并完成初始化'}</Button>
<Button onClick={() => void submit()} disabled={saving} className="project-save-button h-10 w-full border border-brand/20 bg-brand px-4 font-semibold text-primary-foreground sm:w-auto"><Check className="mr-2 h-4 w-4" />{saving ? '保存中…' : '保存项目配置'}</Button>
</div>
</div>
</div>
@@ -513,16 +350,6 @@ export function ProjectConfiguration() {
onConfirm={handleDeleteProject}
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
/>
<ConfirmDialog
open={independentCopyDialogOpen}
title="设为独立副本?"
message="只更新当前文件夹中的项目 ID项目文件保持原位不会移动或复制文件也不会复制云端开发数据。此操作会为当前文件夹生成新的项目 ID原项目不会被修改。"
confirmLabel="确认设为独立副本"
cancelLabel="取消"
onCancel={() => setIndependentCopyDialogOpen(false)}
onConfirm={handleIndependentCopy}
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
/>
</main>;
}

View File

@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
const BOUND_PROJECT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const CANONICAL_PROJECT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
async function readProjectConfig(projectPath: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(path.join(projectPath, '.makelore', 'project.json'), 'utf8')) as Record<string, unknown>;
@@ -25,90 +25,50 @@ async function selectProgrammingProjectFolder(
}
test.describe('Coding project identity UX', () => {
test('supports default create, bind validation, cancellation, legacy resolution, and independent copies', async ({ launchElectronApp }) => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-identity-e2e-'));
test('keeps the service identity automatic and invisible to young users', async ({ launchElectronApp }) => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-identity-e2e-'));
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await selectProgrammingProjectFolder(app, projectPath);
await page.getByTestId('sidebar-create-project').click();
const createDialog = page.getByRole('dialog', { name: '新建项目' });
await expect(createDialog.getByRole('radio', { name: '创建新的项目 ID' })).toBeChecked();
await createDialog.getByRole('radio', { name: '绑定已有项目 ID' }).click();
await createDialog.getByRole('textbox', { name: '已有项目 ID' }).fill('not-a-project-id');
await expect(createDialog.getByText(/ ID||/u)).toHaveCount(0);
await expect(createDialog.getByRole('radio', { name: '创建新的项目 ID' })).toHaveCount(0);
await expect(createDialog.getByRole('radio', { name: '绑定已有项目 ID' })).toHaveCount(0);
await createDialog.getByRole('button', { name: '选择路径' }).click();
await expect(createDialog.getByLabel('项目路径')).toHaveValue(projectPath);
await createDialog.getByRole('button', { name: '确认创建' }).click();
await expect(createDialog).toContainText('请输入有效的项目 ID小写 UUID。');
await createDialog.getByRole('button', { name: '取消' }).first().click();
await expect(page.getByRole('dialog', { name: '新建项目' })).toHaveCount(0);
await page.getByTestId('sidebar-create-project').click();
const defaultCreateDialog = page.getByRole('dialog', { name: '新建项目' });
await expect(defaultCreateDialog.getByRole('radio', { name: '创建新的项目 ID' })).toBeChecked();
await defaultCreateDialog.getByRole('button', { name: '选择路径' }).click();
await expect(defaultCreateDialog.getByLabel('项目路径')).toHaveValue(projectPath);
await defaultCreateDialog.getByRole('button', { name: '确认创建' }).click();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByTestId('coding-chat-empty-agent')).toBeVisible();
const createdConfig = await readProjectConfig(projectPath);
const generatedProjectId = createdConfig.projectId;
expect(generatedProjectId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u);
await expect(page.getByTestId('project-identity-value')).toHaveText(String(generatedProjectId));
await expect(page.getByText('同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。')).toBeVisible();
const generatedProjectId = String(createdConfig.projectId);
expect(generatedProjectId).toMatch(CANONICAL_PROJECT_ID);
await writeFile(path.join(projectPath, 'identity-sentinel.txt'), 'keep this file in place');
const independentButton = page.getByTestId('independent-copy-button');
await independentButton.click();
const independentDialog = page.getByRole('dialog', { name: '设为独立副本?' });
await expect(independentDialog).toContainText('不会移动或复制文件');
await expect(independentDialog).toContainText('不会复制云端开发数据');
await independentDialog.getByRole('button', { name: '取消' }).click();
await expect(independentDialog).toHaveCount(0);
expect((await readProjectConfig(projectPath)).projectId).toBe(generatedProjectId);
await independentButton.click();
await page.getByRole('dialog', { name: '设为独立副本?' }).getByRole('button', { name: '确认设为独立副本' }).click();
await expect(page.getByTestId('project-identity-value')).not.toHaveText(String(generatedProjectId));
const independentConfig = await readProjectConfig(projectPath);
expect(independentConfig.projectId).not.toBe(generatedProjectId);
expect(await readFile(path.join(projectPath, 'identity-sentinel.txt'), 'utf8')).toBe('keep this file in place');
await page.getByRole('button', { name: '创建项目智能体' }).click();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByTestId('project-identity-card')).toHaveCount(0);
await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0);
await expect(page.getByTestId('independent-copy-button')).toHaveCount(0);
await expect(page.getByText(/ ID||/u)).toHaveCount(0);
const legacyConfig = await readProjectConfig(projectPath);
delete legacyConfig.projectId;
await writeFile(path.join(projectPath, '.makelore', 'project.json'), JSON.stringify(legacyConfig, null, 2));
await writeFile(
path.join(projectPath, '.makelore', 'project.json'),
JSON.stringify(legacyConfig, null, 2),
);
await page.reload();
await expect(page.getByTestId('legacy-project-identity-card')).toBeVisible();
const legacyCard = page.getByTestId('legacy-project-identity-card');
await legacyCard.getByRole('radio', { name: '绑定已有项目 ID' }).click();
await legacyCard.getByRole('textbox', { name: '已有项目 ID' }).fill(BOUND_PROJECT_ID);
await legacyCard.getByTestId('resolve-project-identity-button').click();
await expect(page.getByTestId('project-identity-value')).toHaveText(BOUND_PROJECT_ID);
await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0);
} finally {
await closeElectronApp(app);
await rm(projectPath, { recursive: true, force: true });
}
});
test('creates a project bound to an explicit canonical project ID', async ({ launchElectronApp }) => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-bound-identity-e2e-'));
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await selectProgrammingProjectFolder(app, projectPath);
await page.getByTestId('sidebar-create-project').click();
const createDialog = page.getByRole('dialog', { name: '新建项目' });
await createDialog.getByRole('radio', { name: '绑定已有项目 ID' }).click();
await expect(createDialog.getByText('同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。')).toBeVisible();
await createDialog.getByRole('textbox', { name: '已有项目 ID' }).fill(BOUND_PROJECT_ID);
await createDialog.getByRole('button', { name: '选择路径' }).click();
await createDialog.getByRole('button', { name: '确认创建' }).click();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByTestId('project-identity-value')).toHaveText(BOUND_PROJECT_ID);
expect((await readProjectConfig(projectPath)).projectId).toBe(BOUND_PROJECT_ID);
const repairedConfig = await readProjectConfig(projectPath);
const repairedProjectId = String(repairedConfig.projectId);
expect(repairedProjectId).toMatch(CANONICAL_PROJECT_ID);
expect(repairedProjectId).not.toBe(generatedProjectId);
await expect(page.getByTestId('project-identity-card')).toHaveCount(0);
await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0);
await expect(page.getByText(/ ID||/u)).toHaveCount(0);
} finally {
await closeElectronApp(app);
await rm(projectPath, { recursive: true, force: true });

View File

@@ -20,8 +20,10 @@ test.describe('Project configuration skills', () => {
await page.getByTestId('ai-module-option-programming').click();
await expect(page.getByTestId('main-layout')).toBeVisible();
await page.getByTestId('sidebar-create-project').click();
await expect(page.getByRole('radio', { name: '交互式 AI 应用' })).toBeChecked();
await expect(page.getByRole('radio', { name: '自定义项目' })).not.toBeChecked();
await expect(page.getByRole('radio', { name: '创建新的项目 ID' })).toHaveCount(0);
await expect(page.getByRole('radio', { name: '绑定已有项目 ID' })).toHaveCount(0);
await expect(page.getByRole('radio', { name: '交互式 AI 应用' })).toHaveCount(0);
await expect(page.getByRole('radio', { name: '自定义项目' })).toHaveCount(0);
await expect(page.getByRole('radio', { name: '小游戏' })).toHaveCount(0);
await expect(page.getByRole('radio', { name: '小程序' })).toHaveCount(0);
await expect(page.getByRole('radio', { name: '直接使用所选文件夹(默认)' })).toBeChecked();
@@ -29,7 +31,16 @@ test.describe('Project configuration skills', () => {
await expect(page.getByLabel('项目路径')).toHaveValue(parentPath);
await page.getByRole('button', { name: '确认创建' }).click();
await expect(page.getByTestId('coding-chat-empty-agent')).toBeVisible();
await expect(page.getByTestId('project-initialization-gate')).toHaveCount(0);
await page.getByRole('button', { name: '创建项目智能体' }).click();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByTestId('project-identity-card')).toHaveCount(0);
await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0);
await expect(page.getByTestId('independent-copy-button')).toHaveCount(0);
await expect(page.getByText(/ ID||/u)).toHaveCount(0);
await expect(page.getByRole('button', { name: '保存项目配置' })).toBeVisible();
await expect(page.getByRole('button', { name: '确认并完成初始化' })).toHaveCount(0);
await expect(page.getByTestId('sidebar-nav-publish')).toHaveCount(0);
await expect(page.getByText(/新项目还没有智能体/)).toHaveCount(0);
await expect(page.getByTestId('project-configuration-actions')).toBeVisible();
@@ -96,6 +107,9 @@ test.describe('Project configuration skills', () => {
}, null, 2));
await page.reload();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByTestId('project-identity-card')).toHaveCount(0);
await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0);
await expect(page.getByText(/ ID||/u)).toHaveCount(0);
await page.getByTestId('resource-card-skills').click();
await expect(page.getByTestId('superpowers-card')).toHaveCount(0);

View File

@@ -6,6 +6,9 @@ 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';
vi.mock('@/components/layout/MainLayout', () => ({
MainLayout: () => (
@@ -41,6 +44,13 @@ vi.mock('@/pages/Settings', () => ({
describe('App programming provider initialization gate', () => {
const initProviders = vi.fn();
const project = {
id: 'local-project',
name: 'Local project',
createdAt: '2026-09-06T00:00:00.000Z',
updatedAt: '2026-09-06T00:00:00.000Z',
lastOpenedAt: '2026-09-06T00:00:00.000Z',
};
beforeEach(() => {
vi.clearAllMocks();
@@ -49,6 +59,18 @@ describe('App programming provider initialization gate', () => {
init: vi.fn(),
});
useProviderStore.setState({ init: initProviders });
codingWorkspaceStore.setState({
activeProjectId: project.id,
activeProject: project,
load: vi.fn().mockResolvedValue(undefined),
});
useProjectConfigStore.setState({
load: vi.fn().mockResolvedValue({
status: 'valid',
config: { ...createCodingProjectConfigV2(), initialized: false },
knowledgeFiles: [],
}),
});
useAuthStore.setState({
initialized: true,
accessToken: 'access-token',
@@ -94,6 +116,13 @@ describe('App programming provider initialization gate', () => {
await waitFor(() => expect(initProviders).toHaveBeenCalledTimes(1));
});
it('opens a valid project without requiring the legacy initialized flag', async () => {
await renderAt('/chat');
expect(await screen.findByText('Programming workspace')).toBeInTheDocument();
expect(screen.queryByText('项目尚未初始化')).not.toBeInTheDocument();
});
it('redirects an orphaned token without a user identity away from a direct workspace route', async () => {
useAuthStore.setState({
initialized: true,

View File

@@ -199,6 +199,24 @@ describe('CodingChatPanel first Conversation', () => {
vi.resetModules();
});
it('keeps a project with no Agent accessible and offers optional Agent setup', async () => {
const openProjectSettings = vi.fn();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({
project,
config: { ...configForAgents([]), initialized: false },
});
projectApi.conversations.mockResolvedValue([]);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
render(<CodingChatPanel onOpenProjectSettings={openProjectSettings} />);
expect(await screen.findByTestId('coding-chat-empty-agent')).toHaveTextContent('项目已准备好');
expect(projectApi.create).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '创建项目智能体' }));
expect(openProjectSettings).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

@@ -736,11 +736,11 @@ describe('PI-100 coding core Host contract', () => {
it('maps persistence failures to a stable fixed Host error', async () => {
const result = await setup();
const current = await result.projects.getConfig('project-a');
const failingProjects = new CodingProjectService(result.store, {
writeConfig: async () => { throw new Error(`secret disk path=${result.root}`); },
});
const failingConversations = new CodingConversationService(failingProjects, result.runtime);
const current = await failingProjects.getConfig('project-a');
const response = await dispatchHostApiRequest(context({
...result,
projects: failingProjects,

View File

@@ -174,6 +174,36 @@ describe('coding project durable identity', () => {
expect((await projects.getConfig(local.project.id)).config.projectId).toBe(PROJECT_ID);
});
it('automatically assigns one identity when a legacy project is read concurrently', async () => {
const projectPath = await makeRoot();
const store = makeStore();
const local = await createLocalCodingProject({ projectPath, now: CREATED }, store);
const createProjectId = vi.fn(() => PROJECT_ID);
const events: string[] = [];
const projects = new CodingProjectService(store, {
createProjectId,
now: () => UPDATED,
onProjectIdentityChanging: () => { events.push('invalidate'); },
writeConfig: async (filePath, value) => {
events.push('write');
await writeCodingProjectConfigV2(filePath, value);
},
onResourcesChanged: () => { events.push('resources'); },
});
const [first, second] = await Promise.all([
projects.getConfig(local.project.id),
projects.getConfig(local.project.id),
]);
expect(first.config.projectId).toBe(PROJECT_ID);
expect(second.config.projectId).toBe(PROJECT_ID);
expect(createProjectId).toHaveBeenCalledTimes(1);
expect(events).toEqual(['invalidate', 'write', 'resources']);
await expect(readCodingProjectConfigV2(projectPath))
.resolves.toMatchObject({ status: 'valid', config: { projectId: PROJECT_ID, updatedAt: UPDATED } });
});
it('keeps ordinary saves immutable and supports a confirmed independent copy', async () => {
const projectPath = await makeRoot();
const store = makeStore();
@@ -280,8 +310,13 @@ describe('coding project durable identity', () => {
const legacyPath = await makeRoot('makelore-project-identity-legacy-');
const legacyStore = makeStore();
await createLocalCodingProject({ projectPath: legacyPath }, legacyStore);
await expect(new CodingProjectService(legacyStore).requireActiveRealProjectWithIdentity())
.rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_REQUIRED', status: 409 });
const legacyProjects = new CodingProjectService(legacyStore, {
createProjectId: () => NEXT_PROJECT_ID,
});
await expect(legacyProjects.requireActiveRealProjectWithIdentity())
.resolves.toMatchObject({ projectId: NEXT_PROJECT_ID });
await expect(readCodingProjectConfigV2(legacyPath))
.resolves.toMatchObject({ status: 'valid', config: { projectId: NEXT_PROJECT_ID } });
});
it('exposes identity resolution and independent-copy through the Host routes', async () => {

View File

@@ -2,10 +2,7 @@ import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MainLayout } from '@/components/layout/MainLayout';
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';
const routeRenderMock = vi.hoisted(() => vi.fn());
@@ -46,20 +43,6 @@ function renderLayout(path: string) {
describe('MainLayout module isolation', () => {
beforeEach(() => {
routeRenderMock.mockReset();
const project = {
id: 'local-project',
path: '/tmp/local-project',
name: 'local-project',
createdAt: '',
updatedAt: '',
lastOpenedAt: '',
};
codingWorkspaceStore.setState({ activeProject: project });
useProjectConfigStore.setState({
configsByProjectId: {
[project.id]: { ...createCodingProjectConfigV2(), initialized: false },
},
});
useSettingsStore.setState({ sidebarCollapsed: false });
});
@@ -89,19 +72,17 @@ describe('MainLayout module isolation', () => {
});
it('does not overlay the local project initialization gate on AI hardware', () => {
const load = vi.fn().mockResolvedValue(undefined);
useProjectConfigStore.setState({ load });
renderLayout('/ai-hardware');
expect(screen.getByTestId('route-content')).toBeVisible();
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();
expect(load).not.toHaveBeenCalled();
});
it('keeps the local project initialization gate for AI programming routes', () => {
it('does not restore the removed project initialization gate on AI programming routes', () => {
renderLayout('/chat');
expect(screen.getByTestId('project-initialization-gate')).toBeVisible();
expect(screen.getByTestId('route-content')).toBeVisible();
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false');
});