复核反馈修复:思维强度改 popover 滑块,模型不可用改 toast(#600)
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m25s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m25s
Project CI / Repository checks (pull_request) Failing after 35s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m43s
Project CI / Frontend tests (pull_request) Successful in 2m49s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m51s
Project CI / Native shell tests (pull_request) Successful in 6m31s

- 思维强度:`ComposerReasoningEffortSelect` 新增 `presentation: 'dropdown' | 'slider'`(默认 dropdown,策划输入盒原样不动),Direct 输入盒走 slider —— 触发钮固定文案「选择强度」(当前档位在 `aria-label`/`title` 与浮层里的「当前:最高」),档位选择收进 popover 里的原生 `<input type="range" min=0 max=4 step=1>`:自带 `role="slider"`、`aria-valuemin/max/now` 与 ←/→/Home/End,`aria-valuetext` 报中文档位名,点轨道/拖拽按 step 吸附;Esc 与点击外部关闭,改档后浮层保持打开
- 档位枚举、默认值、持久化通道(`read_game_creator_app_config` / `select_game_creator_reasoning_effort`)与旧下拉完全一致,只换表现形式
- 模型不可用:`ConversationModelSelect` 新增 `errorPresentation: 'inline' | 'toast'`(默认 inline,首页/策划/画布不变)与 `onUnavailable`,toast 模式下不再渲染常驻 `[role='alert']`;Direct 输入盒由 `ComposerModelNoticeToast` 复用外壳同一枚 `PlatformRuntimeStatusToast`(bottom-center portal、6s)提示,失败消息新出现时一次、同条去重、清空后可再报
- 文案:`llmModelCatalog.ts` / `clientApi.ts` 的「本地模型服务不可用」改为「模型服务不可用」;不可用态照旧禁用发送,但不再留文字行
- 错位根因与改法:上一轮把模型提示收回控件排内使整颗控件变两行高、在 `align-items: center` 的工具条里把触发钮顶到行线上方;现在该元素不再渲染,`styles.css` 里对应的 composer 作用域 `[role='alert']` 规则按「删死重」删除
- `styles.css`:新增强度 popover / 滑块 / 档位点样式(几何沿用 `.project-chat-attachment-menu` 约定,颜色只取 `--platform-*`)
- 测试:`tests/appSurface/chat-composer.suite.ts` 的档位用例改为滑块(打开、aria 值域、持久化、Esc/点外部关闭),新增 toast 用例与「只报一次」用例;`tests/chatDialogFrameLayout.test.ts` 新增 popover 几何断言;策划面差分审计复跑 0 处声明变化(对收口前提交仍报 62 处,证明检查不空转)
- pitfalls 补两条:输入区不留常驻状态行(用 toast)、档位选择用 popover + 滑块
This commit is contained in:
2026-10-04 15:08:22 +08:00
parent 43afcfa163
commit 0cbf9521ee
9 changed files with 715 additions and 87 deletions
@@ -65,12 +65,28 @@ export function ConversationModelSelect({
onReady,
projectPath,
ref,
errorPresentation = 'inline',
onUnavailable,
onModelLabelChange,
}: {
className?: string;
disabled: boolean;
onReady?: (ready: boolean) => void;
projectPath?: string;
ref?: Ref<ConversationModelSelectHandle>;
/**
* 目录 / 选择失败的呈现方式。
*
* `inline`(默认)把文案渲染在控件里,供首页、策划输入盒、画布等宿主沿用;
* `toast` 不渲染常驻文案,只把消息交给宿主的 `onUnavailable`——输入盒那一排控件
* 容不下第二行文字:一旦在控件里排一行提示,整颗控件会变成两行高,在
* `align-items: center` 的工具条里把触发钮顶到行线上方(#600 复核实测的错位)。
*/
errorPresentation?: 'inline' | 'toast';
/** `errorPresentation: 'toast'` 时,对**新出现**的失败消息回调一次(同一条不重复)。 */
onUnavailable?: (message: string) => void;
/** 当前选中模型的显示名(目录没读完时是空串);供宿主在别处只读展示。 */
onModelLabelChange?: (label: string) => void;
}) {
const initialCatalog = cachedLlmModelCatalog();
const [models, setModels] = useState<ClientLlmModel[]>(
@@ -101,6 +117,36 @@ export function ConversationModelSelect({
onReadyRef.current = onReady;
}, [onReady]);
const onUnavailableRef = useRef(onUnavailable);
const onModelLabelChangeRef = useRef(onModelLabelChange);
useEffect(() => {
onUnavailableRef.current = onUnavailable;
onModelLabelChangeRef.current = onModelLabelChange;
}, [onModelLabelChange, onUnavailable]);
/** 已经报出去的失败消息:同一条只在「新出现」时报一次,失败清空后可以再报一次。 */
const reportedErrorRef = useRef('');
useEffect(() => {
if (errorPresentation !== 'toast') {
return;
}
if (error === '') {
reportedErrorRef.current = '';
return;
}
if (reportedErrorRef.current === error) {
return;
}
reportedErrorRef.current = error;
onUnavailableRef.current?.(error);
}, [error, errorPresentation]);
const selectedModelLabel =
models.find((model) => model.id === selected)?.displayName ?? '';
useEffect(() => {
onModelLabelChangeRef.current?.(selectedModelLabel);
}, [selectedModelLabel]);
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -396,7 +442,11 @@ export function ConversationModelSelect({
: 'conversation-model-select'
}
>
{error ? <span role="alert">{error}</span> : null}
{/* `toast` 呈现方式下这里不渲染任何常驻文案:控件排容不下第二行,
失败由宿主的浮层提示承担(见 `errorPresentation` 的说明)。 */}
{error && errorPresentation === 'inline' ? (
<span role="alert">{error}</span>
) : null}
{notice ? <span role="status">{notice}</span> : null}
<button
type="button"
@@ -25,7 +25,7 @@ type ClientLlmModelCatalogWire = Omit<ClientLlmModelCatalog, 'models'> & {
export async function loadClientLlmModels(): Promise<ClientLlmModelCatalog> {
const invoke = resolveTauriInvoke();
if (!invoke) throw new Error('本地模型服务不可用');
if (!invoke) throw new Error('模型服务不可用');
const catalog = await invoke<ClientLlmModelCatalogWire>(
'load_game_creator_llm_models',
);
@@ -23,7 +23,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 {
+207 -27
View File
@@ -13149,12 +13149,10 @@ details.design-agent-reasoning[open]
要么用更长的选择器链严格盖过前文,把整块挪到文件中间结果也不变。
① 工具栏里的下拉控件(推理档 / 模型)要能收缩、模型名要能省略,宽度不够时靠换行
让位,而不是把整组推到输入盒外面去和左边的 `@` `+` 重叠;
② 模型控件自己的状态提示(`ConversationModelSelect` 的 `[role='alert']` /
`[role='status']`)从"浮在输入区上方"改成"控件排内独占一行":它原来的
`bottom: 36px` 正好把错误文案画在输入内容区第 3~4 行文字上(实测 70×36px
与编辑器相交)——这条覆盖的是全局规则,靠更长的选择器链(多带
`.project-chat-composer-controls`)取胜,不依赖源码顺序;
③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距。 */
② 输入区不留常驻状态行:模型服务的失败走 `ComposerModelNoticeToast` 浮层提示
(控件排容不下第二行文字,实测会把触发钮顶到行线上方,见下面 ② 段);
③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距;
④ 思维强度改成 popover + 滑块(AGC 自己的浮层约定 + 原生 `<input type="range">`)。 */
/* ① 下拉控件:可收缩 + 组内换行;模型名超长走省略号。 */
.game-workbench-chat
@@ -13197,29 +13195,211 @@ details.design-agent-reasoning[open]
text-align: right;
}
/* ② 模型控件的错误 / 状态提示:从绝对定位浮层改为占一行,跟随控件换行。 */
/* ② 模型控件的错误 / 状态提示:**不在控件里渲染**。
这一段原来把 `.conversation-model-select [role='alert']` 从 `bottom: 36px` 浮层收回
控件排内独占一行;实测这样会让整颗模型控件变成两行高,在 `align-items: center` 的
工具条里把触发钮顶到行线上方、提示落到行线下方(用户复核反馈的"完全没对齐")。
现在 Direct 输入盒改用 `errorPresentation="toast"`:控件排内不再有这两颗元素,
本条规则恒无命中对象,已删;文案走输入区自己的浮层提示
(`ComposerModelNoticeToast`)。 */
/* ③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距:
队列 chip 与提示段落的 `margin` 都写回前文各自那条规则。 */
/* ④ 思维强度:触发钮固定文案「选择强度」,档位选择收进 popover 里的滑块。
popover 的定位 / 底色 / 描边 / 阴影沿用 `.project-chat-attachment-menu` 同一套浮层
约定,颜色全部走 `--platform-*`(不引入新色值);滑块是原生 `<input type="range">`,
自带 `role="slider"`、`aria-valuemin/max/now` 与 ←/→/Home/End,`aria-valuetext`
报中文档位名,点轨道或拖拽按 `step` 吸附到档位。 */
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-controls
.conversation-model-select
[role='alert'],
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-controls
.conversation-model-select
[role='status'] {
position: static;
order: 1;
flex: 1 1 100%;
.project-chat-composer-strength {
position: relative;
display: inline-flex;
align-items: center;
min-width: 0;
max-width: none;
margin-top: 2px;
padding: 0;
background: transparent;
font-size: 12px;
line-height: 1.4;
text-align: right;
overflow-wrap: anywhere;
}
/* 触发钮只比其它方钮宽一点(文案 + 箭头),不再显示当前档位。 */
.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 {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
z-index: 4;
display: grid;
gap: 8px;
min-width: 224px;
max-width: min(280px, calc(100vw - 40px));
padding: 10px 12px 12px;
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%);
pointer-events: auto;
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-model {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--platform-text-base);
font-size: 12px;
font-weight: 700;
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-range {
width: 100%;
height: 24px;
margin: 0;
appearance: none;
border: 0;
background: transparent;
cursor: pointer;
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-range::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: var(--platform-button-ghost-fill);
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-range::-webkit-slider-thumb {
width: 18px;
height: 18px;
margin-top: -6px;
appearance: none;
border: 2px solid var(--platform-button-primary-fill);
border-radius: 999px;
background: var(--platform-input-fill);
box-shadow: 0 1px 2px rgb(0 0 0 / 18%);
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-range:hover:not(
:disabled
)::-webkit-slider-thumb {
transform: scale(1.06);
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-range:active:not(
:disabled
)::-webkit-slider-thumb {
transform: scale(0.96);
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-range:focus-visible {
outline: none;
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-range:focus-visible::-webkit-slider-thumb {
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-range:disabled {
cursor: not-allowed;
opacity: 0.55;
}
/* Firefox 的滑块伪元素与 WebKit 不同名,同一套取值再来一份。 */
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-range::-moz-range-track {
height: 6px;
border-radius: 999px;
background: var(--platform-button-ghost-fill);
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-range::-moz-range-thumb {
width: 18px;
height: 18px;
border: 2px solid var(--platform-button-primary-fill);
border-radius: 999px;
background: var(--platform-input-fill);
box-shadow: 0 1px 2px rgb(0 0 0 / 18%);
}
/* 档位点:纯装饰(命中与吸附由原生 range 负责),个数跟着档位契约走,
所以用 `grid-auto-flow: column` 而不是写死 5 列。 */
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-stops {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
justify-items: center;
margin-top: -12px;
pointer-events: none;
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-stops
span {
width: 5px;
height: 5px;
border-radius: 999px;
background: var(--platform-surface-border);
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-stops
span.is-active {
background: var(--platform-button-primary-fill);
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-value {
color: var(--platform-text-soft);
font-size: 12px;
text-align: right;
}
@@ -18,6 +18,9 @@ import {
} from 'lucide-react';
import type { RefObject } from 'react';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
import type { GameCreationAppAssetManifestEntry } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
import { resolveTauriInvoke } from '../../../../../app/tauri';
@@ -310,8 +313,18 @@ export function ComposerVoiceButton({
*/
export function ComposerReasoningEffortSelect({
disabled,
presentation = 'dropdown',
modelLabel,
}: {
disabled: boolean;
/**
* `dropdown`(默认)保持原来的下拉列表,供策划输入盒沿用;
* `slider` 换成 popover + 滑块(Codex 观感),供 Direct 输入盒用——那一排容不下
* 第二个下拉,滑块把选择过程收进浮层,触发钮只占一颗控件的宽度。
*/
presentation?: 'dropdown' | 'slider';
/** `slider` 呈现方式下 popover 头部只读展示的当前模型名(目录没读完时为空)。 */
modelLabel?: string;
}) {
const [open, setOpen] = useState(false);
const [effort, setEffort] = useState<GameCreatorLlmReasoningEffort>(
@@ -321,7 +334,37 @@ 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,
options.findIndex((option) => option.value === effort),
);
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;
@@ -391,58 +434,140 @@ export function ComposerReasoningEffortSelect({
});
}
return (
<span className="project-chat-reasoning-effort">
{/* 与模型选择器同一套观感:复用 `conversation-model-*` 的触发钮与浮层样式,
不再用原生 `<select>`(原生下拉是操作系统菜单,圆角/描边/选中态都跟旁边不一致)。 */}
<div className="conversation-model-select">
<button
type="button"
className="conversation-model-trigger"
/*
下拉形态(策划等宿主原样沿用):与模型选择器同一套观感,复用 `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="推理档"
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled || saving}
onClick={() => setOpen((current) => !current)}
>
{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` 报中文档位名。
*/
const sliderSelect = (
<div className="project-chat-composer-strength">
{open ? (
<div
className="project-chat-composer-strength-popover"
role="group"
aria-label="思维强度"
>
<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="推理档"
className="project-chat-composer-strength-model"
title={modelLabel || undefined}
>
{options.map((option) => (
<button
{/* 只读展示:选模型仍在工具条那颗模型钮上,这里只回答「给哪个模型调强度」。 */}
{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}
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>
className={index === activeIndex ? 'is-active' : undefined}
/>
))}
</div>
) : null}
</div>
</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}
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>
);
return (
<span className="project-chat-reasoning-effort" ref={popoverRef}>
{presentation === 'slider' ? sliderSelect : dropdownSelect}
{notice ? (
<span role="status" className="project-chat-composer-notice">
{notice}
@@ -473,3 +598,66 @@ export function ComposerStopButton({
</button>
);
}
/** 模型服务不可用这类输入区失败的停留时长:与外壳的错误提示同一档(6s)。 */
const COMPOSER_MODEL_NOTICE_MILLIS = 6000;
export type ComposerModelNotice = {
/** 每次提示自增:同一条文案再出现时也会重新弹一次。 */
id: number;
message: string;
};
/**
* 输入区自己的浮层提示(模型服务不可用等)。
*
* 输入区**不留常驻状态行**:那一排控件容不下第二行文字,一旦在控件里排一行提示,
* 整颗控件会变成两行高、把触发钮顶到行线上方(#600 复核实测的错位)。所以失败只走
* 浮层——复用外壳同一枚 `PlatformRuntimeStatusToast` 与同一套定位/时长约定
* (见 `LauncherNoticeToast`、`TemplateLibraryToast`)。
*/
export function ComposerModelNoticeToast({
notice,
onDismiss,
}: {
notice: ComposerModelNotice | null;
onDismiss: () => void;
}) {
/** 收起回调只从 ref 读:宿主每次渲染都换箭头函数,写进依赖会让计时器反复重起。 */
const onDismissRef = useRef(onDismiss);
onDismissRef.current = onDismiss;
useEffect(() => {
if (!notice) {
return;
}
const timer = window.setTimeout(
() => onDismissRef.current(),
COMPOSER_MODEL_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="error"
surface="solid"
size="sm"
shape="pill"
className="shadow-lg"
>
{notice.message}
</PlatformRuntimeStatusToast>
</div>,
document.body,
);
}
@@ -27,10 +27,12 @@ import type { DirectPendingTurn } from '../../conversation/directPendingTurns';
import type { UserContentPart } from '../../generated/UserContentPart';
import {
ComposerAttachmentMenu,
ComposerModelNoticeToast,
ComposerReasoningEffortSelect,
ComposerStopButton,
ComposerTurnQueue,
ComposerVoiceButton,
type ComposerModelNotice,
} from './ComposerControls';
/** 草稿里已有的附件芯片数:附件上限按它算,不再另存一份待发列表。 */
@@ -114,6 +116,16 @@ export function DirectProjectComposer({
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
const modelValidateInFlightRef = useRef(false);
const [voiceNotice, setVoiceNotice] = useState('');
/*
模型服务的失败只走浮层提示:输入区那一排控件不留常驻状态行(会把它顶成两行高)。
同一条文案再次出现时也能再弹一次,所以按自增 id 记这一条。
*/
const [modelNotice, setModelNotice] = useState<ComposerModelNotice | null>(
null,
);
const modelNoticeIdRef = useRef(0);
/** 当前选中模型的显示名(目录没读完时是空串):只在强度浮层里只读展示。 */
const [modelLabel, setModelLabel] = useState('');
const [modelReady, setModelReady] = useState(false);
const [modelValidating, setModelValidating] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -166,6 +178,12 @@ export function DirectProjectComposer({
},
[onUploadFiles],
);
/** 记一条浮层提示:id 自增,保证重复文案也会重新弹一次并重起计时。 */
const pushModelNotice = useCallback((message: string) => {
modelNoticeIdRef.current += 1;
setModelNotice({ id: modelNoticeIdRef.current, message });
}, []);
// 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管),
// 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。
const submitButton = (
@@ -242,12 +260,19 @@ export function DirectProjectComposer({
/>
</div>
<div className="project-chat-composer-controls-right">
<ComposerReasoningEffortSelect disabled={busy} />
<ComposerReasoningEffortSelect
disabled={busy}
presentation="slider"
modelLabel={modelLabel}
/>
<ConversationModelSelect
ref={modelSelectRef}
disabled={busy}
onReady={setModelReady}
projectPath={projectPath ?? ''}
errorPresentation="toast"
onUnavailable={pushModelNotice}
onModelLabelChange={setModelLabel}
/>
<ComposerVoiceButton
disabled={busy || modelValidating}
@@ -284,6 +309,14 @@ export function DirectProjectComposer({
}
onOpenChange={setPickerOpen}
/>
{/*
输入区自己的浮层提示:模型服务不可用这类失败不留常驻文字行(那一排控件容不下
第二行,会把触发钮顶到行线上方)。组件内部 portal 到 body,与外壳的 toast 同一套。
*/}
<ComposerModelNoticeToast
notice={modelNotice}
onDismiss={() => setModelNotice(null)}
/>
</form>
);
}
@@ -1,5 +1,10 @@
import { userItemFromContent } from '../../src/features/project-workspace/resourceReferences';
import { ConversationModelSelect } from '../../src/features/project-workspace/ConversationModelSelect';
import * as platformSession from '../../src/services/platformSession';
import {
LlmModelCatalogConfigError,
resetLlmModelCatalogCacheForTest,
} from '../../src/services/llmModelCatalog';
import {
appendDictationText,
resolveSpeechRecognitionCtor,
@@ -1033,7 +1038,7 @@ export function registerChatComposerControlTests() {
).not.toBeNull();
});
it('moves the reasoning effort control next to the model selector and persists only for later turns', async () => {
it('puts the reasoning strength in a popover slider and persists only for later turns', async () => {
let stored = 'high';
const { invoke, surface } = await openDirectCodexSurface({
select_game_creator_reasoning_effort: (args) => {
@@ -1042,37 +1047,168 @@ export function registerChatComposerControlTests() {
},
});
const select = within(surface).getByRole('button', { name: '推理档' });
// 触发钮固定文案「选择强度」:当前档位只在 aria-label / title 与浮层里出现。
const trigger = within(surface).getByRole('button', { name: /选择强度/ });
expect(trigger.textContent).toContain('选择强度');
await waitFor(() => {
expect(select.textContent).toContain('高');
expect(trigger.getAttribute('aria-label')).toContain('当前 高');
});
// 档位就在模型选择器这一排(同一控制排容器里)。
expect(trigger.getAttribute('title')).toContain('当前:高');
// 触发钮就在模型选择器这一排(同一控制排容器里),关闭态没有滑块。
expect(
within(surface).getByRole('button', { name: '对话模型' }),
).not.toBeNull();
expect(select.closest('.project-chat-composer-controls')).not.toBeNull();
expect(trigger.closest('.project-chat-composer-controls')).not.toBeNull();
expect(
within(surface).queryByRole('slider', { name: '思维强度' }),
).toBeNull();
fireEvent.click(select);
fireEvent.click(within(surface).getByRole('option', { name: '低' }));
// 点开 popover:滑块报出 role/值域与中文档位名。
fireEvent.click(trigger);
const slider = within(surface).getByRole('slider', {
name: '思维强度',
}) as HTMLInputElement;
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();
// 拖动 / 点档位走原生 range 的 change,落到与旧下拉同一条持久化通道与取值。
fireEvent.change(slider, { target: { value: '1' } });
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'select_game_creator_reasoning_effort',
{ effort: 'low' },
);
});
// 以落盘后的回读值为准。
// 以落盘后的回读值为准;改档后浮层保持打开(连续调节不用重开)。
await waitFor(() => {
expect(
within(surface).getByRole('button', { name: '推理档' }).textContent,
).toContain('低');
within(surface)
.getByRole('button', { name: /选择强度/ })
.getAttribute('aria-label'),
).toContain('当前 低');
});
expect(stored).toBe('low');
expect(
within(surface).getByRole('slider', { name: '思维强度' }),
).not.toBeNull();
// 只影响后续回合:当前没有发出任何新一轮直接对话。
expect(
invoke.mock.calls.some(
([command]) => command === 'enqueue_direct_codex_turn',
),
).toBe(false);
// Esc 关闭;再点开、点外部也关闭。
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => {
expect(
within(surface).queryByRole('slider', { name: '思维强度' }),
).toBeNull();
});
fireEvent.click(within(surface).getByRole('button', { name: /选择强度/ }));
expect(
within(surface).getByRole('slider', { name: '思维强度' }),
).not.toBeNull();
fireEvent.mouseDown(document.body);
await waitFor(() => {
expect(
within(surface).queryByRole('slider', { name: '思维强度' }),
).toBeNull();
});
});
it('shows model service unavailability as a toast and leaves no persistent line', async () => {
const { surface } = await openDirectCodexSurface({
load_game_creator_llm_models: () => {
throw new LlmModelCatalogConfigError('模型服务不可用');
},
});
const toast = await waitFor(() => {
const element = document.querySelector(
'[data-composer-model-toast="true"]',
);
expect(element).not.toBeNull();
return element as HTMLElement;
});
expect(toast.textContent).toContain('模型服务不可用');
// 输入区里不留常驻文案、也没有 [role='alert']:失败只走浮层。
const composer = surface.querySelector(
'.project-chat-composer.is-direct-codex',
);
expect(composer).not.toBeNull();
expect(composer?.querySelector('[role="alert"]')).toBeNull();
expect(composer?.textContent).not.toContain('模型服务不可用');
expect(surface.textContent).not.toContain('本地模型服务不可用');
// 不可用态照旧挡住发送,但不靠一行说明文字表达。
expect(
(
within(surface).getByRole('button', {
name: '发送',
}) as HTMLButtonElement
).disabled,
).toBe(true);
});
it('reports one newly appeared model failure and never repeats the same message', async () => {
const onUnavailable = vi.fn();
let failing = true;
const invoke = vi.fn(async (command: string) => {
if (command === 'read_game_creator_app_config') {
return gameCreatorConfigView('high');
}
if (command === 'load_game_creator_llm_models') {
if (failing) {
throw new LlmModelCatalogConfigError('模型服务不可用');
}
return {
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量', agentMode: 'codex' },
],
revision: 7,
};
}
return null;
});
installTauriRuntime({
core: { invoke },
event: { listen: async () => () => undefined },
});
render(
React.createElement(ConversationModelSelect, {
disabled: false,
errorPresentation: 'toast',
onUnavailable,
}),
);
await waitFor(() => {
expect(onUnavailable).toHaveBeenCalledTimes(1);
});
expect(onUnavailable).toHaveBeenCalledWith('模型服务不可用');
// 同一条失败再次同步目录(打开下拉会触发一次)不重复报。
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
await waitFor(() => {
expect(onUnavailable).toHaveBeenCalledTimes(1);
});
// 失败清空后再出现,可以再报一次。
resetLlmModelCatalogCacheForTest();
failing = false;
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await waitFor(() => {
expect(onUnavailable).toHaveBeenCalledTimes(1);
});
resetLlmModelCatalogCacheForTest();
failing = true;
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await waitFor(() => {
expect(onUnavailable).toHaveBeenCalledTimes(2);
});
});
it('appends the dictated transcript after the draft typed into the composer', async () => {
@@ -331,6 +331,31 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文
expect(paddingBottom).toBeLessThan(total);
});
it('思维强度:触发钮留在工具条同一排,档位收进 popover 的滑块(宽屏)', () => {
const trigger = desktopDeclarations(
`${COMPOSER} .project-chat-composer-strength-trigger`,
COMPOSER_TRIGGER,
COMPOSER_TRIGGER_BASE,
);
const anchor = desktopDeclarations(
`${COMPOSER} .project-chat-composer-strength`,
);
const popover = desktopDeclarations(
`${COMPOSER} .project-chat-composer-strength-popover`,
);
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(range, 'width')).toBe('100%');
});
it('窄屏(390px)输入盒仍在文档流、四周有边距,列表底部没有留白', () => {
const conversation = mobileDeclarations(CONVERSATION);
const list = mobileDeclarations(...LIST_SELECTORS);
@@ -2,6 +2,22 @@
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
## 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 输入盒的档位选择用 popover + 滑块,触发钮只占一颗控件宽
- 触发钮固定文案「选择强度」,当前档位只出现在 `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`。
## 2026-10-03 AGC 输入区控件叠在一起:输入区内的操作排与状态提示不能用绝对定位
- **现象**:#600 截图里用户输入区域「元素堆叠」——空草稿时「AI 润色」按钮就压在输入内容区右下角(实测与编辑器相交 28×24px),模型控件的失败提示「本地模型服务不可用」直接画在第 3~4 行文字上(70×36px,长草稿时把整段文字盖住);面板越窄越糟:280px 面板下推理档整颗压在「添加文件」上(28×28px),「插入素材引用」与推理档也相交(20×28px)。