@nextclaw/ui 0.24.3 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/dist/.vite/manifest.json +208 -195
- package/dist/assets/{account-panel-BRTSwNvA.js → account-panel-UvHFa8Kl.js} +1 -1
- package/dist/assets/{api-BnUzdltV.js → api-Dffeij78.js} +5 -5
- package/dist/assets/{app-data-Bjh_zM45.js → app-data-DzaIMy4D.js} +1 -1
- package/dist/assets/app-presenter-provider-O2tUKKGf.js +109 -0
- package/dist/assets/{appearance-settings-page-BrOYSgG0.js → appearance-settings-page-D8cWniw-.js} +1 -1
- package/dist/assets/{channels-list-page-BE0TxB3B.js → channels-list-page-B_fYP1Pn.js} +1 -1
- package/dist/assets/{config-split-page-BxDwwAGa.js → config-split-page-6hHiiapl.js} +1 -1
- package/dist/assets/{confirm-dialog-S5JxZcS1.js → confirm-dialog-ClT6iaj8.js} +1 -1
- package/dist/assets/{desktop-capabilities-page-BwHxJpbl.js → desktop-capabilities-page-CB5pniTx.js} +1 -1
- package/dist/assets/{desktop-update-config-Bfg7OdJ2.js → desktop-update-config-OuQV73gg.js} +1 -1
- package/dist/assets/doc-browser-BFkslEzY.js +1 -0
- package/dist/assets/{doc-browser-C8eqJLi_.js → doc-browser-Cu58uiEg.js} +1 -1
- package/dist/assets/{doc-browser-context-BPCDFcC-.js → doc-browser-context-CekJxJFm.js} +1 -1
- package/dist/assets/{extensions-DgKQRebe.js → extensions-B5r-0jCq.js} +1 -1
- package/dist/assets/{i18n-CKvm9zyk.js → i18n-CPjq_hQX.js} +2 -2
- package/dist/assets/{i18n-provider-CPV43pMX.js → i18n-provider-BYO9AhFy.js} +1 -1
- package/dist/assets/keyboard-shortcuts-settings-page-1efEkGwq.js +1 -0
- package/dist/assets/{login-page-DeDQjVxv.js → login-page-DBqkVTVD.js} +1 -1
- package/dist/assets/login-page-DjcRQLQe.js +1 -0
- package/dist/assets/main-CLwCjK9U.css +1 -0
- package/dist/assets/main-CQ6GUYdX.js +2 -0
- package/dist/assets/main-i18n-catalog.config-QB4DfJRJ.js +1 -0
- package/dist/assets/{mcp-marketplace-page-CWjJGp_D.js → mcp-marketplace-page-CWAxBNaS.js} +1 -1
- package/dist/assets/mcp-marketplace-page-Ct090ceA.js +1 -0
- package/dist/assets/{model-config-page-Dv_zE7HN.js → model-config-page-DBcuZyRC.js} +1 -1
- package/dist/assets/panel-app-i18n-catalog.config-CQFujXqt.js +1 -0
- package/dist/assets/panel-app-maumVhbl.js +1 -0
- package/dist/assets/{panelStandalone-CFRBHV94.js → panelStandalone-BGA1s7JG.js} +2 -2
- package/dist/assets/{privacy-settings-page-D6uc9f-A.js → privacy-settings-page-DVC-aq1v.js} +1 -1
- package/dist/assets/projects-Cq0E3c7N.js +1 -0
- package/dist/assets/{provider-scoped-model-input-DUa_9uwg.js → provider-scoped-model-input-BGdZMWWa.js} +1 -1
- package/dist/assets/{providers-config-page-DAiqxlF_.js → providers-config-page-B-IhHl6x.js} +1 -1
- package/dist/assets/{pwa-C_Zte1xe.js → pwa-C3kR9mdy.js} +1 -1
- package/dist/assets/remote-Cnawibbg.js +1 -0
- package/dist/assets/runtime-config-page-LmrB2q65.js +1 -0
- package/dist/assets/{search-config-page-3xbhRlW2.js → search-config-page-BIXmv5FY.js} +1 -1
- package/dist/assets/{secrets-config-page-Dn8_rdOj.js → secrets-config-page-DU3fadrq.js} +1 -1
- package/dist/assets/security-config-9s0i0I4N.js +1 -0
- package/dist/assets/{service-apps-panel-CGc04aMn.js → service-apps-panel-Bshf3Wmq.js} +1 -1
- package/dist/assets/{tag-chip-BDzpHEli.js → tag-chip-gAH1IOsZ.js} +1 -1
- package/dist/assets/{theme-provider-B6FinATQ.js → theme-provider-DE7m9jvf.js} +1 -1
- package/dist/assets/{use-config-BPUM2lbo.js → use-config-Cyiw06-j.js} +1 -1
- package/dist/assets/{use-desktop-capabilities-BDjArQP3.js → use-desktop-capabilities-OKG96v4y.js} +1 -1
- package/dist/index.html +18 -18
- package/dist/panel-standalone.html +5 -5
- package/package.json +6 -6
- package/src/app/configs/app-navigation.config.ts +10 -3
- package/src/app/index.tsx +7 -0
- package/src/features/chat/components/providers/chat-presenter.provider.tsx +2 -0
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +12 -0
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +13 -1
- package/src/features/chat/features/conversation/components/__tests__/session-voice-input.test-utils.ts +52 -0
- package/src/features/chat/features/conversation/components/chat-voice-input-panel.tsx +70 -0
- package/src/features/chat/features/conversation/components/chat-voice-shortcut-control.tsx +33 -0
- package/src/features/chat/features/conversation/components/session-conversation-area.tsx +1 -11
- package/src/features/chat/features/conversation/components/session-conversation-input.tsx +29 -5
- package/src/features/chat/features/conversation/hooks/__tests__/use-chat-voice-input.test.tsx +185 -0
- package/src/features/chat/features/conversation/hooks/use-chat-voice-input.ts +52 -0
- package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +13 -6
- package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +28 -12
- package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +19 -1
- package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +7 -6
- package/src/features/chat/features/message/utils/chat-message-part.utils.ts +4 -1
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +20 -0
- package/src/features/chat/index.ts +1 -0
- package/src/features/chat/managers/chat-voice-input.manager.ts +174 -0
- package/src/features/chat/presenters/chat.presenter.ts +2 -0
- package/src/features/chat/stores/chat-voice-shortcut.store.ts +31 -0
- package/src/features/desktop-capabilities/index.ts +1 -0
- package/src/features/settings/pages/keyboard-shortcuts-settings-page.tsx +15 -0
- package/src/platforms/mobile/components/mobile-settings-shell.tsx +2 -1
- package/src/shared/lib/api/__tests__/ncp-session-query-cache.utils.test.ts +109 -1
- package/src/shared/lib/api/ncp-session-query-cache.utils.ts +71 -2
- package/src/shared/lib/i18n/locales/en-US/chat.json +32 -1
- package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +32 -1
- package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
- package/src/shared/lib/transport/__tests__/browser-realtime-recovery.service.test.ts +42 -0
- package/src/shared/lib/transport/__tests__/local-transport.service.test.ts +85 -0
- package/src/shared/lib/transport/__tests__/remote.transport.test.ts +54 -1
- package/src/shared/lib/transport/browser-realtime-recovery.service.ts +35 -0
- package/src/shared/lib/transport/local-transport.service.ts +32 -0
- package/src/shared/lib/transport/remote-transport.service.ts +30 -0
- package/dist/assets/app-presenter-provider-CBt-lyr_.js +0 -109
- package/dist/assets/doc-browser-BgRP0Wni.js +0 -1
- package/dist/assets/login-page-ClcrvN04.js +0 -1
- package/dist/assets/main-DGqlaAaI.css +0 -1
- package/dist/assets/main-i18n-catalog.config-CpNF5aVc.js +0 -1
- package/dist/assets/main-nMVfHb5K.js +0 -2
- package/dist/assets/mcp-marketplace-page-fvu2NcXT.js +0 -1
- package/dist/assets/panel-app-BUUle0gP.js +0 -1
- package/dist/assets/panel-app-i18n-catalog.config-BzxE3tZb.js +0 -1
- package/dist/assets/projects-BD1rG75R.js +0 -1
- package/dist/assets/remote-BO0QP7ua.js +0 -1
- package/dist/assets/runtime-config-page-w1UAkGF_.js +0 -1
- package/dist/assets/security-config-Y1nEGM3A.js +0 -1
|
@@ -31,6 +31,8 @@ import { useSessionConversationInputState } from '@/features/chat/features/conve
|
|
|
31
31
|
import { useChatMessageLayoutStore } from '@/features/chat/stores/chat-message-layout.store';
|
|
32
32
|
import { useChatComposerDraftStore } from '@/features/chat/stores/chat-composer-draft.store';
|
|
33
33
|
import { ChatComposerIntentManager } from '@/features/chat/managers/chat-composer-intent.manager';
|
|
34
|
+
import { verifyVoiceDraftLifecycle } from './session-voice-input.test-utils';
|
|
35
|
+
import { ChatVoiceInputManager } from '@/features/chat/managers/chat-voice-input.manager';
|
|
34
36
|
import type { ThinkingLevel } from '@/shared/lib/api';
|
|
35
37
|
|
|
36
38
|
const uploadNcpAssetsMock = vi.hoisted(() => vi.fn());
|
|
@@ -221,6 +223,7 @@ type StreamingInputControl = {
|
|
|
221
223
|
|
|
222
224
|
const chatComposerIntentManager = new ChatComposerIntentManager();
|
|
223
225
|
const presenter = {
|
|
226
|
+
chatVoiceInputManager: new ChatVoiceInputManager(),
|
|
224
227
|
chatComposerIntentManager,
|
|
225
228
|
chatThreadManager: {
|
|
226
229
|
openSideChatDraft: vi.fn(),
|
|
@@ -270,6 +273,13 @@ function createStreamingInputSnapshot(
|
|
|
270
273
|
};
|
|
271
274
|
}
|
|
272
275
|
|
|
276
|
+
it('dictation uses the latest draft and never sends; cancel discards late results', async () => {
|
|
277
|
+
await verifyVoiceDraftLifecycle((voiceManager, send) => renderInput(
|
|
278
|
+
<StreamingSessionConversationInputHarness voiceManager={voiceManager} controlRef={{ current: null }}
|
|
279
|
+
initialText='Original' controllerOverride={{ ...controller, send, sendDisabled: false }} />,
|
|
280
|
+
));
|
|
281
|
+
});
|
|
282
|
+
|
|
273
283
|
function setImeDomText(textbox: HTMLElement, text: string): HTMLParagraphElement {
|
|
274
284
|
const paragraph = textbox.querySelector('p');
|
|
275
285
|
if (!paragraph) {
|
|
@@ -307,12 +317,14 @@ async function insertText(textbox: HTMLElement, text: string): Promise<void> {
|
|
|
307
317
|
}
|
|
308
318
|
|
|
309
319
|
function StreamingSessionConversationInputHarness({
|
|
320
|
+
voiceManager = presenter.chatVoiceInputManager,
|
|
310
321
|
controllerOverride = controller,
|
|
311
322
|
controlRef,
|
|
312
323
|
initialText = '',
|
|
313
324
|
sendError = null,
|
|
314
325
|
}: {
|
|
315
326
|
controllerOverride?: SessionConversationInputController;
|
|
327
|
+
voiceManager?: ChatVoiceInputManager;
|
|
316
328
|
controlRef: MutableRefObject<StreamingInputControl | null>;
|
|
317
329
|
initialText?: string;
|
|
318
330
|
sendError?: string | null;
|
|
@@ -389,7 +401,7 @@ function StreamingSessionConversationInputHarness({
|
|
|
389
401
|
|
|
390
402
|
return (
|
|
391
403
|
<I18nProvider>
|
|
392
|
-
<ChatPresenterProvider presenter={presenter}>
|
|
404
|
+
<ChatPresenterProvider presenter={{ ...presenter, chatVoiceInputManager: voiceManager }}>
|
|
393
405
|
<div data-testid="stream-chunk">{streamChunk}</div>
|
|
394
406
|
<SessionConversationInput
|
|
395
407
|
contextWindow={null}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { expect, vi } from 'vitest';
|
|
3
|
+
import { ChatVoiceInputManager, type SpeechRecognitionLike } from '@/features/chat/managers/chat-voice-input.manager';
|
|
4
|
+
|
|
5
|
+
/** Assembled voice/draft lifecycle check, shared with the real streaming input harness. */
|
|
6
|
+
export async function verifyVoiceDraftLifecycle(
|
|
7
|
+
renderHarness: (manager: ChatVoiceInputManager, send: () => void) => { unmount: () => void },
|
|
8
|
+
) {
|
|
9
|
+
const recognition: SpeechRecognitionLike = {
|
|
10
|
+
lang: '', continuous: false, interimResults: false,
|
|
11
|
+
start: vi.fn(), stop: vi.fn(), abort: vi.fn(),
|
|
12
|
+
onstart: null, onend: null, onresult: null, onerror: null,
|
|
13
|
+
};
|
|
14
|
+
const manager = new ChatVoiceInputManager(() => recognition);
|
|
15
|
+
const send = vi.fn();
|
|
16
|
+
const rendered = renderHarness(manager, send);
|
|
17
|
+
try {
|
|
18
|
+
fireEvent.click(screen.getByRole('button', { name: /^(语音输入|Voice input)$/ }));
|
|
19
|
+
act(() => { recognition.onstart?.(); });
|
|
20
|
+
const textbox = screen.getByRole('textbox');
|
|
21
|
+
const before = textbox.textContent;
|
|
22
|
+
act(() => { recognition.onresult?.({ results: [{ isFinal: false, 0: { transcript: 'speaking' } }] }); });
|
|
23
|
+
await waitFor(() => expect(textbox.textContent).toContain('speaking'));
|
|
24
|
+
expect(textbox.querySelector('[style*="underline"]')?.textContent).toBe('speaking');
|
|
25
|
+
act(() => { recognition.onresult?.({ results: [{ isFinal: false, 0: { transcript: 'speak' } }] }); });
|
|
26
|
+
await waitFor(() => expect(textbox.textContent).not.toContain('speaking'));
|
|
27
|
+
act(() => { recognition.onresult?.({ results: [{ isFinal: true, 0: { transcript: 'spoken' } }] }); });
|
|
28
|
+
fireEvent.click(screen.getAllByRole('button', { name: /结束录音|Finish recording/ })[0]);
|
|
29
|
+
act(() => { recognition.onend?.(); });
|
|
30
|
+
await waitFor(() => expect(textbox.textContent).toContain('spoken'));
|
|
31
|
+
expect(textbox.textContent).toContain(before);
|
|
32
|
+
expect(textbox.querySelector('[style*="underline"]')).toBeNull();
|
|
33
|
+
expect(send).not.toHaveBeenCalled();
|
|
34
|
+
fireEvent.click(screen.getByRole('button', { name: /^(语音输入|Voice input)$/ }));
|
|
35
|
+
const lateResult = recognition.onresult;
|
|
36
|
+
const lateEnd = recognition.onend;
|
|
37
|
+
fireEvent.click(screen.getAllByRole('button', { name: /^(取消|Cancel)$/ })[0]);
|
|
38
|
+
act(() => {
|
|
39
|
+
lateResult?.({ results: [{ isFinal: true, 0: { transcript: 'discard' } }] }); lateEnd?.();
|
|
40
|
+
});
|
|
41
|
+
expect(textbox.textContent).not.toContain('discard');
|
|
42
|
+
fireEvent.click(screen.getByRole('button', { name: /^(语音输入|Voice input)$/ }));
|
|
43
|
+
act(() => { recognition.onstart?.(); });
|
|
44
|
+
fireEvent.click(screen.getByRole('button', { name: /^(语音快捷键|Voice shortcut)$/ }));
|
|
45
|
+
act(() => { recognition.onerror?.({ error: 'no-speech' }); });
|
|
46
|
+
await waitFor(() => expect(screen.getByRole('button', { name: /绑定按键|Bind a key/ })).toBeTruthy());
|
|
47
|
+
expect(screen.queryByRole('alert')).toBeNull();
|
|
48
|
+
expect(screen.queryByRole('button', { name: /^(重试|Retry)$/ })).toBeNull();
|
|
49
|
+
} finally {
|
|
50
|
+
rendered.unmount();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Mic, Square, X, LoaderCircle, Settings } from 'lucide-react';
|
|
2
|
+
import { Button } from '@/shared/components/ui/button';
|
|
3
|
+
import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
|
|
4
|
+
import { t } from '@/shared/lib/i18n';
|
|
5
|
+
import type { ChatVoiceInputManager, VoiceError, VoiceSnapshot } from '@/features/chat/managers/chat-voice-input.manager';
|
|
6
|
+
import { ChatVoiceShortcutControl } from './chat-voice-shortcut-control';
|
|
7
|
+
|
|
8
|
+
const ERROR_KEYS: Record<VoiceError, string> = {
|
|
9
|
+
unsupported: 'chatInputVoiceUnsupported', permission: 'chatInputVoicePermissionDenied',
|
|
10
|
+
network: 'chatInputVoiceNetwork', 'audio-capture': 'chatInputVoiceDevice',
|
|
11
|
+
'no-speech': 'chatInputVoiceNoSpeech', interrupted: 'chatInputVoiceInterrupted',
|
|
12
|
+
timeout: 'chatInputVoiceTimeout', failed: 'chatInputVoiceFailed',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function ChatVoiceInputPanel({ manager, snapshot, onStart, desktop }: {
|
|
16
|
+
manager: ChatVoiceInputManager;
|
|
17
|
+
snapshot: VoiceSnapshot;
|
|
18
|
+
onStart: () => void;
|
|
19
|
+
desktop: boolean;
|
|
20
|
+
}) {
|
|
21
|
+
const { phase, error, seconds } = snapshot;
|
|
22
|
+
if (phase === 'ready') return desktop ? <VoiceShortcutPanel onClose={manager.cancel} /> : null;
|
|
23
|
+
const busy = phase === 'starting' || phase === 'stopping';
|
|
24
|
+
const status = {
|
|
25
|
+
idle: 'chatInputVoice', ready: 'chatInputVoiceShortcut', starting: 'chatInputVoiceStarting',
|
|
26
|
+
recording: 'chatInputVoiceRecording', stopping: 'chatInputVoiceFinishing', error: 'chatInputVoiceFailed',
|
|
27
|
+
}[phase];
|
|
28
|
+
return (
|
|
29
|
+
<div role='region' aria-label={t('chatInputVoice')}
|
|
30
|
+
className={'max-h-[var(--radix-popover-content-available-height,24rem)] max-w-full overflow-y-auto border border-border bg-popover text-popover-foreground shadow-lg ' +
|
|
31
|
+
(error ? 'w-80 rounded-xl p-3' : 'rounded-full px-3 py-1.5')}
|
|
32
|
+
onKeyDown={(event) => {
|
|
33
|
+
if (event.key === 'Escape' && !event.nativeEvent.isComposing) {
|
|
34
|
+
event.stopPropagation(); manager.cancel();
|
|
35
|
+
}
|
|
36
|
+
}}>
|
|
37
|
+
<div className='flex items-center gap-2'>
|
|
38
|
+
{busy ? <LoaderCircle className='h-4 w-4 shrink-0 motion-safe:animate-spin text-muted-foreground' />
|
|
39
|
+
: <Mic className={'h-4 w-4 shrink-0 ' + (phase === 'recording' ? 'text-destructive' : '')} />}
|
|
40
|
+
<span role='status' className='min-w-0 flex-1 text-xs font-medium'>{t(status)}</span>
|
|
41
|
+
{phase === 'recording' ? <span className='text-xs tabular-nums text-muted-foreground'>{seconds}s</span> : null}
|
|
42
|
+
{phase === 'recording' ? <IconActionButton icon={<Square className='h-3 w-3' />}
|
|
43
|
+
label={t('chatInputVoiceFinish')} onClick={manager.finish} /> : null}
|
|
44
|
+
<IconActionButton icon={<X className='h-4 w-4' />} label={t('chatInputVoiceCancel')} onClick={manager.cancel} />
|
|
45
|
+
{desktop ? <IconActionButton icon={<Settings className='h-4 w-4' />}
|
|
46
|
+
label={t('chatInputVoiceShortcut')} disabled={busy} onClick={manager.openSettings} /> : null}
|
|
47
|
+
</div>
|
|
48
|
+
{error ? <>
|
|
49
|
+
<p role='alert' className='mt-2 text-xs leading-5 text-destructive'>{t(ERROR_KEYS[error])}</p>
|
|
50
|
+
<p className='mt-1 text-xs text-muted-foreground'>{t('chatInputVoiceReviewHint')}</p>
|
|
51
|
+
<Button className='mt-2' size='sm' disabled={error === 'unsupported'} onClick={onStart}>{t('chatInputVoiceRetry')}</Button>
|
|
52
|
+
</> : null}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function VoiceShortcutPanel({ onClose }: { onClose: () => void }) {
|
|
58
|
+
return <div role='region' aria-label={t('chatInputVoiceShortcut')}
|
|
59
|
+
className='max-h-[var(--radix-popover-content-available-height,24rem)] w-80 max-w-full overflow-y-auto rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-lg'
|
|
60
|
+
onKeyDown={(event) => {
|
|
61
|
+
if (event.key === 'Escape' && !event.nativeEvent.isComposing) { event.stopPropagation(); onClose(); }
|
|
62
|
+
}}>
|
|
63
|
+
<div className='flex items-center gap-2'>
|
|
64
|
+
<Settings className='h-4 w-4' />
|
|
65
|
+
<span className='flex-1 text-sm font-medium'>{t('chatInputVoiceShortcut')}</span>
|
|
66
|
+
<IconActionButton icon={<X className='h-4 w-4' />} label={t('chatInputVoiceCloseSettings')} onClick={onClose} />
|
|
67
|
+
</div>
|
|
68
|
+
<ChatVoiceShortcutControl />
|
|
69
|
+
</div>;
|
|
70
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Button } from '@/shared/components/ui/button';
|
|
3
|
+
import { t } from '@/shared/lib/i18n';
|
|
4
|
+
import { isVoiceShortcut, useChatVoiceShortcutStore, voiceShortcutLabel } from '@/features/chat/stores/chat-voice-shortcut.store';
|
|
5
|
+
|
|
6
|
+
export function ChatVoiceShortcutControl() {
|
|
7
|
+
const { shortcut, setShortcut } = useChatVoiceShortcutStore();
|
|
8
|
+
const [capturing, setCapturing] = useState(false);
|
|
9
|
+
const [error, setError] = useState<string | null>(null);
|
|
10
|
+
return <div className='mt-3 border-t border-border/60 pt-3 text-xs text-muted-foreground'>
|
|
11
|
+
<div className='flex flex-wrap items-center gap-2'>
|
|
12
|
+
<Button variant='ghost' size='sm' aria-label={t('chatInputVoiceBind')}
|
|
13
|
+
onClick={() => { setCapturing(true); setError(null); }} onBlur={() => setCapturing(false)}
|
|
14
|
+
onKeyDown={(event) => {
|
|
15
|
+
if (!capturing || event.nativeEvent.isComposing) return;
|
|
16
|
+
if (event.key === 'Escape') { event.stopPropagation(); setCapturing(false); return; }
|
|
17
|
+
if (event.key === 'Tab') return;
|
|
18
|
+
event.preventDefault(); event.stopPropagation();
|
|
19
|
+
const value = { code: event.code, ctrl: event.ctrlKey, meta: event.metaKey, alt: event.altKey, shift: event.shiftKey };
|
|
20
|
+
if (!isVoiceShortcut(value)) { setError('chatInputVoiceBindInvalid'); return; }
|
|
21
|
+
try { setShortcut(value); setCapturing(false); setError(null); }
|
|
22
|
+
catch { setError('chatInputVoiceSaveFailed'); }
|
|
23
|
+
}}>
|
|
24
|
+
{capturing ? t('chatInputVoiceCapture') : shortcut ? voiceShortcutLabel(shortcut) : t('chatInputVoiceBind')}
|
|
25
|
+
</Button>
|
|
26
|
+
{shortcut ? <Button variant='ghost' size='sm' onClick={() => {
|
|
27
|
+
try { setShortcut(null); setError(null); } catch { setError('chatInputVoiceSaveFailed'); }
|
|
28
|
+
}}>{t('chatInputVoiceUnbind')}</Button> : null}
|
|
29
|
+
</div>
|
|
30
|
+
<p role={capturing ? 'status' : undefined} className='mt-1 leading-5'>{t(capturing ? 'chatInputVoiceCaptureHelp' : 'chatInputVoiceShortcutHelp')}</p>
|
|
31
|
+
{error ? <p role='alert' className='mt-1 text-destructive'>{t(error)}</p> : null}
|
|
32
|
+
</div>;
|
|
33
|
+
}
|
|
@@ -277,8 +277,6 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
|
|
|
277
277
|
setPendingSessionType: inputActions.setPendingSessionType,
|
|
278
278
|
});
|
|
279
279
|
const isRuntimeBlocked = isNcpChatRuntimeBlocked(systemStatus);
|
|
280
|
-
const currentSessionRunning =
|
|
281
|
-
agent.isRunning || inputQuery.selectedSession?.status === "running";
|
|
282
280
|
const runtimeError = agent.snapshot.error;
|
|
283
281
|
const rawLastSendError = agent.hydrateError?.message
|
|
284
282
|
?? (runtimeError?.code === "run-interrupted" ? null : runtimeError?.message)
|
|
@@ -296,16 +294,8 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
|
|
|
296
294
|
message: filteredLastSendError,
|
|
297
295
|
status: systemStatus,
|
|
298
296
|
});
|
|
299
|
-
const controllerAgent = useMemo(
|
|
300
|
-
() => ({
|
|
301
|
-
...agent,
|
|
302
|
-
isRunning: currentSessionRunning,
|
|
303
|
-
isSending: agent.isSending,
|
|
304
|
-
}),
|
|
305
|
-
[agent, currentSessionRunning],
|
|
306
|
-
);
|
|
307
297
|
const controller = useSessionConversationController({
|
|
308
|
-
agent
|
|
298
|
+
agent,
|
|
309
299
|
inputSnapshot,
|
|
310
300
|
inputQuery,
|
|
311
301
|
isRuntimeBlocked,
|
|
@@ -12,11 +12,15 @@ import {
|
|
|
12
12
|
} from '@nextclaw/agent-chat-ui';
|
|
13
13
|
import { isRuntimeDefaultModelValue } from '@nextclaw/shared';
|
|
14
14
|
|
|
15
|
+
import { Mic, Square } from 'lucide-react';
|
|
16
|
+
import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
|
|
17
|
+
import { ChatVoiceInputPanel } from './chat-voice-input-panel';
|
|
15
18
|
import { useI18n } from '@/app/components/i18n-provider';
|
|
16
19
|
import { useViewportLayout } from '@/app/hooks/use-viewport-layout';
|
|
17
20
|
import { type SessionSkillEntryView } from '@/shared/lib/api';
|
|
18
21
|
import { t } from '@/shared/lib/i18n';
|
|
19
22
|
import { usePresenter } from '@/features/chat/components/providers/chat-presenter.provider';
|
|
23
|
+
import { useChatVoiceInput } from '@/features/chat/features/conversation/hooks/use-chat-voice-input';
|
|
20
24
|
import {
|
|
21
25
|
useChatInputSurfaceState,
|
|
22
26
|
} from '@/features/chat/features/input/hooks/use-chat-input-surface-state';
|
|
@@ -288,6 +292,17 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
|
|
|
288
292
|
const persistSessionPreferences = useSessionConversationPreferencePersistence({ inputActions, selectedSessionKey: inputQuery.selectedSessionKey });
|
|
289
293
|
const handleNodesChange = useSessionConversationComposerNodes(inputActions);
|
|
290
294
|
|
|
295
|
+
const voiceManager = presenter.chatVoiceInputManager;
|
|
296
|
+
const voiceContext = JSON.stringify([
|
|
297
|
+
inputQuery.selectedSessionKey, inputQuery.sessionTypeState.selectedSessionType,
|
|
298
|
+
contextReferenceProjectRoot, selectedModel, inputDisabled,
|
|
299
|
+
]);
|
|
300
|
+
const beginVoiceDraft = useCallback(() => inputBarRef.current?.beginDictation(voiceManager.cancel) ?? null, [voiceManager]);
|
|
301
|
+
const voice = useChatVoiceInput(voiceManager, voiceContext, language, !isMobile && !inputDisabled, beginVoiceDraft);
|
|
302
|
+
const startVoice = () => voiceManager.start(language, voiceContext, beginVoiceDraft());
|
|
303
|
+
const sendWithVoiceCancelled = () => { voiceManager.acceptDraft(); return controller.send(); };
|
|
304
|
+
const steerWithVoiceCancelled = () => { voiceManager.acceptDraft(); return controller.sendSteering(); };
|
|
305
|
+
|
|
291
306
|
useChatComposerReferenceIntent({
|
|
292
307
|
inputBarRef,
|
|
293
308
|
intentManager: presenter.chatComposerIntentManager,
|
|
@@ -416,9 +431,9 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
|
|
|
416
431
|
<ChatInputBar
|
|
417
432
|
ref={inputBarRef}
|
|
418
433
|
surface={useReadingTrack ? 'embedded' : surface}
|
|
419
|
-
topSlot={controller.queuedInputs.length > 0
|
|
420
|
-
|
|
421
|
-
: null}
|
|
434
|
+
topSlot={controller.queuedInputs.length > 0 ? <SessionQueuedInputRows controller={controller} /> : null}
|
|
435
|
+
floatingSlot={voice.phase !== 'idle' ? <ChatVoiceInputPanel manager={voiceManager} snapshot={voice}
|
|
436
|
+
desktop={!isMobile} onStart={startVoice} /> : null}
|
|
422
437
|
composer={{
|
|
423
438
|
nodes: composerNodes,
|
|
424
439
|
placeholder: textareaPlaceholder,
|
|
@@ -441,6 +456,15 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
|
|
|
441
456
|
})}
|
|
442
457
|
toolbar={{
|
|
443
458
|
addMenuLabel: t('chatInputAdd'),
|
|
459
|
+
leadingSlot: <>
|
|
460
|
+
<IconActionButton icon={voice.phase === 'recording' ? <Square className='h-4 w-4' /> : <Mic className='h-4 w-4' />}
|
|
461
|
+
label={t(voice.phase === 'recording' ? 'chatInputVoiceFinish' : 'chatInputVoice')}
|
|
462
|
+
size='lg'
|
|
463
|
+
disabled={inputDisabled || voice.phase === 'stopping'}
|
|
464
|
+
aria-pressed={voice.phase === 'recording'}
|
|
465
|
+
onClick={() => voice.phase === 'recording' ? voiceManager.finish()
|
|
466
|
+
: voice.phase === 'starting' ? voiceManager.cancel() : startVoice()} />
|
|
467
|
+
</>,
|
|
444
468
|
selects: [],
|
|
445
469
|
trailingSelects: toolbarSelects,
|
|
446
470
|
accessories: [
|
|
@@ -465,8 +489,8 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
|
|
|
465
489
|
sendIcon: controller.primaryAction,
|
|
466
490
|
stopButtonLabel: t('chatStop'),
|
|
467
491
|
contextWindow,
|
|
468
|
-
onSend:
|
|
469
|
-
onAlternateSend:
|
|
492
|
+
onSend: sendWithVoiceCancelled,
|
|
493
|
+
onAlternateSend: steerWithVoiceCancelled,
|
|
470
494
|
onStop: controller.stop,
|
|
471
495
|
},
|
|
472
496
|
}}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { ChatVoiceInputManager, type SpeechRecognitionLike } from '@/features/chat/managers/chat-voice-input.manager';
|
|
4
|
+
import { useChatVoiceInput } from '@/features/chat/features/conversation/hooks/use-chat-voice-input';
|
|
5
|
+
import { isVoiceShortcut, useChatVoiceShortcutStore } from '@/features/chat/stores/chat-voice-shortcut.store';
|
|
6
|
+
import type { ChatComposerDictationSession } from '@nextclaw/agent-chat-ui';
|
|
7
|
+
|
|
8
|
+
class Recognition implements SpeechRecognitionLike {
|
|
9
|
+
lang = '';
|
|
10
|
+
continuous = false;
|
|
11
|
+
interimResults = false;
|
|
12
|
+
start = vi.fn();
|
|
13
|
+
stop = vi.fn();
|
|
14
|
+
abort = vi.fn();
|
|
15
|
+
onstart: SpeechRecognitionLike['onstart'] = null;
|
|
16
|
+
onend: SpeechRecognitionLike['onend'] = null;
|
|
17
|
+
onerror: SpeechRecognitionLike['onerror'] = null;
|
|
18
|
+
onresult: SpeechRecognitionLike['onresult'] = null;
|
|
19
|
+
result = (...texts: string[]) => this.onresult?.({ results: texts.map((transcript) => ({ isFinal: true, 0: { transcript } })) });
|
|
20
|
+
}
|
|
21
|
+
let recognition: Recognition;
|
|
22
|
+
let manager: ChatVoiceInputManager;
|
|
23
|
+
let commit = vi.fn<(text?: string) => void>();
|
|
24
|
+
let draft: ChatComposerDictationSession;
|
|
25
|
+
const beginDraft = () => draft;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.useFakeTimers();
|
|
28
|
+
recognition = new Recognition();
|
|
29
|
+
manager = new ChatVoiceInputManager(() => recognition);
|
|
30
|
+
commit = vi.fn<(text?: string) => void>();
|
|
31
|
+
draft = { commit, update: vi.fn(), cancel: vi.fn() };
|
|
32
|
+
useChatVoiceShortcutStore.getState().setShortcut(null);
|
|
33
|
+
vi.spyOn(document, 'hasFocus').mockReturnValue(true);
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
manager.cancel();
|
|
37
|
+
vi.useRealTimers();
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('dictation lifecycle', () => {
|
|
42
|
+
it('waits for the final transcript before opening settings without restarting recording', () => {
|
|
43
|
+
manager.start('zh', 'one', draft);
|
|
44
|
+
recognition.onstart?.();
|
|
45
|
+
recognition.result('first');
|
|
46
|
+
manager.openSettings();
|
|
47
|
+
expect(manager.getSnapshot().phase).toBe('stopping');
|
|
48
|
+
expect(commit).not.toHaveBeenCalled();
|
|
49
|
+
recognition.result('first', 'tail');
|
|
50
|
+
recognition.onend?.();
|
|
51
|
+
expect(commit).toHaveBeenCalledExactlyOnceWith('first tail');
|
|
52
|
+
expect(manager.getSnapshot().phase).toBe('ready');
|
|
53
|
+
manager.cancel();
|
|
54
|
+
expect(recognition.start).toHaveBeenCalledOnce();
|
|
55
|
+
});
|
|
56
|
+
it('still opens settings when finishing recognition fails', () => {
|
|
57
|
+
manager.start('en', 'one', draft);
|
|
58
|
+
recognition.onstart?.();
|
|
59
|
+
manager.openSettings();
|
|
60
|
+
recognition.onerror?.({ error: 'no-speech' });
|
|
61
|
+
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', error: null, text: '', interim: '' });
|
|
62
|
+
});
|
|
63
|
+
it('replaces cumulative results, waits for the final tail, and inserts once', () => {
|
|
64
|
+
manager.start('zh', 'one', draft);
|
|
65
|
+
expect(manager.getSnapshot().phase).toBe('starting');
|
|
66
|
+
recognition.onstart?.();
|
|
67
|
+
recognition.result('hello');
|
|
68
|
+
recognition.result('hello', 'world');
|
|
69
|
+
expect(commit).not.toHaveBeenCalled();
|
|
70
|
+
manager.finish();
|
|
71
|
+
expect(recognition.stop).toHaveBeenCalledOnce();
|
|
72
|
+
recognition.result('hello', 'world', 'again');
|
|
73
|
+
recognition.onend?.();
|
|
74
|
+
expect(commit).toHaveBeenCalledExactlyOnceWith('hello world again');
|
|
75
|
+
expect(manager.getSnapshot().phase).toBe('idle');
|
|
76
|
+
});
|
|
77
|
+
it('ignores late callbacks after cancellation and a new recording', () => {
|
|
78
|
+
manager.start('en', 'one', draft);
|
|
79
|
+
const lateEnd = recognition.onend;
|
|
80
|
+
const lateResult = recognition.onresult;
|
|
81
|
+
manager.cancel();
|
|
82
|
+
expect(recognition.abort).toHaveBeenCalledOnce();
|
|
83
|
+
recognition = new Recognition();
|
|
84
|
+
manager.start('zh', 'two', draft);
|
|
85
|
+
lateResult?.({ results: [{ isFinal: true, 0: { transcript: 'old' } }] });
|
|
86
|
+
lateEnd?.();
|
|
87
|
+
expect(commit).not.toHaveBeenCalled();
|
|
88
|
+
expect(manager.getSnapshot().text).toBe('');
|
|
89
|
+
expect(recognition.lang).toBe('zh-CN');
|
|
90
|
+
});
|
|
91
|
+
it('cancels a quick release before permission or onstart without later recording', () => {
|
|
92
|
+
manager.start('en', 'one', draft);
|
|
93
|
+
const lateStart = recognition.onstart;
|
|
94
|
+
manager.finish();
|
|
95
|
+
lateStart?.();
|
|
96
|
+
expect(recognition.abort).toHaveBeenCalledOnce();
|
|
97
|
+
expect(manager.getSnapshot().phase).toBe('idle');
|
|
98
|
+
vi.advanceTimersByTime(20000);
|
|
99
|
+
expect(manager.getSnapshot().phase).toBe('idle');
|
|
100
|
+
});
|
|
101
|
+
it.each(['not-allowed', 'network', 'audio-capture', 'no-speech'])('surfaces %s without modifying the draft', (error) => {
|
|
102
|
+
manager.start('en', 'one', draft);
|
|
103
|
+
recognition.onerror?.({ error });
|
|
104
|
+
expect(manager.getSnapshot().phase).toBe('error');
|
|
105
|
+
expect(recognition.abort).toHaveBeenCalledOnce();
|
|
106
|
+
expect(commit).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
it('commits confirmed text on interruption without a second insertion', () => {
|
|
109
|
+
manager.start('en', 'one', draft);
|
|
110
|
+
recognition.onstart?.();
|
|
111
|
+
recognition.result('keep this');
|
|
112
|
+
manager.interrupt();
|
|
113
|
+
expect(commit).toHaveBeenCalledExactlyOnceWith('keep this');
|
|
114
|
+
manager.cancel();
|
|
115
|
+
expect(commit).toHaveBeenCalledOnce();
|
|
116
|
+
});
|
|
117
|
+
it('times out startup and stopping and ends recording at 60 seconds', () => {
|
|
118
|
+
manager.start('en', 'one', draft);
|
|
119
|
+
vi.advanceTimersByTime(15000);
|
|
120
|
+
expect(manager.getSnapshot().error).toBe('timeout');
|
|
121
|
+
manager.start('en', 'one', draft);
|
|
122
|
+
recognition.onstart?.();
|
|
123
|
+
vi.advanceTimersByTime(60000);
|
|
124
|
+
expect(manager.getSnapshot().phase).toBe('stopping');
|
|
125
|
+
expect(recognition.stop).toHaveBeenCalledOnce();
|
|
126
|
+
vi.advanceTimersByTime(8000);
|
|
127
|
+
expect(manager.getSnapshot().error).toBe('timeout');
|
|
128
|
+
});
|
|
129
|
+
it('handles unsupported APIs and thrown starts', () => {
|
|
130
|
+
const unsupported = new ChatVoiceInputManager(() => null);
|
|
131
|
+
unsupported.start('en', 'one', draft);
|
|
132
|
+
expect(unsupported.getSnapshot().error).toBe('unsupported');
|
|
133
|
+
recognition.start.mockImplementation(() => { throw new Error('device'); });
|
|
134
|
+
manager.start('en', 'one', draft);
|
|
135
|
+
expect(manager.getSnapshot().error).toBe('failed');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('keyboard and page integration', () => {
|
|
141
|
+
const bind = (ctrl = false, alt = false) => useChatVoiceShortcutStore.getState().setShortcut({ code: 'KeyV', ctrl, meta: false, alt, shift: false });
|
|
142
|
+
const key = (type: string, options: KeyboardEventInit = {}) => new KeyboardEvent(type, { code: 'KeyV', bubbles: true, cancelable: true, ...options });
|
|
143
|
+
it('never steals paste, normal typing, or input-method composition', () => {
|
|
144
|
+
bind();
|
|
145
|
+
renderHook(() => useChatVoiceInput(manager, 'one', 'en', true, beginDraft));
|
|
146
|
+
const input = document.createElement('textarea'); document.body.append(input);
|
|
147
|
+
const typing = key('keydown');
|
|
148
|
+
const paste = key('keydown', { ctrlKey: true });
|
|
149
|
+
const composing = key('keydown', { isComposing: true });
|
|
150
|
+
act(() => { input.dispatchEvent(typing); input.dispatchEvent(paste); window.dispatchEvent(composing); });
|
|
151
|
+
expect(typing.defaultPrevented).toBe(false);
|
|
152
|
+
expect(paste.defaultPrevented).toBe(false);
|
|
153
|
+
expect(recognition.start).not.toHaveBeenCalled();
|
|
154
|
+
input.remove();
|
|
155
|
+
});
|
|
156
|
+
it('holds a configured combination and stops when a modifier is released', () => {
|
|
157
|
+
bind(true, true);
|
|
158
|
+
renderHook(() => useChatVoiceInput(manager, 'one', 'en', true, beginDraft));
|
|
159
|
+
act(() => { window.dispatchEvent(key('keydown', { ctrlKey: true, altKey: true })); recognition.onstart?.(); });
|
|
160
|
+
expect(recognition.start).toHaveBeenCalledOnce();
|
|
161
|
+
act(() => { window.dispatchEvent(key('keyup', { code: 'AltLeft', key: 'Alt' })); });
|
|
162
|
+
expect(recognition.stop).toHaveBeenCalledOnce();
|
|
163
|
+
});
|
|
164
|
+
it('does not register recording shortcuts in a mobile or disabled composer', () => {
|
|
165
|
+
bind();
|
|
166
|
+
renderHook(() => useChatVoiceInput(manager, 'one', 'en', false, beginDraft));
|
|
167
|
+
act(() => { window.dispatchEvent(key('keydown')); });
|
|
168
|
+
expect(recognition.start).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
it('cancels recording on context switch and unmount', () => {
|
|
171
|
+
const hook = renderHook(({ context }) => useChatVoiceInput(manager, context, 'en', true, beginDraft), { initialProps: { context: 'one' } });
|
|
172
|
+
act(() => { manager.start('en', 'one', draft); });
|
|
173
|
+
hook.rerender({ context: 'two' });
|
|
174
|
+
expect(recognition.abort).toHaveBeenCalledOnce();
|
|
175
|
+
expect(manager.getSnapshot().phase).toBe('idle');
|
|
176
|
+
act(() => { manager.start('en', 'two', draft); });
|
|
177
|
+
hook.unmount();
|
|
178
|
+
expect(recognition.abort).toHaveBeenCalledTimes(2);
|
|
179
|
+
});
|
|
180
|
+
it('rejects editing keys and persisted invalid shortcut definitions', () => {
|
|
181
|
+
expect(isVoiceShortcut({ code: 'KeyV', ctrl: true, meta: false, alt: false, shift: false })).toBe(false);
|
|
182
|
+
expect(isVoiceShortcut({ code: 'Enter', ctrl: false, meta: false, alt: false, shift: false })).toBe(false);
|
|
183
|
+
expect(isVoiceShortcut({ code: 'KeyV', ctrl: false, meta: false, alt: false, shift: false })).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore } from 'react';
|
|
2
|
+
import type { ChatComposerDictationSession } from '@nextclaw/agent-chat-ui';
|
|
3
|
+
import type { ChatVoiceInputManager } from '@/features/chat/managers/chat-voice-input.manager';
|
|
4
|
+
import { useChatVoiceShortcutStore } from '@/features/chat/stores/chat-voice-shortcut.store';
|
|
5
|
+
|
|
6
|
+
export function useChatVoiceInput(manager: ChatVoiceInputManager, context: string, language: string,
|
|
7
|
+
keyboardEnabled: boolean, beginDraft: () => ChatComposerDictationSession | null) {
|
|
8
|
+
const snapshot = useSyncExternalStore(manager.subscribe, manager.getSnapshot, manager.getSnapshot);
|
|
9
|
+
const shortcut = useChatVoiceShortcutStore((state) => state.shortcut);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!keyboardEnabled || !shortcut) return;
|
|
12
|
+
let held = false;
|
|
13
|
+
const keydown = (event: KeyboardEvent) => {
|
|
14
|
+
if (event.defaultPrevented || event.isComposing || event.repeat || event.code !== shortcut.code ||
|
|
15
|
+
event.ctrlKey !== shortcut.ctrl || event.metaKey !== shortcut.meta ||
|
|
16
|
+
event.altKey !== shortcut.alt || event.shiftKey !== shortcut.shift ||
|
|
17
|
+
!document.hasFocus()) return;
|
|
18
|
+
if (!['idle', 'ready'].includes(manager.getSnapshot().phase)) return;
|
|
19
|
+
const editable = event.target instanceof HTMLElement &&
|
|
20
|
+
(event.target.isContentEditable || event.target.closest('input, textarea, select, [contenteditable="true"], [role="textbox"]'));
|
|
21
|
+
if (editable && !shortcut.ctrl && !shortcut.meta && !shortcut.alt) return;
|
|
22
|
+
event.preventDefault();
|
|
23
|
+
held = true;
|
|
24
|
+
manager.start(language, context, beginDraft());
|
|
25
|
+
};
|
|
26
|
+
const keyup = (event: KeyboardEvent) => {
|
|
27
|
+
if (held && (event.code === shortcut.code || ['Alt', 'Control', 'Meta', 'Shift'].includes(event.key))) {
|
|
28
|
+
held = false; manager.finish();
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
window.addEventListener('keydown', keydown);
|
|
32
|
+
window.addEventListener('keyup', keyup);
|
|
33
|
+
return () => {
|
|
34
|
+
window.removeEventListener('keydown', keydown);
|
|
35
|
+
window.removeEventListener('keyup', keyup);
|
|
36
|
+
if (held) manager.cancel();
|
|
37
|
+
};
|
|
38
|
+
}, [manager, context, language, keyboardEnabled, beginDraft, shortcut]);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const onVisibility = () => { if (document.hidden) manager.interrupt(); };
|
|
41
|
+
window.addEventListener('blur', manager.interrupt);
|
|
42
|
+
window.addEventListener('pagehide', manager.cancel);
|
|
43
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
44
|
+
return () => {
|
|
45
|
+
window.removeEventListener('blur', manager.interrupt);
|
|
46
|
+
window.removeEventListener('pagehide', manager.cancel);
|
|
47
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
48
|
+
manager.cancel();
|
|
49
|
+
};
|
|
50
|
+
}, [manager, context]);
|
|
51
|
+
return snapshot;
|
|
52
|
+
}
|
|
@@ -438,16 +438,22 @@ describe('buildModelToolbarSelect selection', () => {
|
|
|
438
438
|
]
|
|
439
439
|
},
|
|
440
440
|
{
|
|
441
|
-
key: '
|
|
442
|
-
label: '
|
|
441
|
+
key: 'provider-openai',
|
|
442
|
+
label: 'OpenAI',
|
|
443
443
|
options: [
|
|
444
444
|
{
|
|
445
445
|
value: 'openai/gpt-5',
|
|
446
|
-
label: '
|
|
447
|
-
}
|
|
446
|
+
label: 'gpt-5'
|
|
447
|
+
}
|
|
448
|
+
]
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
key: 'provider-minimax',
|
|
452
|
+
label: 'MiniMax',
|
|
453
|
+
options: [
|
|
448
454
|
{
|
|
449
455
|
value: 'minimax/MiniMax-M2.7',
|
|
450
|
-
label: 'MiniMax
|
|
456
|
+
label: 'MiniMax-M2.7'
|
|
451
457
|
}
|
|
452
458
|
]
|
|
453
459
|
}
|
|
@@ -487,11 +493,12 @@ describe('buildModelToolbarSelect selection', () => {
|
|
|
487
493
|
expect(select.groups?.map((group) => group.key)).toEqual([
|
|
488
494
|
'favorite-models',
|
|
489
495
|
'recent-models',
|
|
490
|
-
'
|
|
496
|
+
'provider-minimax'
|
|
491
497
|
]);
|
|
492
498
|
expect(select.groups?.[0]?.options.map((option) => option.value)).toEqual(['openai/gpt-5']);
|
|
493
499
|
expect(select.groups?.[1]?.options.map((option) => option.value)).toEqual(['anthropic/claude-sonnet-4']);
|
|
494
500
|
expect(select.groups?.[2]?.options.map((option) => option.value)).toEqual(['minimax/MiniMax-M2.7']);
|
|
501
|
+
expect(select.groups?.[2]?.label).toBe('MiniMax');
|
|
495
502
|
expect(select.optionAction).toMatchObject({
|
|
496
503
|
kind: 'favorite',
|
|
497
504
|
activeValues: ['openai/gpt-5'],
|