feat(onboard/parity): UI 一键接入面板——先计划预览、人确认后接入,四个入口接线(P8g) #41

Merged
lanrtop merged 3 commits from feat/agent-client-parity-p8g into master 2026-10-04 02:24:49 +08:00
14 changed files with 1625 additions and 96 deletions
Showing only changes of commit 5b6c197a74 - Show all commits

View File

@ -331,8 +331,8 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。
- review: "2026-10-03 独立只读审查 bd4ebaf,结论 fix_then_merge,10 条发现处置:M1 [中] MCP 入参原样落库(正斜杠、尾部分隔符、长路径前缀、8.3、`..`),scan_unregistered_projects 按字面比较会自相矛盾 → 已修:本体对 Windows 目录 canonicalize 后去长路径前缀落库,register_project_at 回执 path 为落库值、名称缺省按规范形式取目录名(相对路径不 canonicalize,交本体拒绝);scan 的已登记判定与父目录去重改用 registration_path_key。M2 [中] UI 不选目录的概念占位被取消,超出 P8f 范围且与 mcp-server 模块记录的用户用法冲突 → 已恢复原行为:本体新增 RootRequirement,UI 入口有效路径为空时只写 profile+workspace、不校验目录、不查重复,MCP 入口仍拒绝;是否改为显式占位概念交统筹会话问用户(需求澄清),本 PR 不预设答案。L1 [低] 已有登记两列都比,残留列误拦 → 已修:按 platform 只取有效根目录(registration_root),查询补 platform 列。L2 [低] 8.3、`..`、末尾点等漏判 → 已修:新目录在写事务外另算 canonical 键,两键比对已有行的字面键(已有行不读磁盘,写锁内无 I/O)。L3 [低] mount_repo 检查在插 profile 之后且无事务 → 已修:抽 mount_repo_records(连接注入),检查前移、整体 IMMEDIATE 事务;前端先 gitClone 再 mount 的白克隆属 UI 范围,未改。L4 [低] 路径比较键多份实现 → 部分修:registration_path_key 改为复用 mcp_inject::normalize_dsh_root + strip_verbatim_prefix(两函数改 pub(crate));normalize_trust_path、WSL 发行版查询与 UNC 换算的第三份拷贝统一抽 crate::path_key 转后续小修(会动 P4a/P8d 热点 mcp_inject.rs)。I1 [信息] update_project 仍可造同目录登记 → 转后续小修:UI 编辑整表提交,须「有效根目录实际变化才检查 + 排除自身」,否则存量重复登记(beast-mode)连改名都会被拦,单独做更稳。I2 [信息] project_root_with_conn 对 UNC 形式的 wsl_path 错误拼接(已有缺陷)→ 不在本 PR 改(mcp_inject.rs 热点),转后续小修;卡片与代码注释里「同口径」改为只指取列与 Linux 路径换算。I3 [信息] 重登目录遗留旧 context.json 无提示 → 已修:register_project_at 只读检查,project_id 不同时回执与摘要带 warning(不沿用旧 ID)。I4 [信息] 分发分支无测试 → 已修:分发抽成 register_project_call(conn, args),测试按 schema 声明的参数名构造入参;IMMEDIATE 并发仍无测试(见没验什么③)。"
- mcp_verify: "PR #37 合并(55dc637);P8e 合并后经全局 MCP 实测链路 register_project → plan_onboarding → onboard_project 得 status=done(证据同 P8e mcp_verify);同目录换写法再登记被拒并给出已有 project_id。"
### 📋 P8g — UI 一键接入
- status: todo
### 🔵 P8g — UI 一键接入
- status: in_progress
- complexity: M
- files: src/components/dashboard/OnboardPanel.tsx, src/components/dashboard/onboardView.ts, src/components/dashboard/onboardView.test.ts, src/components/dashboard/ProjectModal.tsx, src/components/dashboard/BatchAddModal.tsx, src/components/manage/NewProjectModal.tsx, src/components/dashboard/BlueprintModal.tsx, src/lib/commands.ts
- depends: P8e

File diff suppressed because one or more lines are too long

View File

@ -121,13 +121,27 @@ impl BlockReason {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct BlockedItem {
pub target: String,
pub reason: BlockReason,
pub detail: String,
}
/// 序列化时附派生字段 skippable(= BlockReason::skippable):UI 与 agent 据此决定是否给「跳过」,
/// 可跳过判定只在本体一处(P8g,AGENTS.md「边界单点实现」)。反序列化忽略该字段;digest 不含它。
impl Serialize for BlockedItem {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut item = serializer.serialize_struct("BlockedItem", 4)?;
item.serialize_field("target", &self.target)?;
item.serialize_field("reason", &self.reason)?;
item.serialize_field("detail", &self.detail)?;
item.serialize_field("skippable", &self.reason.skippable())?;
item.end()
}
}
/// 本机侧(不进仓库)的预计动作:token、稳定入口、三端客户端配置。
/// status:noop(已就绪)| apply(apply 时会写)| not_installed | not_applicable | unavailable
/// | manual_kept | other_project | needs_human(后三者阻断确认,见 machine_step_blocker)。
@ -1040,6 +1054,28 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// 阻断项序列化带派生字段 skippable(P8g 界面据此给「跳过」,不在前端复制判定);反序列化忽略它。
#[test]
fn blocked_item_serializes_skippable_from_reason() {
for (reason, skippable) in [
(BlockReason::ManualEntry, true),
(BlockReason::SharedKey, true),
(BlockReason::MixedFile, false),
(BlockReason::IdentityConflict, false),
(BlockReason::NeedsHuman, false),
] {
let item = BlockedItem {
target: "t".into(),
reason,
detail: "d".into(),
};
let value = serde_json::to_value(&item).unwrap();
assert_eq!(value["skippable"], skippable, "{reason:?}");
assert_eq!(value["target"], "t");
assert_eq!(serde_json::from_value::<BlockedItem>(value).unwrap(), item);
}
}
/// 身份冲突、混合文件与需人工处理的项不可跳过(P8d 审查 ②):即使列进 skip_targets 也照旧阻断。
#[test]
fn identity_conflict_and_needs_human_cannot_be_skipped() {

View File

@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { OverviewPage } from "./components/overview/OverviewPage";
import { Dashboard } from "./components/dashboard/Dashboard";
import { ProjectModal } from "./components/dashboard/ProjectModal";
import { OnboardPanel } from "./components/dashboard/OnboardPanel";
import { ResourcesPage } from "./components/resources/ResourcesPage";
import { ToolsPage } from "./components/tools/ToolsPage";
import { SettingsPage } from "./components/settings/SettingsPage";
@ -75,6 +76,7 @@ export default function App() {
</main>
<ProjectModal />
<OnboardPanel />
<Toast />
</div>
);

View File

@ -1,10 +1,47 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { open } from "@tauri-apps/plugin-dialog";
import { batchCreateProjects, getSpaces, scanSubfolders, type SubFolder } from "../../lib/commands";
import { batchCreateProjects, getSpaces, scanSubfolders, type OnboardStatus, type SubFolder } from "../../lib/commands";
import { useUIStore } from "../../store/ui";
import { Dialog } from "../ui/Dialog";
/** 本次添加成功的项目:批量只给入口、不自动接入(P8g),每个项目各自预览确认 */
interface AddedProject {
id: string;
name: string;
/** 本次面板里的接入结果;cancelled = 打开后未执行接入 */
onboard?: OnboardStatus | "cancelled";
}
const ONBOARD_STATUS_LABEL: Record<OnboardStatus | "cancelled", string> = {
done: "✓ 接入完成",
partial: "⚠ 部分完成",
failed: "✗ 预检失败",
refused: "⚠ 已拒绝,需重新确认",
busy: "… 正在接入中",
cancelled: "未接入",
};
function AddedProjects({ added, onOnboard }: { added: AddedProject[]; onOnboard: (project: AddedProject) => void }) {
return (
<div className="px-6 py-4 space-y-2">
<p className="text-sm text-gray-700">已添加 {added.length} 个项目。一键接入需逐个预览并确认,不会自动写入子项目:</p>
{added.map((project) => (
<div key={project.id} className="flex items-center gap-3 border border-gray-100 rounded-lg px-3 py-2">
<span className="flex-1 min-w-0 truncate text-sm text-gray-800">{project.name}</span>
<span className="text-xs text-gray-500">{project.onboard ? ONBOARD_STATUS_LABEL[project.onboard] : "未接入"}</span>
<button
onClick={() => onOnboard(project)}
className="px-3 py-1 rounded-lg border border-blue-200 text-xs text-blue-700 hover:bg-blue-50 shrink-0"
>
{project.onboard === "done" ? "重新检查" : "一键接入"}
</button>
</div>
))}
</div>
);
}
// Windows 路径转 WSL 路径(仅 /mnt/x/... 形式,不处理 UNC)
function winToWsl(p: string): string {
const norm = p.replace(/\\/g, "/");
@ -22,7 +59,19 @@ interface FolderItem extends SubFolder {
export function BatchAddModal({ onClose }: { onClose: () => void }) {
const showToast = useUIStore((s) => s.showToast);
const openOnboard = useUIStore((s) => s.openOnboard);
const qc = useQueryClient();
const [added, setAdded] = useState<AddedProject[] | null>(null);
const onboardOne = (project: AddedProject) =>
openOnboard({
projectId: project.id,
projectName: project.name,
onClosed: (report) =>
setAdded((prev) =>
prev?.map((p) => (p.id === project.id ? { ...p, onboard: report?.status ?? p.onboard ?? "cancelled" } : p)) ?? prev,
),
});
const { data: spaces = [] } = useQuery({ queryKey: ["spaces"], queryFn: getSpaces });
const currentSpace = spaces.find((s) => s.is_current);
@ -103,7 +152,12 @@ export function BatchAddModal({ onClose }: { onClose: () => void }) {
} else {
showToast(`成功添加 ${selected.length} 个项目 ✓`);
}
onClose();
// 没有目录的登记(概念占位)不给接入入口
const created = payload
.filter((p) => !skipped.includes(p.id) && (p.win_path || p.wsl_path))
.map((p) => ({ id: p.id, name: p.name }));
if (created.length > 0) setAdded(created);
else onClose();
} catch (e) {
showToast(`❌ ${e}`);
} finally {
@ -113,6 +167,19 @@ export function BatchAddModal({ onClose }: { onClose: () => void }) {
const checkedCount = items.filter((i) => i.checked).length;
if (added) {
return (
<Dialog title="批量添加项目" onClose={onClose} size="lg">
<AddedProjects added={added} onOnboard={onboardOne} />
<div className="flex justify-end px-6 py-4 border-t border-gray-100">
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50">
完成
</button>
</div>
</Dialog>
);
}
return (
<Dialog title="批量添加项目" onClose={onClose} size="lg">
<div className="px-6 py-4 space-y-4">

View File

@ -20,7 +20,6 @@ import {
getBlueprintStatus,
syncBlueprintRules,
generateBlueprintPrompt,
applyOnboardingPack,
getCommitStats,
removeBlueprintBuff,
getBuffStatus,
@ -31,10 +30,10 @@ import {
type BlueprintStatus,
type SyncResult,
type BuffStatus,
type TemplateResult,
type HookActivation,
type CommitStats,
type OnboardReport,
} from "../../lib/commands";
import { useUIStore } from "../../store/ui";
interface Props {
projectName: string;
@ -54,19 +53,6 @@ const STATUS_CONFIG: Record<string, { color: string; bg: string; label: string }
abandoned: { color: "#52525B", bg: "#FAFAFA", label: "已退役" },
};
// 接入包模板分发五态(对应 onboarding.rs TemplateResult.action)
// label 为 null 时展示后端 detail(updated/appended/failed 的 detail 含版本信息)
const TEMPLATE_ACTION_STYLE: Record<
string,
{ icon: string; color: string; label: string | null }
> = {
written: { icon: "✓", color: "text-green-600", label: "已写入" },
updated: { icon: "↑", color: "text-blue-600", label: null },
appended: { icon: "+", color: "text-blue-600", label: null },
skipped: { icon: "○", color: "text-gray-400", label: "已存在" },
failed: { icon: "✗", color: "text-red-600", label: null },
};
const TASK_PREFIX: Record<string, string> = {
done: "✅",
in_progress: "🔵",
@ -1058,8 +1044,9 @@ function GovernancePanel({
const [buffError, setBuffError] = useState<string | null>(null);
const [buffInitPrompt, setBuffInitPrompt] = useState<string | null>(null);
const [buffInitCopied, setBuffInitCopied] = useState(false);
const [packTemplates, setPackTemplates] = useState<TemplateResult[] | null>(null);
const [packHook, setPackHook] = useState<HookActivation | null>(null);
/** 最近一次一键接入的结果摘要(详情在接入面板里看过) */
const [onboardSummary, setOnboardSummary] = useState<string | null>(null);
const openOnboard = useUIStore((s) => s.openOnboard);
const [promptMode, setPromptMode] = useState<"init" | "sync" | null>(null);
const [prompt, setPrompt] = useState("");
@ -1067,28 +1054,29 @@ function GovernancePanel({
const [promptError, setPromptError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleApplyBuff = async () => {
/** 接入面板关闭后:未执行接入不动;执行过则刷新 buff,尚无蓝图时给 init prompt */
const afterOnboard = async (report: OnboardReport | null) => {
if (!report) return;
setOnboardSummary(`${report.status}:${report.summary}`);
refetchBuff();
if (report.status !== "done" && report.status !== "partial") return;
// 只有尚无蓝图时才展示 init prompt;已有蓝图则 Buff 直接激活无需初始化
if (bpStatus?.status === "none") {
try {
setBuffInitPrompt(await generateBlueprintPrompt(projectPath, "init"));
} catch (e) {
setBuffError(String(e));
}
}
};
// 「应用接入包」改为一键接入:先 plan 预览、人确认后再 apply(P8g),与新建项目走同一面板
const handleApplyBuff = () => {
if (!projectId) return;
setBuffLoading(true);
setBuffError(null);
setBuffInitPrompt(null);
setPackTemplates(null);
setPackHook(null);
try {
const report = await applyOnboardingPack(projectPath, projectId);
setPackTemplates(report.templates);
setPackHook(report.hook);
refetchBuff();
// 只有尚无蓝图时才展示 init prompt;已有蓝图则 Buff 直接激活无需初始化
if (bpStatus?.status === "none") {
const p = await generateBlueprintPrompt(projectPath, "init").catch(() => null);
if (p) setBuffInitPrompt(p);
}
} catch (e) {
setBuffError(String(e));
} finally {
setBuffLoading(false);
}
setOnboardSummary(null);
openOnboard({ projectId, projectName, onClosed: (report) => void afterOnboard(report) });
};
const handleRemoveBuff = async () => {
@ -1098,8 +1086,7 @@ function GovernancePanel({
await removeBlueprintBuff(projectPath);
refetchBuff();
setBuffInitPrompt(null);
setPackTemplates(null);
setPackHook(null);
setOnboardSummary(null);
} catch (e) {
setBuffError(String(e));
} finally {
@ -1296,7 +1283,7 @@ function GovernancePanel({
)}
</div>
<p className="text-[11px] text-gray-500 leading-relaxed">
应用接入包:种入 AGENTS.md / lefthook / git-workflow 标准模板(不覆盖已有),并挂 buff 自动监听 git 提交、采集飞轮数据。
一键接入:先预览将写入的接入包文件(AGENTS.md / lefthook / git-workflow 等)与本机三端配置,确认后再写入,并挂 buff 自动监听 git 提交、采集飞轮数据。
</p>
{buffStatus?.last_sync_at && (
<p className="text-[10px] text-gray-400">上次同步:{buffStatus.last_sync_at}</p>
@ -1304,54 +1291,27 @@ function GovernancePanel({
{buffError && (
<p className="text-[11px] text-red-600 font-mono break-all">{buffError}</p>
)}
{packTemplates && (
<div className="space-y-0.5 pt-0.5">
<p className="text-[10px] text-gray-500 font-semibold">模板分发</p>
{packTemplates.map((t) => {
const style =
TEMPLATE_ACTION_STYLE[t.action] ?? TEMPLATE_ACTION_STYLE.failed;
return (
<div key={t.name}>
<p className="text-[10px] font-mono flex items-center gap-1">
<span className={style.color}>{style.icon}</span>
<span className="text-gray-600 truncate flex-1">{t.name}</span>
<span className="text-gray-400">{style.label ?? t.detail}</span>
</p>
{t.warning && (
<p className="text-[10px] text-amber-600 leading-relaxed pl-3">⚠ {t.warning}</p>
)}
</div>
);
})}
{packHook && (
<p className="text-[10px] font-mono flex items-center gap-1">
<span
className={
packHook.status === "activated"
? "text-green-600"
: packHook.status === "missing_binary"
? "text-amber-500"
: "text-red-500"
}
>
{packHook.status === "activated" ? "✓" : packHook.status === "missing_binary" ? "⚠" : "✗"}
</span>
<span className="text-gray-600 truncate flex-1">lefthook hook</span>
<span className="text-gray-400 truncate" title={packHook.detail}>
{packHook.detail}
</span>
</p>
)}
</div>
{onboardSummary && (
<p className="text-[10px] text-gray-500 break-all">最近一次一键接入:{onboardSummary}</p>
)}
{buffStatus?.status === "active" ? (
<button
onClick={handleRemoveBuff}
disabled={buffLoading}
className="w-full py-1.5 rounded-lg border border-red-200 text-red-600 text-xs hover:bg-red-50 disabled:opacity-50 transition-colors"
>
{buffLoading ? "处理中…" : "撤销 Buff"}
</button>
<div className="flex gap-2">
<button
onClick={handleApplyBuff}
disabled={buffLoading || !projectId}
title={!projectId ? "需要传入 projectId" : "重新计划并预览,确认后补齐缺失项"}
className="flex-1 py-1.5 rounded-lg border border-purple-200 text-purple-700 text-xs hover:bg-purple-50 disabled:opacity-50 transition-colors"
>
重新检查接入
</button>
<button
onClick={handleRemoveBuff}
disabled={buffLoading}
className="flex-1 py-1.5 rounded-lg border border-red-200 text-red-600 text-xs hover:bg-red-50 disabled:opacity-50 transition-colors"
>
{buffLoading ? "处理中…" : "撤销 Buff"}
</button>
</div>
) : (
<button
onClick={handleApplyBuff}
@ -1359,7 +1319,7 @@ function GovernancePanel({
title={!projectId ? "需要传入 projectId" : undefined}
className="w-full py-1.5 rounded-lg bg-purple-600 text-white text-xs hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{buffLoading ? "施加中…" : "⚡ 施加 Buff"}
⚡ 一键接入(先预览再确认)
</button>
)}
{/* 已有蓝图时施加 Buff:直接激活,无需初始化 */}

View File

@ -0,0 +1,189 @@
// 一键接入面板(agent-client-parity P8g):plan_onboarding 预览 → 人确认 → onboard_project → 分步结果。
// 全局挂载在 App,由 useUIStore.openOnboard 打开。判定全部来自后端,映射见 onboardView.ts。
import { useCallback, useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { onboardProject, planOnboarding, type OnboardReport, type OnboardingPlan } from "../../lib/commands";
import { useUIStore } from "../../store/ui";
import {
DEFAULT_SELECTION,
buildPlanView,
buildReportView,
confirmRequest,
selectionOptions,
setBypass,
toggleSkip,
type OnboardSelection,
} from "./onboardView";
import { PlanPreview, ReportResult } from "./OnboardSections";
type Phase =
| { kind: "planning" }
| { kind: "preview"; plan: OnboardingPlan; planned: OnboardSelection }
| { kind: "applying"; plan: OnboardingPlan; planned: OnboardSelection }
| { kind: "report"; report: OnboardReport }
| { kind: "error"; message: string };
export function OnboardPanel() {
const target = useUIStore((s) => s.onboardTarget);
if (!target) return null;
// key:换项目即重置面板状态
return <OnboardPanelBody key={target.projectId} />;
}
function OnboardPanelBody() {
const target = useUIStore((s) => s.onboardTarget)!;
const closeOnboard = useUIStore((s) => s.closeOnboard);
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
const [phase, setPhase] = useState<Phase>({ kind: "planning" });
const [selection, setSelection] = useState<OnboardSelection>(DEFAULT_SELECTION);
const lastReport = useRef<OnboardReport | null>(null);
const requestSeq = useRef(0);
const plan = useCallback(
async (chosen: OnboardSelection) => {
const seq = ++requestSeq.current;
setPhase({ kind: "planning" });
try {
const next = await planOnboarding(target.projectId, selectionOptions(chosen));
if (seq === requestSeq.current) setPhase({ kind: "preview", plan: next, planned: chosen });
} catch (e) {
if (seq === requestSeq.current) setPhase({ kind: "error", message: String(e) });
}
},
[target.projectId],
);
useEffect(() => {
void plan(DEFAULT_SELECTION);
}, [plan]);
const applyConfirmed = async () => {
if (phase.kind !== "preview") return;
const request = confirmRequest(phase.plan, phase.planned, selection);
if (!request) return;
const seq = ++requestSeq.current;
setPhase({ kind: "applying", plan: phase.plan, planned: phase.planned });
try {
const report = await onboardProject(request.projectId, request.expectedDigest, request.confirmed, request.options);
if (seq !== requestSeq.current) return;
lastReport.current = report;
setPhase({ kind: "report", report });
showToast(buildReportView(report).toast, 4000);
void qc.invalidateQueries({ queryKey: ["projects"] });
} catch (e) {
if (seq === requestSeq.current) setPhase({ kind: "error", message: String(e) });
}
};
/** refused 附当前计划:回到预览,按新计划重新确认(选项沿用后端拒绝时的那组) */
const reviewReplan = (next: OnboardingPlan) => {
if (phase.kind !== "report") return;
setPhase({ kind: "preview", plan: next, planned: selection });
};
const close = () => {
if (phase.kind === "applying") return; // 写入进行中不允许关闭,避免误以为已取消
requestSeq.current += 1;
const report = lastReport.current;
closeOnboard();
target.onClosed?.(report);
};
const title = `一键接入${target.projectName ? ` · ${target.projectName}` : ""}`;
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl mx-4 flex flex-col" style={{ maxHeight: "88vh" }}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
<h2 className="text-base font-semibold text-gray-900">{title}</h2>
<button
onClick={close}
disabled={phase.kind === "applying"}
className="text-gray-400 hover:text-gray-600 text-xl leading-none disabled:opacity-30"
aria-label="关闭"
>
×
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4 space-y-4 text-sm">
{phase.kind === "planning" && <p className="text-gray-400">正在生成接入计划(只读,不写任何文件)…</p>}
{phase.kind === "error" && (
<p className="text-red-600 font-mono text-xs break-all">❌ {phase.message}</p>
)}
{(phase.kind === "preview" || phase.kind === "applying") && (
<PlanPreview
view={buildPlanView(phase.plan, phase.planned, selection)}
disabled={phase.kind === "applying"}
onToggleSkip={(item) => setSelection((s) => toggleSkip(s, item))}
onBypass={(on) => setSelection((s) => setBypass(s, on))}
/>
)}
{phase.kind === "report" && (
<ReportResult view={buildReportView(phase.report)} onReviewReplan={reviewReplan} />
)}
</div>
<PanelFooter
phase={phase}
selection={selection}
onCancel={close}
onReplan={() => void plan(selection)}
onConfirm={() => void applyConfirmed()}
/>
</div>
</div>
);
}
const btnCls = "px-4 py-2 rounded-lg text-sm border transition-colors disabled:opacity-50";
function PanelFooter({
phase,
selection,
onCancel,
onReplan,
onConfirm,
}: {
phase: Phase;
selection: OnboardSelection;
onCancel: () => void;
onReplan: () => void;
onConfirm: () => void;
}) {
const view = phase.kind === "preview" ? buildPlanView(phase.plan, phase.planned, selection) : null;
return (
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-100 shrink-0">
{view?.confirm.reason && <p className="flex-1 text-xs text-amber-700">{view.confirm.reason}</p>}
{phase.kind === "applying" && (
<p className="flex-1 text-xs text-gray-500">正在接入:首次可能 30 秒以上,完成前请勿关闭…</p>
)}
{phase.kind === "report" ? (
<button onClick={onCancel} className={`${btnCls} border-gray-200 text-gray-700 hover:bg-gray-50`}>
关闭
</button>
) : (
<button
onClick={onCancel}
disabled={phase.kind === "applying"}
className={`${btnCls} border-gray-200 text-gray-600 hover:bg-gray-50`}
title="取消不写入任何文件"
>
取消
</button>
)}
{(phase.kind === "error" || (view && (view.needsReplan || view.blockingCount > 0))) && (
<button onClick={onReplan} className={`${btnCls} border-blue-200 text-blue-700 hover:bg-blue-50`}>
{phase.kind === "error" ? "重试" : "重新计划"}
</button>
)}
{(phase.kind === "preview" || phase.kind === "applying") && (
<button
onClick={onConfirm}
disabled={phase.kind === "applying" || !view?.confirm.enabled}
className={`${btnCls} border-transparent bg-blue-600 text-white hover:bg-blue-700`}
>
{phase.kind === "applying" ? "接入中…" : "确认接入"}
</button>
)}
</div>
);
}

View File

@ -0,0 +1,277 @@
// 一键接入面板的呈现块(P8g):计划预览与接入结果。只渲染 onboardView 的视图模型,不做判定。
import type { ReactNode } from "react";
import type { OnboardBlockedItem, OnboardingPlan } from "../../lib/commands";
import { PARITY_CHECKS, CHECK_LABEL, type OnboardTone, type PlanView, type ReportView } from "./onboardView";
const TONE_CLS: Record<OnboardTone, string> = {
ok: "text-green-700 bg-green-50 border-green-200",
info: "text-blue-700 bg-blue-50 border-blue-200",
muted: "text-gray-500 bg-gray-50 border-gray-200",
pending: "text-violet-700 bg-violet-50 border-violet-200",
warn: "text-amber-700 bg-amber-50 border-amber-200",
danger: "text-red-700 bg-red-50 border-red-200",
};
function ToneBadge({ tone, children }: { tone: OnboardTone; children: ReactNode }) {
return (
<span className={`inline-block shrink-0 text-[10px] px-1.5 py-0.5 rounded border ${TONE_CLS[tone]}`}>{children}</span>
);
}
function Section({ title, extra, children }: { title: string; extra?: ReactNode; children: ReactNode }) {
return (
<section className="space-y-1.5">
<div className="flex items-center gap-2">
<p className="text-xs font-semibold text-gray-500">{title}</p>
{extra}
</div>
{children}
</section>
);
}
function Row({ label, tone, badge, detail, mono }: { label: ReactNode; tone: OnboardTone; badge: string; detail?: string; mono?: boolean }) {
return (
<div className="flex items-start gap-2 text-xs">
<span className={`flex-1 min-w-0 truncate text-gray-700 ${mono ? "font-mono" : ""}`}>{label}</span>
{detail && <span className="max-w-[55%] text-gray-400 truncate" title={detail}>{detail}</span>}
<ToneBadge tone={tone}>{badge}</ToneBadge>
</div>
);
}
// ── 计划预览 ──────────────────────────────────────────────────────────────────
export function PlanPreview({
view,
disabled,
onToggleSkip,
onBypass,
}: {
view: PlanView;
disabled: boolean;
onToggleSkip: (item: OnboardBlockedItem) => void;
onBypass: (on: boolean) => void;
}) {
const { fileCounts } = view;
return (
<>
<p className="text-xs text-gray-500">
将写入 <span className="font-mono text-gray-700">{view.root}</span>
<span className="ml-2 text-gray-300 font-mono" title={view.digest}>digest {view.digest.slice(0, 8)}</span>
</p>
{view.blocked.length > 0 && (
<Section title="阻断项" extra={view.blockingCount > 0 && <ToneBadge tone="danger">{view.blockingCount} 项阻断确认</ToneBadge>}>
{view.blocked.map((row) => (
<div key={`${row.target}:${row.item.reason}`} className={`rounded-lg border p-2 space-y-1 ${TONE_CLS[row.tone]}`}>
<div className="flex items-center gap-2 text-xs">
<span className="font-mono flex-1 min-w-0 truncate">{row.target}</span>
<span>{row.reasonLabel}</span>
<ToneBadge tone={row.tone}>{row.stateLabel}</ToneBadge>
{row.toggleLabel && (
<button
onClick={() => onToggleSkip(row.item)}
disabled={disabled}
className="px-2 py-0.5 rounded border border-current text-[11px] hover:bg-white/60 disabled:opacity-50"
>
{row.toggleLabel}
</button>
)}
</div>
<p className="text-[11px] opacity-80 break-all">{row.detail}</p>
<p className="text-[11px] opacity-70">{row.hint}</p>
</div>
))}
</Section>
)}
<Section title="需要你留意">
<div className={`rounded-lg border p-2 space-y-1 ${TONE_CLS[view.bypass.tone]}`}>
<label className="flex items-center gap-2 text-xs">
{view.bypass.toggleable && (
<input
type="checkbox"
checked={view.bypass.selected}
disabled={disabled}
onChange={(e) => onBypass(e.target.checked)}
/>
)}
<span className="font-medium">{view.bypass.label}</span>
</label>
<p className="text-[11px] opacity-80">{view.bypass.detail}</p>
</div>
<div className={`rounded-lg border p-2 space-y-1 ${TONE_CLS[view.codexTrust.tone]}`}>
<p className="text-xs font-medium">{view.codexTrust.label}</p>
{view.codexTrust.detail && <p className="text-[11px] opacity-80 break-all">{view.codexTrust.detail}</p>}
</div>
</Section>
<Section
title={`项目文件(${view.files.length})`}
extra={<span className="text-[11px] text-gray-400">新建 {fileCounts.create} · 更新 {fileCounts.update} · 不变 {fileCounts.noop}</span>}
>
{view.files.map((file) => (
<div key={file.path} className="space-y-0.5">
<Row label={file.path} mono tone={file.tone} badge={file.actionLabel} />
{file.warning && <p className="text-[11px] text-amber-700 pl-2">⚠ {file.warning}</p>}
{file.diff && file.action !== "noop" && (
<details className="pl-2">
<summary className="text-[11px] text-gray-400 cursor-pointer">查看差异</summary>
<pre className="text-[10px] bg-gray-50 border border-gray-100 rounded p-2 overflow-x-auto max-h-48">{file.diff}</pre>
</details>
)}
</div>
))}
</Section>
<Section title="本机配置(不进仓库)">
{view.machineSteps.map((step) => (
<Row key={step.step} label={step.label} tone={step.tone} badge={step.statusLabel} detail={step.detail} />
))}
</Section>
{view.nextActions.length > 0 && (
<Section title="接入后由你完成(不阻断)">
{view.nextActions.map((action) => (
<Row key={action.action} label={action.label} tone={action.tone} badge="待办" detail={action.detail} />
))}
</Section>
)}
</>
);
}
// ── 接入结果 ──────────────────────────────────────────────────────────────────
function L1Matrix({ l1 }: { l1: NonNullable<ReportView["l1"]> }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-[11px] border-collapse">
<thead>
<tr className="text-gray-400">
<th className="text-left font-normal py-1 pr-2">检查项</th>
{l1.columns.map((column) => (
<th key={column.client} className="text-left font-normal py-1 px-1">
{column.label}
{column.version && <span className="ml-1 text-gray-300 font-mono">{column.version}</span>}
</th>
))}
</tr>
</thead>
<tbody>
{PARITY_CHECKS.map((check) => (
<tr key={check} className="border-t border-gray-50">
<td className="py-1 pr-2 text-gray-600">
{check} {CHECK_LABEL[check]}
</td>
{l1.columns.map((column) => {
const cell = column.cells[check];
return (
<td key={column.client} className="py-1 px-1" title={`${cell.evidenceLabel}:${cell.detail}`}>
<ToneBadge tone={cell.tone}>{cell.label}</ToneBadge>
</td>
);
})}
</tr>
))}
</tbody>
</table>
{l1.note && <p className="text-[10px] text-gray-400 mt-1">{l1.note}</p>}
</div>
);
}
export function ReportResult({
view,
onReviewReplan,
}: {
view: ReportView;
onReviewReplan: (plan: OnboardingPlan) => void;
}) {
const { commit } = view;
return (
<>
<div className={`rounded-lg border p-3 space-y-1 ${TONE_CLS[view.tone]}`}>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{view.statusLabel}</span>
<span className="text-[11px] opacity-70">用时 {view.durationLabel}</span>
</div>
<p className="text-xs">{view.summary}</p>
{view.reason && <p className="text-xs break-all">原因:{view.reason}</p>}
{view.replan && (
<button
onClick={() => onReviewReplan(view.replan!)}
className="mt-1 px-3 py-1 rounded border border-current text-xs hover:bg-white/60"
>
查看当前计划并重新确认
</button>
)}
</div>
{view.steps.length > 0 && (
<Section title="分步结果">
{view.steps.map((step) => (
<Row
key={step.step}
label={step.note ? `${step.label}(${step.note})` : step.label}
tone={step.tone}
badge={step.statusLabel}
detail={step.detail}
/>
))}
</Section>
)}
{view.l1 && (
<Section title="三端检查(L1 零模型)" extra={view.l1.allApplicableReady && <ToneBadge tone="ok">适用格全部就绪</ToneBadge>}>
<L1Matrix l1={view.l1} />
</Section>
)}
{commit.state === "available" && (
<Section title={commit.title}>
{commit.files.map((file) => (
<p key={file.path} className="text-xs font-mono text-gray-700">
<span className="text-gray-400 mr-2 whitespace-pre">{file.code}</span>
{file.path}
</p>
))}
{commit.otherWarning && <p className="text-[11px] text-amber-700">⚠ {commit.otherWarning}</p>}
{commit.suggestedMessage && (
<p className="text-[11px] text-gray-500">
建议提交信息:<span className="font-mono text-gray-700">{commit.suggestedMessage}</span>
</p>
)}
</Section>
)}
{commit.state === "unavailable" && <p className="text-[11px] text-gray-400">无法列出改动:{commit.reason}</p>}
{view.head && <p className={`text-[11px] ${view.head.unchanged ? "text-gray-400" : "text-amber-700"}`}>{view.head.label}</p>}
{(view.pendingUser.length > 0 || view.nextActions.length > 0) && (
<Section title="待你操作">
{view.pendingUser.map((row) => (
<Row key={row.item} label={row.item} tone={row.tone} badge={row.label} detail={row.detail} />
))}
{view.nextActions.map((action) => (
<Row key={action.action} label={action.label} tone={action.tone} badge="待办" detail={action.detail} />
))}
</Section>
)}
{view.completion.length > 0 && (
<details>
<summary className="text-xs text-gray-500 cursor-pointer">
完成判定明细({view.completion.filter((row) => row.ready).length}/{view.completion.length} 就绪)
</summary>
<div className="mt-1.5 space-y-1">
{view.completion.map((row) => (
<Row key={`${row.source}:${row.item}`} label={row.item} tone={row.tone} badge={row.label} detail={row.detail} />
))}
</div>
</details>
)}
<p className="text-[10px] text-gray-400">{view.l3}</p>
</>
);
}

View File

@ -19,6 +19,7 @@ export function ProjectModal() {
const editTarget = useUIStore((s) => s.editTarget);
const closeEdit = useUIStore((s) => s.closeEdit);
const showToast = useUIStore((s) => s.showToast);
const openOnboard = useUIStore((s) => s.openOnboard);
const qc = useQueryClient();
const { data: spaces = [] } = useQuery({ queryKey: ["spaces"], queryFn: getSpaces });
@ -93,6 +94,10 @@ const pickFolder = async (field: "wsl_path" | "win_path") => {
qc.invalidateQueries({ queryKey: ["projects"] });
qc.invalidateQueries({ queryKey: ["tagMaps"] });
closeEdit();
// create_project 本身无副作用;选了目录的新项目弹出接入预览(P8g),由人确认后才写入。不选目录的概念占位不接入
if (isNew && (form.win_path?.trim() || form.wsl_path?.trim())) {
openOnboard({ projectId: form.id!, projectName: form.name?.trim() });
}
} catch (e) {
showToast(`❌ ${e}`);
} finally {

View File

@ -0,0 +1,290 @@
import { describe, expect, it } from "vitest";
import type {
ClientParityL1Report,
OnboardBlockedItem,
OnboardReport,
OnboardingPlan,
ParityCell,
ParityCheck,
} from "../../lib/commands";
import {
DEFAULT_SELECTION,
buildPlanView,
buildReportView,
confirmRequest,
l1MatrixView,
selectionOptions,
setBypass,
toggleSkip,
type OnboardSelection,
} from "./onboardView";
const manualCodex: OnboardBlockedItem = {
target: ".codex/config.toml",
reason: "manual_entry",
detail: "已有手写的 lian-jing 条目",
skippable: true,
};
const mixedAgents: OnboardBlockedItem = {
target: "AGENTS.md",
reason: "mixed_file",
detail: "托管块外有未提交改动",
skippable: false,
};
function plan(overrides: Partial<OnboardingPlan> = {}): OnboardingPlan {
return {
project_id: "p1",
root: "E:\\code-project\\demo",
files: [
{ path: "AGENTS.md", action: "create", content_sha256: "a" },
{ path: ".claude/settings.json", action: "update", content_sha256: "b", before_sha256: "c" },
{ path: ".lian-jing/context.json", action: "noop", content_sha256: "d" },
],
machine_changes: [
{ step: "token", status: "apply", detail: "将生成项目 token" },
{ step: "claude", status: "noop", detail: "已配置" },
{ step: "codex", status: "not_installed", detail: "未找到 codex" },
{ step: "dsh", status: "not_applicable", detail: "控制面项目" },
],
blocked: [],
skipped: [],
bypass_permissions: true,
codex_trust: "untrusted",
next_actions: [{ action: "codex_trust", detail: "在 Codex 里信任 E:\\code-project\\demo" }],
digest: "digest-1",
...overrides,
};
}
const sel = (overrides: Partial<OnboardSelection> = {}): OnboardSelection => ({ ...DEFAULT_SELECTION, ...overrides });
describe("buildPlanView 阻断与确认", () => {
it("blocked 非空时确认禁用,并逐项列出文件与原因", () => {
const view = buildPlanView(plan({ blocked: [mixedAgents, manualCodex] }), sel(), sel());
expect(view.confirm.enabled).toBe(false);
expect(view.confirm.reason).toContain("2 项阻断");
expect(view.blockingCount).toBe(2);
expect(view.blocked.map((row) => [row.target, row.reasonLabel, row.detail, row.state])).toEqual([
["AGENTS.md", "混合文件:托管块外有未提交改动", "托管块外有未提交改动", "blocking"],
[".codex/config.toml", "手写同名条目", "已有手写的 lian-jing 条目", "blocking"],
]);
expect(view.blocked.every((row) => row.tone === "danger")).toBe(true);
expect(confirmRequest(plan({ blocked: [mixedAgents] }), sel(), sel())).toBeNull();
});
it("只有后端标 skippable 的项给「跳过」,不可跳过的项切换无效", () => {
const view = buildPlanView(plan({ blocked: [mixedAgents, manualCodex] }), sel(), sel());
expect(view.blocked[0].toggleLabel).toBeUndefined();
expect(view.blocked[0].hint).toContain("禁止字符串手术");
expect(view.blocked[1].toggleLabel).toBe("跳过");
expect(toggleSkip(sel(), mixedAgents)).toEqual(sel());
expect(toggleSkip(sel(), manualCodex).skipTargets).toEqual([".codex/config.toml"]);
expect(toggleSkip(sel({ skipTargets: [".codex/config.toml"] }), manualCodex).skipTargets).toEqual([]);
});
it("选了跳过但未重新计划时确认禁用;按跳过重新计划后可确认", () => {
const chosen = toggleSkip(sel(), manualCodex);
const pending = buildPlanView(plan({ blocked: [manualCodex] }), sel(), chosen);
expect(pending.needsReplan).toBe(true);
expect(pending.blocked[0].state).toBe("skip_pending");
expect(pending.confirm).toEqual({ enabled: false, reason: "选项已改:须按当前选择重新计划后才能确认" });
const replanned = buildPlanView(plan({ blocked: [], skipped: [manualCodex] }), chosen, chosen);
expect(replanned.confirm.enabled).toBe(true);
expect(replanned.blocked[0]).toMatchObject({ state: "skipped", tone: "muted", toggleLabel: "取消跳过" });
expect(replanned.blockingCount).toBe(0);
});
it("确认请求携带计划 digest 与生成计划时的选项", () => {
const planned = sel({ skipTargets: [".codex/config.toml", ".codex/config.toml"] });
const request = confirmRequest(plan({ skipped: [manualCodex], digest: "digest-xyz" }), planned, planned);
expect(request).toEqual({
projectId: "p1",
expectedDigest: "digest-xyz",
confirmed: true,
options: { bypass_permissions: true, skip_targets: [".codex/config.toml"] },
});
});
it("selectionOptions 去重排序,计划与应用两次逐字一致", () => {
expect(selectionOptions(sel({ skipTargets: ["b", "a", "b"] }))).toEqual({
bypass_permissions: true,
skip_targets: ["a", "b"],
});
});
});
describe("buildPlanView 灰色说明与单列项", () => {
it("not_installed / not_applicable 显示为灰色说明,不是红色失败", () => {
const view = buildPlanView(plan(), sel(), sel());
const byStep = Object.fromEntries(view.machineSteps.map((row) => [row.step, row]));
expect(byStep.codex).toMatchObject({ statusLabel: "未安装", tone: "muted" });
expect(byStep.dsh).toMatchObject({ statusLabel: "不适用", tone: "muted" });
expect(byStep.token).toMatchObject({ label: "项目 MCP token", statusLabel: "将写入", tone: "info" });
expect(view.machineSteps.some((row) => row.tone === "danger")).toBe(false);
expect(view.fileCounts).toEqual({ create: 1, update: 1, noop: 1 });
});
it("bypassPermissions 单独列出:将写入可取消,取消后须重新计划", () => {
const view = buildPlanView(plan(), sel(), sel());
expect(view.bypass).toMatchObject({ willWrite: true, toggleable: true, tone: "warn" });
expect(view.bypass.label).toContain("bypassPermissions");
const off = setBypass(sel(), false);
expect(buildPlanView(plan(), sel(), off).confirm.enabled).toBe(false);
const cancelled = buildPlanView(plan({ bypass_permissions: false }), off, off);
expect(cancelled.bypass).toMatchObject({ willWrite: false, selected: false, toggleable: true });
expect(cancelled.bypass.label).toBe("已按项目取消 bypassPermissions");
expect(confirmRequest(plan({ bypass_permissions: false }), off, off)?.options.bypass_permissions).toBe(false);
const existing = buildPlanView(plan({ bypass_permissions: false }), sel(), sel());
expect(existing.bypass).toMatchObject({ toggleable: false, tone: "muted" });
});
it("Codex 信任单独列出:未信任为「待你操作」而非阻断", () => {
const view = buildPlanView(plan(), sel(), sel());
expect(view.codexTrust).toMatchObject({ state: "untrusted", tone: "pending" });
expect(view.codexTrust.detail).toContain("信任");
expect(view.confirm.enabled).toBe(true);
expect(buildPlanView(plan({ codex_trust: "not_installed", next_actions: [] }), sel(), sel()).codexTrust.tone).toBe("muted");
});
});
function cell(state: ParityCell["state"], extra: Partial<ParityCell> = {}): ParityCell {
return { state, evidence: "protocol", detail: `${state} detail`, ...extra };
}
function cells(fill: (check: ParityCheck) => ParityCell): Record<ParityCheck, ParityCell> {
return Object.fromEntries((["A", "B", "C", "D", "E", "F"] as ParityCheck[]).map((c) => [c, fill(c)])) as Record<
ParityCheck,
ParityCell
>;
}
const l1: ClientParityL1Report = {
project_id: "p1",
tier: "l1",
probe_spec_version: 1,
profile: "project",
expected_route: "http://127.0.0.1:27190/mcp/projects/p1",
expected_tools: 12,
nonce: "n",
clients: [
{ client: "claude", version: "2.1.90", route: null, cells: cells(() => cell("ready")), duration_ms: 1 },
{
client: "codex",
version: "0.50",
route: null,
cells: cells((c) => (c < "C" ? cell("ready") : cell("fail", { cause: "codex_project_untrusted" }))),
duration_ms: 1,
},
{ client: "dsh", version: null, route: null, cells: cells(() => cell("not_installed")), duration_ms: 1 },
],
all_applicable_ready: false,
duration_ms: 3,
note: "",
};
function report(overrides: Partial<OnboardReport> = {}): OnboardReport {
return {
project_id: "p1",
status: "done",
summary: "9 步完成",
bypass_permissions: true,
steps: [
{ step: "project_files", status: "applied", detail: "写入 14 个文件" },
{ step: "dsh", status: "not_applicable", reason: "not_installed", detail: "未安装 DSH" },
{ step: "codex", status: "blocked", dep: "token", detail: "token 未就绪" },
],
completion: [
{ item: "Claude MCP 已配置", source: "clients", ready: true, detail: "", pending_user: false },
{ item: "Codex 信任", source: "l1", ready: false, detail: "待信任", pending_user: true },
],
l1,
l3: "L3 尚未提供",
written_files: ["AGENTS.md"],
repo_changes: {
state: "available",
status_porcelain: [],
diff_stat: "2 files changed",
onboard_files: [
{ path: "AGENTS.md", code: "??" },
{ path: ".claude/settings.json", code: " M" },
],
other_changes: 1,
},
head: { before: "abc", after: "abc", unchanged: true },
suggested_commit_message: "chore(lian-jing): 一键接入",
next_actions: [{ action: "commit", detail: "git add -- AGENTS.md" }],
duration_ms: 7900,
...overrides,
};
}
describe("buildReportView", () => {
it("分步结果:not_applicable 灰色、blocked 注明依赖", () => {
const view = buildReportView(report());
expect(view.steps.map((row) => [row.step, row.statusLabel, row.tone, row.note])).toEqual([
["project_files", "已写入", "ok", undefined],
["dsh", "不适用", "muted", "未安装"],
["codex", "未执行", "warn", "依赖「项目 MCP token」未完成"],
]);
expect(view.toast).toBe("一键接入接入完成 ✓");
expect(view.durationLabel).toBe("7.9 秒");
});
it("待你提交的 N 个文件与 Codex 信任单列,不计入完成", () => {
const view = buildReportView(report());
expect(view.commit).toMatchObject({ state: "available", title: "待你提交的 2 个文件", otherChanges: 1 });
expect(view.commit.state === "available" && view.commit.otherWarning).toContain("不要 git add -A");
expect(view.completion.map((row) => row.item)).toEqual(["Claude MCP 已配置"]);
expect(view.pendingUser).toEqual([
{ item: "Codex 信任", source: "l1", ready: false, label: "待你操作", tone: "pending", detail: "待信任" },
]);
expect(view.head).toMatchObject({ unchanged: true, tone: "ok" });
});
it("refused 附当前计划供重新确认,提示以 ❌ 开头且不报告改动", () => {
const replan = plan({ digest: "digest-2" });
const view = buildReportView(
report({ status: "refused", reason: "digest 不符", plan: replan, repo_changes: undefined, l1: undefined }),
);
expect(view.replan).toBe(replan);
expect(view.tone).toBe("warn");
expect(view.toast).toBe("❌ 一键接入已拒绝(零写入):digest 不符");
expect(view.commit).toEqual({ state: "none" });
expect(view.l1).toBeUndefined();
});
});
describe("l1MatrixView", () => {
it("未安装为灰色,Codex 未信任为「待你信任」而非红色失败", () => {
const view = l1MatrixView(l1);
const [claude, codex, dsh] = view.columns;
expect(claude.cells.F).toMatchObject({ label: "就绪", tone: "ok" });
expect(codex.cells.A.tone).toBe("ok");
expect(codex.cells.C).toMatchObject({ label: "待你信任", tone: "pending", cause: "codex_project_untrusted" });
expect(dsh.cells.A).toMatchObject({ label: "未安装", tone: "muted" });
expect(Object.values(dsh.cells).every((c) => c.tone === "muted")).toBe(true);
expect(dsh.label).toBe("DSH");
});
it("依赖步骤未完成的格注明步骤;真实失败为红色", () => {
const report = {
...l1,
clients: [
{
...l1.clients[0],
cells: cells((c) =>
c === "A" ? cell("blocked", { cause: "dependency", dep: "claude" }) : cell("fail", { cause: "auth_failed" }),
),
},
],
};
const [claude] = l1MatrixView(report).columns;
expect(claude.cells.A).toMatchObject({ label: "未检查:Claude Code 步骤未完成", tone: "warn" });
expect(claude.cells.F).toMatchObject({ label: "失败", tone: "danger" });
});
});

View File

@ -0,0 +1,671 @@
// 一键接入(agent-client-parity P8g)视图模型:把 OnboardingPlan / OnboardReport 转成界面要展示的结构。
// 只做呈现映射,不复制本体判定(AGENTS.md「边界单点实现」):阻断与否看 plan.blocked、能否跳过看
// blocked[].skippable、完成与否看 report.status 与 completion[].pending_user,全部由后端给出。
// 界面自己只管一件事:当前选择(bypass / 跳过项)与生成计划时所用的选择是否一致——不一致必须重新计划,
// 因为 onboardProject 须带同一组选项与 digest(否则后端 refused)。
import type {
ClientParityL1Report,
CodexTrust,
OnboardBlockReason,
OnboardBlockedItem,
OnboardCompletionItem,
OnboardFileAction,
OnboardNextAction,
OnboardOptions,
OnboardPendingFile,
OnboardReport,
OnboardStatus,
OnboardStepReport,
OnboardingPlan,
ParityCause,
ParityCell,
ParityCellState,
ParityCheck,
ParityEvidence,
} from "../../lib/commands";
/** 呈现色调。muted 是灰色说明(未安装 / 不适用 / 已跳过 / 不变),不是失败;pending 是「待你操作」 */
export type OnboardTone = "ok" | "info" | "muted" | "pending" | "warn" | "danger";
// ── 选项:计划与应用必须一致 ──────────────────────────────────────────────────
export interface OnboardSelection {
/** 勾选 = 保持默认:项目 .claude/settings.json 还没有 permissions 时写入 bypassPermissions */
bypassPermissions: boolean;
/** 人逐项选「跳过」的阻断项 target(只对 skippable 的项生效) */
skipTargets: string[];
}
export const DEFAULT_SELECTION: OnboardSelection = { bypassPermissions: true, skipTargets: [] };
function normalizeTargets(targets: readonly string[]): string[] {
return [...new Set(targets)].sort();
}
/** 选择 → 传给 planOnboarding / onboardProject 的选项(跳过项去重排序,两次调用逐字一致) */
export function selectionOptions(selection: OnboardSelection): OnboardOptions {
return {
bypass_permissions: selection.bypassPermissions,
skip_targets: normalizeTargets(selection.skipTargets),
};
}
export function sameSelection(a: OnboardSelection, b: OnboardSelection): boolean {
if (a.bypassPermissions !== b.bypassPermissions) return false;
const x = normalizeTargets(a.skipTargets);
const y = normalizeTargets(b.skipTargets);
return x.length === y.length && x.every((target, i) => target === y[i]);
}
/** 切换某阻断项的「跳过」。后端没标 skippable 的项不响应(只能中止或处理后重新计划) */
export function toggleSkip(selection: OnboardSelection, item: OnboardBlockedItem): OnboardSelection {
if (!item.skippable) return selection;
const has = selection.skipTargets.includes(item.target);
return {
...selection,
skipTargets: has
? selection.skipTargets.filter((target) => target !== item.target)
: [...selection.skipTargets, item.target],
};
}
export function setBypass(selection: OnboardSelection, bypassPermissions: boolean): OnboardSelection {
return { ...selection, bypassPermissions };
}
// ── 文案映射(只翻译后端给的枚举,不推断) ─────────────────────────────────────
interface Badge {
label: string;
tone: OnboardTone;
}
const FILE_ACTION: Record<OnboardFileAction, Badge> = {
create: { label: "新建", tone: "info" },
update: { label: "更新", tone: "info" },
noop: { label: "不变", tone: "muted" },
};
const BLOCK_REASON_LABEL: Record<OnboardBlockReason, string> = {
mixed_file: "混合文件:托管块外有未提交改动",
identity_conflict: "身份冲突:目录已属于别的项目",
manual_entry: "手写同名条目",
shared_key: "共享项目键:生效条目属于别的项目",
needs_human: "需人工处理",
};
/** 不可跳过的项怎么办(可跳过的项统一给「跳过 / 中止」) */
const BLOCK_REASON_HINT: Record<OnboardBlockReason, string> = {
mixed_file: "先在子项目提交或丢弃块外改动,再重新计划(禁止字符串手术);或中止",
identity_conflict: "核对 .lian-jing/context.json 的归属后重新计划;或中止",
manual_entry: "处理后重新计划;或中止",
shared_key: "处理后重新计划;或中止",
needs_human: "按说明处理后重新计划;或中止",
};
export const STEP_LABEL: Record<string, string> = {
identity: "项目身份",
project_files: "项目文件",
git_hooks: "Git hooks(lefthook install)",
product_group_buff: "产品组与 buff",
token: "项目 MCP token",
shim: "稳定 helper 入口",
claude: "Claude Code",
codex: "Codex",
dsh: "DSH",
};
/** 计划里本机七步的预计动作 */
const MACHINE_STATUS: Record<string, Badge> = {
noop: { label: "已就绪", tone: "ok" },
apply: { label: "将写入", tone: "info" },
not_installed: { label: "未安装", tone: "muted" },
not_applicable: { label: "不适用", tone: "muted" },
unavailable: { label: "无法判定", tone: "warn" },
manual_kept: { label: "手写同名条目", tone: "warn" },
other_project: { label: "属于别的项目", tone: "warn" },
needs_human: { label: "需人工处理", tone: "danger" },
};
const CODEX_TRUST: Record<CodexTrust, Badge> = {
trusted: { label: "Codex 已信任该目录", tone: "ok" },
untrusted: { label: "待你操作:在 Codex 里信任该目录", tone: "pending" },
not_installed: { label: "未安装 Codex", tone: "muted" },
unknown: { label: "待你确认:无法判断 Codex 是否信任该目录", tone: "pending" },
};
export const NEXT_ACTION_LABEL: Record<string, string> = {
codex_trust: "在 Codex 里信任该目录",
commit: "提交接入文件",
gitea_repo: "建 Gitea 仓库",
ci: "配置 CI",
flywheel: "飞轮数据",
toolchain: "准备工具链",
delivery_profile: "交付配置",
lefthook: "安装 lefthook",
restart_dsh_gui: "重启 DSH 桌面 GUI",
claude_trust: "Claude 首次打开目录时确认信任",
reopen_sessions: "重开已打开的会话",
repo_layout: "仓库布局说明",
};
// ── 计划预览 ──────────────────────────────────────────────────────────────────
export interface FileRow {
path: string;
action: OnboardFileAction;
actionLabel: string;
tone: OnboardTone;
diff?: string;
warning?: string;
}
export interface MachineStepRow {
step: string;
label: string;
status: string;
statusLabel: string;
tone: OnboardTone;
detail: string;
}
/** blocking 阻断中;skip_pending 已选跳过、待重新计划;skipped 计划已按跳过生成;unskip_pending 已取消跳过、待重新计划 */
export type BlockedRowState = "blocking" | "skip_pending" | "skipped" | "unskip_pending";
export interface BlockedRow {
item: OnboardBlockedItem;
target: string;
reasonLabel: string;
detail: string;
/** 来自后端 skippable:为 true 才给「跳过」按钮 */
skippable: boolean;
state: BlockedRowState;
stateLabel: string;
tone: OnboardTone;
hint: string;
/** 可跳过的项的按钮文案;不可跳过为空(只能中止或处理后重新计划) */
toggleLabel?: string;
}
export interface BypassView {
/** 本次计划会新写入 permissions.defaultMode=bypassPermissions(后端 plan.bypass_permissions) */
willWrite: boolean;
/** 当前选择:勾选 = 保持默认写入 */
selected: boolean;
/** 生成本计划时的选择 */
planned: boolean;
label: string;
detail: string;
tone: OnboardTone;
/** 会写入或已按项目取消时给开关;项目已有 permissions 时开关无意义,不给 */
toggleable: boolean;
}
export interface CodexTrustView {
state: CodexTrust;
label: string;
tone: OnboardTone;
detail: string;
}
export interface NextActionRow {
action: string;
label: string;
detail: string;
tone: OnboardTone;
}
export interface PlanView {
projectId: string;
root: string;
digest: string;
files: FileRow[];
fileCounts: Record<OnboardFileAction, number>;
machineSteps: MachineStepRow[];
/** plan.blocked 与 plan.skipped 合并展示 */
blocked: BlockedRow[];
/** 仍阻断确认的项数(= plan.blocked.length) */
blockingCount: number;
bypass: BypassView;
codexTrust: CodexTrustView;
nextActions: NextActionRow[];
/** 当前选择与生成计划时不同:必须重新计划才能确认 */
needsReplan: boolean;
confirm: { enabled: boolean; reason?: string };
}
const BLOCKED_STATE: Record<BlockedRowState, Badge> = {
blocking: { label: "阻断", tone: "danger" },
skip_pending: { label: "将跳过(需重新计划)", tone: "warn" },
skipped: { label: "已跳过:保留现状,该端不写", tone: "muted" },
unskip_pending: { label: "将恢复阻断(需重新计划)", tone: "warn" },
};
function blockedRow(item: OnboardBlockedItem, inPlanSkipped: boolean, selected: boolean): BlockedRow {
const skippable = item.skippable === true;
const state: BlockedRowState = inPlanSkipped
? selected ? "skipped" : "unskip_pending"
: skippable && selected ? "skip_pending" : "blocking";
const badge = BLOCKED_STATE[state];
return {
item,
target: item.target,
reasonLabel: BLOCK_REASON_LABEL[item.reason] ?? item.reason,
detail: item.detail,
skippable,
state,
stateLabel: badge.label,
tone: badge.tone,
hint: skippable
? "可逐项「跳过」:保留现状、该端不写(跳过不等于该端可用);或中止"
: BLOCK_REASON_HINT[item.reason] ?? "处理后重新计划;或中止",
toggleLabel: skippable ? (selected ? "取消跳过" : "跳过") : undefined,
};
}
function bypassView(plan: OnboardingPlan, planned: OnboardSelection, selection: OnboardSelection): BypassView {
const base = { willWrite: plan.bypass_permissions, selected: selection.bypassPermissions, planned: planned.bypassPermissions };
if (plan.bypass_permissions) {
return {
...base,
label: "将写入 permissions.defaultMode=bypassPermissions",
detail: "项目 .claude/settings.json 还没有 permissions:按炼境默认写入,Claude Code 在本项目跳过权限确认。可按项目取消(取消后须重新计划)。",
tone: "warn",
toggleable: true,
};
}
if (!planned.bypassPermissions) {
return {
...base,
label: "已按项目取消 bypassPermissions",
detail: "settings.json 缺 permissions 时写显式 defaultMode=default 记住选择,之后的重注入不会补回。",
tone: "muted",
toggleable: true,
};
}
return {
...base,
label: "不新写入 bypassPermissions",
detail: "项目 .claude/settings.json 已有 permissions 设置,本次不改。",
tone: "muted",
toggleable: false,
};
}
function nextActionRow(action: OnboardNextAction): NextActionRow {
return {
action: action.action,
label: NEXT_ACTION_LABEL[action.action] ?? action.action,
detail: action.detail,
tone: action.action === "codex_trust" ? "pending" : "info",
};
}
/**
* 计划 → 预览视图。planned 是生成本计划时的选择,selection 是界面当前的选择。
* 确认按钮:plan.blocked 非空一律禁用(后端也会 refused);选择改过未重新计划也禁用。
*/
export function buildPlanView(
plan: OnboardingPlan,
planned: OnboardSelection,
selection: OnboardSelection,
): PlanView {
const selected = new Set(selection.skipTargets);
const fileCounts: Record<OnboardFileAction, number> = { create: 0, update: 0, noop: 0 };
const files = plan.files.map((file): FileRow => {
fileCounts[file.action] = (fileCounts[file.action] ?? 0) + 1;
const badge = FILE_ACTION[file.action] ?? { label: file.action, tone: "warn" };
return {
path: file.path,
action: file.action,
actionLabel: badge.label,
tone: file.warning ? "warn" : badge.tone,
diff: file.diff,
warning: file.warning,
};
});
const machineSteps = plan.machine_changes.map((step): MachineStepRow => {
const badge = MACHINE_STATUS[step.status] ?? { label: step.status, tone: "warn" };
return {
step: step.step,
label: STEP_LABEL[step.step] ?? step.step,
status: step.status,
statusLabel: badge.label,
tone: badge.tone,
detail: step.detail,
};
});
const blocked = [
...plan.blocked.map((item) => blockedRow(item, false, selected.has(item.target))),
...(plan.skipped ?? []).map((item) => blockedRow(item, true, selected.has(item.target))),
];
const trust = CODEX_TRUST[plan.codex_trust] ?? { label: plan.codex_trust, tone: "warn" };
const trustAction = plan.next_actions.find((action) => action.action === "codex_trust");
const needsReplan = !sameSelection(planned, selection);
const blockingCount = plan.blocked.length;
const reason = needsReplan
? "选项已改:须按当前选择重新计划后才能确认"
: blockingCount > 0
? `有 ${blockingCount} 项阻断,确认已禁用:可跳过的项选「跳过」后重新计划,其余处理后重新计划或中止`
: undefined;
return {
projectId: plan.project_id,
root: plan.root,
digest: plan.digest,
files,
fileCounts,
machineSteps,
blocked,
blockingCount,
bypass: bypassView(plan, planned, selection),
codexTrust: {
state: plan.codex_trust,
label: trust.label,
tone: trust.tone,
detail: trustAction?.detail ?? (plan.codex_trust === "trusted" ? "Codex 会加载项目 .codex/config.toml" : ""),
},
nextActions: plan.next_actions.map(nextActionRow),
needsReplan,
confirm: { enabled: reason === undefined, reason },
};
}
export interface OnboardConfirmRequest {
projectId: string;
/** 用户确认过的计划 digest */
expectedDigest: string;
confirmed: true;
/** 生成该计划时的选项(不是界面当前选择) */
options: OnboardOptions;
}
/** 确认接入的调用参数;确认按钮应禁用时返回 null(不发请求) */
export function confirmRequest(
plan: OnboardingPlan,
planned: OnboardSelection,
selection: OnboardSelection,
): OnboardConfirmRequest | null {
if (!buildPlanView(plan, planned, selection).confirm.enabled) return null;
return {
projectId: plan.project_id,
expectedDigest: plan.digest,
confirmed: true,
options: selectionOptions(planned),
};
}
// ── 接入报告 ──────────────────────────────────────────────────────────────────
const STATUS: Record<OnboardStatus, Badge> = {
done: { label: "接入完成", tone: "ok" },
partial: { label: "部分完成", tone: "warn" },
failed: { label: "未执行:预检失败(零写入)", tone: "danger" },
refused: { label: "已拒绝(零写入)", tone: "warn" },
busy: { label: "该项目正在接入中(零写入)", tone: "info" },
};
const STEP_STATUS: Record<OnboardStepReport["status"], Badge> = {
converged: { label: "已一致", tone: "ok" },
applied: { label: "已写入", tone: "ok" },
skipped: { label: "已跳过", tone: "muted" },
not_applicable: { label: "不适用", tone: "muted" },
blocked: { label: "未执行", tone: "warn" },
failed: { label: "失败", tone: "danger" },
needs_human: { label: "需人工处理", tone: "warn" },
};
/** 步骤 reason 的常见取值(其余原样展示) */
const STEP_REASON: Record<string, string> = {
not_installed: "未安装",
control_plane: "控制面项目",
wsl: "WSL 项目",
manual_kept: "保留手写配置",
other_project: "属于别的项目",
deferred: "延后",
};
export interface StepRow {
step: string;
label: string;
status: OnboardStepReport["status"];
statusLabel: string;
tone: OnboardTone;
/** reason 或 dep 的说明 */
note?: string;
detail: string;
}
export interface CompletionRow {
item: string;
source: OnboardCompletionItem["source"];
ready: boolean;
label: string;
tone: OnboardTone;
detail: string;
}
export const PARITY_CHECKS: ParityCheck[] = ["A", "B", "C", "D", "E", "F"];
export const CHECK_LABEL: Record<ParityCheck, string> = {
A: "项目规则",
B: "lian-jing skill",
C: "MCP 工具集",
D: "get_diagnostics",
E: "列出开发变量",
F: "敏感读取认证链",
};
const CELL_STATE: Record<ParityCellState, Badge> = {
ready: { label: "就绪", tone: "ok" },
fail: { label: "失败", tone: "danger" },
unknown: { label: "未知", tone: "warn" },
not_installed: { label: "未安装", tone: "muted" },
not_applicable: { label: "不适用", tone: "muted" },
blocked: { label: "未检查", tone: "warn" },
timeout: { label: "超时", tone: "danger" },
};
export const EVIDENCE_LABEL: Record<ParityEvidence, string> = {
static: "静态",
client_introspection: "客户端自省",
protocol: "协议重放",
session_event: "会话事件",
server_trace: "服务端轨迹",
};
/** 只等用户在客户端里操作的归因(与后端 completion 的 pending_user 同口径,仅用于着色) */
const PENDING_USER_CAUSES: ParityCause[] = ["codex_project_untrusted"];
export interface L1CellView {
state: ParityCellState;
label: string;
tone: OnboardTone;
evidence: ParityEvidence;
evidenceLabel: string;
cause?: ParityCause;
detail: string;
}
export interface L1ColumnView {
client: string;
label: string;
version: string | null;
route: string | null;
cells: Record<ParityCheck, L1CellView>;
}
export interface L1MatrixView {
profile: ClientParityL1Report["profile"];
allApplicableReady: boolean;
note: string;
columns: L1ColumnView[];
}
function cellView(cell: ParityCell): L1CellView {
const badge = CELL_STATE[cell.state] ?? { label: cell.state, tone: "warn" };
const pendingUser = cell.state !== "ready" && cell.cause !== undefined && PENDING_USER_CAUSES.includes(cell.cause);
const label = pendingUser
? "待你信任"
: cell.state === "blocked" && cell.dep
? `未检查:${STEP_LABEL[cell.dep] ?? cell.dep} 步骤未完成`
: badge.label;
return {
state: cell.state,
label,
tone: pendingUser ? "pending" : badge.tone,
evidence: cell.evidence,
evidenceLabel: EVIDENCE_LABEL[cell.evidence] ?? cell.evidence,
cause: cell.cause,
detail: cell.detail,
};
}
export function l1MatrixView(report: ClientParityL1Report): L1MatrixView {
return {
profile: report.profile,
allApplicableReady: report.all_applicable_ready,
note: report.note,
columns: report.clients.map((column) => {
const cells = {} as Record<ParityCheck, L1CellView>;
for (const check of PARITY_CHECKS) {
const cell = column.cells[check];
cells[check] = cell
? cellView(cell)
: { state: "unknown", label: "缺失", tone: "warn", evidence: "static", evidenceLabel: EVIDENCE_LABEL.static, detail: "报告未给出该格" };
}
return {
client: column.client,
label: STEP_LABEL[column.client] ?? column.client,
version: column.version,
route: column.route,
cells,
};
}),
};
}
export type CommitView =
| {
state: "available";
/** 「待你提交的 N 个文件」 */
title: string;
files: OnboardPendingFile[];
otherChanges: number;
/** 有非接入改动时提醒勿混进接入提交 */
otherWarning?: string;
suggestedMessage?: string;
diffStat: string;
}
| { state: "unavailable"; reason: string }
/** 未执行写入(refused / busy / failed)时不报告改动 */
| { state: "none" };
export interface ReportView {
status: OnboardStatus;
statusLabel: string;
tone: OnboardTone;
summary: string;
reason?: string;
steps: StepRow[];
/** 计入「完成」的项(不含 pending_user) */
completion: CompletionRow[];
/** 「待你操作」:只等你在客户端里操作、不计入完成判定的项(目前只有 Codex 信任) */
pendingUser: CompletionRow[];
l1?: L1MatrixView;
l3: string;
bypassWritten: boolean;
writtenFiles: string[];
commit: CommitView;
nextActions: NextActionRow[];
head?: { unchanged: boolean; label: string; tone: OnboardTone };
/** refused 且附当前计划:须回到预览、按新计划重新确认 */
replan?: OnboardingPlan;
durationLabel: string;
/** 结果提示:失败类状态以「❌」开头 */
toast: string;
}
function stepRow(step: OnboardStepReport): StepRow {
const badge = STEP_STATUS[step.status] ?? { label: step.status, tone: "warn" };
let note: string | undefined;
if (step.status === "blocked") {
note = `依赖「${STEP_LABEL[step.dep] ?? step.dep}」未完成`;
} else if ("reason" in step && step.reason) {
note = STEP_REASON[step.reason] ?? step.reason;
}
return {
step: step.step,
label: STEP_LABEL[step.step] ?? step.step,
status: step.status,
statusLabel: badge.label,
tone: badge.tone,
note,
detail: step.detail,
};
}
function completionRow(item: OnboardCompletionItem): CompletionRow {
const pending = !item.ready && item.pending_user;
return {
item: item.item,
source: item.source,
ready: item.ready,
label: item.ready ? "就绪" : pending ? "待你操作" : "未就绪",
tone: item.ready ? "ok" : pending ? "pending" : "danger",
detail: item.detail,
};
}
function commitView(report: OnboardReport): CommitView {
const changes = report.repo_changes;
if (!changes) return { state: "none" };
if (changes.state === "unavailable") return { state: "unavailable", reason: changes.reason };
return {
state: "available",
title: `待你提交的 ${changes.onboard_files.length} 个文件`,
files: changes.onboard_files,
otherChanges: changes.other_changes,
otherWarning:
changes.other_changes > 0
? `另有 ${changes.other_changes} 处不属于接入的改动:只 add 上面的文件,不要 git add -A`
: undefined,
suggestedMessage: report.suggested_commit_message,
diffStat: changes.diff_stat,
};
}
const FAILURE_STATUSES: OnboardStatus[] = ["failed", "refused", "busy"];
/** 报告 → 结果视图(分步结果、L1 矩阵、待你操作、待提交文件、next_actions) */
export function buildReportView(report: OnboardReport): ReportView {
const badge = STATUS[report.status] ?? { label: report.status, tone: "warn" };
const completion = report.completion.map((item) => ({ item, row: completionRow(item) }));
const failure = FAILURE_STATUSES.includes(report.status);
return {
status: report.status,
statusLabel: badge.label,
tone: badge.tone,
summary: report.summary,
reason: report.reason,
steps: report.steps.map(stepRow),
completion: completion.filter(({ item }) => !item.pending_user).map(({ row }) => row),
pendingUser: completion.filter(({ item }) => item.pending_user).map(({ row }) => row),
l1: report.l1 ? l1MatrixView(report.l1) : undefined,
l3: report.l3,
bypassWritten: report.bypass_permissions,
writtenFiles: report.written_files ?? [],
commit: commitView(report),
nextActions: report.next_actions.map(nextActionRow),
head: report.head
? report.head.unchanged
? { unchanged: true, label: "HEAD 未变(炼境不 add、不 commit)", tone: "ok" }
: { unchanged: false, label: "HEAD 已变化:写入期间有别的提交,请核对", tone: "warn" }
: undefined,
replan: report.status === "refused" ? report.plan : undefined,
durationLabel: `${(report.duration_ms / 1000).toFixed(1)} 秒`,
toast: failure
? `❌ 一键接入${badge.label}:${report.reason ?? report.summary}`
: `一键接入${badge.label} ✓`,
};
}

View File

@ -28,7 +28,10 @@ const EXEC_STEPS = [
export function NewProjectModal({ spaceId, onClose }: Props) {
const showToast = useUIStore((s) => s.showToast);
const openOnboard = useUIStore((s) => s.openOnboard);
const qc = useQueryClient();
/** 模板创建成功的项目 ID:自动弹接入预览(P8g),取消后可从完成页再次打开 */
const [createdId, setCreatedId] = useState<string | null>(null);
// ── Step 1 ────────────────────────────────────────────────────────────────
const [platform, setPlatform] = useState<"win" | "wsl">("win");
@ -128,7 +131,7 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
const variantCmd = selectedVariants.length > 0
? selectedVariants[selectedVariantIdx]?.initCommands
: undefined;
await createProjectFromTemplate(
const result = await createProjectFromTemplate(
selectedTemplateId,
parentPath,
projectName.trim(),
@ -138,6 +141,8 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
);
qc.invalidateQueries({ queryKey: ["projects"] });
setStep("done");
setCreatedId(result.projectId);
openOnboard({ projectId: result.projectId, projectName: projectName.trim() });
} catch (e) {
setLogs((prev) => [...prev, `❌ 创建失败: ${e}`]);
setHasError(true);
@ -449,6 +454,14 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
)}
</div>
<div className="flex gap-3">
{step === "done" && createdId && (
<button
onClick={() => openOnboard({ projectId: createdId, projectName: projectName.trim() })}
className="px-4 py-2 text-sm border border-blue-200 rounded-lg text-blue-700 hover:bg-blue-50"
>
一键接入
</button>
)}
<button
onClick={onClose}
disabled={step === "executing"}

View File

@ -1770,6 +1770,8 @@ export interface OnboardBlockedItem {
target: string;
reason: OnboardBlockReason;
detail: string;
/** 本体派生(BlockReason::skippable):可在确认时逐项「跳过」;界面据此给按钮,不自行判定 */
skippable: boolean;
}
/** 本机七步:token、shim、claude、codex、dsh、product_group_buff、git_hooks */

View File

@ -1,5 +1,13 @@
import { create } from "zustand";
import type { Project } from "../lib/commands";
import type { OnboardReport, Project } from "../lib/commands";
/** 一键接入预览的目标(agent-client-parity P8g):新建、模板创建、批量添加、蓝图治理都打开同一面板 */
export interface OnboardTarget {
projectId: string;
projectName?: string;
/** 面板关闭时回调;未执行接入(取消 / 计划失败)时 report 为 null */
onClosed?: (report: OnboardReport | null) => void;
}
interface UIState {
// Toast
@ -10,6 +18,11 @@ interface UIState {
editTarget: Project | null | undefined; // undefined = closed, null = new, obj = edit
openEdit: (project?: Project | null) => void;
closeEdit: () => void;
// 一键接入面板(全局挂载:新建弹窗保存后即卸载,面板须独立存在)
onboardTarget: OnboardTarget | null;
openOnboard: (target: OnboardTarget) => void;
closeOnboard: () => void;
}
export const useUIStore = create<UIState>((set) => ({
@ -22,4 +35,8 @@ export const useUIStore = create<UIState>((set) => ({
editTarget: undefined,
openEdit: (project) => set({ editTarget: project ?? null }),
closeEdit: () => set({ editTarget: undefined }),
onboardTarget: null,
openOnboard: (target) => set({ onboardTarget: target }),
closeOnboard: () => set({ onboardTarget: null }),
}));