@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/.vite/manifest.json +208 -195
  3. package/dist/assets/{account-panel-BRTSwNvA.js → account-panel-UvHFa8Kl.js} +1 -1
  4. package/dist/assets/{api-BnUzdltV.js → api-Dffeij78.js} +5 -5
  5. package/dist/assets/{app-data-Bjh_zM45.js → app-data-DzaIMy4D.js} +1 -1
  6. package/dist/assets/app-presenter-provider-O2tUKKGf.js +109 -0
  7. package/dist/assets/{appearance-settings-page-BrOYSgG0.js → appearance-settings-page-D8cWniw-.js} +1 -1
  8. package/dist/assets/{channels-list-page-BE0TxB3B.js → channels-list-page-B_fYP1Pn.js} +1 -1
  9. package/dist/assets/{config-split-page-BxDwwAGa.js → config-split-page-6hHiiapl.js} +1 -1
  10. package/dist/assets/{confirm-dialog-S5JxZcS1.js → confirm-dialog-ClT6iaj8.js} +1 -1
  11. package/dist/assets/{desktop-capabilities-page-BwHxJpbl.js → desktop-capabilities-page-CB5pniTx.js} +1 -1
  12. package/dist/assets/{desktop-update-config-Bfg7OdJ2.js → desktop-update-config-OuQV73gg.js} +1 -1
  13. package/dist/assets/doc-browser-BFkslEzY.js +1 -0
  14. package/dist/assets/{doc-browser-C8eqJLi_.js → doc-browser-Cu58uiEg.js} +1 -1
  15. package/dist/assets/{doc-browser-context-BPCDFcC-.js → doc-browser-context-CekJxJFm.js} +1 -1
  16. package/dist/assets/{extensions-DgKQRebe.js → extensions-B5r-0jCq.js} +1 -1
  17. package/dist/assets/{i18n-CKvm9zyk.js → i18n-CPjq_hQX.js} +2 -2
  18. package/dist/assets/{i18n-provider-CPV43pMX.js → i18n-provider-BYO9AhFy.js} +1 -1
  19. package/dist/assets/keyboard-shortcuts-settings-page-1efEkGwq.js +1 -0
  20. package/dist/assets/{login-page-DeDQjVxv.js → login-page-DBqkVTVD.js} +1 -1
  21. package/dist/assets/login-page-DjcRQLQe.js +1 -0
  22. package/dist/assets/main-CLwCjK9U.css +1 -0
  23. package/dist/assets/main-CQ6GUYdX.js +2 -0
  24. package/dist/assets/main-i18n-catalog.config-QB4DfJRJ.js +1 -0
  25. package/dist/assets/{mcp-marketplace-page-CWjJGp_D.js → mcp-marketplace-page-CWAxBNaS.js} +1 -1
  26. package/dist/assets/mcp-marketplace-page-Ct090ceA.js +1 -0
  27. package/dist/assets/{model-config-page-Dv_zE7HN.js → model-config-page-DBcuZyRC.js} +1 -1
  28. package/dist/assets/panel-app-i18n-catalog.config-CQFujXqt.js +1 -0
  29. package/dist/assets/panel-app-maumVhbl.js +1 -0
  30. package/dist/assets/{panelStandalone-CFRBHV94.js → panelStandalone-BGA1s7JG.js} +2 -2
  31. package/dist/assets/{privacy-settings-page-D6uc9f-A.js → privacy-settings-page-DVC-aq1v.js} +1 -1
  32. package/dist/assets/projects-Cq0E3c7N.js +1 -0
  33. package/dist/assets/{provider-scoped-model-input-DUa_9uwg.js → provider-scoped-model-input-BGdZMWWa.js} +1 -1
  34. package/dist/assets/{providers-config-page-DAiqxlF_.js → providers-config-page-B-IhHl6x.js} +1 -1
  35. package/dist/assets/{pwa-C_Zte1xe.js → pwa-C3kR9mdy.js} +1 -1
  36. package/dist/assets/remote-Cnawibbg.js +1 -0
  37. package/dist/assets/runtime-config-page-LmrB2q65.js +1 -0
  38. package/dist/assets/{search-config-page-3xbhRlW2.js → search-config-page-BIXmv5FY.js} +1 -1
  39. package/dist/assets/{secrets-config-page-Dn8_rdOj.js → secrets-config-page-DU3fadrq.js} +1 -1
  40. package/dist/assets/security-config-9s0i0I4N.js +1 -0
  41. package/dist/assets/{service-apps-panel-CGc04aMn.js → service-apps-panel-Bshf3Wmq.js} +1 -1
  42. package/dist/assets/{tag-chip-BDzpHEli.js → tag-chip-gAH1IOsZ.js} +1 -1
  43. package/dist/assets/{theme-provider-B6FinATQ.js → theme-provider-DE7m9jvf.js} +1 -1
  44. package/dist/assets/{use-config-BPUM2lbo.js → use-config-Cyiw06-j.js} +1 -1
  45. package/dist/assets/{use-desktop-capabilities-BDjArQP3.js → use-desktop-capabilities-OKG96v4y.js} +1 -1
  46. package/dist/index.html +18 -18
  47. package/dist/panel-standalone.html +5 -5
  48. package/package.json +6 -6
  49. package/src/app/configs/app-navigation.config.ts +10 -3
  50. package/src/app/index.tsx +7 -0
  51. package/src/features/chat/components/providers/chat-presenter.provider.tsx +2 -0
  52. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +12 -0
  53. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +13 -1
  54. package/src/features/chat/features/conversation/components/__tests__/session-voice-input.test-utils.ts +52 -0
  55. package/src/features/chat/features/conversation/components/chat-voice-input-panel.tsx +70 -0
  56. package/src/features/chat/features/conversation/components/chat-voice-shortcut-control.tsx +33 -0
  57. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +1 -11
  58. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +29 -5
  59. package/src/features/chat/features/conversation/hooks/__tests__/use-chat-voice-input.test.tsx +185 -0
  60. package/src/features/chat/features/conversation/hooks/use-chat-voice-input.ts +52 -0
  61. package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +13 -6
  62. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +28 -12
  63. package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +19 -1
  64. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +7 -6
  65. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +4 -1
  66. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +20 -0
  67. package/src/features/chat/index.ts +1 -0
  68. package/src/features/chat/managers/chat-voice-input.manager.ts +174 -0
  69. package/src/features/chat/presenters/chat.presenter.ts +2 -0
  70. package/src/features/chat/stores/chat-voice-shortcut.store.ts +31 -0
  71. package/src/features/desktop-capabilities/index.ts +1 -0
  72. package/src/features/settings/pages/keyboard-shortcuts-settings-page.tsx +15 -0
  73. package/src/platforms/mobile/components/mobile-settings-shell.tsx +2 -1
  74. package/src/shared/lib/api/__tests__/ncp-session-query-cache.utils.test.ts +109 -1
  75. package/src/shared/lib/api/ncp-session-query-cache.utils.ts +71 -2
  76. package/src/shared/lib/i18n/locales/en-US/chat.json +32 -1
  77. package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
  78. package/src/shared/lib/i18n/locales/zh-CN/chat.json +32 -1
  79. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
  80. package/src/shared/lib/transport/__tests__/browser-realtime-recovery.service.test.ts +42 -0
  81. package/src/shared/lib/transport/__tests__/local-transport.service.test.ts +85 -0
  82. package/src/shared/lib/transport/__tests__/remote.transport.test.ts +54 -1
  83. package/src/shared/lib/transport/browser-realtime-recovery.service.ts +35 -0
  84. package/src/shared/lib/transport/local-transport.service.ts +32 -0
  85. package/src/shared/lib/transport/remote-transport.service.ts +30 -0
  86. package/dist/assets/app-presenter-provider-CBt-lyr_.js +0 -109
  87. package/dist/assets/doc-browser-BgRP0Wni.js +0 -1
  88. package/dist/assets/login-page-ClcrvN04.js +0 -1
  89. package/dist/assets/main-DGqlaAaI.css +0 -1
  90. package/dist/assets/main-i18n-catalog.config-CpNF5aVc.js +0 -1
  91. package/dist/assets/main-nMVfHb5K.js +0 -2
  92. package/dist/assets/mcp-marketplace-page-fvu2NcXT.js +0 -1
  93. package/dist/assets/panel-app-BUUle0gP.js +0 -1
  94. package/dist/assets/panel-app-i18n-catalog.config-BzxE3tZb.js +0 -1
  95. package/dist/assets/projects-BD1rG75R.js +0 -1
  96. package/dist/assets/remote-BO0QP7ua.js +0 -1
  97. package/dist/assets/runtime-config-page-w1UAkGF_.js +0 -1
  98. 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: controllerAgent,
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
- ? <SessionQueuedInputRows controller={controller} />
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: controller.send,
469
- onAlternateSend: controller.sendSteering,
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: 'all-models',
442
- label: 'All models',
441
+ key: 'provider-openai',
442
+ label: 'OpenAI',
443
443
  options: [
444
444
  {
445
445
  value: 'openai/gpt-5',
446
- label: 'OpenAI/gpt-5'
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/MiniMax-M2.7'
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
- 'all-models'
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'],