From ce8e2103d914ba436d20447b0a17f751dd8a5a6c Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Wed, 26 Aug 2026 18:48:22 +0800 Subject: [PATCH] feat(coding): add project identity UX --- .../20260826-ml02-identity-ux-6f2d9a31.md | 88 +++++++++ src/components/layout/Sidebar.tsx | 92 ++++++++++ src/lib/coding-projects.ts | 35 ++++ src/pages/ProjectConfiguration/index.tsx | 173 ++++++++++++++++++ src/stores/coding-workspace.ts | 3 + src/stores/project-config.ts | 27 +++ tests/e2e/coding-project-identity.spec.ts | 117 ++++++++++++ tests/unit/coding-projects-facade.test.ts | 49 +++++ tests/unit/coding-workspace-store.test.ts | 25 +++ tests/unit/project-config-store.test.ts | 74 ++++++++ 10 files changed, 683 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20260826-ml02-identity-ux-6f2d9a31.md create mode 100644 tests/e2e/coding-project-identity.spec.ts create mode 100644 tests/unit/project-config-store.test.ts diff --git a/.project-docs/30-worklog/tasks/20260826-ml02-identity-ux-6f2d9a31.md b/.project-docs/30-worklog/tasks/20260826-ml02-identity-ux-6f2d9a31.md new file mode 100644 index 0000000..b1e85bd --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260826-ml02-identity-ux-6f2d9a31.md @@ -0,0 +1,88 @@ +# Task: ML-02 Identity user experience and E2E + +## Identity + +- Task ID: 20260826-ml02-identity-ux-6f2d9a31 +- Mode: Feature +- Branch: codex/20260826-ml02-identity-ux-6f2d9a31-ml02-identity-ux +- Worktree: D:\Datas\OthersProjects\makelore-ml02-identity-ux-6f2d9a31 +- Base commit: 5ac08d509f8962a3c2c0ec1b1afef84a435f116d +- Owner: ml02_identity_ux +- Status: Ready for Integration + +## Scope + +- Extend the Renderer coding-project facade and store with the accepted ML-01 + identity request/result projections. +- Make the new-project dialog default to a generated identity and offer an + explicit canonical bind-existing choice with same-account sharing guidance. +- Add the one-time legacy identity resolution card and the explicit + independent-copy action to Project Configuration, including fixed UX copy + about files/cloud data and safe error/cancel states. +- Add focused Renderer tests and Electron E2E coverage for create, bind, + invalid/cancel, legacy, and independent-copy flows. + +## Intent And Constraints + +- Keep project identity creation, validation authority, persistence, cloud + access, and preview/session invalidation in Main; Renderer only sends the + accepted create/bind union or literal confirmation and projects safe config + snapshots. +- Preserve the canonical lowercase hyphenated UUID contract and ordinary-save + immutability. A same-account bind shares development data; an independent + copy rewrites only this folder's ID, does not move/copy files or cloud data, + and gets its new UUID from Main. +- Limit changes to the task ownership set: `src/components/layout/Sidebar.tsx`, + `src/pages/ProjectConfiguration/index.tsx`, + `src/lib/coding-projects.ts`, the necessary identity state projection in + `src/stores/coding-workspace.ts` / `src/stores/project-config.ts`, focused + Renderer tests, Electron E2E, and this task record. Do not modify ML-03 Main + seams or add Renderer provisioning/cloud/session logic. +- Use the repository-pinned pnpm `10.33.4`; run focused tests first, then + typecheck/lint and relevant Electron E2E/build checks. Record unavailable + platform/service prerequisites rather than claiming unrun acceptance. + +## Outcome + +- Implemented the ML-02 Renderer identity UX and focused coverage. +- `src/lib/coding-projects.ts` now projects the required create/bind identity + union, validates the canonical lowercase UUID shape for UI preflight, and + exposes the identity-resolution and independent-copy Host operations. +- The new-project dialog defaults to generated identity, makes bind-existing + explicit, explains same-account sharing, and reports invalid input before a + Host call. Project Configuration now shows the one-time legacy resolution + card and a confirmed independent-copy action whose copy states that files + stay in place and cloud data is not copied. +- No Main files, cloud/session logic, or Renderer data provisioning were added. + +## Verification + +- `pnpm --version` -> `10.33.4`. +- `pnpm exec vitest run tests/unit/coding-projects-facade.test.ts + tests/unit/project-config-store.test.ts tests/unit/coding-workspace-store.test.ts + --maxWorkers=1` -> 3 files, 10 tests passed. +- `pnpm typecheck` -> passed. +- `pnpm lint:check` -> 0 errors; 5 pre-existing warnings in + `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`. +- `pnpm build:vite` -> passed. +- `pnpm test` -> 183 files, 1554 tests passed, 2 skipped. +- `pnpm test:electron:windows` -> 2 files, 4 tests passed. +- `pnpm exec playwright test tests/e2e/coding-project-identity.spec.ts + --config=playwright.config.ts` -> 2 tests passed. +- `pnpm exec playwright test tests/e2e/project-configuration-skills.spec.ts + --config=playwright.config.ts` -> 1 test passed. +- `pnpm test:e2e` -> 25 tests passed, 1 unrelated existing PI E2E failed at + `tests/e2e/pi-coding-first-chat.spec.ts:575`: the model combobox remained + disabled while selecting `model-b`; a direct rerun reproduced the same + failure. The identity and Project Configuration specs passed in this run. + +## Follow-ups + +- Investigate the pre-existing/flaky PI model-combobox E2E separately; it is + outside the ML-02 ownership set and was not changed here. + +## Promotion Candidates + +- Identity UX, facade projections, and E2E coverage are ready for integration + after this task commit. The full E2E suite's unrelated PI failure should + remain visible to the integrator. diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index d20e139..3c8ce28 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -48,9 +48,11 @@ 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 = { @@ -70,6 +72,7 @@ type ProjectEntryError = { }; type ProjectDirectoryMode = 'use-selected-directory' | 'create-child-directory'; +type ProjectIdentityKind = ProjectIdentityChoice['kind']; function getFolderName(pathValue: string): string { const trimmed = pathValue.trim(); @@ -164,6 +167,8 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const [newProjectSelectedPath, setNewProjectSelectedPath] = useState(''); const [newProjectDirectoryMode, setNewProjectDirectoryMode] = useState('use-selected-directory'); const [newProjectType, setNewProjectType] = useState('mini_game'); + const [newProjectIdentityKind, setNewProjectIdentityKind] = useState('create'); + const [newProjectIdentityProjectId, setNewProjectIdentityProjectId] = useState(''); const [createProjectError, setCreateProjectError] = useState(null); const [creatingProject, setCreatingProject] = useState(false); const [projectEntryError, setProjectEntryError] = useState(null); @@ -394,6 +399,8 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi setNewProjectSelectedPath(''); setNewProjectDirectoryMode('use-selected-directory'); setNewProjectType('mini_game'); + setNewProjectIdentityKind('create'); + setNewProjectIdentityProjectId(''); setCreateProjectError(null); setCreateDialogOpen(true); }; @@ -402,6 +409,8 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi if (creatingProject) return; setCreateDialogOpen(false); setNewProjectType('mini_game'); + setNewProjectIdentityKind('create'); + setNewProjectIdentityProjectId(''); setCreateProjectError(null); }; @@ -454,12 +463,20 @@ 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 }, ...(newProjectDirectoryMode === 'create-child-directory' ? { parentPath: selectedPath, projectName } : { projectPath: selectedPath }), @@ -744,6 +761,81 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi void confirmCreateProject(); }} > +
+ 项目身份 +
+ + +
+ {newProjectIdentityKind === 'bind' ? ( +
+ + { + 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} + /> +

+ 同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。 +

+
+ ) : null} +
+
项目类型
diff --git a/src/lib/coding-projects.ts b/src/lib/coding-projects.ts index fabb091..6f5605d 100644 --- a/src/lib/coding-projects.ts +++ b/src/lib/coding-projects.ts @@ -5,6 +5,13 @@ import type { CodingProjectSummary, } from '@/types/coding-project'; import type { ProjectType } from '../../shared/project-config'; +import type { ProjectIdentityChoice } from '../../shared/coding-project-contracts'; + +const CANONICAL_PROJECT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; + +export function isCanonicalCodingProjectId(value: string): boolean { + return CANONICAL_PROJECT_ID_PATTERN.test(value.trim()); +} export interface CodingProjectCatalog { projects: CodingProjectSummary[]; @@ -28,6 +35,7 @@ export async function createCodingProject(input: { parentPath?: string; projectName?: string; projectType?: ProjectType; + identity: ProjectIdentityChoice; }): Promise { const response = await hostApiFetch<{ snapshot: CodingProjectConfigSnapshot }>('/api/coding/projects/create', { method: 'POST', @@ -36,6 +44,33 @@ export async function createCodingProject(input: { return response.snapshot; } +export async function resolveCodingProjectIdentity( + localProjectId: string, + identity: ProjectIdentityChoice, +): Promise { + const response = await hostApiFetch<{ snapshot: CodingProjectConfigSnapshot }>( + '/api/coding/projects/identity', + { + method: 'POST', + body: JSON.stringify({ localProjectId, identity }), + }, + ); + return response.snapshot; +} + +export async function makeCodingProjectIndependentCopy( + localProjectId: string, +): Promise { + const response = await hostApiFetch<{ snapshot: CodingProjectConfigSnapshot }>( + '/api/coding/projects/identity/independent-copy', + { + method: 'POST', + body: JSON.stringify({ localProjectId, confirmed: true }), + }, + ); + return response.snapshot; +} + export async function setActiveCodingProject(projectId: string): Promise { const response = await hostApiFetch<{ project: CodingProjectSummary }>('/api/coding/projects/active', { method: 'POST', diff --git a/src/pages/ProjectConfiguration/index.tsx b/src/pages/ProjectConfiguration/index.tsx index 1a719d2..a4a8c40 100644 --- a/src/pages/ProjectConfiguration/index.tsx +++ b/src/pages/ProjectConfiguration/index.tsx @@ -11,16 +11,20 @@ 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; name: string; description: string; location: string; content: string; entries: SkillStructureEntry[] }; @@ -121,6 +125,8 @@ 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); @@ -132,6 +138,11 @@ export function ProjectConfiguration() { const [activeAgentId, setActiveAgentId] = useState(null); const [skills, setSkills] = useState([]); const [saving, setSaving] = useState(false); + const [identityKind, setIdentityKind] = useState('create'); + const [identityProjectId, setIdentityProjectId] = useState(''); + const [identitySaving, setIdentitySaving] = useState(false); + const [identityError, setIdentityError] = useState(null); + const [independentCopyDialogOpen, setIndependentCopyDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [partnerDialogOpen, setPartnerDialogOpen] = useState(false); const [archiveAgentId, setArchiveAgentId] = useState(null); @@ -152,6 +163,11 @@ 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; @@ -243,6 +259,39 @@ export function ProjectConfiguration() { 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); @@ -281,6 +330,120 @@ export function ProjectConfiguration() {

配置你的项目空间

+
+ {draft.projectId ? ( +
+
+

项目身份

+

当前项目 ID

+ {draft.projectId} +

+ 同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。 +

+
+ +
+ ) : ( +
{ + event.preventDefault(); + void handleResolveIdentity(); + }} + > +
+

为旧项目设置身份

+

+ 这是一个没有项目 ID 的旧项目。此操作只会为当前文件夹设置一次身份;项目文件和云端数据都不会被复制。 +

+
+
+ 选择项目 ID +
+ + +
+
+ {identityKind === 'bind' ? ( +
+ + { + 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} + /> +

+ 同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。 +

+
+ ) : null} + {identityError ?

{identityError}

: null} +
+ +
+
+ )} +
setDrawerMode('models')} />} title="工具箱(技能)" subtitle={`已安装${skills.length}个`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /> setDrawerMode('knowledge')} />

我的伙伴

每个伙伴只属于当前项目;一个伙伴可以拥有多条独立会话。

{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)}{draft.agents.every((agent) => agent.archivedAt) ?
还没有伙伴,点击右上角的 + 创建第一个。
: null}
{draft.agents.some((agent) => agent.archivedAt) ?

已归档

{draft.agents.filter((agent) => agent.archivedAt).map((agent) => )}
: null}
@@ -339,6 +502,16 @@ export function ProjectConfiguration() { onConfirm={handleDeleteProject} onError={(error) => toast.error(error instanceof Error ? error.message : String(error))} /> + setIndependentCopyDialogOpen(false)} + onConfirm={handleIndependentCopy} + onError={(error) => toast.error(error instanceof Error ? error.message : String(error))} + /> ; } diff --git a/src/stores/coding-workspace.ts b/src/stores/coding-workspace.ts index cbcf253..f92360e 100644 --- a/src/stores/coding-workspace.ts +++ b/src/stores/coding-workspace.ts @@ -20,6 +20,7 @@ import type { CodingProjectSummary, } from '@/types/coding-project'; import type { ProjectType } from '../../shared/project-config'; +import type { ProjectIdentityChoice } from '../../shared/coding-project-contracts'; interface CodingWorkspaceDependencies { listProjects(): Promise; @@ -43,6 +44,7 @@ interface CodingWorkspaceDependencies { parentPath?: string; projectName?: string; projectType?: ProjectType; + identity: ProjectIdentityChoice; }): Promise<{ project: CodingProjectSummary; config: CodingProjectConfig; knowledgeFiles: string[] }>; setActiveProject(projectId: string): Promise; removeProject(projectId: string): Promise; @@ -71,6 +73,7 @@ export interface CodingWorkspaceState { parentPath?: string; projectName?: string; projectType?: ProjectType; + identity: ProjectIdentityChoice; }): Promise; setActiveProject(projectId: string): Promise; removeProject(projectId: string): Promise; diff --git a/src/stores/project-config.ts b/src/stores/project-config.ts index 86bc53d..214b3d5 100644 --- a/src/stores/project-config.ts +++ b/src/stores/project-config.ts @@ -1,6 +1,11 @@ import { create } from 'zustand'; import { hostApiFetch } from '@/lib/host-api'; +import { + makeCodingProjectIndependentCopy, + resolveCodingProjectIdentity, +} from '@/lib/coding-projects'; import type { CodingProjectConfig, CodingProjectConfigSnapshot } from '@/types/coding-project'; +import type { ProjectIdentityChoice } from '../../shared/coding-project-contracts'; type ProjectConfigResponse = { status: 'valid' | 'missing' | 'invalid'; @@ -16,6 +21,8 @@ type ProjectConfigState = { errorsByProjectId: Record; load: (projectId: string) => Promise; save: (projectId: string, config: CodingProjectConfig) => Promise; + resolveIdentity: (projectId: string, identity: ProjectIdentityChoice) => Promise; + makeIndependentCopy: (projectId: string) => Promise; uploadKnowledge: (projectId: string, file: File) => Promise; remove: (projectId: string) => void; }; @@ -64,6 +71,26 @@ export const useProjectConfigStore = create((set) => ({ return response.snapshot.config; }, + async resolveIdentity(projectId, identity) { + const snapshot = await resolveCodingProjectIdentity(projectId, identity); + set((state) => ({ + configsByProjectId: { ...state.configsByProjectId, [projectId]: snapshot.config }, + knowledgeByProjectId: { ...state.knowledgeByProjectId, [projectId]: snapshot.knowledgeFiles }, + errorsByProjectId: Object.fromEntries(Object.entries(state.errorsByProjectId).filter(([id]) => id !== projectId)), + })); + return snapshot.config; + }, + + async makeIndependentCopy(projectId) { + const snapshot = await makeCodingProjectIndependentCopy(projectId); + set((state) => ({ + configsByProjectId: { ...state.configsByProjectId, [projectId]: snapshot.config }, + knowledgeByProjectId: { ...state.knowledgeByProjectId, [projectId]: snapshot.knowledgeFiles }, + errorsByProjectId: Object.fromEntries(Object.entries(state.errorsByProjectId).filter(([id]) => id !== projectId)), + })); + return snapshot.config; + }, + async uploadKnowledge(projectId, file) { const bytes = new Uint8Array(await file.arrayBuffer()); let binary = ''; diff --git a/tests/e2e/coding-project-identity.spec.ts b/tests/e2e/coding-project-identity.spec.ts new file mode 100644 index 0000000..f7beb66 --- /dev/null +++ b/tests/e2e/coding-project-identity.spec.ts @@ -0,0 +1,117 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +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'; + +async function readProjectConfig(projectPath: string): Promise> { + return JSON.parse(await readFile(path.join(projectPath, '.niancode', 'project.json'), 'utf8')) as Record; +} + +async function selectProgrammingProjectFolder( + app: Parameters[0], + projectPath: string, +) { + await app.evaluate(({ ipcMain }, selectedPath) => { + ipcMain.removeHandler('dialog:open'); + ipcMain.handle('dialog:open', async () => ({ canceled: false, filePaths: [selectedPath] })); + }, projectPath); + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + await page.getByTestId('ai-module-option-programming').click(); + await expect(page.getByTestId('main-layout')).toBeVisible(); + return page; +} + +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-')); + 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 createDialog.getByRole('button', { name: '选择路径' }).click(); + 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(); + 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(); + + 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'); + + const legacyConfig = await readProjectConfig(projectPath); + delete legacyConfig.projectId; + await writeFile(path.join(projectPath, '.niancode', '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); + } finally { + await closeElectronApp(app); + await rm(projectPath, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/coding-projects-facade.test.ts b/tests/unit/coding-projects-facade.test.ts index b901c65..c3cd272 100644 --- a/tests/unit/coding-projects-facade.test.ts +++ b/tests/unit/coding-projects-facade.test.ts @@ -50,4 +50,53 @@ describe('coding project Host facade', () => { ['/api/coding/conversations/conversation%2F1', { method: 'DELETE' }], ]); }); + + it('sends explicit identity choices and a literal independent-copy confirmation', async () => { + hostApiFetch + .mockResolvedValueOnce({ snapshot: { project: { id: 'local-1' }, config: { projectId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, knowledgeFiles: [] } }) + .mockResolvedValueOnce({ snapshot: { project: { id: 'local-1' }, config: { projectId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }, knowledgeFiles: [] } }) + .mockResolvedValueOnce({ snapshot: { project: { id: 'local-1' }, config: { projectId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' }, knowledgeFiles: [] } }); + const { + createCodingProject, + isCanonicalCodingProjectId, + makeCodingProjectIndependentCopy, + resolveCodingProjectIdentity, + } = await import('@/lib/coding-projects'); + + await createCodingProject({ + projectPath: 'C:/projects/new', + projectType: 'custom', + identity: { kind: 'create' }, + }); + await resolveCodingProjectIdentity('local-1', { + kind: 'bind', + projectId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + }); + await makeCodingProjectIndependentCopy('local-1'); + + expect(isCanonicalCodingProjectId('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')).toBe(true); + expect(isCanonicalCodingProjectId('BBBBBBBB-BBBB-4BBB-8BBB-BBBBBBBBBBBB')).toBe(false); + expect(isCanonicalCodingProjectId('not-a-project-id')).toBe(false); + expect(hostApiFetch.mock.calls).toEqual([ + ['/api/coding/projects/create', { + method: 'POST', + body: JSON.stringify({ + projectPath: 'C:/projects/new', + projectType: 'custom', + identity: { kind: 'create' }, + }), + }], + ['/api/coding/projects/identity', { + method: 'POST', + body: JSON.stringify({ + localProjectId: 'local-1', + identity: { kind: 'bind', projectId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }, + }), + }], + ['/api/coding/projects/identity/independent-copy', { + method: 'POST', + body: JSON.stringify({ localProjectId: 'local-1', confirmed: true }), + }], + ]); + }); }); diff --git a/tests/unit/coding-workspace-store.test.ts b/tests/unit/coding-workspace-store.test.ts index 9bcd9d1..397ff2d 100644 --- a/tests/unit/coding-workspace-store.test.ts +++ b/tests/unit/coding-workspace-store.test.ts @@ -106,6 +106,31 @@ describe('coding workspace store', () => { expect(createConversation).not.toHaveBeenCalled(); }); + it('passes the explicit identity choice through project creation', async () => { + const createProject = vi.fn(async () => ({ + project, + config: config([agent('agent-a')]), + knowledgeFiles: [], + })); + const store = createCodingWorkspaceStore({ + listProjects: vi.fn(async () => ({ projects: [project], activeProjectId: project.id })), + getConfig: vi.fn(async () => ({ project, config: config([agent('agent-a')]) })), + listConversations: vi.fn(async () => []), + createProject, + }); + + await expect(store.getState().createProject({ + projectPath: 'C:/projects/new', + projectType: 'custom', + identity: { kind: 'bind', projectId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + })).resolves.toEqual(project); + expect(createProject).toHaveBeenCalledWith({ + projectPath: 'C:/projects/new', + projectType: 'custom', + identity: { kind: 'bind', projectId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + }); + }); + it('creates a missing first Conversation once for concurrent callers', async () => { let resolveCreate!: (value: CodingConversationMetadata) => void; const createFlight = new Promise((resolve) => { diff --git a/tests/unit/project-config-store.test.ts b/tests/unit/project-config-store.test.ts new file mode 100644 index 0000000..2a625cc --- /dev/null +++ b/tests/unit/project-config-store.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useProjectConfigStore } from '@/stores/project-config'; +import type { CodingProjectConfigSnapshot } from '@/types/coding-project'; + +const resolveCodingProjectIdentity = vi.hoisted(() => vi.fn()); +const makeCodingProjectIndependentCopy = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/coding-projects', () => ({ + makeCodingProjectIndependentCopy, + resolveCodingProjectIdentity, +})); + +const projectId = 'local-project-1'; + +function snapshot(identity: string): CodingProjectConfigSnapshot { + return { + project: { + id: projectId, + name: 'Local project', + createdAt: '2026-08-23T00:00:00.000Z', + updatedAt: '2026-08-23T00:00:00.000Z', + lastOpenedAt: '2026-08-23T00:00:00.000Z', + }, + config: { + schemaVersion: 2, + projectType: 'custom', + projectId: identity, + initialized: true, + agents: [], + knowledgeDirectory: 'knowledge', + legacyConversationNotice: 'none', + createdAt: '2026-08-23T00:00:00.000Z', + updatedAt: '2026-08-23T00:00:00.000Z', + }, + knowledgeFiles: ['README.md'], + }; +} + +describe('project config identity projection', () => { + beforeEach(() => { + vi.clearAllMocks(); + useProjectConfigStore.setState({ + configsByProjectId: {}, + knowledgeByProjectId: {}, + loadingProjectId: null, + errorsByProjectId: {}, + }); + }); + + it('projects identity resolution snapshots into the renderer cache', async () => { + const resolved = snapshot('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); + resolveCodingProjectIdentity.mockResolvedValue(resolved); + + await expect(useProjectConfigStore.getState().resolveIdentity(projectId, { kind: 'create' })) + .resolves.toEqual(resolved.config); + + expect(resolveCodingProjectIdentity).toHaveBeenCalledWith(projectId, { kind: 'create' }); + expect(useProjectConfigStore.getState()).toMatchObject({ + configsByProjectId: { [projectId]: resolved.config }, + knowledgeByProjectId: { [projectId]: resolved.knowledgeFiles }, + }); + }); + + it('projects independent-copy snapshots without writing any renderer-owned data', async () => { + const copied = snapshot('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'); + makeCodingProjectIndependentCopy.mockResolvedValue(copied); + + await expect(useProjectConfigStore.getState().makeIndependentCopy(projectId)) + .resolves.toEqual(copied.config); + + expect(makeCodingProjectIndependentCopy).toHaveBeenCalledWith(projectId); + expect(useProjectConfigStore.getState().configsByProjectId[projectId]).toEqual(copied.config); + }); +});