强度滑块并入模型菜单:底行只剩一颗控件(复核反馈 2)
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m16s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

- `ConversationModelSelect` 新增 `strengthSection`:传了它就把弹层包一层 `.conversation-model-menu-shell`(强度区在上、`role="listbox"` 模型列表在下,共用同一张卡);不传时渲染与改动前逐字一致(首页 / 策划 / 画布不变)
- 强度区不再自带触发钮与浮层:`ComposerReasoningEffortSelect` 的 `presentation` 由 `'slider'` 改为 `'section'`(只渲染「思维强度」标题 + 原生 range + 5 档位点 + 「当前:最高」),开合 / Esc / 点外部交给模型菜单既有行为;Direct 输入盒把它作为 `strengthSection` 注入,底行元素 2 → 1
- 删掉上一轮的独立触发钮与其专用 CSS(`project-chat-composer-strength-trigger` / `-strength-popover` / `-strength-model` 与 `-strength` 锚点);同时删掉不再使用的 `onModelLabelChange` 回调——强度区不再复述模型名(触发钮与列表已处处显示模型,重复信息优先去掉以省空间)
- 新增外壳 CSS:定位 / 朝向沿用原模型菜单(贴触发钮右缘、向上展开、不盖输入框),里层菜单回到文档流只负责滚动,超长模型名改走省略号
- 测试:`chat-composer.suite.ts` 改为「打开模型菜单 → 滑块在顶部 → 改档落同一条持久化通道 → Esc/点外部关闭」,并断言底行不再有第二颗控件、滑块不在 listbox 子树里;`chatDialogFrameLayout.test.ts` 断言外壳向上展开 / 强度区网格 / 底行高度不增;`design-agent.suite.ts` 断言策划菜单里没有滑块;`project-development.suite.ts` 断言旧触发钮/浮层 CSS 已删
- pitfalls 同步改写该条(含「滑块不能进 listbox」与「强度区随菜单挂载,打开时先显示默认再回读」的取舍)
- 校验:4 个 vitest 文件 224 passed / 9 skipped、typecheck、eslint、prettier、encoding、spacetime-schema、diff-check 全通过
This commit is contained in:
2026-10-05 00:13:37 +08:00
parent 0aeea3dbb5
commit c197edb203
9 changed files with 272 additions and 248 deletions
@@ -1,5 +1,5 @@
import { Check, ChevronDown, RefreshCcw } from 'lucide-react';
import type { Ref } from 'react';
import type { ReactNode, Ref } from 'react';
import {
useCallback,
useEffect,
@@ -67,7 +67,7 @@ export function ConversationModelSelect({
ref,
errorPresentation = 'inline',
onUnavailable,
onModelLabelChange,
strengthSection,
}: {
className?: string;
disabled: boolean;
@@ -85,8 +85,15 @@ export function ConversationModelSelect({
errorPresentation?: 'inline' | 'toast';
/** `errorPresentation: 'toast'` 时,对**新出现**的失败消息回调一次(同一条不重复)。 */
onUnavailable?: (message: string) => void;
/** 当前选中模型的显示名(目录没读完时是空串);供宿主在别处只读展示。 */
onModelLabelChange?: (label: string) => void;
/**
* 菜单顶部的附加区(Direct 输入盒用来放「思维强度」滑块)。
*
* 传了它才会把弹层包一层 `.conversation-model-menu-shell`:外壳负责定位 / 描边 / 底色,
* 强度区在上、模型列表(`role="listbox"`)在下,滑块**不进** listbox 子树
* (参数控件放进菜单角色里会让读屏进菜单模式后读不到滑块)。
* 不传时渲染与改动前完全一致,策划输入盒、首页、画布都不受影响。
*/
strengthSection?: ReactNode;
}) {
const initialCatalog = cachedLlmModelCatalog();
const [models, setModels] = useState<ClientLlmModel[]>(
@@ -118,11 +125,9 @@ export function ConversationModelSelect({
}, [onReady]);
const onUnavailableRef = useRef(onUnavailable);
const onModelLabelChangeRef = useRef(onModelLabelChange);
useEffect(() => {
onUnavailableRef.current = onUnavailable;
onModelLabelChangeRef.current = onModelLabelChange;
}, [onModelLabelChange, onUnavailable]);
}, [onUnavailable]);
/** 已经报出去的失败消息:同一条只在「新出现」时报一次,失败清空后可以再报一次。 */
const reportedErrorRef = useRef('');
@@ -141,12 +146,6 @@ export function ConversationModelSelect({
onUnavailableRef.current?.(error);
}, [error, errorPresentation]);
const selectedModelLabel =
models.find((model) => model.id === selected)?.displayName ?? '';
useEffect(() => {
onModelLabelChangeRef.current?.(selectedModelLabel);
}, [selectedModelLabel]);
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -433,6 +432,53 @@ 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>
))}
<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}
@@ -472,52 +518,15 @@ export function ConversationModelSelect({
<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>
))}
<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>
);
+78 -37
View File
@@ -13415,7 +13415,8 @@ details.design-agent-reasoning[open]
② 输入区不留常驻状态行:模型服务的失败走 `ComposerModelNoticeToast` 浮层提示
(控件排容不下第二行文字,实测会把触发钮顶到行线上方,见下面 ② 段);
③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距;
④ 思维强度改成 popover + 滑块(AGC 自己的浮层约定 + 原生 `<input type="range">`)。 */
④ 思维强度并入模型菜单:底行只留模型触发钮一颗控件,强度区(原生 `<input type="range">`)
嵌在菜单顶部,与模型列表共用同一张卡(`.conversation-model-menu-shell`)。 */
/* ① 下拉控件:可收缩 + 组内换行;模型名超长走省略号。 */
.game-workbench-chat
@@ -13469,59 +13470,99 @@ details.design-agent-reasoning[open]
/* ③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距:
队列 chip 与提示段落的 `margin` 都写回前文各自那条规则。 */
/* ④ 思维强度:触发钮固定文案「选择强度」,档位选择收进 popover 里的滑块。
popover 的定位 / 底色 / 描边 / 阴影沿用 `.project-chat-attachment-menu` 同一套浮层
约定,颜色全部走 `--platform-*`(不引入新色值);滑块是原生 `<input type="range">`,
自带 `role="slider"`、`aria-valuemin/max/now` 与 ←/→/Home/End,`aria-valuetext`
报中文档位名,点轨道或拖拽按 `step` 吸附到档位。 */
/* ④ 思维强度**并入模型菜单**:底行只留模型触发钮一颗控件,强度区嵌在菜单顶部。
菜单外壳(`.conversation-model-menu-shell`)沿用原来模型菜单的定位 / 描边 / 底色 /
阴影(向上展开、贴触发钮右缘),强度和模型列表共用同一张卡;强度区里是原生
`<input type="range">`,自带 `role="slider"`、`aria-valuemin/max/now` 与
←/→/Home/End,`aria-valuetext` 报中文档位名,点轨道或拖拽按 `step` 吸附到档位。
颜色全部走 `--platform-*`(不引入新色值)。 */
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength {
position: relative;
display: inline-flex;
align-items: center;
min-width: 0;
}
/* 触发钮只比其它方钮宽一点(文案 + 箭头),不再显示当前档位。 */
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-trigger {
gap: 4px;
padding: 0 8px;
white-space: nowrap;
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-popover {
.conversation-model-menu-shell {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
left: 0;
z-index: 4;
z-index: 20;
display: grid;
gap: 8px;
min-width: 224px;
max-width: min(280px, calc(100vw - 40px));
padding: 10px 12px 12px;
width: max-content;
min-width: 216px;
max-width: min(320px, calc(100vw - 24px));
border: 1px solid var(--platform-surface-border);
border-radius: 12px;
background: var(--platform-input-fill);
box-shadow: 0 12px 28px rgb(0 0 0 / 16%);
box-shadow:
0 14px 30px rgb(31 41 55 / 13%),
0 2px 6px rgb(31 41 55 / 5%);
pointer-events: auto;
}
/* 里层菜单回到文档流:外壳已经有位置 / 描边 / 底色,列表只负责滚动与截断。 */
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.conversation-model-menu-shell
.conversation-model-menu {
position: static;
width: 100%;
min-width: 0;
max-width: none;
max-height: 260px;
overflow: auto;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
/* 列表宽度由外壳限定,超长模型名走省略号(原来靠 `width: max-content` 不截断)。 */
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.conversation-model-menu-shell
.conversation-model-menu-option-main {
min-width: 0;
overflow: hidden;
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-model {
min-width: 0;
.conversation-model-menu-shell
.conversation-model-menu-option-main
> span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 强度区:菜单顶部、与模型列表用一条分隔线分开(分隔线是最后一个子属性的
`border-bottom`,列表首项自带 padding,不会显得挤)。 */
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-reasoning-effort.is-section {
display: grid;
gap: 6px;
padding: 9px 9px 7px;
border-bottom: 1px solid var(--platform-line-soft);
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-title {
color: var(--platform-text-base);
font-size: 12px;
font-weight: 700;
@@ -313,17 +313,14 @@ export function ComposerVoiceButton({
export function ComposerReasoningEffortSelect({
disabled,
presentation = 'dropdown',
modelLabel,
}: {
disabled: boolean;
/**
* `dropdown`(默认)保持原来的下拉列表,供策划输入盒沿用;
* `slider` 换成 popover + 滑块(Codex 观感),供 Direct 输入盒用——那一排容不下
* 第二个下拉,滑块把选择过程收进浮层,触发钮只占一颗控件的宽度。
* `section` 只渲染强度本体(标题 + 滑块 + 档位点),供 Direct 输入盒嵌进
* 模型菜单的顶部——底行因此只留一颗控件(模型触发钮),不再额外占位。
*/
presentation?: 'dropdown' | 'slider';
/** `slider` 呈现方式下 popover 头部只读展示的当前模型名(目录没读完时为空)。 */
modelLabel?: string;
presentation?: 'dropdown' | 'section';
}) {
const [open, setOpen] = useState(false);
const [effort, setEffort] = useState<GameCreatorLlmReasoningEffort>(
@@ -333,7 +330,6 @@ export function ComposerReasoningEffortSelect({
const [notice, setNotice] = useState('');
const writeChainRef = useRef<Promise<unknown>>(Promise.resolve());
const mountedRef = useRef(true);
const popoverRef = useRef<HTMLSpanElement | null>(null);
const options = composerReasoningEffortOptions();
const activeIndex = Math.max(
0,
@@ -341,30 +337,6 @@ export function ComposerReasoningEffortSelect({
);
const activeLabel = options[activeIndex]?.label ?? effort;
// 滑块浮层的关闭语义与 `+` 弹层一致:Esc 与点击外部;下拉形态保持原行为不变。
useEffect(() => {
if (presentation !== 'slider' || !open) {
return;
}
function handleOutsidePointerDown(event: MouseEvent) {
const target = event.target as Node | null;
if (popoverRef.current && !popoverRef.current.contains(target)) {
setOpen(false);
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
setOpen(false);
}
}
document.addEventListener('mousedown', handleOutsidePointerDown);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleOutsidePointerDown);
document.removeEventListener('keydown', handleEscape);
};
}, [open, presentation]);
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -487,86 +459,59 @@ export function ComposerReasoningEffortSelect({
);
/*
滑块形态(Direct 输入盒):档位收进浮层,触发钮只占一颗控件的宽度;
强度区(Direct 输入盒):嵌在模型菜单顶部,底行因此只留一颗控件。
原生 `<input type="range">` 自带 `role="slider"` 与 ←/→/Home/End 键盘行为,
点轨道(含拖拽)按 `step` 吸附到档位,`aria-valuetext` 报中文档位名。
点轨道(含拖拽)按 `step` 吸附到档位,`aria-valuetext` 报中文档位名;
浮层的开合与 Esc / 点外部关闭由模型菜单自己负责(本区不再自带触发钮与浮层)。
*/
const sliderSelect = (
<div className="project-chat-composer-strength">
{open ? (
<div
className="project-chat-composer-strength-popover"
role="group"
aria-label="思维强度"
>
<span
className="project-chat-composer-strength-model"
title={modelLabel || undefined}
>
{/* 只读展示:选模型仍在工具条那颗模型钮上,这里只回答「给哪个模型调强度」。 */}
{modelLabel || '模型'}
</span>
{/* 原生 range 的 role/值域本来就成立,这里再显式写一遍:`aria-valuetext` 是
中文档位名,读屏念的是「最高」而不是「4」。 */}
<input
type="range"
className="project-chat-composer-strength-range"
min={0}
max={options.length - 1}
step={1}
value={activeIndex}
aria-label="思维强度"
aria-valuemin={0}
aria-valuemax={options.length - 1}
aria-valuenow={activeIndex}
aria-valuetext={activeLabel}
disabled={disabled || saving}
onChange={(event) => {
const next = options[Number(event.currentTarget.value)];
if (next && next.value !== effort) {
selectEffort(next.value);
}
}}
/>
<span
className="project-chat-composer-strength-stops"
aria-hidden="true"
>
{options.map((option, index) => (
<span
key={option.value}
className={index === activeIndex ? 'is-active' : undefined}
/>
))}
</span>
<span className="project-chat-composer-strength-value">
{`当前:${activeLabel}`}
</span>
</div>
) : null}
{/* 触发钮用固定文案(不显示当前档位):档位在 popover 里看,控件保持一颗的宽度。 */}
<button
type="button"
className="conversation-model-trigger project-chat-composer-strength-trigger"
aria-label={`选择强度,当前 ${activeLabel}`}
title={`选择思维强度(当前:${activeLabel})`}
aria-expanded={open}
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>
{/* 原生 range 的 role/值域本来就成立,这里再显式写一遍:`aria-valuetext` 是
中文档位名,读屏念的是「最高」而不是「4」。 */}
<input
type="range"
className="project-chat-composer-strength-range"
min={0}
max={options.length - 1}
step={1}
value={activeIndex}
aria-label="思维强度"
aria-valuemin={0}
aria-valuemax={options.length - 1}
aria-valuenow={activeIndex}
aria-valuetext={activeLabel}
disabled={disabled || saving}
onClick={() => setOpen((current) => !current)}
>
<span
className="conversation-model-trigger-status"
aria-hidden="true"
/>
<span className="conversation-model-trigger-label">选择强度</span>
<ChevronDown size={13} aria-hidden="true" />
</button>
</div>
onChange={(event) => {
const next = options[Number(event.currentTarget.value)];
if (next && next.value !== effort) {
selectEffort(next.value);
}
}}
/>
<span className="project-chat-composer-strength-stops" aria-hidden="true">
{options.map((option, index) => (
<span
key={option.value}
className={index === activeIndex ? 'is-active' : undefined}
/>
))}
</span>
</span>
);
return (
<span className="project-chat-reasoning-effort" ref={popoverRef}>
{presentation === 'slider' ? sliderSelect : dropdownSelect}
<span className="project-chat-reasoning-effort">
{presentation === 'section' ? strengthSection : dropdownSelect}
{notice ? (
<span role="status" className="project-chat-composer-notice">
{notice}
@@ -147,8 +147,6 @@ export function DirectProjectComposer({
null,
);
const modelNoticeIdRef = useRef(0);
/** 当前选中模型的显示名(目录没读完时是空串):只在强度浮层里只读展示。 */
const [modelLabel, setModelLabel] = useState('');
const [modelReady, setModelReady] = useState(false);
const [modelValidating, setModelValidating] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -301,11 +299,6 @@ export function DirectProjectComposer({
/>
</div>
<div className="project-chat-composer-controls-right">
<ComposerReasoningEffortSelect
disabled={busy}
presentation="slider"
modelLabel={modelLabel}
/>
<ConversationModelSelect
ref={modelSelectRef}
disabled={busy}
@@ -313,7 +306,13 @@ export function DirectProjectComposer({
projectPath={projectPath ?? ''}
errorPresentation="toast"
onUnavailable={pushModelNotice}
onModelLabelChange={setModelLabel}
/* 强度滑块并进模型菜单:底行只留这一颗控件(少占一个位)。 */
strengthSection={
<ComposerReasoningEffortSelect
disabled={busy}
presentation="section"
/>
}
/>
<ComposerVoiceButton
disabled={busy || modelValidating}
@@ -1038,7 +1038,7 @@ export function registerChatComposerControlTests() {
).not.toBeNull();
});
it('puts the reasoning strength in a popover slider and persists only for later turns', async () => {
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) => {
@@ -1047,32 +1047,43 @@ export function registerChatComposerControlTests() {
},
});
// 触发钮固定文案「选择强度」:当前档位只在 aria-label / title 与浮层里出现。
const trigger = within(surface).getByRole('button', { name: /选择强度/ });
expect(trigger.textContent).toContain('选择强度');
await waitFor(() => {
expect(trigger.getAttribute('aria-label')).toContain('当前 高');
});
expect(trigger.getAttribute('title')).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();
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();
// 点开 popover:滑块报出 role/值域与中文档位名。
// 打开模型菜单:强度区在顶部,同一个菜单里还有模型列表。
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(slider.getAttribute('aria-valuetext')).toBe('高');
expect(within(surface).getByText('当前:高')).not.toBeNull();
const listbox = within(surface).getByRole('listbox', {
name: '对话模型',
});
// 滑块不能落进 listbox 子树:参数控件混进菜单角色会让读屏进菜单模式后读不到它。
expect(listbox.contains(slider)).toBe(false);
// 拖动 / 点档位走原生 range 的 change,落到与旧下拉同一条持久化通道与取值。
fireEvent.change(slider, { target: { value: '1' } });
@@ -1082,13 +1093,9 @@ export function registerChatComposerControlTests() {
{ effort: 'low' },
);
});
// 以落盘后的回读值为准;改档后浮层保持打开(连续调节不用重开)。
// 以落盘后的回读值为准;改档后菜单保持打开(连续调节不用重开)。
await waitFor(() => {
expect(
within(surface)
.getByRole('button', { name: /选择强度/ })
.getAttribute('aria-label'),
).toContain('当前 低');
expect(within(surface).getByText('当前:低')).not.toBeNull();
});
expect(stored).toBe('low');
expect(
@@ -1101,14 +1108,16 @@ export function registerChatComposerControlTests() {
),
).toBe(false);
// Esc 关闭;再点开、点外部也关闭。
// Esc 关掉整个菜单;再打开、点外部也关。
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => {
expect(
within(surface).queryByRole('slider', { name: '思维强度' }),
).toBeNull();
});
fireEvent.click(within(surface).getByRole('button', { name: /选择强度/ }));
fireEvent.click(
within(row as HTMLElement).getByRole('button', { name: '对话模型' }),
);
expect(
within(surface).getByRole('slider', { name: '思维强度' }),
).not.toBeNull();
@@ -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', {
@@ -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,
);
@@ -331,29 +331,38 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文
expect(paddingBottom).toBeLessThan(total);
});
it('思维强度:触发钮留在工具条同一排,档位收进 popover 的滑块(宽屏)', () => {
it('思维强度并入模型菜单:外壳向上展开、强度区在菜单顶部(宽屏)', () => {
const trigger = desktopDeclarations(
`${COMPOSER} .project-chat-composer-strength-trigger`,
COMPOSER_TRIGGER,
COMPOSER_TRIGGER_BASE,
);
const anchor = desktopDeclarations(
`${COMPOSER} .project-chat-composer-strength`,
const select = desktopDeclarations(
`${COMPOSER_CONTROLS} .conversation-model-select`,
);
const popover = desktopDeclarations(
`${COMPOSER} .project-chat-composer-strength-popover`,
const shell = desktopDeclarations(
`${COMPOSER} .conversation-model-menu-shell`,
);
const menuInShell = desktopDeclarations(
`${COMPOSER} .conversation-model-menu-shell .conversation-model-menu`,
);
const section = desktopDeclarations(
`${COMPOSER} .project-chat-reasoning-effort.is-section`,
);
const range = desktopDeclarations(
`${COMPOSER} .project-chat-composer-strength-range`,
);
// 触发钮沿用工具条那颗 30px 控件的高度和类:工具条行高仍是 30(见上面的算式)。
expect(pixelValue(trigger, 'height')).toBe(30);
// 浮层锚在强度控件上、从工具条上方展开,不占工具条宽度;滑块占满浮层宽度。
expect(declaration(anchor, 'position')).toBe('relative');
expect(declaration(popover, 'position')).toBe('absolute');
expect(declaration(popover, 'bottom')).toBe('calc(100% + 8px)');
// 弹层锚在模型控件上、从工具条上方展开(与原来的模型菜单同一朝向,不盖输入框)。
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(declaration(menuInShell, 'position')).toBe('static');
expect(declaration(section, 'display')).toBe('grid');
expect(declaration(range, 'width')).toBe('100%');
// 底行没有第二颗强度控件:那一排的高度仍由模型触发钮(30px)决定,见上面的算式。
expect(pixelValue(trigger, 'height')).toBe(30);
});
it('窄屏(390px)输入盒仍在文档流、四周有边距,列表底部没有留白', () => {
@@ -10,13 +10,15 @@
- **判据/取证**:`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 输入盒的档位选择用 popover + 滑块,触发钮只占一颗控件宽
## 2026-10-04 输入盒的强度选择并入模型菜单,底行只留一颗控件
- 触发钮固定文案「选择强度」,当前档位只出现在 `aria-label` / `title` 与浮层里的「当前:最高」;滑块直接用原生 `<input type="range" min=0 max=4 step=1>`:`role="slider"`、`aria-valuemin/max/now` 与 ←/→/Home/End 都是原生行为,`aria-valuetext` 报中文档位名,点轨道或拖拽按 `step` 吸附到档位。
- 档位枚举、默认值、持久化通道(读 `read_game_creator_app_config`、写 `select_game_creator_reasoning_effort`)与旧下拉**完全一致**,只换表现形式;`ComposerReasoningEffortSelect` 的 `presentation` 默认 `'dropdown'`,策划输入盒继续走下拉(零语义变化,差分审计仍为 0 处声明变化)。
- 点档位后浮层**保持打开**(连续调节不用重开),关闭走 Esc 与点击外部;浮层几何沿用 `.project-chat-attachment-menu` 同一套约定,颜色只取 `--platform-*`。
- **判据/取证**:`tests/appSurface/chat-composer.suite.ts` 的滑块用例(打开 / 取值 / 持久化 / 关闭路径)+ `tests/chatDialogFrameLayout.test.ts` 的浮层几何断言。
- **关联**:`view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx`、`src/styles.css`。
- 目标口径(用户复核):**减少底部空间占用** —— 底行只能有模型触发钮一颗控件。思维强度不再单独占位,而是作为**强度区嵌在模型菜单顶部**(标题「思维强度」+「当前:最高」+ 原生 `<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`(策划菜单里没有滑块)。
- **关联**:`features/project-workspace/ConversationModelSelect.tsx`、`view/project-development/chat/components/DirectProjectComposer/{DirectProjectComposer.tsx,ComposerControls.tsx}`、`src/styles.css`。
## 2026-10-03 AGC 输入区控件叠在一起:输入区内的操作排与状态提示不能用绝对定位