按 PR #607 复核意见修掉 9 项(D1/D2/D3/D4/D5/D6/D7/D8/D9)

- D1 档位点被轨道压住且与圆钮错位:容器改 `display: flex; justify-content: space-between; padding: 0 6.5px`(= 圆钮半径 10 − 半点宽 3.5),首末点中心因此落在圆钮两端极限位置;`z-index: 2` 抬到 range 之上;当前档位那一点 `opacity: 0`(圆钮已压在那儿);点改深暖底 `--platform-text-strong` + `--platform-input-highlight` 描边。无头 Chromium 实测(222px 轨道):5 个点中心与圆钮算式偏移全部 0.00px(旧实现 +12.19/+6.09/−0.02/−6.11/−12.20)、点与相邻轨道对比度 10.51~10.59:1(旧 1.21~1.23:1)、当前档位点被圆钮覆盖(中心差 ≤0.5px)
- D2 状态文案(`role="status"`)也会把那排控件顶成两行高:`ConversationModelSelect` 的 notice 与失败分开两条浮层通道(`onNotice` / `onUnavailable`,各自去重),`ComposerReasoningEffortSelect` 加 `noticePresentation: 'inline' | 'toast'` + `onNotice`;`toast` 形态下两种常驻行都不渲染,状态浮层用 `tone="neutral"`、失败仍 `tone="error"`。Direct 用 `toast`,首页/策划/画布默认 `inline` 行为不变
- D4 `forced-colors` 下焦点完全不可见(thumb 的 box-shadow 会被系统丢弃):加 `@media (forced-colors: active)` 回退 `outline: 2px solid Highlight; outline-offset: 2px`(系统色,亮色观感不变)
- D5 键盘长按每个事件都落盘:`selectEffort` 比对「最近一次落盘成功的档位」(`persistedEffortRef`,挂载回读时种上),相同不写。真实按键序列实测:单击 1 次;长按 1.5s(34 个重复事件)4 次(每个新档位一次);已在最高档继续长按 1.2s(28 个事件)0 次(旧行为 ~30 次/秒)
- D6 拖动中途卸载会丢档位:卸载 cleanup 里 flush 一次预览值(读 ref,不碰已卸载组件的 state)。实测:拖到「中」未松手落盘 0 次,`unmount()` 后恰好 1 次;单次拖动(按下 + 6 步横移 + 松手)全程 1 次且终值 = 松手位置
- D7 文档与实现对齐:pitfalls 里 `--strength-progress` → `--strength-ratio`、圆钮改实心 `--platform-panel-fill` + `--platform-subpanel-border` 1px 描边、输入盒最高高度 254 → 256(工具栏行高由 30px 模型钮决定)、「提示改成控件排内独占一行」按现行浮层口径改写
- D8 去掉 10 处新硬编码色值 `rgb(112 57 30 / x%)`:卡片投影改用既有 token `--platform-desktop-hover-shadow`,圆钮/焦点影由 `color-mix(in srgb, var(--platform-accent) …)` 派生,不新增 token
- D9 注释与实现不一致:`.resource-reference-input` 的 `min-height: 0 / padding: 0` 写回前面那条六类规则(不再依赖源码顺序),末尾那条只留网格与行距,注释同步改成「不依赖源码先后」
- D3 深色主题焦点环间隙不可靠:只在 pitfalls 记录(深色 `--platform-input-fill` 仅 5% 不透明;工作台根节点固定 `platform-theme--light`,暂不可达)
- 测试:`chat-composer.suite.ts` 新增「notice 走浮层且输入区无 `[role=status]`/`[role=alert]`」「键盘长按同一档位只写一次、换档仍写」「拖动中途卸载恰好提交一次」;`chatDialogFrameLayout.test.ts` 新增档位点对齐/层级/激活点隐藏的声明级断言
- 校验:相关 vitest(chat-composer / chatDialogFrameLayout / design-agent / project-development)、typecheck、eslint、prettier、encoding、spacetime-schema、diff-check

Refs #600, PR #607
This commit is contained in:
2026-10-05 15:59:10 +08:00
parent 4227724b33
commit d451415edc
7 changed files with 377 additions and 41 deletions
@@ -67,6 +67,7 @@ export function ConversationModelSelect({
ref,
errorPresentation = 'inline',
onUnavailable,
onNotice,
strengthSection,
}: {
className?: string;
@@ -85,6 +86,13 @@ export function ConversationModelSelect({
errorPresentation?: 'inline' | 'toast';
/** `errorPresentation: 'toast'` 时,对**新出现**的失败消息回调一次(同一条不重复)。 */
onUnavailable?: (message: string) => void;
/**
* 状态文案(「模型列表已刷新」「正在刷新模型列表」「默认模型已停用」等)的浮层通道。
*
* 只在 `errorPresentation: 'toast'` 时回调,且与失败**分开**:语气不同(状态不是失败)、
* 去重名额也各算各的。`toast` 模式下没有常驻 `role="status"`,输入区不再被一行状态文字顶高。
*/
onNotice?: (message: string) => void;
/**
* 菜单顶部的附加区(Direct 输入盒用来放「思维强度」滑块)。
*
@@ -146,6 +154,31 @@ export function ConversationModelSelect({
onUnavailableRef.current?.(error);
}, [error, errorPresentation]);
/**
* 状态文案(「模型列表已刷新」「默认模型已停用」等)在 toast 形态下走**另一条**通道:
* 它和失败的语气不同(失败是 error,状态是 neutral),报告也不该占用失败的"同一条只报一次"
* 名额。输入区子树因此不再有常驻 `role="status"`。
*/
const onNoticeRef = useRef(onNotice);
useEffect(() => {
onNoticeRef.current = onNotice;
}, [onNotice]);
const reportedNoticeRef = useRef('');
useEffect(() => {
if (errorPresentation !== 'toast') {
return;
}
if (notice === '') {
reportedNoticeRef.current = '';
return;
}
if (reportedNoticeRef.current === notice) {
return;
}
reportedNoticeRef.current = notice;
onNoticeRef.current?.(notice);
}, [errorPresentation, notice]);
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -493,7 +526,10 @@ export function ConversationModelSelect({
{error && errorPresentation === 'inline' ? (
<span role="alert">{error}</span>
) : null}
{notice ? <span role="status">{notice}</span> : null}
{/* 状态文案同理:Direct 输入区不留常驻 `role="status"`,一并交给浮层通道。 */}
{notice && errorPresentation === 'inline' ? (
<span role="status">{notice}</span>
) : null}
<button
type="button"
className="conversation-model-trigger"
+45 -27
View File
@@ -11519,8 +11519,10 @@ button.design-workspace-tree__entry:hover,
.project-chat-composer.is-direct-codex
.resource-reference-input {
width: 100%;
min-height: 56px;
padding: 0 0 4px;
/* 盒子几何(高度下界 / 内边距)在这一条里定死:后面那条只补网格与行距,
不再重复声明,避免"同权重靠后写取胜"的顺序依赖。 */
min-height: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
@@ -12911,9 +12913,9 @@ button.design-workspace-tree__entry:hover,
/* ============================================================
右侧对话面板:输入盒内文字与控件的对齐(2026-09)
============================================================
这一组必须放在文件最后:输入区的网格结构与「AI 润色」按钮的位置由更早的
`.resource-reference-input` / `.resource-reference-input-actions` 规则决定,
同特异性下只有靠后写的才能盖回去。
这一组是「文字 + 区内操作」两行网格的**唯一来源**,盒子几何(内边距 / 高度下界)
则由前面那条六类 `.… .resource-reference-input` 规则定死:两边不再声明同一个属性,
所以**不依赖源码先后**(放在文件末尾只是为了集中阅读与就近维护)。
输入区自己就是「文字 + 区内操作」的两行网格:
第一行是编辑器(横跨整宽,文字用满盒内宽度);
@@ -12931,8 +12933,6 @@ button.design-workspace-tree__entry:hover,
grid-template-rows: auto auto;
align-content: start;
gap: 6px 8px;
min-height: 0;
padding: 0;
}
/* 编辑器横跨两列:文字不再被右侧 28px 的操作列长期挤窄。 */
@@ -13494,9 +13494,8 @@ details.design-agent-reasoning[open]
border: 1px solid var(--platform-line-soft);
border-radius: 16px;
background: var(--platform-input-fill);
box-shadow:
0 18px 40px rgb(112 57 30 / 14%),
0 2px 8px rgb(112 57 30 / 6%);
/* 卡片阴影用既有 token(同族暖色),不新造色值。 */
box-shadow: var(--platform-desktop-hover-shadow);
pointer-events: auto;
}
@@ -13711,9 +13710,10 @@ details.design-agent-reasoning[open]
`--platform-input-highlight`——后者浅色主题只有 90% 不透明,下面的已选段会透出来,
看起来就是个"靶心"。不再画中心点、也不用主色描边成空心环。 */
background: var(--platform-panel-fill);
/* 圆钮柔影:从品牌主色派生(`color-mix`,不新造 token)。 */
box-shadow:
0 2px 6px rgb(112 57 30 / 24%),
0 1px 2px rgb(112 57 30 / 16%);
0 2px 6px color-mix(in srgb, var(--platform-accent) 24%, transparent),
0 1px 2px color-mix(in srgb, var(--platform-accent) 16%, transparent);
}
.game-workbench-chat
@@ -13723,10 +13723,10 @@ details.design-agent-reasoning[open]
:disabled
)::-webkit-slider-thumb {
transform: scale(1.06);
/* 悬停把暖影压深一档(实心钮靠影子浮起来,不能只靠描边)。 */
/* 悬停把暖影压深一档(同族派生,不新造色值)。 */
box-shadow:
0 3px 9px rgb(112 57 30 / 30%),
0 1px 3px rgb(112 57 30 / 20%);
0 3px 9px color-mix(in srgb, var(--platform-accent) 30%, transparent),
0 1px 3px color-mix(in srgb, var(--platform-accent) 20%, transparent);
}
.game-workbench-chat
@@ -13756,7 +13756,7 @@ details.design-agent-reasoning[open]
box-shadow:
0 0 0 2px var(--platform-input-fill),
0 0 0 4px var(--platform-input-focus-ring),
0 2px 6px rgb(112 57 30 / 24%);
0 2px 6px color-mix(in srgb, var(--platform-accent) 24%, transparent);
}
.game-workbench-chat
@@ -13797,8 +13797,20 @@ details.design-agent-reasoning[open]
border-radius: 999px;
background: var(--platform-panel-fill);
box-shadow:
0 2px 6px rgb(112 57 30 / 24%),
0 1px 2px rgb(112 57 30 / 16%);
0 2px 6px color-mix(in srgb, var(--platform-accent) 24%, transparent),
0 1px 2px color-mix(in srgb, var(--platform-accent) 16%, transparent);
}
/* 高对比(forced-colors)模式下 thumb 的 box-shadow 会被系统丢弃,焦点会完全不可见,
这里回退到系统色 outline;只在 forced-colors 生效,亮色观感不受影响。 */
@media (forced-colors: active) {
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-range:focus-visible {
outline: 2px solid Highlight;
outline-offset: 2px;
}
}
/* Firefox 的焦点:先关掉轨道自带的 focus-outer 描边,再给圆钮同一套「间隙 + 环」。 */
@@ -13816,11 +13828,13 @@ details.design-agent-reasoning[open]
box-shadow:
0 0 0 2px var(--platform-input-fill),
0 0 0 4px var(--platform-input-focus-ring),
0 2px 6px rgb(112 57 30 / 24%);
0 2px 6px color-mix(in srgb, var(--platform-accent) 24%, transparent);
}
/* 档位点:纯装饰(命中与吸附由原生 range 负责),个数跟着档位契约走,
所以用 `grid-auto-flow: column` 而不是写死 5 列。 */
/* 档位点:叠在轨道**之上**(z-index 高于 input),横向跟圆钮用同一套算式——
两侧内边距取「圆钮半径 − 半点宽」,`space-between` 让首末点的**中心**正好落在
圆钮两端的极限位置,中间各点等距重合;当前档位那一点隐藏(圆钮已经压在那里,
再画点就会变成"靶心")。 */
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
@@ -13829,11 +13843,13 @@ details.design-agent-reasoning[open]
top: 50%;
right: 0;
left: 0;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
z-index: 2;
display: flex;
align-items: center;
justify-items: center;
justify-content: space-between;
/* 两侧内边距 = 圆钮半径 10px − 半点宽 3.5px(点 7px 见下):首末点的**中心**因此正好落在
圆钮两端的极限位置,中间各点等距重合。改点宽或钮径时这条与下面的宽高要一起改。 */
padding: 0 6.5px;
height: 6px;
transform: translateY(-50%);
pointer-events: none;
@@ -13844,11 +13860,13 @@ details.design-agent-reasoning[open]
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-stops
span {
box-sizing: border-box;
width: 7px;
height: 7px;
/* 点用深暖底、浅底描边:叠在暖灰轨道上看得到边界(对比度实测见 PR)。 */
border: 1.5px solid var(--platform-input-highlight);
border-radius: 999px;
background: var(--platform-text-muted);
background: var(--platform-text-strong);
}
.game-workbench-chat
@@ -13856,7 +13874,7 @@ details.design-agent-reasoning[open]
.project-chat-composer.is-direct-codex
.project-chat-composer-strength-stops
span.is-active {
background: var(--platform-accent);
opacity: 0;
}
.game-workbench-chat
@@ -313,6 +313,8 @@ export function ComposerVoiceButton({
export function ComposerReasoningEffortSelect({
disabled,
presentation = 'dropdown',
noticePresentation = 'inline',
onNotice,
}: {
disabled: boolean;
/**
@@ -321,6 +323,14 @@ export function ComposerReasoningEffortSelect({
* 模型菜单的顶部——底行因此只留一颗控件(模型触发钮),不再额外占位。
*/
presentation?: 'dropdown' | 'section';
/**
* 状态文案(推理档读取/保存失败)的呈现方式:`inline` 默认,渲染成控件里的
* `role="status"`;`toast` 不渲染常驻文案,交给宿主的 `onNotice` 走浮层
* (Direct 输入区不留常驻状态行)。
*/
noticePresentation?: 'inline' | 'toast';
/** `noticePresentation: 'toast'` 时,对新出现的状态文案回调一次(同条不重复)。 */
onNotice?: (message: string) => void;
}) {
const [open, setOpen] = useState(false);
const [effort, setEffort] = useState<GameCreatorLlmReasoningEffort>(
@@ -382,9 +392,12 @@ export function ComposerReasoningEffortSelect({
void invoke<GameCreatorAppConfigView>('read_game_creator_app_config')
.then((view) => {
if (cancelled || !mountedRef.current) return;
setEffort(
normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort),
const read = normalizeComposerReasoningEffort(
view?.config?.llm?.reasoningEffort,
);
// 回读值就是"磁盘上的档位":把它登记成最近落盘值,之后提交同一档位不再写盘。
persistedEffortRef.current = read;
setEffort(read);
})
.catch(() => {
if (cancelled || !mountedRef.current) return;
@@ -395,12 +408,24 @@ export function ComposerReasoningEffortSelect({
};
}, []);
/** 最近一次**落盘成功**的档位:重复提交同一档位不再写一次(键盘长按/多事件源)。 */
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('');
@@ -416,11 +441,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;
@@ -434,6 +461,47 @@ 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();
},
[],
);
/** 状态文案在 toast 形态下交给宿主浮层:同一条只报一次,清空后可再报。 */
const onNoticeRef = useRef(onNotice);
useEffect(() => {
onNoticeRef.current = onNotice;
}, [onNotice]);
const reportedNoticeRef = useRef('');
useEffect(() => {
if (noticePresentation !== 'toast') {
return;
}
if (notice === '') {
reportedNoticeRef.current = '';
return;
}
if (reportedNoticeRef.current === notice) {
return;
}
reportedNoticeRef.current = notice;
onNoticeRef.current?.(notice);
}, [notice, noticePresentation]);
/** 拖动中的连续事件只改预览:视觉立刻跟手,落盘留到松手那一刻。 */
function previewFromEvent(event: { currentTarget: HTMLInputElement }) {
const next = options[Number(event.currentTarget.value)];
@@ -578,7 +646,7 @@ export function ComposerReasoningEffortSelect({
return (
<span className="project-chat-reasoning-effort">
{presentation === 'section' ? strengthSection : dropdownSelect}
{notice ? (
{notice && noticePresentation === 'inline' ? (
<span role="status" className="project-chat-composer-notice">
{notice}
</span>
@@ -616,6 +684,11 @@ export type ComposerModelNotice = {
/** 每次提示自增:同一条文案再出现时也会重新弹一次。 */
id: number;
message: string;
/**
* 语气:失败(模型服务不可用 / 推理档保存失败)用 `error`,状态(模型列表已刷新等)用
* `neutral`。状态原本是控件里那行常驻 `role="status"`,它同样会把这排控件顶成两行高。
*/
tone: 'error' | 'neutral';
};
/**
@@ -659,7 +732,7 @@ export function ComposerModelNoticeToast({
data-composer-model-toast="true"
>
<PlatformRuntimeStatusToast
tone="error"
tone={notice.tone}
surface="solid"
size="sm"
shape="pill"
@@ -217,10 +217,27 @@ export function DirectProjectComposer({
}),
[],
);
/** 记一条浮层提示:id 自增,保证重复文案也会重新弹一次并重起计时。 */
/** 记一条失败浮层(error 语气):id 自增,保证重复文案也会重新弹一次并重起计时。 */
const pushModelNotice = useCallback((message: string) => {
modelNoticeIdRef.current += 1;
setModelNotice({ id: modelNoticeIdRef.current, message });
setModelNotice({
id: modelNoticeIdRef.current,
message,
tone: 'error',
});
}, []);
/**
* 记一条状态浮层(neutral 语气):模型列表刷新成功、推理档读取/保存的状态文案都走这里。
* 和失败分开是为了语气与去重名额:状态不是失败,也不该占用"同一条失败只报一次"的名额。
*/
const pushModelStatus = useCallback((message: string) => {
modelNoticeIdRef.current += 1;
setModelNotice({
id: modelNoticeIdRef.current,
message,
tone: 'neutral',
});
}, []);
// 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管),
@@ -306,11 +323,16 @@ export function DirectProjectComposer({
projectPath={projectPath ?? ''}
errorPresentation="toast"
onUnavailable={pushModelNotice}
/* 状态文案(刷新成功 / 默认模型变更)同样不留常驻行,走 neutral 浮层。 */
onNotice={pushModelStatus}
/* 强度滑块并进模型菜单:底行只留这一颗控件(少占一个位)。 */
strengthSection={
<ComposerReasoningEffortSelect
disabled={busy}
presentation="section"
/* Direct 输入区不留常驻状态行:推理档的状态文案也走同一浮层通道。 */
noticePresentation="toast"
onNotice={pushModelStatus}
/>
}
/>
@@ -13,7 +13,10 @@ import {
type SpeechRecognitionLike,
VOICE_INPUT_UNSUPPORTED_MESSAGE,
} from '../../src/view/project-development/chat/components/DirectProjectComposer/chatComposerVoice.ts';
import { ComposerVoiceButton } from '../../src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx';
import {
ComposerReasoningEffortSelect,
ComposerVoiceButton,
} from '../../src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx';
import { pendingTurnChipLabel } from '../../src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts';
import {
type DirectPendingTurn,
@@ -1182,6 +1185,100 @@ export function registerChatComposerControlTests() {
expect(stored).toBe('medium');
});
it('strength slider: keyboard auto-repeat on one grade writes once, a new grade writes again', async () => {
// 长按 ←/→ 时浏览器会以 ~30/s 的频率重复派发 input + keyup;旧实现在每个 input 都落盘
// (整条写链 + 回读),这里钉住"同一个已落盘档位的重复提交不再写盘",同时不吞掉真实改档。
const writes: string[] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'select_game_creator_reasoning_effort') {
writes.push(String(args?.effort ?? ''));
return gameCreatorConfigView(String(args?.effort ?? ''));
}
if (command === 'read_game_creator_app_config') {
return gameCreatorConfigView('default');
}
return null;
},
);
installTauriRuntime({
core: { invoke },
event: { listen: async () => () => undefined },
});
render(
React.createElement(ComposerReasoningEffortSelect, {
disabled: false,
presentation: 'section',
}),
);
const slider = (await screen.findByRole('slider', {
name: '思维强度',
})) as HTMLInputElement;
// 第一次改到「中」:拖动/按键松手提交一次。
fireEvent.input(slider, { target: { value: '2' } });
fireEvent.keyUp(slider);
await waitFor(() => {
expect(writes).toEqual(['medium']);
});
// 长按继续:同一档位的重复 input + keyup(中间隔着写盘回读)不再落盘。
for (let repeat = 0; repeat < 8; repeat += 1) {
fireEvent.input(slider, { target: { value: '2' } });
fireEvent.keyUp(slider);
}
await act(async () => {
await Promise.resolve();
});
expect(writes, '同一档位的重复提交只能写一次').toEqual(['medium']);
// 换一档仍然照常写:去重不能把真实变化吞掉。
fireEvent.input(slider, { target: { value: '4' } });
fireEvent.keyUp(slider);
await waitFor(() => {
expect(writes).toEqual(['medium', 'max']);
});
});
it('strength slider: flushes the previewed grade once when the control unmounts mid-drag', async () => {
// 拖动中被 Esc 关掉菜单 / 宿主卸载时,预览值还没落盘:卸载 cleanup 必须主动 flush 一次。
const writes: string[] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'select_game_creator_reasoning_effort') {
writes.push(String(args?.effort ?? ''));
return gameCreatorConfigView(String(args?.effort ?? ''));
}
if (command === 'read_game_creator_app_config') {
return gameCreatorConfigView('default');
}
return null;
},
);
installTauriRuntime({
core: { invoke },
event: { listen: async () => () => undefined },
});
const view = render(
React.createElement(ComposerReasoningEffortSelect, {
disabled: false,
presentation: 'section',
}),
);
const slider = (await screen.findByRole('slider', {
name: '思维强度',
})) as HTMLInputElement;
// 拖动到「中」但还没松手(只有 input,没有 change / keyup):此刻不该落盘。
fireEvent.input(slider, { target: { value: '2' } });
expect(writes).toEqual([]);
view.unmount();
await waitFor(() => {
expect(writes, '卸载 flush 只写一次').toEqual(['medium']);
});
});
it('shows model service unavailability as a toast and leaves no persistent line', async () => {
const { surface } = await openDirectCodexSurface({
load_game_creator_llm_models: () => {
@@ -1215,6 +1312,43 @@ export function registerChatComposerControlTests() {
).toBe(true);
});
it('routes model-list notices to the toast and keeps the Direct composer free of status rows', async () => {
// 刷新模型列表会写一条 notice(「模型列表已刷新」)。toast 形态下它也必须走浮层:
// 输入区子树里既不能留常驻 `role="alert"`(失败),也不能留常驻 `role="status"`(状态)。
const { surface } = await openDirectCodexSurface({
load_game_creator_llm_models: () => ({
defaultModelId: 'quality',
models: [{ id: 'quality', displayName: '高质量', agentMode: 'codex' }],
revision: 23,
}),
});
fireEvent.click(within(surface).getByRole('button', { name: '对话模型' }));
fireEvent.click(
within(surface).getByRole('button', { name: '刷新模型列表' }),
);
const toast = await waitFor(() => {
const element = document.querySelector(
'[data-composer-model-toast="true"]',
);
expect(element?.textContent).toContain('模型列表已刷新');
return element as HTMLElement;
});
// 浮层挂在输入区之外(portal 到 body)。
expect(toast.closest('.project-chat-composer.is-direct-codex')).toBeNull();
const composer = surface.querySelector(
'.project-chat-composer.is-direct-codex',
);
expect(composer).not.toBeNull();
expect(composer?.querySelector('[role="status"]')).toBeNull();
expect(composer?.querySelector('[role="alert"]')).toBeNull();
expect(composer?.textContent).not.toContain('模型列表已刷新');
// 目录缓存是模块级的:这条用例装配过一份成功目录,不清掉会漏给后面的用例。
resetLlmModelCatalogCacheForTest();
});
it('reports one newly appeared model failure and never repeats the same message', async () => {
const onUnavailable = vi.fn();
let failing = true;
@@ -159,6 +159,12 @@ 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`;
/** 强度滑块与它那排装饰档位点(issue #600 的复核点:点被轨道压住、横向与圆钮错位)。 */
const STRENGTH_RANGE = `${COMPOSER} .project-chat-composer-strength-range`;
const STRENGTH_THUMB = `${STRENGTH_RANGE}::-webkit-slider-thumb`;
const STRENGTH_STOPS = `${COMPOSER} .project-chat-composer-strength-stops`;
const STRENGTH_STOPS_DOT = `${STRENGTH_STOPS} span`;
const STRENGTH_STOPS_DOT_ACTIVE = `${STRENGTH_STOPS} span.is-active`;
const TOPBAR = `${CHAT} .project-chat-topbar`;
/** 列表实际会命中的全部选择器:direct-codex 直连与基础规则。 */
const LIST_SELECTORS = [MESSAGE_LIST, MESSAGE_LIST_PLAIN];
@@ -365,6 +371,43 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文
expect(pixelValue(trigger, 'height')).toBe(30);
});
it('档位点叠在轨道之上、横向与圆钮同一套算式,当前档位那一点留给圆钮(宽屏)', () => {
const range = desktopDeclarations(STRENGTH_RANGE);
const thumb = desktopDeclarations(STRENGTH_THUMB);
const stops = desktopDeclarations(STRENGTH_STOPS);
const dot = desktopDeclarations(STRENGTH_STOPS_DOT);
const activeDot = desktopDeclarations(STRENGTH_STOPS_DOT_ACTIVE);
// 点必须画在轨道**之上**:range 自己是 `z-index: 1`,点得比它高才不会被 6px 轨道压掉。
expect(declaration(stops, 'position')).toBe('absolute');
expect(
Number(declaration(stops, 'z-index')),
'档位点要在轨道(range 的 z-index)之上',
).toBeGreaterThan(Number(declaration(range, 'z-index')));
// 横向跟圆钮共用一套算式:`space-between` + 两侧「圆钮半径 − 半点宽」的内边距,
// 首末点的**中心**落点 = 圆钮两端极限位置(= 轨道两端内缩一个半径)。
expect(declaration(stops, 'justify-content')).toBe('space-between');
const knobRadius = pixelValue(thumb, 'width') / 2;
const dotHalf = pixelValue(dot, 'width') / 2;
const stopsPadding = paddingBox(stops);
expect(
stopsPadding.left,
'首点的中心要落在圆钮的左极限位置(改点宽/钮径都要同步这条算式)',
).toBe(knobRadius - dotHalf);
expect(stopsPadding.right).toBe(knobRadius - dotHalf);
// 当前档位那一点不画:圆钮已经压在那儿,再画出来就是"靶心"。
expect(pixelValue(activeDot, 'opacity')).toBe(0);
// 点的填充与描边都来自暖色 token(对比度数值在真机夹具里量,见 PR 正文)。
expect(declaration(dot, 'background')).toContain(
'var(--platform-text-strong)',
);
expect(declaration(dot, 'border')).toContain(
'var(--platform-input-highlight)',
);
});
it('窄屏(390px)输入盒仍在文档流、四周有边距,列表底部没有留白', () => {
const conversation = mobileDeclarations(CONVERSATION);
const list = mobileDeclarations(...LIST_SELECTORS);
+13 -3
View File
@@ -2,6 +2,16 @@
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
## 2026-10-05 PR #607 复核修复:档位点对齐/对比度、状态文案也走浮层、键盘去重、卸载 flush
- **档位点被轨道压住且与圆钮错位(D1)**:旧实现把 5 个点放进 `grid-auto-columns: 1fr`,点中心落在 22.19 / 66.59 / 110.98 / 155.39 / 199.80,而圆钮中心只在 `10 + ratio * (W - 20)`(10 / 60.5 / 111 / 161.5 / 212)——两端各差 **±12.2px**;且点画在 range(`z-index: 1`)**下面**,实测点中心像素被 6px 轨道盖成轨道色,点填充与相邻轨道对比度只有 **1.21:1**。现行口径:容器 `display: flex; justify-content: space-between; padding: 0 6.5px`(6.5 = 圆钮半径 10 − 半点宽 3.5),首末点的**中心**因此正好落在圆钮两端极限位置;容器 `z-index: 2`(高于 range)把点画到轨道之上;**当前档位那一点 `opacity: 0`**(圆钮已经压在那儿,再画就是"靶心");点 7px、`background: var(--platform-text-strong)`、`border: 1.5px solid var(--platform-input-highlight)`。实测(无头 Chromium / 222px 轨道):5 个点中心与圆钮算式偏移 **全部 0.00px**(旧实现 +12.19/+6.09/−0.02/−6.11/−12.20),点中心像素 `rgb(61,31,16)`、与相邻轨道对比度 **10.51~10.59:1**(旧 1.21~1.23:1);当前档位那一点被圆钮覆盖(点中心像素 = 圆钮填充色,圆钮中心与点中心差 ≤0.5px)。
- **状态文案(`role="status"`)也会顶高那一排(D2)**:`notice`(「模型列表已刷新」「正在刷新模型列表」「默认模型已停用」、推理档读取/保存失败)原先无条件渲染成控件里的 `role="status"`,`toast` 形态只接管了 `error`,于是 Direct 输入区仍会被一行状态文字顶成两行高。现行口径:`toast` 形态下失败与状态**分两条通道**(`onUnavailable` / `onNotice`,各自去重、清空后可再报),状态用 `tone="neutral"`、失败用 `tone="error"`;`errorPresentation === 'toast'` 时两种常驻行都不渲染。判据:`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-04 输入区不放常驻状态行:那一排控件容不下第二行文字
- **现象**:AGC 工作台输入区里模型失败提示常驻一行红字,工具条上还浮着一个半透明的「模型 ⌄」压在 `@ + 默认 ⌄` 那一排上;用户复核口径是"完全没对齐",并要求改成 toast。
@@ -18,7 +28,7 @@
- 关掉旧的独立触发钮(底行元素 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`)+ 柔和暖色投影(`rgb(112 57 30 / …)`,不用纯黑)、**不画横贯分隔线**(只靠留白);强度区标题 `--platform-text-strong` 12/600、右侧当前档位 `--platform-text-soft` 11px;轨道 6px 圆头,已选段 `--platform-accent`、未选段 `--platform-subpanel-border`(进度由组件写进来的 `--strength-progress` 驱动渐变),圆钮 20px(`--platform-input-highlight` 底 + `--platform-accent` 2px 描边 + 柔影),档位点 7px 绝对定位叠在轨道中线(`pointer-events: none`,不挡命中);模型列表行高 34px、行圆角 9px、hover `--platform-nav-item-icon-fill`、选中 `--platform-warm-bg` 轻量 pill + 右侧对勾;列表 `max-height: 170px`(=5×34,不出现半行),滚动条 6px 且平时透明、悬停卡片才显形。
- **视觉口径(用户复核二次要求)**:卡片与滑块的观感照 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`。
@@ -27,8 +37,8 @@
- **现象**:#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 润色 / 恢复原文」操作排),操作排 `position: static` 回到行里;模型提示改成控件排内独占一行(`position: static; flex: 1 1 100%`),不再浮在输入区上方;工具栏允许换行、右侧组改 `flex: 1 1 auto`,模型钮封顶 160px 且名字走省略号(收缩由它承担,推理档 `flex: 0 0 auto` 不参与收缩,避免把「默认」截成半个字)。
- **判据/取证**:`apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts` 用层叠求值钉住三层几何与操作排 `grid-row: 2 / position: static`、输入盒最高高度算式(12+160+6+28+0+8+28+12 = 254);`tests/appSurface/project-development.suite.ts` 钉住 composer 的 `gap: 8px`。真实渲染对照见 #600 PR:同一夹具在 495/438/360/296/280px 面板 × 空 / 长多行 / 队列+提示 四态下,改前 2~4 处相交(含文字被提示盖住),改后 0 处相交、0 处溢出。
- **处理(现行口径)**:`.project-chat-composer.is-direct-codex` 是单列网格(`gap: 8px`),三层自上而下——输入内容区 / 工具栏 / 状态提示区;输入内容区自己是两行网格(编辑器横跨两列,第二行左放润色状态、右放「AI 润色 / 恢复原文」操作排),操作排 `position: static` 回到行里;工具栏允许换行、右侧组改 `flex: 1 1 auto`,模型钮封顶 160px 且名字走省略号(收缩由它承担,推理档 `flex: 0 0 auto` 不参与收缩,避免把「默认」截成半个字)。**模型失败提示后来改成走浮层**(见本文件顶部 2026-10-04 / 2026-10-05 两条):控件排里既不留常驻红字、也不留常驻状态文字——「控件排内独占一行」那次快照已退役,行高回到由模型触发钮决定。
- **判据/取证**:`apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts` 用层叠求值钉住三层几何与操作排 `grid-row: 2 / position: static`、输入盒最高高度算式(12 盒内上内边距 + 160 编辑器 + 6 输入区行距 + 28 操作排 + 0 输入区下内边距 + 8 输入盒与工具栏行距 + **30 工具栏行高(模型触发钮 30px 撑起来,发送/语音是 28px)** + 12 盒内下内边距 = **256**);`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 未重跑而假通过