按 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:
+37
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+80
-7
@@ -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"
|
||||
|
||||
+24
-2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user