GDD 批准后补上交付出口:标题栏给出本地路径、查看正文与外部打开
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Successful in 3m57s
Project CI / Native shell tests (pull_request) Successful in 14m18s

批准之后审批卡按设计整张收掉,从那一刻起用户就再也够不到自己刚批的 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 07:40:01 +00:00
parent 6578bcf2ae
commit 1c6a95aa1a
8 changed files with 397 additions and 47 deletions
@@ -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<PathBuf, String> {
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,
@@ -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,
@@ -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 ? (
<PlanGddStageProgress state={state} active={active} />
<PlanGddStageProgress
state={state}
active={active}
projectPath={projectPath}
/>
) : null}
{showCard ? (
<GddApprovalCard
@@ -82,10 +107,15 @@ export function PlanGddSurface({
export function PlanGddStageProgress({
state,
active = false,
projectPath = '',
}: {
state: PlanGddStateViewV1 | null;
active?: boolean;
projectPath?: string;
}) {
const [detailsOpen, setDetailsOpen] = useState(false);
const [openError, setOpenError] = useState('');
const [opening, setOpening] = useState(false);
if (!state || !stageProgressVisible(state, active)) {
return null;
}
@@ -102,6 +132,14 @@ export function PlanGddStageProgress({
const roundLabel = state.session?.awaitingAnswerFor
? `${answeredRounds + 1} 轮 / 共 3 轮`
: `已完成 ${answeredRounds}/3 轮澄清`;
// 批准之后审批卡整张收掉,交付出口就落在这条标题栏上:GDD 的 Markdown 已经在项目
// 里(提交时渲染、审批回执重渲染带上 approved 头),这里只是把它指出来并交给系统
// 打开。恢复态不给出口——那时权威投影还没收敛,路径上的内容可能不是用户批的那版。
const deliveredGdd =
state.state === 'approved' && !state.recoveryPending
? state.displayGdd
: null;
const markdownPath = planGddMarkdownDisplayPath(projectPath);
return (
<section className="plan-gdd-stage-progress" aria-label="立项策划阶段进度">
<div className="plan-gdd-stage-progress__header">
@@ -116,6 +154,57 @@ export function PlanGddStageProgress({
: `当前版本:v${latestVersion}`}
</span>
</div>
{deliveredGdd ? (
<div
className="plan-gdd-stage-progress__delivery"
aria-label="GDD 交付"
>
<code title={markdownPath}>{markdownPath}</code>
<div className="plan-gdd-stage-progress__delivery-actions">
<button type="button" onClick={() => setDetailsOpen(true)}>
GDD
</button>
<button
type="button"
disabled={opening || !projectPath.trim()}
onClick={() => {
const invoke = resolveTauriInvoke();
if (!invoke) {
setOpenError('当前环境不支持打开本地文件');
return;
}
setOpenError('');
setOpening(true);
void invoke('open_local_project_plan_gdd_markdown', {
projectPath,
})
.catch((error: unknown) =>
setOpenError(
error instanceof Error ? error.message : String(error),
),
)
.finally(() => setOpening(false));
}}
>
{opening ? '正在打开' : '打开文件'}
</button>
</div>
{openError ? (
<small
className="plan-gdd-stage-progress__delivery-error"
role="alert"
>
{openError}
</small>
) : null}
</div>
) : null}
{detailsOpen && deliveredGdd ? (
<GddDetailsDialog
gdd={deliveredGdd}
onClose={() => setDetailsOpen(false)}
/>
) : null}
</section>
);
}
@@ -196,6 +285,63 @@ const visibleGddSections = (
},
];
/**
* Fast GDD 的正文视图。
*
* 审批时挂在审批卡上,批准之后审批卡收掉、改由阶段进度条那条交付行触发——同一份
* 弹层两处共用,用户在批准前后看到的是同一个正文。
*/
export function GddDetailsDialog({
gdd,
onClose,
}: {
gdd: NonNullable<PlanGddStateViewV1['displayGdd']>;
onClose: () => void;
}) {
return (
<div
className="gdd-approval-card__dialog-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<section
className="gdd-approval-card__dialog gdd-approval-card__details"
role="dialog"
aria-modal="true"
aria-labelledby="gdd-approval-details-title"
>
<header>
<h3 id="gdd-approval-details-title">{gdd.game.title}</h3>
<p>{gdd.game.oneLiner}</p>
</header>
{visibleGddSections(gdd)
.filter((section) => section.content)
.map((section) => (
<article key={section.title}>
<strong>{section.title}</strong>
<p>{section.content}</p>
</article>
))}
<small
className="gdd-approval-card__details-trace"
aria-label="GDD 版本与指纹"
>
{`Fast GDD v${gdd.version} · ${gdd.fingerprint}`}
</small>
<div className="gdd-approval-card__dialog-actions">
<button type="button" onClick={onClose}>
</button>
</div>
</section>
</div>
);
}
export function GddApprovalCard({
state,
busy,
@@ -361,46 +507,7 @@ export function GddApprovalCard({
) : null}
{gddDetailsOpen ? (
<div
className="gdd-approval-card__dialog-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setGddDetailsOpen(false);
}
}}
>
<section
className="gdd-approval-card__dialog gdd-approval-card__details"
role="dialog"
aria-modal="true"
aria-labelledby="gdd-approval-details-title"
>
<header>
<h3 id="gdd-approval-details-title">{gdd.game.title}</h3>
<p>{gdd.game.oneLiner}</p>
</header>
{visibleGddSections(gdd)
.filter((section) => section.content)
.map((section) => (
<article key={section.title}>
<strong>{section.title}</strong>
<p>{section.content}</p>
</article>
))}
<small
className="gdd-approval-card__details-trace"
aria-label="GDD 版本与指纹"
>
{`Fast GDD v${gdd.version} · ${gdd.fingerprint}`}
</small>
<div className="gdd-approval-card__dialog-actions">
<button type="button" onClick={() => setGddDetailsOpen(false)}>
</button>
</div>
</section>
</div>
<GddDetailsDialog gdd={gdd} onClose={() => setGddDetailsOpen(false)} />
) : null}
</section>
);
@@ -108,6 +108,7 @@ export function ProjectSupervisorView({
<PlanGddSurface
state={planGddState}
active={isPlanningLaneRuntime(runtimePanelProps.runtime)}
projectPath={projectPath}
busy={planGddHydrateBusy}
error={planGddError}
onRefresh={onPlanGddRefresh}
@@ -368,6 +368,7 @@ export function ProjectWorkspaceChatPane({
<PlanGddSurface
state={planGddState}
active={isPlanningLaneRuntime(projectSupervisorRuntime)}
projectPath={projectPath}
busy={planGddHydrateBusy}
error={planGddError}
onRefresh={onPlanGddRefresh}
+48
View File
@@ -3502,6 +3502,54 @@ textarea {
gap: 8px 14px;
}
/* 批准后的交付行。它是策划阶段唯一的产物出口,所以给一条分隔线把它和上面的状态
区分开,而不是混成第三行元信息。 */
.plan-gdd-stage-progress__delivery {
display: grid;
gap: 8px;
margin-top: 4px;
padding-top: 9px;
border-top: 1px solid #e6ecf5;
}
.plan-gdd-stage-progress__delivery code {
min-width: 0;
color: #3c4a5c;
font-size: 11px;
overflow-wrap: anywhere;
}
.plan-gdd-stage-progress__delivery-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.plan-gdd-stage-progress__delivery-actions button {
min-height: 30px;
padding: 0 12px;
border: 1px solid #cfd7e6;
border-radius: 6px;
color: #27364a;
background: #fff;
font-size: 12px;
}
.plan-gdd-stage-progress__delivery-actions button:hover:not(:disabled),
.plan-gdd-stage-progress__delivery-actions button:focus-visible {
border-color: #1f6feb;
color: #1f6feb;
}
.plan-gdd-stage-progress__delivery-actions button:disabled {
opacity: 0.58;
}
.plan-gdd-stage-progress__delivery-error {
color: #b42323;
overflow-wrap: anywhere;
}
.plan-gdd-stage-progress__header strong {
color: #27364a;
}
@@ -55,6 +55,30 @@ function draftPlanGddState(
});
}
/** 已批准:审批卡整张收掉,只剩标题栏和它下面的交付行。 */
function approvedPlanGddState() {
const base = createPlanGddStateView();
const gddRef = base.versions[0]!.gddRef;
return createPlanGddStateView({
state: 'approved',
pendingApproval: null,
approvedGddRef: gddRef,
versions: [
{
...base.versions[0]!,
status: 'approved',
decision: {
action: 'approve' as const,
decidedAtUtc: '2026-08-18T01:00:00Z',
},
},
],
session: base.session
? { ...base.session, phase: 'approved', awaitingAnswerFor: null }
: null,
});
}
/**
* 完整总控面板在策划链路下漏给用户的几块:面板本体、currentAction/计划进度、
* 五段式紧凑进度、子 Agent 列表。策划链路的断言统一从这里查。
@@ -308,6 +332,66 @@ export function registerPlanGddApprovalTests() {
);
});
it('hands the approved GDD back through the stage strip instead of a card', async () => {
// 批准之后审批卡按设计整张收掉,此前那一刻起用户就再也够不到 GDDapprovedGddRef
// 前端没人读,渲染好的 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 拓扑的内部
@@ -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<string, unknown> }) => void) | null = null;
| ((event: { payload: Record<string, unknown> }) => void)
| null = null;
const listen = vi.fn(
async (
eventName: string,
@@ -10763,7 +10772,8 @@ export function registerProjectAgentStatusTests() {
},
);
let runtimeUpdateHandler:
((event: { payload: Record<string, unknown> }) => void) | null = null;
| ((event: { payload: Record<string, unknown> }) => void)
| null = null;
const listen = vi.fn(
async (
eventName: string,