From 3204758f3420e8cc0df3d4e0b46f18d736c6a5e1 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 20:22:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=B8=8D=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E4=B8=8E=E6=A8=A1=E5=9E=8B=E7=8A=B6=E6=80=81=E4=B8=80=E5=BE=8B?= =?UTF-8?q?=E8=B5=B0=E7=BB=84=E4=BB=B6=E8=87=AA=E5=B8=A6=E7=9A=84=E5=BA=95?= =?UTF-8?q?=E9=83=A8=20toast=EF=BC=9A=E5=88=A0=E6=8E=89=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 现象:`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 --- .../components/common/ComposerNoticeToast.tsx | 56 +++++++++ .../src/components/common/composerNotice.ts | 91 ++++++++++++++ .../ConversationModelSelect.tsx | 78 ++++-------- apps/ai-game-creator-shell/src/styles.css | 5 +- .../ComposerControls.tsx | 113 +++--------------- .../DirectProjectComposer.tsx | 48 -------- .../tests/appSurface/chat-composer.suite.ts | 47 +++++--- .../tests/appSurface/design-agent.suite.ts | 19 ++- .../tests/appSurface/home.suite.ts | 33 +++++ docs/project-memory/shared-memory/pitfalls.md | 4 +- 10 files changed, 272 insertions(+), 222 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/components/common/ComposerNoticeToast.tsx create mode 100644 apps/ai-game-creator-shell/src/components/common/composerNotice.ts diff --git a/apps/ai-game-creator-shell/src/components/common/ComposerNoticeToast.tsx b/apps/ai-game-creator-shell/src/components/common/ComposerNoticeToast.tsx new file mode 100644 index 000000000..535c7b336 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/common/ComposerNoticeToast.tsx @@ -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( +
+ + {notice.message} + +
, + document.body, + ); +} diff --git a/apps/ai-game-creator-shell/src/components/common/composerNotice.ts b/apps/ai-game-creator-shell/src/components/common/composerNotice.ts new file mode 100644 index 000000000..201df4333 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/common/composerNotice.ts @@ -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 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` 表示这次由别的宿主渲染(调用方照样把 `` 挂上即可)。 + */ +export function useComposerNoticeHost(): { + notice: ComposerNotice | null; + dismiss: () => void; +} { + const hostIdRef = useRef(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 }; +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index fb6e11cbd..aed89fa26 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -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; - /** - * 目录 / 选择失败的呈现方式。 - * - * `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' ? ( - {error} - ) : null} - {/* 状态文案同理:Direct 输入区不留常驻 `role="status"`,一并交给浮层通道。 */} - {notice && errorPresentation === 'inline' ? ( - {notice} - ) : null} + {/* 失败与状态都只走浮层:控件里不再渲染常驻 `role="alert"` / `role="status"` 行。 */} + ); } - -/** 模型服务不可用这类输入区失败的停留时长:与外壳的错误提示同一档(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( -
- - {notice.message} - -
, - document.body, - ); -} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx index 9a880102a..38454c6c5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -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(null); const modelValidateInFlightRef = useRef(false); const [voiceNotice, setVoiceNotice] = useState(''); - /* - 模型服务的失败只走浮层提示:输入区那一排控件不留常驻状态行(会把它顶成两行高)。 - 同一条文案再次出现时也能再弹一次,所以按自增 id 记这一条。 - */ - const [modelNotice, setModelNotice] = useState( - 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={ } /> @@ -371,14 +331,6 @@ export function DirectProjectComposer({ } onOpenChange={setPickerOpen} /> - {/* - 输入区自己的浮层提示:模型服务不可用这类失败不留常驻文字行(那一排控件容不下 - 第二行,会把触发钮顶到行线上方)。组件内部 portal 到 body,与外壳的 toast 同一套。 - */} - setModelNotice(null)} - /> ); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index dc4e6440a..5f5380a90 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -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('[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 () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index 508999d0c..f533c5c33 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -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( + '[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('高'); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index e0d76bc74..21bb4e55d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -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( + '[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'); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 8490efebe..7cf8a065c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 生效,亮色观感不变)。