Merge pull request 'AGC 用户输入区按三层重排布局,修掉控件叠压(#600)' (#607) from fix/agc-composer-layout into master
Project CI / AI game creator shell Rust crates (push) Successful in 6m20s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m12s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m8s
Project CI / Backend tests (push) Successful in 8m59s
Project CI / Frontend tests (push) Successful in 3m9s
Project CI / AI game creator shell web tests (push) Successful in 4m6s
Project CI / Repository checks (push) Successful in 6m58s
Project CI / Native shell tests (push) Successful in 8m52s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 6m11s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m7s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m4s
Project CI / Backend tests (pull_request) Successful in 8m56s
Project CI / Frontend tests (pull_request) Successful in 3m46s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m0s
Project CI / Native shell tests (pull_request) Successful in 10m3s
Project CI / Repository checks (pull_request) Successful in 8m27s

Reviewed-on: http://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/607
This commit was merged in pull request #607.
This commit is contained in:
2026-10-06 15:23:26 +08:00
17 changed files with 2269 additions and 319 deletions
@@ -0,0 +1,56 @@
import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { COMPOSER_NOTICE_MILLIS, type ComposerNotice } from './composerNotice';
/**
* 输入区浮层提示的本体(底部居中 portal、6s 自动收起)。
*
* 只由 `useComposerNoticeHost` 选中的宿主渲染;`data-composer-model-toast` 是各宿主用例的判据。
*/
export function ComposerNoticeToast({
notice,
onDismiss,
}: {
notice: ComposerNotice | null;
onDismiss: () => void;
}) {
/** 收起回调只从 ref 读:宿主每次渲染都换箭头函数,写进依赖会让计时器反复重起。 */
const onDismissRef = useRef(onDismiss);
onDismissRef.current = onDismiss;
useEffect(() => {
if (!notice) {
return undefined;
}
const timer = window.setTimeout(
() => onDismissRef.current(),
COMPOSER_NOTICE_MILLIS,
);
return () => {
window.clearTimeout(timer);
};
}, [notice]);
if (!notice) {
return null;
}
return createPortal(
<div
key={notice.id}
className="pointer-events-none fixed bottom-8 left-1/2 z-[1100] w-max max-w-[min(680px,calc(100vw-2rem))] -translate-x-1/2"
data-composer-model-toast="true"
>
<PlatformRuntimeStatusToast
tone={notice.tone}
surface="solid"
size="sm"
shape="pill"
className="shadow-lg"
>
{notice.message}
</PlatformRuntimeStatusToast>
</div>,
document.body,
);
}
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from 'react';
/** 浮层提示的停留时长:与外壳(`LauncherNoticeToast`)与模板库提示同一档(6s)。 */
export const COMPOSER_NOTICE_MILLIS = 6000;
export type ComposerNotice = {
/** 每次提示自增:同一条文案再出现时也会重新弹一次并重起计时。 */
id: number;
message: string;
/**
* 语气:失败(模型服务不可用 / 推理档保存失败)用 `error`;状态(模型列表已刷新、
* 正在刷新模型列表、默认模型已停用等)用 `neutral`。
*/
tone: 'error' | 'neutral';
};
/**
* 输入区浮层提示的**模块级单例**:模型选择器与推理档控件都往这里发,宿主不必接任何回调。
*
* 为什么是单例而不是各自 portal:
* 1. 输入区**不留常驻状态行**——那一排控件容不下第二行文字,一旦在控件里排一行提示,整颗
* 控件会变成两行高、把触发钮顶到行线上方(#600 复核实测的错位);
* 2. 同一个输入区里可能同时挂着模型选择器与推理档控件(首页、策划、工作台都一样),各自
* portal 会在同一位置叠出两条 toast。这里由**编号最小的那个宿主**渲染,保证同一时刻
* 只有一条。
*/
let notice: ComposerNotice | null = null;
let sequence = 0;
let hostSequence = 0;
const hosts = new Map<number, () => void>();
function notifyHosts() {
for (const listener of hosts.values()) {
listener();
}
}
/** 发一条提示(每条都会重新弹一次并重起计时)。 */
export function publishComposerNotice(
message: string,
tone: ComposerNotice['tone'] = 'error',
) {
sequence += 1;
notice = { id: sequence, message, tone };
notifyHosts();
}
/** 收起当前提示(6s 计时结束或宿主主动收)。 */
export function dismissComposerNotice() {
notice = null;
notifyHosts();
}
/**
* 在输入区里挂一个 toast 宿主。同一个输入区里多个调用点只有一个会真正渲染(编号最小的那个),
* 所以模型选择器与推理档控件可以各自调用,页面里也只会出现一条浮层。
*
* 返回 `notice: null` 表示这次由别的宿主渲染(调用方照样把 `<ComposerNoticeToast>` 挂上即可)。
*/
export function useComposerNoticeHost(): {
notice: ComposerNotice | null;
dismiss: () => void;
} {
const hostIdRef = useRef<number | null>(null);
const [, forceRender] = useState(0);
if (hostIdRef.current === null) {
hostSequence += 1;
hostIdRef.current = hostSequence;
}
useEffect(() => {
const hostId = hostIdRef.current as number;
const listener = () => forceRender((value) => value + 1);
hosts.set(hostId, listener);
notifyHosts();
return () => {
hosts.delete(hostId);
if (hosts.size === 0) {
// 输入区整体卸载(切页/测试清理)时把待显示的提示一并丢掉,避免留给下一个宿主。
notice = null;
}
notifyHosts();
};
}, []);
if (hosts.size === 0 || Math.min(...hosts.keys()) !== hostIdRef.current) {
return { notice: null, dismiss: dismissComposerNotice };
}
return { notice, dismiss: dismissComposerNotice };
}
@@ -1,5 +1,5 @@
import { Check, ChevronDown, Lock, RefreshCcw } from 'lucide-react';
import type { Ref } from 'react';
import type { ReactNode, Ref } from 'react';
import {
useCallback,
useEffect,
@@ -10,6 +10,11 @@ import {
import { resolveTauriInvoke } from '../../app/tauri';
import type { GameCreatorAppConfigView } from '../../app/types';
import {
publishComposerNotice,
useComposerNoticeHost,
} from '../../components/common/composerNotice';
import { ComposerNoticeToast } from '../../components/common/ComposerNoticeToast';
import {
cachedLlmModelCatalog,
type ClientLlmModel,
@@ -70,12 +75,22 @@ export function ConversationModelSelect({
onReady,
projectPath,
ref,
strengthSection,
}: {
className?: string;
disabled: boolean;
onReady?: (ready: boolean) => void;
projectPath?: string;
ref?: Ref<ConversationModelSelectHandle>;
/**
* 菜单顶部的附加区(Direct 输入盒用来放「思维强度」滑块)。
*
* 传了它才会把弹层包一层 `.conversation-model-menu-shell`:外壳负责定位 / 描边 / 底色,
* 强度区在上、模型列表(`role="listbox"`)在下,滑块**不进** listbox 子树
* (参数控件放进菜单角色里会让读屏进菜单模式后读不到滑块)。
* 不传时渲染与改动前完全一致,策划输入盒、首页、画布都不受影响。
*/
strengthSection?: ReactNode;
}) {
const initialCatalog = cachedLlmModelCatalog();
const [models, setModels] = useState<ClientLlmModel[]>(
@@ -91,6 +106,9 @@ export function ConversationModelSelect({
const [busy, setBusy] = useState(!initialCatalog);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
/** 触发钮上显示的模型名(目录还没读完时用「模型」占位);同时用作触发钮的 `title`。 */
const triggerLabel =
models.find((model) => model.id === selected)?.displayName ?? '模型';
const [manualRefreshBusy, setManualRefreshBusy] = useState(false);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -109,6 +127,41 @@ export function ConversationModelSelect({
onReadyRef.current = onReady;
}, [onReady]);
/**
* 失败与状态都走**组件自己的浮层**(`composerNotice` 单例 + toast 宿主):任何宿主都不留
* 常驻 `role="alert"` / `role="status"` 行——输入区那一排控件容不下第二行文字,一旦排一行
* 提示,整颗控件会变成两行高,把触发钮顶到行线上方(#600 复核实测的错位)。
* 两条通道各自去重:同一条只报一次,清空后再出现可以再报。
*/
const reportedErrorRef = useRef('');
useEffect(() => {
if (error === '') {
reportedErrorRef.current = '';
return;
}
if (reportedErrorRef.current === error) {
return;
}
reportedErrorRef.current = error;
publishComposerNotice(error, 'error');
}, [error]);
const reportedNoticeRef = useRef('');
useEffect(() => {
if (notice === '') {
reportedNoticeRef.current = '';
return;
}
if (reportedNoticeRef.current === notice) {
return;
}
reportedNoticeRef.current = notice;
publishComposerNotice(notice, 'neutral');
}, [notice]);
// 这个输入区里的 toast 宿主(同一输入区多个调用点只有一个真正渲染)。
const composerNotice = useComposerNoticeHost();
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -407,6 +460,75 @@ export function ConversationModelSelect({
}
}
const modelMenu = (
<div
className="conversation-model-menu"
role="listbox"
aria-label="对话模型"
>
{busy && models.length === 0 ? (
<span className="conversation-model-menu-loading">正在读取模型</span>
) : null}
{models.map((model) => (
<button
key={model.id}
type="button"
role="option"
aria-selected={model.id === selected}
disabled={disabled || busy}
onClick={() => {
setOpen(false);
void select(model.id);
}}
>
<span className="conversation-model-menu-option-main">
<span>{model.displayName}</span>
{model.id === defaultModelId ? (
<em className="conversation-model-menu-default">默认</em>
) : null}
</span>
{model.id === selected ? (
<Check size={13} aria-hidden="true" />
) : null}
</button>
))}
{/* 目录里存在但当前档位不可用的模型:不可点;原因通过 aria-label 与 hover 提示暴露,
且刻意不进入 Tab 序列,避免键盘用户在不可选项上停留。 */}
{unavailableModels.map((model) => {
const reasonLabel = unavailableModelReasonLabel(model.reason);
return (
<div
key={model.id}
role="option"
aria-selected={false}
aria-disabled="true"
aria-label={`${model.displayName}:${reasonLabel}`}
className="conversation-model-menu-option conversation-model-menu-option-locked"
data-reason={model.reason}
data-tooltip={reasonLabel}
>
<span className="conversation-model-menu-option-main">
<span>{model.displayName}</span>
</span>
<Lock size={13} aria-hidden="true" />
</div>
);
})}
<div className="conversation-model-menu-divider" />
<button
type="button"
className="conversation-model-menu-refresh"
aria-label="刷新模型列表"
disabled={disabled || busy || manualRefreshBusy}
aria-busy={manualRefreshBusy}
onClick={() => void syncCatalog(true, true)}
>
<RefreshCcw size={13} aria-hidden="true" />
<span>{manualRefreshBusy ? '刷新中…' : '刷新模型列表'}</span>
</button>
</div>
);
return (
<div
ref={containerRef}
@@ -416,12 +538,17 @@ export function ConversationModelSelect({
: 'conversation-model-select'
}
>
{error ? <span role="alert">{error}</span> : null}
{notice ? <span role="status">{notice}</span> : null}
{/* 失败与状态都只走浮层:控件里不再渲染常驻 `role="alert"` / `role="status"` 行。 */}
<ComposerNoticeToast
notice={composerNotice.notice}
onDismiss={composerNotice.dismiss}
/>
<button
type="button"
className="conversation-model-trigger"
aria-label="对话模型"
/* 触发钮最窄可缩到 64px,模型名靠省略号收住:补 `title` 让被截断的名字仍可查看。 */
title={triggerLabel}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
@@ -437,79 +564,20 @@ export function ConversationModelSelect({
/>
<span className="conversation-model-trigger-label">
{/* 控件位只显示已选模型名;目录还没读完时用「模型」占位(加载提示留在菜单里)。 */}
{models.find((model) => model.id === selected)?.displayName ?? '模型'}
{triggerLabel}
</span>
<ChevronDown size={13} aria-hidden="true" />
</button>
{open ? (
<div
className="conversation-model-menu"
role="listbox"
aria-label="对话模型"
>
{busy && models.length === 0 ? (
<span className="conversation-model-menu-loading">
正在读取模型
</span>
) : null}
{models.map((model) => (
<button
key={model.id}
type="button"
role="option"
aria-selected={model.id === selected}
disabled={disabled || busy}
onClick={() => {
setOpen(false);
void select(model.id);
}}
>
<span className="conversation-model-menu-option-main">
<span>{model.displayName}</span>
{model.id === defaultModelId ? (
<em className="conversation-model-menu-default">默认</em>
) : null}
</span>
{model.id === selected ? (
<Check size={13} aria-hidden="true" />
) : null}
</button>
))}
{/* 目录里存在但当前档位不可用的模型:不可点;原因通过 aria-label 与 hover 提示暴露,
且刻意不进入 Tab 序列,避免键盘用户在不可选项上停留。 */}
{unavailableModels.map((model) => {
const reasonLabel = unavailableModelReasonLabel(model.reason);
return (
<div
key={model.id}
role="option"
aria-selected={false}
aria-disabled="true"
aria-label={`${model.displayName}:${reasonLabel}`}
className="conversation-model-menu-option conversation-model-menu-option-locked"
data-reason={model.reason}
data-tooltip={reasonLabel}
>
<span className="conversation-model-menu-option-main">
<span>{model.displayName}</span>
</span>
<Lock size={13} aria-hidden="true" />
</div>
);
})}
<div className="conversation-model-menu-divider" />
<button
type="button"
className="conversation-model-menu-refresh"
aria-label="刷新模型列表"
disabled={disabled || busy || manualRefreshBusy}
aria-busy={manualRefreshBusy}
onClick={() => void syncCatalog(true, true)}
>
<RefreshCcw size={13} aria-hidden="true" />
<span>{manualRefreshBusy ? '刷新中…' : '刷新模型列表'}</span>
</button>
</div>
strengthSection ? (
/* 合并形态(Direct):强度区在上、模型列表在下,外壳负责定位与描边。 */
<div className="conversation-model-menu-shell">
{strengthSection}
{modelMenu}
</div>
) : (
modelMenu
)
) : null}
</div>
);
@@ -114,6 +114,13 @@ type ResourceReferenceInputProps = {
submitSuppressed?: boolean;
/** 输入区操作排(编辑器右侧那一列)里宿主注入的内容,例如素材选择器的 `@` 触发钮。 */
inputActions?: ReactNode;
/**
* 把「AI 润色 / 恢复原文」这一排**渲染到宿主指定的容器里**(`createPortal`)。
*
* Direct 输入区把它挪进下方那条工具条,让润色钮与 `@` / `+` / 模型 / 语音 / 发送 同一水平线
* (否则它会自成一行,窄面板下看起来像"堆叠")。不传时仍然渲染在输入盒内(策划面等保持原样)。
*/
actionsPortalTarget?: HTMLElement | null;
inputRef?: RefObject<HTMLDivElement | null>;
/** 输入区容器元素:宿主定位自己的浮层时用它当锚点。 */
containerRef?: RefObject<HTMLDivElement | null>;
@@ -431,6 +438,7 @@ function ResourceReferenceEditor({
showPolishAction = true,
submitSuppressed = false,
inputActions,
actionsPortalTarget,
composerRef,
rootRef,
}: ResourceReferenceInputProps & {
@@ -842,48 +850,55 @@ function ResourceReferenceEditor({
return () => form.removeEventListener('submit', handleFormSubmit, true);
}, [rootRef]);
return (
<>
<div className="resource-reference-input-actions">
{inputActions}
{showPolishAction ? (
<>
/**
* 「输入区操作排」这一块:宿主注入的 `inputActions` + 内置的「AI 润色 / 恢复原文」。
* 默认渲染在输入盒里;宿主给了 `actionsPortalTarget` 时改渲染到那个容器(Direct 把它放进
* 下方工具条,与 `@` / `+` / 模型 / 语音 / 发送 同一行)。
*/
const inputActionsBlock = (
<div className="resource-reference-input-actions">
{inputActions}
{showPolishAction ? (
<>
<button
type="button"
className="resource-reference-input-polish"
aria-label="AI 润色"
title="AI 润色"
aria-busy={polishing}
disabled={disabled || polishing || draftText.trim() === ''}
onMouseDown={(event) => event.preventDefault()}
onClick={() => void polishPrompt()}
>
{polishing ? (
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
) : (
<Sparkles size={15} aria-hidden="true" />
)}
</button>
{polishedOriginalText !== null ? (
<button
type="button"
className="resource-reference-input-polish"
aria-label="AI 润色"
title="AI 润色"
aria-busy={polishing}
disabled={disabled || polishing || draftText.trim() === ''}
className="resource-reference-input-restore"
aria-label="恢复原文"
title="恢复原文"
disabled={disabled || polishing}
onMouseDown={(event) => event.preventDefault()}
onClick={() => void polishPrompt()}
onClick={restoreOriginalPrompt}
>
{polishing ? (
<Loader2
size={15}
className="animate-spin"
aria-hidden="true"
/>
) : (
<Sparkles size={15} aria-hidden="true" />
)}
<RotateCcw size={15} aria-hidden="true" />
</button>
{polishedOriginalText !== null ? (
<button
type="button"
className="resource-reference-input-restore"
aria-label="恢复原文"
title="恢复原文"
disabled={disabled || polishing}
onMouseDown={(event) => event.preventDefault()}
onClick={restoreOriginalPrompt}
>
<RotateCcw size={15} aria-hidden="true" />
</button>
) : null}
</>
) : null}
</div>
) : null}
</>
) : null}
</div>
);
return (
<>
{actionsPortalTarget
? createPortal(inputActionsBlock, actionsPortalTarget)
: inputActionsBlock}
{/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */}
{/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */}
{showPolishAction &&
@@ -22,7 +22,7 @@ export type ClientLlmModelCatalog = LlmModelsResponse;
export async function loadClientLlmModels(): Promise<ClientLlmModelCatalog> {
const invoke = resolveTauriInvoke();
if (!invoke) throw new Error('本地模型服务不可用');
if (!invoke) throw new Error('模型服务不可用');
// Rust typed command 已经把响应解析成 `LlmModelsResponse`(含 unavailableModels)。
return invoke<ClientLlmModelCatalog>('load_game_creator_llm_models');
}
@@ -30,7 +30,7 @@ export function notifyLlmConfigChanged() {
async function loadEffectiveCatalog(epoch: number) {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new LlmModelCatalogConfigError('本地模型服务不可用');
throw new LlmModelCatalogConfigError('模型服务不可用');
}
let config: GameCreatorAppConfigView | undefined;
try {
File diff suppressed because it is too large Load Diff
@@ -16,6 +16,7 @@ import type { ProjectStartMode } from '../../app/types';
import { resolveHomeCreationFailureMessage } from '../../features/app-shell/homeCreationFailure';
import { ConversationModelSelect } from '../../features/project-workspace/ConversationModelSelect';
import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
import { ComposerReasoningEffortSelect } from '../project-development/chat/components/DirectProjectComposer/ComposerControls';
import RichInputArea, { UploadButton } from './components/RichInputArea';
import {
richTextToAttachments,
@@ -297,6 +298,14 @@ export default function HomeView({
<ConversationModelSelect
className="home-input-model-select"
disabled={homeCreationBusy || creationBusy}
/* 与工作台同一形态:思维强度区在模型菜单顶部(同一个组件、同一份配置通道)。
首页没有浮层宿主,所以状态文案沿用控件内的 `inline`(Direct 那边是 toast)。 */
strengthSection={
<ComposerReasoningEffortSelect
disabled={homeCreationBusy || creationBusy}
presentation="section"
/>
}
/>
<button
className="grid size-7 cursor-pointer place-items-center rounded-full border-0 bg-(image:--platform-button-primary-fill) p-0 text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow) transition-transform hover:scale-105 disabled:cursor-not-allowed disabled:opacity-55"
@@ -16,7 +16,7 @@ import {
Square,
X,
} from 'lucide-react';
import type { RefObject } from 'react';
import type { CSSProperties, RefObject } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { GameCreationAppAssetManifestEntry } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
@@ -25,11 +25,17 @@ import type {
GameCreatorAppConfigView,
GameCreatorLlmReasoningEffort,
} from '../../../../../app/types';
import {
publishComposerNotice,
useComposerNoticeHost,
} from '../../../../../components/common/composerNotice';
import { ComposerNoticeToast } from '../../../../../components/common/ComposerNoticeToast';
import {
composerReasoningEffortOptions,
DEFAULT_COMPOSER_REASONING_EFFORT,
normalizeComposerReasoningEffort,
} from '../../../../../features/project-workspace/composerReasoningEffort';
import { LLM_CONFIG_CHANGED_EVENT } from '../../../../../services/llmModelCatalog';
import type { DirectPendingTurn } from '../../conversation/directPendingTurns';
import {
resolveSpeechRecognitionCtor,
@@ -310,8 +316,15 @@ export function ComposerVoiceButton({
*/
export function ComposerReasoningEffortSelect({
disabled,
presentation = 'dropdown',
}: {
disabled: boolean;
/**
* `dropdown`(默认)保持原来的下拉列表,供策划输入盒沿用;
* `section` 只渲染强度本体(标题 + 滑块 + 档位点),供 Direct 输入盒嵌进
* 模型菜单的顶部——底行因此只留一颗控件(模型触发钮),不再额外占位。
*/
presentation?: 'dropdown' | 'section';
}) {
const [open, setOpen] = useState(false);
const [effort, setEffort] = useState<GameCreatorLlmReasoningEffort>(
@@ -319,9 +332,24 @@ export function ComposerReasoningEffortSelect({
);
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState('');
/*
拖动中的预览档位:`input` 只更新它(视觉立刻跟手),松手 / 抬键才真正落盘。
这样写盘不会发生在拖动过程中——写盘会置 `saving`,一旦用它 disable 掉原生 range,
浏览器会当场中断正在进行的拖拽(实测:拖动 6 步只收到 1 个 input 事件,
圆钮停在第一格不动)。
*/
const [previewEffort, setPreviewEffort] =
useState<GameCreatorLlmReasoningEffort | null>(null);
const previewEffortRef = useRef<GameCreatorLlmReasoningEffort | null>(null);
const writeChainRef = useRef<Promise<unknown>>(Promise.resolve());
const mountedRef = useRef(true);
const options = composerReasoningEffortOptions();
const shownEffort = previewEffort ?? effort;
const activeIndex = Math.max(
0,
options.findIndex((option) => option.value === shownEffort),
);
const activeLabel = options[activeIndex]?.label ?? shownEffort;
useEffect(() => {
mountedRef.current = true;
@@ -330,34 +358,92 @@ export function ComposerReasoningEffortSelect({
};
}, []);
/*
落盘时机交给原生 `change`:range 的 `change` 就是"值被提交"那一刻(鼠标松手 /
键盘一次调整),`input` 才是拖动过程中的连续事件。React 的 `onChange` 对 range
映射的是 `input`(连续),所以这里直接挂原生监听,避免把连续事件当成提交。
*/
const rangeRef = useRef<HTMLInputElement | null>(null);
const commitPreviewRef = useRef<() => void>(() => undefined);
useEffect(() => {
let cancelled = false;
const node = rangeRef.current;
if (!node) {
return undefined;
}
const handleChange = () => commitPreviewRef.current();
node.addEventListener('change', handleChange);
return () => {
node.removeEventListener('change', handleChange);
};
}, []);
/*
读一次配置:种「最近落盘值」并在空闲时回显。
—— 配置有**别的写入者**(运行配置对话框改 `llm.reasoningEffort` 后 `notifyLlmConfigChanged()`,
也可能在窗口失焦期间被改)。只在挂载时读一次会让「最近落盘值」过期:用户重选**同一档**会被
去重跳过,界面显示 A、磁盘却是 B。所以挂载、`LLM_CONFIG_CHANGED_EVENT`、窗口重新获得焦点
时都重读一次(**只读不写盘**),重新种 `persistedEffortRef`。
—— 写盘在途或拖动预览未提交时不回显(回读拿到的还是旧值,回显会让界面闪一下)。
*/
const writeInFlightRef = useRef(0);
const readEffortRef = useRef<(silent?: boolean) => void>(() => undefined);
readEffortRef.current = (silent = false) => {
const invoke = resolveTauriInvoke();
if (!invoke) {
return undefined;
return;
}
void invoke<GameCreatorAppConfigView>('read_game_creator_app_config')
.then((view) => {
if (cancelled || !mountedRef.current) return;
setEffort(
normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort),
if (!mountedRef.current) return;
const read = normalizeComposerReasoningEffort(
view?.config?.llm?.reasoningEffort,
);
// 回读值就是"磁盘上的档位":把它登记成最近落盘值,之后提交同一档位不再写盘。
persistedEffortRef.current = read;
if (writeInFlightRef.current > 0 || previewEffortRef.current !== null) {
return;
}
setEffort(read);
})
.catch(() => {
if (cancelled || !mountedRef.current) return;
if (!mountedRef.current || silent) return;
setNotice('推理档读取失败');
});
};
useEffect(() => {
readEffortRef.current();
}, []);
useEffect(() => {
// 外部写入者改档 / 窗口重新获得焦点:重读一次让「最近落盘值」跟上,避免去重吞掉真实改档。
const reread = () => readEffortRef.current(true);
window.addEventListener(LLM_CONFIG_CHANGED_EVENT, reread);
window.addEventListener('focus', reread);
return () => {
cancelled = true;
window.removeEventListener(LLM_CONFIG_CHANGED_EVENT, reread);
window.removeEventListener('focus', reread);
};
}, []);
/** 最近一次**落盘成功**的档位:重复提交同一档位不再写一次(键盘长按/多事件源)。 */
const persistedEffortRef = useRef<GameCreatorLlmReasoningEffort | null>(null);
/** 卸载 flush 需要读到最新档位,不能依赖渲染闭包。 */
const effortRef = useRef(effort);
effortRef.current = effort;
function selectEffort(next: GameCreatorLlmReasoningEffort) {
const invoke = resolveTauriInvoke();
if (!invoke) {
setNotice('需要在 Tauri App 内运行');
return;
}
if (persistedEffortRef.current === next) {
// 已经就是这个值:不再写盘(键盘 auto-repeat / 多个事件源汇到同一档位时只写一次)。
setEffort(next);
setNotice('');
return;
}
const previous = effort;
setEffort(next);
setNotice('');
@@ -373,11 +459,13 @@ export function ComposerReasoningEffortSelect({
);
void run
.then((view) => {
const echoed = normalizeComposerReasoningEffort(
view?.config?.llm?.reasoningEffort,
);
persistedEffortRef.current = echoed;
if (!mountedRef.current) return;
// 以落盘后的回读值为准,避免界面显示一个没有真正保存的档位。
setEffort(
normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort),
);
setEffort(echoed);
})
.catch(() => {
if (!mountedRef.current) return;
@@ -391,63 +479,185 @@ export function ComposerReasoningEffortSelect({
});
}
/*
拖动中被 Esc / 卸载打断时,预览值还没落盘:在卸载 cleanup 里主动 flush 一次
(只读 ref,不再碰已卸载组件的 state),否则这次拖动会静默丢掉。
*/
const flushPreviewRef = useRef<() => void>(() => undefined);
flushPreviewRef.current = () => {
const pending = previewEffortRef.current;
previewEffortRef.current = null;
if (!pending || pending === effortRef.current) {
return;
}
selectEffort(pending);
};
useEffect(
() => () => {
flushPreviewRef.current();
},
[],
);
/** 状态文案(推理档读取/保存失败)同样走组件自己的浮层:任何宿主都不留常驻状态行。 */
const reportedNoticeRef = useRef('');
useEffect(() => {
if (notice === '') {
reportedNoticeRef.current = '';
return;
}
if (reportedNoticeRef.current === notice) {
return;
}
reportedNoticeRef.current = notice;
publishComposerNotice(notice, 'neutral');
}, [notice]);
// 这个输入区里的 toast 宿主(与模型选择器共用单例,同一时刻只有一条浮层)。
const composerNotice = useComposerNoticeHost();
/** 拖动中的连续事件只改预览:视觉立刻跟手,落盘留到松手那一刻。 */
function previewFromEvent(event: { currentTarget: HTMLInputElement }) {
const next = options[Number(event.currentTarget.value)];
if (!next || next.value === shownEffort) {
return;
}
previewEffortRef.current = next.value;
setPreviewEffort(next.value);
}
/** 松手 / 抬键 / 失焦时把预览落盘(一个手势只写一次)。 */
function commitPreview() {
const next = previewEffortRef.current;
previewEffortRef.current = null;
setPreviewEffort(null);
if (next && next !== effort) {
selectEffort(next);
}
}
commitPreviewRef.current = commitPreview;
/*
下拉形态(策划等宿主原样沿用):与模型选择器同一套观感,复用 `conversation-model-*`
的触发钮与浮层样式。
*/
const dropdownSelect = (
<div className="conversation-model-select">
<button
type="button"
className="conversation-model-trigger"
aria-label="推理档"
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled || saving}
onClick={() => setOpen((current) => !current)}
>
<span
className="conversation-model-trigger-status"
aria-hidden="true"
/>
<span className="conversation-model-trigger-label">{activeLabel}</span>
<ChevronDown size={13} aria-hidden="true" />
</button>
{open ? (
<div
className="conversation-model-menu"
role="listbox"
aria-label="推理档"
>
{options.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={option.value === effort}
disabled={disabled || saving}
onClick={() => {
setOpen(false);
selectEffort(option.value);
}}
>
<span className="conversation-model-menu-option-main">
<span>{option.label}</span>
</span>
{option.value === effort ? (
<Check size={13} aria-hidden="true" />
) : null}
</button>
))}
</div>
) : null}
</div>
);
/*
强度区(Direct 输入盒):嵌在模型菜单顶部,底行因此只留一颗控件。
原生 `<input type="range">` 自带 `role="slider"` 与 ←/→/Home/End 键盘行为,
点轨道(含拖拽)按 `step` 吸附到档位,`aria-valuetext` 报中文档位名;
浮层的开合与 Esc / 点外部关闭由模型菜单自己负责(本区不再自带触发钮与浮层)。
*/
const strengthSection = (
<span
className="project-chat-reasoning-effort is-section"
role="group"
aria-label="思维强度"
>
<span className="project-chat-composer-strength-head">
<span className="project-chat-composer-strength-title">思维强度</span>
<span className="project-chat-composer-strength-value">
{`当前:${activeLabel}`}
</span>
</span>
<span className="project-chat-composer-strength-slider">
{/* 原生 range 的 role/值域本来就成立,这里再显式写一遍:`aria-valuetext` 是
中文档位名,读屏念的是「最高」而不是「4」。 */}
<input
ref={rangeRef}
type="range"
className="project-chat-composer-strength-range"
min={0}
max={options.length - 1}
step={1}
value={activeIndex}
style={
{
/* 档位比例(0~1,无单位):CSS 用它算出**圆钮中心**,已选段终点就落在那里。 */
'--strength-ratio': String(activeIndex / (options.length - 1)),
} as CSSProperties
}
aria-label="思维强度"
aria-valuemin={0}
aria-valuemax={options.length - 1}
aria-valuenow={activeIndex}
aria-valuetext={activeLabel}
/* 拖动中保持可用(写盘只在松手后发生),视觉靠预览值跟手。
`onInput`(浏览器里连续触发)与 `onChange`(React 对 range 的常规映射)都接,
两者更新同一个预览,重复调用没有副作用。 */
disabled={disabled}
onChange={previewFromEvent}
onInput={previewFromEvent}
/* 松手 / 抬键 / 失焦才落盘:拖动中写盘会 disable 控件、原生拖拽当场中断。 */
onPointerUp={commitPreview}
/* 触屏取消(pointercancel)与"松手"同一口径:一并提交当前预览,
否则预览值会滞留到下一次提交或卸载 flush。 */
onPointerCancel={commitPreview}
onKeyUp={commitPreview}
onBlur={commitPreview}
/>
{/* 档位圆点已删除(用户口径「很丑」,第一选择就是删):轨道本身就是刻度,
命中与按 `step` 吸附仍由原生 range 负责,`aria-valuetext` 报中文档位名。 */}
</span>
</span>
);
return (
<span className="project-chat-reasoning-effort">
{/* 与模型选择器同一套观感:复用 `conversation-model-*` 的触发钮与浮层样式,
不再用原生 `<select>`(原生下拉是操作系统菜单,圆角/描边/选中态都跟旁边不一致)。 */}
<div className="conversation-model-select">
<button
type="button"
className="conversation-model-trigger"
aria-label="推理档"
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled || saving}
onClick={() => setOpen((current) => !current)}
>
<span
className="conversation-model-trigger-status"
aria-hidden="true"
/>
<span className="conversation-model-trigger-label">
{options.find((option) => option.value === effort)?.label ?? effort}
</span>
<ChevronDown size={13} aria-hidden="true" />
</button>
{open ? (
<div
className="conversation-model-menu"
role="listbox"
aria-label="推理档"
>
{options.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={option.value === effort}
disabled={disabled || saving}
onClick={() => {
setOpen(false);
selectEffort(option.value);
}}
>
<span className="conversation-model-menu-option-main">
<span>{option.label}</span>
</span>
{option.value === effort ? (
<Check size={13} aria-hidden="true" />
) : null}
</button>
))}
</div>
) : null}
</div>
{notice ? (
<span role="status" className="project-chat-composer-notice">
{notice}
</span>
) : null}
{presentation === 'section' ? strengthSection : dropdownSelect}
{/* 状态文案只走浮层:控件里不再渲染常驻 `role="status"` 行。 */}
<ComposerNoticeToast
notice={composerNotice.notice}
onDismiss={composerNotice.dismiss}
/>
</span>
);
}
@@ -133,6 +133,9 @@ export function DirectProjectComposer({
}) {
const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
const composerRootRef = useRef<HTMLDivElement | null>(null);
/** 工具条里给「AI 润色 / 恢复原文」的挂点(`ResourceReferenceInput` 用 portal 渲染进来)。 */
const [controlsActionsSlot, setControlsActionsSlot] =
useState<HTMLSpanElement | null>(null);
const pickerRef = useRef<ResourceReferencePickerHandle | null>(null);
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
const modelValidateInFlightRef = useRef(false);
@@ -263,6 +266,9 @@ export function DirectProjectComposer({
placeholder="描述你的想法,或 @ 引用素材"
submitSuppressed={pickerOpen}
containerRef={composerRootRef}
/* 「AI 润色 / 恢复原文」排进下方工具条**发送钮左边**:与发送钮同为圆角矩形按钮、
相邻成组;空输入态也不再自带一行(窄面板下那一行看起来像"堆叠")。 */
actionsPortalTarget={controlsActionsSlot}
/>
<div className="project-chat-composer-controls">
<div className="project-chat-composer-controls-left">
@@ -283,18 +289,36 @@ export function DirectProjectComposer({
/>
</div>
<div className="project-chat-composer-controls-right">
<ComposerReasoningEffortSelect disabled={busy} />
<ConversationModelSelect
ref={modelSelectRef}
disabled={busy}
onReady={setModelReady}
projectPath={projectPath ?? ''}
/* 强度滑块并进模型菜单:底行只留这一颗控件(少占一个位)。 */
strengthSection={
<ComposerReasoningEffortSelect
disabled={busy}
presentation="section"
/>
}
/>
<ComposerVoiceButton
disabled={busy || modelValidating}
onTranscript={(text) => composerRef.current?.insertText(text)}
onTranscript={(text) => {
/* 拿到转写就把语音的提示收掉:提示是「上一次动作的回执」,
留着会变成输入区里的常驻文本(那一排容不下常驻行)。 */
setVoiceNotice('');
composerRef.current?.insertText(text);
}}
onNotice={setVoiceNotice}
/>
{/* 润色 / 恢复原文的挂点(portal 目标):放在**发送钮左边**——它与发送钮同为
圆角矩形按钮,相邻成组更协调;DOM 顺序即 `模型 → 麦克风 → 润色 → 发送`,
所以 Tab 序与读屏顺序与视觉一致。 */}
<span
className="project-chat-composer-controls-actions"
ref={setControlsActionsSlot}
/>
{canStopTurn ? (
<ComposerStopButton
cancelling={turnCancelling}
File diff suppressed because it is too large Load Diff
@@ -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<HTMLElement>(
'[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('高');
});
@@ -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<HTMLElement>(
'.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<HTMLInputElement>(
'.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<string, string>, 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<HTMLElement>(
'[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');
@@ -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', () => {
@@ -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<string, string>) {
return { top: value(0), right: value(1), bottom: value(2), left: value(3) };
}
/**
* 展开 `gap` 简写,只关心主/交叉两轴。
*
* 一值写四个方向同一个数;两值先 `row-gap` 后 `column-gap`(与 CSS 的 `gap: <row> <column>`
* 一致)。三层输入区里两条行间距分别写在输入盒与输入盒自己身上,读值时必须分得清是哪一条。
*/
function gapBox(declarations: Map<string, string>) {
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<string, string>, 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<string, string>, 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);
@@ -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',
+53 -2
View File
@@ -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 里 ``<script src="/__genarrative/local-preview-fit.js"></script>`` 出现在 `</body>` 前,且是经典脚本(不带 `type` / `nomodule`);② 页面自身已带该标签时,响应里的标签数量不增加;③ `/__genarrative/local-preview-fit.js` 返回 200,且内容与 `resources/preview/local-preview-fit.js` 逐字节一致(`include_str!` 内嵌,可比 sha256)。
- **结论(现行口径)**:这类「把常量原样返回 / 标签字符串存在」的转发型行为不固化成长用例(仓库口径:复制与源码文本断言不进测试)。改注入逻辑时按人工验证清单核对:① 响应 HTML 里 `<script src="/__genarrative/local-preview-fit.js"></script>` 出现在 `</body>` 前,且是经典脚本(不带 `type` / `nomodule`);② 页面自身已带该标签时,响应里的标签数量不增加;③ `/__genarrative/local-preview-fit.js` 返回 200,且内容与 `resources/preview/local-preview-fit.js` 逐字节一致(`include_str!` 内嵌,可比 sha256)。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/preview.rs`(`PREVIEW_FIT_BRIDGE_SCRIPT` / `PREVIEW_FIT_BRIDGE_TAG` / `inject_preview_fit_bridge`)、`apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js`。
## 2026-10-04 Phaser 4 `hitTestPointer` 的返回顺序不是叠放顺序
@@ -89,6 +110,27 @@
- **判据/取证**:Rust `cargo test … asset_generation_task` 27 条(含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`naming_task_update_requires_the_project_rename_permission`、`naming_task_update_rejects_a_record_from_another_project`、`naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one`、`ledger_with_an_unsupported_schema_version_fails_closed`、`ledger_without_a_schema_version_is_accepted_as_v1`)与 `conditional_project_rename_tests` 7 条(含超长名失败关闭);前端 `homeProjectNamingAsync.test.tsx`(入队→running→终态的推进序列)、`projectNamingGenerationTaskRow.test.tsx`(行渲染)、appSurface 的「syncs the workbench title and recent project list with the AI name after creation」「recovers the naming row when the terminal event lands before the subscription is ready」「re-reads the naming ledger when the generation tasks panel opens」(后两条已做「改前红」验证:退回「先读后订阅 / 面板打开不补读」两条即红)。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`。
## 2026-10-04 输入区不放常驻状态行:那一排控件容不下第二行文字
- **现象**:AGC 工作台输入区里模型失败提示常驻一行红字,工具条上还浮着一个半透明的「模型 ⌄」压在 `@ + 默认 ⌄` 那一排上;口径是「完全没对齐」,改成 toast。
- **成因**:`ConversationModelSelect` 的 `[role='alert']` 曾被从 `bottom: 36px` 浮层收回控件排内(`flex: 1 1 100%` 独占一行),整颗模型控件因此变成两行高;工具条是 `align-items: center` 的 flex 行、行高由最高项决定,于是触发钮被顶到行线上方、提示落到行线下方——**不是"某个元素绝对定位压过来",是行内多了一行文字**。
- **处理(现行口径)**:输入区**不留常驻状态行**,与输入区同一排的失败提示一律走浮层。`ConversationModelSelect` 新增 `errorPresentation: 'inline' | 'toast'`(默认 `inline`,首页 / 策划 / 画布原样不动),Direct 输入盒传 `'toast'`,由 `ComposerModelNoticeToast` 复用外壳同一枚 `PlatformRuntimeStatusToast`(bottom-center portal、6s 自动收起);触发时机是失败消息**新出现**时一次(同条去重,清空后可再报),不做每次渲染都弹;不可用态照旧挡住发送,但不靠文字行表达。文案同步去掉误解性的「本地」前缀(`llmModelCatalog.ts` / `clientApi.ts` → 「模型服务不可用」)。
- **判据/取证**:`tests/appSurface/chat-composer.suite.ts`——不可用时产出 toast,且输入区子树里既没有 `[role='alert']` 也没有旧文案;模型失败只报一次、清空后再出现才再报。
- **关联**:`features/project-workspace/ConversationModelSelect.tsx`、`view/project-development/chat/components/DirectProjectComposer/{DirectProjectComposer.tsx,ComposerControls.tsx}`、`src/styles.css`。
## 2026-10-04 输入盒的强度选择并入模型菜单,底行只留一颗控件
- 目标口径:**减少底部空间占用** —— 底行只能有模型触发钮一颗控件。思维强度不再单独占位,而是作为**强度区嵌在模型菜单顶部**(标题「思维强度」+「当前:最高」+ 原生 `<input type="range" min=0 max=4 step=1>` + 5 个档位点),与模型列表共用同一张卡(`.conversation-model-menu-shell`,向上展开、贴触发钮右缘,里层 `.conversation-model-menu` 回到文档流只负责滚动)。
- **滑块不能放进 `role="listbox"` 子树**:参数控件混进菜单角色会让读屏进菜单模式后读不到它,所以外壳是普通容器(强度区 + listbox 并列),只能靠 `strengthSection` 这个显式 prop 由宿主注入。
- 档位枚举、默认值、持久化通道(读 `read_game_creator_app_config`、写 `select_game_creator_reasoning_effort`)与旧下拉**完全一致**;`ComposerReasoningEffortSelect` 的 `presentation` 默认 `'dropdown'`(策划输入盒继续用,零语义变化),Direct 用 `'section'`;`ConversationModelSelect` 不传 `strengthSection` 时渲染与改动前逐字一致(首页 / 策划 / 画布不受影响)。
- 关掉旧的独立触发钮(底行元素 2 → 1);浮层的开合、Esc 与点击外部关闭都由模型菜单既有行为负责,强度区不再自带浮层。
- **取舍**:强度区随菜单挂载,打开时先显示「默认」再回读成落盘档位(一次 IPC 往返),测试里要 `waitFor`;换掉这一点需要把档位状态提到宿主,暂不做。
- **判据/取证**:`tests/appSurface/chat-composer.suite.ts`(底行不再有第二颗控件、菜单里改档落同一条持久化通道、Esc/点外部关闭、滑块不在 listbox 内)+ `tests/chatDialogFrameLayout.test.ts`(外壳向上展开 / 强度区网格 / 底行高度不增)+ `tests/appSurface/design-agent.suite.ts`(策划菜单里没有滑块)。
- **视觉口径**:卡片与滑块的观感照 Codex 参考图,颜色只取我们自己的暖色 token——卡片圆角 16px、内边距 12px、极淡描边(`--platform-line-soft`)+ 既有暖色投影 token `--platform-desktop-hover-shadow`、**不画横贯分隔线**(只靠留白);强度区标题 `--platform-text-strong` 12/600、右侧当前档位 `--platform-text-soft` 11px;轨道 6px 圆头,已选段 `--platform-accent`、未选段 `--platform-subpanel-border`(进度由组件写进来的 `--strength-ratio` 驱动渐变 `calc(20px + var(--strength-ratio) * (100% - 20px))`),圆钮 20px **实心**(`--platform-panel-fill`,不用半透明 token,避免透出已选段变成"靶心")+ `--platform-subpanel-border` 1px 描边 + 从 `--platform-accent` 派生的 `color-mix` 柔影;档位点 7px 绝对定位叠在轨道**之上**(`z-index: 2` > range 的 1)、当前档位那一点隐藏(见本文件顶部 2026-10-05 那条的对齐算式与实测);模型列表行高 34px、行圆角 9px、hover `--platform-nav-item-icon-fill`、选中 `--platform-warm-bg` 轻量 pill + 右侧对勾;列表 `max-height: 170px`(=5×34,不出现半行),滚动条 6px 且平时透明、悬停卡片才显形。
- **实测(无头 Chromium 夹具,447px / 900px)**:卡片 260×263(圆角 16 / 内边距 12)、强度区 234×61、滑块 222×26、档位点 7px 且中心与轨道中线重合(点 270 / 轨道 269)、列表行高 34、`max-height` 170(内容 315 → 滚动)、默认档进度 0%,按 `End` 到 100% 且文案变「当前:最高」;卡片右缘未溢出视口。
- **关联**:`features/project-workspace/ConversationModelSelect.tsx`、`view/project-development/chat/components/DirectProjectComposer/{DirectProjectComposer.tsx,ComposerControls.tsx}`、`src/styles.css`。
## 2026-10-03 后台异步回填被「进项目」覆盖:AI 项目名只在兜底名仍成立时改
- **现象**(Issue 599):首页建项把 AI 命名改成「与建项并行、结果后台回填」后,偶尔工作台标题停在兜底名(`GameAgent 项目 <短 id>`),而磁盘 manifest 已经是 AI 名字。
@@ -97,6 +139,15 @@
- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx` 的「keeps the AI name when the naming result lands before the project is entered」(去掉等待即复现兜底名覆盖);appSurface 的「syncs the workbench title and recent project list with the AI name after creation」。
- **边界**:条件改名的判据必须由宿主校验(项目 ID + 当前名称仍是兜底名),前端只转述 `expectedProjectId` / `expectedName`;用户已手动改名时宿主返回 `renamed: false`,回填整体跳过、绝不覆盖用户输入。
- **关联**:`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src-tauri/src/commands.rs`。
## 2026-10-03 AGC 输入区控件叠在一起:输入区内的操作排与状态提示不能用绝对定位
- **现象**:#600 截图里用户输入区域「元素堆叠」——空草稿时「AI 润色」按钮就压在输入内容区右下角(实测与编辑器相交 28×24px),模型控件的失败提示「本地模型服务不可用」直接画在第 3~4 行文字上(70×36px,长草稿时把整段文字盖住);面板越窄越糟:280px 面板下推理档整颗压在「添加文件」上(28×28px),「插入素材引用」与推理档也相交(20×28px)。
- **原因**:两条「靠绝对定位把控件挪出流」的旧写法叠在一起:① `.resource-reference-input-actions` 被写成 `position: absolute; right: 0; bottom: 0`,浮到编辑器右下角(同一份样式早前其实是网格第二行,见文件里 11893 行那条注释,被后写的绝对定位规则盖掉);② `ConversationModelSelect` 的 `[role='alert'] / [role='status']` 是 `bottom: 36px` 的浮层,锚点就在输入盒正下方,提示必然落在文字上。另外 `.project-chat-composer-controls-right` 写死 `flex: 0 0 auto`,宽度不够时既不收缩也不换行,整组向右溢出输入盒(360px 面板下溢出 34px、280px 下 114px)。
- **处理(现行口径)**:`.project-chat-composer.is-direct-codex` 是单列网格(`gap: 8px`);**控件全部集中在唯一一行工具条**——左组 `@` / `+`,右组 模型 / 语音 / **「AI 润色 / 恢复原文」(紧贴发送左侧)** / 发送,六颗 `flex-wrap: nowrap` 平铺(润色钮由 `ResourceReferenceInput` 的 `actionsPortalTarget` portal 进右组发送钮之前的挂点,不再自成一行;它与发送钮同为圆角矩形按钮,相邻成组,DOM / Tab 顺序都是 模型 → 麦克风 → 润色 → 发送);状态/提示层只在有内容时占行(空输入态第二行为 0 高、行间距为 0,状态自己带 6px 上间距)。窄宽度用 `min-width` 兜(控件排 252px / 外壳 278px)+ 横向溢出可见:**不换行、不堆叠、不重叠**,发送钮仍可点击(夹具 `elementFromPoint` 命中)。模型钮封顶 160px 且名字走省略号(收缩由它承担),左组是固定 28px 动作钮、`flex: 0 0 auto` 不参与收缩。**模型失败提示后来改成走浮层**(见本文件顶部 2026-10-04 / 2026-10-05 两条):控件排里既不留常驻红字、也不留常驻状态文字——「控件排内独占一行」那次快照已退役,行高回到由模型触发钮决定。
- **判据/取证**:`apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts` 用层叠求值钉住单行工具条(240 / 280 / 296 / 320 / 360 / 438 / 900 七个宽度下 `flex-wrap` 都是 `nowrap`、控件排 `min-width: 252px`、外壳 `min-width: 278px`)、输入盒最高高度算式(12 盒内上内边距 + 160 编辑器 + 0 输入区行距 + 0 状态行 + 0 输入区下内边距 + 8 输入盒与工具栏行距 + **30 工具栏行高(模型触发钮 30px 撑起来,发送/语音是 28px)** + 12 盒内下内边距 = **222**),以及组件源码里润色钮渲染在工具条挂点(`actionsPortalTarget={controlsActionsSlot}`、挂点在控件排之后);`tests/appSurface/project-development.suite.ts` 钉住 composer 的 `gap: 8px`。真实渲染对照见 #600 PR:同一夹具在 495/438/360/296/280px 面板 × 空 / 长多行 / 队列+提示 四态下,改前 2~4 处相交(含文字被提示盖住),改后 0 处相交、0 处溢出。
- **关联**:`apps/ai-game-creator-shell/src/styles.css`(文件末尾「输入区工具栏与状态提示」区块)、`docs/【功能说明】AGC聊天AI润色与发送前提醒-2026-09-10.md`。
## 2026-10-03 AGC 随包 plugins 的 feature 档位必须与消费方一致,且门禁会因 build.rs 未重跑而假通过
- **现象**:Windows 本机 `npm run check:generated-bindings`(`npm run lint` 链内,`scripts/check-repository-ci.sh` 的 Repository checks 也走它)在 `build.rs:167:29` panic:`插件随包资源校验失败:随包插件存在未声明文件:.../src-tauri/resources/plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll(目标 x86_64-pc-windows-msvc 与当前 feature 组合不允许;请先执行随包资源准备步骤)`;树上换成 `agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe` 时报同一类错。反向还有更隐蔽的形态:门禁 2 秒就 exit 0 说「通过」,但 tree 上其实带着编辑器产物。