@nextclaw/ui 0.24.3 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/dist/.vite/manifest.json +208 -195
- package/dist/assets/{account-panel-BRTSwNvA.js → account-panel-UvHFa8Kl.js} +1 -1
- package/dist/assets/{api-BnUzdltV.js → api-Dffeij78.js} +5 -5
- package/dist/assets/{app-data-Bjh_zM45.js → app-data-DzaIMy4D.js} +1 -1
- package/dist/assets/app-presenter-provider-O2tUKKGf.js +109 -0
- package/dist/assets/{appearance-settings-page-BrOYSgG0.js → appearance-settings-page-D8cWniw-.js} +1 -1
- package/dist/assets/{channels-list-page-BE0TxB3B.js → channels-list-page-B_fYP1Pn.js} +1 -1
- package/dist/assets/{config-split-page-BxDwwAGa.js → config-split-page-6hHiiapl.js} +1 -1
- package/dist/assets/{confirm-dialog-S5JxZcS1.js → confirm-dialog-ClT6iaj8.js} +1 -1
- package/dist/assets/{desktop-capabilities-page-BwHxJpbl.js → desktop-capabilities-page-CB5pniTx.js} +1 -1
- package/dist/assets/{desktop-update-config-Bfg7OdJ2.js → desktop-update-config-OuQV73gg.js} +1 -1
- package/dist/assets/doc-browser-BFkslEzY.js +1 -0
- package/dist/assets/{doc-browser-C8eqJLi_.js → doc-browser-Cu58uiEg.js} +1 -1
- package/dist/assets/{doc-browser-context-BPCDFcC-.js → doc-browser-context-CekJxJFm.js} +1 -1
- package/dist/assets/{extensions-DgKQRebe.js → extensions-B5r-0jCq.js} +1 -1
- package/dist/assets/{i18n-CKvm9zyk.js → i18n-CPjq_hQX.js} +2 -2
- package/dist/assets/{i18n-provider-CPV43pMX.js → i18n-provider-BYO9AhFy.js} +1 -1
- package/dist/assets/keyboard-shortcuts-settings-page-1efEkGwq.js +1 -0
- package/dist/assets/{login-page-DeDQjVxv.js → login-page-DBqkVTVD.js} +1 -1
- package/dist/assets/login-page-DjcRQLQe.js +1 -0
- package/dist/assets/main-CLwCjK9U.css +1 -0
- package/dist/assets/main-CQ6GUYdX.js +2 -0
- package/dist/assets/main-i18n-catalog.config-QB4DfJRJ.js +1 -0
- package/dist/assets/{mcp-marketplace-page-CWjJGp_D.js → mcp-marketplace-page-CWAxBNaS.js} +1 -1
- package/dist/assets/mcp-marketplace-page-Ct090ceA.js +1 -0
- package/dist/assets/{model-config-page-Dv_zE7HN.js → model-config-page-DBcuZyRC.js} +1 -1
- package/dist/assets/panel-app-i18n-catalog.config-CQFujXqt.js +1 -0
- package/dist/assets/panel-app-maumVhbl.js +1 -0
- package/dist/assets/{panelStandalone-CFRBHV94.js → panelStandalone-BGA1s7JG.js} +2 -2
- package/dist/assets/{privacy-settings-page-D6uc9f-A.js → privacy-settings-page-DVC-aq1v.js} +1 -1
- package/dist/assets/projects-Cq0E3c7N.js +1 -0
- package/dist/assets/{provider-scoped-model-input-DUa_9uwg.js → provider-scoped-model-input-BGdZMWWa.js} +1 -1
- package/dist/assets/{providers-config-page-DAiqxlF_.js → providers-config-page-B-IhHl6x.js} +1 -1
- package/dist/assets/{pwa-C_Zte1xe.js → pwa-C3kR9mdy.js} +1 -1
- package/dist/assets/remote-Cnawibbg.js +1 -0
- package/dist/assets/runtime-config-page-LmrB2q65.js +1 -0
- package/dist/assets/{search-config-page-3xbhRlW2.js → search-config-page-BIXmv5FY.js} +1 -1
- package/dist/assets/{secrets-config-page-Dn8_rdOj.js → secrets-config-page-DU3fadrq.js} +1 -1
- package/dist/assets/security-config-9s0i0I4N.js +1 -0
- package/dist/assets/{service-apps-panel-CGc04aMn.js → service-apps-panel-Bshf3Wmq.js} +1 -1
- package/dist/assets/{tag-chip-BDzpHEli.js → tag-chip-gAH1IOsZ.js} +1 -1
- package/dist/assets/{theme-provider-B6FinATQ.js → theme-provider-DE7m9jvf.js} +1 -1
- package/dist/assets/{use-config-BPUM2lbo.js → use-config-Cyiw06-j.js} +1 -1
- package/dist/assets/{use-desktop-capabilities-BDjArQP3.js → use-desktop-capabilities-OKG96v4y.js} +1 -1
- package/dist/index.html +18 -18
- package/dist/panel-standalone.html +5 -5
- package/package.json +6 -6
- package/src/app/configs/app-navigation.config.ts +10 -3
- package/src/app/index.tsx +7 -0
- package/src/features/chat/components/providers/chat-presenter.provider.tsx +2 -0
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +12 -0
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +13 -1
- package/src/features/chat/features/conversation/components/__tests__/session-voice-input.test-utils.ts +52 -0
- package/src/features/chat/features/conversation/components/chat-voice-input-panel.tsx +70 -0
- package/src/features/chat/features/conversation/components/chat-voice-shortcut-control.tsx +33 -0
- package/src/features/chat/features/conversation/components/session-conversation-area.tsx +1 -11
- package/src/features/chat/features/conversation/components/session-conversation-input.tsx +29 -5
- package/src/features/chat/features/conversation/hooks/__tests__/use-chat-voice-input.test.tsx +185 -0
- package/src/features/chat/features/conversation/hooks/use-chat-voice-input.ts +52 -0
- package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +13 -6
- package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +28 -12
- package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +19 -1
- package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +7 -6
- package/src/features/chat/features/message/utils/chat-message-part.utils.ts +4 -1
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +20 -0
- package/src/features/chat/index.ts +1 -0
- package/src/features/chat/managers/chat-voice-input.manager.ts +174 -0
- package/src/features/chat/presenters/chat.presenter.ts +2 -0
- package/src/features/chat/stores/chat-voice-shortcut.store.ts +31 -0
- package/src/features/desktop-capabilities/index.ts +1 -0
- package/src/features/settings/pages/keyboard-shortcuts-settings-page.tsx +15 -0
- package/src/platforms/mobile/components/mobile-settings-shell.tsx +2 -1
- package/src/shared/lib/api/__tests__/ncp-session-query-cache.utils.test.ts +109 -1
- package/src/shared/lib/api/ncp-session-query-cache.utils.ts +71 -2
- package/src/shared/lib/i18n/locales/en-US/chat.json +32 -1
- package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +32 -1
- package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
- package/src/shared/lib/transport/__tests__/browser-realtime-recovery.service.test.ts +42 -0
- package/src/shared/lib/transport/__tests__/local-transport.service.test.ts +85 -0
- package/src/shared/lib/transport/__tests__/remote.transport.test.ts +54 -1
- package/src/shared/lib/transport/browser-realtime-recovery.service.ts +35 -0
- package/src/shared/lib/transport/local-transport.service.ts +32 -0
- package/src/shared/lib/transport/remote-transport.service.ts +30 -0
- package/dist/assets/app-presenter-provider-CBt-lyr_.js +0 -109
- package/dist/assets/doc-browser-BgRP0Wni.js +0 -1
- package/dist/assets/login-page-ClcrvN04.js +0 -1
- package/dist/assets/main-DGqlaAaI.css +0 -1
- package/dist/assets/main-i18n-catalog.config-CpNF5aVc.js +0 -1
- package/dist/assets/main-nMVfHb5K.js +0 -2
- package/dist/assets/mcp-marketplace-page-fvu2NcXT.js +0 -1
- package/dist/assets/panel-app-BUUle0gP.js +0 -1
- package/dist/assets/panel-app-i18n-catalog.config-BzxE3tZb.js +0 -1
- package/dist/assets/projects-BD1rG75R.js +0 -1
- package/dist/assets/remote-BO0QP7ua.js +0 -1
- package/dist/assets/runtime-config-page-w1UAkGF_.js +0 -1
- package/dist/assets/security-config-Y1nEGM3A.js +0 -1
|
@@ -31,6 +31,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({
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { ChatComposerDictationSession } from '@nextclaw/agent-chat-ui';
|
|
2
|
+
|
|
3
|
+
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 VoiceSnapshot = { phase: VoicePhase; text: string; interim: string; seconds: number; error: VoiceError | null };
|
|
6
|
+
export type SpeechRecognitionLike = {
|
|
7
|
+
lang: string;
|
|
8
|
+
continuous: boolean;
|
|
9
|
+
interimResults: boolean;
|
|
10
|
+
start: () => void;
|
|
11
|
+
stop: () => void;
|
|
12
|
+
abort: () => void;
|
|
13
|
+
onstart: (() => void) | null;
|
|
14
|
+
onresult: ((event: { results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }> }) => void) | null;
|
|
15
|
+
onend: (() => void) | null;
|
|
16
|
+
onerror: ((event: { error: string }) => void) | null;
|
|
17
|
+
};
|
|
18
|
+
type RecognitionConstructor = new () => SpeechRecognitionLike;
|
|
19
|
+
const createBrowserRecognition = (): SpeechRecognitionLike | null => {
|
|
20
|
+
const host = window as Window & { SpeechRecognition?: RecognitionConstructor; webkitSpeechRecognition?: RecognitionConstructor };
|
|
21
|
+
const Constructor = host.SpeechRecognition ?? host.webkitSpeechRecognition;
|
|
22
|
+
return Constructor ? new Constructor() : null;
|
|
23
|
+
};
|
|
24
|
+
const initialSnapshot = (): VoiceSnapshot => ({ phase: 'idle', text: '', interim: '', seconds: 0, error: null });
|
|
25
|
+
const isActive = (phase: VoicePhase) => ['starting', 'recording', 'stopping'].includes(phase);
|
|
26
|
+
|
|
27
|
+
/** Owns one explicit dictation session; never writes to the chat draft or sends messages. */
|
|
28
|
+
export class ChatVoiceInputManager {
|
|
29
|
+
private snapshot = initialSnapshot();
|
|
30
|
+
private listeners = new Set<() => void>();
|
|
31
|
+
private recognition: SpeechRecognitionLike | null = null;
|
|
32
|
+
private deadline: ReturnType<typeof setTimeout> | undefined;
|
|
33
|
+
private ticker: ReturnType<typeof setInterval> | undefined;
|
|
34
|
+
private context = '';
|
|
35
|
+
private settingsRequested = false;
|
|
36
|
+
private draft: ChatComposerDictationSession | null = null;
|
|
37
|
+
|
|
38
|
+
constructor(private readonly createRecognition = createBrowserRecognition) {}
|
|
39
|
+
|
|
40
|
+
getSnapshot = (): VoiceSnapshot => this.snapshot;
|
|
41
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
42
|
+
this.listeners.add(listener);
|
|
43
|
+
return () => { this.listeners.delete(listener); };
|
|
44
|
+
};
|
|
45
|
+
private update = (patch: Partial<VoiceSnapshot>): void => {
|
|
46
|
+
this.snapshot = { ...this.snapshot, ...patch };
|
|
47
|
+
this.listeners.forEach((listener) => listener());
|
|
48
|
+
};
|
|
49
|
+
open = (context: string): void => {
|
|
50
|
+
if (this.snapshot.phase !== 'idle') return;
|
|
51
|
+
this.context = context;
|
|
52
|
+
this.update({ ...initialSnapshot(), phase: 'ready' });
|
|
53
|
+
};
|
|
54
|
+
openSettings = (): void => {
|
|
55
|
+
this.settingsRequested = true;
|
|
56
|
+
if (this.snapshot.phase === 'recording') { this.finish(); return; }
|
|
57
|
+
if (this.snapshot.phase === 'stopping') return;
|
|
58
|
+
const { context } = this;
|
|
59
|
+
this.acceptDraft();
|
|
60
|
+
this.open(context);
|
|
61
|
+
};
|
|
62
|
+
start = (language: string, context: string, draft: ChatComposerDictationSession | null): void => {
|
|
63
|
+
if (isActive(this.snapshot.phase)) return;
|
|
64
|
+
if (!draft) return;
|
|
65
|
+
this.release();
|
|
66
|
+
this.context = context;
|
|
67
|
+
this.draft = draft;
|
|
68
|
+
this.update({ ...initialSnapshot(), phase: 'starting' });
|
|
69
|
+
try {
|
|
70
|
+
const recognition = this.createRecognition();
|
|
71
|
+
if (!recognition) { this.fail('unsupported'); return; }
|
|
72
|
+
this.recognition = recognition;
|
|
73
|
+
recognition.lang = language === 'zh' ? 'zh-CN' : 'en-US';
|
|
74
|
+
recognition.continuous = true;
|
|
75
|
+
recognition.interimResults = true;
|
|
76
|
+
this.connect(recognition);
|
|
77
|
+
this.setDeadline(15000);
|
|
78
|
+
recognition.start();
|
|
79
|
+
} catch { this.fail('failed'); }
|
|
80
|
+
};
|
|
81
|
+
private connect = (recognition: SpeechRecognitionLike): void => {
|
|
82
|
+
recognition.onstart = () => {
|
|
83
|
+
if (this.recognition !== recognition || this.snapshot.phase !== 'starting') return;
|
|
84
|
+
clearTimeout(this.deadline);
|
|
85
|
+
this.update({ phase: 'recording' });
|
|
86
|
+
const started = Date.now();
|
|
87
|
+
this.ticker = setInterval(() => {
|
|
88
|
+
const seconds = Math.floor((Date.now() - started) / 1000);
|
|
89
|
+
this.update({ seconds });
|
|
90
|
+
if (seconds >= 60) this.finish();
|
|
91
|
+
}, 1000);
|
|
92
|
+
};
|
|
93
|
+
recognition.onresult = (event) => {
|
|
94
|
+
if (this.recognition !== recognition) return;
|
|
95
|
+
const finals: string[] = [];
|
|
96
|
+
const interim: string[] = [];
|
|
97
|
+
for (let i = 0; i < event.results.length; i += 1) {
|
|
98
|
+
const result = event.results[i];
|
|
99
|
+
const text = result[0]?.transcript?.trim();
|
|
100
|
+
if (text) (result.isFinal ? finals : interim).push(text);
|
|
101
|
+
}
|
|
102
|
+
this.update({ text: finals.join(' '), interim: interim.join(' ') });
|
|
103
|
+
this.draft?.update(this.snapshot.text, this.snapshot.interim);
|
|
104
|
+
};
|
|
105
|
+
recognition.onerror = ({ error }) => {
|
|
106
|
+
if (this.recognition !== recognition) return;
|
|
107
|
+
const reason: VoiceError = error === 'not-allowed' || error === 'service-not-allowed'
|
|
108
|
+
? 'permission'
|
|
109
|
+
: error === 'network' || error === 'audio-capture' || error === 'no-speech' ? error : 'failed';
|
|
110
|
+
this.fail(reason);
|
|
111
|
+
};
|
|
112
|
+
recognition.onend = () => {
|
|
113
|
+
if (this.recognition !== recognition) return;
|
|
114
|
+
const text = this.snapshot.text.trim();
|
|
115
|
+
this.release(false);
|
|
116
|
+
if (!text) { this.fail('no-speech'); return; }
|
|
117
|
+
this.draft?.commit(text);
|
|
118
|
+
this.draft = null;
|
|
119
|
+
const { settingsRequested, context } = this;
|
|
120
|
+
this.cancel();
|
|
121
|
+
if (settingsRequested) this.open(context);
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
finish = (): void => {
|
|
125
|
+
if (this.snapshot.phase === 'starting') { this.cancel(); return; }
|
|
126
|
+
if (this.snapshot.phase !== 'recording' || !this.recognition) return;
|
|
127
|
+
clearInterval(this.ticker);
|
|
128
|
+
this.update({ phase: 'stopping' });
|
|
129
|
+
this.setDeadline(8000);
|
|
130
|
+
try { this.recognition.stop(); } catch { this.fail('failed'); }
|
|
131
|
+
};
|
|
132
|
+
interrupt = (): void => {
|
|
133
|
+
if (isActive(this.snapshot.phase)) this.fail('interrupted');
|
|
134
|
+
};
|
|
135
|
+
acceptDraft = (): void => {
|
|
136
|
+
this.draft?.commit();
|
|
137
|
+
this.draft = null;
|
|
138
|
+
this.cancel();
|
|
139
|
+
};
|
|
140
|
+
cancel = (): void => {
|
|
141
|
+
this.settingsRequested = false;
|
|
142
|
+
this.release();
|
|
143
|
+
this.context = '';
|
|
144
|
+
this.draft?.cancel();
|
|
145
|
+
this.draft = null;
|
|
146
|
+
this.update(initialSnapshot());
|
|
147
|
+
};
|
|
148
|
+
private fail = (error: VoiceError): void => {
|
|
149
|
+
this.release();
|
|
150
|
+
if (this.snapshot.text) this.draft?.commit(this.snapshot.text);
|
|
151
|
+
else this.draft?.cancel();
|
|
152
|
+
this.draft = null;
|
|
153
|
+
this.update(this.settingsRequested ? { ...initialSnapshot(), phase: 'ready' } : { phase: 'error', error, interim: '' });
|
|
154
|
+
this.settingsRequested = false;
|
|
155
|
+
};
|
|
156
|
+
private setDeadline = (milliseconds: number): void => {
|
|
157
|
+
clearTimeout(this.deadline);
|
|
158
|
+
this.deadline = setTimeout(() => this.fail('timeout'), milliseconds);
|
|
159
|
+
};
|
|
160
|
+
private release = (abort = true): void => {
|
|
161
|
+
clearTimeout(this.deadline);
|
|
162
|
+
clearInterval(this.ticker);
|
|
163
|
+
const { recognition } = this;
|
|
164
|
+
this.recognition = null;
|
|
165
|
+
if (!recognition) return;
|
|
166
|
+
recognition.onstart = null;
|
|
167
|
+
recognition.onend = null;
|
|
168
|
+
recognition.onerror = null;
|
|
169
|
+
recognition.onresult = null;
|
|
170
|
+
if (abort) {
|
|
171
|
+
try { recognition.abort(); } catch { /* Already released by the browser. */ }
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -4,8 +4,10 @@ import { ChatQueryManager } from '@/features/chat/managers/chat-query.manager';
|
|
|
4
4
|
import { ChatThreadManager } from '@/features/chat/managers/chat-thread.manager';
|
|
5
5
|
import type { ChatComposerIntentManager } from '@/features/chat/managers/chat-composer-intent.manager';
|
|
6
6
|
import type { AppPresenter } from '@/app/presenters/app.presenter';
|
|
7
|
+
import { ChatVoiceInputManager } from '@/features/chat/managers/chat-voice-input.manager';
|
|
7
8
|
|
|
8
9
|
export class ChatPresenter {
|
|
10
|
+
readonly chatVoiceInputManager = new ChatVoiceInputManager();
|
|
9
11
|
readonly chatUiManager: ChatUiManager;
|
|
10
12
|
readonly chatSessionListManager: ChatSessionListManager;
|
|
11
13
|
readonly chatQueryManager: ChatQueryManager;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
import { createJSONStorage, persist } from 'zustand/middleware';
|
|
3
|
+
|
|
4
|
+
export type VoiceShortcut = { code: string; ctrl: boolean; meta: boolean; alt: boolean; shift: boolean };
|
|
5
|
+
export function isVoiceShortcut(value: unknown): value is VoiceShortcut {
|
|
6
|
+
if (!value || typeof value !== 'object') return false;
|
|
7
|
+
const key = value as VoiceShortcut;
|
|
8
|
+
const unmodified = key.ctrl === false && key.meta === false && key.alt === false && key.shift === false;
|
|
9
|
+
return typeof key.code === 'string' && (/^Key[A-Z]$/.test(key.code) || /^F(?:[6-9]|10)$/.test(key.code)) &&
|
|
10
|
+
typeof key.ctrl === 'boolean' && typeof key.meta === 'boolean' &&
|
|
11
|
+
typeof key.alt === 'boolean' && typeof key.shift === 'boolean' &&
|
|
12
|
+
(unmodified || (key.alt && key.ctrl !== key.meta));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ShortcutState = { shortcut: VoiceShortcut | null; setShortcut: (shortcut: VoiceShortcut | null) => void };
|
|
16
|
+
export const useChatVoiceShortcutStore = create<ShortcutState>()(persist(
|
|
17
|
+
(set) => ({ shortcut: null, setShortcut: (shortcut) => set({ shortcut }) }),
|
|
18
|
+
{
|
|
19
|
+
name: 'nextclaw.chat.voice-shortcut', version: 1,
|
|
20
|
+
storage: createJSONStorage(() => window.localStorage),
|
|
21
|
+
partialize: (state) => ({ shortcut: state.shortcut }),
|
|
22
|
+
merge: (persisted, current) => {
|
|
23
|
+
const value = (persisted as { shortcut?: unknown } | null)?.shortcut;
|
|
24
|
+
return { ...current, shortcut: isVoiceShortcut(value) ? value : null };
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
));
|
|
28
|
+
|
|
29
|
+
export function voiceShortcutLabel(shortcut: VoiceShortcut): string {
|
|
30
|
+
return [shortcut.ctrl && 'Ctrl', shortcut.meta && '⌘', shortcut.alt && 'Alt/⌥', shortcut.shift && 'Shift', shortcut.code.replace(/^Key/, '')].filter(Boolean).join(' + ');
|
|
31
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useViewportLayout } from '@/app/hooks/use-viewport-layout';
|
|
2
|
+
import { ChatVoiceShortcutControl } from '@/features/chat';
|
|
3
|
+
import { SettingsPage } from '@/shared/components/settings/settings-page';
|
|
4
|
+
import { SettingRow, SettingsGroup, SettingsSection } from '@/shared/components/settings/setting-row';
|
|
5
|
+
import { t } from '@/shared/lib/i18n';
|
|
6
|
+
|
|
7
|
+
export function KeyboardShortcutsSettingsPage() {
|
|
8
|
+
const { isMobile } = useViewportLayout();
|
|
9
|
+
return <SettingsPage title={t('keyboardShortcuts')}>
|
|
10
|
+
{isMobile ? <p className='text-sm text-muted-foreground'>{t('keyboardShortcutsDesktopOnly')}</p> :
|
|
11
|
+
<SettingsSection><SettingsGroup>
|
|
12
|
+
<SettingRow title={t('chatInputVoice')} layout='stacked'><ChatVoiceShortcutControl /></SettingRow>
|
|
13
|
+
</SettingsGroup></SettingsSection>}
|
|
14
|
+
</SettingsPage>;
|
|
15
|
+
}
|
|
@@ -2,11 +2,12 @@ import { NavLink } from "react-router-dom";
|
|
|
2
2
|
import { getSettingsNavItems } from "@/app/configs/app-navigation.config";
|
|
3
3
|
import { t } from "@/shared/lib/i18n";
|
|
4
4
|
import { cn } from "@/shared/lib/utils";
|
|
5
|
-
import { useDesktopCapabilityAvailability } from "@/features/desktop-capabilities
|
|
5
|
+
import { useDesktopCapabilityAvailability } from "@/features/desktop-capabilities";
|
|
6
6
|
|
|
7
7
|
export function MobileSettingsShell() {
|
|
8
8
|
const desktopCapabilityAvailable = useDesktopCapabilityAvailability();
|
|
9
9
|
const settingsNavItems = getSettingsNavItems(t, {
|
|
10
|
+
includeKeyboardShortcuts: false,
|
|
10
11
|
includeDesktopCapabilities: desktopCapabilityAvailable,
|
|
11
12
|
});
|
|
12
13
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { QueryClient } from '@tanstack/react-query';
|
|
1
|
+
import { QueryClient, type InfiniteData } from '@tanstack/react-query';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
3
|
import {
|
|
4
4
|
applyNcpSessionRealtimeEvent,
|
|
@@ -262,3 +262,111 @@ describe('ncp-session-query-cache', () => {
|
|
|
262
262
|
expect(queryClient.getQueryData<NcpSessionsListView>(['ncp-sessions', 200, 'peer-b'])?.sessions).toEqual([]);
|
|
263
263
|
});
|
|
264
264
|
});
|
|
265
|
+
|
|
266
|
+
describe('paginated ncp session realtime cache', () => {
|
|
267
|
+
it('updates existing paginated summaries without invalidating the session pages', () => {
|
|
268
|
+
const queryClient = new QueryClient();
|
|
269
|
+
const queryKey = ['ncp-session-pages', 1, null] as const;
|
|
270
|
+
const current = createSessionsList();
|
|
271
|
+
queryClient.setQueryData<InfiniteData<NcpSessionsListView>>(queryKey, {
|
|
272
|
+
pages: [
|
|
273
|
+
{ ...current, sessions: [current.sessions[0]!], page: 1, pageSize: 1, hasMore: true },
|
|
274
|
+
{ ...current, sessions: [current.sessions[1]!], page: 2, pageSize: 1, hasMore: false }
|
|
275
|
+
],
|
|
276
|
+
pageParams: [1, 2]
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
applyNcpSessionRealtimeEvent(queryClient, {
|
|
280
|
+
type: 'session.summary.upsert',
|
|
281
|
+
payload: {
|
|
282
|
+
summary: {
|
|
283
|
+
...current.sessions[1]!,
|
|
284
|
+
updatedAt: '2026-03-29T11:00:00.000Z',
|
|
285
|
+
lastMessageAt: '2026-03-29T11:00:00.000Z',
|
|
286
|
+
messageCount: 3
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const updated = queryClient.getQueryData<InfiniteData<NcpSessionsListView>>(queryKey);
|
|
292
|
+
expect(updated?.pages.map((page) => page.sessions[0]?.sessionId)).toEqual(['session-2', 'session-1']);
|
|
293
|
+
expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(false);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('invalidates paginated session queries when an upsert changes their membership', () => {
|
|
297
|
+
const queryClient = new QueryClient();
|
|
298
|
+
const queryKey = ['ncp-session-pages', 100, null] as const;
|
|
299
|
+
queryClient.setQueryData<InfiniteData<NcpSessionsListView>>(queryKey, {
|
|
300
|
+
pages: [{ ...createSessionsList(), page: 1, pageSize: 100, hasMore: false }],
|
|
301
|
+
pageParams: [1]
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
applyNcpSessionRealtimeEvent(queryClient, {
|
|
305
|
+
type: 'session.summary.upsert',
|
|
306
|
+
payload: {
|
|
307
|
+
summary: {
|
|
308
|
+
sessionId: 'session-3',
|
|
309
|
+
messageCount: 1,
|
|
310
|
+
updatedAt: '2026-03-29T12:00:00.000Z',
|
|
311
|
+
status: 'idle'
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(true);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('patches paginated run status without invalidating the session pages', () => {
|
|
320
|
+
const queryClient = new QueryClient();
|
|
321
|
+
const queryKey = ['ncp-session-pages', 100, null] as const;
|
|
322
|
+
queryClient.setQueryData<InfiniteData<NcpSessionsListView>>(queryKey, {
|
|
323
|
+
pages: [{ ...createSessionsList(), page: 1, pageSize: 100, hasMore: false }],
|
|
324
|
+
pageParams: [1]
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
applyNcpSessionRealtimeEvent(queryClient, {
|
|
328
|
+
type: 'session.run-status',
|
|
329
|
+
payload: { sessionKey: 'session-2', status: 'running' }
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const updated = queryClient.getQueryData<InfiniteData<NcpSessionsListView>>(queryKey);
|
|
333
|
+
expect(updated?.pages[0]?.sessions[1]).toMatchObject({
|
|
334
|
+
sessionId: 'session-2',
|
|
335
|
+
messageCount: 2,
|
|
336
|
+
status: 'running'
|
|
337
|
+
});
|
|
338
|
+
expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('invalidates filtered paginated queries when an upsert may change search membership', () => {
|
|
342
|
+
const queryClient = new QueryClient();
|
|
343
|
+
const queryKey = ['ncp-session-pages', 100, 'journal'] as const;
|
|
344
|
+
queryClient.setQueryData<InfiniteData<NcpSessionsListView>>(queryKey, {
|
|
345
|
+
pages: [{ ...createSessionsList(), page: 1, pageSize: 100, hasMore: false }],
|
|
346
|
+
pageParams: [1]
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
applyNcpSessionRealtimeEvent(queryClient, {
|
|
350
|
+
type: 'session.summary.upsert',
|
|
351
|
+
payload: { summary: { ...createSessionsList().sessions[0]!, metadata: { label: 'Journal' } } }
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('invalidates paginated queries after deletion changes their membership', () => {
|
|
358
|
+
const queryClient = new QueryClient();
|
|
359
|
+
const queryKey = ['ncp-session-pages', 100, null] as const;
|
|
360
|
+
queryClient.setQueryData<InfiniteData<NcpSessionsListView>>(queryKey, {
|
|
361
|
+
pages: [{ ...createSessionsList(), page: 1, pageSize: 100, hasMore: false }],
|
|
362
|
+
pageParams: [1]
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
applyNcpSessionRealtimeEvent(queryClient, {
|
|
366
|
+
type: 'session.summary.delete',
|
|
367
|
+
payload: { sessionKey: 'session-1' }
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(true);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QueryClient } from '@tanstack/react-query';
|
|
1
|
+
import type { InfiniteData, QueryClient } from '@tanstack/react-query';
|
|
2
2
|
import type { NcpSessionSummaryView, NcpSessionsListView, WsEvent } from '@/shared/lib/api';
|
|
3
3
|
|
|
4
4
|
function readSessionActivityAt(summary: NcpSessionSummaryView): string {
|
|
@@ -26,6 +26,73 @@ function queryKeyAcceptsSessionSummary(queryKey: readonly unknown[], summary: Nc
|
|
|
26
26
|
return !peerId || summary.peerId === peerId;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
function updateSessionPages(
|
|
30
|
+
current: InfiniteData<NcpSessionsListView> | undefined,
|
|
31
|
+
update: (sessions: NcpSessionsListView) => NcpSessionsListView | undefined
|
|
32
|
+
): InfiniteData<NcpSessionsListView> | undefined {
|
|
33
|
+
if (!current) return current;
|
|
34
|
+
const pageLengths = current.pages.map((page) => page.sessions.length);
|
|
35
|
+
const sessions = current.pages.flatMap((page) => page.sessions);
|
|
36
|
+
const updated = update({
|
|
37
|
+
sessions,
|
|
38
|
+
total: current.pages[0]?.total ?? sessions.length
|
|
39
|
+
});
|
|
40
|
+
if (!updated) return current;
|
|
41
|
+
let offset = 0;
|
|
42
|
+
return {
|
|
43
|
+
...current,
|
|
44
|
+
pages: current.pages.map((page, index) => {
|
|
45
|
+
const nextOffset = offset + (pageLengths[index] ?? 0);
|
|
46
|
+
const nextPage = {
|
|
47
|
+
...page,
|
|
48
|
+
sessions: updated.sessions.slice(offset, nextOffset)
|
|
49
|
+
};
|
|
50
|
+
offset = nextOffset;
|
|
51
|
+
return nextPage;
|
|
52
|
+
})
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function updateExistingNcpSessionSummaryPages(
|
|
57
|
+
queryClient: QueryClient | undefined,
|
|
58
|
+
summary: NcpSessionSummaryView
|
|
59
|
+
): void {
|
|
60
|
+
if (!queryClient) return;
|
|
61
|
+
const entries = queryClient.getQueriesData<InfiniteData<NcpSessionsListView>>({
|
|
62
|
+
queryKey: ['ncp-session-pages']
|
|
63
|
+
});
|
|
64
|
+
for (const [queryKey, current] of entries) {
|
|
65
|
+
const query = typeof queryKey[2] === 'string' ? queryKey[2].trim() : '';
|
|
66
|
+
const containsSession = current?.pages.some((page) =>
|
|
67
|
+
page.sessions.some((session) => session.sessionId === summary.sessionId)
|
|
68
|
+
);
|
|
69
|
+
if (query || !containsSession) {
|
|
70
|
+
void queryClient.invalidateQueries({ queryKey, exact: true });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
queryClient.setQueryData<InfiniteData<NcpSessionsListView>>(
|
|
74
|
+
queryKey,
|
|
75
|
+
(value) => updateSessionPages(
|
|
76
|
+
value,
|
|
77
|
+
(sessions) => upsertNcpSessionSummaryList(sessions, summary)
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function updateNcpSessionRunStatusPages(
|
|
84
|
+
queryClient: QueryClient | undefined,
|
|
85
|
+
payload: { sessionKey: string; status: 'running' | 'idle' }
|
|
86
|
+
): void {
|
|
87
|
+
queryClient?.setQueriesData<InfiniteData<NcpSessionsListView>>(
|
|
88
|
+
{ queryKey: ['ncp-session-pages'] },
|
|
89
|
+
(current) => updateSessionPages(
|
|
90
|
+
current,
|
|
91
|
+
(sessions) => updateNcpSessionRunStatusList(sessions, payload)
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
29
96
|
export function upsertNcpSessionSummaryList(
|
|
30
97
|
current: NcpSessionsListView | undefined,
|
|
31
98
|
summary: NcpSessionSummaryView
|
|
@@ -149,13 +216,14 @@ export function applyNcpSessionRealtimeEvent(
|
|
|
149
216
|
queryClient: QueryClient | undefined,
|
|
150
217
|
event: Extract<WsEvent, { type: 'session.run-status' | 'session.summary.upsert' | 'session.summary.delete' }>
|
|
151
218
|
): void {
|
|
152
|
-
void queryClient?.invalidateQueries({ queryKey: ['ncp-session-pages'] });
|
|
153
219
|
if (event.type === 'session.run-status') {
|
|
154
220
|
updateNcpSessionRunStatusInQueryClient(queryClient, event.payload);
|
|
221
|
+
updateNcpSessionRunStatusPages(queryClient, event.payload);
|
|
155
222
|
return;
|
|
156
223
|
}
|
|
157
224
|
if (event.type === 'session.summary.upsert') {
|
|
158
225
|
upsertNcpSessionSummaryInQueryClient(queryClient, event.payload.summary);
|
|
226
|
+
updateExistingNcpSessionSummaryPages(queryClient, event.payload.summary);
|
|
159
227
|
void queryClient?.invalidateQueries({
|
|
160
228
|
queryKey: ['ncp-session-token-usage', event.payload.summary.sessionId],
|
|
161
229
|
exact: true,
|
|
@@ -164,4 +232,5 @@ export function applyNcpSessionRealtimeEvent(
|
|
|
164
232
|
}
|
|
165
233
|
|
|
166
234
|
deleteNcpSessionSummaryInQueryClient(queryClient, event.payload.sessionKey);
|
|
235
|
+
void queryClient?.invalidateQueries({ queryKey: ['ncp-session-pages'] });
|
|
167
236
|
}
|