feat(coding): add project identity UX
This commit is contained in:
@@ -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.
|
||||
@@ -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<ProjectDirectoryMode>('use-selected-directory');
|
||||
const [newProjectType, setNewProjectType] = useState<ProjectType>('mini_game');
|
||||
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);
|
||||
@@ -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();
|
||||
}}
|
||||
>
|
||||
<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-3">
|
||||
|
||||
@@ -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<CodingProjectConfigSnapshot> {
|
||||
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<CodingProjectConfigSnapshot> {
|
||||
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<CodingProjectConfigSnapshot> {
|
||||
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<CodingProjectSummary> {
|
||||
const response = await hostApiFetch<{ project: CodingProjectSummary }>('/api/coding/projects/active', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -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<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);
|
||||
@@ -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() {
|
||||
</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="🧠" title="大脑(大语言模型)" subtitle={`已配置${modelOptions.length}个`} onClick={() => setDrawerMode('models')} /><ResourceCard id="skills" icon={<Wrench className="h-5 w-5" />} title="工具箱(技能)" subtitle={`已安装${skills.length}个`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /><ResourceCard id="knowledge" icon="📓" 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">
|
||||
@@ -339,6 +502,16 @@ 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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CodingProjectCatalog>;
|
||||
@@ -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<CodingProjectSummary>;
|
||||
removeProject(projectId: string): Promise<void>;
|
||||
@@ -71,6 +73,7 @@ export interface CodingWorkspaceState {
|
||||
parentPath?: string;
|
||||
projectName?: string;
|
||||
projectType?: ProjectType;
|
||||
identity: ProjectIdentityChoice;
|
||||
}): Promise<CodingProjectSummary>;
|
||||
setActiveProject(projectId: string): Promise<CodingProjectSummary>;
|
||||
removeProject(projectId: string): Promise<void>;
|
||||
|
||||
@@ -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<string, string>;
|
||||
load: (projectId: string) => Promise<ProjectConfigResponse>;
|
||||
save: (projectId: string, config: CodingProjectConfig) => Promise<CodingProjectConfig>;
|
||||
resolveIdentity: (projectId: string, identity: ProjectIdentityChoice) => Promise<CodingProjectConfig>;
|
||||
makeIndependentCopy: (projectId: string) => Promise<CodingProjectConfig>;
|
||||
uploadKnowledge: (projectId: string, file: File) => Promise<void>;
|
||||
remove: (projectId: string) => void;
|
||||
};
|
||||
@@ -64,6 +71,26 @@ export const useProjectConfigStore = create<ProjectConfigState>((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 = '';
|
||||
|
||||
117
tests/e2e/coding-project-identity.spec.ts
Normal file
117
tests/e2e/coding-project-identity.spec.ts
Normal file
@@ -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<Record<string, unknown>> {
|
||||
return JSON.parse(await readFile(path.join(projectPath, '.niancode', 'project.json'), 'utf8')) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function selectProgrammingProjectFolder(
|
||||
app: Parameters<typeof closeElectronApp>[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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
}],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<CodingConversationMetadata>((resolve) => {
|
||||
|
||||
74
tests/unit/project-config-store.test.ts
Normal file
74
tests/unit/project-config-store.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user