@nextclaw/ui 0.25.1 → 0.25.2

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 (66) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/.vite/manifest.json +190 -190
  3. package/dist/assets/{account-panel-QhOMbAQP.js → account-panel-BXTCFv7d.js} +1 -1
  4. package/dist/assets/{api-BWFOIoUm.js → api-DqzQ-_3w.js} +1 -1
  5. package/dist/assets/{app-data-BnrEdmg2.js → app-data-C54N2p8b.js} +1 -1
  6. package/dist/assets/app-presenter-provider-V0xnU_Wb.js +115 -0
  7. package/dist/assets/{appearance-settings-page-C5z5sOkC.js → appearance-settings-page-83T2AtVm.js} +1 -1
  8. package/dist/assets/{channels-list-page-CzXYZZtK.js → channels-list-page-Bv7CVSqV.js} +1 -1
  9. package/dist/assets/{config-split-page-CxLWGwdt.js → config-split-page-DqAXKLmG.js} +1 -1
  10. package/dist/assets/{confirm-dialog-BWS7gA_z.js → confirm-dialog-BTKCWMn6.js} +1 -1
  11. package/dist/assets/{desktop-capabilities-page-BJNGkq4q.js → desktop-capabilities-page-zLzCSB7A.js} +1 -1
  12. package/dist/assets/{desktop-update-config-7tBum3MA.js → desktop-update-config-PmofcFf6.js} +1 -1
  13. package/dist/assets/{doc-browser-BNiqa-zm.js → doc-browser-BJrre2S_.js} +1 -1
  14. package/dist/assets/doc-browser-BPqA-zQx.js +1 -0
  15. package/dist/assets/{doc-browser-context-Dp0dEMyT.js → doc-browser-context-TILuCY5c.js} +1 -1
  16. package/dist/assets/{extensions-WWEYpaUh.js → extensions-CVUFK6Jj.js} +1 -1
  17. package/dist/assets/{i18n-ClHx6jwj.js → i18n-20rSH40D.js} +2 -2
  18. package/dist/assets/{i18n-provider-C1NxogO4.js → i18n-provider-BPRq6S7O.js} +1 -1
  19. package/dist/assets/{keyboard-shortcuts-settings-page-CS9BFOBW.js → keyboard-shortcuts-settings-page-CkQbSKwI.js} +1 -1
  20. package/dist/assets/login-page-Dlyvp-81.js +1 -0
  21. package/dist/assets/{login-page-CL0SrBCf.js → login-page-f8qQ4xKY.js} +1 -1
  22. package/dist/assets/main-D2dWy8fM.css +1 -0
  23. package/dist/assets/main-i18n-catalog.config-DZ-5ollN.js +1 -0
  24. package/dist/assets/{main-CvVBCOlN.js → main-ky5Efhq1.js} +2 -2
  25. package/dist/assets/{mcp-marketplace-page-D75hPmzO.js → mcp-marketplace-page-FfIfEo6w.js} +1 -1
  26. package/dist/assets/mcp-marketplace-page-sH7VptN_.js +1 -0
  27. package/dist/assets/{model-config-page-DFfcV9lu.js → model-config-page-Bcm6-pCX.js} +1 -1
  28. package/dist/assets/panel-app-i18n-catalog.config-p3qAY_Eq.js +1 -0
  29. package/dist/assets/{panelStandalone-D2CqTyi_.js → panelStandalone-By-6dDXS.js} +2 -2
  30. package/dist/assets/{privacy-settings-page-CE-xF9bo.js → privacy-settings-page-B1sHLAc1.js} +1 -1
  31. package/dist/assets/{projects-BF0wpY4X.js → projects-O3GTERwP.js} +1 -1
  32. package/dist/assets/{provider-scoped-model-input-BvZitHkT.js → provider-scoped-model-input-BQsugx1v.js} +1 -1
  33. package/dist/assets/{providers-config-page-cDeyluVG.js → providers-config-page-BDEdHJbw.js} +1 -1
  34. package/dist/assets/{pwa-dXn0s_lD.js → pwa-D_nZUwG0.js} +1 -1
  35. package/dist/assets/remote-CFWp4wyH.js +1 -0
  36. package/dist/assets/{runtime-config-page-DNo4dzS1.js → runtime-config-page-BHTKwgDi.js} +1 -1
  37. package/dist/assets/{search-config-page-CA0HxVy0.js → search-config-page-DWKlMHgH.js} +1 -1
  38. package/dist/assets/{secrets-config-page-iT1wamQp.js → secrets-config-page-DiQL088Y.js} +1 -1
  39. package/dist/assets/security-config-DmTDKKY4.js +1 -0
  40. package/dist/assets/{service-apps-panel-CU0tFrqT.js → service-apps-panel-CEnyYsQp.js} +1 -1
  41. package/dist/assets/{tag-chip-CY0Hqk_l.js → tag-chip-B1AYPOKL.js} +1 -1
  42. package/dist/assets/{theme-provider-BvpXGXWH.js → theme-provider-DzvAi3pe.js} +1 -1
  43. package/dist/assets/{use-config-C8ZkQiTc.js → use-config-BEdbtpD4.js} +1 -1
  44. package/dist/assets/{use-desktop-capabilities-DR9snbeo.js → use-desktop-capabilities-BkuRd2cY.js} +1 -1
  45. package/dist/index.html +18 -18
  46. package/dist/panel-standalone.html +5 -5
  47. package/package.json +5 -5
  48. package/src/features/chat/features/conversation/components/__tests__/session-voice-input.test-utils.ts +18 -5
  49. package/src/features/chat/features/conversation/components/chat-voice-input-panel.tsx +37 -14
  50. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +3 -3
  51. package/src/features/chat/features/conversation/hooks/__tests__/use-chat-voice-input.test.tsx +116 -10
  52. package/src/features/chat/features/conversation/hooks/use-chat-voice-input.ts +15 -2
  53. package/src/features/chat/managers/chat-voice-input.manager.ts +26 -4
  54. package/src/features/chat/utils/chat-voice-permissions.utils.test.ts +36 -0
  55. package/src/features/chat/utils/chat-voice-permissions.utils.ts +31 -0
  56. package/src/shared/lib/i18n/locales/en-US/chat.json +18 -3
  57. package/src/shared/lib/i18n/locales/zh-CN/chat.json +18 -3
  58. package/dist/assets/app-presenter-provider-CDRBc_lQ.js +0 -115
  59. package/dist/assets/doc-browser-DZDqghNc.js +0 -1
  60. package/dist/assets/login-page-Dq5nPUCU.js +0 -1
  61. package/dist/assets/main-CLwCjK9U.css +0 -1
  62. package/dist/assets/main-i18n-catalog.config-D4qFo4OB.js +0 -1
  63. package/dist/assets/mcp-marketplace-page-CATnpZ2Q.js +0 -1
  64. package/dist/assets/panel-app-i18n-catalog.config-oUwmxoE7.js +0 -1
  65. package/dist/assets/remote-CV5wQVVj.js +0 -1
  66. package/dist/assets/security-config-B38PLI3M.js +0 -1
@@ -26,21 +26,90 @@ const beginDraft = () => draft;
26
26
  beforeEach(() => {
27
27
  vi.useFakeTimers();
28
28
  recognition = new Recognition();
29
- manager = new ChatVoiceInputManager(() => recognition);
29
+ manager = new ChatVoiceInputManager(() => recognition, async () => null);
30
30
  commit = vi.fn<(text?: string) => void>();
31
31
  draft = { commit, update: vi.fn(), cancel: vi.fn() };
32
32
  useChatVoiceShortcutStore.getState().setShortcut(null);
33
33
  vi.spyOn(document, 'hasFocus').mockReturnValue(true);
34
34
  });
35
35
  afterEach(() => {
36
- manager.cancel();
36
+ act(() => { manager.cancel(); });
37
37
  vi.useRealTimers();
38
38
  vi.restoreAllMocks();
39
39
  });
40
40
 
41
41
  describe('dictation lifecycle', () => {
42
- it('waits for the final transcript before opening settings without restarting recording', () => {
42
+ it('saves on exit only after the final tail arrives and closes without rolling back the draft', async () => {
43
+ manager.start('en', 'one', draft);
44
+ await Promise.resolve();
45
+ recognition.onstart?.();
46
+ recognition.result('hello');
47
+ manager.saveAndClose();
48
+ manager.saveAndClose();
49
+ expect(recognition.stop).toHaveBeenCalledOnce();
50
+ expect(commit).not.toHaveBeenCalled();
51
+ recognition.result('hello', 'tail');
52
+ recognition.onend?.();
53
+ expect(draft.update).toHaveBeenLastCalledWith('hello tail', '');
54
+ expect(commit).toHaveBeenCalledOnce();
55
+ expect(draft.cancel).not.toHaveBeenCalled();
56
+ expect(manager.getSnapshot().phase).toBe('idle');
57
+ });
58
+ it.each(['timeout', 'network'])('preserves visible dictation on explicit exit even if finalization reports %s', async (error) => {
59
+ manager.start('en', 'one', draft);
60
+ await Promise.resolve();
61
+ recognition.onstart?.();
62
+ recognition.onresult?.({ results: [{ isFinal: false, 0: { transcript: 'visible words' } }] });
63
+ manager.saveAndClose();
64
+ if (error === 'timeout') vi.advanceTimersByTime(8000);
65
+ else recognition.onerror?.({ error });
66
+ expect(commit).toHaveBeenCalledExactlyOnceWith();
67
+ expect(draft.cancel).not.toHaveBeenCalled();
68
+ expect(manager.getSnapshot().phase).toBe('idle');
69
+ });
70
+ it('does not count time spent in the explicit permission prompt as a speech-service timeout', async () => {
71
+ let resolveCheck!: (value: null) => void;
72
+ manager = new ChatVoiceInputManager(() => recognition,
73
+ () => new Promise<null>((resolve) => { resolveCheck = resolve; }));
74
+ manager.start('en', 'one', draft);
75
+ vi.advanceTimersByTime(30000);
76
+ expect(manager.getSnapshot().phase).toBe('starting');
77
+ resolveCheck(null);
78
+ await Promise.resolve();
79
+ expect(recognition.start).toHaveBeenCalledOnce();
80
+ vi.advanceTimersByTime(15000);
81
+ expect(manager.getSnapshot().error).toBe('timeout');
82
+ });
83
+ it('checks permission on retry and ignores a successful check after cancellation', async () => {
84
+ let resolveCheck!: (value: null) => void;
85
+ const check = vi.fn().mockResolvedValueOnce('permission')
86
+ .mockImplementationOnce(() => new Promise<null>((resolve) => { resolveCheck = resolve; }));
87
+ manager = new ChatVoiceInputManager(() => recognition, check);
88
+ manager.start('en', 'one', draft);
89
+ await Promise.resolve();
90
+ manager.start('en', 'one', draft);
91
+ expect(check).toHaveBeenCalledTimes(2);
92
+ manager.cancel();
93
+ resolveCheck(null);
94
+ await Promise.resolve();
95
+ expect(recognition.start).not.toHaveBeenCalled();
96
+ expect(manager.getSnapshot().phase).toBe('idle');
97
+ });
98
+ it('starts again after a successful permission check and identifies missing devices', async () => {
99
+ const check = vi.fn().mockResolvedValueOnce('no-device').mockResolvedValueOnce(null);
100
+ manager = new ChatVoiceInputManager(() => recognition, check);
101
+ manager.start('en', 'one', draft);
102
+ await Promise.resolve();
103
+ expect(manager.getSnapshot().error).toBe('no-device');
104
+ manager.start('en', 'one', draft);
105
+ await Promise.resolve();
106
+ expect(recognition.start).toHaveBeenCalledOnce();
107
+ recognition.onstart?.();
108
+ expect(manager.getSnapshot().phase).toBe('recording');
109
+ });
110
+ it('waits for the final transcript before opening settings without restarting recording', async () => {
43
111
  manager.start('zh', 'one', draft);
112
+ await Promise.resolve();
44
113
  recognition.onstart?.();
45
114
  recognition.result('first');
46
115
  manager.openSettings();
@@ -53,15 +122,17 @@ describe('dictation lifecycle', () => {
53
122
  manager.cancel();
54
123
  expect(recognition.start).toHaveBeenCalledOnce();
55
124
  });
56
- it('still opens settings when finishing recognition fails', () => {
125
+ it('still opens settings when finishing recognition fails', async () => {
57
126
  manager.start('en', 'one', draft);
127
+ await Promise.resolve();
58
128
  recognition.onstart?.();
59
129
  manager.openSettings();
60
130
  recognition.onerror?.({ error: 'no-speech' });
61
131
  expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', error: null, text: '', interim: '' });
62
132
  });
63
- it('replaces cumulative results, waits for the final tail, and inserts once', () => {
133
+ it('replaces cumulative results, waits for the final tail, and inserts once', async () => {
64
134
  manager.start('zh', 'one', draft);
135
+ await Promise.resolve();
65
136
  expect(manager.getSnapshot().phase).toBe('starting');
66
137
  recognition.onstart?.();
67
138
  recognition.result('hello');
@@ -105,8 +176,9 @@ describe('dictation lifecycle', () => {
105
176
  expect(recognition.abort).toHaveBeenCalledOnce();
106
177
  expect(commit).not.toHaveBeenCalled();
107
178
  });
108
- it('commits confirmed text on interruption without a second insertion', () => {
179
+ it('commits confirmed text on interruption without a second insertion', async () => {
109
180
  manager.start('en', 'one', draft);
181
+ await Promise.resolve();
110
182
  recognition.onstart?.();
111
183
  recognition.result('keep this');
112
184
  manager.interrupt();
@@ -114,11 +186,13 @@ describe('dictation lifecycle', () => {
114
186
  manager.cancel();
115
187
  expect(commit).toHaveBeenCalledOnce();
116
188
  });
117
- it('times out startup and stopping and ends recording at 60 seconds', () => {
189
+ it('times out startup and stopping and ends recording at 60 seconds', async () => {
118
190
  manager.start('en', 'one', draft);
191
+ await Promise.resolve();
119
192
  vi.advanceTimersByTime(15000);
120
193
  expect(manager.getSnapshot().error).toBe('timeout');
121
194
  manager.start('en', 'one', draft);
195
+ await Promise.resolve();
122
196
  recognition.onstart?.();
123
197
  vi.advanceTimersByTime(60000);
124
198
  expect(manager.getSnapshot().phase).toBe('stopping');
@@ -126,18 +200,50 @@ describe('dictation lifecycle', () => {
126
200
  vi.advanceTimersByTime(8000);
127
201
  expect(manager.getSnapshot().error).toBe('timeout');
128
202
  });
129
- it('handles unsupported APIs and thrown starts', () => {
203
+ it('handles unsupported APIs and thrown starts', async () => {
130
204
  const unsupported = new ChatVoiceInputManager(() => null);
131
205
  unsupported.start('en', 'one', draft);
132
206
  expect(unsupported.getSnapshot().error).toBe('unsupported');
133
207
  recognition.start.mockImplementation(() => { throw new Error('device'); });
134
208
  manager.start('en', 'one', draft);
209
+ await Promise.resolve();
135
210
  expect(manager.getSnapshot().error).toBe('failed');
136
211
  });
137
212
 
138
213
  });
139
214
 
140
215
  describe('keyboard and page integration', () => {
216
+ it('keeps a pending authorization alive across window blur and starts after permission is granted', async () => {
217
+ renderHook(() => useChatVoiceInput(manager, 'one', 'en', true, beginDraft));
218
+ act(() => { manager.start('en', 'one', draft); window.dispatchEvent(new Event('blur')); });
219
+ expect(manager.getSnapshot().phase).toBe('starting');
220
+ expect(recognition.abort).not.toHaveBeenCalled();
221
+ await act(async () => { await Promise.resolve(); recognition.onstart?.(); });
222
+ expect(manager.getSnapshot().phase).toBe('recording');
223
+ });
224
+ it.each(['visibilitychange', 'pagehide'])('cancels pending authorization on %s and ignores a late grant', (event) => {
225
+ renderHook(() => useChatVoiceInput(manager, 'one', 'en', true, beginDraft));
226
+ act(() => { manager.start('en', 'one', draft); });
227
+ const lateStart = recognition.onstart;
228
+ vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
229
+ act(() => { (event === 'pagehide' ? window : document).dispatchEvent(new Event(event)); lateStart?.(); });
230
+ expect(recognition.abort).toHaveBeenCalledOnce();
231
+ expect(manager.getSnapshot().phase).not.toBe('recording');
232
+ });
233
+ it('still interrupts active recording on window blur', async () => {
234
+ renderHook(() => useChatVoiceInput(manager, 'one', 'en', true, beginDraft));
235
+ await act(async () => { manager.start('en', 'one', draft); await Promise.resolve(); recognition.onstart?.(); window.dispatchEvent(new Event('blur')); });
236
+ expect(manager.getSnapshot().error).toBe('interrupted');
237
+ expect(recognition.abort).toHaveBeenCalledOnce();
238
+ });
239
+ it('still stops an active recording when the page becomes hidden', async () => {
240
+ renderHook(() => useChatVoiceInput(manager, 'one', 'en', true, beginDraft));
241
+ await act(async () => { manager.start('en', 'one', draft); await Promise.resolve(); recognition.onstart?.(); });
242
+ vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
243
+ act(() => { document.dispatchEvent(new Event('visibilitychange')); });
244
+ expect(manager.getSnapshot().error).toBe('interrupted');
245
+ expect(recognition.abort).toHaveBeenCalledOnce();
246
+ });
141
247
  const bind = (ctrl = false, alt = false) => useChatVoiceShortcutStore.getState().setShortcut({ code: 'KeyV', ctrl, meta: false, alt, shift: false });
142
248
  const key = (type: string, options: KeyboardEventInit = {}) => new KeyboardEvent(type, { code: 'KeyV', bubbles: true, cancelable: true, ...options });
143
249
  it('never steals paste, normal typing, or input-method composition', () => {
@@ -153,10 +259,10 @@ describe('keyboard and page integration', () => {
153
259
  expect(recognition.start).not.toHaveBeenCalled();
154
260
  input.remove();
155
261
  });
156
- it('holds a configured combination and stops when a modifier is released', () => {
262
+ it('holds a configured combination and stops when a modifier is released', async () => {
157
263
  bind(true, true);
158
264
  renderHook(() => useChatVoiceInput(manager, 'one', 'en', true, beginDraft));
159
- act(() => { window.dispatchEvent(key('keydown', { ctrlKey: true, altKey: true })); recognition.onstart?.(); });
265
+ await act(async () => { window.dispatchEvent(key('keydown', { ctrlKey: true, altKey: true })); await Promise.resolve(); recognition.onstart?.(); });
160
266
  expect(recognition.start).toHaveBeenCalledOnce();
161
267
  act(() => { window.dispatchEvent(key('keyup', { code: 'AltLeft', key: 'Alt' })); });
162
268
  expect(recognition.stop).toHaveBeenCalledOnce();
@@ -36,13 +36,26 @@ export function useChatVoiceInput(manager: ChatVoiceInputManager, context: strin
36
36
  if (held) manager.cancel();
37
37
  };
38
38
  }, [manager, context, language, keyboardEnabled, beginDraft, shortcut]);
39
+ useEffect(() => {
40
+ const onEscape = (event: KeyboardEvent) => {
41
+ if (event.key !== 'Escape' || event.isComposing || ['idle', 'ready'].includes(manager.getSnapshot().phase)) return;
42
+ event.preventDefault();
43
+ event.stopPropagation();
44
+ manager.saveAndClose();
45
+ };
46
+ window.addEventListener('keydown', onEscape, true);
47
+ return () => { window.removeEventListener('keydown', onEscape, true); };
48
+ }, [manager]);
39
49
  useEffect(() => {
40
50
  const onVisibility = () => { if (document.hidden) manager.interrupt(); };
41
- window.addEventListener('blur', manager.interrupt);
51
+ // Native authorization prompts can blur the window before recognition starts.
52
+ // Actual page departure is handled independently, including pending requests.
53
+ const onBlur = () => { if (manager.getSnapshot().phase !== 'starting') manager.interrupt(); };
54
+ window.addEventListener('blur', onBlur);
42
55
  window.addEventListener('pagehide', manager.cancel);
43
56
  document.addEventListener('visibilitychange', onVisibility);
44
57
  return () => {
45
- window.removeEventListener('blur', manager.interrupt);
58
+ window.removeEventListener('blur', onBlur);
46
59
  window.removeEventListener('pagehide', manager.cancel);
47
60
  document.removeEventListener('visibilitychange', onVisibility);
48
61
  manager.cancel();
@@ -1,7 +1,8 @@
1
1
  import type { ChatComposerDictationSession } from '@nextclaw/agent-chat-ui';
2
+ import { checkMicrophoneAccess, classifyMicrophoneError } from '@/features/chat/utils/chat-voice-permissions.utils';
2
3
 
3
4
  export type VoicePhase = 'idle' | 'ready' | 'starting' | 'recording' | 'stopping' | 'error';
4
- export type VoiceError = 'unsupported' | 'permission' | 'network' | 'audio-capture' | 'no-speech' | 'interrupted' | 'timeout' | 'failed';
5
+ export type VoiceError = 'unsupported' | 'permission' | 'service-denied' | 'no-device' | 'device-busy' | 'network' | 'audio-capture' | 'no-speech' | 'interrupted' | 'timeout' | 'failed';
5
6
  export type VoiceSnapshot = { phase: VoicePhase; text: string; interim: string; seconds: number; error: VoiceError | null };
6
7
  export type SpeechRecognitionLike = {
7
8
  lang: string;
@@ -33,9 +34,11 @@ export class ChatVoiceInputManager {
33
34
  private ticker: ReturnType<typeof setInterval> | undefined;
34
35
  private context = '';
35
36
  private settingsRequested = false;
37
+ private exitRequested = false;
36
38
  private draft: ChatComposerDictationSession | null = null;
37
39
 
38
- constructor(private readonly createRecognition = createBrowserRecognition) {}
40
+ constructor(private readonly createRecognition = createBrowserRecognition,
41
+ private readonly checkMicrophone = checkMicrophoneAccess) {}
39
42
 
40
43
  getSnapshot = (): VoiceSnapshot => this.snapshot;
41
44
  subscribe = (listener: () => void): (() => void) => {
@@ -74,9 +77,19 @@ export class ChatVoiceInputManager {
74
77
  recognition.continuous = true;
75
78
  recognition.interimResults = true;
76
79
  this.connect(recognition);
80
+ void this.checkAndStart(recognition);
81
+ } catch (error) { this.fail(classifyMicrophoneError(error)); }
82
+ };
83
+ private checkAndStart = async (recognition: SpeechRecognitionLike): Promise<void> => {
84
+ try {
85
+ const error = await this.checkMicrophone();
86
+ if (this.recognition !== recognition || this.snapshot.phase !== 'starting') return;
87
+ if (error) { this.fail(error); return; }
77
88
  this.setDeadline(15000);
78
89
  recognition.start();
79
- } catch { this.fail('failed'); }
90
+ } catch (error) {
91
+ if (this.recognition === recognition) this.fail(classifyMicrophoneError(error));
92
+ }
80
93
  };
81
94
  private connect = (recognition: SpeechRecognitionLike): void => {
82
95
  recognition.onstart = () => {
@@ -104,13 +117,14 @@ export class ChatVoiceInputManager {
104
117
  };
105
118
  recognition.onerror = ({ error }) => {
106
119
  if (this.recognition !== recognition) return;
107
- const reason: VoiceError = error === 'not-allowed' || error === 'service-not-allowed'
120
+ const reason: VoiceError = error === 'service-not-allowed' ? 'service-denied' : error === 'not-allowed'
108
121
  ? 'permission'
109
122
  : error === 'network' || error === 'audio-capture' || error === 'no-speech' ? error : 'failed';
110
123
  this.fail(reason);
111
124
  };
112
125
  recognition.onend = () => {
113
126
  if (this.recognition !== recognition) return;
127
+ if (this.exitRequested) { this.release(false); this.acceptDraft(); return; }
114
128
  const text = this.snapshot.text.trim();
115
129
  this.release(false);
116
130
  if (!text) { this.fail('no-speech'); return; }
@@ -129,6 +143,12 @@ export class ChatVoiceInputManager {
129
143
  this.setDeadline(8000);
130
144
  try { this.recognition.stop(); } catch { this.fail('failed'); }
131
145
  };
146
+ saveAndClose = (): void => {
147
+ this.exitRequested = true;
148
+ if (this.snapshot.phase === 'recording') { this.finish(); return; }
149
+ if (this.snapshot.phase === 'stopping') return;
150
+ this.acceptDraft();
151
+ };
132
152
  interrupt = (): void => {
133
153
  if (isActive(this.snapshot.phase)) this.fail('interrupted');
134
154
  };
@@ -139,6 +159,7 @@ export class ChatVoiceInputManager {
139
159
  };
140
160
  cancel = (): void => {
141
161
  this.settingsRequested = false;
162
+ this.exitRequested = false;
142
163
  this.release();
143
164
  this.context = '';
144
165
  this.draft?.cancel();
@@ -146,6 +167,7 @@ export class ChatVoiceInputManager {
146
167
  this.update(initialSnapshot());
147
168
  };
148
169
  private fail = (error: VoiceError): void => {
170
+ if (this.exitRequested) { this.acceptDraft(); return; }
149
171
  this.release();
150
172
  if (this.snapshot.text) this.draft?.commit(this.snapshot.text);
151
173
  else this.draft?.cancel();
@@ -0,0 +1,36 @@
1
+ import { afterEach, expect, it, vi } from 'vitest';
2
+ import { checkMicrophoneAccess, classifyMicrophoneError, voicePermissionInstructionKeys } from './chat-voice-permissions.utils';
3
+
4
+ afterEach(() => vi.unstubAllGlobals());
5
+
6
+ it.each([
7
+ ['NotAllowedError', 'permission'], ['NotFoundError', 'no-device'],
8
+ ['NotSupportedError', 'unsupported'],
9
+ ['NotReadableError', 'device-busy'], ['AbortError', 'device-busy'], ['Error', 'failed'],
10
+ ])('classifies %s from the actual media API', (name, expected) => {
11
+ expect(classifyMicrophoneError(new DOMException('microphone', name))).toBe(expected);
12
+ });
13
+
14
+ it('rechecks access and immediately releases every captured track', async () => {
15
+ const stop = vi.fn();
16
+ const getUserMedia = vi.fn().mockResolvedValue({ getTracks: () => [{ stop }, { stop }] });
17
+ vi.stubGlobal('navigator', { mediaDevices: { getUserMedia } });
18
+ expect(await checkMicrophoneAccess()).toBeNull();
19
+ expect(getUserMedia).toHaveBeenCalledWith({ audio: true });
20
+ expect(stop).toHaveBeenCalledTimes(2);
21
+ });
22
+
23
+ it('reports unavailable media API and an actual denial without caching', async () => {
24
+ vi.stubGlobal('navigator', {});
25
+ expect(await checkMicrophoneAccess()).toBe('unsupported');
26
+ vi.stubGlobal('navigator', { mediaDevices: { getUserMedia: vi.fn().mockRejectedValue(new DOMException('', 'NotAllowedError')) } });
27
+ expect(await checkMicrophoneAccess()).toBe('permission');
28
+ });
29
+
30
+ it.each([
31
+ ['Macintosh Chrome', 'Mac'], ['Windows Chrome', 'Windows'], ['iPhone', 'Ios'],
32
+ ['Android Chrome', 'Android'], ['Linux', 'System'],
33
+ ])('offers instructions matching %s', (ua, platform) => {
34
+ expect(voicePermissionInstructionKeys(ua)).toEqual(['chatInputVoicePermissionBrowser', `chatInputVoicePermission${platform}`]);
35
+ expect(voicePermissionInstructionKeys(`${ua} Electron`)[0]).toBe('chatInputVoicePermissionDesktop');
36
+ });
@@ -0,0 +1,31 @@
1
+ export type MicrophoneError = 'permission' | 'no-device' | 'device-busy' | 'unsupported' | 'failed';
2
+
3
+ export function classifyMicrophoneError(error: unknown): MicrophoneError {
4
+ const name = error instanceof DOMException || error instanceof Error ? error.name : '';
5
+ if (name === 'NotSupportedError') return 'unsupported';
6
+ if (name === 'NotAllowedError' || name === 'SecurityError') return 'permission';
7
+ if (name === 'NotFoundError' || name === 'DevicesNotFoundError') return 'no-device';
8
+ if (name === 'NotReadableError' || name === 'AbortError') return 'device-busy';
9
+ return 'failed';
10
+ }
11
+
12
+ /** Check on explicit start/retry; release every track even if the panel was closed. */
13
+ export async function checkMicrophoneAccess(): Promise<MicrophoneError | null> {
14
+ if (!navigator.mediaDevices?.getUserMedia) return 'unsupported';
15
+ try {
16
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
17
+ stream.getTracks().forEach((track) => track.stop());
18
+ return null;
19
+ } catch (error) {
20
+ return classifyMicrophoneError(error);
21
+ }
22
+ }
23
+
24
+ export function voicePermissionInstructionKeys(userAgent: string): string[] {
25
+ const system = /iPhone|iPad|iPod/i.test(userAgent) ? 'chatInputVoicePermissionIos'
26
+ : /Android/i.test(userAgent) ? 'chatInputVoicePermissionAndroid'
27
+ : /Macintosh|Mac OS X/i.test(userAgent) ? 'chatInputVoicePermissionMac'
28
+ : /Windows/i.test(userAgent) ? 'chatInputVoicePermissionWindows'
29
+ : 'chatInputVoicePermissionSystem';
30
+ return [/Electron/i.test(userAgent) ? 'chatInputVoicePermissionDesktop' : 'chatInputVoicePermissionBrowser', system];
31
+ }
@@ -640,17 +640,32 @@
640
640
  "chatInputAttachmentTooLarge": "Files must be {maxMb} MB or smaller.",
641
641
  "chatInputAttachmentReadFailed": "Failed to read the file. Please try again.",
642
642
  "chatInputVoice": "Voice input",
643
- "chatInputVoicePermissionDenied": "Microphone permission was denied. Allow it in the browser or system settings and retry.",
643
+ "chatInputVoicePermissionDenied": "Microphone access was denied. The browser may not distinguish site permission from system permission. Check the following settings.",
644
+ "chatInputVoicePermissionPurpose": "Voice input uses your microphone to turn speech into a draft. Choose Allow when prompted; review the text before sending it.",
645
+ "chatInputVoiceCheckAndRetry": "Check microphone and retry",
646
+ "chatInputVoicePermissionRetryHint": "After allowing access, select “Check microphone and retry”. If the system requires an app restart, save your draft before reopening your browser or NextClaw.",
647
+ "chatInputVoicePermissionBrowser": "Open the site information or permissions menu beside the address bar and allow Microphone for this site. Alternatively, search browser settings for Microphone and check whether this site is blocked.",
648
+ "chatInputVoicePermissionDesktop": "Check that the system allows NextClaw to use the microphone. If access is already allowed, reopen NextClaw and retry.",
649
+ "chatInputVoicePermissionMac": "macOS: open System Settings → Privacy & Security → Microphone, and allow your browser or NextClaw.",
650
+ "chatInputVoicePermissionWindows": "Windows: open Settings → Privacy & security (Privacy on Windows 10) → Microphone. Enable microphone access and access for desktop apps.",
651
+ "chatInputVoicePermissionIos": "iPhone / iPad: check Microphone in the browser’s website settings. Also check Settings → Privacy & Security → Microphone for browser access, if listed.",
652
+ "chatInputVoicePermissionAndroid": "Android: open Settings → Apps → your browser → Permissions → Microphone and allow access. Also check this website’s browser permissions.",
653
+ "chatInputVoicePermissionSystem": "In system sound settings, select an available input device and check whether microphone access is restricted for your browser or NextClaw.",
654
+ "chatInputVoiceNoDevice": "No microphone was detected. Connect or enable an input device, then check again.",
655
+ "chatInputVoiceDeviceBusy": "The microphone cannot be read. Another app or a system restriction may be preventing access. Close other recording apps, check your input device and retry.",
656
+ "chatInputVoiceServiceDenied": "The browser denied access to speech recognition. Check its speech service settings or organization policy, or use a browser that supports speech recognition.",
644
657
  "chatInputVoiceUnsupported": "Speech input is not supported in this browser.",
645
658
  "chatInputVoiceNoSpeech": "Could not hear you. Please try again.",
646
659
  "chatInputVoiceFailed": "Voice input failed. Please try again.",
647
660
  "chatInputVoicePrivacy": "Click to dictate at the cursor. Underlined words are still being recognized. Your browser may process audio online. Nothing is sent automatically.",
648
661
  "chatInputVoiceStart": "Start recording",
649
662
  "chatInputVoiceFinish": "Finish recording",
663
+ "chatInputVoiceSaveAndClose": "Save and close",
664
+ "chatInputVoiceEscapeHint": "Press Esc to save dictation and close",
650
665
  "chatInputVoiceCancel": "Cancel",
651
- "chatInputVoiceStarting": "Connecting to microphone…",
666
+ "chatInputVoiceStarting": "Preparing",
652
667
  "chatInputVoiceRecording": "Listening",
653
- "chatInputVoiceFinishing": "Finishing transcription…",
668
+ "chatInputVoiceFinishing": "Finishing",
654
669
  "chatInputVoiceReview": "Recording stopped",
655
670
  "chatInputVoiceSpeak": "Speak now. Your transcript will appear here.",
656
671
  "chatInputVoiceReviewHint": "Confirmed words remain in your draft; provisional words were removed. Continue editing or try again.",
@@ -640,17 +640,32 @@
640
640
  "chatInputAttachmentTooLarge": "文件不能超过 {maxMb} MB。",
641
641
  "chatInputAttachmentReadFailed": "读取文件失败,请重试。",
642
642
  "chatInputVoice": "语音输入",
643
- "chatInputVoicePermissionDenied": "麦克风权限被拒绝,请在浏览器或系统中允许后重试。",
643
+ "chatInputVoicePermissionDenied": "无法获得麦克风权限。浏览器可能无法区分网站权限和系统权限,请按以下步骤检查。",
644
+ "chatInputVoicePermissionPurpose": "语音输入需要使用麦克风,把你说的话转为草稿。出现权限提示时请选择允许;文字由你检查后发送。",
645
+ "chatInputVoiceCheckAndRetry": "检测麦克风并重试",
646
+ "chatInputVoicePermissionRetryHint": "允许后点击“检测麦克风并重试”。若系统要求重启应用,请先保留草稿再重开浏览器或 NextClaw。",
647
+ "chatInputVoicePermissionBrowser": "打开地址栏左侧的网站信息或权限菜单,将当前网站的“麦克风”设为允许。找不到时,在浏览器设置中搜索“麦克风”,检查当前网站是否被屏蔽。",
648
+ "chatInputVoicePermissionDesktop": "检查系统是否允许 NextClaw 使用麦克风。如果权限已允许但仍失败,请重开 NextClaw 后重试。",
649
+ "chatInputVoicePermissionMac": "macOS:打开系统设置 → 隐私与安全性 → 麦克风,允许当前浏览器或 NextClaw。",
650
+ "chatInputVoicePermissionWindows": "Windows:打开设置 → 隐私和安全性(Windows 10 为“隐私”)→ 麦克风,开启麦克风访问及桌面应用访问。",
651
+ "chatInputVoicePermissionIos": "iPhone / iPad:在浏览器的网站设置中检查麦克风权限;同时在系统设置 → 隐私与安全性 → 麦克风中检查浏览器权限(如有该项)。",
652
+ "chatInputVoicePermissionAndroid": "Android:打开系统设置 → 应用 → 当前浏览器 → 权限 → 麦克风,允许使用;并检查浏览器中当前网站的权限。",
653
+ "chatInputVoicePermissionSystem": "在系统声音设置中确认已选中可用输入设备,并检查系统是否限制了浏览器或 NextClaw 的麦克风访问。",
654
+ "chatInputVoiceNoDevice": "没有检测到麦克风。请连接或启用输入设备,再检测重试。",
655
+ "chatInputVoiceDeviceBusy": "麦克风无法读取,可能被其他应用占用或被系统限制。请关闭其他录音应用,检查输入设备后重试。",
656
+ "chatInputVoiceServiceDenied": "浏览器拒绝了语音识别服务。请检查浏览器的语音服务设置或组织策略,或使用支持语音识别的浏览器。",
644
657
  "chatInputVoiceUnsupported": "当前浏览器不支持语音输入。",
645
658
  "chatInputVoiceNoSpeech": "没有听清,请再试一次。",
646
659
  "chatInputVoiceFailed": "语音输入失败,请重试。",
647
660
  "chatInputVoicePrivacy": "点击录音,文字实时显示在光标处,下划线表示尚未确定的内容。浏览器语音服务可能联网处理声音,不会自动发送。",
648
661
  "chatInputVoiceStart": "开始录音",
649
662
  "chatInputVoiceFinish": "结束录音",
663
+ "chatInputVoiceSaveAndClose": "保存并退出",
664
+ "chatInputVoiceEscapeHint": "按 Esc 保存语音文字并退出",
650
665
  "chatInputVoiceCancel": "取消",
651
- "chatInputVoiceStarting": "正在连接麦克风…",
666
+ "chatInputVoiceStarting": "准备录音",
652
667
  "chatInputVoiceRecording": "正在聆听",
653
- "chatInputVoiceFinishing": "正在完成转写…",
668
+ "chatInputVoiceFinishing": "完成转写",
654
669
  "chatInputVoiceReview": "录音已停止",
655
670
  "chatInputVoiceSpeak": "请说话,转写会显示在这里",
656
671
  "chatInputVoiceReviewHint": "已确认的文字保留在草稿中,未确定的片段已撤回。你可以继续编辑或重试。",