语音提示不再常驻 + 模型触发钮补 title + 工具条 DOM 级断言
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m18s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m30s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m18s
Project CI / Backend tests (pull_request) Successful in 7m43s
Project CI / Frontend tests (pull_request) Successful in 4m39s
Project CI / Repository checks (pull_request) Successful in 7m7s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m53s
Project CI / Native shell tests (pull_request) Successful in 9m18s

- D-B:`voiceNotice` 只在 mic 的 `onNotice` 里 set、从不清理(会常驻到组件卸载)→ 改成「拿到转写就收掉」(`onTranscript` 里 `setVoiceNotice('')`);提示是上一次动作的回执,选这个时机而不是 6s 定时,避免与 toast 的 6s 语义混在一起
- D-B 用例(改前红):`chat-composer.suite.ts`「语音提示是上一次动作的回执:拿到转写后不再常驻」——录音 + `onerror({error:'network'})` → `project-chat-composer-notice` 出现「语音识别服务…」;再 `emitTranscript('做一个小游戏')` → 该行消失且节点脱离文档(旧实现里它一直在)
- D-C:模型触发钮最窄可缩到 64px、名字走省略号但没有 `title`,截断后无从查看 → 触发钮补 `title={triggerLabel}`(与显示名同源),并加属性断言:`title` == 触发钮文本
- 测试加强(评审说偏声明级):`chat-composer.suite.ts` 新增「AI 润色钮在工具条这一行里(DOM 位置级)」——润色钮必须位于 `.project-chat-composer-controls` 内、父链在 `-controls-actions` 挂点里、不在 `.resource-reference-input` 里、空态工具条外没有第二行按钮(用真实挂载的 Direct 输入区断言);同排几何(top 差 ≤1px、两两不相交、七宽度)由无头 Chromium 夹具实测(240/280/296/320/360/438/900 全通过)
- 校验:`appSurface + chatDialogFrameLayout` **227 passed / 9 skipped**;typecheck pass 3 / fail 0;eslint、prettier、encoding 5327 files、spacetime-schema 92 tables、diff-check 全绿

Refs #600, PR #607
This commit is contained in:
2026-10-06 01:25:43 +08:00
parent 69a5b868ef
commit b7440d8c39
3 changed files with 85 additions and 2 deletions
@@ -98,6 +98,9 @@ export function ConversationModelSelect({
const [busy, setBusy] = useState(!initialCatalog);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
/** 触发钮上显示的模型名(目录还没读完时用「模型」占位);同时用作触发钮的 `title`。 */
const triggerLabel =
models.find((model) => model.id === selected)?.displayName ?? '模型';
const [manualRefreshBusy, setManualRefreshBusy] = useState(false);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -502,6 +505,8 @@ export function ConversationModelSelect({
type="button"
className="conversation-model-trigger"
aria-label="对话模型"
/* 触发钮最窄可缩到 64px,模型名靠省略号收住:补 `title` 让被截断的名字仍可查看。 */
title={triggerLabel}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
@@ -517,7 +522,7 @@ export function ConversationModelSelect({
/>
<span className="conversation-model-trigger-label">
{/* 控件位只显示已选模型名;目录还没读完时用「模型」占位(加载提示留在菜单里)。 */}
{models.find((model) => model.id === selected)?.displayName ?? '模型'}
{triggerLabel}
</span>
<ChevronDown size={13} aria-hidden="true" />
</button>
@@ -310,7 +310,12 @@ export function DirectProjectComposer({
/>
<ComposerVoiceButton
disabled={busy || modelValidating}
onTranscript={(text) => composerRef.current?.insertText(text)}
onTranscript={(text) => {
/* 拿到转写就把语音的提示收掉:提示是「上一次动作的回执」,
留着会变成输入区里的常驻文本(那一排容不下常驻行)。 */
setVoiceNotice('');
composerRef.current?.insertText(text);
}}
onNotice={setVoiceNotice}
/>
{canStopTurn ? (
@@ -1375,6 +1375,79 @@ export function registerChatComposerControlTests() {
await waitFor(() => expect(writes).toEqual(['medium']));
});
it('语音提示是上一次动作的回执:拿到转写后不再常驻', async () => {
// 语音报错会写一条 `project-chat-composer-notice`(输入盒下方的独立一行)。它是「上一次动作的
// 回执」,下一次拿到转写必须收掉——否则语音报错会常驻到组件卸载,和"输入区不留常驻文本"相冲。
const fake = installFakeSpeechRecognition();
try {
const { surface } = await openDirectCodexSurface();
const mic = within(surface).getByRole('button', { name: '语音输入' });
fireEvent.click(mic);
act(() => {
(
fake.instances[0] as unknown as {
onerror?: (event: unknown) => void;
}
).onerror?.({ error: 'network' });
});
const notice = await waitFor(() => {
const element = surface.querySelector('.project-chat-composer-notice');
expect(element?.textContent).toContain('语音识别服务');
return element as HTMLElement;
});
// 下一次拿到转写:提示收掉,输入区不留常驻行。
act(() => {
fake.instances[0]?.emitTranscript('做一个小游戏');
});
await waitFor(() => {
expect(
surface.querySelector('.project-chat-composer-notice'),
).toBeNull();
});
expect(notice.isConnected).toBe(false);
} finally {
fake.restore();
}
});
it('AI 润色钮在工具条这一行里(DOM 位置级)', async () => {
// 声明级的 `flex-wrap: nowrap` 抓不到"渲染后仍换行":这里钉住润色钮真的挂在
// `.project-chat-composer-controls` 里(`actionsPortalTarget` 把它 portal 进工具条),
// 而不是留在输入盒的第二行。同一行的几何(top 差值 ≤1px、两两不相交、空态没有
// 行外按钮)由无头 Chromium 夹具实测:240/280/296/320/360/438/900 七档全部通过。
const { surface } = await openDirectCodexSurface();
const controls = surface.querySelector('.project-chat-composer-controls');
const polish = surface.querySelector('.resource-reference-input-polish');
const referenceTrigger = surface.querySelector(
'.project-chat-reference-trigger',
);
const input = surface.querySelector('.resource-reference-input');
expect(controls).not.toBeNull();
expect(polish).not.toBeNull();
expect(referenceTrigger).not.toBeNull();
expect(input).not.toBeNull();
expect(controls?.contains(polish as Node)).toBe(true);
// 润色钮和 `@` / `+` 同组(左组,输入类动作),挂在 `+` 之后。
expect(polish?.parentElement?.parentElement?.className).toContain(
'project-chat-composer-controls-actions',
);
expect(input?.contains(polish as Node)).toBe(false);
// 空输入态:工具条之外不再有第二行控件(润色状态行只在有内容时渲染)。
expect(
surface.querySelectorAll(
'form.project-chat-composer button:not(.conversation-model-menu *)',
),
).toHaveLength(6);
// 触发钮最窄可缩到 64px、名字靠省略号收住:必须带 `title`,否则截断后无从查看。
const modelTrigger = within(surface).getByRole('button', {
name: '对话模型',
});
expect(modelTrigger.getAttribute('title')).toBe(
modelTrigger.textContent?.trim(),
);
});
it('shows model service unavailability as a toast and leaves no persistent line', async () => {
const { surface } = await openDirectCodexSurface({
load_game_creator_llm_models: () => {