模型不可用与模型状态一律走组件自带的底部 toast:删掉宿主分支
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m40s
Project CI / Backend tests (pull_request) Failing after 21s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m34s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m52s
Project CI / Repository checks (pull_request) Failing after 29s
Project CI / Frontend tests (pull_request) Successful in 2m54s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m1s
Project CI / Native shell tests (pull_request) Successful in 9m1s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m40s
Project CI / Backend tests (pull_request) Failing after 21s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m34s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m52s
Project CI / Repository checks (pull_request) Failing after 29s
Project CI / Frontend tests (pull_request) Successful in 2m54s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m1s
Project CI / Native shell tests (pull_request) Successful in 9m1s
- 现象:`errorPresentation` 默认 `inline`,只有工作台那只传 `toast` + 宿主回调;首页/策划/画布的输入区里「模型服务不可用」还是**常驻占位文本**,不是浮层——同一个组件在不同宿主表现不一致 - 改法:浮层收进组件内部。新增 `components/common/composerNotice.ts`(模块级单例:`publishComposerNotice` + `useComposerNoticeHost`)与 `components/common/ComposerNoticeToast.tsx`(portal 到底部居中、6s 自动收起);`ConversationModelSelect` 与 `ComposerReasoningEffortSelect` 自己在失败/状态出现时报一条(各自去重:同一条只弹一次、清空后可再弹),失败 `tone="error"`、状态 `tone="neutral"` - 单宿主:同一输入区里两个调用点只有一个真正渲染(编号最小的那个),保证同一时刻只有一条浮层、不会互相叠;输入区整体卸载时把待显示的提示一并丢掉,不留给下一个宿主 - 删宿主分支:`errorPresentation` / `onUnavailable` / `onNotice` / `noticePresentation` 四个 prop 与工作台宿主的 toast state / `pushModelNotice` / `pushModelStatus` 一并删除(grep 命中 0);任何宿主的输入区子树里都不再有常驻 `role="alert"` / `role="status"` 行 - 语义不变:不可用时发送仍禁用(`!modelReady`);文案仍是「模型服务不可用」;档位持久化通道不变 - 测试:新增「首页模型不可用与状态也走组件自己的底部 toast(子树不留常驻行)」;`design-agent.suite` 的推理档保存失败改成断言底部 toast + 控件子树无 `role="status"`;`chat-composer.suite` 的失败去重用例改成断言 toast 节点(同一条不重复弹、清空后再出现换新节点) Refs #600, PR #607
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { COMPOSER_NOTICE_MILLIS, type ComposerNotice } from './composerNotice';
|
||||
|
||||
/**
|
||||
* 输入区浮层提示的本体(底部居中 portal、6s 自动收起)。
|
||||
*
|
||||
* 只由 `useComposerNoticeHost` 选中的宿主渲染;`data-composer-model-toast` 是各宿主用例的判据。
|
||||
*/
|
||||
export function ComposerNoticeToast({
|
||||
notice,
|
||||
onDismiss,
|
||||
}: {
|
||||
notice: ComposerNotice | null;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
/** 收起回调只从 ref 读:宿主每次渲染都换箭头函数,写进依赖会让计时器反复重起。 */
|
||||
const onDismissRef = useRef(onDismiss);
|
||||
onDismissRef.current = onDismiss;
|
||||
useEffect(() => {
|
||||
if (!notice) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setTimeout(
|
||||
() => onDismissRef.current(),
|
||||
COMPOSER_NOTICE_MILLIS,
|
||||
);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [notice]);
|
||||
|
||||
if (!notice) {
|
||||
return null;
|
||||
}
|
||||
return createPortal(
|
||||
<div
|
||||
key={notice.id}
|
||||
className="pointer-events-none fixed bottom-8 left-1/2 z-[1100] w-max max-w-[min(680px,calc(100vw-2rem))] -translate-x-1/2"
|
||||
data-composer-model-toast="true"
|
||||
>
|
||||
<PlatformRuntimeStatusToast
|
||||
tone={notice.tone}
|
||||
surface="solid"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className="shadow-lg"
|
||||
>
|
||||
{notice.message}
|
||||
</PlatformRuntimeStatusToast>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/** 浮层提示的停留时长:与外壳(`LauncherNoticeToast`)与模板库提示同一档(6s)。 */
|
||||
export const COMPOSER_NOTICE_MILLIS = 6000;
|
||||
|
||||
export type ComposerNotice = {
|
||||
/** 每次提示自增:同一条文案再出现时也会重新弹一次并重起计时。 */
|
||||
id: number;
|
||||
message: string;
|
||||
/**
|
||||
* 语气:失败(模型服务不可用 / 推理档保存失败)用 `error`;状态(模型列表已刷新、
|
||||
* 正在刷新模型列表、默认模型已停用等)用 `neutral`。
|
||||
*/
|
||||
tone: 'error' | 'neutral';
|
||||
};
|
||||
|
||||
/**
|
||||
* 输入区浮层提示的**模块级单例**:模型选择器与推理档控件都往这里发,宿主不必接任何回调。
|
||||
*
|
||||
* 为什么是单例而不是各自 portal:
|
||||
* 1. 输入区**不留常驻状态行**——那一排控件容不下第二行文字,一旦在控件里排一行提示,整颗
|
||||
* 控件会变成两行高、把触发钮顶到行线上方(#600 复核实测的错位);
|
||||
* 2. 同一个输入区里可能同时挂着模型选择器与推理档控件(首页、策划、工作台都一样),各自
|
||||
* portal 会在同一位置叠出两条 toast。这里由**编号最小的那个宿主**渲染,保证同一时刻
|
||||
* 只有一条。
|
||||
*/
|
||||
let notice: ComposerNotice | null = null;
|
||||
let sequence = 0;
|
||||
let hostSequence = 0;
|
||||
const hosts = new Map<number, () => void>();
|
||||
|
||||
function notifyHosts() {
|
||||
for (const listener of hosts.values()) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
/** 发一条提示(每条都会重新弹一次并重起计时)。 */
|
||||
export function publishComposerNotice(
|
||||
message: string,
|
||||
tone: ComposerNotice['tone'] = 'error',
|
||||
) {
|
||||
sequence += 1;
|
||||
notice = { id: sequence, message, tone };
|
||||
notifyHosts();
|
||||
}
|
||||
|
||||
/** 收起当前提示(6s 计时结束或宿主主动收)。 */
|
||||
export function dismissComposerNotice() {
|
||||
notice = null;
|
||||
notifyHosts();
|
||||
}
|
||||
|
||||
/**
|
||||
* 在输入区里挂一个 toast 宿主。同一个输入区里多个调用点只有一个会真正渲染(编号最小的那个),
|
||||
* 所以模型选择器与推理档控件可以各自调用,页面里也只会出现一条浮层。
|
||||
*
|
||||
* 返回 `notice: null` 表示这次由别的宿主渲染(调用方照样把 `<ComposerNoticeToast>` 挂上即可)。
|
||||
*/
|
||||
export function useComposerNoticeHost(): {
|
||||
notice: ComposerNotice | null;
|
||||
dismiss: () => void;
|
||||
} {
|
||||
const hostIdRef = useRef<number | null>(null);
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
if (hostIdRef.current === null) {
|
||||
hostSequence += 1;
|
||||
hostIdRef.current = hostSequence;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const hostId = hostIdRef.current as number;
|
||||
const listener = () => forceRender((value) => value + 1);
|
||||
hosts.set(hostId, listener);
|
||||
notifyHosts();
|
||||
return () => {
|
||||
hosts.delete(hostId);
|
||||
if (hosts.size === 0) {
|
||||
// 输入区整体卸载(切页/测试清理)时把待显示的提示一并丢掉,避免留给下一个宿主。
|
||||
notice = null;
|
||||
}
|
||||
notifyHosts();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (hosts.size === 0 || Math.min(...hosts.keys()) !== hostIdRef.current) {
|
||||
return { notice: null, dismiss: dismissComposerNotice };
|
||||
}
|
||||
return { notice, dismiss: dismissComposerNotice };
|
||||
}
|
||||
+23
-55
@@ -10,6 +10,11 @@ import {
|
||||
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { GameCreatorAppConfigView } from '../../app/types';
|
||||
import {
|
||||
publishComposerNotice,
|
||||
useComposerNoticeHost,
|
||||
} from '../../components/common/composerNotice';
|
||||
import { ComposerNoticeToast } from '../../components/common/ComposerNoticeToast';
|
||||
import {
|
||||
cachedLlmModelCatalog,
|
||||
type ClientLlmModel,
|
||||
@@ -65,9 +70,6 @@ export function ConversationModelSelect({
|
||||
onReady,
|
||||
projectPath,
|
||||
ref,
|
||||
errorPresentation = 'inline',
|
||||
onUnavailable,
|
||||
onNotice,
|
||||
strengthSection,
|
||||
}: {
|
||||
className?: string;
|
||||
@@ -75,24 +77,6 @@ export function ConversationModelSelect({
|
||||
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;
|
||||
/**
|
||||
* 状态文案(「模型列表已刷新」「正在刷新模型列表」「默认模型已停用」等)的浮层通道。
|
||||
*
|
||||
* 只在 `errorPresentation: 'toast'` 时回调,且与失败**分开**:语气不同(状态不是失败)、
|
||||
* 去重名额也各算各的。`toast` 模式下没有常驻 `role="status"`,输入区不再被一行状态文字顶高。
|
||||
*/
|
||||
onNotice?: (message: string) => void;
|
||||
/**
|
||||
* 菜单顶部的附加区(Direct 输入盒用来放「思维强度」滑块)。
|
||||
*
|
||||
@@ -132,17 +116,14 @@ export function ConversationModelSelect({
|
||||
onReadyRef.current = onReady;
|
||||
}, [onReady]);
|
||||
|
||||
const onUnavailableRef = useRef(onUnavailable);
|
||||
useEffect(() => {
|
||||
onUnavailableRef.current = onUnavailable;
|
||||
}, [onUnavailable]);
|
||||
|
||||
/** 已经报出去的失败消息:同一条只在「新出现」时报一次,失败清空后可以再报一次。 */
|
||||
/**
|
||||
* 失败与状态都走**组件自己的浮层**(`composerNotice` 单例 + toast 宿主):任何宿主都不留
|
||||
* 常驻 `role="alert"` / `role="status"` 行——输入区那一排控件容不下第二行文字,一旦排一行
|
||||
* 提示,整颗控件会变成两行高,把触发钮顶到行线上方(#600 复核实测的错位)。
|
||||
* 两条通道各自去重:同一条只报一次,清空后再出现可以再报。
|
||||
*/
|
||||
const reportedErrorRef = useRef('');
|
||||
useEffect(() => {
|
||||
if (errorPresentation !== 'toast') {
|
||||
return;
|
||||
}
|
||||
if (error === '') {
|
||||
reportedErrorRef.current = '';
|
||||
return;
|
||||
@@ -151,23 +132,11 @@ export function ConversationModelSelect({
|
||||
return;
|
||||
}
|
||||
reportedErrorRef.current = error;
|
||||
onUnavailableRef.current?.(error);
|
||||
}, [error, errorPresentation]);
|
||||
publishComposerNotice(error, 'error');
|
||||
}, [error]);
|
||||
|
||||
/**
|
||||
* 状态文案(「模型列表已刷新」「默认模型已停用」等)在 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;
|
||||
@@ -176,8 +145,11 @@ export function ConversationModelSelect({
|
||||
return;
|
||||
}
|
||||
reportedNoticeRef.current = notice;
|
||||
onNoticeRef.current?.(notice);
|
||||
}, [errorPresentation, notice]);
|
||||
publishComposerNotice(notice, 'neutral');
|
||||
}, [notice]);
|
||||
|
||||
// 这个输入区里的 toast 宿主(同一输入区多个调用点只有一个真正渲染)。
|
||||
const composerNotice = useComposerNoticeHost();
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@@ -521,15 +493,11 @@ export function ConversationModelSelect({
|
||||
: 'conversation-model-select'
|
||||
}
|
||||
>
|
||||
{/* `toast` 呈现方式下这里不渲染任何常驻文案:控件排容不下第二行,
|
||||
失败由宿主的浮层提示承担(见 `errorPresentation` 的说明)。 */}
|
||||
{error && errorPresentation === 'inline' ? (
|
||||
<span role="alert">{error}</span>
|
||||
) : null}
|
||||
{/* 状态文案同理:Direct 输入区不留常驻 `role="status"`,一并交给浮层通道。 */}
|
||||
{notice && errorPresentation === 'inline' ? (
|
||||
<span role="status">{notice}</span>
|
||||
) : null}
|
||||
{/* 失败与状态都只走浮层:控件里不再渲染常驻 `role="alert"` / `role="status"` 行。 */}
|
||||
<ComposerNoticeToast
|
||||
notice={composerNotice.notice}
|
||||
onDismiss={composerNotice.dismiss}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="conversation-model-trigger"
|
||||
|
||||
@@ -13744,9 +13744,8 @@ details.design-agent-reasoning[open]
|
||||
这一段原来把 `.conversation-model-select [role='alert']` 从 `bottom: 36px` 浮层收回
|
||||
控件排内独占一行;实测这样会让整颗模型控件变成两行高,在 `align-items: center` 的
|
||||
工具条里把触发钮顶到行线上方、提示落到行线下方(用户复核反馈的"完全没对齐")。
|
||||
现在 Direct 输入盒改用 `errorPresentation="toast"`:控件排内不再有这两颗元素,
|
||||
本条规则恒无命中对象,已删;文案走输入区自己的浮层提示
|
||||
(`ComposerModelNoticeToast`)。 */
|
||||
现在失败与状态一律走组件自己的底部浮层(`components/common/composerNotice.ts` +
|
||||
`ComposerNoticeToast`,任何宿主都一样):控件排内不再有这两颗元素,本条规则恒无命中对象,已删。 */
|
||||
|
||||
/* ③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距:
|
||||
队列 chip 与提示段落的 `margin` 都写回前文各自那条规则。 */
|
||||
|
||||
+17
-96
@@ -5,7 +5,6 @@
|
||||
* 这些组件只承载表现与交互;回合附件由宿主导入后插成正文芯片(正文是唯一事实源),
|
||||
* 待发消息队列由宿主持有(这里只是一份事件投影,文案派生见 `pendingTurnChipLabel.ts`)。
|
||||
*/
|
||||
import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { CSSProperties, RefObject } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { GameCreationAppAssetManifestEntry } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { resolveTauriInvoke } from '../../../../../app/tauri';
|
||||
@@ -27,6 +25,11 @@ import type {
|
||||
GameCreatorAppConfigView,
|
||||
GameCreatorLlmReasoningEffort,
|
||||
} from '../../../../../app/types';
|
||||
import {
|
||||
publishComposerNotice,
|
||||
useComposerNoticeHost,
|
||||
} from '../../../../../components/common/composerNotice';
|
||||
import { ComposerNoticeToast } from '../../../../../components/common/ComposerNoticeToast';
|
||||
import {
|
||||
composerReasoningEffortOptions,
|
||||
DEFAULT_COMPOSER_REASONING_EFFORT,
|
||||
@@ -314,8 +317,6 @@ export function ComposerVoiceButton({
|
||||
export function ComposerReasoningEffortSelect({
|
||||
disabled,
|
||||
presentation = 'dropdown',
|
||||
noticePresentation = 'inline',
|
||||
onNotice,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
/**
|
||||
@@ -324,14 +325,6 @@ 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>(
|
||||
@@ -506,16 +499,9 @@ export function ComposerReasoningEffortSelect({
|
||||
[],
|
||||
);
|
||||
|
||||
/** 状态文案在 toast 形态下交给宿主浮层:同一条只报一次,清空后可再报。 */
|
||||
const onNoticeRef = useRef(onNotice);
|
||||
useEffect(() => {
|
||||
onNoticeRef.current = onNotice;
|
||||
}, [onNotice]);
|
||||
/** 状态文案(推理档读取/保存失败)同样走组件自己的浮层:任何宿主都不留常驻状态行。 */
|
||||
const reportedNoticeRef = useRef('');
|
||||
useEffect(() => {
|
||||
if (noticePresentation !== 'toast') {
|
||||
return;
|
||||
}
|
||||
if (notice === '') {
|
||||
reportedNoticeRef.current = '';
|
||||
return;
|
||||
@@ -524,8 +510,11 @@ export function ComposerReasoningEffortSelect({
|
||||
return;
|
||||
}
|
||||
reportedNoticeRef.current = notice;
|
||||
onNoticeRef.current?.(notice);
|
||||
}, [notice, noticePresentation]);
|
||||
publishComposerNotice(notice, 'neutral');
|
||||
}, [notice]);
|
||||
|
||||
// 这个输入区里的 toast 宿主(与模型选择器共用单例,同一时刻只有一条浮层)。
|
||||
const composerNotice = useComposerNoticeHost();
|
||||
|
||||
/** 拖动中的连续事件只改预览:视觉立刻跟手,落盘留到松手那一刻。 */
|
||||
function previewFromEvent(event: { currentTarget: HTMLInputElement }) {
|
||||
@@ -632,7 +621,7 @@ export function ComposerReasoningEffortSelect({
|
||||
value={activeIndex}
|
||||
style={
|
||||
{
|
||||
/* 档位比例(0~1,无单位):CSS 用它算出圆钮中心,填充铺到圆钮右缘。 */
|
||||
/* 档位比例(0~1,无单位):CSS 用它算出**圆钮中心**,已选段终点就落在那里。 */
|
||||
'--strength-ratio': String(activeIndex / (options.length - 1)),
|
||||
} as CSSProperties
|
||||
}
|
||||
@@ -664,11 +653,11 @@ export function ComposerReasoningEffortSelect({
|
||||
return (
|
||||
<span className="project-chat-reasoning-effort">
|
||||
{presentation === 'section' ? strengthSection : dropdownSelect}
|
||||
{notice && noticePresentation === 'inline' ? (
|
||||
<span role="status" className="project-chat-composer-notice">
|
||||
{notice}
|
||||
</span>
|
||||
) : null}
|
||||
{/* 状态文案只走浮层:控件里不再渲染常驻 `role="status"` 行。 */}
|
||||
<ComposerNoticeToast
|
||||
notice={composerNotice.notice}
|
||||
onDismiss={composerNotice.dismiss}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -694,71 +683,3 @@ export function ComposerStopButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 模型服务不可用这类输入区失败的停留时长:与外壳的错误提示同一档(6s)。 */
|
||||
const COMPOSER_MODEL_NOTICE_MILLIS = 6000;
|
||||
|
||||
export type ComposerModelNotice = {
|
||||
/** 每次提示自增:同一条文案再出现时也会重新弹一次。 */
|
||||
id: number;
|
||||
message: string;
|
||||
/**
|
||||
* 语气:失败(模型服务不可用 / 推理档保存失败)用 `error`,状态(模型列表已刷新等)用
|
||||
* `neutral`。状态原本是控件里那行常驻 `role="status"`,它同样会把这排控件顶成两行高。
|
||||
*/
|
||||
tone: 'error' | 'neutral';
|
||||
};
|
||||
|
||||
/**
|
||||
* 输入区自己的浮层提示(模型服务不可用等)。
|
||||
*
|
||||
* 输入区**不留常驻状态行**:那一排控件容不下第二行文字,一旦在控件里排一行提示,
|
||||
* 整颗控件会变成两行高、把触发钮顶到行线上方(#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={notice.tone}
|
||||
surface="solid"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className="shadow-lg"
|
||||
>
|
||||
{notice.message}
|
||||
</PlatformRuntimeStatusToast>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
-48
@@ -38,8 +38,6 @@ import type { DirectPendingTurn } from '../../conversation/directPendingTurns';
|
||||
import type { UserContentPart } from '../../generated/UserContentPart';
|
||||
import {
|
||||
ComposerAttachmentMenu,
|
||||
type ComposerModelNotice,
|
||||
ComposerModelNoticeToast,
|
||||
ComposerReasoningEffortSelect,
|
||||
ComposerStopButton,
|
||||
ComposerTurnQueue,
|
||||
@@ -139,14 +137,6 @@ 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 [modelReady, setModelReady] = useState(false);
|
||||
const [modelValidating, setModelValidating] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -217,29 +207,6 @@ export function DirectProjectComposer({
|
||||
}),
|
||||
[],
|
||||
);
|
||||
/** 记一条失败浮层(error 语气):id 自增,保证重复文案也会重新弹一次并重起计时。 */
|
||||
const pushModelNotice = useCallback((message: string) => {
|
||||
modelNoticeIdRef.current += 1;
|
||||
setModelNotice({
|
||||
id: modelNoticeIdRef.current,
|
||||
message,
|
||||
tone: 'error',
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 记一条状态浮层(neutral 语气):模型列表刷新成功、推理档读取/保存的状态文案都走这里。
|
||||
* 和失败分开是为了语气与去重名额:状态不是失败,也不该占用"同一条失败只报一次"的名额。
|
||||
*/
|
||||
const pushModelStatus = useCallback((message: string) => {
|
||||
modelNoticeIdRef.current += 1;
|
||||
setModelNotice({
|
||||
id: modelNoticeIdRef.current,
|
||||
message,
|
||||
tone: 'neutral',
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管),
|
||||
// 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。
|
||||
const submitButton = (
|
||||
@@ -321,18 +288,11 @@ export function DirectProjectComposer({
|
||||
disabled={busy}
|
||||
onReady={setModelReady}
|
||||
projectPath={projectPath ?? ''}
|
||||
errorPresentation="toast"
|
||||
onUnavailable={pushModelNotice}
|
||||
/* 状态文案(刷新成功 / 默认模型变更)同样不留常驻行,走 neutral 浮层。 */
|
||||
onNotice={pushModelStatus}
|
||||
/* 强度滑块并进模型菜单:底行只留这一颗控件(少占一个位)。 */
|
||||
strengthSection={
|
||||
<ComposerReasoningEffortSelect
|
||||
disabled={busy}
|
||||
presentation="section"
|
||||
/* Direct 输入区不留常驻状态行:推理档的状态文案也走同一浮层通道。 */
|
||||
noticePresentation="toast"
|
||||
onNotice={pushModelStatus}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -371,14 +331,6 @@ export function DirectProjectComposer({
|
||||
}
|
||||
onOpenChange={setPickerOpen}
|
||||
/>
|
||||
{/*
|
||||
输入区自己的浮层提示:模型服务不可用这类失败不留常驻文字行(那一排控件容不下
|
||||
第二行,会把触发钮顶到行线上方)。组件内部 portal 到 body,与外壳的 toast 同一套。
|
||||
*/}
|
||||
<ComposerModelNoticeToast
|
||||
notice={modelNotice}
|
||||
onDismiss={() => setModelNotice(null)}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1446,7 +1446,6 @@ export function registerChatComposerControlTests() {
|
||||
});
|
||||
|
||||
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') {
|
||||
@@ -1470,37 +1469,51 @@ export function registerChatComposerControlTests() {
|
||||
core: { invoke },
|
||||
event: { listen: async () => () => undefined },
|
||||
});
|
||||
render(
|
||||
React.createElement(ConversationModelSelect, {
|
||||
disabled: false,
|
||||
errorPresentation: 'toast',
|
||||
onUnavailable,
|
||||
}),
|
||||
const view = render(
|
||||
React.createElement(ConversationModelSelect, { disabled: false }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(onUnavailable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onUnavailable).toHaveBeenCalledWith('模型服务不可用');
|
||||
|
||||
// 同一条失败再次同步目录(打开下拉会触发一次)不重复报。
|
||||
// 组件自己弹浮层:底部 toast 一条,控件子树里没有常驻 `role="alert"` 行。
|
||||
const toastOf = () =>
|
||||
document.querySelector<HTMLElement>('[data-composer-model-toast="true"]');
|
||||
await waitFor(() => {
|
||||
expect(toastOf()?.textContent).toContain('模型服务不可用');
|
||||
});
|
||||
const firstToast = toastOf();
|
||||
expect(
|
||||
document.querySelectorAll('[data-composer-model-toast="true"]'),
|
||||
).toHaveLength(1);
|
||||
// 常驻行(控件里那两颗)没了;浮层自身的 `role="alert"` 在 portal 里,属于 toast 本体。
|
||||
expect(view.container.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(view.container.querySelector('[role="status"]')).toBeNull();
|
||||
|
||||
// 同一条失败再次同步目录(打开下拉会触发一次)不重复弹。
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
|
||||
await waitFor(() => {
|
||||
expect(onUnavailable).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(
|
||||
document.querySelectorAll('[data-composer-model-toast="true"]'),
|
||||
).toHaveLength(1);
|
||||
expect(toastOf()).toBe(firstToast);
|
||||
|
||||
// 失败清空后再出现,可以再报一次。
|
||||
// 失败清空(目录恢复正常)后再出现,可以再弹一次:节点换新(每次提示自增 id)。
|
||||
resetLlmModelCatalogCacheForTest();
|
||||
failing = false;
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
|
||||
await waitFor(() => {
|
||||
expect(onUnavailable).toHaveBeenCalledTimes(1);
|
||||
expect(toastOf()?.textContent).toContain('模型列表已刷新');
|
||||
});
|
||||
resetLlmModelCatalogCacheForTest();
|
||||
failing = true;
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
|
||||
await waitFor(() => {
|
||||
expect(onUnavailable).toHaveBeenCalledTimes(2);
|
||||
expect(toastOf()?.textContent).toContain('模型服务不可用');
|
||||
expect(toastOf()).not.toBe(firstToast);
|
||||
});
|
||||
expect(
|
||||
document.querySelectorAll('[data-composer-model-toast="true"]'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('appends the dictated transcript after the draft typed into the composer', async () => {
|
||||
|
||||
@@ -387,7 +387,24 @@ export function registerDesignAgentSurfaceTests() {
|
||||
await waitFor(() => expect(reasoning.textContent).toContain('高'));
|
||||
fireEvent.click(reasoning);
|
||||
fireEvent.click(screen.getByRole('option', { name: '低' }));
|
||||
await screen.findByText('推理档保存失败');
|
||||
// 失败文案走组件自己的底部 toast(portal 在 body),策划输入区子树里不留常驻行。
|
||||
const toast = await waitFor(() => {
|
||||
const element = document.querySelector<HTMLElement>(
|
||||
'[data-composer-model-toast="true"]',
|
||||
);
|
||||
expect(element?.textContent).toContain('推理档保存失败');
|
||||
return element as HTMLElement;
|
||||
});
|
||||
expect(
|
||||
document.querySelectorAll('[data-composer-model-toast="true"]'),
|
||||
).toHaveLength(1);
|
||||
expect(toast.querySelector('[role]')?.getAttribute('role')).toBe('status');
|
||||
const effortControl = document.querySelector(
|
||||
'.project-chat-reasoning-effort',
|
||||
);
|
||||
expect(effortControl).not.toBeNull();
|
||||
expect(effortControl?.querySelector('[role="status"]')).toBeNull();
|
||||
expect(effortControl?.contains(toast)).toBe(false);
|
||||
expect(reasoning.textContent).toContain('高');
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { HOME_CREATION_AFTER_PROJECT_FAILURE } from '../../src/features/app-shell/homeCreationFailure';
|
||||
import type { ProjectChatComponentProps } from '../../src/features/app-shell/model';
|
||||
import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher';
|
||||
import { LlmModelCatalogConfigError } from '../../src/services/llmModelCatalog';
|
||||
import { repoPath } from '../repoPath';
|
||||
import {
|
||||
declaration,
|
||||
@@ -261,6 +262,38 @@ export function registerClientHomeTests() {
|
||||
expect(value(selected, 'background')).toContain('var(--platform-warm-bg)');
|
||||
});
|
||||
|
||||
it('首页模型不可用与状态也走组件自己的底部 toast(子树不留常驻行)', async () => {
|
||||
// 任何宿主都不留常驻 `role="alert"` / `role="status"`:浮层收进组件内部(模块级单例 + 宿主)。
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'read_game_creator_app_config') {
|
||||
return { config: { selectedModelId: 'quality' } };
|
||||
}
|
||||
if (command === 'load_game_creator_llm_models') {
|
||||
throw new LlmModelCatalogConfigError('模型服务不可用');
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
installTauriRuntime({ core: { invoke } });
|
||||
renderLauncherAt('/?launcher');
|
||||
|
||||
const toast = await waitFor(() => {
|
||||
const element = document.querySelector<HTMLElement>(
|
||||
'[data-composer-model-toast="true"]',
|
||||
);
|
||||
expect(element?.textContent).toContain('模型服务不可用');
|
||||
return element as HTMLElement;
|
||||
});
|
||||
// 浮层 portal 在 body(底部居中),不在首页输入区子树里;子树里也没有常驻行。
|
||||
const homeSelect = document.querySelector('.home-input-model-select');
|
||||
expect(homeSelect).not.toBeNull();
|
||||
expect(homeSelect?.contains(toast)).toBe(false);
|
||||
expect(homeSelect?.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(homeSelect?.querySelector('[role="status"]')).toBeNull();
|
||||
expect(
|
||||
document.querySelectorAll('[data-composer-model-toast="true"]'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('anchors the empty home input placeholder to the editor while the page scrolls', () => {
|
||||
renderLauncherAt('/?launcher');
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
## 2026-10-05 PR #607 复核修复:档位点对齐/对比度、状态文案也走浮层、键盘去重、卸载 flush
|
||||
|
||||
- **档位圆点已删除(D1 的收口)**:这一轮把档位圆点**整体删除**(半透明备选方案未采用)。现在滑块只剩轨道 + 圆钮:轨道 6px 圆头、已选段 `--platform-accent` 由 `--strength-ratio` 驱动(`calc(20px + ratio * (100% - 20px))`)、圆钮 20px 实心暖白(`--platform-panel-fill` + `--platform-subpanel-border` 1px 描边 + `color-mix` 柔影);强度区横向内边距 `4px 6px 2px` → `4px 0 2px`(滑块铺满卡片内容宽度,填充段与圆钮两端与轨道两端贴齐);相关 CSS(`space-between` 排布 / `z-index: 2` 抬层 / `.is-active{opacity:0}` / 点的 `color-mix` 底色 / 只为点对齐的 `padding: 0 7px`)与渲染标记一并删除。**判据**:`chatDialogFrameLayout.test.ts` 反向守卫(样式表里不再有 `.project-chat-composer-strength-stops` 规则、组件源码不再渲染该类名;滑块契约仍在:宽 100% / 高 26 / 圆钮 20×20、强度区左右内边距 0)+ `home.suite.ts` 首页菜单里查不到那组点。实测(447 视口,像素扫描):轨道 90..324(宽 234 = 卡片内容宽),档位 0 时圆钮左缘 90.5(距轨道左端 0.5px)、档位 4 时圆钮右缘 322.3(距右端 1.8px,扫描行不在圆钮正中所以略窄),填充段始终铺到圆钮右缘止。**历史成因(只留一句,细节由 Git 追溯)**:圆点此前被 6px 轨道盖住、且与圆钮两端错位 ±12.2px,曾用「抬到轨道之上 + space-between 对齐 + 浅暖色」修过一轮,最终整体删除。
|
||||
- **状态文案(`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**,且浮层在输入区之外。
|
||||
- **档位圆点已删除(D1 的收口)**:这一轮把档位圆点**整体删除**(半透明备选方案未采用)。现在滑块只剩轨道 + 圆钮:轨道 6px 圆头、已选段 `--platform-accent` 由 `--strength-ratio` 驱动、**终点落在圆钮中心**(`calc(10px + ratio * (100% - 20px))`)、圆钮 20px 实心暖白(`--platform-panel-fill` + `--platform-subpanel-border` 1px 描边 + `color-mix` 柔影);强度区横向内边距 `4px 6px 2px` → `4px 0 2px`(滑块铺满卡片内容宽度,填充段与圆钮两端与轨道两端贴齐);相关 CSS(`space-between` 排布 / `z-index: 2` 抬层 / `.is-active{opacity:0}` / 点的 `color-mix` 底色 / 只为点对齐的 `padding: 0 7px`)与渲染标记一并删除。**判据**:`chatDialogFrameLayout.test.ts` 反向守卫(样式表里不再有 `.project-chat-composer-strength-stops` 规则、组件源码不再渲染该类名;滑块契约仍在:宽 100% / 高 26 / 圆钮 20×20、强度区左右内边距 0)+ `home.suite.ts` 首页菜单里查不到那组点。实测(447 视口,像素扫描):轨道 90..324(宽 234 = 卡片内容宽),档位 0 时圆钮左缘 90.5(距轨道左端 0.5px)、档位 4 时圆钮右缘 322.3(距右端 1.8px,扫描行不在圆钮正中所以略窄),填充段终点落在**圆钮中心**(`calc(10px + ratio * (100% - 20px))`,被圆钮盖住),因此圆钮右侧不会露出橙色(终点曾写成 `20px + …` = 圆钮右缘,4× 设备像素下能看到一小截溢出)。**历史成因(只留一句,细节由 Git 追溯)**:圆点此前被 6px 轨道盖住、且与圆钮两端错位 ±12.2px,曾用「抬到轨道之上 + space-between 对齐 + 浅暖色」修过一轮,最终整体删除。
|
||||
- **状态文案(`role="status"`)也会顶高那一排(D2)**:`notice`(「模型列表已刷新」「正在刷新模型列表」「默认模型已停用」、推理档读取/保存失败)原先无条件渲染成控件里的 `role="status"`,`toast` 形态只接管了 `error`,于是 Direct 输入区仍会被一行状态文字顶成两行高。现行口径:浮层**收进组件内部**(`components/common/composerNotice.tsx` 的模块级单例 + toast 宿主),失败与状态分两条通道各自去重(同一条只弹一次、清空后可再弹),失败 `tone="error"`、状态 `tone="neutral"`;**任何宿主**的输入区子树里都不再有常驻 `role="alert"` / `role="status"` 行,宿主的 `errorPresentation` / `onUnavailable` / `onNotice` / `noticePresentation` 这套分支已删除。判据:`chat-composer.suite.ts`「routes model-list notices to the toast…」断言 Direct 输入区子树里 `[role='status']` 与 `[role='alert']` **都为 0**,且浮层在输入区之外。
|
||||
- **键盘长按每个事件都落盘(D5)**:range 的方向键在 Chromium 里每次重复都会补 `change`,旧行为 ~30 次/秒全量写盘(写链 + 持久化 + 回读)。现行口径:`selectEffort` 先比对「最近一次**落盘成功**的档位」(`persistedEffortRef`,挂载回读时就种上),相同则不写;连续调整只写**每个新档位**一次,钳位后的重复事件 0 次。实测(真实按键序列):单击方向键 **1** 次落盘;长按 1.5s(34 个重复事件)**4** 次(`low/medium/high/max`,即每个新档位一次);已在最高档继续长按 1.2s(28 个事件)**0** 次。
|
||||
- **拖动中途卸载会丢档位(D6)**:预览值只在 `change`/`keyup`/`pointerup`/`blur` 落盘,拖到一半被 Esc 关掉菜单或宿主卸载时不会提交。现行口径:卸载 cleanup 里 flush 一次(读 ref,不碰已卸载组件的 state)。实测:拖动到「中」未松手时落盘 0 次,`unmount()` 后恰好 **1** 次(`medium`);单次拖动(按下 + 6 步横移 + 松手)全程 **1** 次(`high`),终值 = 松手位置。
|
||||
- **`forced-colors` 下焦点不可见(D4)**:高对比模式会丢弃 thumb 的 `box-shadow`,而焦点环正是画在 thumb 上的阴影。现行口径:加 `@media (forced-colors: active)` 回退 `outline: 2px solid Highlight; outline-offset: 2px`(系统色,不算新色值;只在 forced-colors 生效,亮色观感不变)。
|
||||
|
||||
Reference in New Issue
Block a user