diff --git a/.project-docs/30-worklog/tasks/20260906-coding-default-page-a7c42e.md b/.project-docs/30-worklog/tasks/20260906-coding-default-page-a7c42e.md new file mode 100644 index 0000000..836d25c --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260906-coding-default-page-a7c42e.md @@ -0,0 +1,123 @@ +# Task: Redesign Coding default page + +## Identity + +- Task ID: 20260906-coding-default-page-a7c42e +- Mode: Feature +- Branch: codex/20260906-coding-default-page-a7c42e-coding-default-page +- Worktree: /Users/inmanx/Documents/makelore-task-coding-default-page-a7c42e +- Base commit: f8eee430f4f4167dc2ecc682ece96c652cdde14b +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Replace the no-project Code entry redirect with a dedicated conversation-first + landing state on `/chat`. +- Restyle both the no-project landing and the empty Conversation timeline around + the existing Makelore M brand mark, a large centered prompt, and the spacious + vertical hierarchy shown in the supplied Codex reference. +- Keep project creation reachable from the landing surface and update focused + Renderer/Electron coverage plus README current-state wording. +- Apply the user's follow-up correction: the lower landing surface is a standalone + `新增项目` CTA followed by horizontally arranged existing-project cards, not a + composer-shaped project starter. +- Apply the user's final copy correction: remove the visible `已有项目` heading and + `选择一个项目继续` helper while retaining the horizontal project-card rail. + +## Intent And Constraints + +- Reuse `src/assets/logo.svg`; do not copy or approximate the Codex mark and do + not generate a new brand asset. +- Preserve the single Makelore light visual system, bundled typography, Chinese + UI, project-local `.makelore` authority, and all Main-owned runtime/API seams. +- The no-project call to action must open the existing Sidebar project-creation + flow rather than introducing a second creation implementation; existing-project + cards must likewise reuse the Sidebar's validated project-entry behavior. +- Existing projects, Agent selection, Conversation creation, Composer behavior, + model/runtime controls, and accepted/uncertain mutation semantics remain + unchanged. +- Completed peer tasks for Plugin rehoming and removal of the project setup gate + overlap in Code-entry files but have no committed branch tips to consume. This + task therefore stays isolated on the recorded base and keeps its changes small + enough for later semantic integration. + +## Outcome + +- Code now resolves an empty local project list to `/chat` instead of redirecting + to Project Configuration. +- The no-project view uses the existing Makelore M vector asset and a large + centered guide sentence while retaining the Makelore sidebar and light visual + system. Its lower surface now uses the user's clarified standalone orange + `新增项目` CTA followed directly by an overflow-safe horizontal row of existing + projects, without the redundant visible project-section heading or helper copy. +- Project creation reuses the Sidebar's existing dialog through a renderer-local + event bridge; project-card selection uses the same bridge to invoke the + Sidebar's validated entry flow instead of duplicating project activation. +- Each project card is backed by the real local project catalog and exposes the + project name and last-opened date; an empty catalog shows only the CTA and does + not fabricate sample projects. +- Empty Conversations reuse the same branded hero above the real editable + Composer and automatically yield to the normal timeline after the first entry. +- README current-state wording, focused unit coverage, and the Code-entry Electron + navigation regression were updated. A side-by-side visual QA pass is recorded + in project-root `design-qa.md` with `final result: passed`. + +## Verification + +- `corepack pnpm install --frozen-lockfile` — passed with pinned pnpm `10.33.4`. +- `corepack pnpm run typecheck` — passed. +- `corepack pnpm exec vitest run tests/unit/coding-chat-panel.test.tsx tests/unit/coding-conversation-timeline.test.tsx tests/unit/module-navigation.test.tsx tests/unit/app-module-provider-gate.test.tsx --maxWorkers=4` + — 4 files, 58 tests passed. +- Follow-up focused run: + `corepack pnpm exec vitest run tests/unit/coding-chat-panel.test.tsx tests/unit/coding-project-entry.test.ts tests/unit/module-navigation.test.tsx tests/unit/app-module-provider-gate.test.tsx --maxWorkers=4` + — 4 files, 48 tests passed. +- Final-copy focused run: + `corepack pnpm exec vitest run tests/unit/coding-chat-panel.test.tsx tests/unit/coding-project-entry.test.ts --maxWorkers=2` + — 2 files, 23 tests passed, including assertions that `已有项目` and + `选择一个项目继续` stay absent while real cards remain. +- `corepack pnpm exec eslint src/pages/Chat/CodingChatPanel.tsx tests/unit/coding-chat-panel.test.tsx tests/e2e/main-navigation.spec.ts` + — passed with no output. +- `corepack pnpm test` — 223 files passed; 1,863 tests passed and 3 skipped. +- `corepack pnpm run lint:check` — passed with 0 errors and the repository's 5 + pre-existing warnings in `src/pages/Home/index.tsx` and + `src/pages/Makelore/index.tsx`. +- `corepack pnpm run build:vite` — Renderer, Electron Main, Preload, and utility + worker production builds passed; only existing Browserslist, mixed-import, and + chunk-size warnings were emitted. +- `corepack pnpm exec playwright test tests/e2e/main-navigation.spec.ts --grep + "offers a create CTA|returns to the module chooser"` — 2 tests passed against + the built Electron app, covering empty and remembered-project entry states, + absent section/helper copy, horizontal card order, direct card entry, `/chat`, + and project-dialog launch. +- `git diff --check` — passed. +- Visual comparison: + `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore-v3.png` + plus the focused lower crop + `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore-v3-bottom.png` + — the user-identified composer/CTA and redundant-copy mismatches are resolved, + and no actionable P0/P1/P2 differences remain. + +## Follow-ups + +- During integration, semantically reconcile `App.tsx`, `Sidebar.tsx`, and the + Code-entry tests with the concurrently completed but uncommitted Plugin-rehoming + and project-gate-removal tasks; their branch tips contain no commits to merge or + cherry-pick from this feature worktree. + +## Promotion Candidates + +- Target: `.project-docs/30-worklog/current-state.md` and + `.project-docs/40-domain/business-rules.md`. + Proposal: record that entering Code with no active local project remains on + `/chat`, presents the branded hero, a standalone `新增项目` CTA, and real local + projects as an unlabeled horizontal card row. Creation and project-card entry + route through the single existing Sidebar flows; empty Conversations share the + same brand hero until their first entry. + Evidence: focused unit coverage, the passing Electron navigation regression, + production build, and the passed side-by-side visual QA. + Future impact: future routing or setup work must not restore the configuration + redirect or a second project-creation implementation. + Human confirmation: not required for intent because the user explicitly + requested this entry behavior; integration still must reconcile the overlapping + peer tasks semantically. diff --git a/README.md b/README.md index 770f081..c706250 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 - AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。 +- AI 编程默认入口:进入 Code 后保持在 `/chat`。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 - AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 - 后台生命周期:Main 统一维护模块活动状态与任务租约。隐藏窗口、离开模块和关闭开发浏览器会释放非必要连接;生成、Code 执行、下载与发布构建持有租约并在完成后释放。已写入 Pi 的 prompt 或上下文整理即使确认超过 10 秒,也会继续持有运行所有权与后台租约,直到迟到响应、权威事件、明确失败或用户清理使其收敛;此时同一 Conversation 不接受重叠 mutation,其他 Conversation 不受影响。各模块的后台连接、轮询和子进程必须通过同一生命周期入口登记。 diff --git a/src/App.tsx b/src/App.tsx index 3bd2cb7..bb430c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -218,7 +218,7 @@ function ProjectChatRoute() { if (!project) { if (!cancelled) { setResolvedProjectKey(null); - setResolvedRoute('config'); + setResolvedRoute('chat'); } return; } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index ab6e39a..1299efb 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -31,6 +31,10 @@ import { import { getAiModuleForPath } from '@/lib/ai-modules'; import { invokeIpc } from '@/lib/api-client'; import { getAuthUserDisplayName } from '@/lib/auth-user-display'; +import { + subscribeCodingProjectCreationRequest, + subscribeCodingProjectOpenRequest, +} from '@/lib/coding-project-entry'; import { WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT } from '@/lib/works-square-usage-events'; import { fetchWorksTokenUsage, type WorksTokenUsage } from '@/lib/works-square'; import { isWorksTokenUsageExhausted } from '@/lib/works-square-token-usage'; @@ -385,13 +389,18 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi }, [authUser, refreshTokenUsage]); - const openCreateProject = () => { + const openCreateProject = useCallback(() => { setNewProjectName(''); setNewProjectSelectedPath(''); setNewProjectDirectoryMode('use-selected-directory'); setCreateProjectError(null); setCreateDialogOpen(true); - }; + }, []); + + useEffect(() => { + if (!isProgrammingModule) return undefined; + return subscribeCodingProjectCreationRequest(openCreateProject); + }, [isProgrammingModule, openCreateProject]); const closeCreateProject = () => { if (creatingProject) return; @@ -411,11 +420,11 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi setCreateProjectError(null); }; - const showProjectEntryError = (project: CodingProjectSummary, message: string) => { + const showProjectEntryError = useCallback((project: CodingProjectSummary, message: string) => { setProjectEntryError({ project, message }); - }; + }, []); - const enterProject = async (project: CodingProjectSummary) => { + const enterProject = useCallback(async (project: CodingProjectSummary) => { const result = await loadProjectConfig(project.id); if (result.status !== 'valid' || !result.config) { showProjectEntryError(project, result.status === 'missing' ? '项目缺少必要的配置文件' : result.error ?? '项目配置无效'); @@ -423,16 +432,16 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi } await setActiveProject(project.id); navigate('/chat'); - }; + }, [loadProjectConfig, navigate, setActiveProject, showProjectEntryError]); - const readAndEnterProject = async (project: CodingProjectSummary) => { + const readAndEnterProject = useCallback(async (project: CodingProjectSummary) => { setProjectEntryError(null); try { await enterProject(project); } catch (error) { showProjectEntryError(project, error instanceof Error ? error.message : String(error)); } - }; + }, [enterProject, showProjectEntryError]); const confirmCreateProject = async () => { @@ -465,9 +474,17 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi } }; - const openProject = async (project: CodingProjectSummary) => { + const openProject = useCallback(async (project: CodingProjectSummary) => { await readAndEnterProject(project); - }; + }, [readAndEnterProject]); + + useEffect(() => { + if (!isProgrammingModule) return undefined; + return subscribeCodingProjectOpenRequest((projectId) => { + const project = visibleProjects.find((candidate) => candidate.id === projectId); + if (project) void openProject(project); + }); + }, [isProgrammingModule, openProject, visibleProjects]); const requestRemoveUnavailableProject = () => { if (!projectEntryError) return; diff --git a/src/lib/coding-project-entry.ts b/src/lib/coding-project-entry.ts new file mode 100644 index 0000000..4a737ec --- /dev/null +++ b/src/lib/coding-project-entry.ts @@ -0,0 +1,32 @@ +const CODING_PROJECT_CREATE_REQUEST_EVENT = 'makelore:coding-project-create-request'; +const CODING_PROJECT_OPEN_REQUEST_EVENT = 'makelore:coding-project-open-request'; + +export function requestCodingProjectCreation(): void { + if (typeof window === 'undefined') return; + window.dispatchEvent(new Event(CODING_PROJECT_CREATE_REQUEST_EVENT)); +} + +export function subscribeCodingProjectCreationRequest(listener: () => void): () => void { + if (typeof window === 'undefined') return () => undefined; + window.addEventListener(CODING_PROJECT_CREATE_REQUEST_EVENT, listener); + return () => window.removeEventListener(CODING_PROJECT_CREATE_REQUEST_EVENT, listener); +} + +export function requestCodingProjectOpen(projectId: string): void { + if (typeof window === 'undefined') return; + window.dispatchEvent(new CustomEvent(CODING_PROJECT_OPEN_REQUEST_EVENT, { + detail: { projectId }, + })); +} + +export function subscribeCodingProjectOpenRequest( + listener: (projectId: string) => void, +): () => void { + if (typeof window === 'undefined') return () => undefined; + const handleRequest = (event: Event) => { + const projectId = (event as CustomEvent<{ projectId?: unknown }>).detail?.projectId; + if (typeof projectId === 'string' && projectId.trim()) listener(projectId); + }; + window.addEventListener(CODING_PROJECT_OPEN_REQUEST_EVENT, handleRequest); + return () => window.removeEventListener(CODING_PROJECT_OPEN_REQUEST_EVENT, handleRequest); +} diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 143b2e1..e2c6e2f 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Bot, + ChevronRight, CircleAlert, + FolderKanban, + Plus, RefreshCw, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -33,6 +35,7 @@ import type { } from '@/types/coding-conversation'; import type { CodingConversationMetadata, + CodingProjectSummary, } from '@/types/coding-project'; import { CodingComposer } from './CodingComposer'; import { CodingChangesSummary } from './CodingChangesSummary'; @@ -40,10 +43,13 @@ import { CodingConversationSidebar } from './CodingConversationSidebar'; import { CodingConversationHeader } from './CodingConversationHeader'; import { CodingConversationTimeline } from './CodingConversationTimeline'; import { CodingInteractionPanel } from './CodingInteractionPanel'; +import { CodingWelcomeHero } from './CodingWelcomeHero'; import { createLocalConversationSnapshot } from './coding-chat-snapshot'; export interface CodingChatPanelProps { navigationDraft?: string; + onCreateProject?(): void; + onOpenProject?(projectId: string): void; onOpenProjectSettings?(): void; } @@ -107,10 +113,24 @@ function acceptedPromptCount( )).length; } +const PROJECT_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', { + month: 'short', + day: 'numeric', +}); + +function projectLastOpenedLabel(project: CodingProjectSummary): string { + const timestamp = Date.parse(project.lastOpenedAt); + if (!Number.isFinite(timestamp)) return '本地项目'; + return `最近打开 ${PROJECT_DATE_FORMATTER.format(timestamp)}`; +} + export function CodingChatPanel({ navigationDraft, + onCreateProject, + onOpenProject, onOpenProjectSettings, }: CodingChatPanelProps) { + const projects = useCodingWorkspaceStore((state) => state.projects); const activeProject = useCodingWorkspaceStore((state) => state.activeProject); const config = useCodingWorkspaceStore((state) => state.config); const conversations = useCodingWorkspaceStore((state) => state.conversations); @@ -592,18 +612,76 @@ export function CodingChatPanel({ if (!activeProject && workspaceLoadState !== 'loading') { return ( -
-
-
-
-

先选择一个编程项目

-

- 项目与智能体配置从本地读取,打开输入框不会提前启动 Pi 运行实例。 -

- +
+ ) : null} + + + + {projects.length > 0 ? ( +
+
+ {projects.map((project) => ( + + ))} +
+
+ ) : null}
); diff --git a/src/pages/Chat/CodingConversationTimeline.tsx b/src/pages/Chat/CodingConversationTimeline.tsx index e8db3cf..5c6efda 100644 --- a/src/pages/Chat/CodingConversationTimeline.tsx +++ b/src/pages/Chat/CodingConversationTimeline.tsx @@ -11,7 +11,6 @@ import { import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { - Bot, BookOpen, Check, CheckCircle2, @@ -64,6 +63,7 @@ import type { SubagentDetailsV1, } from '../../../shared/coding-conversation-contracts'; import { CodingAttachmentPreview } from './CodingAttachmentPreview'; +import { CodingWelcomeHero } from './CodingWelcomeHero'; const EMPTY_NODES: ConversationNode[] = []; const IDLE_RUN: ConversationRunState = { status: 'idle' }; @@ -1610,7 +1610,10 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli if (element.scrollTop <= 48) loadEarlier(); }} > -
+
{windowStart > 0 && (
diff --git a/src/pages/Chat/CodingWelcomeHero.tsx b/src/pages/Chat/CodingWelcomeHero.tsx new file mode 100644 index 0000000..397220e --- /dev/null +++ b/src/pages/Chat/CodingWelcomeHero.tsx @@ -0,0 +1,29 @@ +import makeloreMark from '@/assets/logo.svg'; +import { cn } from '@/lib/utils'; + +export function CodingWelcomeHero({ + className, + headingLevel = 'h1', +}: { + className?: string; + headingLevel?: 'h1' | 'h2'; +}) { + const Heading = headingLevel; + + return ( +
+ 麦洛 M 标识 + + 你想让麦洛和你一起构建什么? + +
+ ); +} diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index 5fbc514..6aa4e10 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -1,5 +1,9 @@ import { CodingChatPanel } from './CodingChatPanel'; import { useNavigate } from 'react-router-dom'; +import { + requestCodingProjectCreation, + requestCodingProjectOpen, +} from '@/lib/coding-project-entry'; function getNavigationDraft(state: unknown): string | undefined { if (!state || typeof state !== 'object') return undefined; @@ -24,6 +28,8 @@ export function Chat() {
navigate('/project-config')} />
diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index 0a9b53e..8967363 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -1,6 +1,86 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; +async function seedInactiveCodingProjects(resources: { rootDir: string; userDataDir: string }) { + const now = '2026-09-06T08:00:00.000Z'; + const older = '2026-09-04T08:00:00.000Z'; + const projects = [ + { + id: 'project-moonlight', + name: '月光小游戏', + path: join(resources.rootDir, '月光小游戏'), + createdAt: older, + updatedAt: now, + lastOpenedAt: now, + projectId: '11111111-1111-4111-8111-111111111111', + }, + { + id: 'project-storybook', + name: '故事绘本', + path: join(resources.rootDir, '故事绘本'), + createdAt: older, + updatedAt: older, + lastOpenedAt: older, + projectId: '22222222-2222-4222-8222-222222222222', + }, + ]; + + for (const project of projects) { + await mkdir(join(project.path, '.makelore'), { recursive: true }); + await mkdir(join(project.path, 'knowledge'), { recursive: true }); + await writeFile(join(project.path, '.makelore', 'project.json'), JSON.stringify({ + schemaVersion: 2, + projectType: 'custom', + projectId: project.projectId, + initialized: true, + agents: [], + knowledgeDirectory: 'knowledge', + createdAt: project.createdAt, + updatedAt: project.updatedAt, + })); + } + + await writeFile(join(resources.userDataDir, 'makelore-projects.json'), JSON.stringify({ + projects: { + projects: Object.fromEntries(projects.map(({ projectId: _projectId, ...project }) => [project.id, project])), + activeProjectId: null, + }, + })); +} + test.describe('Makelore module navigation without setup flow', () => { + test('offers a create CTA and horizontal cards for remembered inactive Code projects', async ({ + electronTestResources, + launchElectronApp, + }) => { + await seedInactiveCodingProjects(electronTestResources); + const app = await launchElectronApp({ skipSetup: true }); + + try { + 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).toHaveURL(/\/chat$/); + await expect(page.getByRole('button', { name: '新增项目' })).toBeVisible(); + await expect(page.getByText('已有项目')).toHaveCount(0); + await expect(page.getByText('选择一个项目继续')).toHaveCount(0); + const projectCards = page.getByTestId('coding-existing-projects'); + await expect(projectCards).toBeVisible(); + await expect(projectCards.getByRole('button')).toHaveCount(2); + await expect(projectCards.getByRole('button').nth(0)).toContainText('月光小游戏'); + await expect(projectCards.getByRole('button').nth(1)).toContainText('故事绘本'); + + await projectCards.getByRole('button', { name: '进入项目 故事绘本' }).click(); + await expect(page.getByTestId('coding-chat-panel')).toBeVisible(); + await expect(page.getByTestId('coding-existing-projects')).toHaveCount(0); + await expect(page.getByTestId('sidebar-course-project-project-storybook')).toHaveAttribute('aria-current', 'page'); + } finally { + await closeElectronApp(app); + } + }); + test('keeps the three module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); @@ -139,8 +219,14 @@ test.describe('Makelore module navigation without setup flow', () => { await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); await page.getByTestId('ai-module-option-programming').click(); await expect(page.getByTestId('main-layout')).toBeVisible(); + await expect(page).toHaveURL(/\/chat$/); await expect(page.getByText('请先新建项目')).toHaveCount(0); - await expect(page.getByText('一念成光,万物可创。')).toBeVisible(); + await expect(page.getByRole('heading', { name: '你想让麦洛和你一起构建什么?' })).toBeVisible(); + await expect(page.getByRole('img', { name: '麦洛 M 标识' })).toBeVisible(); + await expect(page.getByRole('button', { name: '新增项目' })).toBeVisible(); + await expect(page.getByTestId('coding-project-starter')).toHaveCount(0); + await expect(page.getByTestId('coding-existing-projects')).toHaveCount(0); + await expect(page.getByText('一念成光,万物可创。')).toHaveCount(0); await expect(page.getByRole('img', { name: 'Makelore logo' })).toBeVisible(); await expect(page.getByTestId('sidebar-module-switcher-trigger')).toContainText('编程 Code'); await expect(page.getByTestId('sidebar-module-return-icon')).toBeVisible(); @@ -154,6 +240,12 @@ test.describe('Makelore module navigation without setup flow', () => { await expect(page.getByTestId('game-asset-browser')).toHaveCount(0); await expect(page.getByTestId('game-asset-review-summary')).toHaveCount(0); + await page.getByTestId('coding-start-project-button').click(); + const createProjectDialog = page.getByRole('dialog', { name: '新建项目' }); + await expect(createProjectDialog).toBeVisible(); + await createProjectDialog.getByRole('button', { name: '取消' }).first().click(); + await expect(page.getByRole('dialog', { name: '新建项目' })).toHaveCount(0); + await expect(page.getByTestId('sidebar-module-switcher')).toBeVisible(); const moduleHomeTrigger = page.getByTestId('sidebar-module-switcher-trigger'); await expect(moduleHomeTrigger).toHaveAttribute('aria-label', '返回首页'); diff --git a/tests/unit/coding-chat-panel.test.tsx b/tests/unit/coding-chat-panel.test.tsx index 9a04bf3..6fa574a 100644 --- a/tests/unit/coding-chat-panel.test.tsx +++ b/tests/unit/coding-chat-panel.test.tsx @@ -60,6 +60,12 @@ const project: CodingProjectSummary = { updatedAt: '2026-08-23T00:00:00.000Z', lastOpenedAt: '2026-08-23T00:00:00.000Z', }; +const secondProject: CodingProjectSummary = { + ...project, + id: 'project-2', + name: 'Second local project', + lastOpenedAt: '2026-08-22T00:00:00.000Z', +}; const agent: CodingProjectAgent = { id: 'agent-1', avatarId: 'default', @@ -217,6 +223,40 @@ describe('CodingChatPanel first Conversation', () => { expect(openProjectSettings).toHaveBeenCalledOnce(); }); + it('shows a branded create CTA and horizontal existing-project cards before a project is selected', async () => { + projectApi.list.mockResolvedValue({ projects: [project, secondProject], activeProjectId: null }); + const onCreateProject = vi.fn(); + const onOpenProject = vi.fn(); + const onOpenProjectSettings = vi.fn(); + const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + + render( + , + ); + + expect(await screen.findByTestId('coding-chat-empty-project')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '你想让麦洛和你一起构建什么?' })).toBeInTheDocument(); + expect(screen.getByRole('img', { name: '麦洛 M 标识' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '新增项目' })).toBeInTheDocument(); + expect(screen.getByTestId('coding-existing-projects')).toHaveTextContent('Local project'); + expect(screen.getByTestId('coding-existing-projects')).toHaveTextContent('Second local project'); + expect(screen.queryByText('已有项目')).not.toBeInTheDocument(); + expect(screen.queryByText('选择一个项目继续')).not.toBeInTheDocument(); + expect(screen.queryByTestId('coding-project-starter')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '项目与插件设置' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('coding-start-project-button')); + expect(onCreateProject).toHaveBeenCalledOnce(); + + fireEvent.click(screen.getByRole('button', { name: '进入项目 Second local project' })); + expect(onOpenProject).toHaveBeenCalledWith(secondProject.id); + expect(onOpenProjectSettings).not.toHaveBeenCalled(); + }); + 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 }); @@ -233,6 +273,8 @@ describe('CodingChatPanel first Conversation', () => { const view = render(); const textbox = await screen.findByRole('textbox'); + expect(screen.getByRole('heading', { name: '你想让麦洛和你一起构建什么?' })).toBeInTheDocument(); + expect(screen.getByRole('img', { name: '麦洛 M 标识' })).toBeInTheDocument(); expect(textbox).toBeEnabled(); expect(projectApi.create).toHaveBeenCalledOnce(); expect(conversationApi.snapshot).toHaveBeenCalledWith(conversation.id); @@ -940,7 +982,7 @@ describe('CodingChatPanel first Conversation', () => { target: { files }, }); expect(await screen.findByText('每条消息最多添加 16 张图片。')).toBeInTheDocument(); - expect(screen.getAllByRole('img')).toHaveLength(16); + expect(screen.getAllByRole('img', { name: /^image-\d+\.png$/u })).toHaveLength(16); expect(codingConversationStore.getState().entriesByConversationId[conversation.id]) .toMatchObject({ loadState: 'live', error: null }); await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled()); diff --git a/tests/unit/coding-project-entry.test.ts b/tests/unit/coding-project-entry.test.ts new file mode 100644 index 0000000..f2db7c5 --- /dev/null +++ b/tests/unit/coding-project-entry.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + requestCodingProjectCreation, + requestCodingProjectOpen, + subscribeCodingProjectCreationRequest, + subscribeCodingProjectOpenRequest, +} from '@/lib/coding-project-entry'; + +describe('coding project entry events', () => { + it('delivers project creation requests and removes the listener on unsubscribe', () => { + const listener = vi.fn(); + const unsubscribe = subscribeCodingProjectCreationRequest(listener); + + requestCodingProjectCreation(); + expect(listener).toHaveBeenCalledOnce(); + + unsubscribe(); + requestCodingProjectCreation(); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('delivers the selected project id and ignores requests after unsubscribe', () => { + const listener = vi.fn(); + const unsubscribe = subscribeCodingProjectOpenRequest(listener); + + requestCodingProjectOpen('project-2'); + expect(listener).toHaveBeenCalledWith('project-2'); + + unsubscribe(); + requestCodingProjectOpen('project-3'); + expect(listener).toHaveBeenCalledOnce(); + }); +});