@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.
- package/CHANGELOG.md +43 -0
- package/dist/.vite/manifest.json +538 -525
- package/dist/assets/{account-panel-BxMlLXYH.js → account-panel-UvHFa8Kl.js} +1 -1
- package/dist/assets/{api-Utt-xzrT.js → api-Dffeij78.js} +5 -5
- package/dist/assets/{app-data-DYXDL6X2.js → app-data-DzaIMy4D.js} +1 -1
- package/dist/assets/app-presenter-provider-O2tUKKGf.js +109 -0
- package/dist/assets/{appearance-settings-page-BfYznAK5.js → appearance-settings-page-D8cWniw-.js} +1 -1
- package/dist/assets/{book-open-DDW_3XpM.js → book-open-xqEj1OLE.js} +1 -1
- package/dist/assets/{boxes-DwJeuK_h.js → boxes-D6uPnkGo.js} +1 -1
- package/dist/assets/{card-IevY2nm-.js → card-CS8su2Zh.js} +1 -1
- package/dist/assets/{channels-list-page-DitnqbMR.js → channels-list-page-B_fYP1Pn.js} +1 -1
- package/dist/assets/{chevron-down-An7Km-t4.js → chevron-down-BL-yMYcX.js} +1 -1
- package/dist/assets/{circle-alert-CDXqELuo.js → circle-alert-lSa3LRvS.js} +1 -1
- package/dist/assets/{circle-dashed-CapXEpiV.js → circle-dashed-CU3yApvP.js} +1 -1
- package/dist/assets/{client-B0krol7A.js → client-BtGBwLTs.js} +1 -1
- package/dist/assets/{config-split-page-BtnkqWVp.js → config-split-page-6hHiiapl.js} +1 -1
- package/dist/assets/{confirm-dialog-DFt4jpN7.js → confirm-dialog-ClT6iaj8.js} +1 -1
- package/dist/assets/{createLucideIcon-BTHK0Ot9.js → createLucideIcon-BNKf0_kc.js} +1 -1
- package/dist/assets/{desktop-capabilities-page-CCf14WId.js → desktop-capabilities-page-CB5pniTx.js} +1 -1
- package/dist/assets/{desktop-update-config-DVoVgfFi.js → desktop-update-config-OuQV73gg.js} +1 -1
- package/dist/assets/{dialog-BFN_1DBH.js → dialog-Xe73hpQz.js} +1 -1
- package/dist/assets/{dist-Bc9hOVNN.js → dist-BiIcnf9j.js} +1 -1
- package/dist/assets/{dist-CEhIDAnr.js → dist-C5I0v8Qn.js} +1 -1
- package/dist/assets/{dist-hkVPP1th.js → dist-czPa8xFO.js} +1 -1
- package/dist/assets/doc-browser-BFkslEzY.js +1 -0
- package/dist/assets/{doc-browser-BNUxmwD6.js → doc-browser-Cu58uiEg.js} +1 -1
- package/dist/assets/{doc-browser-context-C4yQrKrH.js → doc-browser-context-CekJxJFm.js} +1 -1
- package/dist/assets/{ellipsis-BRI6ROeu.js → ellipsis-C5YXdOUx.js} +1 -1
- package/dist/assets/{es2015-MOCbM0Eu.js → es2015-cuwbYI-l.js} +1 -1
- package/dist/assets/{extensions-DxUBMtHW.js → extensions-B5r-0jCq.js} +1 -1
- package/dist/assets/{eye-cnynUDGt.js → eye-C84inPcV.js} +1 -1
- package/dist/assets/{form-actions-gQ0JEUwB.js → form-actions-AIdXOoCL.js} +1 -1
- package/dist/assets/{i18n-B9yXA8KO.js → i18n-CPjq_hQX.js} +2 -2
- package/dist/assets/{i18n-provider-BYBJua91.js → i18n-provider-BYO9AhFy.js} +1 -1
- package/dist/assets/keyboard-shortcuts-settings-page-1efEkGwq.js +1 -0
- package/dist/assets/loader-circle-K1BDdHR5.js +1 -0
- package/dist/assets/{login-page-CqrHhzaN.js → login-page-DBqkVTVD.js} +1 -1
- package/dist/assets/login-page-DjcRQLQe.js +1 -0
- package/dist/assets/{logo-badge-Cr03YceJ.js → logo-badge-C-5-zim6.js} +1 -1
- package/dist/assets/main-CLwCjK9U.css +1 -0
- package/dist/assets/main-CQ6GUYdX.js +2 -0
- package/dist/assets/main-i18n-catalog.config-QB4DfJRJ.js +1 -0
- package/dist/assets/{mcp-marketplace-page-CifiVgFD.js → mcp-marketplace-page-CWAxBNaS.js} +1 -1
- package/dist/assets/mcp-marketplace-page-Ct090ceA.js +1 -0
- package/dist/assets/{model-config-page-C6jVNbSj.js → model-config-page-DBcuZyRC.js} +1 -1
- package/dist/assets/{navigation-link-kbCLRSr_.js → navigation-link-BAQjgx2F.js} +1 -1
- package/dist/assets/panel-app-i18n-catalog.config-CQFujXqt.js +1 -0
- package/dist/assets/panel-app-maumVhbl.js +1 -0
- package/dist/assets/{panelStandalone-DMBnYyTw.js → panelStandalone-BGA1s7JG.js} +2 -2
- package/dist/assets/plus-LA8-jpxP.js +1 -0
- package/dist/assets/{popover-XVDgjxcl.js → popover-NkrFlWHp.js} +1 -1
- package/dist/assets/{privacy-settings-page-BN27MBVA.js → privacy-settings-page-DVC-aq1v.js} +1 -1
- package/dist/assets/projects-Cq0E3c7N.js +1 -0
- package/dist/assets/{provider-scoped-model-input-BGZMCThn.js → provider-scoped-model-input-BGdZMWWa.js} +1 -1
- package/dist/assets/{providers-config-page-D5aBwbOD.js → providers-config-page-B-IhHl6x.js} +1 -1
- package/dist/assets/{puzzle-D5Dhacmb.js → puzzle-D6gulz7u.js} +1 -1
- package/dist/assets/{pwa-ClQK56Qe.js → pwa-C3kR9mdy.js} +1 -1
- package/dist/assets/{refresh-cw-Bw5BY7vp.js → refresh-cw-h9Xq3LY3.js} +1 -1
- package/dist/assets/remote-Cnawibbg.js +1 -0
- package/dist/assets/{rotate-cw-BksCLAeE.js → rotate-cw-B6xm41Wn.js} +1 -1
- package/dist/assets/runtime-config-page-LmrB2q65.js +1 -0
- package/dist/assets/{save-CVi8VWzb.js → save-fff_WpY2.js} +1 -1
- package/dist/assets/{search-DN-f7IIC.js → search-CUe41wjm.js} +1 -1
- package/dist/assets/{search-config-page-BmuZTVxs.js → search-config-page-BIXmv5FY.js} +1 -1
- package/dist/assets/{secrets-config-page-NPHpg9sb.js → secrets-config-page-DU3fadrq.js} +1 -1
- package/dist/assets/security-config-9s0i0I4N.js +1 -0
- package/dist/assets/{select-CmDTqk2C.js → select-CG5YWnwe.js} +1 -1
- package/dist/assets/{service-apps-panel-BYGJ9_pO.js → service-apps-panel-Bshf3Wmq.js} +1 -1
- package/dist/assets/{settings-2-B62YtZon.js → settings-2-CE3ELQyk.js} +1 -1
- package/dist/assets/skeleton-BL9h32W7.js +1 -0
- package/dist/assets/{tabs-Bk_mG7ql.js → tabs-BuE0qKMh.js} +1 -1
- package/dist/assets/{tag-chip-DgqKlI5D.js → tag-chip-gAH1IOsZ.js} +1 -1
- package/dist/assets/{theme-provider-CeBY2pyu.js → theme-provider-DE7m9jvf.js} +1 -1
- package/dist/assets/{tooltip-CgGbBdYf.js → tooltip-DYcuWhuP.js} +1 -1
- package/dist/assets/{trash-2-CVOev8yc.js → trash-2-BPPYt2NV.js} +1 -1
- package/dist/assets/{use-config-b2DN67gs.js → use-config-Cyiw06-j.js} +1 -1
- package/dist/assets/{use-desktop-capabilities-B3tLU1yl.js → use-desktop-capabilities-OKG96v4y.js} +1 -1
- package/dist/assets/{use-panel-app-scroll-restoration-BekzoX6z.js → use-panel-app-scroll-restoration-DsV1IJx-.js} +1 -1
- package/dist/assets/{use-viewport-layout-CUA3jQsA.js → use-viewport-layout-BS18drMT.js} +1 -1
- package/dist/assets/{utils-UBHTdG5Q.js → utils-CBZPzC_b.js} +1 -1
- package/dist/assets/x-DdXOqLnX.js +1 -0
- package/dist/index.html +50 -50
- package/dist/panel-standalone.html +15 -15
- package/package.json +7 -7
- package/src/app/configs/app-navigation.config.ts +10 -3
- package/src/app/index.tsx +7 -0
- package/src/features/chat/components/providers/chat-presenter.provider.tsx +2 -0
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +12 -0
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +13 -1
- package/src/features/chat/features/conversation/components/__tests__/session-voice-input.test-utils.ts +52 -0
- package/src/features/chat/features/conversation/components/chat-voice-input-panel.tsx +70 -0
- package/src/features/chat/features/conversation/components/chat-voice-shortcut-control.tsx +33 -0
- package/src/features/chat/features/conversation/components/session-conversation-area.tsx +1 -11
- package/src/features/chat/features/conversation/components/session-conversation-input.tsx +29 -5
- package/src/features/chat/features/conversation/hooks/__tests__/use-chat-voice-input.test.tsx +185 -0
- package/src/features/chat/features/conversation/hooks/use-chat-voice-input.ts +52 -0
- package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +13 -6
- package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +28 -12
- package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +19 -1
- package/src/features/chat/features/message/utils/__tests__/chat-message-timeline.utils.test.ts +10 -0
- package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +7 -6
- package/src/features/chat/features/message/utils/chat-message-part.utils.ts +4 -1
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +20 -0
- package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +90 -6
- package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +64 -2
- package/src/features/chat/index.ts +1 -0
- package/src/features/chat/managers/chat-voice-input.manager.ts +174 -0
- package/src/features/chat/presenters/chat.presenter.ts +2 -0
- package/src/features/chat/stores/chat-voice-shortcut.store.ts +31 -0
- package/src/features/desktop-capabilities/index.ts +1 -0
- package/src/features/panel-apps/components/__tests__/panel-app-open-standalone-menu-item.test.tsx +3 -2
- package/src/features/panel-apps/components/panel-app-open-standalone-menu-item.tsx +4 -1
- package/src/features/projects/AGENTS.md +1 -2
- package/src/features/projects/components/project-agreement.tsx +36 -77
- package/src/features/projects/components/project-artifacts.test.tsx +90 -62
- package/src/features/projects/components/project-artifacts.tsx +81 -160
- package/src/features/projects/components/project-materials.test.tsx +80 -0
- package/src/features/projects/components/project-overview.test.tsx +50 -40
- package/src/features/projects/components/project-overview.tsx +5 -4
- package/src/features/projects/components/project-skills.tsx +24 -14
- package/src/features/projects/components/work/project-work-item-drawer.test.tsx +3 -2
- package/src/features/projects/components/work/project-work-items.test.tsx +13 -1
- package/src/features/projects/components/work/project-work-items.tsx +7 -7
- package/src/features/projects/hooks/use-project-materials.test.tsx +65 -0
- package/src/features/projects/hooks/use-project-materials.ts +24 -0
- package/src/features/projects/hooks/use-project-work.test.tsx +57 -1
- package/src/features/projects/hooks/use-project-work.ts +11 -5
- package/src/features/projects/pages/project-home-page.tsx +51 -127
- package/src/features/projects/pages/projects-page.test.tsx +68 -75
- package/src/features/projects/presenters/project-home.presenter.test.ts +1 -64
- package/src/features/projects/presenters/project-home.presenter.ts +17 -54
- package/src/features/projects/utils/project-artifact-view.utils.test.ts +0 -46
- package/src/features/projects/utils/project-artifact-view.utils.ts +0 -47
- package/src/features/settings/pages/keyboard-shortcuts-settings-page.tsx +15 -0
- package/src/platforms/mobile/components/mobile-settings-shell.tsx +2 -1
- package/src/shared/lib/api/__tests__/ncp-session-query-cache.utils.test.ts +109 -1
- package/src/shared/lib/api/ncp-session-query-cache.utils.ts +71 -2
- package/src/shared/lib/i18n/locales/en-US/chat.json +32 -1
- package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
- package/src/shared/lib/i18n/locales/en-US/projects.json +8 -59
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +32 -1
- package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
- package/src/shared/lib/i18n/locales/zh-CN/projects.json +8 -59
- package/src/shared/lib/transport/__tests__/browser-realtime-recovery.service.test.ts +42 -0
- package/src/shared/lib/transport/__tests__/local-transport.service.test.ts +85 -0
- package/src/shared/lib/transport/__tests__/remote.transport.test.ts +54 -1
- package/src/shared/lib/transport/browser-realtime-recovery.service.ts +35 -0
- package/src/shared/lib/transport/local-transport.service.ts +32 -0
- package/src/shared/lib/transport/remote-transport.service.ts +30 -0
- package/dist/assets/app-presenter-provider-CLsFiXvV.js +0 -109
- package/dist/assets/doc-browser-w4eGZXLu.js +0 -1
- package/dist/assets/loader-circle-rWr5Yo0E.js +0 -1
- package/dist/assets/login-page-CfMSkOsg.js +0 -1
- package/dist/assets/main-8cVkMfgE.css +0 -1
- package/dist/assets/main-Bys58GxY.js +0 -2
- package/dist/assets/main-i18n-catalog.config-BSq9yiUQ.js +0 -1
- package/dist/assets/mcp-marketplace-page-DX5VE3mU.js +0 -1
- package/dist/assets/panel-app-BUUle0gP.js +0 -1
- package/dist/assets/panel-app-i18n-catalog.config-C2St67SP.js +0 -1
- package/dist/assets/plus-BHnKwuPq.js +0 -1
- package/dist/assets/projects-DV3UuJMS.js +0 -1
- package/dist/assets/remote-Bl005fzu.js +0 -1
- package/dist/assets/runtime-config-page-CmktQcMW.js +0 -1
- package/dist/assets/security-config-Djptuyf2.js +0 -1
- package/dist/assets/skeleton-Eh64l4T2.js +0 -1
- package/dist/assets/x-C0iaOsRW.js +0 -1
- package/src/features/projects/components/project-requests.tsx +0 -57
- package/src/features/projects/components/project-runs.tsx +0 -60
- package/src/features/projects/hooks/use-project-observation.test.tsx +0 -93
- package/src/features/projects/hooks/use-project-observation.ts +0 -115
- package/src/features/projects/utils/project-request-response.utils.test.ts +0 -44
- 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: '
|
|
442
|
-
label: '
|
|
441
|
+
key: 'provider-openai',
|
|
442
|
+
label: 'OpenAI',
|
|
443
443
|
options: [
|
|
444
444
|
{
|
|
445
445
|
value: 'openai/gpt-5',
|
|
446
|
-
label: '
|
|
447
|
-
}
|
|
446
|
+
label: 'gpt-5'
|
|
447
|
+
}
|
|
448
|
+
]
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
key: 'provider-minimax',
|
|
452
|
+
label: 'MiniMax',
|
|
453
|
+
options: [
|
|
448
454
|
{
|
|
449
455
|
value: 'minimax/MiniMax-M2.7',
|
|
450
|
-
label: 'MiniMax
|
|
456
|
+
label: 'MiniMax-M2.7'
|
|
451
457
|
}
|
|
452
458
|
]
|
|
453
459
|
}
|
|
@@ -487,11 +493,12 @@ describe('buildModelToolbarSelect selection', () => {
|
|
|
487
493
|
expect(select.groups?.map((group) => group.key)).toEqual([
|
|
488
494
|
'favorite-models',
|
|
489
495
|
'recent-models',
|
|
490
|
-
'
|
|
496
|
+
'provider-minimax'
|
|
491
497
|
]);
|
|
492
498
|
expect(select.groups?.[0]?.options.map((option) => option.value)).toEqual(['openai/gpt-5']);
|
|
493
499
|
expect(select.groups?.[1]?.options.map((option) => option.value)).toEqual(['anthropic/claude-sonnet-4']);
|
|
494
500
|
expect(select.groups?.[2]?.options.map((option) => option.value)).toEqual(['minimax/MiniMax-M2.7']);
|
|
501
|
+
expect(select.groups?.[2]?.label).toBe('MiniMax');
|
|
495
502
|
expect(select.optionAction).toMatchObject({
|
|
496
503
|
kind: 'favorite',
|
|
497
504
|
activeValues: ['openai/gpt-5'],
|
|
@@ -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
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
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({
|
package/src/features/chat/features/message/utils/__tests__/chat-message-timeline.utils.test.ts
CHANGED
|
@@ -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
|
-
|
|
246
|
-
command: "
|
|
247
|
-
|
|
248
|
-
|
|
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":"
|
|
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:
|
|
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:
|
|
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
|
});
|
package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx
CHANGED
|
@@ -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={[
|
|
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-
|
|
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-
|
|
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
|
|
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", () => {
|