语音提示不再常驻 + 模型触发钮补 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
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:
+6
-1
@@ -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>
|
||||
|
||||
+6
-1
@@ -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: () => {
|
||||
|
||||
Reference in New Issue
Block a user