模型不可用与模型状态一律走组件自带的底部 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

- 现象:`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:
2026-10-05 20:22:41 +08:00
parent 7d73a0a3ce
commit 3204758f34
10 changed files with 272 additions and 222 deletions
@@ -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 };
}
@@ -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"
+2 -3
View File
@@ -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` 都写回前文各自那条规则。 */
@@ -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,
);
}
@@ -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');