合并 origin/master(21c79f54a)到 feat/game-purchase
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m28s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 3m52s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 5m23s
Project CI / Repository checks (pull_request) Failing after 56s
Project CI / Frontend tests (pull_request) Successful in 3m7s
Project CI / Native shell tests (pull_request) Failing after 7m11s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m47s
Project CI / Backend tests (pull_request) Successful in 9m28s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m28s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 3m52s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 5m23s
Project CI / Repository checks (pull_request) Failing after 56s
Project CI / Frontend tests (pull_request) Successful in 3m7s
Project CI / Native shell tests (pull_request) Failing after 7m11s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m47s
Project CI / Backend tests (pull_request) Successful in 9m28s
- 冲突 docs/project-memory/shared-memory/decision-log.md:两侧都是在「# 决策记录」顶部新增条目,保留双份——本分支「2026-10-05 播放会话前缀在三处入口清空 Cookie」在前,master「2026-10-03 首页自动建项与 AI 项目命名解耦(Issue 599)」在后。 - 冲突 docs/project-memory/shared-memory/pitfalls.md:同为顶部新增条目,保留双份——本分支「2026-10-05 付费游戏播放会话前缀落在 /api/*」在前,master「2026-10-04 生成任务账本 taskType 维度与 v1 兼容」「2026-10-03 后台异步回填被进项目覆盖」在后。 - 其余逐处核对:master 本次只改了 apps/ai-game-creator-shell 与 docs(首页建项与 AI 命名解耦、AGC 标题栏项目标签、相关计划/记忆文档),与买断制付费功能文件无交集,全部吸收;master 未触碰 SpacetimeDB schema、钱包流水来源类型、游戏分发契约、边缘路由与生成绑定。 - 合并后回归核对:GameDistributionGame.price_mud_points 仍在结构体末尾且带默认值 0;migration 白名单仍含 game_distribution_purchase;RuntimeProfileWalletLedgerSourceType::GamePurchase(game_purchase)仍在。
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ use crate::agent::{
|
||||
use crate::commands::prepare_local_project_asset_generation;
|
||||
use crate::project::run_local_project_audio_generation_at;
|
||||
|
||||
pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running";
|
||||
const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。";
|
||||
/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。
|
||||
const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材";
|
||||
|
||||
@@ -502,6 +502,297 @@ pub(crate) fn rename_local_game_project(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ConditionalRenameLocalProjectResult {
|
||||
pub(crate) renamed: bool,
|
||||
pub(crate) manifest: GameCreationAppManifest,
|
||||
pub(crate) revision: u64,
|
||||
}
|
||||
|
||||
/// AI 后台命名回填用的条件改名核心。
|
||||
///
|
||||
/// 只有「项目身份未变」且「当前名称仍是这次创建时的兜底名」时才落盘改名;任何一项不匹配都
|
||||
/// 原样返回当前 manifest / revision 并把 `renamed` 置为 false,绝不覆盖用户已经改过的名字。
|
||||
/// 与 [`rename_local_game_project`] 一致:改名是簿记写入,刻意不推项目 revision。
|
||||
pub(crate) fn rename_local_game_project_if_unchanged_at(
|
||||
root: &Path,
|
||||
expected_project_id: &str,
|
||||
expected_name: &str,
|
||||
name: &str,
|
||||
) -> Result<ConditionalRenameLocalProjectResult, String> {
|
||||
let next_name = normalize_game_creation_project_name(name.trim())?;
|
||||
let expected_project_id = expected_project_id.trim();
|
||||
if expected_project_id.is_empty() {
|
||||
return Err("项目改名 expectedProjectId 不能为空".to_string());
|
||||
}
|
||||
let expected_name = expected_name.trim();
|
||||
if expected_name.is_empty() {
|
||||
return Err("项目改名 expectedName 不能为空".to_string());
|
||||
}
|
||||
enforce_project_permission_policy(root, "project.rename")?;
|
||||
let _lock = acquire_project_write_lock(root, "project.rename")?;
|
||||
let current = read_existing_manifest_for_project(root)?;
|
||||
let revision = read_game_creator_agent_runtime_project_revision(root)?.revision;
|
||||
// 项目身份不匹配:目录换了项目或身份被改写,一律不改名。
|
||||
if current.project_id != expected_project_id {
|
||||
return Ok(ConditionalRenameLocalProjectResult {
|
||||
renamed: false,
|
||||
manifest: current,
|
||||
revision,
|
||||
});
|
||||
}
|
||||
// 用户已经手动改过名(或别的流程改过):兜底名不再成立,绝不覆盖。
|
||||
if current.name != expected_name {
|
||||
return Ok(ConditionalRenameLocalProjectResult {
|
||||
renamed: false,
|
||||
manifest: current,
|
||||
revision,
|
||||
});
|
||||
}
|
||||
// 结果与现状一致:没有必要再写一次盘。
|
||||
if current.name == next_name {
|
||||
return Ok(ConditionalRenameLocalProjectResult {
|
||||
renamed: false,
|
||||
manifest: current,
|
||||
revision,
|
||||
});
|
||||
}
|
||||
|
||||
let manifest = mutate_manifest_at(root, |manifest| {
|
||||
manifest.name = next_name;
|
||||
Ok(manifest.clone())
|
||||
})?;
|
||||
Ok(ConditionalRenameLocalProjectResult {
|
||||
renamed: true,
|
||||
manifest,
|
||||
revision: read_game_creator_agent_runtime_project_revision(root)?.revision,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn rename_local_game_project_if_unchanged(
|
||||
project_path: String,
|
||||
expected_project_id: String,
|
||||
expected_name: String,
|
||||
name: String,
|
||||
) -> Result<ConditionalRenameLocalProjectResult, String> {
|
||||
let root = validated_local_project_directory_path(project_path.trim())?;
|
||||
rename_local_game_project_if_unchanged_at(&root, &expected_project_id, &expected_name, &name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod conditional_project_rename_tests {
|
||||
use super::*;
|
||||
|
||||
const FALLBACK_NAME: &str = "策划项目 ab12cd34";
|
||||
|
||||
fn fixture_root(project_id: &str) -> tempfile::TempDir {
|
||||
let project = tempfile::tempdir().expect("project tempdir");
|
||||
init_local_game_project_at(project.path(), project_id, FALLBACK_NAME)
|
||||
.expect("initialize project");
|
||||
project
|
||||
}
|
||||
|
||||
fn manifest_name(root: &Path) -> String {
|
||||
read_existing_manifest_for_project(root)
|
||||
.expect("read manifest")
|
||||
.name
|
||||
}
|
||||
|
||||
fn project_revision(root: &Path) -> u64 {
|
||||
read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read revision")
|
||||
.revision
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_rename_applies_when_fallback_name_still_matches() {
|
||||
let project = fixture_root("conditional-rename-project");
|
||||
let root = project.path();
|
||||
let project_id = read_existing_manifest_for_project(root)
|
||||
.expect("read manifest before")
|
||||
.project_id;
|
||||
let revision_before = project_revision(root);
|
||||
|
||||
let result = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"conditional-rename-project",
|
||||
FALLBACK_NAME,
|
||||
"星轨夜航",
|
||||
)
|
||||
.expect("conditional rename applies");
|
||||
|
||||
assert!(result.renamed);
|
||||
assert_eq!(result.manifest.name, "星轨夜航");
|
||||
assert_eq!(result.manifest.project_id, project_id);
|
||||
assert_eq!(manifest_name(root), "星轨夜航");
|
||||
assert_eq!(project_revision(root), result.revision);
|
||||
// 与 rename_local_game_project 同语义:改名是簿记写入,刻意不推项目 revision
|
||||
// (见 projectResourceLiveUpdateModel.ts:推 revision 会让运行时的验证凭证无故漂移),
|
||||
// 所以这里断言改名前后 revision 一致,而不是变大。
|
||||
assert_eq!(result.revision, revision_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_rename_keeps_user_rename_when_expected_name_is_stale() {
|
||||
let project = fixture_root("conditional-rename-user-edited");
|
||||
let root = project.path();
|
||||
// 用户先手动改名:此刻兜底名仍然成立,这一步会落盘。
|
||||
let edited = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"conditional-rename-user-edited",
|
||||
FALLBACK_NAME,
|
||||
"用户自己起的名字",
|
||||
)
|
||||
.expect("user rename applies");
|
||||
assert!(edited.renamed);
|
||||
|
||||
// AI 后台命名这时才回来:它带的兜底名已过期,必须整体跳过。
|
||||
let result = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"conditional-rename-user-edited",
|
||||
FALLBACK_NAME,
|
||||
"星轨夜航",
|
||||
)
|
||||
.expect("stale expected name is not an error");
|
||||
|
||||
assert!(!result.renamed);
|
||||
assert_eq!(result.manifest.name, "用户自己起的名字");
|
||||
assert_eq!(manifest_name(root), "用户自己起的名字");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_rename_skips_when_project_identity_differs() {
|
||||
let project = fixture_root("conditional-rename-project");
|
||||
let root = project.path();
|
||||
let revision_before = project_revision(root);
|
||||
|
||||
let result = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"another-project-id",
|
||||
FALLBACK_NAME,
|
||||
"星轨夜航",
|
||||
)
|
||||
.expect("identity mismatch is not an error");
|
||||
|
||||
assert!(!result.renamed);
|
||||
assert_eq!(result.manifest.project_id, "conditional-rename-project");
|
||||
assert_eq!(result.manifest.name, FALLBACK_NAME);
|
||||
assert_eq!(manifest_name(root), FALLBACK_NAME);
|
||||
assert_eq!(project_revision(root), revision_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_rename_rejects_invalid_name_without_touching_the_project() {
|
||||
let project = fixture_root("conditional-rename-invalid");
|
||||
let root = project.path();
|
||||
let revision_before = project_revision(root);
|
||||
|
||||
for invalid in ["", " ", "带\u{0}控制字符的名"] {
|
||||
let error = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"conditional-rename-invalid",
|
||||
FALLBACK_NAME,
|
||||
invalid,
|
||||
)
|
||||
.expect_err("invalid name must fail closed");
|
||||
assert!(error.contains("项目名称"), "{error}");
|
||||
}
|
||||
|
||||
assert_eq!(manifest_name(root), FALLBACK_NAME);
|
||||
assert_eq!(project_revision(root), revision_before);
|
||||
}
|
||||
|
||||
/// 超长名字同样走 `normalize_game_creation_project_name` 的门禁:条件改名必须失败关闭,
|
||||
/// 且盘上 manifest 与 revision 都不动。
|
||||
#[test]
|
||||
fn conditional_rename_rejects_an_over_long_name_without_touching_the_project() {
|
||||
let project = fixture_root("conditional-rename-too-long");
|
||||
let root = project.path();
|
||||
let revision_before = project_revision(root);
|
||||
|
||||
let too_long = "名".repeat(crate::project::GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1);
|
||||
let error = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"conditional-rename-too-long",
|
||||
FALLBACK_NAME,
|
||||
&too_long,
|
||||
)
|
||||
.expect_err("over-long name must fail closed");
|
||||
assert!(error.contains("项目名称"), "{error}");
|
||||
|
||||
assert_eq!(manifest_name(root), FALLBACK_NAME);
|
||||
assert_eq!(project_revision(root), revision_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_rename_rejects_empty_expectations_without_touching_the_project() {
|
||||
let project = fixture_root("conditional-rename-empty-expectation");
|
||||
let root = project.path();
|
||||
let revision_before = project_revision(root);
|
||||
|
||||
// 空 expectedProjectId / expectedName 与其它按 expected 判等的入口同口径:失败关闭,
|
||||
// 而不是静默地按「不匹配」跳过——空串永远不可能是调用方真正想表达的身份。
|
||||
for (expected_project_id, expected_name, marker) in [
|
||||
("", FALLBACK_NAME, "expectedProjectId"),
|
||||
(" ", FALLBACK_NAME, "expectedProjectId"),
|
||||
("conditional-rename-empty-expectation", "", "expectedName"),
|
||||
(
|
||||
"conditional-rename-empty-expectation",
|
||||
" ",
|
||||
"expectedName",
|
||||
),
|
||||
] {
|
||||
let error = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
expected_project_id,
|
||||
expected_name,
|
||||
"星轨夜航",
|
||||
)
|
||||
.expect_err("empty expectation must fail closed");
|
||||
assert!(error.contains(marker), "{error} 应指出 {marker}");
|
||||
}
|
||||
|
||||
assert_eq!(manifest_name(root), FALLBACK_NAME);
|
||||
assert_eq!(project_revision(root), revision_before);
|
||||
|
||||
// 非空但不匹配仍然是「跳过」这条正常路径:用户已改名不是错误。
|
||||
let skipped = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"conditional-rename-empty-expectation",
|
||||
"用户自己起的名字",
|
||||
"星轨夜航",
|
||||
)
|
||||
.expect("stale but non-empty expectation is not an error");
|
||||
assert!(!skipped.renamed);
|
||||
assert_eq!(skipped.manifest.name, FALLBACK_NAME);
|
||||
assert_eq!(manifest_name(root), FALLBACK_NAME);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_rename_reports_false_when_name_already_matches() {
|
||||
let project = fixture_root("conditional-rename-same-name");
|
||||
let root = project.path();
|
||||
let revision_before = project_revision(root);
|
||||
|
||||
for same in [FALLBACK_NAME, " 策划项目 ab12cd34 "] {
|
||||
let result = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"conditional-rename-same-name",
|
||||
FALLBACK_NAME,
|
||||
same,
|
||||
)
|
||||
.expect("same name is not an error");
|
||||
assert!(!result.renamed);
|
||||
assert_eq!(result.manifest.name, FALLBACK_NAME);
|
||||
}
|
||||
|
||||
assert_eq!(manifest_name(root), FALLBACK_NAME);
|
||||
assert_eq!(project_revision(root), revision_before);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_local_godot_project(
|
||||
project_path: String,
|
||||
|
||||
@@ -550,6 +550,7 @@ pub(super) fn run() {
|
||||
inspect_local_project_directory,
|
||||
pick_local_project_directory,
|
||||
rename_local_game_project,
|
||||
rename_local_game_project_if_unchanged,
|
||||
suggest_automatic_project_name,
|
||||
polish_local_project_prompt,
|
||||
pick_client_extension_file,
|
||||
@@ -652,6 +653,8 @@ pub(super) fn run() {
|
||||
generate_local_project_asset,
|
||||
start_local_project_asset_generation,
|
||||
list_local_project_asset_generations,
|
||||
enqueue_local_project_naming_task,
|
||||
update_local_project_naming_task,
|
||||
open_canvas_project,
|
||||
run_limited_local_command,
|
||||
append_local_permission_log,
|
||||
|
||||
@@ -177,6 +177,16 @@ export interface RenameLocalProjectResult {
|
||||
revision: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 条件改名结果:`renamed === false` 表示宿主按「项目 ID 或当前名称不匹配」整体跳过,
|
||||
* 此时 `manifest` / `revision` 仍是跳过时的现状,调用方不要据此覆盖本地上下文。
|
||||
*/
|
||||
export interface ConditionalRenameLocalProjectResult {
|
||||
renamed: boolean;
|
||||
manifest: GameCreationAppManifest;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export interface LocalProjectDirectoryStatus {
|
||||
projectPath: string;
|
||||
exists: boolean;
|
||||
|
||||
@@ -177,6 +177,7 @@ export function WindowChrome({ children }: WindowChromeProps) {
|
||||
<ActiveProjectRunsPanel
|
||||
activeTurns={activeProjectRuns.activeTurns}
|
||||
currentProjectPath={activeProjectRuns.currentProjectPath}
|
||||
currentProjectName={activeProjectRuns.currentProjectName}
|
||||
readFailed={activeProjectRuns.readFailed}
|
||||
onOpenProject={activeProjectRuns.onOpenProject}
|
||||
placement="titlebar"
|
||||
|
||||
@@ -18,6 +18,12 @@ export type WindowChromeContextValue = {
|
||||
export type WindowChromeActiveProjectRuns = {
|
||||
activeTurns: GameCreatorDirectActiveTurn[];
|
||||
currentProjectPath?: string | null;
|
||||
/**
|
||||
* 当前工作台打开的项目名(清单项目名)。标题栏标签的主文案认这一个:正在运行的项目只在
|
||||
* 徽标和展开菜单里表达,否则「我在哪个项目」会被后台别的项目顶掉。只在项目视图发布:
|
||||
* 离开工作台后置空,标签回落到最近启动的在跑项目。
|
||||
*/
|
||||
currentProjectName?: string | null;
|
||||
readFailed?: boolean;
|
||||
onOpenProject?: (projectPath: string) => void;
|
||||
};
|
||||
|
||||
@@ -6,15 +6,18 @@ import { projectNameFromPath } from '../agent-runtime';
|
||||
import { projectPathsMatchForInvalidation } from '../project-summary/projectPath';
|
||||
|
||||
/**
|
||||
* 窗口标题栏的"正在运行的项目"入口,也保留面板布局供独立组件测试和复用。
|
||||
* 窗口标题栏的项目入口,也保留面板布局供独立组件测试和复用。
|
||||
*
|
||||
* 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连),
|
||||
* 面板只负责呈现:项目名、阶段、已运行时长,以及点击进入该项目。没有在跑回合时
|
||||
* 整块不渲染,不留空白占位。
|
||||
* 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连)。
|
||||
* 标题栏形态的主文案恒为**当前项目**:正在运行的项目只占圆点、数量徽标与展开菜单,
|
||||
* 否则「我在哪个项目」会被后台别的项目顶掉;不在项目内(没有 `currentProjectName`)时
|
||||
* 才回落到最近启动的运行项目。没有在跑回合且没读失败时整块不渲染,不留空白占位。
|
||||
*/
|
||||
export type ActiveProjectRunsPanelProps = {
|
||||
activeTurns: GameCreatorDirectActiveTurn[];
|
||||
currentProjectPath?: string | null;
|
||||
/** 当前工作台打开的项目名;有值即顶栏主文案,运行中的项目不再抢它。 */
|
||||
currentProjectName?: string | null;
|
||||
readFailed?: boolean;
|
||||
onOpenProject?: (projectPath: string) => void;
|
||||
placement?: 'panel' | 'titlebar';
|
||||
@@ -58,6 +61,7 @@ function activeTurnDisplayName(turn: GameCreatorDirectActiveTurn) {
|
||||
export function ActiveProjectRunsPanel({
|
||||
activeTurns,
|
||||
currentProjectPath = null,
|
||||
currentProjectName = null,
|
||||
readFailed = false,
|
||||
onOpenProject,
|
||||
placement = 'panel',
|
||||
@@ -87,11 +91,26 @@ export function ActiveProjectRunsPanel({
|
||||
};
|
||||
}, [open, placement]);
|
||||
|
||||
if (activeTurns.length === 0) {
|
||||
if (!readFailed) {
|
||||
const now = Date.now();
|
||||
const orderedTurns = [...activeTurns].sort(
|
||||
(left, right) => left.startedAt - right.startedAt,
|
||||
);
|
||||
const latestTurn = orderedTurns[orderedTurns.length - 1] ?? null;
|
||||
const runningCount = orderedTurns.length;
|
||||
const currentName = currentProjectName?.trim() ?? '';
|
||||
/**
|
||||
* 标签主文案只认当前项目:正在运行的项目不在标题栏抢主文案,只在徽标和展开菜单里表达。
|
||||
* 不在项目内(没有当前项目名)时才回落到最近启动的运行项目。
|
||||
*/
|
||||
const primaryName =
|
||||
currentName || (latestTurn ? activeTurnDisplayName(latestTurn) : '');
|
||||
|
||||
if (placement === 'titlebar') {
|
||||
if (!latestTurn && !readFailed) {
|
||||
return null;
|
||||
}
|
||||
if (placement === 'titlebar') {
|
||||
// 读不到快照、又没有当前项目可显示:保持原来那条只读提示,不假装成可展开入口。
|
||||
if (!primaryName) {
|
||||
return (
|
||||
<span
|
||||
className="launcher-runs-titlebar launcher-runs-titlebar--error"
|
||||
@@ -101,26 +120,18 @@ export function ActiveProjectRunsPanel({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。
|
||||
return (
|
||||
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
|
||||
<span className="launcher-runs-panel-note" role="status">
|
||||
未能读取正在运行的项目
|
||||
</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const orderedTurns = [...activeTurns].sort(
|
||||
(left, right) => left.startedAt - right.startedAt,
|
||||
);
|
||||
if (placement === 'titlebar') {
|
||||
const latestTurn = orderedTurns[orderedTurns.length - 1];
|
||||
if (!latestTurn) {
|
||||
return null;
|
||||
}
|
||||
const latestName = activeTurnDisplayName(latestTurn);
|
||||
/**
|
||||
* 标签提示 = 作用域 + 运行状态,分成两段是为了不在一句话里重复「正在运行」:
|
||||
* 回落态的主文案本身就是「最近启动的在跑项目」,状态位再写一遍会读成「正在运行的项目:X;
|
||||
* 正在运行 N 个项目」。
|
||||
*/
|
||||
const scopeLabel = currentName
|
||||
? `当前项目:${primaryName}`
|
||||
: `最近运行的项目:${primaryName}`;
|
||||
const runStateLabel = readFailed
|
||||
? '正在运行的项目读取失败'
|
||||
: `共 ${runningCount} 个项目在运行`;
|
||||
const triggerLabel = `${scopeLabel};${runStateLabel}`;
|
||||
const openProject = (projectPath: string) => {
|
||||
setOpen(false);
|
||||
onOpenProject?.(projectPath);
|
||||
@@ -129,23 +140,30 @@ export function ActiveProjectRunsPanel({
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="launcher-runs-titlebar"
|
||||
data-active-project-count={orderedTurns.length}
|
||||
data-active-project-count={runningCount}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-runs-titlebar-trigger"
|
||||
aria-label={`正在运行的项目:${latestName}`}
|
||||
aria-label={triggerLabel}
|
||||
title={triggerLabel}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span className="launcher-runs-panel-dot" aria-hidden="true" />
|
||||
<span className="launcher-runs-titlebar-name" title={latestName}>
|
||||
{latestName}
|
||||
<span
|
||||
className={`launcher-runs-panel-dot${readFailed ? ' is-warning' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="launcher-runs-titlebar-name" title={primaryName}>
|
||||
{primaryName}
|
||||
</span>
|
||||
{orderedTurns.length > 1 ? (
|
||||
<span className="launcher-runs-titlebar-count">
|
||||
{orderedTurns.length}
|
||||
{runningCount > 0 ? (
|
||||
<span
|
||||
className="launcher-runs-titlebar-count"
|
||||
title={`${runningCount} 个项目正在运行`}
|
||||
>
|
||||
{runningCount}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
@@ -158,51 +176,83 @@ export function ActiveProjectRunsPanel({
|
||||
<div className="launcher-runs-titlebar-menu" role="menu">
|
||||
<div className="launcher-runs-titlebar-menu-header">
|
||||
<strong>正在运行的项目</strong>
|
||||
<span>{orderedTurns.length} 个</span>
|
||||
{/* 头部只报数量:读取失败的结论由下面那行 role=status 独占,避免同一句连排两遍。 */}
|
||||
<span>{runningCount > 0 ? `${runningCount} 个` : null}</span>
|
||||
</div>
|
||||
<ul className="launcher-runs-titlebar-list">
|
||||
{orderedTurns.map((turn) => {
|
||||
const name = activeTurnDisplayName(turn);
|
||||
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
|
||||
const isCurrent = Boolean(
|
||||
currentProjectPath &&
|
||||
projectPathsMatchForInvalidation(
|
||||
turn.projectPath,
|
||||
currentProjectPath,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<li key={`${turn.projectPath}:${turn.turnId}`}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="launcher-runs-titlebar-item"
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
disabled={!onOpenProject}
|
||||
onClick={() => openProject(turn.projectPath)}
|
||||
>
|
||||
<span
|
||||
className="launcher-runs-titlebar-item-name"
|
||||
title={name}
|
||||
{readFailed ? (
|
||||
<p className="launcher-runs-titlebar-menu-note" role="status">
|
||||
正在运行的项目读取失败
|
||||
</p>
|
||||
) : null}
|
||||
{runningCount > 0 ? (
|
||||
<ul className="launcher-runs-titlebar-list">
|
||||
{orderedTurns.map((turn) => {
|
||||
const isCurrent = Boolean(
|
||||
currentProjectPath &&
|
||||
projectPathsMatchForInvalidation(
|
||||
turn.projectPath,
|
||||
currentProjectPath,
|
||||
),
|
||||
);
|
||||
// 当前项目这一行与标签主文案同名:同一个项目在不同的运行状态下
|
||||
// 显示两个名字,用户没法把"标签上那个"和"列表里这个"对上。
|
||||
const name =
|
||||
isCurrent && currentName
|
||||
? currentName
|
||||
: activeTurnDisplayName(turn);
|
||||
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
|
||||
return (
|
||||
<li key={`${turn.projectPath}:${turn.turnId}`}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="launcher-runs-titlebar-item"
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
disabled={!onOpenProject}
|
||||
onClick={() => openProject(turn.projectPath)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<span className="launcher-runs-titlebar-item-meta">
|
||||
{[activeTurnStatusLabel(turn.status), elapsed]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<span
|
||||
className="launcher-runs-titlebar-item-name"
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<span className="launcher-runs-titlebar-item-meta">
|
||||
{[
|
||||
isCurrent ? '当前' : null,
|
||||
activeTurnStatusLabel(turn.status),
|
||||
elapsed,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (runningCount === 0) {
|
||||
// 快照读到了、只是没有在跑回合:整块不渲染,不能报成读取失败。
|
||||
if (!readFailed) {
|
||||
return null;
|
||||
}
|
||||
// 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。
|
||||
return (
|
||||
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
|
||||
<span className="launcher-runs-panel-note" role="status">
|
||||
未能读取正在运行的项目
|
||||
</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
|
||||
<header className="launcher-runs-panel-header">
|
||||
@@ -211,8 +261,6 @@ export function ActiveProjectRunsPanel({
|
||||
</header>
|
||||
<ul className="launcher-runs-panel-list">
|
||||
{orderedTurns.map((turn) => {
|
||||
const name = activeTurnDisplayName(turn);
|
||||
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
|
||||
const isCurrent = Boolean(
|
||||
currentProjectPath &&
|
||||
projectPathsMatchForInvalidation(
|
||||
@@ -220,6 +268,11 @@ export function ActiveProjectRunsPanel({
|
||||
currentProjectPath,
|
||||
),
|
||||
);
|
||||
const name =
|
||||
isCurrent && currentName
|
||||
? currentName
|
||||
: activeTurnDisplayName(turn);
|
||||
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
|
||||
return (
|
||||
<li key={`${turn.projectPath}:${turn.turnId}`}>
|
||||
<button
|
||||
@@ -233,7 +286,11 @@ export function ActiveProjectRunsPanel({
|
||||
{name}
|
||||
</span>
|
||||
<span className="launcher-runs-panel-meta">
|
||||
{[activeTurnStatusLabel(turn.status), elapsed]
|
||||
{[
|
||||
isCurrent ? '当前' : null,
|
||||
activeTurnStatusLabel(turn.status),
|
||||
elapsed,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
|
||||
@@ -137,6 +137,7 @@ export function WorkspaceLauncherShell({
|
||||
showTransientNotice,
|
||||
setLauncherView,
|
||||
rememberRecentWorkspace,
|
||||
refreshRecentWorkspace: recentProjects.refreshRecentWorkspace,
|
||||
});
|
||||
const templateLibrary = useTemplateLibrary({
|
||||
userId: currentUser.id,
|
||||
@@ -300,16 +301,31 @@ export function WorkspaceLauncherShell({
|
||||
openActiveProjectRef.current = openActiveProject;
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* 只在项目视图发布"当前项目"身份。
|
||||
*
|
||||
* `currentProjectContext` 是会话级状态:离开工作台(首页 / 项目组 / 模板库)不会被清空,
|
||||
* 而活动回合快照是全局轮询。若照搬上下文,回首页后标签会一直是「当前项目:<上次打开的项目>」,
|
||||
* 徽标数字却属于别的项目;和同文件里按 `launcherView` 门控的窗口标题 effect 也对不上。
|
||||
*/
|
||||
const inProjectView = launcherView === 'project-development';
|
||||
setActiveProjectRuns({
|
||||
activeTurns,
|
||||
currentProjectPath: currentProjectContext?.projectPath ?? null,
|
||||
currentProjectPath: inProjectView
|
||||
? (currentProjectContext?.projectPath ?? null)
|
||||
: null,
|
||||
currentProjectName: inProjectView
|
||||
? (currentProjectContext?.projectName ?? null)
|
||||
: null,
|
||||
readFailed: snapshotReadFailed,
|
||||
onOpenProject: (projectPath: string) =>
|
||||
openActiveProjectRef.current(projectPath),
|
||||
});
|
||||
}, [
|
||||
activeTurns,
|
||||
currentProjectContext?.projectName,
|
||||
currentProjectContext?.projectPath,
|
||||
launcherView,
|
||||
setActiveProjectRuns,
|
||||
snapshotReadFailed,
|
||||
]);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createLocalProjectId } from '../../app/constants';
|
||||
import { useEscapeToClose } from '../../app/dialogs';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type {
|
||||
ConditionalRenameLocalProjectResult,
|
||||
InitLocalProjectResult,
|
||||
LauncherImportedAttachment,
|
||||
LauncherProjectContext,
|
||||
@@ -84,11 +85,23 @@ type UseHomeProjectCreationOptions = {
|
||||
showTransientNotice: ShowTransientNotice;
|
||||
setLauncherView: Dispatch<SetStateAction<LauncherView>>;
|
||||
rememberRecentWorkspace: (projectPath: string) => void;
|
||||
/**
|
||||
* 最近项目行的名称来自宿主目录重检(`inspect_local_project_directory` 读 manifest),
|
||||
* 不是内存投影:后台 AI 改名成功后必须显式重检这一行,列表才会同步到新名字。
|
||||
*/
|
||||
refreshRecentWorkspace?: (projectPath: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const DESIGN_ARTIFACTS_BUILD_PROMPT =
|
||||
'查看当前项目 design_artifacts/ 目录及其子目录下的文档,理解其游戏设计,并按照这些文档将游戏实现出来。';
|
||||
|
||||
/**
|
||||
* 发起一次 AI 项目命名请求并返回其结果 Promise。
|
||||
*
|
||||
* 调用方**不要 await 它再建项目**:命名只是增强能力,工作区创建、附件导入和首轮创作都不依赖它。
|
||||
* 这里返回的 Promise 由后台改名任务消费(见 `applyAutomaticProjectName`);配置缺失、Provider
|
||||
* 失败、超时或非法输出都解析为 `null`,由调用方保留兜底名称。
|
||||
*/
|
||||
async function suggestAutomaticProjectName(
|
||||
invoke: TauriInvoke,
|
||||
draft: HomeDraft,
|
||||
@@ -104,6 +117,58 @@ async function suggestAutomaticProjectName(
|
||||
}
|
||||
}
|
||||
|
||||
/** 「AI 项目命名」任务的结论文案:结论只在这条链路里,所以文案由命名链路给定。 */
|
||||
const PROJECT_NAMING_FAILED_PHASE =
|
||||
'AI 命名请求失败或没有返回可用名称,已保留兜底名。';
|
||||
const PROJECT_NAMING_UNCHANGED_PHASE = 'AI 建议名与当前名称一致,无需改名。';
|
||||
const PROJECT_NAMING_RENAME_FAILED_PHASE = '自动改名失败,已保留兜底名。';
|
||||
const PROJECT_NAMING_SKIPPED_PHASE =
|
||||
'当前名称已不是本次创建的兜底名(用户已手动改名),已跳过 AI 命名。';
|
||||
|
||||
/**
|
||||
* 推进「生成任务」列表里的那条「AI 项目命名」任务。
|
||||
*
|
||||
* 账本是**展示旁路**:旧壳没有这两条命令、账本写盘失败或权限拒绝都不该影响命名与建项,
|
||||
* 所以失败只写诊断日志、绝不向上抛。
|
||||
*
|
||||
* 两条命令名都写成**字面量**:渲染层的 invoke 目标必须能被 `scripts/check-config.mjs` 静态枚举
|
||||
* (命令名走变量会让门禁报「App invoke 缺命令」)。
|
||||
*/
|
||||
async function enqueueProjectNamingTask(
|
||||
invoke: TauriInvoke,
|
||||
input: { projectPath: string; taskId: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
await invoke('enqueue_local_project_naming_task', input);
|
||||
} catch (error) {
|
||||
appendWebviewLog('warn', [
|
||||
`[home-project-name] enqueue_local_project_naming_task: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProjectNamingTask(
|
||||
invoke: TauriInvoke,
|
||||
input: {
|
||||
projectPath: string;
|
||||
taskId: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
phaseDetail?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await invoke('update_local_project_naming_task', input);
|
||||
} catch (error) {
|
||||
appendWebviewLog('warn', [
|
||||
`[home-project-name] update_local_project_naming_task: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把浏览器 File 上传进项目并登记为资产,返回带项目相对路径的附件记录。
|
||||
*
|
||||
@@ -185,6 +250,7 @@ export function useHomeProjectCreation({
|
||||
showTransientNotice,
|
||||
setLauncherView,
|
||||
rememberRecentWorkspace,
|
||||
refreshRecentWorkspace,
|
||||
}: UseHomeProjectCreationOptions) {
|
||||
const lifecycleRef = useRef({ mounted: false, generation: 0 });
|
||||
const analyticsProjectPathRef = useRef<string | null>(null);
|
||||
@@ -415,6 +481,127 @@ export function useHomeProjectCreation({
|
||||
resetLauncherHomeDraft();
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台把 AI 建议名落到已创建的工作区。
|
||||
*
|
||||
* 这条链路同时是「生成任务」列表里那条「AI 项目命名」任务的推进者:
|
||||
* `enqueue_local_project_naming_task`(排队中)→ `update_…(running)` → 终态
|
||||
* (completed / failed)。任务行与素材生成共用同一份账本、同一个变更事件与同一个侧栏,
|
||||
* 状态流转对用户可见;账本推进失败只写诊断日志,绝不影响命名与建项本身。
|
||||
*
|
||||
* 与建项并行发起(`naming` 在建项前就已经打出请求);改名落盘本身不等任何东西,
|
||||
* 但「项目上下文」与「最近项目行」这两项都建立在进项目之后:AI 比进项目更快时直接写上下文
|
||||
* 会被随后的 `enterProjectDevelopment` 用兜底名覆盖,而最近项目行也只有进项目登记过才会被
|
||||
* 重检。所以它们等 `entrySettled`(建项主体收尾,含进项目成功/失败/让位于别的项目)。
|
||||
* - 宿主侧条件改名要求「项目 ID 相同」且「当前名称仍是这次创建时的兜底名称」;用户已手动改名时
|
||||
* 返回 `renamed: false`,这里整体跳过、绝不覆盖用户输入,任务按「已完成(已跳过)」收口。
|
||||
* - AI 返回空/非法/失败(Promise 解析为 null)时保持兜底名称,任务收口为 failed。
|
||||
*/
|
||||
async function applyAutomaticProjectName(
|
||||
invoke: TauriInvoke,
|
||||
naming: Promise<string | null>,
|
||||
created: InitLocalProjectResult,
|
||||
entrySettled: Promise<void>,
|
||||
) {
|
||||
const generation = lifecycleRef.current.generation;
|
||||
const namingTask = {
|
||||
projectPath: created.projectPath,
|
||||
// 一个项目一条命名任务:重开后账本里读到的还是同一行,不会越攒越多。
|
||||
taskId: `project-naming:${created.manifest.projectId}`,
|
||||
};
|
||||
await enqueueProjectNamingTask(invoke, namingTask);
|
||||
await updateProjectNamingTask(invoke, { ...namingTask, status: 'running' });
|
||||
let suggested: string | null = null;
|
||||
try {
|
||||
suggested = (await naming)?.trim() || null;
|
||||
} catch {
|
||||
suggested = null;
|
||||
}
|
||||
// 没有可用的建议名:保留兜底名,任务按失败收口(AI 失败 / 超时 / 非法内容同一条路径)。
|
||||
if (!suggested) {
|
||||
await updateProjectNamingTask(invoke, {
|
||||
...namingTask,
|
||||
status: 'failed',
|
||||
phaseDetail: PROJECT_NAMING_FAILED_PHASE,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 建议名就是兜底名:没有可改的,别白跑一次写操作,按完成收口并说明原因。
|
||||
if (suggested === created.manifest.name) {
|
||||
await updateProjectNamingTask(invoke, {
|
||||
...namingTask,
|
||||
status: 'completed',
|
||||
phaseDetail: PROJECT_NAMING_UNCHANGED_PHASE,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let outcome: ConditionalRenameLocalProjectResult;
|
||||
try {
|
||||
outcome = await invoke<ConditionalRenameLocalProjectResult>(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
{
|
||||
projectPath: created.projectPath,
|
||||
expectedProjectId: created.manifest.projectId,
|
||||
expectedName: created.manifest.name,
|
||||
name: suggested,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
appendWebviewLog('warn', [
|
||||
`[home-project-name] ${error instanceof Error ? error.message : String(error)}`,
|
||||
]);
|
||||
await updateProjectNamingTask(invoke, {
|
||||
...namingTask,
|
||||
status: 'failed',
|
||||
phaseDetail: PROJECT_NAMING_RENAME_FAILED_PHASE,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!outcome?.renamed) {
|
||||
// 用户在结果回来之前已经手动改过名(或项目身份已变):跳过,并如实说明跳过原因。
|
||||
await updateProjectNamingTask(invoke, {
|
||||
...namingTask,
|
||||
status: 'completed',
|
||||
phaseDetail: PROJECT_NAMING_SKIPPED_PHASE,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await updateProjectNamingTask(invoke, {
|
||||
...namingTask,
|
||||
status: 'completed',
|
||||
phaseDetail: `已应用 AI 名称「${outcome.manifest.name}」。`,
|
||||
});
|
||||
await entrySettled;
|
||||
/**
|
||||
* 等待期间壳可能已经被卸载(关窗 / 切走 / StrictMode 重放)。
|
||||
*
|
||||
* 落盘的改名是项目级事实(换台机器打开也是 AI 名),照旧保留;但「当前项目上下文」与
|
||||
* 「最近项目行重检」都是本壳的 UI 投影,卸载后一律不写——与 `enterProjectDevelopment`
|
||||
* 的守卫同口径(`mounted` + 代次),否则迟到的命名结果会写进已经作废的壳。
|
||||
*/
|
||||
if (
|
||||
!lifecycleRef.current.mounted ||
|
||||
generation !== lifecycleRef.current.generation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setCurrentProjectContext((current) =>
|
||||
current && current.projectPath === created.projectPath
|
||||
? {
|
||||
...current,
|
||||
projectName: outcome.manifest.name,
|
||||
manifest: outcome.manifest,
|
||||
projectRevision: outcome.revision,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
try {
|
||||
await refreshRecentWorkspace?.(created.projectPath);
|
||||
} catch {
|
||||
// 最近项目行是辅助投影:重检失败不影响已经生效的改名与工作台标题。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建失败的统一收口。
|
||||
*
|
||||
@@ -834,8 +1021,9 @@ export function useHomeProjectCreation({
|
||||
startMode: ProjectStartMode,
|
||||
) {
|
||||
return createHomeDraftAutomaticallyWithOptions(draft, startMode, {
|
||||
// 做方案的首轮还要调用一次策划 Provider;项目名称不是策划输入的
|
||||
// 前置条件,避免在进入工作区前再额外等待一次模型请求。
|
||||
// 判据就是 `ProjectStartMode`:'planning' 覆盖「做方案」和勾了「策划补全」的「做游戏」
|
||||
// (见 view/home/homeStartMode.ts),这一档只调策划 Provider,项目命名对它没有价值;
|
||||
// 只有 'direct-build' 才发起自动项目命名请求。
|
||||
suggestName: startMode !== 'planning',
|
||||
});
|
||||
}
|
||||
@@ -899,6 +1087,17 @@ export function useHomeProjectCreation({
|
||||
resolve(HOME_CREATION_WATCHDOG_MESSAGE);
|
||||
}, HOME_CREATION_DEADLINE_MS);
|
||||
});
|
||||
/**
|
||||
* 「建项主体收尾」信号(进项目成功、进项目失败、或让位于别的项目都算)。
|
||||
*
|
||||
* 后台 AI 改名可能比进项目更快:那时工作区上下文还没建立,直接写上下文会被随后的
|
||||
* `enterProjectDevelopment` 覆盖回兜底名,最近项目行也还没被登记进列表。改名落盘照旧立即执行,
|
||||
* 只有这两项依赖进项目结果的更新等这枚信号。
|
||||
*/
|
||||
let settleEntry!: () => void;
|
||||
const entrySettled = new Promise<void>((resolve) => {
|
||||
settleEntry = resolve;
|
||||
});
|
||||
/** 后台继续跑的建项主体:用户可见的等待由 `watchdog` 兜底,这里只负责最终落定。 */
|
||||
const creation = (async () => {
|
||||
try {
|
||||
@@ -919,17 +1118,30 @@ export function useHomeProjectCreation({
|
||||
);
|
||||
return message;
|
||||
}
|
||||
const suggestedName = options.suggestName
|
||||
? await suggestAutomaticProjectName(invoke, draft)
|
||||
// 命名与建项并行:先把命名请求打出去,再建工作区,两者互不等待。命名结果由后台任务
|
||||
// 落定(见 `applyAutomaticProjectName`),用户不必为了一个名字多等一次模型请求。
|
||||
const automaticName = options.suggestName
|
||||
? suggestAutomaticProjectName(invoke, draft)
|
||||
: null;
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'create_automatic_local_game_project',
|
||||
{
|
||||
name: suggestedName,
|
||||
// 不传名:走宿主兜底名("GameAgent 项目 <id>" / "策划项目 <id>"),
|
||||
// AI 建议名回来后再按「名称仍是兜底名」的条件改名。
|
||||
name: null,
|
||||
planning: startMode === 'planning',
|
||||
},
|
||||
);
|
||||
createdProjectPath = result.projectPath;
|
||||
if (automaticName) {
|
||||
// 纯后台任务:进入工作区、导入附件与首轮创作都不等它。
|
||||
void applyAutomaticProjectName(
|
||||
invoke,
|
||||
automaticName,
|
||||
result,
|
||||
entrySettled,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'project', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
@@ -990,6 +1202,8 @@ export function useHomeProjectCreation({
|
||||
}
|
||||
throw failHomeCreation(error, Boolean(createdProjectPath));
|
||||
} finally {
|
||||
// 进项目(或放弃进项目)已经尘埃落定,后台改名可以安全地写上下文与最近项目。
|
||||
settleEntry();
|
||||
if (watchdogId !== undefined) {
|
||||
window.clearTimeout(watchdogId);
|
||||
}
|
||||
|
||||
+117
-10
@@ -6,11 +6,14 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLiveNow } from '../project-workspace/useLiveNow';
|
||||
import {
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS,
|
||||
RESOURCE_CANVAS_PROJECT_NAMING_STATUS_LABELS,
|
||||
RESOURCE_CANVAS_PROJECT_NAMING_TASK_TITLE,
|
||||
resourceCanvasAssetGenerationElapsedLabel,
|
||||
type ResourceCanvasAssetGenerationTask,
|
||||
resourceCanvasAssetGenerationTaskElapsedMillis,
|
||||
resourceCanvasAssetGenerationTaskIsTerminal,
|
||||
resourceCanvasAssetGenerationTaskTone,
|
||||
type ResourceCanvasProjectNamingTask,
|
||||
sortResourceCanvasAssetGenerationTasks,
|
||||
} from './resourceCanvasAssetGenerationTaskModel';
|
||||
import {
|
||||
@@ -39,6 +42,13 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
|
||||
* 账本渲染。缺省为空数组:老调用方不传就没有这一段。
|
||||
*/
|
||||
resourceEditTasks?: readonly ResourceCanvasResourceEditTask[];
|
||||
/**
|
||||
* 「AI 项目命名」任务(首页建项后的后台自动命名,账本里 `taskType = project-naming`)。
|
||||
*
|
||||
* 与素材生成共用同一份账本、同一个变更事件与同一个侧栏:展示名固定为「AI 项目命名」,不渲染
|
||||
* 素材缩略图 / 提示词 / 派发按钮,也不参与「定位到素材」。缺省为空数组。
|
||||
*/
|
||||
namingTasks?: readonly ResourceCanvasProjectNamingTask[];
|
||||
/** 侧栏是否展开;折叠时只留贴边把手。 */
|
||||
open: boolean;
|
||||
onToggleOpen: () => void;
|
||||
@@ -75,22 +85,37 @@ type ResourceCanvasGenerationTaskRow =
|
||||
readonly source: 'resource-edit';
|
||||
readonly createdAtMillis: number;
|
||||
readonly task: ResourceCanvasResourceEditTask;
|
||||
}
|
||||
| {
|
||||
readonly source: 'project-naming';
|
||||
readonly createdAtMillis: number;
|
||||
readonly task: ResourceCanvasProjectNamingTask;
|
||||
};
|
||||
|
||||
function resourceCanvasGenerationTaskRowKey(
|
||||
row: ResourceCanvasGenerationTaskRow,
|
||||
): string {
|
||||
return row.source === 'asset-generation'
|
||||
? row.task.taskId
|
||||
: row.task.operationId;
|
||||
switch (row.source) {
|
||||
case 'asset-generation':
|
||||
return row.task.taskId;
|
||||
case 'resource-edit':
|
||||
return row.task.operationId;
|
||||
default:
|
||||
return row.task.taskId;
|
||||
}
|
||||
}
|
||||
|
||||
function resourceCanvasGenerationTaskRowIsTerminal(
|
||||
row: ResourceCanvasGenerationTaskRow,
|
||||
): boolean {
|
||||
return row.source === 'asset-generation'
|
||||
? resourceCanvasAssetGenerationTaskIsTerminal(row.task)
|
||||
: resourceCanvasResourceEditTaskIsTerminal(row.task);
|
||||
switch (row.source) {
|
||||
case 'asset-generation':
|
||||
return resourceCanvasAssetGenerationTaskIsTerminal(row.task);
|
||||
case 'resource-edit':
|
||||
return resourceCanvasResourceEditTaskIsTerminal(row.task);
|
||||
default:
|
||||
return row.task.status === 'completed' || row.task.status === 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
function sortResourceCanvasGenerationTaskRows(
|
||||
@@ -256,13 +281,89 @@ function resourceEditTaskRow(task: ResourceCanvasResourceEditTask) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「AI 项目命名」行的已耗时。
|
||||
*
|
||||
* 与素材任务 / 派生任务共用同一份 `useLiveNow` 口径(终态定格在账本结束时间上)。
|
||||
* **必须是一个组件**:行本身由函数返回,把 hook 直接留在行函数里会让「行数与行种类」的变化
|
||||
* 变成 hooks 数量的变化(React 直接抛「Rendered more hooks than during the previous render」)。
|
||||
*/
|
||||
function ProjectNamingElapsedLabel({
|
||||
task,
|
||||
}: {
|
||||
task: ResourceCanvasProjectNamingTask;
|
||||
}) {
|
||||
const terminal = task.status === 'completed' || task.status === 'failed';
|
||||
const now = useLiveNow(!terminal);
|
||||
return (
|
||||
<span className="game-resource-generation-task-card-elapsed">
|
||||
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
|
||||
resourceCanvasAssetGenerationTaskElapsedMillis(task, now),
|
||||
)}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「AI 项目命名」任务的一行(首页建项后的后台自动命名)。
|
||||
*
|
||||
* 与素材任务同一套卡片 class 与 tone,但语义上刻意更窄:**展示名固定**为
|
||||
* 「AI 项目命名」,没有缩略图、没有提示词、没有派发/定位动作。收口结论(`phaseDetail`,
|
||||
* 由后端拥有:已应用 / 用户已改名跳过 / 失败保留兜底名)**只在终态渲染**——进行中由状态徽章
|
||||
* 表达,用户不需要看素材通道那种阶段进度文案。
|
||||
*/
|
||||
function projectNamingTaskRow(task: ResourceCanvasProjectNamingTask) {
|
||||
const terminal = task.status === 'completed' || task.status === 'failed';
|
||||
return (
|
||||
<li
|
||||
key={task.taskId}
|
||||
className="game-resource-generation-task-card"
|
||||
data-task-status={task.status}
|
||||
data-task-source="project-naming"
|
||||
>
|
||||
<div className="game-resource-generation-task-card-title-row">
|
||||
<strong className="game-resource-generation-task-card-name">
|
||||
{RESOURCE_CANVAS_PROJECT_NAMING_TASK_TITLE}
|
||||
</strong>
|
||||
<span className="game-resource-generation-task-card-action">
|
||||
AI 起名
|
||||
</span>
|
||||
</div>
|
||||
<div className="game-resource-generation-task-card-meta">
|
||||
<span
|
||||
className="game-resource-generation-task-badge"
|
||||
data-tone={resourceCanvasAssetGenerationTaskTone(task.status)}
|
||||
>
|
||||
{RESOURCE_CANVAS_PROJECT_NAMING_STATUS_LABELS[task.status]}
|
||||
</span>
|
||||
{terminal ? (
|
||||
<span className="game-resource-generation-task-card-phase">
|
||||
{task.phaseDetail}
|
||||
</span>
|
||||
) : null}
|
||||
<ProjectNamingElapsedLabel task={task} />
|
||||
</div>
|
||||
{task.error ? (
|
||||
<p className="game-resource-generation-task-card-error" role="alert">
|
||||
{task.error}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function resourceCanvasGenerationTaskRowNode(
|
||||
row: ResourceCanvasGenerationTaskRow,
|
||||
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
|
||||
) {
|
||||
return row.source === 'asset-generation'
|
||||
? assetGenerationTaskRow(row.task, onFocusTask)
|
||||
: resourceEditTaskRow(row.task);
|
||||
switch (row.source) {
|
||||
case 'asset-generation':
|
||||
return assetGenerationTaskRow(row.task, onFocusTask);
|
||||
case 'resource-edit':
|
||||
return resourceEditTaskRow(row.task);
|
||||
default:
|
||||
return projectNamingTaskRow(row.task);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,6 +386,7 @@ function resourceCanvasGenerationTaskRowNode(
|
||||
export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
tasks,
|
||||
resourceEditTasks = [],
|
||||
namingTasks = [],
|
||||
open,
|
||||
onToggleOpen,
|
||||
onFocusTask,
|
||||
@@ -315,8 +417,13 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
createdAtMillis: task.createdAtMillis,
|
||||
task,
|
||||
})),
|
||||
...namingTasks.map((task) => ({
|
||||
source: 'project-naming' as const,
|
||||
createdAtMillis: task.createdAtMillis,
|
||||
task,
|
||||
})),
|
||||
]),
|
||||
[resourceEditTasks, tasks],
|
||||
[namingTasks, resourceEditTasks, tasks],
|
||||
);
|
||||
const active = ordered.filter(
|
||||
(row) => !resourceCanvasGenerationTaskRowIsTerminal(row),
|
||||
|
||||
+128
-2
@@ -25,7 +25,15 @@ export type ResourceCanvasAssetGenerationTaskStatus =
|
||||
export type LocalProjectAssetGenerationTaskRecord = {
|
||||
taskId: string;
|
||||
projectId: string;
|
||||
kind: GameCreationAppAssetKind;
|
||||
/**
|
||||
* 这条任务属于哪一类:素材生成(既有)或项目命名(首页建项后的 AI 命名)。
|
||||
*
|
||||
* 旧壳(账本 v1)没有这个字段,一律按 `asset-generation` 读——见
|
||||
* `resolveLocalProjectGenerationTaskType`。
|
||||
*/
|
||||
taskType?: LocalProjectGenerationTaskType | null;
|
||||
/** 素材类任务才有;命名任务为 null。 */
|
||||
kind?: GameCreationAppAssetKind | null;
|
||||
assetName: string;
|
||||
status: string;
|
||||
phaseDetail: string;
|
||||
@@ -36,6 +44,34 @@ export type LocalProjectAssetGenerationTaskRecord = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 账本里的任务类型。
|
||||
*
|
||||
* 两者共用同一份账本、同一个变更事件与同一个「生成任务」侧栏:素材生成按后端账本推进,
|
||||
* 项目命名由首页后台命名链路推进(`enqueue_local_project_naming_task` /
|
||||
* `update_local_project_naming_task`)。命名任务不派发素材生成请求、没有素材缩略图与提示词。
|
||||
*/
|
||||
export type LocalProjectGenerationTaskType =
|
||||
| 'asset-generation'
|
||||
| 'project-naming';
|
||||
|
||||
export const LOCAL_PROJECT_GENERATION_TASK_TYPE_ASSET_GENERATION =
|
||||
'asset-generation';
|
||||
|
||||
export const LOCAL_PROJECT_GENERATION_TASK_TYPE_PROJECT_NAMING =
|
||||
'project-naming';
|
||||
|
||||
/** 「生成任务」列表里「AI 项目命名」这条任务的固定展示名。 */
|
||||
export const RESOURCE_CANVAS_PROJECT_NAMING_TASK_TITLE = 'AI 项目命名';
|
||||
|
||||
export function resolveLocalProjectGenerationTaskType(
|
||||
value: string | null | undefined,
|
||||
): LocalProjectGenerationTaskType {
|
||||
return value === LOCAL_PROJECT_GENERATION_TASK_TYPE_PROJECT_NAMING
|
||||
? 'project-naming'
|
||||
: 'asset-generation';
|
||||
}
|
||||
|
||||
/** 宿主「生成任务」列表里的一条:本地排队信息 + 后端账本记录。 */
|
||||
export type ResourceCanvasAssetGenerationTask = {
|
||||
/** 本地任务 id,同时作为提交给后端的 taskId(重开项目后靠它对上账本记录)。 */
|
||||
@@ -373,6 +409,92 @@ export function restoreResourceCanvasAssetGenerationTask(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 「生成任务」列表里的一条「AI 项目命名」任务。
|
||||
*
|
||||
* 它与素材生成共用同一份账本、同一个变更事件与同一个侧栏行样式,但**只**保留状态流转需要的
|
||||
* 字段:没有素材缩略图、没有提示词、没有比例/落点,也不参与素材派发与「定位到素材」。
|
||||
*/
|
||||
export type ResourceCanvasProjectNamingTask = {
|
||||
taskId: string;
|
||||
projectId: string;
|
||||
status: ResourceCanvasAssetGenerationTaskStatus;
|
||||
/**
|
||||
* 收口结论(后端拥有)。**只在终态渲染**:草稿期用状态徽章表达,用户不需要看「排队中。」这类
|
||||
* 素材通道的阶段文案。
|
||||
*/
|
||||
phaseDetail: string;
|
||||
createdAtMillis: number;
|
||||
startedAtMillis: number | null;
|
||||
finishedAtMillis: number | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
/** 命名任务的状态文案:只有 `running` 与素材生成(「生成中」)不同。 */
|
||||
export const RESOURCE_CANVAS_PROJECT_NAMING_STATUS_LABELS: Record<
|
||||
ResourceCanvasAssetGenerationTaskStatus,
|
||||
string
|
||||
> = {
|
||||
queued: '排队中',
|
||||
running: '命名中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
/** 账本记录 → 命名任务行(重开项目、或入队当帧从账本读回时恢复显示)。 */
|
||||
export function restoreResourceCanvasProjectNamingTask(
|
||||
record: LocalProjectAssetGenerationTaskRecord,
|
||||
): ResourceCanvasProjectNamingTask {
|
||||
return {
|
||||
taskId: record.taskId,
|
||||
projectId: record.projectId,
|
||||
status: resolveResourceCanvasAssetGenerationTaskStatus(record.status),
|
||||
phaseDetail: record.phaseDetail,
|
||||
createdAtMillis: record.createdAtMillis,
|
||||
startedAtMillis: record.startedAtMillis,
|
||||
finishedAtMillis: record.finishedAtMillis,
|
||||
error: record.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一次账本读取按任务类型拆开。
|
||||
*
|
||||
* 旧壳(账本 v1)的记录没有 `taskType`,一律按素材生成读;命名记录**不能**进素材任务列表
|
||||
* (那里会按 `kind` 解析并参与派发)。
|
||||
*/
|
||||
export function splitLocalProjectGenerationTaskRecords(
|
||||
records: readonly LocalProjectAssetGenerationTaskRecord[] | null | undefined,
|
||||
): {
|
||||
assetRecords: LocalProjectAssetGenerationTaskRecord[];
|
||||
namingRecords: LocalProjectAssetGenerationTaskRecord[];
|
||||
} {
|
||||
const assetRecords: LocalProjectAssetGenerationTaskRecord[] = [];
|
||||
const namingRecords: LocalProjectAssetGenerationTaskRecord[] = [];
|
||||
if (!Array.isArray(records)) {
|
||||
return { assetRecords, namingRecords };
|
||||
}
|
||||
for (const record of records) {
|
||||
if (
|
||||
typeof record !== 'object' ||
|
||||
record === null ||
|
||||
typeof record.taskId !== 'string' ||
|
||||
record.taskId.trim().length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
resolveLocalProjectGenerationTaskType(record.taskType) ===
|
||||
LOCAL_PROJECT_GENERATION_TASK_TYPE_PROJECT_NAMING
|
||||
) {
|
||||
namingRecords.push(record);
|
||||
} else {
|
||||
assetRecords.push(record);
|
||||
}
|
||||
}
|
||||
return { assetRecords, namingRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地任务 + 后端记录 → 下一版任务列表。
|
||||
*
|
||||
@@ -384,6 +506,8 @@ export function restoreResourceCanvasAssetGenerationTask(
|
||||
* 账本文件读坏、命令未注册都可能给出 `undefined` 或别的形状)。非数组一律按「没有后端记录」
|
||||
* 处理——抛出去会变成 effect 里的未处理 Promise 拒绝,把「任务列表暂不可用」升级成整块视图出错。
|
||||
* 数组里的垃圾条目(`null` / 数字 / 缺 `taskId`)同样逐条丢弃,不让一条坏记录带崩整次恢复。
|
||||
* 命名任务是**另一类行**(`splitLocalProjectGenerationTaskRecords`),这里一律剔除,避免拿
|
||||
* `kind` 解析它们。
|
||||
*/
|
||||
export function applyLocalProjectAssetGenerationRecords(
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[],
|
||||
@@ -397,7 +521,9 @@ export function applyLocalProjectAssetGenerationRecords(
|
||||
typeof (record as LocalProjectAssetGenerationTaskRecord).taskId ===
|
||||
'string' &&
|
||||
(record as LocalProjectAssetGenerationTaskRecord).taskId.trim()
|
||||
.length > 0,
|
||||
.length > 0 &&
|
||||
resolveLocalProjectGenerationTaskType(record.taskType) ===
|
||||
LOCAL_PROJECT_GENERATION_TASK_TYPE_ASSET_GENERATION,
|
||||
)
|
||||
: [];
|
||||
const recordsByTaskId = new Map(
|
||||
|
||||
@@ -507,6 +507,16 @@ body {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 读取失败时菜单里的那一行结论:只说明"没读到",不顶掉标签上的当前项目名,
|
||||
* 也不改写成业务、权限或审批失败。
|
||||
*/
|
||||
.launcher-runs-titlebar-menu-note {
|
||||
margin: 8px 5px 2px;
|
||||
color: var(--platform-text-muted, #a38f80);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-list {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
@@ -1116,6 +1126,12 @@ textarea {
|
||||
box-shadow: 0 0 0 3px rgb(77 159 84 / 14%);
|
||||
}
|
||||
|
||||
/* 快照读取失败:圆点改成告警色,文字结论留在展开菜单里,不占用标题栏主文案。 */
|
||||
.launcher-runs-panel-dot.is-warning {
|
||||
background: #c7653d;
|
||||
box-shadow: 0 0 0 3px rgb(199 101 61 / 14%);
|
||||
}
|
||||
|
||||
.launcher-runs-panel-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
@@ -169,6 +169,9 @@ import {
|
||||
type LocalProjectAssetGenerationTaskRecord,
|
||||
type ResourceCanvasAssetGenerationTask,
|
||||
resourceCanvasAssetGenerationTaskIsTerminal,
|
||||
type ResourceCanvasProjectNamingTask,
|
||||
restoreResourceCanvasProjectNamingTask,
|
||||
splitLocalProjectGenerationTaskRecords,
|
||||
} from '../../features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
|
||||
import { ResourceCanvasAssetGenerationTasksPanelView } from '../../features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
|
||||
import {
|
||||
@@ -2102,6 +2105,16 @@ export default function ProjectDevelopmentView({
|
||||
resourceAssetGenerationTasksPanelOpen,
|
||||
setResourceAssetGenerationTasksPanelOpen,
|
||||
] = useState(false);
|
||||
/**
|
||||
* 「AI 项目命名」任务(首页建项后的后台自动命名,账本 `taskType = project-naming`)。
|
||||
*
|
||||
* 它与素材生成共用同一份账本与同一个「生成任务」侧栏,但推进不由本组件驱动:命名在首页那边跑
|
||||
* (`enqueue_local_project_naming_task` / `update_local_project_naming_task`),这里只按
|
||||
* 变更事件做一次受控快照读取把状态显示出来。
|
||||
*/
|
||||
const [projectNamingTasks, setProjectNamingTasks] = useState<
|
||||
ResourceCanvasProjectNamingTask[]
|
||||
>([]);
|
||||
/**
|
||||
* 占位落点要用的几何:栏目位置表与卡片尺寸表。两者都在本渲染靠后处才算出来,
|
||||
* 所以用 ref 暴露给占位 Hook(工具点击发生在那之后,读到的总是最新一帧)。
|
||||
@@ -8973,6 +8986,34 @@ export default function ProjectDevelopmentView({
|
||||
[manifest.assets, resourceGenerationPlaceholders],
|
||||
);
|
||||
|
||||
/**
|
||||
* 刷新「AI 项目命名」行:从账本重读一次(受控快照读取)。
|
||||
*
|
||||
* 读失败或返回值不是数组时**保留上一次的命名行**——一次 IPC 失败不该把列表清空。
|
||||
*/
|
||||
const refreshProjectNamingTasks = useCallback(async () => {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await invoke<LocalProjectAssetGenerationTaskRecord[]>(
|
||||
'list_local_project_asset_generations',
|
||||
{ projectPath },
|
||||
);
|
||||
if (!Array.isArray(response)) {
|
||||
return;
|
||||
}
|
||||
setProjectNamingTasks(
|
||||
splitLocalProjectGenerationTaskRecords(response).namingRecords.map(
|
||||
restoreResourceCanvasProjectNamingTask,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// 账本读不到:保留上一次的命名行,不猜状态。
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
/**
|
||||
* 重开项目时恢复项目内的任务账本。
|
||||
*
|
||||
@@ -8990,7 +9031,9 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
const projectId = manifest.projectId;
|
||||
let cancelled = false;
|
||||
let unsubscribeNamingTasks: (() => void) | null = null;
|
||||
setResourceAssetGenerationTasksPanelOpen(false);
|
||||
setProjectNamingTasks([]);
|
||||
resourceAssetGenerationPanelSubmissionRef.current = null;
|
||||
setResourceAssetGenerationPanelReopen(null);
|
||||
// 切项目:生成浮层与它的占位都不跨项目(占位本身由占位 Hook 按 projectId 收口)。
|
||||
@@ -9002,6 +9045,39 @@ export default function ProjectDevelopmentView({
|
||||
resourceGenerationDraftRef.current.clear();
|
||||
resourceAssetGenerationDraftRef.current.clear();
|
||||
void (async () => {
|
||||
/**
|
||||
* **先订阅、再读快照**(与素材队列「先订阅后派发」同一口径)。
|
||||
*
|
||||
* 命名行的推进发生在本组件之外(首页后台命名链路),刷新只能靠「变更事件 + 一次受控快照
|
||||
* 读取」;读取是异步的,若先读后订阅,落在这一次 IPC 往返里的终态事件没有消费者、直接丢掉,
|
||||
* 命名行会永久停在「命名中」。订阅失败也要照读一次,靠面板打开时的补读兜底。
|
||||
*
|
||||
* 订阅与这次恢复读共用同一次 effect:本项目挂载时对账本的读次数保持不变(把命名读并进
|
||||
* 这一次恢复读),不改变既有生成任务链路可观测的轮询计数。
|
||||
*/
|
||||
if (canSubscribeTauriEvents()) {
|
||||
try {
|
||||
const dispose = await subscribeTauriEvent<{ projectPath?: string }>(
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_CHANGED_EVENT,
|
||||
(event) => {
|
||||
if (cancelled || event.payload?.projectPath !== projectPath) {
|
||||
return;
|
||||
}
|
||||
void refreshProjectNamingTasks();
|
||||
},
|
||||
);
|
||||
if (cancelled) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
unsubscribeNamingTasks = dispose;
|
||||
} catch {
|
||||
// 订阅不上:退化成「进项目读一次 + 面板打开时重读」。
|
||||
}
|
||||
}
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const reportUnavailable = () => {
|
||||
if (!cancelled) {
|
||||
setResourceWorkbenchNotice(
|
||||
@@ -9027,19 +9103,54 @@ export default function ProjectDevelopmentView({
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const { assetRecords, namingRecords } =
|
||||
splitLocalProjectGenerationTaskRecords(records);
|
||||
const restored = mergeResourceCanvasAssetGenerationTasksWithRecords(
|
||||
resourceAssetGenerationTasksRef.current.filter(
|
||||
(task) => task.projectId === projectId,
|
||||
),
|
||||
records,
|
||||
assetRecords,
|
||||
);
|
||||
resourceAssetGenerationTasksRef.current = restored;
|
||||
setResourceAssetGenerationTasks(restored);
|
||||
setProjectNamingTasks(
|
||||
namingRecords.map(restoreResourceCanvasProjectNamingTask),
|
||||
);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribeNamingTasks?.();
|
||||
};
|
||||
}, [manifest.projectId, projectPath]);
|
||||
}, [manifest.projectId, projectPath, refreshProjectNamingTasks]);
|
||||
|
||||
/**
|
||||
* 面板打开时补一次快照读。
|
||||
*
|
||||
* 这是事件之外的兜底:订阅尚未就绪、窗口刚恢复、或用户就是想确认进度时,打开面板就能看到
|
||||
* 账本里的真实状态,而不是永久停在「命名中」。
|
||||
*
|
||||
* **只在列表里确实存在一条在途命名行时才读**:没有命名任务的项目(绝大多数)不该因为打开
|
||||
* 面板就多打一次账本 IPC——那会改变「轮询计数」这类外部可观测行为(既有生成任务用例正是
|
||||
* 按轮询次数推进 mock 的)。依赖的是布尔而不是行数组:读取结果只会让它在 true→false 之间
|
||||
* 最多翻转一次,不会自激。
|
||||
*/
|
||||
const hasInFlightProjectNamingTask = projectNamingTasks.some(
|
||||
(task) => task.status !== 'completed' && task.status !== 'failed',
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!resourceAssetGenerationTasksPanelOpen ||
|
||||
!hasInFlightProjectNamingTask
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
void refreshProjectNamingTasks();
|
||||
return undefined;
|
||||
}, [
|
||||
hasInFlightProjectNamingTask,
|
||||
resourceAssetGenerationTasksPanelOpen,
|
||||
refreshProjectNamingTasks,
|
||||
]);
|
||||
|
||||
/**
|
||||
* 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。
|
||||
@@ -11254,6 +11365,9 @@ export default function ProjectDevelopmentView({
|
||||
!task.restored ||
|
||||
!resourceCanvasResourceEditTaskIsTerminal(task),
|
||||
)}
|
||||
namingTasks={projectNamingTasks.filter(
|
||||
(task) => task.projectId === manifest.projectId,
|
||||
)}
|
||||
open={resourceAssetGenerationTasksPanelOpen}
|
||||
onToggleOpen={() =>
|
||||
setResourceAssetGenerationTasksPanelOpen((current) => !current)
|
||||
|
||||
@@ -33,15 +33,24 @@ function TitleSetter({ value }: { value: string }) {
|
||||
|
||||
function ActiveRunsSetter({
|
||||
activeTurns,
|
||||
currentProjectName,
|
||||
currentProjectPath,
|
||||
}: {
|
||||
activeTurns: GameCreatorDirectActiveTurn[];
|
||||
currentProjectName?: string;
|
||||
currentProjectPath?: string;
|
||||
}) {
|
||||
const { setActiveProjectRuns } = useWindowChrome();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setActiveProjectRuns({ activeTurns, onOpenProject: () => undefined })
|
||||
setActiveProjectRuns({
|
||||
activeTurns,
|
||||
...(currentProjectName ? { currentProjectName } : {}),
|
||||
...(currentProjectPath ? { currentProjectPath } : {}),
|
||||
onOpenProject: () => undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
显示运行项目
|
||||
@@ -155,15 +164,52 @@ describe('WindowChrome', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '显示运行项目' }));
|
||||
expect(
|
||||
screen.getByRole('button', { name: /正在运行的项目:后开始/ }),
|
||||
screen.getByRole('button', { name: /最近运行的项目:后开始/ }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /正在运行的项目:后开始/ }),
|
||||
screen.getByRole('button', { name: /最近运行的项目:后开始/ }),
|
||||
);
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* 回归:进项目后当前项目没在跑、后台别的项目在跑时,标题栏曾经整块换成
|
||||
* 「正在运行的项目」,当前项目名在窗口上消失。
|
||||
*/
|
||||
it('keeps the current project in the title bar while another project runs', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<WindowChrome>
|
||||
<ActiveRunsSetter
|
||||
activeTurns={[
|
||||
{
|
||||
projectPath: 'C:/projects/other',
|
||||
projectName: '别的项目',
|
||||
turnId: 'turn-other',
|
||||
startedAt: 100,
|
||||
status: 'running',
|
||||
updatedAt: 120,
|
||||
sequence: 1,
|
||||
},
|
||||
]}
|
||||
currentProjectName="飞鸟 v2"
|
||||
currentProjectPath="C:/projects/bird"
|
||||
/>
|
||||
</WindowChrome>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '显示运行项目' }));
|
||||
|
||||
expect(screen.getByText('飞鸟 v2')).toBeTruthy();
|
||||
expect(screen.queryByText('别的项目')).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '当前项目:飞鸟 v2;共 1 个项目在运行',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
/**
|
||||
* 回归:发布面板等 ThemedModal 弹窗打开时,标题栏在模态之外,焦点陷阱曾把
|
||||
* 标题栏上的点击一起拦下 —— 三个窗口按钮看着正常但点不动。
|
||||
|
||||
@@ -1681,6 +1681,20 @@ export function registerHomeProjectCreationTests() {
|
||||
});
|
||||
return '角色参考游戏';
|
||||
}
|
||||
if (command === 'rename_local_game_project_if_unchanged') {
|
||||
// 建项时用的是宿主兜底名;后台命名回来后按「项目 ID + 兜底名」条件改名。
|
||||
expect(args).toEqual({
|
||||
projectPath: automaticProjectPath,
|
||||
expectedProjectId: manifest.projectId,
|
||||
expectedName: manifest.name,
|
||||
name: '角色参考游戏',
|
||||
});
|
||||
return {
|
||||
renamed: true,
|
||||
manifest: { ...manifest, name: '角色参考游戏' },
|
||||
revision: 2,
|
||||
};
|
||||
}
|
||||
if (command === 'enqueue_direct_codex_turn') {
|
||||
chatHarness.completeDirectThreadTurn({
|
||||
turnId: String(args?.clientTurnId ?? ''),
|
||||
@@ -1720,10 +1734,15 @@ export function registerHomeProjectCreationTests() {
|
||||
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
|
||||
|
||||
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
|
||||
// 建项不再等 AI 命名:名称留空由宿主兜底,命名结果在后台条件改名。
|
||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||
name: '角色参考游戏',
|
||||
name: null,
|
||||
planning: false,
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
expect.objectContaining({ name: '角色参考游戏' }),
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
|
||||
projectPath: automaticProjectPath,
|
||||
fileName: '角色参考.png',
|
||||
@@ -1793,6 +1812,428 @@ export function registerHomeProjectCreationTests() {
|
||||
).toEqual([{ type: 'input_text', text: '再补一句玩法' }]);
|
||||
});
|
||||
|
||||
it('syncs the workbench title and recent project list with the AI name after creation', async () => {
|
||||
const projectPath = '/tmp/home-async-named-project';
|
||||
const fallbackManifest = createGameCreationAppManifest(
|
||||
'home-async-named-project',
|
||||
'GameAgent 项目 1a2b3c4d',
|
||||
);
|
||||
const renamedManifest = { ...fallbackManifest, name: '星轨夜航' };
|
||||
let renamed = false;
|
||||
// 「生成任务」账本里的那条「AI 项目命名」:入队/推进由首页链路写,这里按同一份状态回放给工作台。
|
||||
let namingLedgerStatus = 'running';
|
||||
let namingLedgerPhase = '正在等待 AI 命名结果。';
|
||||
const namingLedger = () => [
|
||||
{
|
||||
taskId: `project-naming:${fallbackManifest.projectId}`,
|
||||
projectId: fallbackManifest.projectId,
|
||||
taskType: 'project-naming',
|
||||
kind: null,
|
||||
assetName: 'AI 项目命名',
|
||||
status: namingLedgerStatus,
|
||||
phaseDetail: namingLedgerPhase,
|
||||
createdAtMillis: 1,
|
||||
startedAtMillis: 2,
|
||||
finishedAtMillis: null,
|
||||
assetId: null,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
const namingTaskChangedHandlers = new Set<
|
||||
(event: { payload: unknown }) => void
|
||||
>();
|
||||
// 命名闸门手控:先证明"命名还没回来时已经进了工作区",再放行命名验证同步。
|
||||
let resolveNaming!: (name: string) => void;
|
||||
const namingGate = new Promise<string>((resolve) => {
|
||||
resolveNaming = resolve;
|
||||
});
|
||||
const chatHarness = createProjectChatRuntimeHarness({
|
||||
projectPath,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return {
|
||||
projectPath,
|
||||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||||
manifest: fallbackManifest,
|
||||
};
|
||||
}
|
||||
// 命名与建项并行:建项拿不到名字,AI 结果只走后台条件改名。
|
||||
if (command === 'suggest_automatic_project_name') return namingGate;
|
||||
if (command === 'enqueue_local_project_naming_task') {
|
||||
namingLedgerStatus = 'queued';
|
||||
namingLedgerPhase = '排队中。';
|
||||
return namingLedger()[0];
|
||||
}
|
||||
if (command === 'update_local_project_naming_task') {
|
||||
namingLedgerStatus = String(args?.status ?? namingLedgerStatus);
|
||||
namingLedgerPhase = String(args?.phaseDetail ?? namingLedgerPhase);
|
||||
return namingLedger()[0];
|
||||
}
|
||||
if (command === 'list_local_project_asset_generations') {
|
||||
return namingLedger();
|
||||
}
|
||||
if (command === 'rename_local_game_project_if_unchanged') {
|
||||
expect(args).toEqual({
|
||||
projectPath,
|
||||
expectedProjectId: fallbackManifest.projectId,
|
||||
expectedName: fallbackManifest.name,
|
||||
name: '星轨夜航',
|
||||
});
|
||||
renamed = true;
|
||||
return { renamed: true, manifest: renamedManifest, revision: 2 };
|
||||
}
|
||||
if (command === 'get_local_game_manifest') {
|
||||
return renamed ? renamedManifest : fallbackManifest;
|
||||
}
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
return {
|
||||
projectPath: String(args?.projectPath ?? ''),
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
isCocosProject: false,
|
||||
godotProjectRoot: null,
|
||||
// 最近项目行的名字就来自这次重检;改名前是兜底名,改名后是 AI 名。
|
||||
projectName: renamed ? '星轨夜航' : fallbackManifest.name,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
if (command === 'enqueue_direct_codex_turn') {
|
||||
chatHarness.completeDirectThreadTurn({
|
||||
turnId: String(args?.clientTurnId ?? ''),
|
||||
prompt: '做一个星轨游戏',
|
||||
reply: '开工。',
|
||||
});
|
||||
return '开工。';
|
||||
}
|
||||
return chatHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
// 账本变更事件由工作台订阅:这里把它记下来,测试可以按「一次变更 → 一次快照读取」驱动刷新。
|
||||
const listen = async (
|
||||
eventName: string,
|
||||
handler: (event: { payload: unknown }) => void,
|
||||
) => {
|
||||
if (eventName === 'game-creator-asset-generation-task-changed') {
|
||||
namingTaskChangedHandlers.add(handler);
|
||||
return () => {
|
||||
namingTaskChangedHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
return chatHarness.listen(eventName, handler);
|
||||
};
|
||||
installTauriRuntime({
|
||||
core: { invoke },
|
||||
event: { listen },
|
||||
});
|
||||
window.localStorage.setItem(
|
||||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||||
JSON.stringify([projectPath]),
|
||||
);
|
||||
renderLauncherAt('/?launcher', 'home', true);
|
||||
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '做一个星轨游戏';
|
||||
fireEvent.paste(promptInput);
|
||||
await waitFor(() => {
|
||||
expect(promptInput.textContent).toContain('做一个星轨游戏');
|
||||
});
|
||||
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
|
||||
// 进工作区时命名还没回来:创建并不等命名结果,兜底名仍然生效。
|
||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||
name: null,
|
||||
planning: false,
|
||||
});
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// 「生成任务」侧栏里能看到这条在跑的「AI 项目命名」(与素材生成共用同一份账本与列表)。
|
||||
fireEvent.click(screen.getByLabelText('生成任务'));
|
||||
expect(await screen.findByText('AI 项目命名')).not.toBeNull();
|
||||
expect(
|
||||
within(
|
||||
document.querySelector(
|
||||
'[data-task-source="project-naming"]',
|
||||
) as HTMLElement,
|
||||
).getByText('命名中'),
|
||||
).not.toBeNull();
|
||||
|
||||
// 放行命名:AI 名称落定后走条件改名(工作台标题取自项目上下文,见 hook 用例)。
|
||||
await act(async () => {
|
||||
resolveNaming('星轨夜航');
|
||||
await namingGate;
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
expect.objectContaining({ name: '星轨夜航' }),
|
||||
);
|
||||
});
|
||||
|
||||
// 账本变更事件 → 一次受控快照读取:终态结论出现在同一行上(不用重开项目)。
|
||||
await act(async () => {
|
||||
for (const handler of namingTaskChangedHandlers) {
|
||||
handler({
|
||||
payload: {
|
||||
projectPath,
|
||||
taskId: `project-naming:${fallbackManifest.projectId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
expect(
|
||||
await screen.findByText('已应用 AI 名称「星轨夜航」。'),
|
||||
).not.toBeNull();
|
||||
|
||||
// 最近项目行按目录重检读 manifest:列表最终也显示 AI 名称。
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
|
||||
expect(await screen.findByText('星轨夜航')).not.toBeNull();
|
||||
expect(screen.queryByText('GameAgent 项目 1a2b3c4d')).toBeNull();
|
||||
});
|
||||
|
||||
it('recovers the naming row when the terminal event lands before the subscription is ready', async () => {
|
||||
const projectPath = '/tmp/home-naming-event-race';
|
||||
const fallbackManifest = createGameCreationAppManifest(
|
||||
'home-naming-event-race',
|
||||
'GameAgent 项目 5f6a7b8c',
|
||||
);
|
||||
const renamedManifest = { ...fallbackManifest, name: '星轨夜航' };
|
||||
const ledgerRecord = (status: string, phaseDetail: string) => [
|
||||
{
|
||||
taskId: `project-naming:${fallbackManifest.projectId}`,
|
||||
projectId: fallbackManifest.projectId,
|
||||
taskType: 'project-naming',
|
||||
kind: null,
|
||||
assetName: 'AI 项目命名',
|
||||
status,
|
||||
phaseDetail,
|
||||
createdAtMillis: 1,
|
||||
startedAtMillis: 2,
|
||||
finishedAtMillis: status === 'completed' ? 3 : null,
|
||||
assetId: null,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
let namingSettled = false;
|
||||
// 订阅注册被闸门卡住:这一段里落地的事件在真实世界里没有订阅者——就是丢了。
|
||||
let releaseSubscription: (() => void) | null = null;
|
||||
let subscriptionReady = false;
|
||||
let droppedWhileUnsubscribed = 0;
|
||||
const chatHarness = createProjectChatRuntimeHarness({
|
||||
projectPath,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return {
|
||||
projectPath,
|
||||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||||
manifest: fallbackManifest,
|
||||
};
|
||||
}
|
||||
if (command === 'suggest_automatic_project_name') return '星轨夜航';
|
||||
if (command === 'rename_local_game_project_if_unchanged') {
|
||||
namingSettled = true;
|
||||
return { renamed: true, manifest: renamedManifest, revision: 2 };
|
||||
}
|
||||
if (command === 'get_local_game_manifest') {
|
||||
return namingSettled ? renamedManifest : fallbackManifest;
|
||||
}
|
||||
if (command === 'list_local_project_asset_generations') {
|
||||
// 进项目那一次读拿到的是「老化快照」(响应在命名落定之前就算好了);
|
||||
// 订阅就绪之后的读才是账本真相。
|
||||
return namingSettled && subscriptionReady
|
||||
? ledgerRecord('completed', '已应用 AI 名称「星轨夜航」。')
|
||||
: ledgerRecord('running', '正在等待 AI 命名结果。');
|
||||
}
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
return {
|
||||
projectPath: String(args?.projectPath ?? ''),
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
isCocosProject: false,
|
||||
godotProjectRoot: null,
|
||||
projectName: renamedManifest.name,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
if (command === 'enqueue_direct_codex_turn') {
|
||||
chatHarness.completeDirectThreadTurn({
|
||||
turnId: String(args?.clientTurnId ?? ''),
|
||||
prompt: '做一个星轨游戏',
|
||||
reply: '开工。',
|
||||
});
|
||||
return '开工。';
|
||||
}
|
||||
return chatHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
const listen = (
|
||||
eventName: string,
|
||||
handler: (event: { payload: unknown }) => void,
|
||||
) => {
|
||||
if (eventName !== 'game-creator-asset-generation-task-changed') {
|
||||
return chatHarness.listen(eventName, handler);
|
||||
}
|
||||
return new Promise<() => void>((resolve) => {
|
||||
releaseSubscription = () => {
|
||||
subscriptionReady = true;
|
||||
resolve(() => {});
|
||||
};
|
||||
});
|
||||
};
|
||||
installTauriRuntime({ core: { invoke }, event: { listen } });
|
||||
renderLauncherAt('/?launcher', 'home', true);
|
||||
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '做一个星轨游戏';
|
||||
fireEvent.paste(promptInput);
|
||||
await waitFor(() => {
|
||||
expect(promptInput.textContent).toContain('做一个星轨游戏');
|
||||
});
|
||||
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
|
||||
await screen.findByLabelText('项目开发工作台');
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
// 次序:读返回(陈旧 running)→ 终态事件(此时没有订阅者,丢掉)→ 订阅才注册。
|
||||
droppedWhileUnsubscribed += 1;
|
||||
await act(async () => {
|
||||
releaseSubscription?.();
|
||||
});
|
||||
|
||||
// 命名行必须到终态:右上角开关的在途计数归零(旧实现会永久停在「生成任务 · 1」)。
|
||||
await waitFor(() => {
|
||||
expect(droppedWhileUnsubscribed).toBe(1);
|
||||
expect(screen.getByLabelText('生成任务').textContent).toBe('生成任务');
|
||||
});
|
||||
});
|
||||
|
||||
it('re-reads the naming ledger when the generation tasks panel opens', async () => {
|
||||
const projectPath = '/tmp/home-naming-panel-reread';
|
||||
const fallbackManifest = createGameCreationAppManifest(
|
||||
'home-naming-panel-reread',
|
||||
'GameAgent 项目 6a7b8c9d',
|
||||
);
|
||||
const renamedManifest = { ...fallbackManifest, name: '星轨夜航' };
|
||||
const ledgerRecord = (status: string, phaseDetail: string) => [
|
||||
{
|
||||
taskId: `project-naming:${fallbackManifest.projectId}`,
|
||||
projectId: fallbackManifest.projectId,
|
||||
taskType: 'project-naming',
|
||||
kind: null,
|
||||
assetName: 'AI 项目命名',
|
||||
status,
|
||||
phaseDetail,
|
||||
createdAtMillis: 1,
|
||||
startedAtMillis: 2,
|
||||
finishedAtMillis: status === 'completed' ? 3 : null,
|
||||
assetId: null,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
let ledgerSettled = false;
|
||||
const chatHarness = createProjectChatRuntimeHarness({
|
||||
projectPath,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return {
|
||||
projectPath,
|
||||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||||
manifest: fallbackManifest,
|
||||
};
|
||||
}
|
||||
if (command === 'suggest_automatic_project_name') return '星轨夜航';
|
||||
if (command === 'rename_local_game_project_if_unchanged') {
|
||||
return { renamed: true, manifest: renamedManifest, revision: 2 };
|
||||
}
|
||||
if (command === 'get_local_game_manifest') return renamedManifest;
|
||||
if (command === 'list_local_project_asset_generations') {
|
||||
// 账本真相由 `ledgerSettled` 控制:打开面板之前它是陈旧快照,
|
||||
// 之后会重读一次拿到终态(本用例里订阅直接失败,只剩这条兜底)。
|
||||
return ledgerSettled
|
||||
? ledgerRecord('completed', '已应用 AI 名称「星轨夜航」。')
|
||||
: ledgerRecord('running', '正在等待 AI 命名结果。');
|
||||
}
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
return {
|
||||
projectPath: String(args?.projectPath ?? ''),
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
isCocosProject: false,
|
||||
godotProjectRoot: null,
|
||||
projectName: renamedManifest.name,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
if (command === 'enqueue_direct_codex_turn') {
|
||||
chatHarness.completeDirectThreadTurn({
|
||||
turnId: String(args?.clientTurnId ?? ''),
|
||||
prompt: '做一个星轨游戏',
|
||||
reply: '开工。',
|
||||
});
|
||||
return '开工。';
|
||||
}
|
||||
return chatHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
const listen = (
|
||||
eventName: string,
|
||||
handler: (event: { payload: unknown }) => void,
|
||||
) => {
|
||||
if (eventName === 'game-creator-asset-generation-task-changed') {
|
||||
// 订阅失败:这条链路只剩「面板打开时补读」这一个兜底。
|
||||
return Promise.reject(new Error('subscription unavailable'));
|
||||
}
|
||||
return chatHarness.listen(eventName, handler);
|
||||
};
|
||||
installTauriRuntime({ core: { invoke }, event: { listen } });
|
||||
renderLauncherAt('/?launcher', 'home', true);
|
||||
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '做一个星轨游戏';
|
||||
fireEvent.paste(promptInput);
|
||||
await waitFor(() => {
|
||||
expect(promptInput.textContent).toContain('做一个星轨游戏');
|
||||
});
|
||||
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
|
||||
await screen.findByLabelText('项目开发工作台');
|
||||
// 陈旧快照下命名行还在跑:开关上带着在途计数。
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('生成任务').textContent).toContain(
|
||||
'生成任务 · 1',
|
||||
);
|
||||
});
|
||||
// 账本随后落定,但(订阅失败的场景里)这条链路收不到任何事件。
|
||||
ledgerSettled = true;
|
||||
|
||||
fireEvent.click(screen.getByLabelText('生成任务'));
|
||||
// 打开面板补一次读:行必须变成账本里的终态,而不是永远停在「命名中」。
|
||||
expect(
|
||||
await screen.findByText('已应用 AI 名称「星轨夜航」。'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the home composer out of chat mode while automatic project creation is pending', async () => {
|
||||
let rejectAutomaticProject: ((error: Error) => void) | null = null;
|
||||
const automaticProject = new Promise<never>((_resolve, reject) => {
|
||||
|
||||
@@ -308,7 +308,17 @@ describe('ActiveProjectRunsPanel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
|
||||
/**
|
||||
* 回归:快照读到了、只是没有在跑回合时,面板形态曾把这个健康空态渲染成
|
||||
* 「未能读取正在运行的项目」,把"没在跑"说成"没读到"。
|
||||
*/
|
||||
it('面板形态下快照读取正常但没有在跑回合时整块不渲染', () => {
|
||||
const { container } = render(<ActiveProjectRunsPanel activeTurns={[]} />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('不在项目内时标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
|
||||
const onOpenProject = vi.fn();
|
||||
render(
|
||||
<ActiveProjectRunsPanel
|
||||
@@ -345,4 +355,92 @@ describe('ActiveProjectRunsPanel', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ }));
|
||||
expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
|
||||
});
|
||||
|
||||
/**
|
||||
* 回归:进入的项目没在跑、后台别的项目在跑时,旧实现在标题栏显示的是
|
||||
* 最后开始的那个项目(当前项目名整个消失),用户会以为自己开错了项目。
|
||||
*/
|
||||
it('进入项目后标签显示当前项目名,后台在跑的项目只进徽标与展开菜单', () => {
|
||||
render(
|
||||
<ActiveProjectRunsPanel
|
||||
placement="titlebar"
|
||||
activeTurns={[
|
||||
{
|
||||
projectPath: 'C:/projects/other',
|
||||
projectName: 'gameagent-other',
|
||||
turnId: 'turn-other',
|
||||
startedAt: 100,
|
||||
status: 'running',
|
||||
updatedAt: 120,
|
||||
sequence: 1,
|
||||
},
|
||||
]}
|
||||
currentProjectName="飞鸟 v2"
|
||||
currentProjectPath="C:/projects/bird"
|
||||
onOpenProject={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: '当前项目:飞鸟 v2;共 1 个项目在运行',
|
||||
});
|
||||
expect(trigger.textContent).toContain('飞鸟 v2');
|
||||
expect(trigger.textContent).toContain('1');
|
||||
expect(trigger.textContent).not.toContain('gameagent-other');
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: /gameagent-other/ }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('当前项目自己的回合在跑时列表标「当前」并沿用项目名', () => {
|
||||
render(
|
||||
<ActiveProjectRunsPanel
|
||||
placement="titlebar"
|
||||
activeTurns={[
|
||||
{
|
||||
projectPath: 'C:/projects/bird',
|
||||
projectName: 'gameagent-bird',
|
||||
turnId: 'turn-bird',
|
||||
startedAt: 100,
|
||||
status: 'streaming',
|
||||
updatedAt: 120,
|
||||
sequence: 1,
|
||||
},
|
||||
]}
|
||||
currentProjectName="飞鸟 v2"
|
||||
currentProjectPath="C:/projects/bird"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /当前项目:飞鸟 v2/ }));
|
||||
const item = screen.getByRole('menuitem');
|
||||
expect(item.textContent).toContain('飞鸟 v2');
|
||||
expect(item.textContent).not.toContain('gameagent-bird');
|
||||
expect(item.textContent).toContain('当前');
|
||||
expect(item.getAttribute('aria-current')).toBe('true');
|
||||
});
|
||||
|
||||
it('读取失败时不顶掉当前项目名,读取结论留在展开菜单里', () => {
|
||||
render(
|
||||
<ActiveProjectRunsPanel
|
||||
placement="titlebar"
|
||||
activeTurns={[]}
|
||||
readFailed
|
||||
currentProjectName="飞鸟 v2"
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: /当前项目:飞鸟 v2;正在运行的项目读取失败/,
|
||||
});
|
||||
expect(trigger.textContent).toContain('飞鸟 v2');
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole('menu')).toBeTruthy();
|
||||
expect(screen.getByRole('status').textContent).toBe(
|
||||
'正在运行的项目读取失败',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, renderHook } from '@testing-library/react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { HomeProjectCreationController } from '../src/features/app-shell/useHomeProjectCreation';
|
||||
import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation';
|
||||
import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake';
|
||||
|
||||
const PROJECT_PATH = 'C:/test/async-naming-game';
|
||||
const PROJECT_ID = 'gameagent-1a2b3c4d';
|
||||
/** 宿主在建项时给的兜底名(`GameAgent 项目 <8 位短 id>`)。 */
|
||||
const FALLBACK_NAME = 'GameAgent 项目 1a2b3c4d';
|
||||
const SUGGESTED_NAME = '星河远征';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetTauriRuntime();
|
||||
});
|
||||
|
||||
/** 手控闸门:测试自己决定命名请求何时返回,不靠 sleep 赌时序。 */
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function mount(options: {
|
||||
naming: Promise<string | null> | string;
|
||||
/** 条件改名命令的返回;缺省表示调用它就是测试失败。 */
|
||||
conditionalRename?: () => Promise<unknown>;
|
||||
/** 其它命令的按名处理;用于把某一步(如进项目时的预览核验)卡在手控闸门上。 */
|
||||
extraCommands?: Record<
|
||||
string,
|
||||
(args?: Record<string, unknown>) => Promise<unknown>
|
||||
>;
|
||||
}) {
|
||||
const order: string[] = [];
|
||||
const namingTaskCalls: Array<{
|
||||
command: string;
|
||||
args: Record<string, unknown>;
|
||||
}> = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'capture_analytics_context') return {};
|
||||
if (command === 'preflight_web_game_creation') return { status: 'ready' };
|
||||
if (
|
||||
command === 'enqueue_local_project_naming_task' ||
|
||||
command === 'update_local_project_naming_task'
|
||||
) {
|
||||
namingTaskCalls.push({ command, args: args ?? {} });
|
||||
return null;
|
||||
}
|
||||
if (command === 'suggest_automatic_project_name') {
|
||||
order.push(command);
|
||||
return options.naming;
|
||||
}
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
order.push(command);
|
||||
return {
|
||||
projectPath: PROJECT_PATH,
|
||||
manifestPath: `${PROJECT_PATH}/.agent/manifest.json`,
|
||||
manifest: createGameCreationAppManifest(PROJECT_ID, FALLBACK_NAME),
|
||||
};
|
||||
}
|
||||
if (command === 'get_local_game_project_revision') return { revision: 1 };
|
||||
if (command === 'rename_local_game_project_if_unchanged') {
|
||||
if (!options.conditionalRename) {
|
||||
throw new Error('未预期的条件改名调用');
|
||||
}
|
||||
return options.conditionalRename();
|
||||
}
|
||||
const extra = options.extraCommands?.[command];
|
||||
if (extra) {
|
||||
return extra();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
const refreshRecentWorkspace = vi.fn(async () => {});
|
||||
installTauriRuntime({ core: { invoke } });
|
||||
const hook = renderHook(() =>
|
||||
useHomeProjectCreation({
|
||||
setStatus: vi.fn(),
|
||||
showTransientNotice: vi.fn(),
|
||||
setLauncherView: vi.fn(),
|
||||
rememberRecentWorkspace: vi.fn(),
|
||||
refreshRecentWorkspace,
|
||||
}),
|
||||
);
|
||||
return { ...hook, invoke, order, namingTaskCalls, refreshRecentWorkspace };
|
||||
}
|
||||
|
||||
/**
|
||||
* 「AI 项目命名」任务在账本里的推进序列:`命令:状态`。
|
||||
*
|
||||
* 三个断言点共用同一份口径:入队(无 status)→ running → 终态。
|
||||
*/
|
||||
function namingTaskStatusTrace(
|
||||
calls: ReadonlyArray<{ command: string; args: Record<string, unknown> }>,
|
||||
): string[] {
|
||||
return calls.map(
|
||||
(call) =>
|
||||
`${call.command.replace('_local_project_naming_task', '')}:${
|
||||
typeof call.args.status === 'string' ? call.args.status : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createAutomatically(
|
||||
result: { current: HomeProjectCreationController },
|
||||
startMode: 'direct-build' | 'planning' = 'direct-build',
|
||||
) {
|
||||
return result.current.createHomeDraftAutomatically(
|
||||
{ creationType: 'game', prompt: '做一个小游戏', attachments: [] },
|
||||
startMode,
|
||||
);
|
||||
}
|
||||
|
||||
it('enters the workspace even though the AI naming request never returns', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, invoke, order, namingTaskCalls } = mount({
|
||||
naming: naming.promise,
|
||||
});
|
||||
|
||||
let creation!: Promise<string>;
|
||||
act(() => {
|
||||
creation = createAutomatically(result);
|
||||
});
|
||||
let status = '';
|
||||
// 命名闸门一直关着。若建项链路还在等命名,这里会一直挂到用例超时——
|
||||
// 这正是"`create_automatic_local_game_project` 不再等待命名"的判定方式。
|
||||
await act(async () => {
|
||||
status = await creation;
|
||||
});
|
||||
|
||||
expect(status).toBe('已创建工作区并进入项目开发');
|
||||
expect(result.current.projectPath).toBe(PROJECT_PATH);
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
|
||||
// 命名请求先打出、建项随后并行,两者都不等对方。
|
||||
expect(order).toEqual([
|
||||
'suggest_automatic_project_name',
|
||||
'create_automatic_local_game_project',
|
||||
]);
|
||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||
name: null,
|
||||
planning: false,
|
||||
});
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
expect.anything(),
|
||||
);
|
||||
// 命名还没回来:任务已经入队并标成「命名中」,用户能在生成任务里看到它在跑。
|
||||
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
|
||||
'enqueue:',
|
||||
'update:running',
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
naming.resolve(null);
|
||||
await naming.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('applies a valid AI name after it arrives and refreshes the recent row', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, invoke, namingTaskCalls, refreshRecentWorkspace } = mount({
|
||||
naming: naming.promise,
|
||||
conditionalRename: async () => ({
|
||||
renamed: true,
|
||||
manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME),
|
||||
revision: 7,
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await createAutomatically(result);
|
||||
});
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
|
||||
|
||||
await act(async () => {
|
||||
naming.resolve(` ${SUGGESTED_NAME} `);
|
||||
await naming.promise;
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
{
|
||||
projectPath: PROJECT_PATH,
|
||||
// 条件改名的判据由前端原样转述:项目 ID + 这次创建的兜底名称。
|
||||
expectedProjectId: PROJECT_ID,
|
||||
expectedName: FALLBACK_NAME,
|
||||
name: SUGGESTED_NAME,
|
||||
},
|
||||
);
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(
|
||||
SUGGESTED_NAME,
|
||||
);
|
||||
expect(result.current.currentProjectContext?.manifest.name).toBe(
|
||||
SUGGESTED_NAME,
|
||||
);
|
||||
expect(result.current.currentProjectContext?.projectRevision).toBe(7);
|
||||
expect(refreshRecentWorkspace).toHaveBeenCalledWith(PROJECT_PATH);
|
||||
// 「生成任务」列表里那条「AI 项目命名」按入队 → 命名中 → 已完成推进,结论可自解释。
|
||||
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
|
||||
'enqueue:',
|
||||
'update:running',
|
||||
'update:completed',
|
||||
]);
|
||||
expect(namingTaskCalls.map((call) => call.args.taskId)).toEqual([
|
||||
`project-naming:${PROJECT_ID}`,
|
||||
`project-naming:${PROJECT_ID}`,
|
||||
`project-naming:${PROJECT_ID}`,
|
||||
]);
|
||||
expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain(
|
||||
SUGGESTED_NAME,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the AI name when the naming result lands before the project is entered', async () => {
|
||||
const renamedManifest = createGameCreationAppManifest(
|
||||
PROJECT_ID,
|
||||
SUGGESTED_NAME,
|
||||
);
|
||||
// 进项目时的会话预览核验卡住:AI 改名先落定,进项目还没写完上下文。
|
||||
let releasePreview!: () => void;
|
||||
const previewGate = new Promise<void>((resolve) => {
|
||||
releasePreview = resolve;
|
||||
});
|
||||
let markRenameInvoked!: () => void;
|
||||
const renameInvoked = new Promise<void>((resolve) => {
|
||||
markRenameInvoked = resolve;
|
||||
});
|
||||
const { result } = mount({
|
||||
// 命名瞬时返回(最快的模型情况),建项与改名之间没有等待。
|
||||
naming: SUGGESTED_NAME,
|
||||
conditionalRename: async () => {
|
||||
markRenameInvoked();
|
||||
return { renamed: true, manifest: renamedManifest, revision: 1 };
|
||||
},
|
||||
extraCommands: {
|
||||
get_local_game_preview_status: async () => {
|
||||
await previewGate;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let creation!: Promise<string>;
|
||||
act(() => {
|
||||
creation = createAutomatically(result);
|
||||
});
|
||||
await act(async () => {
|
||||
await renameInvoked;
|
||||
});
|
||||
// 此刻改名已经落定,但工作区上下文还没建立。
|
||||
expect(result.current.currentProjectContext).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
releasePreview();
|
||||
await creation;
|
||||
});
|
||||
|
||||
// 进项目写下的兜底上下文之后,后台改名结果必须仍然生效,不能被覆盖回兜底名。
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(
|
||||
SUGGESTED_NAME,
|
||||
);
|
||||
expect(result.current.currentProjectContext?.manifest.name).toBe(
|
||||
SUGGESTED_NAME,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['', ' ', null])(
|
||||
'keeps the fallback name for a non-usable AI response (%s)',
|
||||
async (response) => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, invoke, namingTaskCalls } = mount({
|
||||
naming: naming.promise,
|
||||
conditionalRename: async () => {
|
||||
throw new Error('空/非法 AI 名称不应触发改名');
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await createAutomatically(result);
|
||||
});
|
||||
await act(async () => {
|
||||
naming.resolve(response);
|
||||
await naming.promise;
|
||||
});
|
||||
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(
|
||||
FALLBACK_NAME,
|
||||
);
|
||||
// 失败/超时/非法内容:任务按 failed 收口,兜底名保留。
|
||||
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
|
||||
'enqueue:',
|
||||
'update:running',
|
||||
'update:failed',
|
||||
]);
|
||||
expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain(
|
||||
'保留兜底名',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not overwrite a name the user already changed', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, namingTaskCalls } = mount({
|
||||
naming: naming.promise,
|
||||
// 用户已经手动改过名:宿主按「当前名称不再是兜底名」整体跳过。
|
||||
conditionalRename: async () => ({
|
||||
renamed: false,
|
||||
manifest: createGameCreationAppManifest(PROJECT_ID, '我自己起的名字'),
|
||||
revision: 9,
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await createAutomatically(result);
|
||||
});
|
||||
await act(async () => {
|
||||
naming.resolve(SUGGESTED_NAME);
|
||||
await naming.promise;
|
||||
});
|
||||
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
|
||||
// 跳过不是失败:任务按 completed 收口,并且结论自己说清「为什么没改名」。
|
||||
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
|
||||
'enqueue:',
|
||||
'update:running',
|
||||
'update:completed',
|
||||
]);
|
||||
expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain(
|
||||
'已跳过 AI 命名',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the created workspace when the conditional rename fails', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, namingTaskCalls } = mount({
|
||||
naming: naming.promise,
|
||||
conditionalRename: async () => {
|
||||
throw new Error('项目正在被其他写操作占用');
|
||||
},
|
||||
});
|
||||
|
||||
let status = '';
|
||||
await act(async () => {
|
||||
status = await createAutomatically(result);
|
||||
});
|
||||
await act(async () => {
|
||||
naming.resolve(SUGGESTED_NAME);
|
||||
await naming.promise;
|
||||
});
|
||||
|
||||
expect(status).toBe('已创建工作区并进入项目开发');
|
||||
expect(result.current.projectPath).toBe(PROJECT_PATH);
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
|
||||
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
|
||||
'enqueue:',
|
||||
'update:running',
|
||||
'update:failed',
|
||||
]);
|
||||
expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain(
|
||||
'已保留兜底名',
|
||||
);
|
||||
});
|
||||
|
||||
it('never starts automatic naming for the 做方案 flow', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, invoke } = mount({ naming: naming.promise });
|
||||
|
||||
await act(async () => {
|
||||
await createAutomatically(result, 'planning');
|
||||
});
|
||||
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'suggest_automatic_project_name',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||
name: null,
|
||||
planning: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
naming.resolve(null);
|
||||
await naming.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('does not hijack the project the user already moved to when the AI name lands', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, refreshRecentWorkspace } = mount({
|
||||
naming: naming.promise,
|
||||
conditionalRename: async () => ({
|
||||
renamed: true,
|
||||
manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME),
|
||||
revision: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await createAutomatically(result);
|
||||
});
|
||||
|
||||
// 用户已经切到另一个工作区(看门狗解围后打开别项目走的是同一条「上下文已被别人接管」判据)。
|
||||
await act(async () => {
|
||||
result.current.setCurrentProjectContext({
|
||||
projectPath: 'C:/test/other-project',
|
||||
projectName: '另一个项目',
|
||||
projectKind: 'web',
|
||||
manifest: createGameCreationAppManifest('other-project', '另一个项目'),
|
||||
projectRevision: 3,
|
||||
creationType: null,
|
||||
startMode: null,
|
||||
initialPrompt: '',
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
createdAt: 1,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
naming.resolve(SUGGESTED_NAME);
|
||||
await naming.promise;
|
||||
});
|
||||
|
||||
// 后台改名不能把用户当前所在的项目上下文换成刚建好的那个工作区。
|
||||
expect(result.current.currentProjectContext?.projectPath).toBe(
|
||||
'C:/test/other-project',
|
||||
);
|
||||
expect(result.current.currentProjectContext?.projectName).toBe('另一个项目');
|
||||
// 但刚刚建好的工作区确实改名了:最近项目行照旧按目录重检。
|
||||
expect(refreshRecentWorkspace).toHaveBeenCalledWith(PROJECT_PATH);
|
||||
});
|
||||
|
||||
it('does not write the project context after the launcher unmounted', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, unmount, refreshRecentWorkspace } = mount({
|
||||
naming: naming.promise,
|
||||
conditionalRename: async () => ({
|
||||
renamed: true,
|
||||
manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME),
|
||||
revision: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await createAutomatically(result);
|
||||
});
|
||||
unmount();
|
||||
await act(async () => {
|
||||
naming.resolve(SUGGESTED_NAME);
|
||||
await naming.promise;
|
||||
});
|
||||
|
||||
// 卸载后迟到的命名结果不再写上下文,也就不该走到上下文写入后面的最近项目重检。
|
||||
// (卸载后 `result.current` 冻结在最后一次渲染,只有这条副作用能观测到越界写入。)
|
||||
expect(refreshRecentWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not write the project context after the window was hidden', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, refreshRecentWorkspace } = mount({
|
||||
naming: naming.promise,
|
||||
conditionalRename: async () => ({
|
||||
renamed: true,
|
||||
manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME),
|
||||
revision: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await createAutomatically(result);
|
||||
});
|
||||
// `pagehide` 是壳自己的作废信号(关窗 / 切走),与卸载走同一枚 mounted + 代次守卫。
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
});
|
||||
await act(async () => {
|
||||
naming.resolve(SUGGESTED_NAME);
|
||||
await naming.promise;
|
||||
});
|
||||
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
|
||||
expect(refreshRecentWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ResourceCanvasProjectNamingTask } from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
|
||||
import { ResourceCanvasAssetGenerationTasksPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
|
||||
import { cleanup, render, screen, within } from './appSurface/harness';
|
||||
|
||||
/**
|
||||
* 「生成任务」侧栏里的「AI 项目命名」行。
|
||||
*
|
||||
* 它与素材生成共用同一份账本、同一个事件与同一套卡片样式,但有两条自己的口径:
|
||||
* 1. **展示名固定**为「AI 项目命名」:不渲染素材缩略图 / 提示词 / 派发与「定位到素材」;
|
||||
* 2. **结论只在终态渲染**:进行中由状态徽章表达(排队中 / 命名中),终态才显示后端给的结论
|
||||
* (已应用 AI 名称 / 已跳过 / 失败保留兜底名)。
|
||||
*/
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function namingTask(
|
||||
overrides: Partial<ResourceCanvasProjectNamingTask> = {},
|
||||
): ResourceCanvasProjectNamingTask {
|
||||
return {
|
||||
taskId: 'project-naming:local-project-1',
|
||||
projectId: 'local-project-1',
|
||||
status: 'running',
|
||||
phaseDetail: '正在等待 AI 命名结果。',
|
||||
createdAtMillis: 1000,
|
||||
startedAtMillis: 1200,
|
||||
finishedAtMillis: null,
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(tasks: readonly ResourceCanvasProjectNamingTask[]) {
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
namingTasks={tasks}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('生成任务侧栏里的 AI 项目命名行', () => {
|
||||
it('进行中只给状态徽章,不渲染结论、缩略图、提示词或定位动作', () => {
|
||||
renderPanel([namingTask()]);
|
||||
|
||||
const row = document.querySelector(
|
||||
'[data-task-source="project-naming"]',
|
||||
) as HTMLElement | null;
|
||||
expect(row).not.toBeNull();
|
||||
expect(within(row!).getByText('AI 项目命名')).not.toBeNull();
|
||||
expect(within(row!).getByText('命名中')).not.toBeNull();
|
||||
// 进行中不显示后端阶段文案;也没有素材任务的缩略图/提示词/派发按钮。
|
||||
expect(within(row!).queryByText('正在等待 AI 命名结果。')).toBeNull();
|
||||
expect(within(row!).queryByRole('button', { name: /定位素材/ })).toBeNull();
|
||||
expect(row!.querySelector('img')).toBeNull();
|
||||
});
|
||||
|
||||
it('终态渲染可自解释的结论(已应用 / 已跳过 / 失败保留兜底名)', () => {
|
||||
const { unmount } = render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
namingTasks={[
|
||||
namingTask({
|
||||
status: 'completed',
|
||||
phaseDetail: '已应用 AI 名称「星河远征」。',
|
||||
finishedAtMillis: 2200,
|
||||
}),
|
||||
]}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const appliedRow = document.querySelector(
|
||||
'[data-task-source="project-naming"]',
|
||||
) as HTMLElement | null;
|
||||
expect(within(appliedRow!).getByText('已完成')).not.toBeNull();
|
||||
expect(
|
||||
within(appliedRow!).getByText('已应用 AI 名称「星河远征」。'),
|
||||
).not.toBeNull();
|
||||
unmount();
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
namingTasks={[
|
||||
namingTask({
|
||||
status: 'completed',
|
||||
phaseDetail:
|
||||
'当前名称已不是本次创建的兜底名(用户已手动改名),已跳过 AI 命名。',
|
||||
finishedAtMillis: 2200,
|
||||
}),
|
||||
]}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const skippedRow = document.querySelector(
|
||||
'[data-task-source="project-naming"]',
|
||||
) as HTMLElement | null;
|
||||
expect(
|
||||
within(skippedRow!).getByText(
|
||||
'当前名称已不是本次创建的兜底名(用户已手动改名),已跳过 AI 命名。',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('失败行显示失败徽章与原因,并计入在途计数', () => {
|
||||
const { unmount } = render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
namingTasks={[
|
||||
namingTask({
|
||||
status: 'failed',
|
||||
phaseDetail: 'AI 命名请求失败或没有返回可用名称,已保留兜底名。',
|
||||
finishedAtMillis: 2200,
|
||||
error: '自动项目命名超时(超过 15 秒)',
|
||||
}),
|
||||
]}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const row = document.querySelector(
|
||||
'[data-task-source="project-naming"]',
|
||||
) as HTMLElement | null;
|
||||
expect(within(row!).getByText('失败')).not.toBeNull();
|
||||
expect(
|
||||
within(row!).getByText(
|
||||
'AI 命名请求失败或没有返回可用名称,已保留兜底名。',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(within(row!).getByRole('alert').textContent).toContain(
|
||||
'自动项目命名超时',
|
||||
);
|
||||
unmount();
|
||||
|
||||
// 收起态:在途的命名任务要出现在右上角开关的计数里(与素材任务同一个计数)。
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
namingTasks={[namingTask()]}
|
||||
open={false}
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText('生成任务').textContent).toContain(
|
||||
'生成任务 · 1',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import type { LauncherProjectContext } from '../src/app/types';
|
||||
import { WindowChrome } from '../src/components/WindowChrome';
|
||||
import {
|
||||
useWindowChrome,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher';
|
||||
import {
|
||||
act,
|
||||
createGameCreationAppManifest,
|
||||
expect,
|
||||
it,
|
||||
React,
|
||||
@@ -24,6 +26,7 @@ const homeProjectOverride = vi.hoisted(() => ({
|
||||
openProject: null as
|
||||
| null
|
||||
| ((path: string, mode: 'open' | 'create') => Promise<void>),
|
||||
projectContext: null as LauncherProjectContext | null,
|
||||
}));
|
||||
|
||||
vi.mock('../src/features/app-shell/useHomeProjectCreation', async () => {
|
||||
@@ -36,9 +39,16 @@ vi.mock('../src/features/app-shell/useHomeProjectCreation', async () => {
|
||||
...args: Parameters<typeof actual.useHomeProjectCreation>
|
||||
) {
|
||||
const result = actual.useHomeProjectCreation(...args);
|
||||
return homeProjectOverride.openProject
|
||||
? { ...result, openProject: homeProjectOverride.openProject }
|
||||
const withContext = homeProjectOverride.projectContext
|
||||
? {
|
||||
...result,
|
||||
currentProjectContext: homeProjectOverride.projectContext,
|
||||
setCurrentProjectContext: (() => undefined) as never,
|
||||
}
|
||||
: result;
|
||||
return homeProjectOverride.openProject
|
||||
? { ...withContext, openProject: homeProjectOverride.openProject }
|
||||
: withContext;
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -112,3 +122,113 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且
|
||||
rendered.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 只记录工作台发布给窗口标题栏的「当前项目」名;转发超过 12 次就不再往下传,
|
||||
* 仅用于防止回归时测试进入无限 setState。
|
||||
*/
|
||||
function ProjectScopeBridge({
|
||||
children,
|
||||
record,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
record: (projectName: string | null) => void;
|
||||
}) {
|
||||
const chrome = useWindowChrome();
|
||||
const { setActiveProjectRuns } = chrome;
|
||||
const forwardCount = useRef(0);
|
||||
const publish = useCallback(
|
||||
(next: WindowChromeActiveProjectRuns | null) => {
|
||||
if (next) record(next.currentProjectName ?? null);
|
||||
if (forwardCount.current >= 12) return;
|
||||
forwardCount.current += 1;
|
||||
setActiveProjectRuns(next);
|
||||
},
|
||||
[record, setActiveProjectRuns],
|
||||
);
|
||||
return (
|
||||
<WindowChromeContext.Provider
|
||||
value={{ ...chrome, setActiveProjectRuns: publish }}
|
||||
>
|
||||
{children}
|
||||
</WindowChromeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 会话里已打开过项目:`currentProjectContext` 在离开工作台后不会被清空。 */
|
||||
function openedProjectContext(): LauncherProjectContext {
|
||||
return {
|
||||
projectPath: '/tmp/window-scope-project',
|
||||
projectName: '飞鸟 v2',
|
||||
projectKind: 'web',
|
||||
manifest: createGameCreationAppManifest('window-scope-project', '飞鸟 v2'),
|
||||
projectRevision: null,
|
||||
creationType: null,
|
||||
startMode: null,
|
||||
initialPrompt: '',
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
createdAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function renderScopeRecordedAt(
|
||||
initialView: 'projects' | 'project-development',
|
||||
record: (projectName: string | null) => void,
|
||||
) {
|
||||
return render(
|
||||
<WindowChrome>
|
||||
<ProjectScopeBridge record={record}>
|
||||
<WorkspaceLauncherShell
|
||||
currentUser={testAuthUser}
|
||||
onLogout={() => undefined}
|
||||
initialView={initialView}
|
||||
ProjectChat={() => null}
|
||||
/>
|
||||
</ProjectScopeBridge>
|
||||
</WindowChrome>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回归:`currentProjectContext` 是会话级的(离开工作台不清空),发布时若照搬它,
|
||||
* 首页 / 项目组 / 模板库上标签会一直写「当前项目:<上次打开的项目>」,徽标数字却属于
|
||||
* 别的项目。只有工作台视图才认这个身份。
|
||||
*/
|
||||
it('不在工作台视图时不发布会话残留的项目身份', async () => {
|
||||
const publishedNames: Array<string | null> = [];
|
||||
homeProjectOverride.projectContext = openedProjectContext();
|
||||
resetTauriRuntime();
|
||||
const rendered = renderScopeRecordedAt('projects', (name) => {
|
||||
publishedNames.push(name);
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(publishedNames.length).toBeGreaterThan(0);
|
||||
expect(publishedNames.every((name) => name === null)).toBe(true);
|
||||
} finally {
|
||||
homeProjectOverride.projectContext = null;
|
||||
rendered.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('工作台视图里照常发布当前项目名', async () => {
|
||||
const publishedNames: Array<string | null> = [];
|
||||
homeProjectOverride.projectContext = openedProjectContext();
|
||||
resetTauriRuntime();
|
||||
const rendered = renderScopeRecordedAt('project-development', (name) => {
|
||||
publishedNames.push(name);
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(publishedNames).toContain('飞鸟 v2');
|
||||
} finally {
|
||||
homeProjectOverride.projectContext = null;
|
||||
rendered.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user