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(
+
,
+ 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(
- ,
- 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 生效,亮色观感不变)。