Merge branch 'master' into codex/agc-browser-orphan-cleanup
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-09-22 16:26:30 +08:00
7 changed files with 365 additions and 13 deletions
+10 -3
View File
@@ -513,6 +513,7 @@ export function App({
texts.push(entry.text);
reasoningByMessageId.set(entry.messageId, texts);
}
// 持久策划消息没有发送时间,不能把读取时刻显示成历史发送时间。
const messages: ChatMessage[] = view.messages
.filter((message) => message.text.trim())
.map((message) => ({
@@ -521,7 +522,6 @@ export function App({
runtimeOwned: true,
messageId: message.id,
reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'),
updatedAt: Date.now(),
}));
const initialPrompt = initialPlanningPromptLatchRef.current.prompt;
if (
@@ -534,7 +534,6 @@ export function App({
role: 'user',
text: initialPrompt,
runtimeOwned: true,
updatedAt: Date.now(),
});
}
return messages;
@@ -872,7 +871,15 @@ export function App({
if (messageList) {
messageList.scrollTop = messageList.scrollHeight;
}
}, [messages, projectChatError]);
}, [
messages,
projectChatError,
designAgentTransientReply,
designAgentReasoning,
designAgentView,
pendingUiConfirmation,
chatFileImportNotice,
]);
useEffect(() => {
latestMessagesRef.current = messages;
+50 -7
View File
@@ -10121,8 +10121,8 @@ button.design-workspace-tree__entry:hover,
justify-self: start;
}
/* 策划聊天区包含阶段控制卡消息Runtime 状态和输入框GameAgent 资源工作台的
消息列表默认占满整个聊天区策划模式需要单独恢复五行布局避免输入框被推到视口外 */
/* 阶段状态条和可选待办按内容排布只有消息列表吸收剩余高度
不按子节点序号分配 grid 避免状态条或待办占用消息的伸缩空间 */
/* 策划工作台保留标题行,避免共用跨行规则将标题挤到底部。 */
.game-workbench-layout--design .game-workbench-chat {
grid-template-rows: auto minmax(0, 1fr);
@@ -10142,13 +10142,24 @@ button.design-workspace-tree__entry:hover,
}
.game-workbench-layout--design .project-chat-conversation {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto auto;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: visible;
}
.game-workbench-layout--design .project-chat-conversation > * {
flex: 0 0 auto;
min-width: 0;
}
.game-workbench-layout--design
.project-chat-conversation
> .project-chat-message-list {
flex: 1 1 0;
}
.game-workbench-layout--design .project-chat-message-list {
height: auto;
min-height: 0;
@@ -10156,6 +10167,16 @@ button.design-workspace-tree__entry:hover,
padding-bottom: 12px;
}
.game-workbench-layout--design .design-agent-controls,
.game-workbench-layout--design .design-agent-pending-actions,
.game-workbench-layout--design .pending-command {
flex-shrink: 1;
min-height: 0;
max-height: 30%;
overflow: auto;
overflow-wrap: anywhere;
}
.game-workbench-layout--design .project-chat-composer {
min-height: 0;
grid-template-columns: minmax(0, 1fr);
@@ -10176,13 +10197,24 @@ button.design-workspace-tree__entry:hover,
}
@media (max-width: 760px) {
.game-workbench-layout--design {
height: auto;
.game-project-workbench--design {
overflow-x: hidden;
overflow-y: auto;
}
/* 两块面板在固定外壳内滚动;覆盖外壳的 height: 100%,避免第二行被裁切。 */
.game-project-workbench--design .game-workbench-layout--design {
height: auto;
grid-template-columns: minmax(0, 1fr);
}
.game-workbench-layout--design .game-workbench-stage {
height: 560px;
}
.game-workbench-layout--design .game-workbench-stage,
.game-workbench-layout--design .game-workbench-chat {
min-height: 560px;
height: clamp(560px, calc(100dvh - 154px), 900px);
}
.game-workbench-layout--design .project-chat-surface,
@@ -11019,6 +11051,12 @@ button.design-workspace-tree__entry:hover,
font-variant-numeric: tabular-nums;
}
.game-workbench-layout--design .project-chat-composer-notice {
max-height: 96px;
overflow: auto;
overflow-wrap: anywhere;
}
.game-workbench-layout--design .project-chat-composer-notice,
.game-workbench-chat
.project-chat-surface.is-direct-codex
@@ -11253,6 +11291,7 @@ button.design-workspace-tree__entry:hover,
/* 顶部极简条44px 无独立背景块只有一条极细分隔线
左右内缩与消息内容 / 输入盒统一为 16px面板自身 padding 已归 0 */
.game-workbench-layout--design .project-chat-topbar,
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-topbar {
@@ -11267,6 +11306,7 @@ button.design-workspace-tree__entry:hover,
background: transparent;
}
.game-workbench-layout--design .project-chat-topbar-status,
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-topbar-status {
@@ -11279,6 +11319,7 @@ button.design-workspace-tree__entry:hover,
line-height: 1.5;
}
.game-workbench-layout--design .project-chat-topbar-status > span:last-child,
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-topbar-status
@@ -11287,6 +11328,7 @@ button.design-workspace-tree__entry:hover,
overflow-wrap: anywhere;
}
.game-workbench-layout--design .project-chat-topbar-dot,
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-topbar-dot {
@@ -11298,6 +11340,7 @@ button.design-workspace-tree__entry:hover,
background: var(--platform-neutral-bg);
}
.game-workbench-layout--design .project-chat-topbar-dot.is-busy,
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-topbar-dot.is-busy {
@@ -199,9 +199,11 @@ export function PlanningChatView({
className={`project-chat-topbar-dot${controlBusy ? ' is-busy' : ''}`}
aria-hidden="true"
/>
{controlBusy
? '策划 Agent 正在处理'
: workspaceStatusForDisplay(workspaceStatus)}
<span>
{controlBusy
? '策划 Agent 正在处理'
: workspaceStatusForDisplay(workspaceStatus)}
</span>
</span>
</header>
<div
@@ -405,6 +405,148 @@ export function registerDesignAgentSurfaceTests() {
);
});
it('follows streamed Design Agent content until the user scrolls up', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designConversationView(),
designAgentContinueView: designConversationView(),
});
renderDesignAgent(harness);
const input = await screen.findByLabelText('项目需求');
await setComposerText(input, '请继续完善方案');
fireEvent.submit(input.closest('form') as HTMLFormElement);
await waitFor(() =>
expect(harness.invoke).toHaveBeenCalledWith(
'continue_design_agent_session',
expect.objectContaining({
input: { type: 'message', text: '请继续完善方案' },
}),
),
);
const continueCall = [...harness.invoke.mock.calls]
.reverse()
.find(([command]) => command === 'continue_design_agent_session');
const clientTurnId = String(
(continueCall?.[1] as { clientTurnId?: string }).clientTurnId,
);
const messageList = screen.getByLabelText('立项策划消息');
Object.defineProperty(messageList, 'scrollHeight', {
configurable: true,
value: 1000,
});
Object.defineProperty(messageList, 'clientHeight', {
configurable: true,
value: 200,
});
messageList.scrollTop = 800;
fireEvent.scroll(messageList);
act(() =>
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'reasoning',
reasoningText: '先核对玩法循环',
}),
);
await screen.findAllByText('先核对玩法循环');
await waitFor(() => expect(messageList.scrollTop).toBe(1000));
messageList.scrollTop = 800;
fireEvent.scroll(messageList);
act(() =>
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'text',
text: '正在补充关卡节奏',
}),
);
await screen.findByLabelText('策划 Agent 实时回复');
await waitFor(() => expect(messageList.scrollTop).toBe(1000));
messageList.scrollTop = 120;
fireEvent.scroll(messageList);
act(() => {
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'reasoning',
reasoningText: '继续检查多人规则',
});
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'text',
text: '正在补充关卡节奏与多人规则',
});
});
await screen.findAllByText('继续检查多人规则');
await waitFor(() =>
expect(
screen.getByLabelText('策划 Agent 实时回复').textContent,
).toContain('多人规则'),
);
expect(messageList.scrollTop).toBe(120);
});
it('shows only known optimistic send times and does not invent persisted times', async () => {
const hydratedView = {
...designConversationView(),
messages: [{ id: 'user-history', role: 'user', text: '历史策划需求' }],
};
const continuedView = {
...designConversationView(),
messages: [
...hydratedView.messages,
{ id: 'user-current', role: 'user', text: '当前补充需求' },
],
};
const harness = createProjectChatRuntimeHarness({
designAgentView: hydratedView,
designAgentContinueView: continuedView,
});
const originalInvoke = harness.invoke.getMockImplementation()!;
let finishContinue!: () => void;
const continueGate = new Promise<void>((resolve) => {
finishContinue = resolve;
});
harness.invoke.mockImplementation(async (command, args) => {
if (command === 'continue_design_agent_session') {
await continueGate;
}
return originalInvoke(command, args);
});
renderDesignAgent(harness);
const historicalText = await screen.findByText('历史策划需求');
expect(
historicalText.closest('.message')?.querySelector('time'),
).toBeNull();
const input = screen.getByLabelText('项目需求');
await setComposerText(input, '当前补充需求');
fireEvent.submit(input.closest('form') as HTMLFormElement);
const optimisticText = await screen.findByText('当前补充需求');
const optimisticTime = optimisticText
.closest('.message')
?.querySelector('time');
expect(optimisticTime).not.toBeNull();
expect(optimisticTime?.getAttribute('dateTime')).toBeTruthy();
await act(async () => finishContinue());
await waitFor(() => {
const currentText = screen.getByText('当前补充需求');
expect(currentText.closest('.message')?.querySelector('time')).toBeNull();
});
expect(
screen
.getByText('历史策划需求')
.closest('.message')
?.querySelector('time'),
).toBeNull();
});
it('hydrates an existing design session and decides approval through design commands', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designApprovalView(),
@@ -416,3 +416,138 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文
expect(pixelValue(listInPanelDirectCodex, 'min-height')).toBe(0);
});
});
describe('策划对话:消息伸缩与长内容边界', () => {
function planningDeclarations(className: string, width: number) {
const root = document.createElement('div');
root.innerHTML = `<div class="window-chrome"><div class="window-chrome__content">
<div class="launcher-shell platform-theme"><aside></aside><main class="launcher-main">
<section class="launcher-page launcher-project-development game-project-workbench game-project-workbench--design">
<div class="game-workbench-layout game-workbench-layout--design">
<section class="game-workbench-stage"><div class="design-workspace-panel">
<div class="design-workspace-panel__body"><div class="design-workspace-tree"></div></div>
</div></section>
<aside class="game-workbench-chat"><header>策划</header>
<section class="project-chat-surface"><div class="project-chat-conversation">
<section class="design-agent-controls"></section>
<header class="project-chat-topbar"><span class="project-chat-topbar-status">
<span class="project-chat-topbar-dot"></span><span>已打开:项目</span>
</span></header>
<div class="message-list project-chat-message-list"></div>
<section class="design-agent-pending-actions"></section>
<form class="project-chat-composer"><p class="project-chat-composer-notice"></p></form>
</div></section>
</aside></div></section></main></div></div></div>`;
const element = root.querySelector(className)!;
// 从 DOM 匹配完整选择器,防止遗漏基础规则或误把 Direct 专用样式算到策划入口。
const selectors = rules.flatMap((rule) =>
rule.selectors.filter((selector) => {
try {
return element.matches(selector);
} catch {
// jsdom 不支持的浏览器伪类与本面板无关。
return false;
}
}),
);
return resolveDeclarations(rules, selectors, width);
}
it.each([1440, 390])(
'%ipx:消息吸收剩余高度,状态和输入区不参与拉伸',
(width) => {
const conversation = planningDeclarations(
'.project-chat-conversation',
width,
);
const list = planningDeclarations('.project-chat-message-list', width);
const composer = planningDeclarations('.project-chat-composer', width);
const topbar = planningDeclarations('.project-chat-topbar', width);
expect(declaration(conversation, 'display')).toBe('flex');
expect(declaration(conversation, 'flex-direction')).toBe('column');
expect(declaration(conversation, 'height')).toBe('100%');
expect(declaration(conversation, 'overflow')).toBe('visible');
expect(declaration(list, 'flex')).toBe('1 1 0');
expect(declaration(list, 'min-height')).toBe('0');
expect(declaration(list, 'overflow-y')).toBe('auto');
expect(declaration(composer, 'flex')).toBe('0 0 auto');
expect(declaration(topbar, 'flex')).toBe('0 0 auto');
expect(declaration(topbar, 'min-height')).toBe('44px');
expect(
declaration(
planningDeclarations('.project-chat-topbar-status', width),
'font-size',
),
).toBe('12px');
expect(
declaration(
planningDeclarations(
'.project-chat-topbar-status > span:last-child',
width,
),
'overflow-wrap',
),
).toBe('anywhere');
for (const selector of [
'.design-agent-controls',
'.design-agent-pending-actions',
]) {
const panel = planningDeclarations(selector, width);
expect(declaration(panel, 'max-height')).toBe('30%');
expect(declaration(panel, 'min-height')).toBe('0');
expect(declaration(panel, 'overflow')).toBe('auto');
}
},
);
it.each([390, 760])(
'%ipx:固定外壳内工作台可滚动到对话,两块面板保持明确高度',
(width) => {
const workbench = planningDeclarations('.game-project-workbench', width);
const layout = planningDeclarations('.game-workbench-layout', width);
const stage = planningDeclarations('.game-workbench-stage', width);
const chat = planningDeclarations('.game-workbench-chat', width);
expect(declaration(workbench, 'min-height')).toBe('0');
expect(declaration(workbench, 'overflow-y')).toBe('auto');
expect(declaration(workbench, 'overflow-x')).toBe('hidden');
expect(declaration(layout, 'height')).toBe('auto');
expect(declaration(layout, 'grid-template-columns')).toBe(
'minmax(0, 1fr)',
);
expect(declaration(stage, 'height')).toBe('560px');
expect(declaration(chat, 'height')).toBe(
'clamp(560px, calc(100dvh - 154px), 900px)',
);
expect(
declaration(
planningDeclarations('.design-workspace-panel', width),
'height',
),
).toBe('100%');
expect(
declaration(
planningDeclarations('.design-workspace-tree', width),
'overflow',
),
).toBe('auto');
},
);
it.each([761, 1280])('%ipx:双栏继续填满外壳剩余高度', (width) => {
const workbench = planningDeclarations('.game-project-workbench', width);
const layout = planningDeclarations('.game-workbench-layout', width);
expect(declaration(workbench, 'overflow')).toBe('hidden');
expect(workbench.has('overflow-y')).toBe(false);
expect(declaration(layout, 'height')).toBe('100%');
expect(declaration(layout, 'grid-template-columns')).toBe(
'minmax(0, 1fr) minmax(380px, 0.42fr)',
);
});
it('批量导入失败提示可滚动且可换行,不撑高输入区或撑宽面板', () => {
const notice = planningDeclarations('.project-chat-composer-notice', 390);
expect(declaration(notice, 'max-height')).toBe('96px');
expect(declaration(notice, 'overflow')).toBe('auto');
expect(declaration(notice, 'overflow-wrap')).toBe('anywhere');
});
});
@@ -5943,6 +5943,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
`append_application_log_line` 在落盘前对整行做 `sanitize_diagnostic_message`:行内只要出现 `token=``bearer ``authorization``credential``api key` / `apikey` / `api_key` 这类标记,**整行**就被换成 `<sensitive diagnostic details redacted>`,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId``code` 一起消失(2026-09-21 加统一错误事件的日志投影时按两行落:身份行只放程序生成与调用方常量字段,summary / hint / detail 等自由文本一律只放详情行,且自由文本先自行压平换行——裸词标记脱敏消不掉,自由文本放错行会把 eventId、code 一起带走)。
## 策划聊天不能按可选子节点序号分配消息高度
策划与开发 Agent 共用聊天类名,但布局合同不同。策划新增状态条后,按第二个子节点分配 `1fr` 会把空白给状态条、让消息框随回复增长;只给 `.is-direct-codex` 的状态样式也不会覆盖策划。策划使用纵向 Flex,仅消息列表伸缩,阶段/待处理区域限高滚动;改共用样式时同时核对两种入口。jsdom 交互通过不代表布局正确,须匹配完整 CSS 层叠并用真实浏览器核对空/短/长消息与长待办。窄屏上下堆叠必须在固定外壳内提供工作台滚动容器,两块面板明确限高;低优先级的 `height: auto` 不能覆盖外壳后代规则的 `height: 100%`。布局夹具必须包含窗口外壳与启动器层级,并实际滚动验证输入框可见可操作,不能只检查输入框在聊天面板内部。实时正文/思考不更新持久消息数组,滚动跟随必须覆盖这些独立状态并保留用户上滚门禁;历史消息缺失的时间不得用读取时刻填补。详见 AGC 实施计划的“策划 Agent 对话显示与滚动合同”。
## 2026-09-22 Rust 对象快照必须对齐 CI 编译目录和 Cargo 环境
- 现象:sccache 快照已包含数百 MiB 对象,但全新 target 的“热缓存”仍然全部 miss,甚至比直接 rustc 更慢。
@@ -241,6 +241,25 @@ Rust 侧在 `server-rs/crates/shared-contracts` 维护唯一权威 `GameCreation
Rust 分片日志在失败时输出有界 stdout 尾部中的失败段,保留 panic 位置、断言详情和 Rust 汇总;selected 只表示该分片选中的用例数量。成功分片保留简短摘要。定向验证使用相关 Rust 用例与 `node --test apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs`
## 策划 Agent 对话显示与滚动合同
策划工作台的对话区保留阶段状态、工作区状态、消息、可选确认/澄清和输入区。面板高度由工作台和视口决定;空消息、短回复和流式长回复不能改变消息区域的上下边界。阶段、状态条、输入区按内容排列,消息区独占剩余空间并内部滚动;待处理卡片出现时只让出其实际所需且有上限的空间,不依赖可选节点的序号分配伸缩高度。
- 工作区状态条复用现有聊天状态条的字号、间距和状态点。长项目名允许换行,不能撑宽面板;长错误、澄清和批量文件导入结果在受限区域内完整可读,不能挤走输入框。输入编辑器保留原有高度上限,模型/推理菜单继续允许弹出面板。
- 已在消息底部时,实时正文与思考增高后继续跟随;用户主动上滚阅读历史后不自动抢回底部,发送新消息后恢复跟随。阶段/待处理卡片改变消息可用高度时,仍遵守同一跟随意图。滚动仅为临时 UI 状态,不写入正式会话。
- 历史消息只有收到真实发送时间才能显示时间。现有策划持久消息不含逐消息时间,读取或刷新时保持未知,不以当前时刻补造。当前会话刚提交的乐观消息可以显示已知发送时间;正式快照覆盖后不补造或猜配旧消息时间。
- ≤760px 时资源区与对话区单列排列,策划工作台在固定外壳内纵向滚动,用户向下滚动可到达输入区。布局使用内容高度,并以同等或更高选择器优先级覆盖外壳的 `height: 100%`;资源区明确为 560px,对话区高度为 `clamp(560px, calc(100dvh - 154px), 900px)`。文件树与消息列表各自内部滚动,长内容不增加两块面板高度。>760px 继续共用外壳剩余高度,不启用工作台整体滚动。正式主窗最小宽度不变,窄屏验收覆盖浏览器响应式布局。
- 审批、澄清、导入期间禁用、错误重试、项目归属和发送权限沿用现有行为。此次调整不改变 Runtime、API、持久协议或数据库,无数据迁移;不重做开发 Agent 的对话布局。
验收使用策划组件与现有界面测试覆盖流式/历史跟随、未知时间、审批澄清和导入禁用;使用真实浏览器与当前样式核对桌面/窄屏的空、短、长消息,长项目名、长错误与长澄清,以及输入框和菜单位置。布局验收必须包含 `window-chrome → window-chrome__content → launcher-shell → launcher-main → game-project-workbench` 的完整外壳层级,覆盖 390px、760px、761px 和桌面尺寸;窄屏实际滚动工作台后,输入框须进入可视范围且可操作,长文件树、长消息和待确认内容不能使面板失去高度边界。不能只断言输入框位于聊天面板内部。开发 Agent 的既有布局回归同批执行。真实 Provider 请求不作为纯展示修复的前置条件,验证记录须区分模拟事件与真实 Provider。
| 验收面 | 对应证据 |
| --- | --- |
| 策划与开发 Agent 布局隔离、伸缩与窄屏滚动可达 | `chatDialogFrameLayout.test.ts` 包含外壳的选择器层叠回归;Chrome 实际组件、完整外壳与完整样式的 390px/760px/761px/桌面几何、滚动和输入操作检查 |
| 实时正文/思考跟随、上滚停留、发送恢复 | `appSurface/design-agent.suite.ts` 的实时事件与发送恢复用例;还原旧滚动依赖时新增用例失败 |
| 历史未知时间与乐观发送时间 | 同套界面测试覆盖读取、乐观发送和正式快照覆盖;恢复 `Date.now()` 补造时新增用例失败 |
| 审批/澄清、文件导入禁用及共用控件 | `appSurface.test.ts` 现有交互回归与 Chrome 模型菜单位置检查;不改变原生调用和业务状态 |
## 策划 Agent 批量局部修改
`patch_file` 的所有 edits 均匹配同一份原文件,参数顺序不影响结果。完成唯一匹配与不重叠校验后,按原文起点升序拼接未修改片段与替换文本,最后一次性写入;任一校验失败时不写文件。回归用例覆盖乱序 edits、中文内容与替换长度增减,并核对完整落盘内容。此行为仅属于策划 Agent 文件工具。