From df02680f9de3bec3519c7034574bed9974915928 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 6 Sep 2026 13:54:20 +0800 Subject: [PATCH] feat: improve project agent onboarding --- .../20260906-project-setup-ux-90fe6cf2.md | 107 +++++---- README.md | 3 +- shared/coding-project-contracts.ts | 19 ++ src/App.tsx | 8 +- src/components/layout/MainLayout.tsx | 11 +- src/components/layout/Sidebar.tsx | 7 +- src/pages/Chat/CodingChatPanel.tsx | 12 +- .../Chat/CodingProjectSetupEmptyState.tsx | 33 +++ src/pages/ProjectConfiguration/index.tsx | 195 ++++++++++++++-- .../e2e/project-configuration-skills.spec.ts | 22 +- tests/unit/app-module-provider-gate.test.tsx | 84 +++++++ tests/unit/coding-chat-panel.test.tsx | 22 +- tests/unit/coding-project-readiness.test.ts | 113 ++++++++++ tests/unit/main-layout-module-gate.test.tsx | 53 +++++ .../project-configuration-onboarding.test.tsx | 211 ++++++++++++++++++ 15 files changed, 826 insertions(+), 74 deletions(-) create mode 100644 src/pages/Chat/CodingProjectSetupEmptyState.tsx create mode 100644 tests/unit/coding-project-readiness.test.ts create mode 100644 tests/unit/project-configuration-onboarding.test.tsx diff --git a/.project-docs/30-worklog/tasks/20260906-project-setup-ux-90fe6cf2.md b/.project-docs/30-worklog/tasks/20260906-project-setup-ux-90fe6cf2.md index 6692645..4f54993 100644 --- a/.project-docs/30-worklog/tasks/20260906-project-setup-ux-90fe6cf2.md +++ b/.project-docs/30-worklog/tasks/20260906-project-setup-ux-90fe6cf2.md @@ -36,59 +36,72 @@ ## Outcome -- Confirmed that the screenshot's “项目智能体” is a persisted project-owned parent Agent, - not a runtime child Agent. ADR-006 and ADR-008 deliberately keep child Agents ephemeral - and without inherited project Plugin resources. -- Confirmed a state-invariant gap: Project Configuration validates only the Agents that - exist, so an empty Agent array passes; `submit()` then saves `initialized: true`. - `ProjectChatRoute` and `MainLayout` gate only on that boolean, despite the gate copy - claiming that at least one manually configured Agent is required. -- Confirmed the resulting dead-end presentation: Coding chat has no selected Agent, clears - the Conversation selection, renders only a small “当前项目还没有可用智能体” sidebar - message, leaves the main canvas empty, and disables the Composer without a recovery CTA. -- Confirmed the navigation gap: Project Configuration uses history-relative - `navigate(-1)`. The destination depends on how the page was entered and can even be a - visually identical Project Configuration history entry; saving does not navigate to - the Conversation page. -- Confirmed an additional supported-path gap: first-run Setup does not configure a model. - With zero model options, the create-Agent icon shows a toast and opens a read-only model - drawer that contains no direct action to `/models`, so stronger Agent guidance alone - would still lead some users into a dead end. -- Recommended the “required but escapable” flow: an incomplete project may exist and the - user may leave, but completion/Conversation readiness requires at least one enabled, - unarchived, fully configured project Agent. In the empty state, replace the icon-only - instruction with a prominent text CTA; after the first Agent is valid, make the primary - action “保存并进入对话” with deterministic `/chat` navigation. Keep a full chat recovery - state for legacy/archived-all data and route it directly back to Agent creation. -- Recommended treating zero models as an explicit prerequisite state with a “先配置模型” - CTA and a return intent, and replacing history-relative back behavior with context-aware, - labelled destinations such as “返回对话” or “稍后设置”. -- No product behavior or source code was changed. +- Confirmed that the screenshot's “项目智能体” is the persisted project-owned parent + Agent, not an ephemeral runtime child Agent, and retained that terminology throughout. +- Added one shared Conversation-readiness rule: `initialized` must be true and the project + must contain at least one enabled, unarchived Agent with a non-empty name, avatar, role, + responsibility, and resolved model. Project list entry, direct `/chat` resolution, + the programming-module gate, and Coding chat now use the same rule instead of trusting + `initialized` alone. +- Reworked the zero-ready-Agent configuration state into a prominent required-step card. + With no model it offers “先配置模型” and a direct model-settings action; otherwise it + creates the first Agent or resumes an enabled incomplete Agent. Disabled-only state + intentionally offers a new Agent because the edit dialog has no enable control. +- The first Agent is persisted immediately and then navigates to `/chat`. Later Agent + additions and edits retain the existing page-draft plus explicit-save behavior. +- Replaced history-relative back navigation with labelled, deterministic “返回对话” or + “稍后设置” destinations. Unsaved Agent changes are confirmed before any page exit, + including the model-settings prerequisite path. +- Archiving the last ready Agent now explains that saving will return the project to a + pending state; saving that state clears `initialized` and leaves the unusable chat route. +- Added a full Coding chat recovery state with a direct project-configuration action and no + Composer or Conversation/runtime creation when no usable Agent exists. +- Updated README with the accepted readiness and onboarding behavior. ## Verification -- Inspected the supplied 1493×891 Project Configuration screenshot at original detail. -- Read the project memory startup set, relevant product/domain rules, ADR-006, ADR-008, - and related concurrent task scopes after passing the ownership and planning gates. -- Inspected `src/pages/ProjectConfiguration/index.tsx`, `src/App.tsx`, - `src/components/layout/MainLayout.tsx`, `src/components/layout/Sidebar.tsx`, - `src/pages/Chat/CodingChatPanel.tsx`, `CodingConversationSidebar.tsx`, - `AgentCreationDialog.tsx`, the project configuration schema/service, Setup, README, - focused tests, and relevant Git blame/history. -- Queried the UI/UX reference for onboarding, empty-state, primary-action, keyboard, and - predictable-back guidance. Its generated dark palette was rejected because Makelore's - repository contract requires the existing single light visual system. -- No automated test was run because this task intentionally made no behavior change; a - future implementation requires focused unit coverage and the shared Electron E2E flow. +- Focused Vitest: 5 files, 63 tests passed, covering readiness, App routing, MainLayout + gating, Project Configuration onboarding/navigation, and Coding chat recovery. +- Full `pnpm test`: 230 files / 1928 tests passed with 2 skipped, followed by the isolated + pressure test passing 1/1. +- `pnpm run typecheck`: passed after the final interaction changes. +- `pnpm run lint:check`: passed with 0 errors; it retained 5 pre-existing warnings in + `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`. +- `pnpm run build:vite`: passed after the final interaction changes; only the repository's + existing Browserslist, dynamic-import, and chunk-size warnings were emitted. +- Electron E2E `project-configuration-skills.spec.ts`: passed 1/1, including zero-model + guidance, legacy `initialized: true` plus zero-Agent recovery, and labelled return to chat. +- Playwright trace inspection showed an initial E2E failure was caused by that spec's later + restrictive Host API stub, not product navigation; the navigation assertion now runs + before the stub and passes. +- Independent read-only review found the disabled-only recovery loop; after the targeted + correction and test, the reviewer returned PASS. +- `git diff --check`: passed. +- Task Documentation Gate remains blocked because merging current `main` introduced the + unchanged foreign task record + `.project-docs/30-worklog/tasks/20260906-agent-browser-failure-a7c91e4d.md` relative to + this task's older recorded base. The record was not modified by this task. ## Follow-ups -- If the recommended direction is accepted, implement one shared readiness predicate and - cover zero Agents, all Agents archived, no configured model, first Agent completion, - deterministic return/continue navigation, unsaved changes, and chat recovery. -- Update README only with the accepted final interaction behavior, not this discussion or - a historical worklog. +- Obtain explicit human confirmation for the project-doc recovery required to adopt the + unchanged foreign task record brought in by the already-created, unpushed `main` merge; + then rerun `check_doc_drift.py`, set this record to Ready for Integration, commit the + result, and run `task_context.py complete`. ## Promotion Candidates -- None recorded. +- Target: `.project-docs/30-worklog/current-state.md`. + Proposal: record that Coding Conversation readiness requires both persisted + initialization and at least one enabled, unarchived, fully configured project Agent; + all project-entry and chat-recovery surfaces share this invariant, and the first Agent + setup is the explicit completion path. + Evidence: shared readiness tests, Project Configuration onboarding tests, App/MainLayout + route tests, Coding chat recovery tests, Electron E2E, successful full unit suite, + typecheck, lint, production build, and independent review. + Future impact: future project-entry, archival, or Agent-edit work must reuse the shared + predicate rather than treating `initialized` as sufficient on its own. + Semantic conflicts: none known; the rule preserves the existing no-default-Agent and + project-owned Agent decisions. + Human confirmation required: no, unless integration discovers a conflicting accepted + product decision. diff --git a/README.md b/README.md index 2e441ef..d7c2327 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,8 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 新项目默认没有智能体;用户在项目内创建智能体时必须填写名称、预设头像或本地头像、职责说明和精确的 Provider 账号与默认模型。该选择以 `{ accountId, modelId, thinkingLevel }` 保存,不依赖运行时私有 Provider id。本地头像会自动裁剪为 256×256,并优先压缩为 WebP 后随项目配置保存;系统指令与技能属于高级设置。 - Makelore 为每个项目智能体附加平台管理的中文语言规则:自然语言默认使用简体中文,包括可见的思考过程、进度说明、工具调用说明和最终回复;代码、命令、路径、日志、错误原文与必要引用保留原始语言。用户或项目智能体系统指令明确要求其他语言时按该要求执行;模型仍不遵循时视为 Provider 模型行为。 -- 项目配置页的新增与已有智能体编辑统一使用居中弹窗;编辑保存先更新当前页面草稿,点击底部“保存项目配置”后统一持久化。“可用模型”抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择。智能体的默认模型只作为新对话的初始值;核心聊天页可为当前对话独立切换模型和思考强度,切换不会改写智能体默认值或其他对话。 +- 新项目可以暂时保留为待配置状态,但只有至少一个已启用、未归档且名称、头像、默认模型、角色与职责完整的项目智能体时,项目才可进入对话;旧配置即使错误保留了 `initialized: true`,也会回到项目配置完成这一步。配置页用顶部必需步骤和主操作强引导创建智能体,无模型时直接引导到模型设置;左上角提供带文字的确定性返回操作。聊天页遇到无可用智能体时显示可恢复空态,不展示不可用的输入框。 +- 项目配置页的新增与已有智能体编辑统一使用居中弹窗;首个智能体创建成功后会立即持久化并进入项目对话,后续新增与编辑先更新当前页面草稿,再点击底部“保存项目配置”统一持久化。“可用模型”抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择。智能体的默认模型只作为新对话的初始值;核心聊天页可为当前对话独立切换模型和思考强度,切换不会改写智能体默认值或其他对话。 - 项目智能体只保存自身的精确默认模型;未完成模型选择的智能体保持待配置状态,不从项目级 `defaultModel` 或运行时私有配置回退。 - “可用技能”入口使用模块图标;点击技能后先展示目录结构,再展示主文件 `SKILL.md` 原文,并支持返回技能列表。 - 一个智能体可以拥有多条互相独立的 Pi Session;智能体和对话元数据分别由 `.makelore/project.json` 与 `.makelore/conversations.json` 保存,稳定的智能体与对话 id 保持本地历史连续。 diff --git a/shared/coding-project-contracts.ts b/shared/coding-project-contracts.ts index b2f1fce..12e9c9a 100644 --- a/shared/coding-project-contracts.ts +++ b/shared/coding-project-contracts.ts @@ -56,3 +56,22 @@ export interface CodingProjectConfigSnapshot { config: CodingProjectConfig; knowledgeFiles: string[]; } + +export function isCodingProjectAgentReady(agent: CodingProjectAgent): boolean { + return agent.enabled + && agent.archivedAt === null + && agent.name.trim().length > 0 + && agent.avatarId.trim().length > 0 + && agent.roleName.trim().length > 0 + && agent.responsibility.mission.trim().length > 0 + && agent.modelResolution === 'resolved' + && agent.model !== null; +} + +export function hasReadyCodingProjectAgent(config: CodingProjectConfig): boolean { + return config.agents.some(isCodingProjectAgentReady); +} + +export function isCodingProjectConversationReady(config: CodingProjectConfig): boolean { + return config.initialized && hasReadyCodingProjectAgent(config); +} diff --git a/src/App.tsx b/src/App.tsx index c97a8d8..921dda8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import { subscribeHostEvent } from '@/lib/host-events'; import { reportDesktopActivity, type DesktopActivityModule } from '@/lib/host-api'; import { installRendererPerformanceDiagnostics } from '@/lib/performance-diagnostics'; import { resolveSupportedLanguage } from '../shared/language'; +import { isCodingProjectConversationReady } from '../shared/coding-project-contracts'; import { LegacyPluginRedirect } from './pages/Plugins/legacy-plugin-redirect'; const Models = lazy(() => import('./pages/Models').then(({ Models: component }) => ({ default: component }))); @@ -229,7 +230,12 @@ 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 && isCodingProjectConversationReady(configResult.config)) + ? 'chat' + : 'config', + ); } } catch { if (!cancelled) { diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 06c2037..b103f45 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -14,6 +14,7 @@ import { useProjectConfigStore } from '@/stores/project-config'; import { useSettingsStore } from '@/stores/settings'; import { cn } from '@/lib/utils'; import type { SidebarPeekSource } from './sidebar-peek'; +import { isCodingProjectConversationReady } from '../../../shared/coding-project-contracts'; const SIDEBAR_PEEK_CLOSE_DELAY_MS = 180; @@ -88,7 +89,11 @@ export function MainLayout() { if (activeProject && isProgrammingModule) void load(activeProject.id).catch(() => undefined); }, [activeProject, isProgrammingModule, load]); - const initializationBlocked = Boolean(activeProject && (!config || !config.initialized) && !isInitializationSafeRoute); + const initializationBlocked = Boolean( + activeProject + && (!config || !isCodingProjectConversationReady(config)) + && !isInitializationSafeRoute, + ); return (
{/* Title bar: drag region on macOS, icon + controls on Windows */} @@ -121,8 +126,8 @@ export function MainLayout() {
-

项目尚未初始化

-

请先在项目配置中完成至少一个手动 Agent 的名称、模型和职责设置。

+

项目还差一步

+

请先在项目配置中创建或恢复一个项目智能体,并完成名称、模型和职责设置。

diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 6637de7..4e2aa95 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -53,7 +53,10 @@ 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 { + isCodingProjectConversationReady, + type ProjectIdentityChoice, +} from '../../../shared/coding-project-contracts'; import { toast } from 'sonner'; type OpenDialogResult = { @@ -438,7 +441,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi return; } await setActiveProject(project.id); - navigate(result.config.initialized + navigate(isCodingProjectConversationReady(result.config) ? '/chat' : projectConfigPath); }; diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 52b1a01..85919c1 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -41,7 +41,12 @@ import { CodingConversationSidebar } from './CodingConversationSidebar'; import { CodingConversationHeader } from './CodingConversationHeader'; import { CodingConversationTimeline } from './CodingConversationTimeline'; import { CodingInteractionPanel } from './CodingInteractionPanel'; +import { CodingProjectSetupEmptyState } from './CodingProjectSetupEmptyState'; import { createLocalConversationSnapshot } from './coding-chat-snapshot'; +import { + isCodingProjectAgentReady, + isCodingProjectConversationReady, +} from '../../../shared/coding-project-contracts'; export interface CodingChatPanelProps { navigationDraft?: string; @@ -158,8 +163,9 @@ export function CodingChatPanel({ const submissionFlightsRef = useRef(new Set()); const agents = useMemo(() => ( - config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? [] + config?.agents.filter(isCodingProjectAgentReady) ?? [] ), [config]); + const conversationReady = Boolean(config && isCodingProjectConversationReady(config)); const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null; const selectedConversation = conversations.find((conversation) => ( conversation.id === selectedConversationId @@ -615,6 +621,10 @@ export function CodingChatPanel({ ); } + if (activeProject && config && !conversationReady && workspaceLoadState !== 'loading') { + return ; + } + return (
+
+
+
+

创建项目智能体后开始对话

+

+ 当前项目还没有可用的项目智能体。完成名称、模型和职责设置后,就可以发送消息。 +

+ +
+
+ ); +} diff --git a/src/pages/ProjectConfiguration/index.tsx b/src/pages/ProjectConfiguration/index.tsx index 1e86629..5459d14 100644 --- a/src/pages/ProjectConfiguration/index.tsx +++ b/src/pages/ProjectConfiguration/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { ArrowLeft, Blocks, Check, Cpu, FileText, Files, FolderOpen, Plus, Trash2, Upload, X } from 'lucide-react'; +import { ArrowLeft, Blocks, Bot, Check, Cpu, FileText, Files, FolderOpen, Plus, Trash2, Upload, X } from 'lucide-react'; import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -20,10 +20,16 @@ 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'; +import { + hasReadyCodingProjectAgent, + isCodingProjectAgentReady, + isCodingProjectConversationReady, + type ProjectIdentityChoice, +} from '../../../shared/coding-project-contracts'; const EMPTY_FILES: string[] = []; type DrawerMode = 'models' | 'knowledge' | 'skills' | null; +type ExitDestination = '/chat' | '/module-select' | '/models'; type ProjectIdentityKind = ProjectIdentityChoice['kind']; type SkillStructureEntry = { path: string; type: 'file' | 'directory' }; type SkillInfo = { @@ -93,7 +99,8 @@ function SkillDetail({ skill }: { skill: SkillInfo }) { } function AgentCard({ agent, onOpen }: { agent: CodingProjectAgent; onOpen: () => void }) { - return ; + const ready = isCodingProjectAgentReady(agent); + return ; } function ModelCard({ model }: { model: CodingModelOption }) { @@ -111,10 +118,20 @@ function ModelCard({ model }: { model: CodingModelOption }) {
; } -function ModelList({ models }: { models: CodingModelOption[] }) { +function ModelList({ + models, + onOpenModelSettings, +}: { + models: CodingModelOption[]; + onOpenModelSettings: () => void; +}) { if (models.length === 0) { return
- 当前没有可用模型,请先在模型设置中完成配置。智能体的默认模型在智能体配置中设置。 +

当前没有可用模型。先完成模型配置,再从左侧项目入口回到这里创建智能体。

+
; } return
@@ -153,6 +170,7 @@ export function ProjectConfiguration() { const [identityError, setIdentityError] = useState(null); const [independentCopyDialogOpen, setIndependentCopyDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [pendingExitDestination, setPendingExitDestination] = useState(null); const [partnerDialogOpen, setPartnerDialogOpen] = useState(false); const [archiveAgentId, setArchiveAgentId] = useState(null); const knowledgeInputRef = useRef(null); @@ -197,11 +215,47 @@ export function ProjectConfiguration() { const activeAgent = draft?.agents.find((agent) => agent.id === activeAgentId) ?? null; const activeSkill = skills.find((skill) => skill.id === activeSkillId) ?? null; const playSaveConfirmation = () => undefined; - const handleBack = () => navigate(-1); if (!activeProject) return

一念成光,万物可创。

; if (!draft) return
正在读取项目配置…
; + const draftHasReadyAgent = hasReadyCodingProjectAgent(draft); + const storedConversationReady = Boolean(stored && isCodingProjectConversationReady(stored)); + const hasUnsavedChanges = Boolean(stored && JSON.stringify(stored) !== JSON.stringify(draft)); + const backDestination: ExitDestination = storedConversationReady ? '/chat' : '/module-select'; + const backLabel = storedConversationReady ? '返回对话' : '稍后设置'; + const pendingAgent = draft.agents.find((agent) => ( + agent.enabled && !agent.archivedAt && !isCodingProjectAgentReady(agent) + )); + const setupActionLabel = modelOptions.length === 0 + ? '先配置模型' + : pendingAgent + ? '完成智能体配置' + : '创建项目智能体'; + const archiveAgent = draft.agents.find((agent) => agent.id === archiveAgentId); + const archiveRemovesLastReadyAgent = Boolean( + archiveAgent + && isCodingProjectAgentReady(archiveAgent) + && !draft.agents.some((agent) => agent.id !== archiveAgentId && isCodingProjectAgentReady(agent)), + ); + const requestNavigation = (destination: ExitDestination) => { + if (hasUnsavedChanges) { + setPendingExitDestination(destination); + return; + } + navigate(destination); + }; + const handleBack = () => requestNavigation(backDestination); + const openModelSettings = () => requestNavigation('/models'); + const openAgentCreation = () => { + if (modelOptions.length === 0) { + openModelSettings(); + return; + } + setActiveAgentId(pendingAgent?.id ?? null); + setPartnerDialogOpen(true); + }; + const updateAgent = (next: CodingProjectAgent) => setDraft((current) => current ? { ...current, agents: current.agents.map((item) => item.id === next.id ? { ...next, updatedAt: new Date().toISOString() } : item) } : current); const handlePartnerDialogOpenChange = (open: boolean) => { setPartnerDialogOpen(open); @@ -221,7 +275,22 @@ export function ProjectConfiguration() { skillIds: input.skillIds, responsibility: { mission: input.responsibility, owns: [], boundaries: [], collaborators: [], principles: [] }, }; - setDraft((current) => current ? { ...current, agents: [...current.agents, agent] } : current); + const nextDraft = { ...draft, agents: [...draft.agents, agent] }; + if (!draftHasReadyAgent) { + setSaving(true); + try { + const saved = await save(activeProject.id, { ...nextDraft, initialized: true }); + setDraft(structuredClone(saved)); + await reloadWorkspace(); + playSaveConfirmation(); + toast.success('项目智能体已创建'); + navigate('/chat'); + } finally { + setSaving(false); + } + return; + } + setDraft(nextDraft); }; const handleUpdatePartner = async (input: AgentCreationInput) => { if (!activeAgentId) return; @@ -258,12 +327,26 @@ export function ProjectConfiguration() { toast.error('请补全智能体名称、头像、默认模型和职责说明;名称需在当前项目内唯一。'); return; } + const conversationReady = hasReadyCodingProjectAgent(draft); + if (!conversationReady && !storedConversationReady) { + openAgentCreation(); + return; + } setSaving(true); try { - await save(activeProject.id, { ...draft, initialized: true }); + const saved = await save(activeProject.id, { ...draft, initialized: conversationReady }); + setDraft(structuredClone(saved)); await reloadWorkspace(); playSaveConfirmation(); - toast.success(draft.initialized ? '项目配置已更新' : '项目初始化完成'); + if (!conversationReady) { + toast.success('项目已保存,创建智能体后即可开始对话'); + navigate('/module-select'); + } else if (!storedConversationReady) { + toast.success('项目初始化完成'); + navigate('/chat'); + } else { + toast.success('项目配置已更新'); + } } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); } finally { @@ -336,11 +419,36 @@ export function ProjectConfiguration() { return
-

项目配置

+ {!draftHasReadyAgent ? ( +
+
+ +
+
+

{draft.agents.length === 0 ? '项目已创建,还差 1 步' : '项目需要一个可用智能体'}

+ 必需 +
+

+ {modelOptions.length === 0 + ? '先配置一个可用模型,再创建项目智能体即可开始对话。' + : '创建一个项目智能体并完成名称、默认模型和职责设置,即可开始对话。'} +

+
+
+ +
+ ) : null}
{draft.projectId ? (
@@ -456,7 +564,31 @@ export function ProjectConfiguration() { )}
} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => 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}
+
+
+
+
+

项目智能体

+ {!draftHasReadyAgent ? 必需 : null} +
+

智能体配置仅属于当前项目;每个智能体可以拥有多条独立对话。

+
+ {draftHasReadyAgent ? ( + + ) : null} +
+
+ {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} +
@@ -469,7 +601,23 @@ export function ProjectConfiguration() { buttonSize="sm" /> ) : null} - + {!draftHasReadyAgent && !storedConversationReady ? ( + + ) : ( + + )}
@@ -484,24 +632,39 @@ export function ProjectConfiguration() { onUpdate={handleUpdatePartner} onArchive={activeAgent && !activeAgent.builtIn ? () => setArchiveAgentId(activeAgent.id) : undefined} title={activeAgent ? `${activeAgent.name || '未命名智能体'} · 编辑智能体` : '创建项目智能体'} - submitLabel={activeAgent ? '保存智能体' : '创建智能体'} + submitLabel={activeAgent ? '保存智能体' : draftHasReadyAgent ? '创建智能体' : '创建并进入对话'} /> { if (!open) { setDrawerMode(null); setActiveSkillId(null); } }}> - {drawerMode === 'models' ? setDrawerMode(null)}> : null} + {drawerMode === 'models' ? setDrawerMode(null)}> : null} {drawerMode === 'knowledge' ? setDrawerMode(null)}> { void handleKnowledge(event.target.files?.[0]); event.currentTarget.value = ''; }} />
{knowledge.length ? knowledge.map((file) =>
{file}
) :
当前还没有知识文件。
}
: null} {drawerMode === 'skills' && activeSkill ? setActiveSkillId(null)} closeLabel="返回技能列表" closeIcon="back"> : null} {drawerMode === 'skills' && !activeSkill ? setDrawerMode(null)}>
{skills.length > 0 ? skills.map((skill) => ) :
当前没有可查看的技能。
}
: null}
agent.id === archiveAgentId)?.name || '未命名智能体'}”?`} - message="归档后将停止提供新的使用入口,但会保留智能体配置和全部对话记录;你可以之后在智能体列表底部恢复。" + title={`归档智能体“${archiveAgent?.name || '未命名智能体'}”?`} + message={archiveRemovesLastReadyAgent + ? '这是当前最后一个可用智能体。归档并保存后,项目将进入待配置状态,无法开始新对话;智能体配置和全部对话记录仍会保留。' + : '归档后将停止提供新的使用入口,但会保留智能体配置和全部对话记录;你可以之后在智能体列表底部恢复。'} confirmLabel="确认归档" cancelLabel="取消" onCancel={() => setArchiveAgentId(null)} onConfirm={handleArchiveAgent} onError={(error) => toast.error(error instanceof Error ? error.message : String(error))} /> + setPendingExitDestination(null)} + onConfirm={() => { + const destination = pendingExitDestination; + setPendingExitDestination(null); + if (destination) navigate(destination); + }} + /> { await expect(page.getByTestId('project-configuration-actions')).toBeVisible(); const actionBarBottomGap = await page.getByTestId('project-configuration-actions').evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom); expect(actionBarBottomGap).toBeLessThan(40); - await expect(page.getByRole('button', { name: '创建智能体' })).toBeVisible(); + const setupCallout = page.getByTestId('project-agent-setup-callout'); + await expect(setupCallout).toContainText('项目已创建,还差 1 步'); + await expect(setupCallout).toContainText('必需'); + await expect(page.getByTestId('project-agent-setup-primary')).toHaveText('先配置模型'); + await expect(page.getByTestId('project-configuration-back-button')).toHaveAccessibleName('稍后设置'); for (const [resourceId, title] of [ ['resource-card-models', '可用模型'], @@ -48,6 +52,7 @@ test.describe('Project configuration skills', () => { if (resourceId === 'resource-card-models') { await expect(drawer.getByRole('combobox')).toHaveCount(0); await expect(drawer.locator('[data-testid="project-model-list"], [data-testid="project-model-empty-state"]')).toHaveCount(1); + await expect(drawer.getByRole('button', { name: '前往模型设置' })).toBeVisible(); } const closeButton = drawer.getByRole('button', { name: '关闭' }); const closeGeometry = await closeButton.evaluate((button) => { @@ -73,6 +78,15 @@ test.describe('Project configuration skills', () => { const now = new Date().toISOString(); await mkdir(path.join(parentPath, '.makelore'), { recursive: true }); + await writeFile(path.join(parentPath, '.makelore', 'project.json'), JSON.stringify({ + ...createCodingProjectConfigV2(now, 'interactive_ai_app'), + initialized: true, + agents: [], + }, null, 2)); + await page.evaluate(() => { window.location.hash = '#/chat'; }); + await expect(page.getByTestId('project-configuration-page')).toBeVisible(); + await expect(page.getByTestId('project-agent-setup-callout')).toBeVisible(); + await writeFile(path.join(parentPath, '.makelore', 'project.json'), JSON.stringify({ ...createCodingProjectConfigV2(now, 'interactive_ai_app'), initialized: true, @@ -96,6 +110,11 @@ test.describe('Project configuration skills', () => { }, null, 2)); await page.reload(); await expect(page.getByTestId('project-configuration-page')).toBeVisible(); + await expect(page.getByTestId('project-configuration-back-button')).toHaveAccessibleName('返回对话'); + await page.getByTestId('project-configuration-back-button').click(); + await expect(page.getByTestId('chat-operation-page')).toBeVisible(); + await page.evaluate(() => { window.location.hash = '#/project-config'; }); + await expect(page.getByTestId('project-configuration-page')).toBeVisible(); await page.getByTestId('resource-card-skills').click(); await expect(page.getByTestId('superpowers-card')).toHaveCount(0); @@ -163,6 +182,7 @@ test.describe('Project configuration skills', () => { await expect(avatarDialog).toHaveCount(0); await expect(maintenanceDialog.getByAltText('当前上传头像')).toBeVisible(); await expect(page.getByText('完成配置')).toHaveCount(0); + await maintenanceDialog.getByRole('button', { name: '取消' }).click(); } finally { await closeElectronApp(app); await rm(parentPath, { recursive: true, force: true }); diff --git a/tests/unit/app-module-provider-gate.test.tsx b/tests/unit/app-module-provider-gate.test.tsx index 24684c1..e1f14ad 100644 --- a/tests/unit/app-module-provider-gate.test.tsx +++ b/tests/unit/app-module-provider-gate.test.tsx @@ -6,6 +6,10 @@ import { useAuthStore } from '@/stores/auth'; import { useProviderStore } from '@/stores/providers'; import { useSettingsStore } from '@/stores/settings'; import { useUserSyncStore } from '@/stores/user-sync'; +import { codingWorkspaceStore } from '@/stores/coding-workspace'; +import { useProjectConfigStore } from '@/stores/project-config'; +import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config'; +import type { CodingProjectAgent } from '@/types/coding-project'; vi.mock('@/components/layout/MainLayout', () => ({ MainLayout: () => ( @@ -39,11 +43,41 @@ vi.mock('@/pages/Settings', () => ({ Settings: () =>
Global settings
, })); +vi.mock('@/pages/ProjectConfiguration', () => ({ + ProjectConfiguration: () =>
Project configuration
, +})); + +const readyAgent: CodingProjectAgent = { + id: 'agent-1', + avatarId: 'avatar-01', + roleName: '项目智能体', + name: 'Builder', + builtIn: false, + enabled: true, + model: { accountId: 'account-1', modelId: 'model-1', thinkingLevel: 'off' }, + modelResolution: 'resolved', + skillIds: [], + responsibility: { + mission: '完成项目工作', + owns: [], + boundaries: [], + collaborators: [], + principles: [], + }, + prompt: '', + archivedAt: null, + pinned: false, + createdAt: '2026-09-06T00:00:00.000Z', + updatedAt: '2026-09-06T00:00:00.000Z', +}; + describe('App programming provider initialization gate', () => { const initProviders = vi.fn(); beforeEach(() => { vi.clearAllMocks(); + codingWorkspaceStore.setState(codingWorkspaceStore.getInitialState(), true); + useProjectConfigStore.setState(useProjectConfigStore.getInitialState(), true); useSettingsStore.setState({ setupComplete: true, init: vi.fn(), @@ -95,6 +129,56 @@ describe('App programming provider initialization gate', () => { await waitFor(() => expect(initProviders).toHaveBeenCalledTimes(1)); }); + it('redirects a legacy initialized project without a usable Agent to configuration', async () => { + const project = { + id: 'local-project', + path: '/tmp/local-project', + name: 'local-project', + createdAt: '', + updatedAt: '', + lastOpenedAt: '', + }; + const config = { ...createCodingProjectConfigV2(), initialized: true, agents: [] }; + codingWorkspaceStore.setState({ + activeProjectId: project.id, + activeProject: project, + load: vi.fn().mockResolvedValue(undefined), + }); + useProjectConfigStore.setState({ + load: vi.fn().mockResolvedValue({ status: 'valid', config, knowledgeFiles: [] }), + }); + + await renderAt('/chat'); + + expect(await screen.findByText('Project configuration')).toBeInTheDocument(); + expect(screen.queryByText('Programming workspace')).not.toBeInTheDocument(); + }); + + it('opens chat when the project has a fully configured Agent', async () => { + const project = { + id: 'local-project', + path: '/tmp/local-project', + name: 'local-project', + createdAt: '', + updatedAt: '', + lastOpenedAt: '', + }; + const config = { ...createCodingProjectConfigV2(), initialized: true, agents: [readyAgent] }; + codingWorkspaceStore.setState({ + activeProjectId: project.id, + activeProject: project, + load: vi.fn().mockResolvedValue(undefined), + }); + useProjectConfigStore.setState({ + load: vi.fn().mockResolvedValue({ status: 'valid', config, knowledgeFiles: [] }), + }); + + await renderAt('/chat'); + + expect(await screen.findByText('Programming workspace')).toBeInTheDocument(); + expect(screen.queryByText('Project configuration')).not.toBeInTheDocument(); + }); + it('redirects an orphaned token without a user identity away from a direct workspace route', async () => { useAuthStore.setState({ initialized: true, diff --git a/tests/unit/coding-chat-panel.test.tsx b/tests/unit/coding-chat-panel.test.tsx index 28b85cf..24fea85 100644 --- a/tests/unit/coding-chat-panel.test.tsx +++ b/tests/unit/coding-chat-panel.test.tsx @@ -80,8 +80,8 @@ const agent: CodingProjectAgent = { pinned: true, createdAt: '2026-08-23T00:00:00.000Z', updatedAt: '2026-08-23T00:00:00.000Z', - model: null, - modelResolution: 'required', + model: { accountId: 'account-1', modelId: 'model-1', thinkingLevel: 'off' }, + modelResolution: 'resolved', }; const config: CodingProjectConfig = { schemaVersion: 2, @@ -199,6 +199,24 @@ describe('CodingChatPanel first Conversation', () => { vi.resetModules(); }); + it('shows an actionable setup state when the active project has no usable Agent', async () => { + projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); + projectApi.config.mockResolvedValue({ project, config: configForAgents([]) }); + projectApi.conversations.mockResolvedValue([]); + const onOpenProjectSettings = vi.fn(); + const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + + render(); + + expect(await screen.findByTestId('coding-chat-agent-setup-empty-state')).toBeVisible(); + expect(screen.getByText('创建项目智能体后开始对话')).toBeVisible(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(projectApi.create).not.toHaveBeenCalled(); + expect(conversationApi.snapshot).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '创建或恢复项目智能体' })); + expect(onOpenProjectSettings).toHaveBeenCalledOnce(); + }); + it('makes the first-Conversation textarea editable while runtime metadata is held', async () => { projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); projectApi.config.mockResolvedValue({ project, config }); diff --git a/tests/unit/coding-project-readiness.test.ts b/tests/unit/coding-project-readiness.test.ts new file mode 100644 index 0000000..38122b5 --- /dev/null +++ b/tests/unit/coding-project-readiness.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import type { + CodingProjectAgent, + CodingProjectConfig, +} from '../../shared/coding-project-contracts'; +import { + hasReadyCodingProjectAgent, + isCodingProjectAgentReady, + isCodingProjectConversationReady, +} from '../../shared/coding-project-contracts'; + +const createdAt = '2026-09-06T00:00:00.000Z'; + +function createAgent(overrides: Partial = {}): CodingProjectAgent { + return { + id: 'agent-1', + avatarId: 'spark', + roleName: '编程助手', + name: '小洛', + builtIn: false, + enabled: true, + skillIds: [], + responsibility: { + mission: '帮助完成项目', + owns: [], + boundaries: [], + collaborators: [], + principles: [], + }, + prompt: '', + archivedAt: null, + pinned: false, + model: { + accountId: 'account-1', + modelId: 'model-1', + thinkingLevel: 'off', + }, + modelResolution: 'resolved', + createdAt, + updatedAt: createdAt, + ...overrides, + }; +} + +function createConfig(overrides: Partial = {}): CodingProjectConfig { + return { + schemaVersion: 2, + projectType: 'custom', + initialized: true, + agents: [createAgent()], + knowledgeDirectory: 'knowledge', + createdAt, + updatedAt: createdAt, + ...overrides, + }; +} + +describe('coding project Conversation readiness', () => { + it('accepts an enabled, unarchived, fully configured Agent', () => { + const agent = createAgent(); + const config = createConfig({ agents: [agent] }); + + expect(isCodingProjectAgentReady(agent)).toBe(true); + expect(hasReadyCodingProjectAgent(config)).toBe(true); + expect(isCodingProjectConversationReady(config)).toBe(true); + }); + + it('rejects a project with no Agents', () => { + const config = createConfig({ agents: [] }); + + expect(hasReadyCodingProjectAgent(config)).toBe(false); + expect(isCodingProjectConversationReady(config)).toBe(false); + }); + + it.each([ + ['disabled', { enabled: false }], + ['archived', { archivedAt: createdAt }], + ] satisfies Array<[string, Partial]>)('rejects a %s Agent', (_label, overrides) => { + const agent = createAgent(overrides); + + expect(isCodingProjectAgentReady(agent)).toBe(false); + }); + + it('rejects an Agent whose model still requires resolution', () => { + const agent = createAgent({ model: null, modelResolution: 'required' }); + + expect(isCodingProjectAgentReady(agent)).toBe(false); + }); + + it('rejects an Agent with a resolved but missing model', () => { + const agent = createAgent({ model: null }); + + expect(isCodingProjectAgentReady(agent)).toBe(false); + }); + + it('rejects an Agent with an empty responsibility mission', () => { + const agent = createAgent({ + responsibility: { + ...createAgent().responsibility, + mission: ' ', + }, + }); + + expect(isCodingProjectAgentReady(agent)).toBe(false); + }); + + it('requires the project to be initialized', () => { + const config = createConfig({ initialized: false }); + + expect(hasReadyCodingProjectAgent(config)).toBe(true); + expect(isCodingProjectConversationReady(config)).toBe(false); + }); +}); diff --git a/tests/unit/main-layout-module-gate.test.tsx b/tests/unit/main-layout-module-gate.test.tsx index 827d9c7..62d688f 100644 --- a/tests/unit/main-layout-module-gate.test.tsx +++ b/tests/unit/main-layout-module-gate.test.tsx @@ -6,9 +6,34 @@ import { codingWorkspaceStore } from '@/stores/coding-workspace'; import { useProjectConfigStore } from '@/stores/project-config'; import { useSettingsStore } from '@/stores/settings'; import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config'; +import type { CodingProjectAgent } from '@/types/coding-project'; const learningIpcMock = vi.hoisted(() => vi.fn()); +const readyAgent: CodingProjectAgent = { + id: 'agent-1', + avatarId: 'avatar-01', + roleName: '项目智能体', + name: 'Builder', + builtIn: false, + enabled: true, + model: { accountId: 'account-1', modelId: 'model-1', thinkingLevel: 'off' }, + modelResolution: 'resolved', + skillIds: [], + responsibility: { + mission: '完成项目工作', + owns: [], + boundaries: [], + collaborators: [], + principles: [], + }, + prompt: '', + archivedAt: null, + pinned: false, + createdAt: '2026-09-06T00:00:00.000Z', + updatedAt: '2026-09-06T00:00:00.000Z', +}; + vi.mock('@/components/layout/Sidebar', () => ({ Sidebar: ({ sidebarCollapsedOverride }: { sidebarCollapsedOverride?: boolean }) => (