@nextclaw/ui 0.24.2 → 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 (172) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/.vite/manifest.json +538 -525
  3. package/dist/assets/{account-panel-BxMlLXYH.js → account-panel-UvHFa8Kl.js} +1 -1
  4. package/dist/assets/{api-Utt-xzrT.js → api-Dffeij78.js} +5 -5
  5. package/dist/assets/{app-data-DYXDL6X2.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-BfYznAK5.js → appearance-settings-page-D8cWniw-.js} +1 -1
  8. package/dist/assets/{book-open-DDW_3XpM.js → book-open-xqEj1OLE.js} +1 -1
  9. package/dist/assets/{boxes-DwJeuK_h.js → boxes-D6uPnkGo.js} +1 -1
  10. package/dist/assets/{card-IevY2nm-.js → card-CS8su2Zh.js} +1 -1
  11. package/dist/assets/{channels-list-page-DitnqbMR.js → channels-list-page-B_fYP1Pn.js} +1 -1
  12. package/dist/assets/{chevron-down-An7Km-t4.js → chevron-down-BL-yMYcX.js} +1 -1
  13. package/dist/assets/{circle-alert-CDXqELuo.js → circle-alert-lSa3LRvS.js} +1 -1
  14. package/dist/assets/{circle-dashed-CapXEpiV.js → circle-dashed-CU3yApvP.js} +1 -1
  15. package/dist/assets/{client-B0krol7A.js → client-BtGBwLTs.js} +1 -1
  16. package/dist/assets/{config-split-page-BtnkqWVp.js → config-split-page-6hHiiapl.js} +1 -1
  17. package/dist/assets/{confirm-dialog-DFt4jpN7.js → confirm-dialog-ClT6iaj8.js} +1 -1
  18. package/dist/assets/{createLucideIcon-BTHK0Ot9.js → createLucideIcon-BNKf0_kc.js} +1 -1
  19. package/dist/assets/{desktop-capabilities-page-CCf14WId.js → desktop-capabilities-page-CB5pniTx.js} +1 -1
  20. package/dist/assets/{desktop-update-config-DVoVgfFi.js → desktop-update-config-OuQV73gg.js} +1 -1
  21. package/dist/assets/{dialog-BFN_1DBH.js → dialog-Xe73hpQz.js} +1 -1
  22. package/dist/assets/{dist-Bc9hOVNN.js → dist-BiIcnf9j.js} +1 -1
  23. package/dist/assets/{dist-CEhIDAnr.js → dist-C5I0v8Qn.js} +1 -1
  24. package/dist/assets/{dist-hkVPP1th.js → dist-czPa8xFO.js} +1 -1
  25. package/dist/assets/doc-browser-BFkslEzY.js +1 -0
  26. package/dist/assets/{doc-browser-BNUxmwD6.js → doc-browser-Cu58uiEg.js} +1 -1
  27. package/dist/assets/{doc-browser-context-C4yQrKrH.js → doc-browser-context-CekJxJFm.js} +1 -1
  28. package/dist/assets/{ellipsis-BRI6ROeu.js → ellipsis-C5YXdOUx.js} +1 -1
  29. package/dist/assets/{es2015-MOCbM0Eu.js → es2015-cuwbYI-l.js} +1 -1
  30. package/dist/assets/{extensions-DxUBMtHW.js → extensions-B5r-0jCq.js} +1 -1
  31. package/dist/assets/{eye-cnynUDGt.js → eye-C84inPcV.js} +1 -1
  32. package/dist/assets/{form-actions-gQ0JEUwB.js → form-actions-AIdXOoCL.js} +1 -1
  33. package/dist/assets/{i18n-B9yXA8KO.js → i18n-CPjq_hQX.js} +2 -2
  34. package/dist/assets/{i18n-provider-BYBJua91.js → i18n-provider-BYO9AhFy.js} +1 -1
  35. package/dist/assets/keyboard-shortcuts-settings-page-1efEkGwq.js +1 -0
  36. package/dist/assets/loader-circle-K1BDdHR5.js +1 -0
  37. package/dist/assets/{login-page-CqrHhzaN.js → login-page-DBqkVTVD.js} +1 -1
  38. package/dist/assets/login-page-DjcRQLQe.js +1 -0
  39. package/dist/assets/{logo-badge-Cr03YceJ.js → logo-badge-C-5-zim6.js} +1 -1
  40. package/dist/assets/main-CLwCjK9U.css +1 -0
  41. package/dist/assets/main-CQ6GUYdX.js +2 -0
  42. package/dist/assets/main-i18n-catalog.config-QB4DfJRJ.js +1 -0
  43. package/dist/assets/{mcp-marketplace-page-CifiVgFD.js → mcp-marketplace-page-CWAxBNaS.js} +1 -1
  44. package/dist/assets/mcp-marketplace-page-Ct090ceA.js +1 -0
  45. package/dist/assets/{model-config-page-C6jVNbSj.js → model-config-page-DBcuZyRC.js} +1 -1
  46. package/dist/assets/{navigation-link-kbCLRSr_.js → navigation-link-BAQjgx2F.js} +1 -1
  47. package/dist/assets/panel-app-i18n-catalog.config-CQFujXqt.js +1 -0
  48. package/dist/assets/panel-app-maumVhbl.js +1 -0
  49. package/dist/assets/{panelStandalone-DMBnYyTw.js → panelStandalone-BGA1s7JG.js} +2 -2
  50. package/dist/assets/plus-LA8-jpxP.js +1 -0
  51. package/dist/assets/{popover-XVDgjxcl.js → popover-NkrFlWHp.js} +1 -1
  52. package/dist/assets/{privacy-settings-page-BN27MBVA.js → privacy-settings-page-DVC-aq1v.js} +1 -1
  53. package/dist/assets/projects-Cq0E3c7N.js +1 -0
  54. package/dist/assets/{provider-scoped-model-input-BGZMCThn.js → provider-scoped-model-input-BGdZMWWa.js} +1 -1
  55. package/dist/assets/{providers-config-page-D5aBwbOD.js → providers-config-page-B-IhHl6x.js} +1 -1
  56. package/dist/assets/{puzzle-D5Dhacmb.js → puzzle-D6gulz7u.js} +1 -1
  57. package/dist/assets/{pwa-ClQK56Qe.js → pwa-C3kR9mdy.js} +1 -1
  58. package/dist/assets/{refresh-cw-Bw5BY7vp.js → refresh-cw-h9Xq3LY3.js} +1 -1
  59. package/dist/assets/remote-Cnawibbg.js +1 -0
  60. package/dist/assets/{rotate-cw-BksCLAeE.js → rotate-cw-B6xm41Wn.js} +1 -1
  61. package/dist/assets/runtime-config-page-LmrB2q65.js +1 -0
  62. package/dist/assets/{save-CVi8VWzb.js → save-fff_WpY2.js} +1 -1
  63. package/dist/assets/{search-DN-f7IIC.js → search-CUe41wjm.js} +1 -1
  64. package/dist/assets/{search-config-page-BmuZTVxs.js → search-config-page-BIXmv5FY.js} +1 -1
  65. package/dist/assets/{secrets-config-page-NPHpg9sb.js → secrets-config-page-DU3fadrq.js} +1 -1
  66. package/dist/assets/security-config-9s0i0I4N.js +1 -0
  67. package/dist/assets/{select-CmDTqk2C.js → select-CG5YWnwe.js} +1 -1
  68. package/dist/assets/{service-apps-panel-BYGJ9_pO.js → service-apps-panel-Bshf3Wmq.js} +1 -1
  69. package/dist/assets/{settings-2-B62YtZon.js → settings-2-CE3ELQyk.js} +1 -1
  70. package/dist/assets/skeleton-BL9h32W7.js +1 -0
  71. package/dist/assets/{tabs-Bk_mG7ql.js → tabs-BuE0qKMh.js} +1 -1
  72. package/dist/assets/{tag-chip-DgqKlI5D.js → tag-chip-gAH1IOsZ.js} +1 -1
  73. package/dist/assets/{theme-provider-CeBY2pyu.js → theme-provider-DE7m9jvf.js} +1 -1
  74. package/dist/assets/{tooltip-CgGbBdYf.js → tooltip-DYcuWhuP.js} +1 -1
  75. package/dist/assets/{trash-2-CVOev8yc.js → trash-2-BPPYt2NV.js} +1 -1
  76. package/dist/assets/{use-config-b2DN67gs.js → use-config-Cyiw06-j.js} +1 -1
  77. package/dist/assets/{use-desktop-capabilities-B3tLU1yl.js → use-desktop-capabilities-OKG96v4y.js} +1 -1
  78. package/dist/assets/{use-panel-app-scroll-restoration-BekzoX6z.js → use-panel-app-scroll-restoration-DsV1IJx-.js} +1 -1
  79. package/dist/assets/{use-viewport-layout-CUA3jQsA.js → use-viewport-layout-BS18drMT.js} +1 -1
  80. package/dist/assets/{utils-UBHTdG5Q.js → utils-CBZPzC_b.js} +1 -1
  81. package/dist/assets/x-DdXOqLnX.js +1 -0
  82. package/dist/index.html +50 -50
  83. package/dist/panel-standalone.html +15 -15
  84. package/package.json +7 -7
  85. package/src/app/configs/app-navigation.config.ts +10 -3
  86. package/src/app/index.tsx +7 -0
  87. package/src/features/chat/components/providers/chat-presenter.provider.tsx +2 -0
  88. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +12 -0
  89. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +13 -1
  90. package/src/features/chat/features/conversation/components/__tests__/session-voice-input.test-utils.ts +52 -0
  91. package/src/features/chat/features/conversation/components/chat-voice-input-panel.tsx +70 -0
  92. package/src/features/chat/features/conversation/components/chat-voice-shortcut-control.tsx +33 -0
  93. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +1 -11
  94. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +29 -5
  95. package/src/features/chat/features/conversation/hooks/__tests__/use-chat-voice-input.test.tsx +185 -0
  96. package/src/features/chat/features/conversation/hooks/use-chat-voice-input.ts +52 -0
  97. package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +13 -6
  98. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +28 -12
  99. package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +19 -1
  100. package/src/features/chat/features/message/utils/__tests__/chat-message-timeline.utils.test.ts +10 -0
  101. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +7 -6
  102. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +4 -1
  103. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +20 -0
  104. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +90 -6
  105. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +64 -2
  106. package/src/features/chat/index.ts +1 -0
  107. package/src/features/chat/managers/chat-voice-input.manager.ts +174 -0
  108. package/src/features/chat/presenters/chat.presenter.ts +2 -0
  109. package/src/features/chat/stores/chat-voice-shortcut.store.ts +31 -0
  110. package/src/features/desktop-capabilities/index.ts +1 -0
  111. package/src/features/panel-apps/components/__tests__/panel-app-open-standalone-menu-item.test.tsx +3 -2
  112. package/src/features/panel-apps/components/panel-app-open-standalone-menu-item.tsx +4 -1
  113. package/src/features/projects/AGENTS.md +1 -2
  114. package/src/features/projects/components/project-agreement.tsx +36 -77
  115. package/src/features/projects/components/project-artifacts.test.tsx +90 -62
  116. package/src/features/projects/components/project-artifacts.tsx +81 -160
  117. package/src/features/projects/components/project-materials.test.tsx +80 -0
  118. package/src/features/projects/components/project-overview.test.tsx +50 -40
  119. package/src/features/projects/components/project-overview.tsx +5 -4
  120. package/src/features/projects/components/project-skills.tsx +24 -14
  121. package/src/features/projects/components/work/project-work-item-drawer.test.tsx +3 -2
  122. package/src/features/projects/components/work/project-work-items.test.tsx +13 -1
  123. package/src/features/projects/components/work/project-work-items.tsx +7 -7
  124. package/src/features/projects/hooks/use-project-materials.test.tsx +65 -0
  125. package/src/features/projects/hooks/use-project-materials.ts +24 -0
  126. package/src/features/projects/hooks/use-project-work.test.tsx +57 -1
  127. package/src/features/projects/hooks/use-project-work.ts +11 -5
  128. package/src/features/projects/pages/project-home-page.tsx +51 -127
  129. package/src/features/projects/pages/projects-page.test.tsx +68 -75
  130. package/src/features/projects/presenters/project-home.presenter.test.ts +1 -64
  131. package/src/features/projects/presenters/project-home.presenter.ts +17 -54
  132. package/src/features/projects/utils/project-artifact-view.utils.test.ts +0 -46
  133. package/src/features/projects/utils/project-artifact-view.utils.ts +0 -47
  134. package/src/features/settings/pages/keyboard-shortcuts-settings-page.tsx +15 -0
  135. package/src/platforms/mobile/components/mobile-settings-shell.tsx +2 -1
  136. package/src/shared/lib/api/__tests__/ncp-session-query-cache.utils.test.ts +109 -1
  137. package/src/shared/lib/api/ncp-session-query-cache.utils.ts +71 -2
  138. package/src/shared/lib/i18n/locales/en-US/chat.json +32 -1
  139. package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
  140. package/src/shared/lib/i18n/locales/en-US/projects.json +8 -59
  141. package/src/shared/lib/i18n/locales/zh-CN/chat.json +32 -1
  142. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
  143. package/src/shared/lib/i18n/locales/zh-CN/projects.json +8 -59
  144. package/src/shared/lib/transport/__tests__/browser-realtime-recovery.service.test.ts +42 -0
  145. package/src/shared/lib/transport/__tests__/local-transport.service.test.ts +85 -0
  146. package/src/shared/lib/transport/__tests__/remote.transport.test.ts +54 -1
  147. package/src/shared/lib/transport/browser-realtime-recovery.service.ts +35 -0
  148. package/src/shared/lib/transport/local-transport.service.ts +32 -0
  149. package/src/shared/lib/transport/remote-transport.service.ts +30 -0
  150. package/dist/assets/app-presenter-provider-CLsFiXvV.js +0 -109
  151. package/dist/assets/doc-browser-w4eGZXLu.js +0 -1
  152. package/dist/assets/loader-circle-rWr5Yo0E.js +0 -1
  153. package/dist/assets/login-page-CfMSkOsg.js +0 -1
  154. package/dist/assets/main-8cVkMfgE.css +0 -1
  155. package/dist/assets/main-Bys58GxY.js +0 -2
  156. package/dist/assets/main-i18n-catalog.config-BSq9yiUQ.js +0 -1
  157. package/dist/assets/mcp-marketplace-page-DX5VE3mU.js +0 -1
  158. package/dist/assets/panel-app-BUUle0gP.js +0 -1
  159. package/dist/assets/panel-app-i18n-catalog.config-C2St67SP.js +0 -1
  160. package/dist/assets/plus-BHnKwuPq.js +0 -1
  161. package/dist/assets/projects-DV3UuJMS.js +0 -1
  162. package/dist/assets/remote-Bl005fzu.js +0 -1
  163. package/dist/assets/runtime-config-page-CmktQcMW.js +0 -1
  164. package/dist/assets/security-config-Djptuyf2.js +0 -1
  165. package/dist/assets/skeleton-Eh64l4T2.js +0 -1
  166. package/dist/assets/x-C0iaOsRW.js +0 -1
  167. package/src/features/projects/components/project-requests.tsx +0 -57
  168. package/src/features/projects/components/project-runs.tsx +0 -60
  169. package/src/features/projects/hooks/use-project-observation.test.tsx +0 -93
  170. package/src/features/projects/hooks/use-project-observation.ts +0 -115
  171. package/src/features/projects/utils/project-request-response.utils.test.ts +0 -44
  172. package/src/features/projects/utils/project-request-response.utils.ts +0 -39
@@ -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'],
@@ -31,6 +31,22 @@ function buildDiscoveredModelGroups(options: ChatModelRecord[]) {
31
31
  return [...groups.values()];
32
32
  }
33
33
 
34
+ function buildRemainingModelGroups(options: ChatModelRecord[], allModelsLabel: string) {
35
+ const groups = new Map<string, { key: string; label: string; options: Array<{ value: string; label: string }> }>();
36
+ for (const option of options) {
37
+ const separatorIndex = option.value.indexOf('/');
38
+ const providerKey = separatorIndex > 0 ? option.value.slice(0, separatorIndex) : "other";
39
+ const group = groups.get(providerKey) ?? {
40
+ key: `provider-${providerKey}`,
41
+ label: option.providerLabel.trim() || allModelsLabel,
42
+ options: [],
43
+ };
44
+ group.options.push({ value: option.value, label: option.modelLabel.trim() });
45
+ groups.set(providerKey, group);
46
+ }
47
+ return [...groups.values()];
48
+ }
49
+
34
50
  export function toChatModelRecords(snapshotModels: ChatModelOption[]): ChatModelRecord[] {
35
51
  return snapshotModels.map((model) => ({
36
52
  value: model.value,
@@ -183,8 +199,9 @@ export function buildModelToolbarSelect({
183
199
  const remainingOptions = modelOptions.filter(
184
200
  (option) => !favoriteValueSet.has(option.value) && !recentValueSet.has(option.value),
185
201
  );
186
- const optionGroups = favoriteOptions.length > 0 || recentOptions.length > 0
187
- ? [
202
+ const groups = [
203
+ ...(favoriteOptions.length > 0
204
+ ? [
188
205
  {
189
206
  key: "favorite-models",
190
207
  label: texts.favoriteModelsLabel,
@@ -193,6 +210,10 @@ export function buildModelToolbarSelect({
193
210
  label: formatModelOptionLabel(option),
194
211
  })),
195
212
  },
213
+ ]
214
+ : []),
215
+ ...(recentOptions.length > 0
216
+ ? [
196
217
  {
197
218
  key: "recent-models",
198
219
  label: texts.recentModelsLabel,
@@ -201,16 +222,11 @@ export function buildModelToolbarSelect({
201
222
  label: formatModelOptionLabel(option),
202
223
  })),
203
224
  },
204
- {
205
- key: "all-models",
206
- label: texts.allModelsLabel,
207
- options: remainingOptions.map((option) => ({
208
- value: option.value,
209
- label: formatModelOptionLabel(option),
210
- })),
211
- },
212
- ].filter((group) => group.options.length > 0)
213
- : undefined;
225
+ ]
226
+ : []),
227
+ ...buildRemainingModelGroups(remainingOptions, texts.allModelsLabel),
228
+ ].filter((group) => group.options.length > 0);
229
+ const optionGroups = groups.length > 0 ? groups : undefined;
214
230
  const discoveryOptions = discoveredModelOptions ?? [];
215
231
 
216
232
  return {
@@ -1,4 +1,5 @@
1
- import type { NcpMessage } from "@nextclaw/ncp";
1
+ import { NcpEventType, type NcpMessage } from "@nextclaw/ncp";
2
+ import { DefaultNcpAgentConversationStateManager } from "@nextclaw/ncp-toolkit";
2
3
  import { describe, expect, it } from "vitest";
3
4
  import { buildChatMessageProcessSummary } from "@/features/chat/features/message/utils/chat-message-process-summary.utils";
4
5
 
@@ -29,6 +30,23 @@ const baseAssistantMessage = {
29
30
  } satisfies NcpMessage;
30
31
 
31
32
  describe("buildChatMessageProcessSummary", () => {
33
+ it("shows elapsed time from the real completed-message then run-finished event sequence", async () => {
34
+ const manager = new DefaultNcpAgentConversationStateManager();
35
+ await manager.dispatchBatch([
36
+ { type: NcpEventType.RunStarted, payload: { sessionId: "session-1", runId: "run-1" } },
37
+ { type: NcpEventType.MessageCompleted, payload: { sessionId: "session-1", message: baseAssistantMessage } },
38
+ {
39
+ type: NcpEventType.RunFinished,
40
+ payload: {
41
+ sessionId: "session-1", runId: "run-1", messageId: baseAssistantMessage.id,
42
+ startedAt: "2026-03-31T10:00:00.000Z", endedAt: "2026-03-31T10:03:51.000Z",
43
+ },
44
+ },
45
+ ]);
46
+ const message = manager.getSnapshot().messages[0]!;
47
+ expect(buildChatMessageProcessSummary({ message, processedLabel: "已处理" })?.label).toBe("已处理 3m 51s");
48
+ });
49
+
32
50
  it("does not invent duration when lifecycle timing is absent", () => {
33
51
  expect(
34
52
  buildChatMessageProcessSummary({
@@ -125,6 +125,16 @@ describe("chat message timeline visibility", () => {
125
125
  })).toBe(false);
126
126
  expect(isVisibleChatMessage(visibleMessage)).toBe(true);
127
127
  });
128
+
129
+ it("keeps a normal assistant reply visible when it explains the silent marker", () => {
130
+ expect(isVisibleChatMessage({
131
+ ...visibleMessage,
132
+ parts: [{
133
+ type: "text",
134
+ text: "空转 tick 调用一次服务,然后返回 `<noreply/>`,成本很低。",
135
+ }],
136
+ })).toBe(true);
137
+ });
128
138
  });
129
139
 
130
140
  describe("chat message continuation projection", () => {
@@ -242,10 +242,11 @@ it("preserves full generic tool args for the expanded body while keeping the hea
242
242
 
243
243
  it("keeps structured terminal results as structured data instead of raw json output", () => {
244
244
  const terminalResult = {
245
- status: "completed",
246
- command: "python3 -m http.server 8765",
247
- aggregated_output: "",
248
- exit_code: 0,
245
+ ok: true,
246
+ command: "nextclaw --version",
247
+ exitCode: 0,
248
+ stdout: "0.48.3\n",
249
+ stderr: "",
249
250
  };
250
251
 
251
252
  const adapted = adapt([
@@ -259,7 +260,7 @@ it("keeps structured terminal results as structured data instead of raw json out
259
260
  status: ToolInvocationStatus.RESULT,
260
261
  toolCallId: "call-terminal-result",
261
262
  toolName: "command_execution",
262
- args: '{"command":"python3 -m http.server 8765"}',
263
+ args: '{"command":"nextclaw --version"}',
263
264
  result: terminalResult,
264
265
  },
265
266
  },
@@ -271,7 +272,7 @@ it("keeps structured terminal results as structured data instead of raw json out
271
272
  type: "tool-card",
272
273
  card: {
273
274
  toolName: "command_execution",
274
- summary: "command: python3 -m http.server 8765",
275
+ summary: "command: nextclaw --version",
275
276
  output: undefined,
276
277
  outputData: terminalResult,
277
278
  statusTone: "success",
@@ -174,7 +174,10 @@ function buildToolInvocationPart(
174
174
  detail,
175
175
  inputData: fileOperationCardData ? undefined : invocation.parsedArgs ?? invocation.args,
176
176
  text: shouldShowRawResult && rawResult ? rawResult : undefined,
177
- outputData: shouldShowRawResult ? invocation.result : undefined,
177
+ outputData:
178
+ shouldShowRawResult || shouldHideStructuredTerminalJson
179
+ ? invocation.result
180
+ : undefined,
178
181
  callId: invocation.toolCallId || undefined,
179
182
  toolCallId: invocation.toolCallId || undefined,
180
183
  ...(invocation.execution ? { execution: invocation.execution } : {}),
@@ -513,6 +513,26 @@ describe("useNcpAgentRuntime", () => {
513
513
  });
514
514
 
515
515
  describe("useNcpAgentRuntime commands and live stream", () => {
516
+ it("keeps the run active after message completion and settles immediately on the terminal event", async () => {
517
+ const client = new DeferredSendClient();
518
+ const manager = new DefaultNcpAgentConversationStateManager();
519
+ const { result } = renderHook(() => useNcpAgentRuntime({ sessionId: "session-1", client, manager }));
520
+ await act(async () => {
521
+ await client.emit({ type: NcpEventType.RunStarted, payload: { sessionId: "session-1", runId: "run-1" } });
522
+ await client.emit({ type: NcpEventType.MessageCompleted, payload: {
523
+ sessionId: "session-1",
524
+ message: { id: "assistant-1", sessionId: "session-1", role: "assistant", status: "final", timestamp: now, parts: [{ type: "text", text: "Done" }] },
525
+ } });
526
+ });
527
+ await waitFor(() => expect(result.current.visibleMessages[0]?.status).toBe("final"));
528
+ expect(result.current.isRunning).toBe(true);
529
+ await act(async () => {
530
+ await client.emit({ type: NcpEventType.RunFinished, payload: { sessionId: "session-1", runId: "run-1", messageId: "assistant-1" } });
531
+ });
532
+ await waitFor(() => expect(result.current.isRunning).toBe(false));
533
+ expect(result.current.isSending).toBe(false);
534
+ });
535
+
516
536
  beforeEach(() => {
517
537
  vi.clearAllMocks();
518
538
  });
@@ -9,7 +9,9 @@ import {
9
9
  import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
10
10
 
11
11
  const mocks = vi.hoisted(() => ({
12
+ confirm: vi.fn(),
12
13
  createSession: vi.fn(),
14
+ removeProject: vi.fn(),
13
15
  toggleProjectCollapsed: vi.fn(),
14
16
  toggleProjectPinned: vi.fn(),
15
17
  }));
@@ -19,6 +21,18 @@ vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
19
21
  chatSessionListManager: mocks,
20
22
  }),
21
23
  }));
24
+ vi.mock("@/shared/hooks/use-projects", () => ({
25
+ useRemoveProject: () => ({
26
+ mutateAsync: mocks.removeProject,
27
+ isPending: false,
28
+ }),
29
+ }));
30
+ vi.mock("@/shared/hooks/use-confirm-dialog", () => ({
31
+ useConfirmDialog: () => ({
32
+ confirm: mocks.confirm,
33
+ ConfirmDialog: () => <div data-testid="confirm-dialog" />,
34
+ }),
35
+ }));
22
36
 
23
37
  const projectGroup: ChatSidebarProjectGroup = {
24
38
  projectId: "project-analysis",
@@ -50,9 +64,10 @@ function LocationProbe() {
50
64
  function renderProjectGroups(
51
65
  isPinned = false,
52
66
  group: ChatSidebarProjectGroup = projectGroup,
67
+ initialPath = "/chat",
53
68
  ) {
54
69
  return render(
55
- <MemoryRouter initialEntries={["/chat"]}>
70
+ <MemoryRouter initialEntries={[initialPath]}>
56
71
  <ChatSidebarProjectGroups
57
72
  groups={[{ ...group, isPinned }]}
58
73
  projectCronJobCountByRoot={new Map([[projectGroup.projectRoot, 2]])}
@@ -69,7 +84,11 @@ function renderProjectGroups(
69
84
 
70
85
  describe("ChatSidebarProjectGroups", () => {
71
86
  beforeEach(() => {
87
+ mocks.confirm.mockReset();
88
+ mocks.confirm.mockResolvedValue(true);
72
89
  mocks.createSession.mockReset();
90
+ mocks.removeProject.mockReset();
91
+ mocks.removeProject.mockResolvedValue(undefined);
73
92
  mocks.toggleProjectCollapsed.mockReset();
74
93
  mocks.toggleProjectPinned.mockReset();
75
94
  useChatSessionListStore.setState({
@@ -97,12 +116,16 @@ describe("ChatSidebarProjectGroups", () => {
97
116
  expect(projectRow?.className).toContain("hover:bg-gray-200/60");
98
117
  const projectContent = projectLink.parentElement;
99
118
  expect(projectContent?.classList.contains("pr-14")).toBe(false);
100
- expect(projectContent?.className).toContain("group-hover/project:pr-14");
119
+ expect(projectContent?.className).toContain("group-hover/project:pr-20");
101
120
  expect(projectContent?.className).toContain(
102
- "group-has-[[data-project-actions]:focus-within]/project:pr-14",
121
+ "group-has-[[data-project-actions]:focus-within]/project:pr-20",
122
+ );
123
+ expect(projectLink.querySelector("span")?.className).not.toContain(
124
+ "uppercase",
125
+ );
126
+ expect(projectLink.querySelector("span")?.className).toContain(
127
+ "text-[13px]",
103
128
  );
104
- expect(projectLink.querySelector("span")?.className).not.toContain("uppercase");
105
- expect(projectLink.querySelector("span")?.className).toContain("text-[13px]");
106
129
  expect(
107
130
  header.compareDocumentPosition(projectLink) &
108
131
  Node.DOCUMENT_POSITION_FOLLOWING,
@@ -159,7 +182,7 @@ describe("ChatSidebarProjectGroups", () => {
159
182
  expect(within(tooltip).getByText("2")).toBeTruthy();
160
183
  });
161
184
 
162
- it("keeps project creation and pinning in one trailing action cluster", () => {
185
+ it("keeps project creation, pinning, and more actions in one trailing cluster", () => {
163
186
  renderProjectGroups();
164
187
 
165
188
  fireEvent.click(screen.getByLabelText("Pin project"));
@@ -168,6 +191,67 @@ describe("ChatSidebarProjectGroups", () => {
168
191
  "/tmp/analysis-project",
169
192
  );
170
193
  expect(screen.getByLabelText("New Task · analysis-project")).not.toBeNull();
194
+ const pinButton = screen.getByLabelText("Pin project");
195
+ const moreButton = screen.getByLabelText(
196
+ "More actions for analysis-project",
197
+ );
198
+ expect(moreButton.querySelector(".lucide-ellipsis-vertical")).not.toBeNull();
199
+ expect(
200
+ pinButton.compareDocumentPosition(moreButton) &
201
+ Node.DOCUMENT_POSITION_FOLLOWING,
202
+ ).toBeTruthy();
203
+ });
204
+
205
+ it("removes a project from an ordinary more-actions menu after confirmation", async () => {
206
+ const user = userEvent.setup();
207
+ renderProjectGroups(
208
+ false,
209
+ projectGroup,
210
+ "/projects/project-analysis/overview",
211
+ );
212
+
213
+ await user.click(
214
+ screen.getByLabelText("More actions for analysis-project"),
215
+ );
216
+ const removeButton = await screen.findByRole("button", {
217
+ name: "Remove from project list",
218
+ });
219
+ expect(removeButton.className).not.toContain("text-destructive");
220
+ await user.click(removeButton);
221
+
222
+ await vi.waitFor(() =>
223
+ expect(mocks.confirm).toHaveBeenCalledWith(
224
+ expect.objectContaining({
225
+ description: expect.stringMatching(/local folder|本地目录/),
226
+ variant: "default",
227
+ }),
228
+ ),
229
+ );
230
+ await vi.waitFor(() =>
231
+ expect(mocks.removeProject).toHaveBeenCalledWith("project-analysis"),
232
+ );
233
+ await vi.waitFor(() =>
234
+ expect(screen.getByTestId("location").textContent).toBe("/projects"),
235
+ );
236
+ });
237
+
238
+ it("keeps the project when removal is cancelled from the menu", async () => {
239
+ mocks.confirm.mockResolvedValue(false);
240
+ const user = userEvent.setup();
241
+ renderProjectGroups();
242
+
243
+ await user.click(
244
+ screen.getByLabelText("More actions for analysis-project"),
245
+ );
246
+ await user.click(
247
+ await screen.findByRole("button", {
248
+ name: "Remove from project list",
249
+ }),
250
+ );
251
+
252
+ await vi.waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce());
253
+ expect(mocks.removeProject).not.toHaveBeenCalled();
254
+ expect(screen.getByRole("link", { name: "analysis-project" })).toBeTruthy();
171
255
  });
172
256
 
173
257
  it("uses the same pin control to show and clear the project pin state", () => {