@nextclaw/ui 0.20.3 → 0.21.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 +23 -0
- package/dist/assets/{api-iMkU_o5P.js → api-DNLQPHfQ.js} +5 -5
- package/dist/assets/{appearance-settings-page-CU5k4b-M.js → appearance-settings-page-BlAFMOGV.js} +1 -1
- package/dist/assets/{book-open-nD8gdMhA.js → book-open-BryfPTTa.js} +1 -1
- package/dist/assets/{channels-list-page-Drlt62kY.js → channels-list-page-Cfbgubyv.js} +2 -2
- package/dist/assets/{config-split-page-BtJZp1E5.js → config-split-page-Dd8xG0Jk.js} +1 -1
- package/dist/assets/{confirm-dialog-k2-Jq_x3.js → confirm-dialog-CZIWjnJ6.js} +2 -2
- package/dist/assets/{createLucideIcon-DxlwKLr-.js → createLucideIcon-BxwVe8ZD.js} +1 -1
- package/dist/assets/desktop-capabilities-page-C6YxsHeV.js +1 -0
- package/dist/assets/desktop-update-config-BVN4QPJn.js +1 -0
- package/dist/assets/{dist-m08DUswB.js → dist-BRloKr3S.js} +1 -1
- package/dist/assets/{dist-Cy_e4kNb.js → dist-Cd8Nm3BW.js} +1 -1
- package/dist/assets/{doc-browser-npOvWtgf.js → doc-browser-Bzgqmmiu.js} +1 -1
- package/dist/assets/doc-browser-D0qI0Z_R.js +1 -0
- package/dist/assets/{doc-browser-context-jiy-O4F8.js → doc-browser-context-3JGufiMA.js} +1 -1
- package/dist/assets/{extensions-CIb17__o.js → extensions-Dedd2oDI.js} +1 -1
- package/dist/assets/{form-actions-DGf5zUg_.js → form-actions-FWCEof3V.js} +1 -1
- package/dist/assets/index-CXkHcESm.css +1 -0
- package/dist/assets/index-D25tRg1p.js +109 -0
- package/dist/assets/loader-circle-BoyCoFps.js +1 -0
- package/dist/assets/mcp-marketplace-page-Cn-9CDh3.js +1 -0
- package/dist/assets/mcp-marketplace-page-e9Rmfz-d.js +10 -0
- package/dist/assets/model-config-page-C173Ssil.js +1 -0
- package/dist/assets/{navigation-link-DAfwFCj4.js → navigation-link-DTPOvGGL.js} +1 -1
- package/dist/assets/plus-D1R4Vipq.js +1 -0
- package/dist/assets/privacy-settings-page-CRserBVF.js +1 -0
- package/dist/assets/providers-config-page-C8jUtnZb.js +1 -0
- package/dist/assets/react-BAzO6LZS.js +8 -0
- package/dist/assets/{refresh-cw-D_Eibznw.js → refresh-cw-BCj-yjde.js} +1 -1
- package/dist/assets/remote-6BHWP5zi.js +1 -0
- package/dist/assets/{rotate-cw-QQjmoWuq.js → rotate-cw-BPnW4qUl.js} +1 -1
- package/dist/assets/runtime-config-page-aeQ1Dryz.js +1 -0
- package/dist/assets/{save-W2xthJJ2.js → save-KT0MumfJ.js} +1 -1
- package/dist/assets/{search-Vih1R2BD.js → search-Cv1Hx_nx.js} +1 -1
- package/dist/assets/{search-config-page-CtBcL2uc.js → search-config-page-b283V-fo.js} +1 -1
- package/dist/assets/{secrets-config-page-Q6XfIj0h.js → secrets-config-page-ss7x3BOo.js} +2 -2
- package/dist/assets/security-config-HHz62oxH.js +1 -0
- package/dist/assets/{select-lNdozU4o.js → select-BVgD4EpG.js} +1 -1
- package/dist/assets/skeleton-ChUmcyys.js +1 -0
- package/dist/assets/{tag-chip-gdaU9bE7.js → tag-chip-DZ4ircUB.js} +1 -1
- package/dist/assets/x-BWDRfVWL.js +1 -0
- package/dist/index.html +21 -21
- package/package.json +7 -7
- package/src/app/components/layout/sidebar.tsx +5 -1
- package/src/app/configs/app-navigation.config.ts +17 -2
- package/src/app/hooks/use-feature-controls.ts +11 -0
- package/src/app/index.tsx +10 -0
- package/src/features/chat/components/layout/chat-sidebar.tsx +1 -0
- package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +12 -0
- package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +3 -0
- package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +14 -1
- package/src/features/chat/features/session/utils/chat-session-deletion.utils.ts +25 -0
- package/src/features/chat/managers/__tests__/chat-thread-deletion.manager.test.ts +125 -0
- package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +0 -37
- package/src/features/chat/managers/chat-thread.manager.ts +17 -28
- package/src/features/desktop-capabilities/components/desktop-authorization-dialog.test.tsx +58 -0
- package/src/features/desktop-capabilities/components/desktop-authorization-dialog.tsx +161 -0
- package/src/features/desktop-capabilities/hooks/use-desktop-capabilities.test.ts +10 -0
- package/src/features/desktop-capabilities/hooks/use-desktop-capabilities.ts +55 -0
- package/src/features/desktop-capabilities/index.ts +1 -0
- package/src/features/desktop-capabilities/managers/desktop-capability.manager.ts +25 -0
- package/src/features/desktop-capabilities/pages/desktop-capabilities-page.test.tsx +44 -0
- package/src/features/desktop-capabilities/pages/desktop-capabilities-page.tsx +171 -0
- package/src/features/desktop-capabilities/stores/desktop-authorization.store.ts +21 -0
- package/src/features/marketplace/components/mcp/__tests__/mcp-connection-dialog.test.tsx +23 -0
- package/src/features/marketplace/components/mcp/mcp-connection-dialog.tsx +35 -0
- package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +20 -2
- package/src/platforms/mobile/components/mobile-settings-shell.tsx +5 -1
- package/src/shared/components/ui/__tests__/confirm-dialog.test.tsx +31 -0
- package/src/shared/components/ui/confirm-dialog.tsx +11 -1
- package/src/shared/lib/api/index.ts +1 -0
- package/src/shared/lib/api/utils/mcp-connection.utils.ts +10 -0
- package/src/shared/lib/i18n/locales/en-US/chat.json +2 -0
- package/src/shared/lib/i18n/locales/en-US/core.json +43 -0
- package/src/shared/lib/i18n/locales/en-US/marketplace.json +29 -1
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +2 -0
- package/src/shared/lib/i18n/locales/zh-CN/core.json +43 -0
- package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +29 -1
- package/dist/assets/desktop-update-config-BiZmy7FC.js +0 -1
- package/dist/assets/doc-browser-CPsPHh12.js +0 -1
- package/dist/assets/index-C86hxgv5.css +0 -1
- package/dist/assets/index-CYBXR7zN.js +0 -109
- package/dist/assets/loader-circle-BlLuRFVW.js +0 -1
- package/dist/assets/mcp-marketplace-page-B12JNh7Q.js +0 -1
- package/dist/assets/mcp-marketplace-page-kLWW6kzt.js +0 -9
- package/dist/assets/model-config-page-DSeAjVT_.js +0 -1
- package/dist/assets/plus-D4LtWtTk.js +0 -1
- package/dist/assets/privacy-settings-page-tN0Mt15x.js +0 -1
- package/dist/assets/providers-config-page-CgPrXR1u.js +0 -1
- package/dist/assets/react-8iBFECZE.js +0 -8
- package/dist/assets/remote-Derr_AQp.js +0 -1
- package/dist/assets/runtime-config-page-Crq96Jjq.js +0 -1
- package/dist/assets/security-config-CE9rkQvq.js +0 -1
- package/dist/assets/skeleton-B__aTcly.js +0 -1
- package/dist/assets/x-CWdWBu_U.js +0 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { appQueryClient } from "@/app-query-client";
|
|
3
|
+
import { ChatThreadManager } from "@/features/chat/managers/chat-thread.manager";
|
|
4
|
+
import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
|
|
5
|
+
import { useChatThreadStore } from "@/features/chat/stores/chat-thread.store";
|
|
6
|
+
import type * as SharedApi from "@/shared/lib/api";
|
|
7
|
+
|
|
8
|
+
const { deleteNcpSessionMock, deleteSummaryMock, toast } = vi.hoisted(() => ({
|
|
9
|
+
deleteNcpSessionMock: vi.fn(),
|
|
10
|
+
deleteSummaryMock: vi.fn(),
|
|
11
|
+
toast: { success: vi.fn(), error: vi.fn() },
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("@/shared/lib/api", async (importOriginal) => {
|
|
15
|
+
const actual = await importOriginal<typeof SharedApi>();
|
|
16
|
+
return {
|
|
17
|
+
...actual,
|
|
18
|
+
deleteNcpSession: deleteNcpSessionMock,
|
|
19
|
+
deleteNcpSessionSummaryInQueryClient: deleteSummaryMock,
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
vi.mock("sonner", () => ({ toast }));
|
|
24
|
+
|
|
25
|
+
function createUiManager() {
|
|
26
|
+
return {
|
|
27
|
+
confirm: vi.fn(async () => true),
|
|
28
|
+
goToChatRoot: vi.fn(),
|
|
29
|
+
} as unknown as ConstructorParameters<typeof ChatThreadManager>[0];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
deleteNcpSessionMock.mockReset();
|
|
34
|
+
deleteNcpSessionMock.mockResolvedValue({
|
|
35
|
+
deleted: true,
|
|
36
|
+
sessionId: "parent-session-1",
|
|
37
|
+
});
|
|
38
|
+
deleteSummaryMock.mockReset();
|
|
39
|
+
toast.success.mockReset();
|
|
40
|
+
toast.error.mockReset();
|
|
41
|
+
useChatSessionListStore.setState({
|
|
42
|
+
snapshot: {
|
|
43
|
+
...useChatSessionListStore.getInitialState().snapshot,
|
|
44
|
+
selectedSessionKey: "parent-session-1",
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
useChatThreadStore.setState({
|
|
48
|
+
snapshot: {
|
|
49
|
+
...useChatThreadStore.getInitialState().snapshot,
|
|
50
|
+
sessionKey: "parent-session-1",
|
|
51
|
+
canDeleteSession: true,
|
|
52
|
+
childSessionTabs: [
|
|
53
|
+
{
|
|
54
|
+
sessionKey: "child-session-1",
|
|
55
|
+
parentSessionKey: "parent-session-1",
|
|
56
|
+
label: "Child Session 1",
|
|
57
|
+
agentId: "reviewer",
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("ChatThreadManager deletion", () => {
|
|
65
|
+
it("clears the current thread and confirms the deletion", async () => {
|
|
66
|
+
const uiManager = createUiManager();
|
|
67
|
+
const removeQueries = vi
|
|
68
|
+
.spyOn(appQueryClient, "removeQueries")
|
|
69
|
+
.mockImplementation(async () => undefined);
|
|
70
|
+
const manager = new ChatThreadManager(
|
|
71
|
+
uiManager,
|
|
72
|
+
{} as ConstructorParameters<typeof ChatThreadManager>[1],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
await manager.deleteSession();
|
|
76
|
+
|
|
77
|
+
expect(useChatThreadStore.getState().snapshot.sessionKey).toBeNull();
|
|
78
|
+
expect(deleteSummaryMock).toHaveBeenCalledWith(
|
|
79
|
+
appQueryClient,
|
|
80
|
+
"parent-session-1",
|
|
81
|
+
);
|
|
82
|
+
expect(removeQueries).toHaveBeenCalledWith({
|
|
83
|
+
queryKey: ["ncp-session-messages", "parent-session-1"],
|
|
84
|
+
});
|
|
85
|
+
expect(uiManager.goToChatRoot).toHaveBeenCalledWith({ replace: true });
|
|
86
|
+
expect(toast.success).toHaveBeenCalledWith("Session deleted.");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("preserves the current thread when deleting another session", async () => {
|
|
90
|
+
const uiManager = createUiManager();
|
|
91
|
+
const manager = new ChatThreadManager(
|
|
92
|
+
uiManager,
|
|
93
|
+
{} as ConstructorParameters<typeof ChatThreadManager>[1],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await manager.deleteSession("other-session-1");
|
|
97
|
+
|
|
98
|
+
expect(deleteNcpSessionMock).toHaveBeenCalledWith("other-session-1");
|
|
99
|
+
expect(useChatThreadStore.getState().snapshot.sessionKey).toBe(
|
|
100
|
+
"parent-session-1",
|
|
101
|
+
);
|
|
102
|
+
expect(uiManager.goToChatRoot).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("keeps the session visible and reports a deletion error", async () => {
|
|
106
|
+
deleteNcpSessionMock.mockRejectedValueOnce(
|
|
107
|
+
new Error("network unavailable"),
|
|
108
|
+
);
|
|
109
|
+
const uiManager = createUiManager();
|
|
110
|
+
const manager = new ChatThreadManager(
|
|
111
|
+
uiManager,
|
|
112
|
+
{} as ConstructorParameters<typeof ChatThreadManager>[1],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
await manager.deleteSession();
|
|
116
|
+
|
|
117
|
+
expect(useChatThreadStore.getState().snapshot.sessionKey).toBe(
|
|
118
|
+
"parent-session-1",
|
|
119
|
+
);
|
|
120
|
+
expect(uiManager.goToChatRoot).not.toHaveBeenCalled();
|
|
121
|
+
expect(toast.error).toHaveBeenCalledWith(
|
|
122
|
+
"Could not delete session: network unavailable",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { appQueryClient } from '@/app-query-client';
|
|
3
2
|
import { ChatThreadManager } from '@/features/chat/managers/chat-thread.manager';
|
|
4
3
|
import { useChatSessionListStore } from '@/features/chat/stores/chat-session-list.store';
|
|
5
4
|
import { useChatThreadStore } from '@/features/chat/stores/chat-thread.store';
|
|
@@ -841,39 +840,3 @@ describe('ChatThreadManager visible workspace selection', () => {
|
|
|
841
840
|
expect(sessionListManager.markVisibleWorkspaceChildRead).toHaveBeenCalledWith(tab);
|
|
842
841
|
});
|
|
843
842
|
});
|
|
844
|
-
|
|
845
|
-
describe('ChatThreadManager deletion', () => {
|
|
846
|
-
it('clears thread state while leaving route-derived selection to navigation', async () => {
|
|
847
|
-
const removeQueries = vi.spyOn(appQueryClient, 'removeQueries').mockImplementation(async () => undefined);
|
|
848
|
-
const uiManager = {
|
|
849
|
-
goToSession: vi.fn(),
|
|
850
|
-
goToChatRoot: vi.fn(),
|
|
851
|
-
goToProviders: vi.fn(),
|
|
852
|
-
confirm: vi.fn(async () => true),
|
|
853
|
-
} as unknown as ConstructorParameters<typeof ChatThreadManager>[0];
|
|
854
|
-
const sessionListManager = {} as ConstructorParameters<typeof ChatThreadManager>[1];
|
|
855
|
-
const manager = new ChatThreadManager(uiManager, sessionListManager);
|
|
856
|
-
|
|
857
|
-
await manager.deleteSession();
|
|
858
|
-
|
|
859
|
-
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('parent-session-1');
|
|
860
|
-
expect(useChatThreadStore.getState().snapshot).toMatchObject({
|
|
861
|
-
sessionKey: null,
|
|
862
|
-
canDeleteSession: false,
|
|
863
|
-
workspacePanelParentKey: null,
|
|
864
|
-
childSessionTabs: [],
|
|
865
|
-
activeChildSessionKey: null,
|
|
866
|
-
workspaceFileTabs: [],
|
|
867
|
-
activeWorkspaceFileKey: null,
|
|
868
|
-
workspaceNavigationHistory: [],
|
|
869
|
-
workspaceNavigationHistoryIndex: 0,
|
|
870
|
-
});
|
|
871
|
-
expect(deleteSummaryMock).toHaveBeenCalledWith(appQueryClient, 'parent-session-1');
|
|
872
|
-
expect(removeQueries).toHaveBeenCalledWith({
|
|
873
|
-
queryKey: ['ncp-session-messages', 'parent-session-1'],
|
|
874
|
-
});
|
|
875
|
-
expect(uiManager.goToChatRoot).toHaveBeenCalledWith({ replace: true });
|
|
876
|
-
|
|
877
|
-
removeQueries.mockRestore();
|
|
878
|
-
});
|
|
879
|
-
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { appQueryClient } from '@/app-query-client';
|
|
2
2
|
import { deleteNcpSession as deleteNcpSessionApi, deleteNcpSessionSummaryInQueryClient } from '@/shared/lib/api';
|
|
3
|
+
import { toast } from 'sonner';
|
|
3
4
|
import type {
|
|
4
5
|
ChatFileOpenActionViewModel,
|
|
5
6
|
ChatUiShowContentRequest,
|
|
@@ -17,6 +18,7 @@ import type {
|
|
|
17
18
|
ChatWorkspaceFileTab,
|
|
18
19
|
} from '@/features/chat/stores/chat-thread.store';
|
|
19
20
|
import { useChatThreadStore } from '@/features/chat/stores/chat-thread.store';
|
|
21
|
+
import { createDeletedChatThreadStatePatch } from '@/features/chat/features/session/utils/chat-session-deletion.utils';
|
|
20
22
|
import {
|
|
21
23
|
normalizeChatWorkspaceExplorerWidth,
|
|
22
24
|
normalizeChatWorkspacePanelWidth,
|
|
@@ -80,27 +82,7 @@ export class ChatThreadManager {
|
|
|
80
82
|
};
|
|
81
83
|
|
|
82
84
|
private clearDeletedSessionState = () => {
|
|
83
|
-
useChatThreadStore.getState().setSnapshot(
|
|
84
|
-
sessionKey: null,
|
|
85
|
-
sessionTypeLabel: null,
|
|
86
|
-
agentId: null,
|
|
87
|
-
sessionDisplayName: undefined,
|
|
88
|
-
draftProjectRoot: null,
|
|
89
|
-
sessionProjectName: null,
|
|
90
|
-
canDeleteSession: false,
|
|
91
|
-
parentSessionKey: null,
|
|
92
|
-
parentSessionLabel: null,
|
|
93
|
-
workspacePanelParentKey: null,
|
|
94
|
-
activeWorkspacePanelKind: null,
|
|
95
|
-
childSessionTabs: [],
|
|
96
|
-
activeChildSessionKey: null,
|
|
97
|
-
activeSideChatDraft: null,
|
|
98
|
-
workspaceFileTabs: [],
|
|
99
|
-
activeWorkspaceFileKey: null,
|
|
100
|
-
closedWorkspaceTabEntries: [],
|
|
101
|
-
workspaceNavigationHistory: [],
|
|
102
|
-
workspaceNavigationHistoryIndex: 0,
|
|
103
|
-
});
|
|
85
|
+
useChatThreadStore.getState().setSnapshot(createDeletedChatThreadStatePatch());
|
|
104
86
|
};
|
|
105
87
|
|
|
106
88
|
private resolveWorkspaceParentSessionKey = (): string | null => {
|
|
@@ -565,11 +547,12 @@ export class ChatThreadManager {
|
|
|
565
547
|
this.uiManager.goToSession(resolvedParentSessionKey);
|
|
566
548
|
};
|
|
567
549
|
|
|
568
|
-
deleteSession = async () => {
|
|
550
|
+
deleteSession = async (rawSessionKey?: string) => {
|
|
569
551
|
const {
|
|
570
552
|
snapshot: { selectedSessionKey },
|
|
571
553
|
} = useChatSessionListStore.getState();
|
|
572
|
-
|
|
554
|
+
const sessionKey = rawSessionKey?.trim() || selectedSessionKey;
|
|
555
|
+
if (!sessionKey) {
|
|
573
556
|
return;
|
|
574
557
|
}
|
|
575
558
|
const confirmed = await this.uiManager.confirm({
|
|
@@ -582,13 +565,19 @@ export class ChatThreadManager {
|
|
|
582
565
|
}
|
|
583
566
|
useChatThreadStore.getState().setSnapshot({ isDeletePending: true });
|
|
584
567
|
try {
|
|
585
|
-
await deleteNcpSessionApi(
|
|
586
|
-
deleteNcpSessionSummaryInQueryClient(appQueryClient,
|
|
568
|
+
await deleteNcpSessionApi(sessionKey);
|
|
569
|
+
deleteNcpSessionSummaryInQueryClient(appQueryClient, sessionKey);
|
|
587
570
|
appQueryClient.removeQueries({
|
|
588
|
-
queryKey: ['ncp-session-messages',
|
|
571
|
+
queryKey: ['ncp-session-messages', sessionKey],
|
|
589
572
|
});
|
|
590
|
-
|
|
591
|
-
|
|
573
|
+
toast.success(t('chatDeleteSessionSucceeded'));
|
|
574
|
+
if (sessionKey === selectedSessionKey) {
|
|
575
|
+
this.clearDeletedSessionState();
|
|
576
|
+
this.uiManager.goToChatRoot({ replace: true });
|
|
577
|
+
}
|
|
578
|
+
} catch (error) {
|
|
579
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
580
|
+
toast.error(`${t('chatDeleteSessionFailed')}: ${message}`);
|
|
592
581
|
} finally {
|
|
593
582
|
useChatThreadStore.getState().setSnapshot({ isDeletePending: false });
|
|
594
583
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { DesktopAuthorizationDialog } from "./desktop-authorization-dialog";
|
|
4
|
+
import { useDesktopAuthorizationStore } from "../stores/desktop-authorization.store";
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({ grantAccess: vi.fn() }));
|
|
7
|
+
|
|
8
|
+
vi.mock("../managers/desktop-capability.manager", () => ({
|
|
9
|
+
desktopCapabilityManager: {
|
|
10
|
+
grantAccess: mocks.grantAccess,
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
describe("DesktopAuthorizationDialog", () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
mocks.grantAccess.mockReset();
|
|
17
|
+
mocks.grantAccess.mockResolvedValue({});
|
|
18
|
+
useDesktopAuthorizationStore.getState().clear();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("shows the trusted subject and grants only after explicit approval", async () => {
|
|
22
|
+
useDesktopAuthorizationStore.getState().present({
|
|
23
|
+
applicationId: "wechat",
|
|
24
|
+
request: {
|
|
25
|
+
subject: { type: "agent", id: "main" },
|
|
26
|
+
resource: {
|
|
27
|
+
type: "desktop.application",
|
|
28
|
+
target: { applicationId: "wechat", platform: "darwin", bundleId: "com.tencent.xinWeChat" },
|
|
29
|
+
},
|
|
30
|
+
access: ["ui.write"],
|
|
31
|
+
declarationFingerprint: "fingerprint",
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
render(<DesktopAuthorizationDialog />);
|
|
36
|
+
expect(screen.getByText("AI · main")).toBeTruthy();
|
|
37
|
+
expect(screen.getByText("wechat")).toBeTruthy();
|
|
38
|
+
expect(mocks.grantAccess).not.toHaveBeenCalled();
|
|
39
|
+
|
|
40
|
+
fireEvent.click(screen.getByRole("button", { name: "Allow" }));
|
|
41
|
+
await waitFor(() => expect(mocks.grantAccess).toHaveBeenCalledOnce());
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("explains the bounded pointer permission in user terms", () => {
|
|
45
|
+
useDesktopAuthorizationStore.getState().present({
|
|
46
|
+
applicationId: "wechat",
|
|
47
|
+
request: {
|
|
48
|
+
subject: { type: "agent", id: "main" },
|
|
49
|
+
resource: { type: "desktop.application", target: { applicationId: "wechat", platform: "darwin", bundleId: "com.tencent.xinWeChat" } },
|
|
50
|
+
access: ["input.pointer"],
|
|
51
|
+
declarationFingerprint: "fingerprint",
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
render(<DesktopAuthorizationDialog />);
|
|
55
|
+
expect(screen.getByText("Click inside the app window")).toBeTruthy();
|
|
56
|
+
expect(screen.getByText(/cannot use keys, scroll, drag, send, or confirm/i)).toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { ShieldAlert } from "lucide-react";
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from "@/shared/components/ui/dialog";
|
|
12
|
+
import { Button } from "@/shared/components/ui/button";
|
|
13
|
+
import { appQueryClient } from "@/app-query-client";
|
|
14
|
+
import { nextclawClient } from "@/shared/lib/api/managers/client.manager";
|
|
15
|
+
import { t } from "@/shared/lib/i18n";
|
|
16
|
+
import { desktopCapabilityManager } from "../managers/desktop-capability.manager";
|
|
17
|
+
import {
|
|
18
|
+
type DesktopAuthorizationRequest,
|
|
19
|
+
useDesktopAuthorizationStore,
|
|
20
|
+
} from "../stores/desktop-authorization.store";
|
|
21
|
+
|
|
22
|
+
const AUTHORIZATION_EVENT_TYPE = "desktop.authorization.required";
|
|
23
|
+
|
|
24
|
+
export function DesktopAuthorizationDialog() {
|
|
25
|
+
const pending = useDesktopAuthorizationStore((state) => state.pending);
|
|
26
|
+
const present = useDesktopAuthorizationStore((state) => state.present);
|
|
27
|
+
const clear = useDesktopAuthorizationStore((state) => state.clear);
|
|
28
|
+
const [isGranting, setIsGranting] = useState(false);
|
|
29
|
+
|
|
30
|
+
useEffect(() => nextclawClient.eventBus.subscribeAll((event) => {
|
|
31
|
+
if (event.type !== AUTHORIZATION_EVENT_TYPE) return;
|
|
32
|
+
const request = readAuthorizationRequest(event.payload);
|
|
33
|
+
if (request) present(request);
|
|
34
|
+
}), [present]);
|
|
35
|
+
|
|
36
|
+
const allow = async () => {
|
|
37
|
+
if (!pending || isGranting) return;
|
|
38
|
+
setIsGranting(true);
|
|
39
|
+
try {
|
|
40
|
+
await desktopCapabilityManager.grantAccess(pending.request);
|
|
41
|
+
await appQueryClient.invalidateQueries({
|
|
42
|
+
queryKey: ["desktop-capability", "grants"],
|
|
43
|
+
});
|
|
44
|
+
clear();
|
|
45
|
+
toast.success(t("desktopAuthorizationAllowed"));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
toast.error(
|
|
48
|
+
error instanceof Error
|
|
49
|
+
? error.message
|
|
50
|
+
: t("desktopAuthorizationFailed"),
|
|
51
|
+
);
|
|
52
|
+
} finally {
|
|
53
|
+
setIsGranting(false);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Dialog
|
|
59
|
+
open={Boolean(pending)}
|
|
60
|
+
onOpenChange={(open) => {
|
|
61
|
+
if (!open && !isGranting) clear();
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<DialogContent className="[&>:last-child]:hidden">
|
|
65
|
+
<DialogHeader>
|
|
66
|
+
<DialogTitle className="flex items-center gap-2">
|
|
67
|
+
<ShieldAlert className="h-4 w-4 text-amber-600" />
|
|
68
|
+
{t("desktopAuthorizationTitle")}
|
|
69
|
+
</DialogTitle>
|
|
70
|
+
<DialogDescription>
|
|
71
|
+
{t("desktopAuthorizationDescription")}
|
|
72
|
+
</DialogDescription>
|
|
73
|
+
</DialogHeader>
|
|
74
|
+
{pending ? (
|
|
75
|
+
<div className="space-y-3 rounded-xl bg-muted/55 p-4 text-sm">
|
|
76
|
+
<AuthorizationField
|
|
77
|
+
label={t("desktopAuthorizationCaller")}
|
|
78
|
+
value={formatSubject(pending)}
|
|
79
|
+
/>
|
|
80
|
+
<AuthorizationField
|
|
81
|
+
label={t("desktopAuthorizationApplication")}
|
|
82
|
+
value={pending.applicationId}
|
|
83
|
+
/>
|
|
84
|
+
<AuthorizationField
|
|
85
|
+
label={t("desktopAuthorizationAccess")}
|
|
86
|
+
value={pending.request.access.map(formatAccess).join(", ")}
|
|
87
|
+
/>
|
|
88
|
+
<p className="text-xs leading-5 text-muted-foreground">
|
|
89
|
+
{formatRisk(pending.request.access)}
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
) : null}
|
|
93
|
+
<DialogFooter className="gap-2 sm:gap-0">
|
|
94
|
+
<Button
|
|
95
|
+
type="button"
|
|
96
|
+
variant="outline"
|
|
97
|
+
disabled={isGranting}
|
|
98
|
+
onClick={clear}
|
|
99
|
+
>
|
|
100
|
+
{t("desktopAuthorizationReject")}
|
|
101
|
+
</Button>
|
|
102
|
+
<Button type="button" disabled={isGranting} onClick={() => void allow()}>
|
|
103
|
+
{isGranting ? t("desktopAuthorizationAllowing") : t("desktopAuthorizationAllow")}
|
|
104
|
+
</Button>
|
|
105
|
+
</DialogFooter>
|
|
106
|
+
</DialogContent>
|
|
107
|
+
</Dialog>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function AuthorizationField({ label, value }: { label: string; value: string }) {
|
|
112
|
+
return (
|
|
113
|
+
<div className="grid grid-cols-[5rem_minmax(0,1fr)] gap-3">
|
|
114
|
+
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
|
115
|
+
<div className="min-w-0 break-words text-xs text-foreground">{value}</div>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readAuthorizationRequest(value: unknown): DesktopAuthorizationRequest | null {
|
|
121
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
122
|
+
const payload = value as Record<string, unknown>;
|
|
123
|
+
const { request } = payload;
|
|
124
|
+
if (!request || typeof request !== "object" || Array.isArray(request)) return null;
|
|
125
|
+
const candidate = request as Record<string, unknown>;
|
|
126
|
+
if (
|
|
127
|
+
typeof payload.applicationId !== "string" ||
|
|
128
|
+
!candidate.subject ||
|
|
129
|
+
!candidate.resource ||
|
|
130
|
+
!Array.isArray(candidate.access) ||
|
|
131
|
+
typeof candidate.declarationFingerprint !== "string"
|
|
132
|
+
) return null;
|
|
133
|
+
return {
|
|
134
|
+
applicationId: payload.applicationId,
|
|
135
|
+
request: candidate as DesktopAuthorizationRequest["request"],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatSubject(pending: DesktopAuthorizationRequest): string {
|
|
140
|
+
const { subject } = pending.request;
|
|
141
|
+
const label = subject.type === "agent"
|
|
142
|
+
? t("desktopCapabilitiesAgent")
|
|
143
|
+
: t("desktopCapabilitiesExtension");
|
|
144
|
+
return `${label} · ${subject.id}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function formatAccess(access: string): string {
|
|
148
|
+
if (access === "ui.read") return t("desktopAuthorizationRead");
|
|
149
|
+
if (access === "ui.observe") return t("desktopAuthorizationObserve");
|
|
150
|
+
if (access === "ui.write") return t("desktopAuthorizationWrite");
|
|
151
|
+
if (access === "screen.capture-window") return t("desktopAuthorizationScreenCapture");
|
|
152
|
+
if (access === "input.pointer") return t("desktopAuthorizationPointerInput");
|
|
153
|
+
return access;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function formatRisk(access: string[]): string {
|
|
157
|
+
if (access.includes("ui.write")) return t("desktopAuthorizationWriteRisk");
|
|
158
|
+
if (access.includes("screen.capture-window")) return t("desktopAuthorizationScreenCaptureRisk");
|
|
159
|
+
if (access.includes("input.pointer")) return t("desktopAuthorizationPointerInputRisk");
|
|
160
|
+
return t("desktopAuthorizationReadRisk");
|
|
161
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isDesktopAutomationAvailable } from "./use-desktop-capabilities";
|
|
3
|
+
|
|
4
|
+
describe("isDesktopAutomationAvailable", () => {
|
|
5
|
+
it("uses only the backend product feature-control value", () => {
|
|
6
|
+
expect(isDesktopAutomationAvailable({ desktopAutomation: { available: true } })).toBe(true);
|
|
7
|
+
expect(isDesktopAutomationAvailable({ desktopAutomation: { available: false } })).toBe(false);
|
|
8
|
+
expect(isDesktopAutomationAvailable(undefined)).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { CapabilityGrantView, ProductFeatureControlsView } from "@nextclaw/client-sdk";
|
|
3
|
+
import { useFeatureControls } from "@/app/hooks/use-feature-controls";
|
|
4
|
+
import { desktopCapabilityManager } from "../managers/desktop-capability.manager";
|
|
5
|
+
|
|
6
|
+
const STATUS_QUERY_KEY = ["desktop-capability", "status"] as const;
|
|
7
|
+
const GRANTS_QUERY_KEY = ["desktop-capability", "grants"] as const;
|
|
8
|
+
|
|
9
|
+
export function useDesktopCapabilityStatus() {
|
|
10
|
+
return useQuery({
|
|
11
|
+
queryKey: STATUS_QUERY_KEY,
|
|
12
|
+
queryFn: desktopCapabilityManager.getStatus,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useDesktopCapabilityAvailability(): boolean {
|
|
17
|
+
const featureControls = useFeatureControls();
|
|
18
|
+
return isDesktopAutomationAvailable(featureControls.data);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isDesktopAutomationAvailable(controls: ProductFeatureControlsView | undefined): boolean {
|
|
22
|
+
return controls?.desktopAutomation.available === true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useDesktopCapabilityGrants() {
|
|
26
|
+
return useQuery({
|
|
27
|
+
queryKey: GRANTS_QUERY_KEY,
|
|
28
|
+
queryFn: desktopCapabilityManager.listGrants,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useRequestDesktopSystemPermission() {
|
|
33
|
+
const queryClient = useQueryClient();
|
|
34
|
+
return useMutation({
|
|
35
|
+
mutationFn: desktopCapabilityManager.requestSystemPermission,
|
|
36
|
+
onSuccess: async () => {
|
|
37
|
+
await queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY });
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useOpenDesktopSystemSettings() {
|
|
43
|
+
return useMutation({ mutationFn: desktopCapabilityManager.openSystemSettings });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useRevokeDesktopGrant() {
|
|
47
|
+
const queryClient = useQueryClient();
|
|
48
|
+
return useMutation({
|
|
49
|
+
mutationFn: async (grant: CapabilityGrantView) =>
|
|
50
|
+
await desktopCapabilityManager.revokeGrant(grant),
|
|
51
|
+
onSuccess: async () => {
|
|
52
|
+
await queryClient.invalidateQueries({ queryKey: GRANTS_QUERY_KEY });
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DesktopAuthorizationDialog } from "./components/desktop-authorization-dialog";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CapabilityGrantRequestView,
|
|
3
|
+
CapabilityGrantView,
|
|
4
|
+
} from "@nextclaw/client-sdk";
|
|
5
|
+
import { nextclawClient } from "@/shared/lib/api/managers/client.manager";
|
|
6
|
+
|
|
7
|
+
export class DesktopCapabilityManager {
|
|
8
|
+
getStatus = async () => await nextclawClient.capabilityAccess.getDesktopStatus();
|
|
9
|
+
|
|
10
|
+
listGrants = async () => await nextclawClient.capabilityAccess.listDesktopGrants();
|
|
11
|
+
|
|
12
|
+
grantAccess = async (request: CapabilityGrantRequestView) =>
|
|
13
|
+
await nextclawClient.capabilityAccess.grantDesktopAccess(request);
|
|
14
|
+
|
|
15
|
+
requestSystemPermission = async () =>
|
|
16
|
+
await nextclawClient.capabilityAccess.requestDesktopPermissions();
|
|
17
|
+
|
|
18
|
+
openSystemSettings = async () =>
|
|
19
|
+
await nextclawClient.capabilityAccess.openDesktopPermissionSettings();
|
|
20
|
+
|
|
21
|
+
revokeGrant = async (grant: CapabilityGrantView) =>
|
|
22
|
+
await nextclawClient.capabilityAccess.revokeDesktopAccess(grant);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const desktopCapabilityManager = new DesktopCapabilityManager();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { DesktopCapabilitiesPage } from "./desktop-capabilities-page";
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({ refetch: vi.fn(), revoke: vi.fn() }));
|
|
6
|
+
|
|
7
|
+
vi.mock("../hooks/use-desktop-capabilities", () => ({
|
|
8
|
+
useDesktopCapabilityStatus: () => ({
|
|
9
|
+
data: {
|
|
10
|
+
online: true,
|
|
11
|
+
permissions: { accessibility: "granted", screenCapture: "not_supported" },
|
|
12
|
+
},
|
|
13
|
+
isLoading: false,
|
|
14
|
+
isFetching: false,
|
|
15
|
+
refetch: mocks.refetch,
|
|
16
|
+
}),
|
|
17
|
+
useDesktopCapabilityGrants: () => ({
|
|
18
|
+
data: [
|
|
19
|
+
{
|
|
20
|
+
subject: { type: "agent", id: "main" },
|
|
21
|
+
resource: { type: "desktop.application", target: { applicationId: "wechat" } },
|
|
22
|
+
access: ["ui.read"],
|
|
23
|
+
declarationFingerprint: "fingerprint",
|
|
24
|
+
grantedAt: "2026-08-26T00:00:00.000Z",
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
isLoading: false,
|
|
28
|
+
}),
|
|
29
|
+
useRequestDesktopSystemPermission: () => ({ isPending: false, mutate: vi.fn() }),
|
|
30
|
+
useOpenDesktopSystemSettings: () => ({ isPending: false, mutate: vi.fn() }),
|
|
31
|
+
useRevokeDesktopGrant: () => ({ isPending: false, mutate: mocks.revoke }),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
describe("DesktopCapabilitiesPage", () => {
|
|
35
|
+
it("shows system permission and Agent grants with reachable actions", () => {
|
|
36
|
+
render(<DesktopCapabilitiesPage />);
|
|
37
|
+
|
|
38
|
+
expect(screen.getByText("Desktop Access")).toBeTruthy();
|
|
39
|
+
expect(screen.getByText("AI · main")).toBeTruthy();
|
|
40
|
+
expect(screen.getByText("wechat · ui.read")).toBeTruthy();
|
|
41
|
+
fireEvent.click(screen.getByRole("button", { name: "Check again" }));
|
|
42
|
+
expect(mocks.refetch).toHaveBeenCalledOnce();
|
|
43
|
+
});
|
|
44
|
+
});
|