fix: refine coding project landing actions
This commit is contained in:
@@ -15,10 +15,13 @@
|
||||
- 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 existing
|
||||
bottom-composer hierarchy shown in the supplied Codex reference.
|
||||
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.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
@@ -27,7 +30,8 @@
|
||||
- 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.
|
||||
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.
|
||||
@@ -40,12 +44,16 @@
|
||||
|
||||
- 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, a large centered
|
||||
guide sentence, and a low, wide project starter modeled on the supplied Codex
|
||||
composition while retaining the Makelore sidebar and light visual system.
|
||||
- Every project-starter action reuses the Sidebar's existing project-creation
|
||||
dialog through a renderer-local event bridge; the secondary action opens the
|
||||
existing project/settings route.
|
||||
- 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 by an overflow-safe horizontal row of existing projects.
|
||||
- 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
|
||||
@@ -58,7 +66,10 @@
|
||||
- `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.
|
||||
- `corepack pnpm test` — 222 files passed; 1,861 tests passed and 3 skipped.
|
||||
- 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.
|
||||
- `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`.
|
||||
@@ -66,12 +77,16 @@
|
||||
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
|
||||
"returns to the module chooser"` — passed against the built Electron app,
|
||||
including `/chat`, branded hero, starter visibility, and project-dialog launch.
|
||||
"offers a create CTA|returns to the module chooser"` — 2 tests passed against
|
||||
the built Electron app, covering empty and remembered-project entry states,
|
||||
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.png`
|
||||
— no actionable P0/P1/P2 differences.
|
||||
`/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore-v2.png`
|
||||
plus the focused lower crop
|
||||
`/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore-v2-bottom.png`
|
||||
— the user-identified composer/CTA mismatch is resolved and no actionable
|
||||
P0/P1/P2 differences remain.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
@@ -79,17 +94,16 @@
|
||||
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.
|
||||
- Recheck the starter's `项目与插件设置` label after those peer changes establish
|
||||
their final settings terminology.
|
||||
|
||||
## 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 project starter, and routes all creation actions
|
||||
through the single existing Sidebar flow; empty Conversations share the same
|
||||
brand hero until their first entry.
|
||||
`/chat`, presents the branded hero, a standalone `新增项目` CTA, and real local
|
||||
projects as a 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
|
||||
|
||||
@@ -16,7 +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 编程默认入口:进入 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 不受影响。各模块的后台连接、轮询和子进程必须通过同一生命周期入口登记。
|
||||
|
||||
49
design-qa.md
49
design-qa.md
@@ -1,47 +1,52 @@
|
||||
**Comparison Target**
|
||||
|
||||
- Source visual truth: `/var/folders/m7/x9ry3xgx4x50fxb20_0108tr0000gn/T/codex-clipboard-58e2cc35-7a17-4951-8bbc-53fee12d5246.png`
|
||||
- Implementation screenshot: `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/makelore-coding-default.png`
|
||||
- Combined comparison: `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore.png`
|
||||
- State: reference start screen and Makelore Code with no active local project.
|
||||
- Viewport: implementation CSS viewport 1280 x 800 at device scale factor 1. The source is 908 x 835 pixels with unknown capture density. For full-view comparison, the source was aspect-fit onto a 1280 x 800 white canvas and the implementation was kept at its native 1280 x 800 size; both canvases were then placed side by side.
|
||||
- Source visual truth: `/var/folders/m7/x9ry3xgx4x50fxb20_0108tr0000gn/T/codex-clipboard-58e2cc35-7a17-4951-8bbc-53fee12d5246.png`, superseded at the lower action area by the user's explicit follow-up: use a standalone `新增项目` CTA and horizontally arranged existing-project cards, not a conversation box.
|
||||
- Implementation screenshot: `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/makelore-coding-default-v2.png`
|
||||
- Full-view comparison: `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore-v2.png`
|
||||
- Focused lower-region comparison: `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore-v2-bottom.png`
|
||||
- State: Codex reference start screen and Makelore Code with two remembered local projects but no active project.
|
||||
- Viewport: implementation CSS viewport 1280 x 800 at device scale factor 1. The source is 908 x 835 pixels with unknown capture density. For full-view comparison, the source was aspect-fit onto a 1280 x 800 white canvas and the implementation remained at its native 1280 x 800 size. The focused comparison uses a 400-pixel-tall lower crop from each normalized content region.
|
||||
|
||||
**Findings**
|
||||
|
||||
- No actionable P0, P1, or P2 differences remain.
|
||||
- Fonts and typography: both screens use a restrained system sans-serif hierarchy. The Makelore prompt has the same single-line, medium-weight emphasis and remains comfortably readable without wrapping at the verified viewport.
|
||||
- Spacing and layout rhythm: the M mark and guide sentence form one centered hero group, while the project starter stays anchored near the bottom with proportions close to the reference composer. The persistent Makelore sidebar is an intentional product-shell constraint rather than design drift.
|
||||
- Colors and visual tokens: the implementation keeps Makelore's existing light background, muted neutral surfaces, dark foreground, and subtle elevation. It does not introduce a conflicting theme or decorative palette.
|
||||
- Image quality and asset fidelity: the hero uses the repository's existing vector Makelore M asset, with no placeholder, CSS drawing, or reconstructed logo.
|
||||
- Copy and content: the guide sentence is adapted to Makelore, and the bottom surface explains the required no-project action while preserving the reference's prompt-first reading order.
|
||||
- No actionable P0, P1, or P2 differences remain after applying the user's correction.
|
||||
- Fonts and typography: the M hero keeps the reference's restrained system-sans hierarchy and single-line display copy. The CTA, section label, project names, and recency metadata form a clear descending hierarchy without crowding or truncation at the verified viewport.
|
||||
- Spacing and layout rhythm: the hero retains generous white space. The standalone CTA is visually separated from the existing-project row, and fixed-width cards create a true horizontal browsing pattern without increasing page height as the catalog grows.
|
||||
- Colors and visual tokens: the CTA uses the existing Makelore brand orange and the cards use existing neutral surface tokens, preserving the single light product system and one-accent discipline.
|
||||
- Image quality and asset fidelity: the hero uses the repository's existing vector Makelore M asset. Project cards use the product's installed icon library; no placeholder image, CSS drawing, or reconstructed logo is present.
|
||||
- Copy and content: `新增项目`, `已有项目`, and `选择一个项目继续` state the two available paths directly. Project cards use real project names and last-opened timestamps from the local catalog.
|
||||
- Interaction and accessibility: the CTA opens the existing creation dialog; each card has a project-specific accessible name, keyboard focus treatment, pressed feedback, horizontal overflow, and routes through the Sidebar's existing validated project-entry behavior.
|
||||
|
||||
**Open Questions**
|
||||
|
||||
- None. The source does not include Makelore's persistent sidebar or its required project-selection state; those differences are intentional and necessary.
|
||||
- None. The source screenshot's composer remains useful only for overall composition; the user's later instruction is authoritative for the lower interaction model.
|
||||
|
||||
**Full-view Comparison Evidence**
|
||||
|
||||
- The combined comparison shows matching visual priority: small centered mark, large central guide copy, substantial white space, and a low, wide action surface.
|
||||
- The implementation preserves the reference's quiet density while making the unavailable-chat state actionable through the existing project creation flow.
|
||||
- The combined view preserves the reference's primary visual order: small centered mark, large guide sentence, ample whitespace, then the entry action area.
|
||||
- The Makelore-specific CTA and project cards occupy the same quiet lower zone without resembling an editable conversation surface.
|
||||
|
||||
**Focused Region Comparison Evidence**
|
||||
|
||||
- A separate crop was not needed: at native 1x capture, the M mark, guide sentence, starter metadata, CTA copy, icons, radii, and shadows are all legible in the full-view evidence.
|
||||
- The focused comparison confirms that the earlier composer-shaped block is gone. The orange CTA is isolated and unmistakably actionable, while the two project cards align on one horizontal baseline with consistent icon, title, date, radius, and elevation treatment.
|
||||
|
||||
**Comparison History**
|
||||
|
||||
- Pass 1: no actionable P0/P1/P2 findings. No visual fixes were required after the combined comparison.
|
||||
- Pass 1: the user identified a P1 interaction-model mismatch: the lower surface looked like a default conversation box and omitted horizontally arranged existing projects.
|
||||
- Fix: replaced the composer-shaped starter with a standalone `新增项目` CTA; added a horizontal, overflow-safe row backed by the real project catalog; connected card clicks to the existing Sidebar entry path; added unit and Electron coverage.
|
||||
- Pass 2: the revised full-view and focused comparisons show the corrected CTA/card hierarchy with no remaining actionable P0/P1/P2 findings.
|
||||
|
||||
**Implementation Checklist**
|
||||
|
||||
- [x] Replace the old project-configuration default with the Code conversation entry.
|
||||
- [x] Use the real Makelore M asset in the centered hero.
|
||||
- [x] Add a large guide sentence and bottom project starter.
|
||||
- [x] Connect the starter controls to the existing project dialog and settings route.
|
||||
- [x] Verify the Electron entry interaction and capture the no-project state.
|
||||
- [x] Keep the Makelore M mark and large centered guide sentence.
|
||||
- [x] Remove the composer-shaped no-project starter.
|
||||
- [x] Add one standalone `新增项目` primary CTA.
|
||||
- [x] Render real existing projects as horizontal cards.
|
||||
- [x] Reuse the existing creation and validated project-entry flows.
|
||||
- [x] Verify both the empty catalog and remembered-project states in Electron.
|
||||
|
||||
**Follow-up Polish**
|
||||
|
||||
- P3: after the concurrent project-dialog simplification lands, recheck the starter's secondary label so it reflects the final project/settings terminology exactly.
|
||||
- None required for this correction.
|
||||
|
||||
final result: passed
|
||||
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
import { getAiModuleForPath } from '@/lib/ai-modules';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { getAuthUserDisplayName } from '@/lib/auth-user-display';
|
||||
import { subscribeCodingProjectCreationRequest } from '@/lib/coding-project-entry';
|
||||
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';
|
||||
@@ -431,11 +434,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 ?? '项目配置无效');
|
||||
@@ -445,16 +448,16 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
navigate(result.config.initialized
|
||||
? '/chat'
|
||||
: projectConfigPath);
|
||||
};
|
||||
}, [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 () => {
|
||||
@@ -495,9 +498,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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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;
|
||||
@@ -10,3 +11,22 @@ export function subscribeCodingProjectCreationRequest(listener: () => void): ()
|
||||
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,11 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowUp,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
FolderKanban,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -36,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';
|
||||
@@ -49,6 +49,7 @@ import { createLocalConversationSnapshot } from './coding-chat-snapshot';
|
||||
export interface CodingChatPanelProps {
|
||||
navigationDraft?: string;
|
||||
onCreateProject?(): void;
|
||||
onOpenProject?(projectId: string): void;
|
||||
onOpenProjectSettings?(): void;
|
||||
}
|
||||
|
||||
@@ -112,11 +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);
|
||||
@@ -604,7 +618,7 @@ export function CodingChatPanel({
|
||||
>
|
||||
<CodingWelcomeHero className="min-h-[18rem] flex-1 pb-8 pt-6 sm:pb-12" />
|
||||
|
||||
<div className="mx-auto w-full max-w-[46rem] shrink-0">
|
||||
<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" />
|
||||
@@ -621,64 +635,60 @@ export function CodingChatPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-hidden rounded-[1.35rem] bg-surface-subtle shadow-[0_12px_36px_rgba(35,43,56,0.07)]" data-testid="coding-project-starter">
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring flex min-h-10 w-full items-center gap-2.5 px-4 py-2 text-left text-xs font-medium text-muted-foreground transition-colors duration-150 hover:bg-surface-tertiary hover:text-foreground disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
|
||||
onClick={onCreateProject}
|
||||
disabled={!onCreateProject}
|
||||
>
|
||||
<FolderKanban className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="font-semibold text-foreground">尚未选择项目</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>本地工作区</span>
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<div className="chat-composer-surface border p-3 sm:p-3.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="coding-start-project-button"
|
||||
className="focus-ring block min-h-[4.5rem] w-full rounded-xl px-1 pb-4 pt-1 text-left text-[15px] leading-6 text-muted-foreground transition-colors duration-150 hover:text-foreground disabled:cursor-default disabled:hover:text-muted-foreground"
|
||||
onClick={onCreateProject}
|
||||
disabled={!onCreateProject}
|
||||
>
|
||||
新建或打开一个本地项目,然后告诉麦洛你想构建什么
|
||||
</button>
|
||||
|
||||
<div className="flex min-h-8 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-8 rounded-lg px-2.5 text-xs font-medium"
|
||||
onClick={onCreateProject}
|
||||
disabled={!onCreateProject}
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4 text-brand" aria-hidden="true" />
|
||||
新建或打开项目
|
||||
</Button>
|
||||
{onOpenProjectSettings ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-8 rounded-lg px-2.5 text-xs font-medium text-muted-foreground"
|
||||
onClick={onOpenProjectSettings}
|
||||
>
|
||||
<Settings2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
项目与插件设置
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
className="ml-auto h-8 w-8 shrink-0 rounded-full bg-foreground p-0 text-background shadow-soft hover:bg-foreground/90"
|
||||
aria-label="新建或打开项目"
|
||||
onClick={onCreateProject}
|
||||
disabled={!onCreateProject}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
{projects.length > 0 ? (
|
||||
<section className="mt-7" aria-labelledby="coding-existing-projects-title">
|
||||
<div className="mb-3 flex items-end justify-between gap-4 px-0.5">
|
||||
<h2 id="coding-existing-projects-title" className="text-sm font-semibold tracking-[-0.01em]">
|
||||
已有项目
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">选择一个项目继续</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { CodingChatPanel } from './CodingChatPanel';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { requestCodingProjectCreation } from '@/lib/coding-project-entry';
|
||||
import {
|
||||
requestCodingProjectCreation,
|
||||
requestCodingProjectOpen,
|
||||
} from '@/lib/coding-project-entry';
|
||||
|
||||
function getNavigationDraft(state: unknown): string | undefined {
|
||||
if (!state || typeof state !== 'object') return undefined;
|
||||
@@ -26,6 +29,7 @@ export function Chat() {
|
||||
<CodingChatPanel
|
||||
navigationDraft={navigationDraft}
|
||||
onCreateProject={requestCodingProjectCreation}
|
||||
onOpenProject={requestCodingProjectOpen}
|
||||
onOpenProjectSettings={() => navigate('/project-config')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,84 @@
|
||||
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();
|
||||
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 });
|
||||
|
||||
@@ -143,7 +221,9 @@ test.describe('Makelore module navigation without setup flow', () => {
|
||||
await expect(page.getByText('请先新建项目')).toHaveCount(0);
|
||||
await expect(page.getByRole('heading', { name: '你想让麦洛和你一起构建什么?' })).toBeVisible();
|
||||
await expect(page.getByRole('img', { name: '麦洛 M 标识' })).toBeVisible();
|
||||
await expect(page.getByTestId('coding-project-starter')).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');
|
||||
|
||||
@@ -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',
|
||||
@@ -199,15 +205,17 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('shows a branded project starter before any local project is selected', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [], activeProjectId: null });
|
||||
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}
|
||||
/>,
|
||||
);
|
||||
@@ -215,13 +223,18 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
expect(await screen.findByTestId('coding-chat-empty-project')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '你想让麦洛和你一起构建什么?' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: '麦洛 M 标识' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('coding-project-starter')).toHaveTextContent('尚未选择项目');
|
||||
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.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: '项目与插件设置' }));
|
||||
expect(onOpenProjectSettings).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 () => {
|
||||
|
||||
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