merge: integrate coding default page
# Conflicts: # src/components/layout/Sidebar.tsx # tests/unit/coding-chat-panel.test.tsx
This commit is contained in:
@@ -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.
|
||||
@@ -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 不受影响。各模块的后台连接、轮询和子进程必须通过同一生命周期入口登记。
|
||||
|
||||
@@ -218,7 +218,7 @@ function ProjectChatRoute() {
|
||||
if (!project) {
|
||||
if (!cancelled) {
|
||||
setResolvedProjectKey(null);
|
||||
setResolvedRoute('config');
|
||||
setResolvedRoute('chat');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
32
src/lib/coding-project-entry.ts
Normal file
32
src/lib/coding-project-entry.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="flex min-h-0 flex-1 items-center justify-center bg-background p-6" data-testid="coding-chat-empty-project">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-subtle shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
|
||||
<Bot className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="mt-4 text-balance text-lg font-semibold">先选择一个编程项目</h1>
|
||||
<p className="mt-2 text-pretty text-sm leading-6 text-muted-foreground">
|
||||
项目与智能体配置从本地读取,打开输入框不会提前启动 Pi 运行实例。
|
||||
</p>
|
||||
<Button className="mt-5 min-h-10 rounded-xl" onClick={onOpenProjectSettings}>
|
||||
打开项目设置
|
||||
<section
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-background px-4 pb-3 pt-4 text-foreground sm:px-6 sm:pb-5"
|
||||
data-testid="coding-chat-empty-project"
|
||||
>
|
||||
<CodingWelcomeHero className="min-h-[18rem] flex-1 pb-8 pt-6 sm:pb-12" />
|
||||
|
||||
<div className="mx-auto w-full max-w-[52rem] shrink-0 pb-1">
|
||||
{workspaceError ? (
|
||||
<div className="mb-2 flex min-h-10 items-center gap-2 rounded-xl bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<p className="min-w-0 flex-1 text-pretty" role="alert">{workspaceError}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-8 shrink-0 rounded-lg px-2.5 text-destructive"
|
||||
onClick={() => void loadWorkspace().catch(() => undefined)}
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="coding-start-project-button"
|
||||
className="mx-auto flex h-12 min-w-44 rounded-xl border border-brand/20 bg-brand px-7 text-[15px] font-semibold text-primary-foreground shadow-[0_10px_24px_rgba(190,68,24,0.18)] transition-[transform,box-shadow,background-color] duration-200 hover:-translate-y-0.5 hover:bg-brand-hover hover:shadow-[0_14px_30px_rgba(190,68,24,0.22)] active:translate-y-0"
|
||||
onClick={onCreateProject}
|
||||
disabled={!onCreateProject}
|
||||
>
|
||||
<Plus className="mr-2 h-4.5 w-4.5" aria-hidden="true" />
|
||||
新增项目
|
||||
</Button>
|
||||
|
||||
{projects.length > 0 ? (
|
||||
<section className="mt-7" aria-label="项目列表">
|
||||
<div
|
||||
className="flex gap-3 overflow-x-auto pb-2"
|
||||
data-testid="coding-existing-projects"
|
||||
>
|
||||
{projects.map((project) => (
|
||||
<button
|
||||
type="button"
|
||||
key={project.id}
|
||||
data-testid={`coding-existing-project-${project.id}`}
|
||||
aria-label={`进入项目 ${project.name}`}
|
||||
className="motion-press group/project-card flex min-h-[6.75rem] w-64 shrink-0 flex-col justify-between rounded-2xl bg-surface-subtle p-4 text-left shadow-[0_8px_22px_rgba(35,43,56,0.055)] ring-1 ring-foreground/[0.055] transition-[transform,box-shadow,background-color] duration-200 hover:-translate-y-0.5 hover:bg-white hover:shadow-[0_14px_30px_rgba(35,43,56,0.09)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-2"
|
||||
onClick={() => onOpenProject?.(project.id)}
|
||||
disabled={!onOpenProject}
|
||||
>
|
||||
<span className="flex items-start justify-between gap-4">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-white text-brand shadow-[0_0_0_1px_rgba(35,43,56,0.06)] group-hover/project-card:bg-brand-soft">
|
||||
<FolderKanban className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<ChevronRight className="mt-1 h-4 w-4 text-muted-foreground/55 transition-[transform,color] duration-200 group-hover/project-card:translate-x-0.5 group-hover/project-card:text-foreground" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="mt-4 min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-foreground">{project.name}</span>
|
||||
<time
|
||||
dateTime={project.lastOpenedAt}
|
||||
className="mt-1 block text-xs text-muted-foreground"
|
||||
>
|
||||
{projectLastOpenedLabel(project)}
|
||||
</time>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[50rem] flex-col gap-8">
|
||||
<div className={cn(
|
||||
'mx-auto flex w-full max-w-[50rem] flex-col gap-8',
|
||||
nodes.length === 0 && 'min-h-full',
|
||||
)}>
|
||||
{windowStart > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1632,15 +1635,10 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli
|
||||
/>
|
||||
))}
|
||||
{nodes.length === 0 && (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center px-6 py-16 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-surface-subtle">
|
||||
<Bot className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-balance text-base font-semibold">开始一条对话</h2>
|
||||
<p className="mt-1.5 text-pretty text-sm leading-6 text-muted-foreground">
|
||||
Pi 会连续展示思考和工具进度,处理完成后自动收纳过程并给出结论。
|
||||
</p>
|
||||
</div>
|
||||
<CodingWelcomeHero
|
||||
headingLevel="h2"
|
||||
className="min-h-[18rem] flex-1 px-6 pb-12 pt-6 sm:pb-16"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
29
src/pages/Chat/CodingWelcomeHero.tsx
Normal file
29
src/pages/Chat/CodingWelcomeHero.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
data-testid="coding-welcome-hero"
|
||||
className={cn('flex flex-col items-center justify-center text-center', className)}
|
||||
>
|
||||
<img
|
||||
src={makeloreMark}
|
||||
alt="麦洛 M 标识"
|
||||
className="h-auto w-12 select-none sm:w-[3.25rem]"
|
||||
draggable={false}
|
||||
/>
|
||||
<Heading className="mt-6 max-w-2xl text-balance text-[clamp(1.65rem,2.45vw,2.1rem)] font-medium leading-[1.28] tracking-[-0.04em] text-foreground">
|
||||
你想让麦洛和你一起构建什么?
|
||||
</Heading>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<div data-testid="chat-operation-page" className="flex h-[calc(100%_+_3rem)] min-h-0 -m-6 overflow-hidden bg-background">
|
||||
<CodingChatPanel
|
||||
navigationDraft={navigationDraft}
|
||||
onCreateProject={requestCodingProjectCreation}
|
||||
onOpenProject={requestCodingProjectOpen}
|
||||
onOpenProjectSettings={() => navigate('/project-config')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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', '返回首页');
|
||||
|
||||
@@ -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(
|
||||
<CodingChatPanel
|
||||
onCreateProject={onCreateProject}
|
||||
onOpenProject={onOpenProject}
|
||||
onOpenProjectSettings={onOpenProjectSettings}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<CodingChatPanel />);
|
||||
|
||||
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());
|
||||
|
||||
33
tests/unit/coding-project-entry.test.ts
Normal file
33
tests/unit/coding-project-entry.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user