@@ -283,18 +289,36 @@ export function DirectProjectComposer({
/>
-
+ }
/>
composerRef.current?.insertText(text)}
+ onTranscript={(text) => {
+ /* 拿到转写就把语音的提示收掉:提示是「上一次动作的回执」,
+ 留着会变成输入区里的常驻文本(那一排容不下常驻行)。 */
+ setVoiceNotice('');
+ composerRef.current?.insertText(text);
+ }}
onNotice={setVoiceNotice}
/>
+ {/* 润色 / 恢复原文的挂点(portal 目标):放在**发送钮左边**——它与发送钮同为
+ 圆角矩形按钮,相邻成组更协调;DOM 顺序即 `模型 → 麦克风 → 润色 → 发送`,
+ 所以 Tab 序与读屏顺序与视觉一致。 */}
+
{canStopTurn ? (
{
+ it('puts the reasoning strength inside the model menu and persists only for later turns', async () => {
let stored = 'high';
const { invoke, surface } = await openDirectCodexSurface({
select_game_creator_reasoning_effort: (args) => {
@@ -1042,37 +1051,557 @@ export function registerChatComposerControlTests() {
},
});
- const select = within(surface).getByRole('button', { name: '推理档' });
- await waitFor(() => {
- expect(select.textContent).toContain('高');
- });
- // 档位就在模型选择器这一排(同一控制排容器里)。
+ // 底行只留一颗控件:模型触发钮;独立的「选择强度 / 推理档」触发钮不再存在。
+ const row = surface.querySelector(
+ '.project-chat-composer-controls-right',
+ ) as HTMLElement | null;
+ expect(row).not.toBeNull();
expect(
- within(surface).getByRole('button', { name: '对话模型' }),
- ).not.toBeNull();
- expect(select.closest('.project-chat-composer-controls')).not.toBeNull();
+ within(row as HTMLElement).queryAllByRole('button', {
+ name: /选择强度|推理档/,
+ }),
+ ).toHaveLength(0);
+ const trigger = within(row as HTMLElement).getByRole('button', {
+ name: '对话模型',
+ });
+ expect(trigger.closest('.project-chat-composer-controls')).not.toBeNull();
+ // 关闭态既没有滑块,也没有菜单。
+ expect(
+ within(surface).queryByRole('slider', { name: '思维强度' }),
+ ).toBeNull();
- fireEvent.click(select);
- fireEvent.click(within(surface).getByRole('option', { name: '低' }));
+ // 打开模型菜单:强度区在顶部,同一个菜单里还有模型列表。
+ fireEvent.click(trigger);
+ const slider = within(surface).getByRole('slider', {
+ name: '思维强度',
+ }) as HTMLInputElement;
+ // 强度区随菜单挂载后才去读一次配置(`read_game_creator_app_config`),等回读到位。
+ await waitFor(() => {
+ expect(slider.getAttribute('aria-valuetext')).toBe('高');
+ });
+ expect(slider.getAttribute('aria-valuemin')).toBe('0');
+ expect(slider.getAttribute('aria-valuemax')).toBe('4');
+ expect(slider.getAttribute('aria-valuenow')).toBe('3');
+ expect(within(surface).getByText('当前:高')).not.toBeNull();
+ const listbox = within(surface).getByRole('listbox', {
+ name: '对话模型',
+ });
+ // 滑块不能落进 listbox 子树:参数控件混进菜单角色会让读屏进菜单模式后读不到它。
+ expect(listbox.contains(slider)).toBe(false);
+
+ // 拖动 / 点档位:连续事件只改预览,松手(这里用抬键路径)才落到既有持久化通道与取值。
+ fireEvent.change(slider, { target: { value: '1' } });
+ fireEvent.keyUp(slider);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'select_game_creator_reasoning_effort',
{ effort: 'low' },
);
});
- // 以落盘后的回读值为准。
+ // 以落盘后的回读值为准;改档后菜单保持打开(连续调节不用重开)。
await waitFor(() => {
- expect(
- within(surface).getByRole('button', { name: '推理档' }).textContent,
- ).toContain('低');
+ expect(within(surface).getByText('当前:低')).not.toBeNull();
});
expect(stored).toBe('low');
+ expect(
+ within(surface).getByRole('slider', { name: '思维强度' }),
+ ).not.toBeNull();
// 只影响后续回合:当前没有发出任何新一轮直接对话。
expect(
invoke.mock.calls.some(
([command]) => command === 'enqueue_direct_codex_turn',
),
).toBe(false);
+
+ // Esc 关掉整个菜单;再打开、点外部也关。
+ fireEvent.keyDown(document, { key: 'Escape' });
+ await waitFor(() => {
+ expect(
+ within(surface).queryByRole('slider', { name: '思维强度' }),
+ ).toBeNull();
+ });
+ fireEvent.click(
+ within(row as HTMLElement).getByRole('button', { name: '对话模型' }),
+ );
+ expect(
+ within(surface).getByRole('slider', { name: '思维强度' }),
+ ).not.toBeNull();
+ fireEvent.mouseDown(document.body);
+ await waitFor(() => {
+ expect(
+ within(surface).queryByRole('slider', { name: '思维强度' }),
+ ).toBeNull();
+ });
+ });
+
+ it('strength slider: drag follows continuously and only commits once on release', async () => {
+ let stored = 'default';
+ const { invoke, surface } = await openDirectCodexSurface({
+ select_game_creator_reasoning_effort: (args) => {
+ stored = String(args?.effort ?? '');
+ return gameCreatorConfigView(stored);
+ },
+ });
+
+ fireEvent.click(within(surface).getByRole('button', { name: '对话模型' }));
+ const slider = within(surface).getByRole('slider', {
+ name: '思维强度',
+ }) as HTMLInputElement;
+ const writes = () =>
+ invoke.mock.calls.filter(
+ ([command]) => command === 'select_game_creator_reasoning_effort',
+ ).length;
+
+ // 拖动中:连续 input 必须逐步跟手;既不能把控件 disable(原生拖拽会被中断),
+ // 也不能每格都落盘(写盘回读会把拖动中的值顶回去)。
+ // jsdom 对受控 range 的值回写与指针事件不全(不实现 PointerEvent、原生 change 不触发),
+ // 所以这里断言环境能看到的契约:控件保持可用、节点不被重挂载、拖动中不落盘;
+ // 真实的「按下 → 多步移动 → 抬起」指针序列与松手落盘在无头 Chromium 夹具里验证(见 PR 正文)。
+ for (const value of ['0', '1', '2'] as const) {
+ fireEvent.input(slider, { target: { value } });
+ expect(
+ within(surface).getAllByRole('slider'),
+ '拖动期间只应有一颗滑块',
+ ).toHaveLength(1);
+ expect(
+ within(surface).getByRole('slider', { name: '思维强度' }),
+ '滑块节点不能因 input 更新被重挂载',
+ ).toBe(slider);
+ expect(
+ slider.disabled,
+ '拖动中不应禁用滑块:禁用会中断正在进行的原生拖拽',
+ ).toBe(false);
+ expect(writes(), '拖动过程中不落盘').toBe(0);
+ }
+
+ // 松手才提交一次,取值走既有通道、与旧下拉一致(jsdom 里用抬键路径代替松手,见上)。
+ fireEvent.keyUp(slider);
+ await waitFor(() => {
+ expect(writes()).toBe(1);
+ });
+ expect(invoke).toHaveBeenCalledWith(
+ 'select_game_creator_reasoning_effort',
+ { effort: 'medium' },
+ );
+ expect(stored).toBe('medium');
+ });
+
+ it('strength slider: keyboard auto-repeat on one grade writes once, a new grade writes again', async () => {
+ // 长按 ←/→ 时浏览器会以 ~30/s 的频率重复派发 input + keyup;旧实现在每个 input 都落盘
+ // (整条写链 + 回读),这里钉住"同一个已落盘档位的重复提交不再写盘",同时不吞掉真实改档。
+ const writes: string[] = [];
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ if (command === 'select_game_creator_reasoning_effort') {
+ writes.push(String(args?.effort ?? ''));
+ return gameCreatorConfigView(String(args?.effort ?? ''));
+ }
+ if (command === 'read_game_creator_app_config') {
+ return gameCreatorConfigView('default');
+ }
+ return null;
+ },
+ );
+ installTauriRuntime({
+ core: { invoke },
+ event: { listen: async () => () => undefined },
+ });
+ render(
+ React.createElement(ComposerReasoningEffortSelect, {
+ disabled: false,
+ presentation: 'section',
+ }),
+ );
+ const slider = (await screen.findByRole('slider', {
+ name: '思维强度',
+ })) as HTMLInputElement;
+
+ // 第一次改到「中」:拖动/按键松手提交一次。
+ fireEvent.input(slider, { target: { value: '2' } });
+ fireEvent.keyUp(slider);
+ await waitFor(() => {
+ expect(writes).toEqual(['medium']);
+ });
+
+ // 长按继续:同一档位的重复 input + keyup(中间隔着写盘回读)不再落盘。
+ for (let repeat = 0; repeat < 8; repeat += 1) {
+ fireEvent.input(slider, { target: { value: '2' } });
+ fireEvent.keyUp(slider);
+ }
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(writes, '同一档位的重复提交只能写一次').toEqual(['medium']);
+
+ // 换一档仍然照常写:去重不能把真实变化吞掉。
+ fireEvent.input(slider, { target: { value: '4' } });
+ fireEvent.keyUp(slider);
+ await waitFor(() => {
+ expect(writes).toEqual(['medium', 'max']);
+ });
+ });
+
+ it('strength slider: flushes the previewed grade once when the control unmounts mid-drag', async () => {
+ // 拖动中被 Esc 关掉菜单 / 宿主卸载时,预览值还没落盘:卸载 cleanup 必须主动 flush 一次。
+ const writes: string[] = [];
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ if (command === 'select_game_creator_reasoning_effort') {
+ writes.push(String(args?.effort ?? ''));
+ return gameCreatorConfigView(String(args?.effort ?? ''));
+ }
+ if (command === 'read_game_creator_app_config') {
+ return gameCreatorConfigView('default');
+ }
+ return null;
+ },
+ );
+ installTauriRuntime({
+ core: { invoke },
+ event: { listen: async () => () => undefined },
+ });
+ const view = render(
+ React.createElement(ComposerReasoningEffortSelect, {
+ disabled: false,
+ presentation: 'section',
+ }),
+ );
+ const slider = (await screen.findByRole('slider', {
+ name: '思维强度',
+ })) as HTMLInputElement;
+
+ // 拖动到「中」但还没松手(只有 input,没有 change / keyup):此刻不该落盘。
+ fireEvent.input(slider, { target: { value: '2' } });
+ expect(writes).toEqual([]);
+
+ view.unmount();
+ await waitFor(() => {
+ expect(writes, '卸载 flush 只写一次').toEqual(['medium']);
+ });
+ });
+
+ it('strength slider: an external config change is re-read so re-selecting that grade still commits', async () => {
+ // 配置还有别的写入者(`RuntimeConfigDialog` 保存后 `notifyLlmConfigChanged()`)。组件必须重读
+ // 配置并重新种「最近落盘值」,否则外部把档位改成「最高」后用户再选「高」会被去重跳过:
+ // 界面显示高、磁盘仍是最高(旧实现在这条用例里 writes 不含 'high')。
+ let stored = 'default';
+ const writes: string[] = [];
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ if (command === 'read_game_creator_app_config') {
+ return gameCreatorConfigView(stored);
+ }
+ if (command === 'select_game_creator_reasoning_effort') {
+ writes.push(String(args?.effort ?? ''));
+ stored = String(args?.effort ?? '');
+ return gameCreatorConfigView(stored);
+ }
+ return null;
+ },
+ );
+ installTauriRuntime({
+ core: { invoke },
+ event: { listen: async () => () => undefined },
+ });
+ render(
+ React.createElement(ComposerReasoningEffortSelect, {
+ disabled: false,
+ presentation: 'section',
+ }),
+ );
+ const slider = (await screen.findByRole('slider', {
+ name: '思维强度',
+ })) as HTMLInputElement;
+ await waitFor(() =>
+ expect(slider.getAttribute('aria-valuetext')).toBe('默认'),
+ );
+
+ // 外部改档(另一处写完配置会派发这个事件):组件重读并回显成「最高」。
+ stored = 'max';
+ await act(async () => {
+ window.dispatchEvent(new Event(LLM_CONFIG_CHANGED_EVENT));
+ await Promise.resolve();
+ });
+ await waitFor(() =>
+ expect(slider.getAttribute('aria-valuetext')).toBe('最高'),
+ );
+
+ // 用户改选「高」:仍然要真落盘一次(档位枚举 0=默认 1=低 2=中 3=高 4=最高)。
+ fireEvent.input(slider, { target: { value: '3' } });
+ fireEvent.keyUp(slider);
+ await waitFor(() => expect(writes).toEqual(['high']));
+ expect(stored).toBe('high');
+ });
+
+ it('strength slider: pointercancel commits the pending preview once', async () => {
+ // 触屏取消(pointercancel)与「松手即提交」同一口径:预览值不能滞留到下一次提交或卸载 flush。
+ // jsdom 没有 PointerEvent,这里派发同名原生事件(React 的合成 handler 按事件名分发)。
+ const writes: string[] = [];
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ if (command === 'select_game_creator_reasoning_effort') {
+ writes.push(String(args?.effort ?? ''));
+ return gameCreatorConfigView(String(args?.effort ?? ''));
+ }
+ if (command === 'read_game_creator_app_config') {
+ return gameCreatorConfigView('default');
+ }
+ return null;
+ },
+ );
+ installTauriRuntime({
+ core: { invoke },
+ event: { listen: async () => () => undefined },
+ });
+ render(
+ React.createElement(ComposerReasoningEffortSelect, {
+ disabled: false,
+ presentation: 'section',
+ }),
+ );
+ const slider = (await screen.findByRole('slider', {
+ name: '思维强度',
+ })) as HTMLInputElement;
+ await waitFor(() =>
+ expect(slider.getAttribute('aria-valuetext')).toBe('默认'),
+ );
+
+ fireEvent.input(slider, { target: { value: '2' } });
+ expect(writes, '拖动过程中不落盘').toEqual([]);
+ await act(async () => {
+ slider.dispatchEvent(new Event('pointercancel', { bubbles: true }));
+ await Promise.resolve();
+ });
+ await waitFor(() => expect(writes).toEqual(['medium']));
+ });
+
+ it('语音提示是上一次动作的回执:拿到转写后不再常驻', async () => {
+ // 语音报错会写一条 `project-chat-composer-notice`(输入盒下方的独立一行)。它是「上一次动作的
+ // 回执」,下一次拿到转写必须收掉——否则语音报错会常驻到组件卸载,和"输入区不留常驻文本"相冲。
+ const fake = installFakeSpeechRecognition();
+ try {
+ const { surface } = await openDirectCodexSurface();
+ const mic = within(surface).getByRole('button', { name: '语音输入' });
+ fireEvent.click(mic);
+ act(() => {
+ (
+ fake.instances[0] as unknown as {
+ onerror?: (event: unknown) => void;
+ }
+ ).onerror?.({ error: 'network' });
+ });
+ const notice = await waitFor(() => {
+ const element = surface.querySelector('.project-chat-composer-notice');
+ expect(element?.textContent).toContain('语音识别服务');
+ return element as HTMLElement;
+ });
+
+ // 下一次拿到转写:提示收掉,输入区不留常驻行。
+ act(() => {
+ fake.instances[0]?.emitTranscript('做一个小游戏');
+ });
+ await waitFor(() => {
+ expect(
+ surface.querySelector('.project-chat-composer-notice'),
+ ).toBeNull();
+ });
+ expect(notice.isConnected).toBe(false);
+ } finally {
+ fake.restore();
+ }
+ });
+
+ it('AI 润色钮在工具条这一行里(DOM 位置级)', async () => {
+ // 声明级的 `flex-wrap: nowrap` 抓不到"渲染后仍换行":这里钉住润色钮真的挂在
+ // `.project-chat-composer-controls` 里(`actionsPortalTarget` 把它 portal 进工具条),
+ // 而不是留在输入盒的第二行。同一行的几何(top 差值 ≤1px、两两不相交、空态没有
+ // 行外按钮)由无头 Chromium 夹具实测:240/280/296/320/360/438/900 七档全部通过。
+ const { surface } = await openDirectCodexSurface();
+ const controls = surface.querySelector('.project-chat-composer-controls');
+ const polish = surface.querySelector('.resource-reference-input-polish');
+ const referenceTrigger = surface.querySelector(
+ '.project-chat-reference-trigger',
+ );
+ const input = surface.querySelector('.resource-reference-input');
+ expect(controls).not.toBeNull();
+ expect(polish).not.toBeNull();
+ expect(referenceTrigger).not.toBeNull();
+ expect(input).not.toBeNull();
+ expect(controls?.contains(polish as Node)).toBe(true);
+ // 润色钮在**右组、发送钮左边**(与发送同为圆角矩形按钮,相邻成组):
+ // 挂点父链是 `-controls-right`,DOM 顺序里润色紧挨发送之前(Tab/读屏顺序同视觉)。
+ const polishButton = polish as HTMLElement | null;
+ const slot = polishButton?.closest(
+ '.project-chat-composer-controls-actions',
+ );
+ expect(slot?.parentElement?.className).toContain(
+ 'project-chat-composer-controls-right',
+ );
+ const sendButton = within(surface).getByRole('button', { name: '发送' });
+ expect(slot?.nextElementSibling).toBe(sendButton);
+ expect(
+ Boolean(
+ polishButton &&
+ polishButton.compareDocumentPosition(sendButton) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ),
+ '润色钮必须排在发送钮之前',
+ ).toBe(true);
+ expect(input?.contains(polishButton)).toBe(false);
+ // 空输入态:工具条之外不再有第二行控件(润色状态行只在有内容时渲染)。
+ expect(
+ surface.querySelectorAll(
+ 'form.project-chat-composer button:not(.conversation-model-menu *)',
+ ),
+ ).toHaveLength(6);
+ // 触发钮最窄可缩到 64px、名字靠省略号收住:必须带 `title`,否则截断后无从查看。
+ const modelTrigger = within(surface).getByRole('button', {
+ name: '对话模型',
+ });
+ expect(modelTrigger.getAttribute('title')).toBe(
+ modelTrigger.textContent?.trim(),
+ );
+ });
+
+ it('shows model service unavailability as a toast and leaves no persistent line', async () => {
+ const { surface } = await openDirectCodexSurface({
+ load_game_creator_llm_models: () => {
+ throw new LlmModelCatalogConfigError('模型服务不可用');
+ },
+ });
+
+ const toast = await waitFor(() => {
+ const element = document.querySelector(
+ '[data-composer-model-toast="true"]',
+ );
+ expect(element).not.toBeNull();
+ return element as HTMLElement;
+ });
+ expect(toast.textContent).toContain('模型服务不可用');
+ // 输入区里不留常驻文案、也没有 [role='alert']:失败只走浮层。
+ const composer = surface.querySelector(
+ '.project-chat-composer.is-direct-codex',
+ );
+ expect(composer).not.toBeNull();
+ expect(composer?.querySelector('[role="alert"]')).toBeNull();
+ expect(composer?.textContent).not.toContain('模型服务不可用');
+ expect(surface.textContent).not.toContain('本地模型服务不可用');
+ // 不可用态照旧挡住发送,但不靠一行说明文字表达。
+ expect(
+ (
+ within(surface).getByRole('button', {
+ name: '发送',
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(true);
+ });
+
+ it('routes model-list notices to the toast and keeps the Direct composer free of status rows', async () => {
+ // 刷新模型列表会写一条 notice(「模型列表已刷新」)。toast 形态下它也必须走浮层:
+ // 输入区子树里既不能留常驻 `role="alert"`(失败),也不能留常驻 `role="status"`(状态)。
+ const { surface } = await openDirectCodexSurface({
+ load_game_creator_llm_models: () => ({
+ defaultModelId: 'quality',
+ models: [{ id: 'quality', displayName: '高质量', agentMode: 'codex' }],
+ revision: 23,
+ }),
+ });
+
+ fireEvent.click(within(surface).getByRole('button', { name: '对话模型' }));
+ fireEvent.click(
+ within(surface).getByRole('button', { name: '刷新模型列表' }),
+ );
+
+ const toast = await waitFor(() => {
+ const element = document.querySelector(
+ '[data-composer-model-toast="true"]',
+ );
+ expect(element?.textContent).toContain('模型列表已刷新');
+ return element as HTMLElement;
+ });
+ // 浮层挂在输入区之外(portal 到 body)。
+ expect(toast.closest('.project-chat-composer.is-direct-codex')).toBeNull();
+
+ const composer = surface.querySelector(
+ '.project-chat-composer.is-direct-codex',
+ );
+ expect(composer).not.toBeNull();
+ expect(composer?.querySelector('[role="status"]')).toBeNull();
+ expect(composer?.querySelector('[role="alert"]')).toBeNull();
+ expect(composer?.textContent).not.toContain('模型列表已刷新');
+ // 目录缓存是模块级的:这条用例装配过一份成功目录,不清掉会漏给后面的用例。
+ resetLlmModelCatalogCacheForTest();
+ });
+
+ it('reports one newly appeared model failure and never repeats the same message', async () => {
+ let failing = true;
+ const invoke = vi.fn(async (command: string) => {
+ if (command === 'read_game_creator_app_config') {
+ return gameCreatorConfigView('high');
+ }
+ if (command === 'load_game_creator_llm_models') {
+ if (failing) {
+ throw new LlmModelCatalogConfigError('模型服务不可用');
+ }
+ return {
+ defaultModelId: 'quality',
+ models: [
+ { id: 'quality', displayName: '高质量', agentMode: 'codex' },
+ ],
+ revision: 7,
+ };
+ }
+ return null;
+ });
+ installTauriRuntime({
+ core: { invoke },
+ event: { listen: async () => () => undefined },
+ });
+ const view = render(
+ React.createElement(ConversationModelSelect, { disabled: false }),
+ );
+
+ // 组件自己弹浮层:底部 toast 一条,控件子树里没有常驻 `role="alert"` 行。
+ const toastOf = () =>
+ document.querySelector('[data-composer-model-toast="true"]');
+ await waitFor(() => {
+ expect(toastOf()?.textContent).toContain('模型服务不可用');
+ });
+ const firstToast = toastOf();
+ expect(
+ document.querySelectorAll('[data-composer-model-toast="true"]'),
+ ).toHaveLength(1);
+ // 常驻行(控件里那两颗)没了;浮层自身的 `role="alert"` 在 portal 里,属于 toast 本体。
+ expect(view.container.querySelector('[role="alert"]')).toBeNull();
+ expect(view.container.querySelector('[role="status"]')).toBeNull();
+
+ // 同一条失败再次同步目录(打开下拉会触发一次)不重复弹。
+ fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(
+ document.querySelectorAll('[data-composer-model-toast="true"]'),
+ ).toHaveLength(1);
+ expect(toastOf()).toBe(firstToast);
+
+ // 失败清空(目录恢复正常)后再出现,可以再弹一次:节点换新(每次提示自增 id)。
+ resetLlmModelCatalogCacheForTest();
+ failing = false;
+ fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
+ await waitFor(() => {
+ expect(toastOf()?.textContent).toContain('模型列表已刷新');
+ });
+ resetLlmModelCatalogCacheForTest();
+ failing = true;
+ fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
+ await waitFor(() => {
+ expect(toastOf()?.textContent).toContain('模型服务不可用');
+ expect(toastOf()).not.toBe(firstToast);
+ });
+ expect(
+ document.querySelectorAll('[data-composer-model-toast="true"]'),
+ ).toHaveLength(1);
});
it('appends the dictated transcript after the draft typed into the composer', async () => {
diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts
index f19d826ae..f533c5c33 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts
@@ -309,6 +309,9 @@ export function registerDesignAgentSurfaceTests() {
).toContain('低');
});
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
+ // 策划面菜单保持原样:只有模型列表,没有 Direct 侧专属的强度滑块。
+ expect(screen.getByRole('listbox', { name: '对话模型' })).not.toBeNull();
+ expect(screen.queryByRole('slider', { name: '思维强度' })).toBeNull();
fireEvent.click(await screen.findByRole('option', { name: '快速' }));
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith('select_game_creator_model', {
@@ -384,7 +387,24 @@ export function registerDesignAgentSurfaceTests() {
await waitFor(() => expect(reasoning.textContent).toContain('高'));
fireEvent.click(reasoning);
fireEvent.click(screen.getByRole('option', { name: '低' }));
- await screen.findByText('推理档保存失败');
+ // 失败文案走组件自己的底部 toast(portal 在 body),策划输入区子树里不留常驻行。
+ const toast = await waitFor(() => {
+ const element = document.querySelector(
+ '[data-composer-model-toast="true"]',
+ );
+ expect(element?.textContent).toContain('推理档保存失败');
+ return element as HTMLElement;
+ });
+ expect(
+ document.querySelectorAll('[data-composer-model-toast="true"]'),
+ ).toHaveLength(1);
+ expect(toast.querySelector('[role]')?.getAttribute('role')).toBe('status');
+ const effortControl = document.querySelector(
+ '.project-chat-reasoning-effort',
+ );
+ expect(effortControl).not.toBeNull();
+ expect(effortControl?.querySelector('[role="status"]')).toBeNull();
+ expect(effortControl?.contains(toast)).toBe(false);
expect(reasoning.textContent).toContain('高');
});
diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
index 879c560c7..1be205dae 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
@@ -1,6 +1,15 @@
+import { readFileSync } from 'node:fs';
+
import { HOME_CREATION_AFTER_PROJECT_FAILURE } from '../../src/features/app-shell/homeCreationFailure';
import type { ProjectChatComponentProps } from '../../src/features/app-shell/model';
import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher';
+import { LlmModelCatalogConfigError } from '../../src/services/llmModelCatalog';
+import { repoPath } from '../repoPath';
+import {
+ declaration,
+ parseStyleSheet,
+ resolveDeclarations,
+} from '../styleCascade';
import {
act,
App,
@@ -164,6 +173,127 @@ export function registerClientHomeTests() {
expect(createButton).toHaveProperty('disabled', false);
});
+ it('首页模型菜单复用同一套共享样式面(卡片与列表观感与工作台一致)', async () => {
+ // 样式面抬升前,首页这颗只命中基础规则(10px 圆角 / 5px 内边距 / 32px 行),
+ // 工作台那套(16px 圆角 / 12px 内边距 / 34px 行)挂在 `.game-workbench-chat …` 祖先链上。
+ // 这条用例钉住"任何 ConversationModelSelect 实例都拿到同一份":断言只靠组件自带类名
+ // (`.conversation-model-select …`)就能解析出卡片与列表的观感。
+ const invoke = vi.fn(async (command: string) => {
+ if (command === 'read_game_creator_app_config') {
+ return { config: { selectedModelId: 'quality' } };
+ }
+ if (command === 'load_game_creator_llm_models') {
+ return {
+ defaultModelId: 'quality',
+ models: [
+ { id: 'quality', displayName: '高质量' },
+ { id: 'fast', displayName: '快速' },
+ ],
+ revision: 1,
+ };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ installTauriRuntime({ core: { invoke } });
+ renderLauncherAt('/?launcher');
+
+ const modelTrigger = await screen.findByRole('button', {
+ name: '对话模型',
+ });
+ fireEvent.click(modelTrigger);
+ const menu = await waitFor(() => {
+ const element = document.querySelector(
+ '.home-input-model-select .conversation-model-menu',
+ );
+ expect(element).not.toBeNull();
+ return element as HTMLElement;
+ });
+
+ // 组件自带类名在场:共享样式面的选择器就靠它,不需要任何宿主祖先链。
+ expect(menu.classList.contains('conversation-model-menu')).toBe(true);
+ expect(menu.closest('.conversation-model-select')).not.toBeNull();
+ expect(menu.closest('.game-workbench-chat')).toBeNull();
+ // 首页这颗也有「思维强度」区(同一个组件、同一份配置通道),且菜单里有原生滑块。
+ const group = document.querySelector(
+ '.home-input-model-select [role="group"]',
+ );
+ expect(group?.getAttribute('aria-label')).toBe('思维强度');
+ const slider = document.querySelector(
+ '.home-input-model-select input[type="range"]',
+ );
+ expect(slider).not.toBeNull();
+ expect(slider?.getAttribute('aria-label')).toBe('思维强度');
+ await waitFor(() =>
+ expect(slider?.getAttribute('aria-valuetext')).toBe('默认'),
+ );
+ // 档位圆点已删除(样式表里与渲染里都不再有那组元素)。
+ expect(
+ document.querySelector('.project-chat-composer-strength-stops'),
+ ).toBeNull();
+
+ const rules = parseStyleSheet(
+ readFileSync(
+ repoPath('apps/ai-game-creator-shell/src/styles.css'),
+ 'utf8',
+ ),
+ );
+ // 共享样式面按 `!important` 声明(要压过基础规则里同属性的旧值),断言只看最终值。
+ const value = (declarations: Map, property: string) =>
+ declaration(declarations, property).replace(/ !important$/u, '');
+ const sharedCard = '.conversation-model-select .conversation-model-menu';
+ const card = resolveDeclarations(rules, [sharedCard], 1440);
+ expect(value(card, 'border-radius')).toBe('16px');
+ expect(value(card, 'padding')).toBe('12px');
+ expect(value(card, 'border')).toContain('var(--platform-line-soft)');
+ expect(value(card, 'box-shadow')).toContain(
+ 'var(--platform-desktop-hover-shadow)',
+ );
+
+ const row = resolveDeclarations(rules, [`${sharedCard} button`], 1440);
+ expect(value(row, 'min-height')).toBe('34px');
+ expect(value(row, 'padding')).toBe('0 10px');
+ expect(value(row, 'border-radius')).toBe('9px');
+
+ const selected = resolveDeclarations(
+ rules,
+ [`${sharedCard} button[aria-selected='true']`],
+ 1440,
+ );
+ expect(value(selected, 'background')).toContain('var(--platform-warm-bg)');
+ });
+
+ it('首页模型不可用与状态也走组件自己的底部 toast(子树不留常驻行)', async () => {
+ // 任何宿主都不留常驻 `role="alert"` / `role="status"`:浮层收进组件内部(模块级单例 + 宿主)。
+ const invoke = vi.fn(async (command: string) => {
+ if (command === 'read_game_creator_app_config') {
+ return { config: { selectedModelId: 'quality' } };
+ }
+ if (command === 'load_game_creator_llm_models') {
+ throw new LlmModelCatalogConfigError('模型服务不可用');
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ installTauriRuntime({ core: { invoke } });
+ renderLauncherAt('/?launcher');
+
+ const toast = await waitFor(() => {
+ const element = document.querySelector(
+ '[data-composer-model-toast="true"]',
+ );
+ expect(element?.textContent).toContain('模型服务不可用');
+ return element as HTMLElement;
+ });
+ // 浮层 portal 在 body(底部居中),不在首页输入区子树里;子树里也没有常驻行。
+ const homeSelect = document.querySelector('.home-input-model-select');
+ expect(homeSelect).not.toBeNull();
+ expect(homeSelect?.contains(toast)).toBe(false);
+ expect(homeSelect?.querySelector('[role="alert"]')).toBeNull();
+ expect(homeSelect?.querySelector('[role="status"]')).toBeNull();
+ expect(
+ document.querySelectorAll('[data-composer-model-toast="true"]'),
+ ).toHaveLength(1);
+ });
+
it('anchors the empty home input placeholder to the editor while the page scrolls', () => {
renderLauncherAt('/?launcher');
diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
index c83bafff6..8004a2abf 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
@@ -5961,14 +5961,14 @@ export function registerProjectWorkbenchFoundationTests() {
expect(styles).toMatch(
/\.game-workbench-chat \.project-runtime-summary\s*\{[^}]*position:\s*sticky[^}]*top:\s*-10px/s,
);
- // 输入盒是两行网格(文本区 / 控制排)的文档流块,不再是贴在列表下边的
- // 绝对定位浮层,也不再与 82px 的发送钮列共用网格。
+ // 输入盒是单列网格的文档流块(三层:输入内容区 / 工具栏 / 状态提示区),不再是贴在
+ // 列表下边的绝对定位浮层,也不再与 82px 的发送钮列共用网格;层与层之间用行间距分开。
const composerRule = styles.match(
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-composer\.is-direct-codex\s*\{([^}]*)\}/s,
);
expect(composerRule?.[1]).not.toBeUndefined();
expect(composerRule?.[1]).toContain('position: relative;');
- expect(composerRule?.[1]).toContain('grid-template-rows: auto auto;');
+ expect(composerRule?.[1]).toContain('gap: 8px;');
expect(composerRule?.[1]).toContain('z-index: 1;');
// 四边留白统一 16px、盒内内边距四边同为 12px(文字左内缩必须等于上内缩);
// 不允许再出现 `8px 12px 10px` / `10px 12px` 这类「左右一个值、上下另一个值」的写法。
@@ -5979,6 +5979,13 @@ export function registerProjectWorkbenchFoundationTests() {
'background: var(--platform-input-fill);',
);
expect(composerRule?.[1]).not.toContain('border-top:');
+ // 强度并入模型菜单后,底行不再有第二颗控件:独立触发钮与它自己的浮层整段删除,
+ // 换成模型菜单外壳(外壳定位 / 朝向与原来的模型菜单一致:贴触发钮右缘、向上展开)。
+ expect(styles).not.toContain('project-chat-composer-strength-trigger');
+ expect(styles).not.toContain('project-chat-composer-strength-popover');
+ expect(styles).toMatch(
+ /\.conversation-model-menu-shell\s*\{[^}]*position:\s*absolute[^}]*bottom:\s*calc\(100% \+ 8px\)/s,
+ );
expect(styles).toMatch(
/\.game-workbench-chat \.pending-command-actions button\s*\{[^}]*min-width:\s*52px[^}]*flex:\s*0 0 auto/s,
);
@@ -6410,13 +6417,16 @@ export function registerProjectWorkbenchFoundationTests() {
expect(messageListRule).toContain('padding-bottom: 0;');
expect(messageListRule).toContain('flex: 1 1 auto;');
- // 编辑器高度上限仍是输入盒高度的来源之一。
+ // 编辑器的 min/max 高度是输入盒高度的来源之一。生效声明在六类选择器那条
+ // (比旧五类规则多一层 `.resource-reference-input`,严格更高),旧规则的 96 / 140
+ // 恒不生效、已按「删死重」删除;层叠生效值与输入盒最高高度算式由
+ // tests/chatDialogFrameLayout.test.ts 验证。
const editorRule =
styles.match(
- /\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-composer\s+\.resource-reference-input-editor\s*\{([^}]*)\}/s,
+ /\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-composer\.is-direct-codex\s+\.resource-reference-input\s+\.resource-reference-input-editor\s*\{([^}]*)\}/s,
)?.[1] ?? '';
- expect(styleNumber(editorRule, 'max-height')).toBe(140);
- expect(styleNumber(editorRule, 'min-height')).toBe(96);
+ expect(styleNumber(editorRule, 'max-height')).toBe(160);
+ expect(styleNumber(editorRule, 'min-height')).toBe(72);
});
it('keeps workbench chat bubbles aligned and the process card inset with them', () => {
diff --git a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts
index bcb9b3135..4e2f41816 100644
--- a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts
+++ b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts
@@ -20,6 +20,14 @@ const CHAT_CONVERSATION_PATH = repoPath(
const CHAT_COMPOSER_PATH = repoPath(
'apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx',
);
+/** 共享输入区实现(「AI 润色 / 恢复原文」与状态行都在这里渲染)。 */
+const INPUT_SOURCE_PATH = repoPath(
+ 'apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx',
+);
+/** 同目录的控件实现(推理强度滑块在这里渲染)。 */
+const CHAT_COMPOSER_CONTROLS_PATH = repoPath(
+ 'apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx',
+);
function lengthPx(rawValue: string, label: string): number {
const value = rawValue.trim();
@@ -118,6 +126,22 @@ function marginBox(declarations: Map) {
return { top: value(0), right: value(1), bottom: value(2), left: value(3) };
}
+/**
+ * 展开 `gap` 简写,只关心主/交叉两轴。
+ *
+ * 一值写四个方向同一个数;两值先 `row-gap` 后 `column-gap`(与 CSS 的 `gap: `
+ * 一致)。三层输入区里两条行间距分别写在输入盒与输入盒自己身上,读值时必须分得清是哪一条。
+ */
+function gapBox(declarations: Map) {
+ const shorthand = declarations.get('gap') ?? '0';
+ const parts = shorthand.split(' ').filter(Boolean);
+ const value = (index: number) => {
+ const part = parts[index] ?? parts[parts.length - 1] ?? parts[0];
+ return lengthPx(part!, `gap 简写 ${shorthand}`);
+ };
+ return { row: value(0), column: value(1) };
+}
+
/* ============================================================
选择器身份
============================================================ */
@@ -132,10 +156,29 @@ const COMPOSER_PLAIN = `${CHAT} .project-chat-composer`;
const CONVERSATION_COMPOSER = `${CONVERSATION} .project-chat-composer.is-direct-codex`;
const COMPOSER_INPUT = `${COMPOSER} .resource-reference-input`;
const COMPOSER_INPUT_PLAIN = `${COMPOSER_PLAIN} .resource-reference-input`;
-const COMPOSER_EDITOR = `${COMPOSER_PLAIN} .resource-reference-input-editor`;
+const COMPOSER_EDITOR = `${COMPOSER_INPUT} .resource-reference-input-editor`;
+const COMPOSER_EDITOR_PLAIN = `${COMPOSER_PLAIN} .resource-reference-input-editor`;
const COMPOSER_CONTROLS = `${COMPOSER} .project-chat-composer-controls`;
const COMPOSER_SUBMIT = `${COMPOSER_CONTROLS} .project-chat-submit-button`;
-const INPUT_ACTIONS = `${COMPOSER} .resource-reference-input-actions`;
+/** 模型触发钮:工具栏那一排里最高的一颗(30px),行高由它决定而不是发送钮(28px)。 */
+const COMPOSER_TRIGGER = `${COMPOSER_CONTROLS} .conversation-model-trigger`;
+/** 触发钮的基础几何规则挂在 `.project-chat-composer`(不带 `.is-direct-codex`)上,同样命中。 */
+const COMPOSER_TRIGGER_BASE =
+ '.project-chat-surface.is-direct-codex .project-chat-composer .conversation-model-trigger';
+const INPUT_ACTIONS = `${COMPOSER_INPUT} .resource-reference-input-actions`;
+const INPUT_ACTIONS_PLAIN = `${COMPOSER} .resource-reference-input-actions`;
+/** 模型菜单的共享样式面(选择器只用组件自带类名,不带宿主祖先链;首页与工作台共用)。 */
+const SHARED_CARD = '.conversation-model-select .conversation-model-menu';
+const SHARED_SHELL =
+ '.conversation-model-select .conversation-model-menu-shell';
+const SHARED_CARD_IN_SHELL = `${SHARED_SHELL} .conversation-model-menu`;
+/** 强度区与滑块(issue #600 的复核点;档位圆点按用户口径已删除)。
+ 这些规则与卡片一样在共享样式面(组件自带类名,不带宿主祖先链)。 */
+const STRENGTH_SECTION = '.project-chat-reasoning-effort.is-section';
+const STRENGTH_RANGE = '.project-chat-composer-strength-range';
+const STRENGTH_THUMB = `${STRENGTH_RANGE}::-webkit-slider-thumb`;
+/** 已删除的档位点容器:留着它做反向守卫(谁都别把点写回来)。 */
+const STRENGTH_STOPS = '.project-chat-composer-strength-stops';
const TOPBAR = `${CHAT} .project-chat-topbar`;
/** 列表实际会命中的全部选择器:direct-codex 直连与基础规则。 */
const LIST_SELECTORS = [MESSAGE_LIST, MESSAGE_LIST_PLAIN];
@@ -169,47 +212,79 @@ function composerMaxHeight() {
);
const editor = desktopDeclarations(
COMPOSER_EDITOR,
+ COMPOSER_EDITOR_PLAIN,
'.resource-reference-input-editor',
);
- const actions = desktopDeclarations(
- INPUT_ACTIONS,
- '.resource-reference-input-actions',
- );
+ const actions = desktopDeclarations(INPUT_ACTIONS, INPUT_ACTIONS_PLAIN);
const polishButton = desktopDeclarations(
`${INPUT_ACTIONS} .resource-reference-input-polish`,
'.resource-reference-input-polish',
);
const controls = desktopDeclarations(COMPOSER_CONTROLS);
const submit = desktopDeclarations(COMPOSER_SUBMIT);
+ const trigger = desktopDeclarations(COMPOSER_TRIGGER, COMPOSER_TRIGGER_BASE);
const editorMaxHeight = pixelValue(editor, 'max-height');
- const actionsRowHeight = pixelValue(polishButton, 'height');
- const inputRowGap = pixelValue(input, 'gap');
+ // 「AI 润色 / 恢复原文」已经排进下方工具条(`.project-chat-composer-controls-actions`
+ // 挂点 + `actionsPortalTarget`),不再是输入盒里的第二行:这里只留它的按钮高度断言,
+ // 输入盒的第二行现在只可能放润色状态(没内容时为 0 高)。
+ expect(
+ pixelValue(polishButton, 'height'),
+ '润色钮是 28px 方块,改了要同步改工具条那一排的高度',
+ ).toBe(28);
+ // 输入盒与输入盒外各有一条行间距:前者是「文字区 / 润色状态」之间(状态没内容时为 0),
+ // 后者是「输入内容区 / 工具栏」之间。
+ const inputRowGap = gapBox(input).row;
+ const composerRowGap = gapBox(composer).row;
const inputPaddingBottom = paddingBox(input).bottom;
const composerPaddingTop = paddingBox(composer).top;
const composerPaddingBottom = paddingBox(composer).bottom;
- const controlsRowHeight =
- pixelValue(controls, 'padding-top') + pixelValue(submit, 'height');
+ // 工具栏行高由这一排里**最高的一颗控件**决定:模型触发钮 30px 高于发送 / 语音的
+ // 28px(`align-items: center` 只做居中,不会把行高压回 28)。所以这里取两者最大值,
+ // 而不是写死某一颗钮的高度——改任一颗的尺寸都会自动反映到算式里。
+ expect(
+ controls.has('padding-top'),
+ '工具栏与输入区之间的间距由输入盒的 row-gap 提供,不再用 padding-top',
+ ).toBe(false);
+ expect(
+ pixelValue(trigger, 'height'),
+ '工具栏行高由模型触发钮决定,改了要重新核对这条算式',
+ ).toBe(30);
+ const controlsRowHeight = Math.max(
+ pixelValue(submit, 'height'),
+ pixelValue(trigger, 'height'),
+ );
expect(
pixelValue(editor, 'min-height'),
'编辑器最小高度是输入盒高度的下界,改了要同步改这里的算式',
- ).toBe(96);
- // 操作排的高度就是那三只 28px 方钮自己撑起来的:它自己不设 height / min-height,
- // 一旦设了,网格行高会被顶起来、编辑器的 min-height 也跟着变,算式随之作废。
- expect(actions.has('height')).toBe(false);
- expect(actions.has('min-height')).toBe(false);
+ ).toBe(72);
+ // 输入盒第二行是**条件渲染**的润色状态行:`ResourceReferenceInput` 只在
+ // `polishing || polishError || polishNotice` 时才渲染它,空态这一行高度为 0(`gap` 的
+ // row 分量已归 0,有内容时由状态元素自带的 6px 上间距提供间隔)。这里断言那条条件
+ // 还在(谁把它改成常驻渲染,这条就红),所以 `statusRowHeight = 0` 是真实依据而不是写死。
+ const inputSource = readFileSync(INPUT_SOURCE_PATH, 'utf8');
+ expect(
+ inputSource,
+ '润色状态行必须是条件渲染(空态不occupy第二行)',
+ ).toContain('polishing || polishError || polishNotice');
+ const statusRule = desktopDeclarations(
+ `${COMPOSER_INPUT} .resource-reference-input-status`,
+ );
+ expect(pixelValue(statusRule, 'margin-top')).toBe(6);
+ const statusRowHeight = 0;
return {
- actionsRowHeight,
+ polishButtonHeight: pixelValue(polishButton, 'height'),
total:
+ composerPaddingTop +
editorMaxHeight +
inputRowGap +
- actionsRowHeight +
+ statusRowHeight +
inputPaddingBottom +
- composerPaddingTop +
- composerPaddingBottom +
- controlsRowHeight,
+ composerRowGap +
+ controlsRowHeight +
+ composerPaddingBottom,
};
}
@@ -268,13 +343,15 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文
it('消息列表底部不留给浮层的空白,滚动到底不会多出一段空白(宽屏)', () => {
const list = desktopDeclarations(...LIST_SELECTORS);
- const { total, actionsRowHeight } = composerMaxHeight();
+ const { total, polishButtonHeight } = composerMaxHeight();
- // 输入盒最高高度由真实声明算出来:编辑器 140 + 行距 8 + 操作排 28 + 输入框下内边距 4
- // + 输入盒内边距 24(四边同为 12:文字左内缩必须等于上内缩)+ 操作条(2 + 28 方钮)= 234。
- expect(actionsRowHeight, '操作排按钮高度变了就要重算下面的算式').toBe(28);
+ // 输入盒最高高度由真实声明算出来(自上而下):
+ // 盒内上内边距 12 + 编辑器 160 + 输入区行距 0(状态行没内容时为 0 高,不留幻影行距)
+ // + 状态行 0 + 输入区下内边距 0 + 输入盒与工具栏的行距 8
+ // + 工具栏 30(模型触发钮撑起来的行高)+ 盒内下内边距 12 = **222**。
+ expect(polishButtonHeight, '润色钮在工具条里仍是 28px 方块').toBe(28);
expect(pixelValue(desktopDeclarations(COMPOSER_SUBMIT), 'height')).toBe(28);
- expect(total).toBe(234);
+ expect(total).toBe(222);
const paddingBottom = paddingBox(list).bottom;
// 新契约:留白归 0。旧版这里是一条 `inset + 输入区最高高度 + 间距` 的算式,
@@ -287,6 +364,192 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文
expect(paddingBottom).toBeLessThan(total);
});
+ it('思维强度并入模型菜单:外壳向上展开、强度区在菜单顶部(宽屏)', () => {
+ const trigger = desktopDeclarations(
+ COMPOSER_TRIGGER,
+ COMPOSER_TRIGGER_BASE,
+ );
+ const select = desktopDeclarations(
+ `${COMPOSER_CONTROLS} .conversation-model-select`,
+ );
+ // 外壳:锚点是工作台专属几何;卡片观感来自共享样式面(见下面的 `sharedCard`),
+ // 所以这里把"这个元素会命中的全部选择器"都传进去——只传工作台那条会漏掉搬家后的声明。
+ const shell = desktopDeclarations(
+ `${COMPOSER} .conversation-model-menu-shell`,
+ SHARED_SHELL,
+ );
+ const menuInShell = desktopDeclarations(
+ `${COMPOSER} .conversation-model-menu-shell .conversation-model-menu`,
+ SHARED_CARD,
+ SHARED_CARD_IN_SHELL,
+ );
+ const section = desktopDeclarations(STRENGTH_SECTION);
+ const range = desktopDeclarations(STRENGTH_RANGE);
+ /**
+ * 共享样式面为了压过基础规则里那几条 `!important`,自己也是按 `!important` 声明的;
+ * 断言时只看最终值,去掉后缀。
+ */
+ const value = (declarations: Map, property: string) =>
+ declaration(declarations, property).replace(/ !important$/u, '');
+
+ // 弹层锚在模型控件上、从工具条上方展开(与原来的模型菜单同一朝向,不盖输入框)。
+ expect(declaration(select, 'position')).toContain('relative');
+ expect(declaration(shell, 'position')).toBe('absolute');
+ expect(declaration(shell, 'bottom')).toBe('calc(100% + 8px)');
+ expect(declaration(shell, 'right')).toBe('0');
+ // 卡片观感由共享样式面提供(与首页那颗同一份规则)。
+ expect(pixelValue(shell, 'border-radius')).toBe(16);
+ expect(paddingBox(shell).top).toBe(12);
+ expect(declaration(shell, 'box-shadow')).toContain(
+ 'var(--platform-desktop-hover-shadow)',
+ );
+ expect(declaration(shell, 'max-height')).toBe('none');
+ // 里层菜单回到文档流(位置 / 描边 / 底色归外壳),强度区是菜单顶部的一块网格,滑块占满宽度。
+ expect(value(menuInShell, 'position')).toBe('static');
+ expect(value(menuInShell, 'max-height')).toBe('170px');
+ expect(value(menuInShell, 'background')).toBe('transparent');
+ expect(value(menuInShell, 'border-radius')).toBe('12px');
+ expect(declaration(section, 'display')).toBe('grid');
+ expect(declaration(range, 'width')).toBe('100%');
+ // 底行没有第二颗强度控件:那一排的高度仍由模型触发钮(30px)决定,见上面的算式。
+ expect(pixelValue(trigger, 'height')).toBe(30);
+ });
+
+ it('档位圆点已删除,滑块契约与「两端贴齐」的强度区内边距仍在(宽屏)', () => {
+ const section = desktopDeclarations(STRENGTH_SECTION);
+ const range = desktopDeclarations(STRENGTH_RANGE);
+ const thumb = desktopDeclarations(STRENGTH_THUMB);
+ const stops = desktopDeclarations(STRENGTH_STOPS);
+
+ // 反向守卫①:样式表里**不再有**档位点的规则(谁把点写回来,这条就红)。
+ expect(stops.has('position')).toBe(false);
+ expect(stops.size).toBe(0);
+ // 反向守卫②:组件源码里也不再渲染那组点。
+ const controlsSource = readFileSync(CHAT_COMPOSER_CONTROLS_PATH, 'utf8');
+ expect(controlsSource).not.toContain(
+ 'project-chat-composer-strength-stops',
+ );
+
+ // 滑块本体契约不变:铺满卡片内容宽度,轨道 6px、圆钮 20px。
+ expect(declaration(range, 'width')).toBe('100%');
+ expect(pixelValue(range, 'height')).toBe(26);
+ expect(pixelValue(thumb, 'width')).toBe(20);
+ expect(pixelValue(thumb, 'height')).toBe(20);
+
+ // 强度区左右不留内边距(那条 padding 原先只服务档位点排布):填充段与圆钮两端与轨道贴齐,
+ // 中间不出现多余留白。上下留白不动(4 / 2)。
+ const sectionPadding = paddingBox(section);
+ expect(
+ sectionPadding.left,
+ '滑块左右不留白:点删掉后这条内边距必须归零',
+ ).toBe(0);
+ expect(sectionPadding.right).toBe(0);
+ expect(sectionPadding.top).toBe(4);
+ expect(sectionPadding.bottom).toBe(2);
+
+ // 已选段终点必须与**圆钮中心**重合(`10px + ratio * (100% - 20px)`):写成 `20px + …`
+ // (= 圆钮**右缘**)时圆钮右侧会露出一小截橙色(4× 设备像素实拍对照见 PR)。
+ const track = desktopDeclarations(
+ `${STRENGTH_RANGE}::-webkit-slider-runnable-track`,
+ );
+ const trackBackground = declaration(track, 'background');
+ expect(trackBackground).toContain(
+ 'calc(10px + var(--strength-ratio, 0) * (100% - 20px))',
+ );
+ expect(trackBackground).not.toContain(
+ 'calc(20px + var(--strength-ratio, 0) * (100% - 20px))',
+ );
+ });
+
+ it('工具条只有一行:七个宽度下都不换行,控件排与外壳带单行最小宽度(含 AI 润色钮)', () => {
+ // 宽度扫描:任何面板宽下工具条都必须 `nowrap`,且样式表里**不再有** Direct 作用域的
+ // `flex-wrap: wrap`(窄面板曾经靠换行避免重叠,现在改成 min-width + 溢出可见)。
+ const CONTROLS_LEFT = `${COMPOSER} .project-chat-composer-controls-left`;
+ const CONTROLS_RIGHT = `${COMPOSER} .project-chat-composer-controls-right`;
+ const CONTROLS_MODEL_SELECT = `${COMPOSER_CONTROLS} .conversation-model-select`;
+ const WIDTHS = [240, 280, 296, 320, 360, 438, 900] as const;
+ for (const width of WIDTHS) {
+ const controls = resolveDeclarations(rules, [COMPOSER_CONTROLS], width);
+ expect(
+ declaration(controls, 'flex-wrap'),
+ `${width}px 下工具条必须保持单行`,
+ ).toBe('nowrap');
+ const left = resolveDeclarations(rules, [CONTROLS_LEFT], width);
+ expect(declaration(left, 'flex-wrap'), `${width}px 下左组不换行`).toBe(
+ 'nowrap',
+ );
+ const right = resolveDeclarations(rules, [CONTROLS_RIGHT], width);
+ expect(right.has('flex-wrap'), '右组本来就不换行').toBe(false);
+ const modelSelect = resolveDeclarations(
+ rules,
+ [CONTROLS_MODEL_SELECT, `${CONTROLS_RIGHT} .conversation-model-select`],
+ width,
+ );
+ expect(declaration(modelSelect, 'flex-wrap')).toBe('nowrap');
+ }
+
+ // 单行下限:控件排 252px(Σ 控件最小宽度 204 + 5×8 间距 = 244,再留 8 余量),
+ // 外壳 252 + 左右内边距各 12 + 边框各 1 = 278px。夹具实测:自然宽度 290.4。
+ const controls = desktopDeclarations(COMPOSER_CONTROLS);
+ expect(pixelValue(controls, 'min-width')).toBe(252);
+ const composer = desktopDeclarations(
+ COMPOSER,
+ COMPOSER_PLAIN,
+ CONVERSATION_COMPOSER,
+ );
+ expect(
+ pixelValue(composer, 'min-width'),
+ '外壳最小宽度 = 工具条下限 + 左右内边距 + 边框',
+ ).toBe(278);
+
+ // 空输入态:组件源码里「AI 润色 / 恢复原文」是渲染到工具条那个挂点里的
+ // (`actionsPortalTarget`),不再是输入盒里的第二行。
+ const composerSource = readFileSync(CHAT_COMPOSER_PATH, 'utf8');
+ const rightGroupIndex = composerSource.indexOf(
+ 'className="project-chat-composer-controls-right"',
+ );
+ const slotIndex = composerSource.indexOf(
+ 'project-chat-composer-controls-actions',
+ );
+ const submitIndex = composerSource.indexOf('{canStopTurn ? (');
+ expect(rightGroupIndex).toBeGreaterThanOrEqual(0);
+ // 挂点在**右组内**、且在发送钮之前(DOM 顺序 `模型 → 麦克风 → 润色 → 发送`,Tab 顺序同视觉)。
+ expect(slotIndex).toBeGreaterThan(rightGroupIndex);
+ expect(submitIndex).toBeGreaterThan(slotIndex);
+ expect(composerSource).toContain(
+ 'actionsPortalTarget={controlsActionsSlot}',
+ );
+ });
+
+ it('模型菜单刷新行:图标与文本贴最左,选项行才用 space-between(宽屏)', () => {
+ // 宿主作用域里那条「抵消通用按钮样式」的规则曾经统一写 `justify-content: space-between`
+ // (8 类 + `!important`),把刷新行的文案顶到右边——现在它不写对齐,改由共享面按角色分:
+ // 选项行 `space-between`(名字左 / 勾右),刷新行 `flex-start`(图标 + 文本成一组)。
+ const HOST_ROW =
+ '.game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer .conversation-model-select .conversation-model-menu button';
+ const REFRESH_BASE = '.conversation-model-menu-refresh';
+ const refresh = desktopDeclarations(HOST_ROW, REFRESH_BASE);
+ /** 共享面这几条是按 `!important` 声明的(要压过基础规则里的旧值),断言只看最终值。 */
+ const value = (declarations: Map, property: string) =>
+ declaration(declarations, property).replace(/ !important$/u, '');
+ expect(
+ value(refresh, 'justify-content'),
+ '刷新行必须左对齐(图标 + 文案成一组)',
+ ).toBe('flex-start');
+ expect(value(refresh, 'gap')).toBe('8px');
+
+ const OPTION_SHARED = `${SHARED_CARD} button[role='option']`;
+ const OPTION_BASE = '.conversation-model-menu button[role="option"]';
+ const option = desktopDeclarations(OPTION_SHARED, OPTION_BASE);
+ expect(value(option, 'justify-content'), '选项行仍是名字左 / 勾右').toBe(
+ 'space-between',
+ );
+
+ // 说明:上面两条断言已经覆盖「宿主块不再盖掉刷新行」——`resolveDeclarations` 会把
+ // 宿主链那条 8 类的规则一并算进来,如果它又写回 `justify-content: space-between !important`,
+ // 第一条断言会直接变回 `space-between` 而红。所以不再额外做源码切片断言。
+ });
+
it('窄屏(390px)输入盒仍在文档流、四周有边距,列表底部没有留白', () => {
const conversation = mobileDeclarations(CONVERSATION);
const list = mobileDeclarations(...LIST_SELECTORS);
diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
index c75b60aea..33b00be56 100644
--- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
+++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
@@ -544,7 +544,10 @@ test('pre-send validation falls back when the selected model is disabled', async
await act(async () => {
await expect(ref.current?.ensureUsable()).resolves.toBe(true);
});
- expect(screen.getByText('所选模型已下线,已切换为默认模型')).not.toBeNull();
+ // 状态文案现在走组件自带的底部 toast(portal 到 body):等它出现,别用同步查询。
+ expect(
+ await screen.findByText('所选模型已下线,已切换为默认模型'),
+ ).not.toBeNull();
expect(savedModelId).toBe('quality');
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'quality',
@@ -570,7 +573,7 @@ test('follows the new server default when the saved selection was the default',
await waitFor(() => expect(savedModelId).toBe('fast'));
expect(savedModelIsDefault).toBe(true);
expect(
- screen.getByText('默认模型已更新,已切换为新的默认模型'),
+ await screen.findByText('默认模型已更新,已切换为新的默认模型'),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'fast',
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index 84c727441..0c6d227a4 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -2,9 +2,30 @@
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
+## 2026-10-05 PR #607 复核修复:档位点对齐/对比度、状态文案也走浮层、键盘去重、卸载 flush
+
+- **档位圆点已删除(D1 的收口)**:这一轮把档位圆点**整体删除**(半透明备选方案未采用)。现在滑块只剩轨道 + 圆钮:轨道 6px 圆头、已选段 `--platform-accent` 由 `--strength-ratio` 驱动、**终点落在圆钮中心**(`calc(10px + ratio * (100% - 20px))`)、圆钮 20px 实心暖白(`--platform-panel-fill` + `--platform-subpanel-border` 1px 描边 + `color-mix` 柔影);强度区横向内边距 `4px 6px 2px` → `4px 0 2px`(滑块铺满卡片内容宽度,填充段与圆钮两端与轨道两端贴齐);相关 CSS(`space-between` 排布 / `z-index: 2` 抬层 / `.is-active{opacity:0}` / 点的 `color-mix` 底色 / 只为点对齐的 `padding: 0 7px`)与渲染标记一并删除。**判据**:`chatDialogFrameLayout.test.ts` 反向守卫(样式表里不再有 `.project-chat-composer-strength-stops` 规则、组件源码不再渲染该类名;滑块契约仍在:宽 100% / 高 26 / 圆钮 20×20、强度区左右内边距 0)+ `home.suite.ts` 首页菜单里查不到那组点。实测(447 视口,像素扫描):轨道 90..324(宽 234 = 卡片内容宽),档位 0 时圆钮左缘 90.5(距轨道左端 0.5px)、档位 4 时圆钮右缘 322.3(距右端 1.8px,扫描行不在圆钮正中所以略窄),填充段终点落在**圆钮中心**(`calc(10px + ratio * (100% - 20px))`,被圆钮盖住),因此圆钮右侧不会露出橙色(终点曾写成 `20px + …` = 圆钮右缘,4× 设备像素下能看到一小截溢出)。**历史成因(只留一句,细节由 Git 追溯)**:圆点此前被 6px 轨道盖住、且与圆钮两端错位 ±12.2px,曾用「抬到轨道之上 + space-between 对齐 + 浅暖色」修过一轮,最终整体删除。
+- **状态文案(`role="status"`)也会顶高那一排(D2)**:`notice`(「模型列表已刷新」「正在刷新模型列表」「默认模型已停用」、推理档读取/保存失败)原先无条件渲染成控件里的 `role="status"`,`toast` 形态只接管了 `error`,于是 Direct 输入区仍会被一行状态文字顶成两行高。现行口径:浮层**收进组件内部**(`components/common/composerNotice.tsx` 的模块级单例 + toast 宿主),失败与状态分两条通道各自去重(同一条只弹一次、清空后可再弹),失败 `tone="error"`、状态 `tone="neutral"`;**任何宿主**的输入区子树里都不再有常驻 `role="alert"` / `role="status"` 行,宿主的 `errorPresentation` / `onUnavailable` / `onNotice` / `noticePresentation` 这套分支已删除。判据:`chat-composer.suite.ts`「routes model-list notices to the toast…」断言 Direct 输入区子树里 `[role='status']` 与 `[role='alert']` **都为 0**,且浮层在输入区之外。
+- **键盘长按每个事件都落盘(D5)**:range 的方向键在 Chromium 里每次重复都会补 `change`,旧行为 ~30 次/秒全量写盘(写链 + 持久化 + 回读)。现行口径:`selectEffort` 先比对「最近一次**落盘成功**的档位」(`persistedEffortRef`,挂载回读时就种上),相同则不写;连续调整只写**每个新档位**一次,钳位后的重复事件 0 次。实测(真实按键序列):单击方向键 **1** 次落盘;长按 1.5s(34 个重复事件)**4** 次(`low/medium/high/max`,即每个新档位一次);已在最高档继续长按 1.2s(28 个事件)**0** 次。
+- **拖动中途卸载会丢档位(D6)**:预览值只在 `change`/`keyup`/`pointerup`/`blur` 落盘,拖到一半被 Esc 关掉菜单或宿主卸载时不会提交。现行口径:卸载 cleanup 里 flush 一次(读 ref,不碰已卸载组件的 state)。实测:拖动到「中」未松手时落盘 0 次,`unmount()` 后恰好 **1** 次(`medium`);单次拖动(按下 + 6 步横移 + 松手)全程 **1** 次(`high`),终值 = 松手位置。
+- **`forced-colors` 下焦点不可见(D4)**:高对比模式会丢弃 thumb 的 `box-shadow`,而焦点环正是画在 thumb 上的阴影。现行口径:加 `@media (forced-colors: active)` 回退 `outline: 2px solid Highlight; outline-offset: 2px`(系统色,不算新色值;只在 forced-colors 生效,亮色观感不变)。
+- **注释与实现必须同口径(D9)**:`.resource-reference-input` 的 `min-height: 0 / padding: 0` 原先写在文件末尾那条六类规则里靠**源码顺序**盖住前面的 `min-height: 56px / padding: 0 0 4px`,与「不依赖源码顺序」的注释矛盾。现行口径:几何(内边距 / 高度下界)写回 11520 行那条六类规则,末尾那条只留网格与行距,注释同步改成"不依赖源码先后"。
+- **深色主题下焦点环间隙不可靠(D3,仅记录不改代码)**:间隙色取 `--platform-input-fill`,深色主题该 token 只有 5% 不透明(`rgba(255,255,255,0.05)`),间隙会与焦点环颜色几乎一样。当前工作台根节点固定 `platform-theme--light`,走不到这条分支;将来真要启用深色主题,把间隙换成不透明 token 再验一次。
+
+## 2026-10-05 模型菜单的观感是组件自带的共享样式面(首页同步复用)
+
+- **现象**:工作台那颗 `ConversationModelSelect` 已经改成「强度区 + 模型列表」的卡片观感,但首页输入区右下角那颗(`className="home-input-model-select"`,同一个组件)还是**旧卡片**:161px 窄卡、10px 圆角、5px 内边距、32px 行高、旧灰投影。首页与工作台应当是同一套观感。
+- **成因**:#600 那轮把卡片 / 列表的新观感全挂在 `.game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex …` 这条**宿主祖先链**上,于是只有工作台那一处命中;同一个组件换个宿主就退回基础规则。
+- **处理(现行口径)**:卡片 / 列表 / 行 / hover / 选中 pill / 细滚动条这套观感抬到**组件自带的共享样式面**——选择器只用组件类名(`.conversation-model-select .conversation-model-menu-shell`、`.conversation-model-select .conversation-model-menu`、`… .conversation-model-menu button…`),**不带任何宿主祖先链**;留在宿主作用域的只有工具栏几何(控制排的宽高与收缩、队列 chip、模型触发钮),**弹层锚点也是组件级**(`position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 20`,见下方"后续")。合并形态(有外壳)与独立形态(策划/画布直接渲染菜单)**同一套值**:卡 16px 圆角 / 12px 内边距 / 1px `--platform-line-soft` 描边 / `--platform-desktop-hover-shadow`;行 34px、圆角 9px、hover `--platform-nav-item-icon-fill`、选中 `--platform-warm-bg` + 600;独立形态的卡片自己就是滚动区(`max-height: 194 = 170 + 上下各 12`,与合并形态同高),外壳形态的列表 `max-height: 170`(5×34,不出现半行)。
+- **首页强度区(分两步收口)**:先只同步了卡片观感(当时首页那颗没有 effort 的 state / props,`view/home/index.tsx` 只传 `className` 与 `disabled`,直接加强度区等于新增一个首页从未暴露过的设置项);随后按要求把强度区一并对齐,见下方"后续"。
+- **判据/取证**:`tests/appSurface/home.suite.ts`「首页模型菜单复用同一套共享样式面」——首页实例的 `.conversation-model-menu` 的祖先链里**没有** `.game-workbench-chat`,而只靠 `.conversation-model-select …` 就能解析出 16px 圆角 / 12px 内边距 / 34px 行,且菜单里确实有原生滑块(`input[type="range"]` 带 `aria-label="思维强度"`);`tests/chatDialogFrameLayout.test.ts` 的外壳用例按新选择器集合更新(锚点与卡片观感都来自共享面,工作台作用域只剩工具栏几何)。真机实测(无头 Chromium,同一夹具同一裁剪框):首页那颗 161×309 / 10px / 5px / 32px → **260×194 / 16px / 12px / 34px**(与工作台卡片逐项一致);工作台那颗外壳 260×262.5、内层列表 234×170、行 34/9px **前后逐值不变**。
+- **边界**:策划面与画布也复用同一个组件 → 它们的菜单观感同样变成这张卡片(有意为之;窄面板下卡片最小宽度 260px,仍受 `calc(100vw - 24px)` 与各自锚点约束)。若某个宿主需要另一套尺寸,在**该宿主作用域**里覆盖,不要再把观感抄第二份。
+- **后续(同一批收口)**:强度区与外壳锚点也一并抬到共享面——`view/home/index.tsx` 给首页那颗传了**同一个** `ComposerReasoningEffortSelect`(`presentation="section"`,读写仍是组件自己的 `read_game_creator_app_config` / `select_game_creator_reasoning_effort`),首页因此与工作台逐项同形:外壳 260×262.5、强度区 234×60.5、列表 234×170、滑块 222×26(447 与 1200 两档实测一致;工作台回归后逐值不变)。抬升时必须删掉工作台作用域里两条**死重**规则(给旧独立「推理档」控件排版的 `display: inline-flex` 一族),否则它们会以更高特异性盖掉共享面的 `display: grid`,把强度区重新算宽 24px。
+- **滑块两点(最终收口)**:档位圆点先是改成派生浅暖色、直径收到 6px(= 轨道高度),随后**整体删除**(见上面 D1 条)。「进度条高度与滑块高度一致」保留两种解释对照:**变体 A 落地**(填充段 = 轨道高度 6px),**变体 B**(填充段做成 20px = 圆钮高度)只在夹具里渲染出图、未提交。
+
## 2026-10-05 给 `shared-contracts` 请求 DTO 加字段:`#[serde(default)]` 救不了 Rust 结构体字面量,AGC 壳编译失败会伪装成 `npm run dev:all` 起不来
-- **现象**:`npm run dev:all` 的根栈五个服务与 AGC Vite 都就绪、终端也会打印启动汇总,但 AGC 窗口始终不出现;stdout 里夹着 `error[E0063]: missing field `price_mud_points` in initializer of `GameDistributionCreateVersionRequest``(`src\game_distribution_publish.rs:1356`)和 `error: could not compile `genarrative-ai-game-creator-shell` (bin ...) due to 1 previous error`。编译失败不会让 `tauri dev` 退出,dev:all 只会一直挂着(`waitForAgcFrontend` 的 660s 预算耗尽后才收束整棵树),容易被误判成端口占用或根栈没起来。
+- **现象**:`npm run dev:all` 的根栈五个服务与 AGC Vite 都就绪、终端也会打印启动汇总,但 AGC 窗口始终不出现;stdout 里夹着 `error[E0063]: missing field `price_mud_points`in initializer of`GameDistributionCreateVersionRequest``(`src\game_distribution_publish.rs:1356`)和 `error: could not compile `genarrative-ai-game-creator-shell` (bin ...) due to 1 previous error`。编译失败不会让 `tauri dev` 退出,dev:all 只会一直挂着(`waitForAgcFrontend` 的 660s 预算耗尽后才收束整棵树),容易被误判成端口占用或根栈没起来。
- **根因**:变更在 `server-rs/crates/shared-contracts/src/game_distribution.rs` 给请求 DTO 末尾追加了 `#[serde(default)] pub price_mud_points: u64`。`serde(default)` 只影响**反序列化缺字段**,Rust **结构体字面量仍必须列全字段**。AGC 壳 `apps/ai-game-creator-shell/src-tauri` 是**独立 cargo workspace**(自带 `[workspace]`,只把 `shared-contracts` 当 path 依赖),`server-rs` 侧的 `cargo test -p ...` 六个 crate 全绿也覆盖不到它。
- **处理(现行口径)**:改 `shared-contracts` 里**请求 DTO 字段**时,必须同一次编译 AGC 壳:先 `npm run agc:bundled-resources:prepare`(`build.rs` 只做只读校验,未准备资源会以「随包插件存在未声明文件」失败关闭),再 `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute`(约 30s,feature 与 dev/release 对齐);完整口径是 `npm run ai-game-creator-shell:check:rust`。定价字段的取值口径分两段:**历史口径**(2026-10-05 之前)AGC 发布面板没有买断制定价入口,构造版本请求时显式按免费提交 `price_mud_points: 0`,与升级前行为一致;**现行口径** AGC 发布面板已支持「免费 / 买断制」定价,请求按作者选择从载荷透传价格,`#[serde(default)]` 仅用于向后兼容不传该字段的旧客户端(按免费处理)。
- **边界**:`cargo check` **不能省 feature 参数**——默认 feature 下 `build.rs` 会因为已 staging 的 godot/unity 插件 DLL「不在当前 feature 组合的声明清单里」而红,那是资源准备前置条件,不是本次代码的编译错误。门禁本身**已经存在**:CI `.gitea/workflows/project-ci.yml` 的 `AI game creator shell Rust lane 1/2`、`lane 2/2` 各顺序跑两片 `npm run check:native-shells:agc-rust-shard-N`(底层 `ai-game-creator-shell:check:rust:shell` → `cargo test --locked` 编出壳 bin),本地同序列是 `npm run check:native-shells`。漏拦的原因是本地验证只跑了 `server-rs` 侧 crate、没等 MR/CI 上的 AGC lane,不是缺门禁;因为 2466 条 bin 单测跑满很贵,**不要**把整条 lane 塞进常用本地命令,只在改动 shared-contracts 请求 DTO 时补上面的 `cargo check`。
@@ -62,7 +83,7 @@
- **现象 / 风险**:改动 `preview.rs` 的注入逻辑(`inject_preview_fit_bridge` / `build_preview_response` / `PREVIEW_FIT_BRIDGE_TAG`)时,没有自动化门禁会告诉你「标签没注入」或「注入了两次」。两种失效都只在运行时才暴露:没注入等于整套运行画面点选静默失效(桥脚本的尺寸上报与点选都不执行,页面看起来完全正常);注入两次会让桥的监听、尺寸上报与点选回调各注册一遍,页面同样看不出差别。
- **现状**:`preview.rs` 的 `mod tests` 只有 4 个用例——`npm_preview_requires_build_and_prefers_bundled_assets`、`root_layout_serves_root_entry_and_keeps_legacy_paths_available`、`root_layout_does_not_expose_control_or_data_directories`、`legacy_layout_serves_root_ui_modules`;它们只断言预览路由的选取、状态行与页面自身文本,不涉及桥标签是否出现、出现几次,也不覆盖 `scan_preview_fit_bridge_html` 的「页面已带标签就不重复注入」分支(`inject_preview_fit_bridge` 里的 `if scan.has_bridge_script { return html.into_bytes(); }`)。
-- **结论(现行口径)**:这类「把常量原样返回 / 标签字符串存在」的转发型行为不固化成长用例(仓库口径:复制与源码文本断言不进测试)。改注入逻辑时按人工验证清单核对:① 响应 HTML 里 ```` 出现在 `