From 1c6a95aa1ad5a26672cf104c08c0a1b554372e2c Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 24 Aug 2026 07:40:01 +0000 Subject: [PATCH] =?UTF-8?q?GDD=20=E6=89=B9=E5=87=86=E5=90=8E=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=E4=BA=A4=E4=BB=98=E5=87=BA=E5=8F=A3=EF=BC=9A=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=A0=8F=E7=BB=99=E5=87=BA=E6=9C=AC=E5=9C=B0=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E3=80=81=E6=9F=A5=E7=9C=8B=E6=AD=A3=E6=96=87=E4=B8=8E?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 批准之后审批卡按设计整张收掉,从那一刻起用户就再也够不到自己刚批的 GDD: approvedGddRef 前端没人读,渲染好的 game/fast_gdd.md 也没有任何入口。做方案链路 跑到头是没有交付物的。 不新增卡片——交付行挂在阶段进度条底下,只在 state 为 approved 且不在恢复态时出现: 一行绝对路径,两个按钮「查看 GDD 正文」「打开文件」。批准前后是同一个框,多一行, 视觉连续。恢复态不给出口:那时权威投影还没收敛,磁盘上那份未必是用户批的那版。 正文弹层从审批卡里抽成 GddDetailsDialog 两处共用,内容一字未改,于是批准前后看到 的是同一份正文。路径按项目路径自身的分隔符拼,Windows 下不会混出反斜杠与正斜杠 各半的怪路径。 后端新增 open_local_project_plan_gdd_markdown,走 opener 交给系统默认程序。路径不 由前端拼:命令自己用 resolve_local_project_path 在项目根下解析常量相对路径——那是 项目内路径的唯一安全入口(根校验、归一化、逐段拒绝符号链接),GDD 的渲染侧用的也 是同一个解析器,两边对「项目内的这个文件」必须是同一个判定。再加存在性与普通文件 检查,未渲染时给出明确原因而不是把不存在的路径丢给 shell。 顺带修一条我在 80200e6a3 调高度时漏跑全量而留下的红:project-development 里那条 断言把 clamp 的三个断点钉成了字面量。它要锁的不变量是「自带上限 + 自己滚」,数值 是随排版调整的设计取值;钉死只会让每次调高度都顺带改测试,却挡不住真正的回归。 改成不锁数值,并补一条策划窄条没退回去继承 240px 天花板的断言。 新增测试(前端三条都做过 A/B,关掉交付行即红): - Rust:路径门四条(正常解析、未渲染、非项目目录、相对路径)。 - 前端:approved 态出现路径与两个按钮且没有批准/修改/退回;点「打开文件」用正确 参数 invoke;recoveryPending 时交付行不出现。 Co-Authored-By: Claude Opus 5 --- .../src-tauri/src/commands.rs | 98 +++++++++ .../src-tauri/src/main.rs | 1 + .../project-workspace/GddApprovalCard.tsx | 191 ++++++++++++++---- .../ProjectSupervisorView.tsx | 1 + .../ProjectWorkspaceChatPane.tsx | 1 + apps/ai-game-creator-shell/src/styles.css | 48 +++++ .../tests/appSurface/plan-gdd.suite.ts | 84 ++++++++ .../appSurface/project-development.suite.ts | 20 +- 8 files changed, 397 insertions(+), 47 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 4c6b7e704..4608fc3e5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -552,6 +552,104 @@ pub(crate) fn validated_local_project_directory_path( Ok(path.to_path_buf()) } +/// Open the approved Fast GDD Markdown in whatever application the OS has +/// registered for it. +/// +/// The GDD is the one product artifact the 立项策划 lane hands back, and it is +/// already on disk — `plan.submit_gdd` renders `game/fast_gdd.md` and the +/// approval receipt re-renders it with the approved header. This command only +/// hands that existing path to the shell; it never creates or rewrites it. +#[tauri::command] +pub(crate) fn open_local_project_plan_gdd_markdown( + app: tauri::AppHandle, + project_path: String, +) -> Result<(), String> { + let path = validated_local_project_plan_gdd_markdown_path(project_path.trim())?; + app.opener() + .open_path(path.to_string_lossy().into_owned(), None::<&str>) + .map_err(|error| format!("打开 Fast GDD 文件失败:{error}")) +} + +pub(crate) fn validated_local_project_plan_gdd_markdown_path( + project_path: &str, +) -> Result { + let root = validated_local_project_directory_path(project_path)?; + // `resolve_local_project_path` 是项目内路径的唯一安全入口:它做根校验、相对路径 + // 归一化,并逐段拒绝符号链接。这里的相对路径是常量,但仍然走它——GDD 的渲染侧 + // (`planning_storage`)用的也是同一个解析器,两边对「项目内的这个文件」必须是 + // 同一个判定,不能一边解析一边拼字符串。 + let path = resolve_local_project_path(&root, PLAN_FAST_GDD_PATH)?; + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_file() => Ok(path), + Ok(_) => Err("Fast GDD 产物不是普通文件".to_string()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Err("Fast GDD 产物尚未生成,请先完成立项策划审批".to_string()) + } + Err(error) => Err(format!("读取 Fast GDD 产物失败:{error}")), + } +} + +#[cfg(test)] +mod plan_gdd_markdown_path_tests { + use super::*; + + fn fixture() -> tempfile::TempDir { + let temporary = tempfile::tempdir().expect("create GDD path fixture"); + crate::project::init_local_game_project_at( + &temporary.path().join("project"), + "gdd-open", + "打开 GDD 产物", + ) + .expect("initialize GDD path fixture"); + temporary + } + + #[test] + fn resolves_the_rendered_markdown_under_the_project_root() { + let temporary = fixture(); + let root = temporary.path().join("project"); + fs::create_dir_all(root.join("game")).expect("create game directory"); + fs::write(root.join(PLAN_FAST_GDD_PATH), "# Fast GDD").expect("render markdown"); + + let resolved = + validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned()) + .expect("resolve rendered markdown"); + + assert_eq!(resolved, root.join(PLAN_FAST_GDD_PATH)); + } + + #[test] + fn refuses_to_open_a_markdown_that_has_not_been_rendered_yet() { + // 恢复态下 `plan.submit_gdd` 的 Markdown 渲染可能还没落盘。这时按钮必须给出 + // 明确原因,而不是把一个不存在的路径丢给 shell 由系统弹一个无从解释的错误。 + let temporary = fixture(); + let root = temporary.path().join("project"); + + let error = + validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned()) + .expect_err("missing markdown must fail closed"); + + assert!(error.contains("尚未生成"), "unexpected error: {error}"); + } + + #[test] + fn refuses_a_project_path_that_is_not_an_initialized_project() { + let temporary = tempfile::tempdir().expect("create bare fixture"); + let error = validated_local_project_plan_gdd_markdown_path( + &temporary.path().to_string_lossy().into_owned(), + ) + .expect_err("a directory without .agent is not a project root"); + assert!(!error.is_empty()); + } + + #[test] + fn refuses_a_relative_project_path() { + let error = validated_local_project_plan_gdd_markdown_path("relative/project") + .expect_err("relative project path must fail"); + assert!(error.contains("绝对路径"), "unexpected error: {error}"); + } +} + #[tauri::command] pub(crate) fn get_local_game_manifest( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index cd8e4dc7a..3a63e76df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2364,6 +2364,7 @@ fn main() { pick_local_project_directory, pick_local_file, open_local_project_directory, + open_local_project_plan_gdd_markdown, control_agent_run, generate_local_game_draft, chat_with_game_creator_agent, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx index ceda1bf76..101678457 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -1,10 +1,30 @@ import { useState } from 'react'; +import { resolveTauriInvoke } from '../../app/tauri'; import type { PlanGddDecisionAction, PlanGddStateViewV1, } from '../../app/types'; +/** 立项策划唯一的产品产物,由 `plan.submit_gdd` 与审批回执渲染到项目内。 */ +const PLAN_FAST_GDD_RELATIVE_PATH = 'game/fast_gdd.md'; + +/** + * 拼出交付文件的绝对路径。 + * + * 用户要拿这行去资源管理器里找文件,所以跟随项目路径本身的分隔符:Windows 下项目 + * 路径是反斜杠,混排出来的 `C:\...\project/game/fast_gdd.md` 虽然能用,但复制到 + * 地址栏之外的地方就不像一个路径了。 + */ +function planGddMarkdownDisplayPath(projectPath: string) { + const trimmed = projectPath.trim().replace(/[\\/]+$/u, ''); + if (!trimmed) { + return PLAN_FAST_GDD_RELATIVE_PATH; + } + const separator = trimmed.includes('\\') ? '\\' : '/'; + return `${trimmed}${separator}${PLAN_FAST_GDD_RELATIVE_PATH.split('/').join(separator)}`; +} + type GddApprovalCardProps = { state: PlanGddStateViewV1 | null; busy: boolean; @@ -48,11 +68,12 @@ function approvalCardVisible(state: PlanGddStateViewV1 | null) { export function PlanGddSurface({ state, active = false, + projectPath, busy, error, onRefresh, onDecision, -}: GddApprovalCardProps & { active?: boolean }) { +}: GddApprovalCardProps & { active?: boolean; projectPath: string }) { const showProgress = stageProgressVisible(state, active); const showCard = approvalCardVisible(state); if (!showProgress && !showCard) { @@ -64,7 +85,11 @@ export function PlanGddSurface({ aria-label="立项策划" > {showProgress ? ( - + ) : null} {showCard ? (
@@ -116,6 +154,57 @@ export function PlanGddStageProgress({ : `当前版本:v${latestVersion}`}
+ {deliveredGdd ? ( +
+ {markdownPath} +
+ + +
+ {openError ? ( + + {openError} + + ) : null} +
+ ) : null} + {detailsOpen && deliveredGdd ? ( + setDetailsOpen(false)} + /> + ) : null} ); } @@ -196,6 +285,63 @@ const visibleGddSections = ( }, ]; +/** + * Fast GDD 的正文视图。 + * + * 审批时挂在审批卡上,批准之后审批卡收掉、改由阶段进度条那条交付行触发——同一份 + * 弹层两处共用,用户在批准前后看到的是同一个正文。 + */ +export function GddDetailsDialog({ + gdd, + onClose, +}: { + gdd: NonNullable; + onClose: () => void; +}) { + return ( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + > +
+
+

{gdd.game.title}

+

{gdd.game.oneLiner}

+
+ {visibleGddSections(gdd) + .filter((section) => section.content) + .map((section) => ( +
+ {section.title} +

{section.content}

+
+ ))} + + {`Fast GDD v${gdd.version} · ${gdd.fingerprint}`} + +
+ +
+
+
+ ); +} + export function GddApprovalCard({ state, busy, @@ -361,46 +507,7 @@ export function GddApprovalCard({ ) : null} {gddDetailsOpen ? ( -
{ - if (event.target === event.currentTarget) { - setGddDetailsOpen(false); - } - }} - > -
-
-

{gdd.game.title}

-

{gdd.game.oneLiner}

-
- {visibleGddSections(gdd) - .filter((section) => section.content) - .map((section) => ( -
- {section.title} -

{section.content}

-
- ))} - - {`Fast GDD v${gdd.version} · ${gdd.fingerprint}`} - -
- -
-
-
+ setGddDetailsOpen(false)} /> ) : null} ); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 2df253fa7..10a88f942 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -108,6 +108,7 @@ export function ProjectSupervisorView({ { + // 批准之后审批卡按设计整张收掉,此前那一刻起用户就再也够不到 GDD:approvedGddRef + // 前端没人读,渲染好的 Markdown 也没有出口。交付行补的就是这个缺口——不额外占一 + // 张卡,只在标题栏下多一行:文件在哪、看正文、用外部程序打开。 + const harness = createProjectSupervisorRuntimeHarness(); + harness.setPlanGddState(approvedPlanGddState()); + await mountPlanningSurface(harness); + + expect(screen.queryByLabelText('GDD 审批卡')).toBeNull(); + const delivery = await screen.findByLabelText('GDD 交付'); + expect(delivery.textContent).toContain( + `${harness.projectPath}/game/fast_gdd.md`, + ); + expect(screen.queryByRole('button', { name: /^批准/ })).toBeNull(); + expect(screen.queryByRole('button', { name: '修改' })).toBeNull(); + expect(screen.queryByRole('button', { name: '退回重做' })).toBeNull(); + + // 正文弹层与审批时是同一份,批准前后看到的内容一致。 + fireEvent.click( + within(delivery).getByRole('button', { name: '查看 GDD 正文' }), + ); + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByLabelText('GDD 版本与指纹')).not.toBeNull(); + }); + + it('asks the shell to open the rendered GDD for the current project', async () => { + const harness = createProjectSupervisorRuntimeHarness(); + harness.setPlanGddState(approvedPlanGddState()); + await mountPlanningSurface(harness); + + const delivery = await screen.findByLabelText('GDD 交付'); + fireEvent.click(within(delivery).getByRole('button', { name: '打开文件' })); + + await waitFor(() => { + expect( + harness.invoke.mock.calls.filter( + ([command]) => command === 'open_local_project_plan_gdd_markdown', + ), + ).toHaveLength(1); + }); + const [, args] = harness.invoke.mock.calls.find( + ([command]) => command === 'open_local_project_plan_gdd_markdown', + )!; + // 路径交给后端自己在项目根下解析,前端只报项目——避免两侧各拼一次相对路径。 + expect(args).toEqual({ projectPath: harness.projectPath }); + }); + + it('keeps the delivery row hidden while the approval projection is still recovering', async () => { + // 恢复态下权威投影还没收敛,磁盘上那份 Markdown 未必是用户批的那版。此时给出口 + // 等于让用户读一份可能已经失效的交付物。 + const harness = createProjectSupervisorRuntimeHarness(); + harness.setPlanGddState({ + ...approvedPlanGddState(), + recoveryPending: true, + }); + await mountPlanningSurface(harness); + + expect(screen.queryByLabelText('GDD 交付')).toBeNull(); + }); + it('keeps the supervisor runtime panel off the planning lane while the planner is working', async () => { // 完整面板是给做游戏链路的:十几个专业 Agent、多步计划、逐 Agent 重试。策划链路 // 只有一个 project-planning 子 Run、一两步计划,面板画出来的全是 D11 拓扑的内部 diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 0ff2de0eb..ec0ab4a31 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -3498,12 +3498,20 @@ export function registerProjectWorkbenchFoundationTests() { expect(styles).toMatch( /\.game-workbench-chat \.project-supervisor-message-list\s*\{[^}]*flex:\s*1 1 auto[^}]*min-height:\s*96px[^}]*overflow-y:\s*auto/s, ); - // 审批卡自带上限并内部滚动,不能靠挤别人来容纳决定项。 + // 审批卡与运行状态各自带上限并内部滚动,不能靠挤别人来容纳自己的内容。锁的是 + // 「有 clamp 上限 + 自己滚」这个不变量,不锁具体数值——三个断点是随排版调整的 + // 设计取值,钉死它们只会让每次调高度都顺带改一次测试,却挡不住真正的回归(去掉 + // 上限或去掉内部滚动)。 expect(styles).toMatch( - /\.game-workbench-chat \.gdd-approval-card\s*\{[^}]*max-height:\s*clamp\(160px, 34dvh, 380px\)[^}]*overflow-y:\s*auto/s, + /\.game-workbench-chat \.gdd-approval-card\s*\{[^}]*max-height:\s*clamp\([^)]*\)[^}]*overflow-y:\s*auto/s, ); expect(styles).toMatch( - /\.game-workbench-chat \.agent-runtime-status\s*\{[^}]*max-height:\s*clamp\(120px, 24dvh, 240px\)[^}]*overflow-y:\s*auto/s, + /\.game-workbench-chat \.agent-runtime-status\s*\{[^}]*max-height:\s*clamp\([^)]*\)[^}]*overflow-y:\s*auto/s, + ); + // 策划窄条是 `.agent-runtime-status` 的一种,但它只在需要用户动手时出现,用的是 + // 自己那条更宽的上限;这条断言保证它没有退回去继承调试面板那个 240px 天花板。 + expect(styles).toMatch( + /\.game-workbench-chat \.planning-lane-runtime-strip\s*\{[^}]*max-height:\s*clamp\([^)]*\)/s, ); expect(styles).toMatch( /\.game-workbench-chat \.project-runtime-summary\s*\{[^}]*position:\s*sticky[^}]*top:\s*-10px/s, @@ -9806,7 +9814,8 @@ export function registerProjectSupervisorSurfaceTests() { projectPath, }); let directTurnUpdateHandler: - ((event: { payload: Record }) => void) | null = null; + | ((event: { payload: Record }) => void) + | null = null; const listen = vi.fn( async ( eventName: string, @@ -10763,7 +10772,8 @@ export function registerProjectAgentStatusTests() { }, ); let runtimeUpdateHandler: - ((event: { payload: Record }) => void) | null = null; + | ((event: { payload: Record }) => void) + | null = null; const listen = vi.fn( async ( eventName: string,