合并 修复 AGC 发布入口在 DirectProject 下的反馈与确认
Project CI / AI game creator shell Rust crates (push) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m53s
Project CI / Backend tests (push) Successful in 4m13s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m31s
Project CI / Frontend tests (push) Successful in 2m39s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 8m55s
Project CI / Native shell tests (push) Successful in 7m2s
Project CI / Repository checks (push) Successful in 2m35s
Project CI / AI game creator shell web tests (push) Successful in 1m28s

- 发布动作即时回显权限检查与构建打包状态
- DirectProject 对话渲染项目权限确认卡片并回传确认与取消
- 权限拒绝、取消和查询失败统一回显到项目对话
- 新增发布反馈回归用例并记录排障口径
This commit is contained in:
2026-09-23 13:01:45 +08:00
4 changed files with 338 additions and 10 deletions
+31 -10
View File
@@ -1145,6 +1145,7 @@ export function App({
...current,
{ role: 'assistant', text: message },
]);
directProjectChatRef.current?.announce(message);
return true;
}
if (!policyView.policy.confirmCommands.includes(commandId)) {
@@ -1155,6 +1156,7 @@ export function App({
...current,
{ role: 'assistant', text: readyMessage },
]);
directProjectChatRef.current?.announce(readyMessage);
return true;
}
@@ -1180,6 +1182,7 @@ export function App({
...current,
{ role: 'assistant', text: message },
]);
directProjectChatRef.current?.announce(message);
return true;
} catch {
return false;
@@ -1229,7 +1232,11 @@ export function App({
announcePublishMessage('先打开一个项目再发布');
return;
}
// 权限查询也可能慢或挂住,先回一条即时反馈,别让按钮看起来没反应。
announcePublishMessage('正在检查发布权限…');
const runExport = async () => {
// 导出可能包含构建步骤,再回一条即时反馈。
announcePublishMessage('正在构建并打包试玩包,请稍候…');
try {
const result = await invoke<LocalProjectExportPackageResult>(
'export_local_project_package',
@@ -1251,16 +1258,26 @@ export function App({
);
}
};
const queued = await queueProjectPolicyConfirmationIfNeeded(
invoke,
'project.export_package',
nextProjectPath,
'导出试玩包并打开「发布到游戏广场」面板。',
'导出试玩包需要确认,确认后继续。',
() => void runExport(),
);
if (!queued) {
await runExport();
try {
const queued = await queueProjectPolicyConfirmationIfNeeded(
invoke,
'project.export_package',
nextProjectPath,
'导出试玩包并打开「发布到游戏广场」面板。',
'导出试玩包需要确认,确认后继续。',
() => void runExport(),
);
if (!queued) {
await runExport();
}
} catch (error) {
// 权限查询失败也必须回话:onClick 的 Promise 没有 catch 时用户只会看到
// 「点了没反应」,这里把它收敛成聊天里的可读错误。
announcePublishMessage(
`发布前权限检查失败:${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
@@ -1368,6 +1385,7 @@ export function App({
? '已取消对比项目 checkpoint'
: '已取消导出本地试玩包';
setWorkspaceStatus(status);
directProjectChatRef.current?.announce(status);
}
if (pending.commandId === 'project.create') {
setWorkspaceStatus('已取消');
@@ -2320,6 +2338,9 @@ export function App({
onRequestGamePublish={
gamePublishAllowed ? requestGamePublish : undefined
}
onCancelConfirmation={cancelUiCommandConfirmation}
onConfirmConfirmation={confirmUiCommand}
pendingConfirmation={pendingUiConfirmation}
projectPath={localProject?.projectPath ?? projectPath ?? null}
ref={directProjectChatRef}
/>
@@ -10,6 +10,7 @@ import {
import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD } from '../../../app/constants';
import { claimInitialTurnForPage } from '../../../app/initialTurnClaims';
import type { PendingUiConfirmation } from '../../../app/types';
import { projectNameFromPath } from '../../../features/agent-runtime';
import { directCodexUserItemFromContent } from '../../../features/project-workspace/resourceReferences';
import { type ApprovalMode, approvalModeLabel } from '../approvalMode';
@@ -80,6 +81,13 @@ export type DirectProjectChatViewProps = {
ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
/** 发布入口:由工作台壳持有导出与发布面板,聊天只提供触发按钮。 */
onRequestGamePublish?: () => void;
/**
* 工作台壳入队的项目权限确认:聊天只展示并转发确认/取消,
* 策略读取、拒绝判定和确认后的动作仍由工作台壳持有。
*/
pendingConfirmation?: PendingUiConfirmation | null;
onConfirmConfirmation?: () => void;
onCancelConfirmation?: () => void;
/** 工作台壳把运行/预览这类动作结果交给聊天的出口。 */
ref?: Ref<DirectProjectChatHandle>;
};
@@ -95,6 +103,9 @@ function directInitialTurnText(content: readonly DirectCodexUserContentPart[]) {
export function DirectProjectChatView({
onRequestGamePublish,
onConfirmConfirmation,
onCancelConfirmation,
pendingConfirmation = null,
projectPath,
initialTurn = null,
ensureConversationReadAllowed,
@@ -242,6 +253,22 @@ export function DirectProjectChatView({
onLoadEarlierHistory={() => void loadEarlierHistory()}
onScroll={handleScroll}
/>
{pendingConfirmation &&
onConfirmConfirmation &&
onCancelConfirmation ? (
<div className="pending-command">
<span>
{pendingConfirmation.commandId}
<small>{pendingConfirmation.detail}</small>
</span>
<button type="button" onClick={onCancelConfirmation}>
</button>
<button type="button" onClick={onConfirmConfirmation}>
</button>
</div>
) : null}
<DirectProjectComposer
assets={assets}
{...(versions ? { versions } : {})}
@@ -0,0 +1,272 @@
// @vitest-environment jsdom
/**
* 客户端「发布」入口的可见反馈。
*
* DirectProject 项目不发工作台状态行,发布动作的提示必须回到项目对话;
* 权限要求确认时也要在聊天里给出确认/取消,否则表现就是「点了没反应」。
*/
import { beforeEach, describe, it, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
App,
createGameCreationAppManifest,
createProjectChatRuntimeHarness,
emptyProjectPolicy,
expect,
fireEvent,
render,
screen,
waitFor,
within,
} from './appSurface/harness';
const readGamePublishAvailabilityMock = vi.hoisted(() =>
vi.fn(async () => true),
);
vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
const actual =
await importOriginal<
typeof import('../src/services/gameDistributionPublish')
>();
return {
...actual,
readGamePublishAvailability: readGamePublishAvailabilityMock,
};
});
const PROJECT_PATH = '/tmp/game-publish-feedback-project';
const PROJECT_ID = 'game-publish-feedback-project';
function createFixtureManifest(): GameCreationAppManifest {
return createGameCreationAppManifest(PROJECT_ID, '发布反馈项目');
}
function installTauri(
options: {
exportPackage?: () => unknown;
policy?: ReturnType<typeof emptyProjectPolicy>;
readPolicy?: () => unknown;
} = {},
) {
const manifest = createFixtureManifest();
const chatHarness = createProjectChatRuntimeHarness({
projectPath: PROJECT_PATH,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_manifest') return manifest;
if (command === 'get_local_game_project_revision') return { revision: 1 };
if (command === 'read_project_permission_policy') {
if (options.readPolicy) return options.readPolicy();
return options.policy ?? emptyProjectPolicy();
}
if (command === 'export_local_project_package') {
if (options.exportPackage) return options.exportPackage();
return {
projectPath: PROJECT_PATH,
packagePath: `${PROJECT_PATH}/exports/game.zip`,
packageRelativePath: 'exports/game.zip',
fileCount: 1,
totalBytes: 1,
};
}
return chatHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke: invoke as never },
event: { listen: chatHarness.listen as never },
};
return { invoke };
}
function renderPublishProject() {
return render(
<App
initialProjectManifest={createFixtureManifest()}
initialProjectPath={PROJECT_PATH}
/>,
);
}
async function clickPublish() {
const publish = await screen.findByRole('button', {
name: '发布到游戏广场',
});
fireEvent.click(publish);
return screen.findByLabelText('陶泥儿项目对话');
}
beforeEach(() => {
window.history.pushState({}, '', '/');
readGamePublishAvailabilityMock.mockReset();
readGamePublishAvailabilityMock.mockResolvedValue(true);
});
function createExportPackageResult() {
return {
projectPath: PROJECT_PATH,
packagePath: `${PROJECT_PATH}/exports/game.zip`,
packageRelativePath: 'exports/game.zip',
fileCount: 1,
totalBytes: 1,
};
}
function createConfirmPolicy() {
return {
path: '.agent/policy.json',
policy: {
deniedCommands: [],
confirmCommands: ['project.export_package'],
},
};
}
describe('客户端发布入口的可见反馈', () => {
it('导出成功时先回即时反馈,再回结果并打开发布面板', async () => {
installTauri({ exportPackage: createExportPackageResult });
renderPublishProject();
const surface = await clickPublish();
await waitFor(() => {
expect(surface.textContent ?? '').toContain('正在检查发布权限…');
expect(surface.textContent ?? '').toContain(
'正在构建并打包试玩包,请稍候…',
);
expect(surface.textContent ?? '').toContain(
'已构建并打包试玩包:exports/game.zip',
);
});
expect(
await screen.findByRole('dialog', { name: '发布到游戏广场' }),
).not.toBeNull();
});
it('导出失败时把可读错误写回项目对话', async () => {
installTauri({
exportPackage: () => {
throw new Error('导出试玩包前需要先生成 exports/README.md');
},
});
renderPublishProject();
const surface = await clickPublish();
await waitFor(() => {
expect(surface.textContent ?? '').toContain(
'导出试玩包前需要先生成 exports/README.md',
);
});
});
it('策略要求确认时在 DirectProject 里展示确认卡片,确认后继续导出', async () => {
const exportPackage = vi.fn(createExportPackageResult);
installTauri({ exportPackage, policy: createConfirmPolicy() });
renderPublishProject();
const publish = await screen.findByRole('button', {
name: '发布到游戏广场',
});
fireEvent.click(publish);
const commandLabel = await screen.findByText('project.export_package');
const card = commandLabel.closest('.pending-command');
expect(card).not.toBeNull();
expect(exportPackage).not.toHaveBeenCalled();
expect(
within(card as HTMLElement).getByText(
'导出试玩包并打开「发布到游戏广场」面板。',
),
).not.toBeNull();
fireEvent.click(
within(card as HTMLElement).getByRole('button', { name: '确认' }),
);
await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1));
const surface = await screen.findByLabelText('陶泥儿项目对话');
expect(surface.textContent ?? '').toContain(
'正在构建并打包试玩包,请稍候…',
);
expect(surface.textContent ?? '').toContain(
'已构建并打包试玩包:exports/game.zip',
);
});
it('取消权限确认时把取消结果写回项目对话', async () => {
const exportPackage = vi.fn(createExportPackageResult);
installTauri({ exportPackage, policy: createConfirmPolicy() });
renderPublishProject();
fireEvent.click(
await screen.findByRole('button', { name: '发布到游戏广场' }),
);
const commandLabel = await screen.findByText('project.export_package');
const card = commandLabel.closest('.pending-command');
expect(card).not.toBeNull();
fireEvent.click(
within(card as HTMLElement).getByRole('button', { name: '取消' }),
);
const surface = await screen.findByLabelText('陶泥儿项目对话');
await waitFor(() => {
expect(surface.textContent ?? '').toContain('已取消导出本地试玩包');
});
expect(exportPackage).not.toHaveBeenCalled();
});
it('确认时策略已改为拒绝,也要把拒绝原因写回项目对话', async () => {
const exportPackage = vi.fn(createExportPackageResult);
let denyOnNextPolicyRead = false;
installTauri({
exportPackage,
readPolicy: () => {
if (!denyOnNextPolicyRead) return createConfirmPolicy();
return {
path: '.agent/policy.json',
policy: {
deniedCommands: ['project.export_package'],
confirmCommands: [],
},
};
},
});
renderPublishProject();
fireEvent.click(
await screen.findByRole('button', { name: '发布到游戏广场' }),
);
const commandLabel = await screen.findByText('project.export_package');
const card = commandLabel.closest('.pending-command');
expect(card).not.toBeNull();
denyOnNextPolicyRead = true;
fireEvent.click(
within(card as HTMLElement).getByRole('button', { name: '确认' }),
);
const surface = await screen.findByLabelText('陶泥儿项目对话');
await waitFor(() => {
expect(surface.textContent ?? '').toContain(
'项目权限策略拒绝执行:project.export_package',
);
});
expect(exportPackage).not.toHaveBeenCalled();
});
it('权限查询失败时也在项目对话里回显错误', async () => {
installTauri({
readPolicy: () => {
throw new Error('策略文件损坏');
},
});
renderPublishProject();
const surface = await clickPublish();
await waitFor(() => {
expect(surface.textContent ?? '').toContain(
'发布前权限检查失败:策略文件损坏',
);
});
});
});
@@ -5805,6 +5805,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **处理(现行口径)**:① 产品行为保留“导出成功后自动打开面板”(一键发布入口),但受影响的用例必须先用 `findByRole('dialog', { name: '发布到游戏广场' })` 断言面板出现、点「关闭发布面板」再继续后续会话操作;② 给这类“新增自动弹窗”改流程时,先跑一遍相关 `appSurface` 用例,避免只跑新增用例;③ 排查同类“点了没反应”时,先看当前是否有焦点陷阱模态打开,而不是先怀疑事件绑定或状态。
- **关联**`apps/ai-game-creator-shell/src/App.tsx``setPublishPanelOpen(true)`)、`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx``apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx``apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts`
## 2026-09-23 AGC 发布按钮点了没反应:DirectProject 不渲染 workspaceStatus,权限确认也没有挂载点
- **现象**:AGC 聊天头能看到「发布」按钮,点击后没有任何提示、面板也不出现;项目权限要求确认时同样静默,用户只能描述为「点了没反应」。
- **原因**:普通项目固定走 `DirectProjectChatView`,它不渲染工作台状态行,也不渲染 `PlanningChatView` 里那块 `pending-command` 确认卡片;发布提示、权限确认和取消结果原先只写 `workspaceStatus` / `messages`。上游 `f502829fd` 已把发布提示同时 `announce` 回 DirectProject,但确认卡片仍未挂载到 DirectProject;权限查询抛错也没有 catch,确认时策略改为拒绝同样只写工作台消息。结果等待确认、命令被拒、确认后拒绝这几条路径仍表现为「点了没反应」。
- **处理(现行口径)**:发布相关提示统一走 `announcePublishMessage`,同时写 `workspaceStatus``directProjectChatRef.current.announce``DirectProjectChatView` 增加 `pendingConfirmation` / `onConfirmConfirmation` / `onCancelConfirmation`,在对话列表与输入盒之间渲染与 `PlanningChatView` 同形的确认条;`requestGamePublish` 对权限查询整段兜底并把错误回显到聊天,点击后先回「正在检查发布权限…」,进入导出再回「正在构建并打包试玩包」。
- **验证**`npx vitest run apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx`(正常发布 / 导出失败 / 确认继续 / 取消 / 确认时拒绝 / 权限查询失败 6 条)与 AGC 全量前端用例通过;`npm run ai-game-creator-shell:typecheck``npm run check:encoding``git diff --check` 通过。
- **关联**`apps/ai-game-creator-shell/src/App.tsx``apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx``apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx`
## 2026-09-21 受控 Lexical 输入区的回写用被动 effect:滞后渲染的 props 会把用户草稿清空
- **现象**DirectProject 输入盒里粘贴(或连续输入)长文本,提交时 `chat_with_game_creator_direct_codex` 根本没发出去,界面停在空输入盒;`chat-composer` 用例里表现为「队列/终止/语音追加」五条一起红,但手工操作只在快速输入后偶发。