@nextclaw/ui 0.20.3 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/assets/{api-iMkU_o5P.js → api-m33O1Kb0.js} +6 -6
  3. package/dist/assets/{appearance-settings-page-CU5k4b-M.js → appearance-settings-page-x4Vjnfmz.js} +1 -1
  4. package/dist/assets/{book-open-nD8gdMhA.js → book-open-BHiasMCg.js} +1 -1
  5. package/dist/assets/{channels-list-page-Drlt62kY.js → channels-list-page-DOxJZeXq.js} +2 -2
  6. package/dist/assets/{config-split-page-BtJZp1E5.js → config-split-page-DBnMEP1o.js} +1 -1
  7. package/dist/assets/{confirm-dialog-k2-Jq_x3.js → confirm-dialog-f5NKLh5I.js} +2 -2
  8. package/dist/assets/{createLucideIcon-DxlwKLr-.js → createLucideIcon-CHALGA42.js} +1 -1
  9. package/dist/assets/desktop-capabilities-page-DixayAe6.js +1 -0
  10. package/dist/assets/desktop-update-config-ChhfeOHj.js +1 -0
  11. package/dist/assets/{dist-Cy_e4kNb.js → dist-_UMsWSY0.js} +1 -1
  12. package/dist/assets/{dist-m08DUswB.js → dist-sD6V4nHX.js} +1 -1
  13. package/dist/assets/doc-browser-Bq7LJsQY.js +1 -0
  14. package/dist/assets/doc-browser-BuDgpY2e.js +1 -0
  15. package/dist/assets/{doc-browser-context-jiy-O4F8.js → doc-browser-context-Capim3og.js} +1 -1
  16. package/dist/assets/{extensions-CIb17__o.js → extensions-Bdclr3Wr.js} +1 -1
  17. package/dist/assets/{form-actions-DGf5zUg_.js → form-actions-CH0kWhEt.js} +1 -1
  18. package/dist/assets/index-BjxnLYE3.js +109 -0
  19. package/dist/assets/index-CBmwJjwE.css +1 -0
  20. package/dist/assets/loader-circle-CH_0iPLy.js +1 -0
  21. package/dist/assets/mcp-marketplace-page-BUsxLVB5.js +10 -0
  22. package/dist/assets/mcp-marketplace-page-GPRV3fB0.js +1 -0
  23. package/dist/assets/model-config-page-CIevwqp4.js +1 -0
  24. package/dist/assets/{navigation-link-DAfwFCj4.js → navigation-link-CAUiM3zS.js} +1 -1
  25. package/dist/assets/plus-B3obD55V.js +1 -0
  26. package/dist/assets/privacy-settings-page-CNKfZsYH.js +1 -0
  27. package/dist/assets/providers-config-page-DAah2NIS.js +1 -0
  28. package/dist/assets/react-Co6JiC1n.js +8 -0
  29. package/dist/assets/{refresh-cw-D_Eibznw.js → refresh-cw-BG_LX3SS.js} +1 -1
  30. package/dist/assets/remote-DuscgfvR.js +1 -0
  31. package/dist/assets/{rotate-cw-QQjmoWuq.js → rotate-cw-Cz7I_m7O.js} +1 -1
  32. package/dist/assets/runtime-config-page-Cl1RpmSC.js +1 -0
  33. package/dist/assets/{save-W2xthJJ2.js → save-BiHnb8ps.js} +1 -1
  34. package/dist/assets/{search-Vih1R2BD.js → search-C47TyRg0.js} +1 -1
  35. package/dist/assets/{search-config-page-CtBcL2uc.js → search-config-page-BAIzd6gN.js} +1 -1
  36. package/dist/assets/{secrets-config-page-Q6XfIj0h.js → secrets-config-page-Ddi-n43R.js} +2 -2
  37. package/dist/assets/security-config-C8MHnE1r.js +1 -0
  38. package/dist/assets/{select-lNdozU4o.js → select-BzQKfW1e.js} +2 -2
  39. package/dist/assets/skeleton-BXFAE9SE.js +1 -0
  40. package/dist/assets/{tag-chip-gdaU9bE7.js → tag-chip-C3K814HZ.js} +1 -1
  41. package/dist/assets/x-C1r9IXet.js +1 -0
  42. package/dist/index.html +29 -21
  43. package/dist/themes/island/island-atmosphere.webp +0 -0
  44. package/index.html +8 -0
  45. package/package.json +8 -8
  46. package/public/themes/island/island-atmosphere.webp +0 -0
  47. package/src/app/components/layout/header.tsx +4 -1
  48. package/src/app/components/layout/page-layout.tsx +4 -1
  49. package/src/app/components/layout/sidebar.tsx +6 -1
  50. package/src/app/configs/app-navigation.config.ts +17 -2
  51. package/src/app/hooks/use-feature-controls.ts +11 -0
  52. package/src/app/index.tsx +10 -0
  53. package/src/app/styles/island-theme.css +542 -0
  54. package/src/features/chat/components/conversation/__tests__/chat-conversation-header.test.tsx +1 -0
  55. package/src/features/chat/components/conversation/chat-conversation-header.tsx +5 -1
  56. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +4 -1
  57. package/src/features/chat/components/layout/__tests__/chat-sidebar-toolbar.test.tsx +10 -0
  58. package/src/features/chat/components/layout/chat-page-shell.tsx +4 -1
  59. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +2 -0
  60. package/src/features/chat/components/layout/chat-sidebar.tsx +2 -0
  61. package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +12 -0
  62. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +3 -0
  63. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +14 -1
  64. package/src/features/chat/features/session/utils/chat-session-deletion.utils.ts +25 -0
  65. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel.test.tsx +1 -0
  66. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +1 -0
  67. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +1 -0
  68. package/src/features/chat/managers/__tests__/chat-thread-deletion.manager.test.ts +125 -0
  69. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +0 -37
  70. package/src/features/chat/managers/chat-thread.manager.ts +17 -28
  71. package/src/features/desktop-capabilities/components/desktop-authorization-dialog.test.tsx +58 -0
  72. package/src/features/desktop-capabilities/components/desktop-authorization-dialog.tsx +161 -0
  73. package/src/features/desktop-capabilities/hooks/use-desktop-capabilities.test.ts +10 -0
  74. package/src/features/desktop-capabilities/hooks/use-desktop-capabilities.ts +55 -0
  75. package/src/features/desktop-capabilities/index.ts +1 -0
  76. package/src/features/desktop-capabilities/managers/desktop-capability.manager.ts +25 -0
  77. package/src/features/desktop-capabilities/pages/desktop-capabilities-page.test.tsx +44 -0
  78. package/src/features/desktop-capabilities/pages/desktop-capabilities-page.tsx +171 -0
  79. package/src/features/desktop-capabilities/stores/desktop-authorization.store.ts +21 -0
  80. package/src/features/marketplace/components/mcp/__tests__/mcp-connection-dialog.test.tsx +23 -0
  81. package/src/features/marketplace/components/mcp/mcp-connection-dialog.tsx +35 -0
  82. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +20 -2
  83. package/src/features/pwa/managers/__tests__/pwa-shell-theme.manager.test.ts +9 -0
  84. package/src/features/pwa/managers/pwa-shell-theme.manager.ts +1 -0
  85. package/src/features/service-apps/components/__tests__/service-apps-panel.test.tsx +35 -1
  86. package/src/features/service-apps/components/service-action-row.tsx +130 -0
  87. package/src/features/service-apps/components/service-app-list-item.tsx +11 -66
  88. package/src/features/service-apps/components/service-apps-panel.tsx +12 -0
  89. package/src/features/service-apps/hooks/use-service-apps.ts +20 -1
  90. package/src/features/settings/pages/__tests__/appearance-settings-page.test.tsx +2 -2
  91. package/src/features/side-dock/components/side-dock.tsx +1 -0
  92. package/src/index.css +1 -0
  93. package/src/platforms/desktop/components/__tests__/desktop-app-shell.test.tsx +8 -0
  94. package/src/platforms/desktop/components/desktop-app-shell.tsx +56 -2
  95. package/src/platforms/desktop/components/desktop-window-chrome.tsx +1 -0
  96. package/src/platforms/mobile/components/mobile-settings-shell.tsx +5 -1
  97. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +3 -0
  98. package/src/shared/components/doc-browser/doc-browser.tsx +2 -0
  99. package/src/shared/components/ui/__tests__/confirm-dialog.test.tsx +31 -0
  100. package/src/shared/components/ui/confirm-dialog.tsx +11 -1
  101. package/src/shared/components/ui/context-menu/context-menu.tsx +1 -0
  102. package/src/shared/components/ui/dialog.tsx +2 -0
  103. package/src/shared/components/ui/overlays/sheet.tsx +2 -0
  104. package/src/shared/components/ui/popover.tsx +1 -0
  105. package/src/shared/components/ui/select.tsx +1 -0
  106. package/src/shared/components/ui/tooltip.tsx +1 -0
  107. package/src/shared/lib/api/index.ts +1 -0
  108. package/src/shared/lib/api/utils/mcp-connection.utils.ts +10 -0
  109. package/src/shared/lib/i18n/locales/en-US/chat.json +2 -0
  110. package/src/shared/lib/i18n/locales/en-US/core.json +44 -0
  111. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +1 -0
  112. package/src/shared/lib/i18n/locales/en-US/marketplace.json +29 -1
  113. package/src/shared/lib/i18n/locales/zh-CN/chat.json +2 -0
  114. package/src/shared/lib/i18n/locales/zh-CN/core.json +44 -0
  115. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +1 -0
  116. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +29 -1
  117. package/src/shared/lib/theme/index.test.ts +9 -0
  118. package/src/shared/lib/theme/index.ts +2 -0
  119. package/dist/assets/desktop-update-config-BiZmy7FC.js +0 -1
  120. package/dist/assets/doc-browser-CPsPHh12.js +0 -1
  121. package/dist/assets/doc-browser-npOvWtgf.js +0 -1
  122. package/dist/assets/index-C86hxgv5.css +0 -1
  123. package/dist/assets/index-CYBXR7zN.js +0 -109
  124. package/dist/assets/loader-circle-BlLuRFVW.js +0 -1
  125. package/dist/assets/mcp-marketplace-page-B12JNh7Q.js +0 -1
  126. package/dist/assets/mcp-marketplace-page-kLWW6kzt.js +0 -9
  127. package/dist/assets/model-config-page-DSeAjVT_.js +0 -1
  128. package/dist/assets/plus-D4LtWtTk.js +0 -1
  129. package/dist/assets/privacy-settings-page-tN0Mt15x.js +0 -1
  130. package/dist/assets/providers-config-page-CgPrXR1u.js +0 -1
  131. package/dist/assets/react-8iBFECZE.js +0 -8
  132. package/dist/assets/remote-Derr_AQp.js +0 -1
  133. package/dist/assets/runtime-config-page-Crq96Jjq.js +0 -1
  134. package/dist/assets/security-config-CE9rkQvq.js +0 -1
  135. package/dist/assets/skeleton-B__aTcly.js +0 -1
  136. package/dist/assets/x-CWdWBu_U.js +0 -1
@@ -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
+ });
@@ -0,0 +1,171 @@
1
+ import type { CapabilityGrantView } from "@nextclaw/client-sdk";
2
+ import { Button } from "@/shared/components/ui/button";
3
+ import { SettingsPage } from "@/shared/components/settings/settings-page";
4
+ import {
5
+ SettingRow,
6
+ SettingsGroup,
7
+ SettingsSection,
8
+ } from "@/shared/components/settings/setting-row";
9
+ import { t } from "@/shared/lib/i18n";
10
+ import {
11
+ useDesktopCapabilityGrants,
12
+ useDesktopCapabilityStatus,
13
+ useOpenDesktopSystemSettings,
14
+ useRequestDesktopSystemPermission,
15
+ useRevokeDesktopGrant,
16
+ } from "../hooks/use-desktop-capabilities";
17
+
18
+ export function DesktopCapabilitiesPage() {
19
+ const statusQuery = useDesktopCapabilityStatus();
20
+ const grantsQuery = useDesktopCapabilityGrants();
21
+ const requestPermission = useRequestDesktopSystemPermission();
22
+ const openSettings = useOpenDesktopSystemSettings();
23
+ const revokeGrant = useRevokeDesktopGrant();
24
+ const status = statusQuery.data;
25
+ const busy = requestPermission.isPending || openSettings.isPending;
26
+
27
+ return (
28
+ <SettingsPage
29
+ title={t("desktopCapabilitiesPageTitle")}
30
+ description={t("desktopCapabilitiesPageDescription")}
31
+ >
32
+ <SettingsSection
33
+ title={t("desktopCapabilitiesSystemTitle")}
34
+ description={t("desktopCapabilitiesSystemDescription")}
35
+ >
36
+ <SettingsGroup>
37
+ <SettingRow
38
+ title={t("desktopCapabilitiesHostStatus")}
39
+ description={
40
+ statusQuery.isLoading
41
+ ? t("loading")
42
+ : status?.online
43
+ ? t("desktopCapabilitiesHostOnline")
44
+ : t("desktopCapabilitiesHostOffline")
45
+ }
46
+ control={(
47
+ <Button
48
+ type="button"
49
+ variant="outline"
50
+ size="sm"
51
+ disabled={statusQuery.isFetching}
52
+ onClick={() => void statusQuery.refetch()}
53
+ >
54
+ {t("desktopCapabilitiesRecheck")}
55
+ </Button>
56
+ )}
57
+ />
58
+ <SettingRow
59
+ title={t("desktopCapabilitiesAccessibility")}
60
+ description={formatPermission(status?.permissions.accessibility)}
61
+ control={(
62
+ <div className="flex flex-wrap justify-end gap-2">
63
+ <Button
64
+ type="button"
65
+ variant="outline"
66
+ size="sm"
67
+ disabled={busy}
68
+ onClick={() => openSettings.mutate()}
69
+ >
70
+ {t("desktopCapabilitiesOpenSettings")}
71
+ </Button>
72
+ <Button
73
+ type="button"
74
+ size="sm"
75
+ disabled={busy}
76
+ onClick={() => requestPermission.mutate()}
77
+ >
78
+ {t("desktopCapabilitiesRequestPermission")}
79
+ </Button>
80
+ </div>
81
+ )}
82
+ />
83
+ </SettingsGroup>
84
+ </SettingsSection>
85
+
86
+ <SettingsSection
87
+ title={t("desktopCapabilitiesGrantsTitle")}
88
+ description={t("desktopCapabilitiesGrantsDescription")}
89
+ >
90
+ <SettingsGroup>
91
+ {grantsQuery.isLoading ? (
92
+ <SettingRow title={t("loading")} />
93
+ ) : grantsQuery.data?.length ? (
94
+ grantsQuery.data.map((grant) => (
95
+ <DesktopGrantRow
96
+ key={grantKey(grant)}
97
+ grant={grant}
98
+ disabled={revokeGrant.isPending}
99
+ onRevoke={() => revokeGrant.mutate(grant)}
100
+ />
101
+ ))
102
+ ) : (
103
+ <SettingRow
104
+ title={t("desktopCapabilitiesNoGrants")}
105
+ description={t("desktopCapabilitiesNoGrantsDescription")}
106
+ />
107
+ )}
108
+ </SettingsGroup>
109
+ </SettingsSection>
110
+ </SettingsPage>
111
+ );
112
+ }
113
+
114
+ function DesktopGrantRow({
115
+ disabled,
116
+ grant,
117
+ onRevoke,
118
+ }: {
119
+ disabled: boolean;
120
+ grant: CapabilityGrantView;
121
+ onRevoke: () => void;
122
+ }) {
123
+ const target = readApplicationId(grant.resource.target);
124
+ const subject = grant.subject.type === "agent"
125
+ ? `${t("desktopCapabilitiesAgent")} · ${grant.subject.id}`
126
+ : `${t("desktopCapabilitiesExtension")} · ${grant.subject.id}`;
127
+ return (
128
+ <SettingRow
129
+ title={subject}
130
+ description={`${target} · ${grant.access.join(", ")}`}
131
+ control={(
132
+ <Button
133
+ type="button"
134
+ variant="destructive"
135
+ size="sm"
136
+ disabled={disabled}
137
+ onClick={onRevoke}
138
+ >
139
+ {t("desktopCapabilitiesRevoke")}
140
+ </Button>
141
+ )}
142
+ />
143
+ );
144
+ }
145
+
146
+ function readApplicationId(target: unknown): string {
147
+ if (!target || typeof target !== "object" || Array.isArray(target)) {
148
+ return t("desktopCapabilitiesUnknownApplication");
149
+ }
150
+ const { applicationId } = target as { applicationId?: unknown };
151
+ return typeof applicationId === "string" && applicationId.trim()
152
+ ? applicationId.trim()
153
+ : t("desktopCapabilitiesUnknownApplication");
154
+ }
155
+
156
+ function formatPermission(value: string | undefined): string {
157
+ switch (value) {
158
+ case "granted":
159
+ return t("desktopCapabilitiesPermissionGranted");
160
+ case "not_granted":
161
+ return t("desktopCapabilitiesPermissionNotGranted");
162
+ case "not_supported":
163
+ return t("desktopCapabilitiesPermissionNotSupported");
164
+ default:
165
+ return t("desktopCapabilitiesPermissionUnknown");
166
+ }
167
+ }
168
+
169
+ function grantKey(grant: CapabilityGrantView): string {
170
+ return `${grant.subject.type}:${grant.subject.id}:${JSON.stringify(grant.resource.target)}:${grant.access.join(",")}`;
171
+ }
@@ -0,0 +1,21 @@
1
+ import { create } from "zustand";
2
+ import type { CapabilityGrantRequestView } from "@nextclaw/client-sdk";
3
+
4
+ export type DesktopAuthorizationRequest = {
5
+ applicationId: string;
6
+ request: CapabilityGrantRequestView;
7
+ };
8
+
9
+ type DesktopAuthorizationState = {
10
+ pending: DesktopAuthorizationRequest | null;
11
+ present: (request: DesktopAuthorizationRequest) => void;
12
+ clear: () => void;
13
+ };
14
+
15
+ export const useDesktopAuthorizationStore = create<DesktopAuthorizationState>(
16
+ (set) => ({
17
+ pending: null,
18
+ present: (pending) => set({ pending }),
19
+ clear: () => set({ pending: null }),
20
+ }),
21
+ );
@@ -0,0 +1,23 @@
1
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { McpConnectionDialog } from "../mcp-connection-dialog";
4
+
5
+ describe("McpConnectionDialog", () => {
6
+ it("requires a successful test before saving a stdio connection", async () => {
7
+ const onTest = vi.fn(async () => ({ name: "local-tools", transport: "stdio" as const, accessible: true, toolCount: 2 }));
8
+ const onSave = vi.fn(async () => undefined);
9
+ render(<McpConnectionDialog open testing={false} saving={false} onOpenChange={vi.fn()} onTest={onTest} onSave={onSave} />);
10
+
11
+ const saveButton = screen.getByRole("button", { name: "Save connection" });
12
+ expect(saveButton.hasAttribute("disabled")).toBe(true);
13
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "local-tools" } });
14
+ fireEvent.change(screen.getByLabelText("Command"), { target: { value: "node" } });
15
+ fireEvent.click(screen.getByRole("button", { name: "Test connection" }));
16
+
17
+ await waitFor(() => expect(onTest).toHaveBeenCalledOnce());
18
+ expect(await screen.findByText("Connected. Found 2 tools.")).not.toBeNull();
19
+ expect(saveButton.hasAttribute("disabled")).toBe(false);
20
+ fireEvent.click(saveButton);
21
+ await waitFor(() => expect(onSave).toHaveBeenCalledOnce());
22
+ });
23
+ });
@@ -0,0 +1,35 @@
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import type { McpConnectionRequest, McpConnectionTestResult } from "@nextclaw/client-sdk";
3
+ import { Button } from "@/shared/components/ui/button";
4
+ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/shared/components/ui/overlays/sheet";
5
+ import { Input } from "@/shared/components/ui/input";
6
+ import { Textarea } from "@/shared/components/ui/textarea";
7
+ import { Switch } from "@/shared/components/ui/switch";
8
+ import { t } from "@/shared/lib/i18n";
9
+
10
+ type Transport = "stdio" | "http" | "sse";
11
+
12
+ export function McpConnectionDialog({ open, testing, saving, onOpenChange, onTest, onSave }: {
13
+ open: boolean; testing: boolean; saving: boolean; onOpenChange: (open: boolean) => void;
14
+ onTest: (request: McpConnectionRequest) => Promise<McpConnectionTestResult>; onSave: (request: McpConnectionRequest) => Promise<void>;
15
+ }) {
16
+ const [transport, setTransport] = useState<Transport>("stdio");
17
+ const [name, setName] = useState(""); const [command, setCommand] = useState(""); const [args, setArgs] = useState("");
18
+ const [cwd, setCwd] = useState(""); const [env, setEnv] = useState(""); const [url, setUrl] = useState("");
19
+ const [headers, setHeaders] = useState(""); const [timeout, setTimeout] = useState("15000"); const [verifyTls, setVerifyTls] = useState(true);
20
+ const [result, setResult] = useState<McpConnectionTestResult | null>(null); const [error, setError] = useState<string | null>(null);
21
+ const request = useMemo(() => buildRequest({ transport, name, command, args, cwd, env, url, headers, timeout, verifyTls }), [args, command, cwd, env, headers, name, timeout, transport, url, verifyTls]);
22
+ const dirty = () => { setResult(null); setError(null); };
23
+ const test = async () => { if (request instanceof Error) return setError(request.message); dirty(); try { const next = await onTest(request); setResult(next); if (!next.accessible) setError(next.error ?? t("mcpConnectionTestFailed")); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } };
24
+ const save = async () => { if (request instanceof Error || !result?.accessible) return; try { await onSave(request); onOpenChange(false); dirty(); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } };
25
+ return <Sheet open={open} onOpenChange={onOpenChange}><SheetContent side="right" closeLabel={t("cancel")} className="flex h-full w-full flex-col overflow-hidden p-0 sm:max-w-[560px]"><SheetHeader className="shrink-0 border-b border-border/60 px-5 pb-5 pt-6 pr-14 sm:px-7 sm:pt-7"><SheetTitle>{t("mcpConnectionTitle")}</SheetTitle><SheetDescription>{t("mcpConnectionDescription")}</SheetDescription></SheetHeader><div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-6 sm:px-7">
26
+ <Field label={t("mcpConnectionTransport")}><select className="flex h-10 w-full rounded-xl border border-border/75 bg-card px-3 text-sm" value={transport} onChange={(e) => { setTransport(e.target.value as Transport); dirty(); }}><option value="stdio">{t("mcpConnectionStdio")}</option><option value="http">HTTP</option><option value="sse">SSE</option></select></Field>
27
+ <Field label={t("mcpConnectionName")}><Input value={name} onChange={(e) => { setName(e.target.value); dirty(); }} placeholder="my-mcp" /></Field>
28
+ {transport === "stdio" ? <><Field label={t("mcpConnectionCommand")}><Input value={command} onChange={(e) => { setCommand(e.target.value); dirty(); }} placeholder="npx" /></Field><Field label={t("mcpConnectionArgs")} hint={t("mcpConnectionArgsHint")}><Input value={args} onChange={(e) => { setArgs(e.target.value); dirty(); }} placeholder="-y @example/mcp-server" /></Field><Field label={t("mcpConnectionCwd")}><Input value={cwd} onChange={(e) => { setCwd(e.target.value); dirty(); }} /></Field><Field label={t("mcpConnectionEnv")} hint={t("mcpConnectionPairsHint")}><Textarea className="min-h-20 font-mono" value={env} onChange={(e) => { setEnv(e.target.value); dirty(); }} placeholder="API_KEY=..." /></Field><p className="rounded-xl bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-100">{t("mcpConnectionLocalWarning")}</p></> : <><Field label={t("mcpConnectionUrl")}><Input type="url" value={url} onChange={(e) => { setUrl(e.target.value); dirty(); }} placeholder="https://example.com/mcp" /></Field><Field label={t("mcpConnectionHeaders")} hint={t("mcpConnectionPairsHint")}><Textarea className="min-h-20 font-mono" value={headers} onChange={(e) => { setHeaders(e.target.value); dirty(); }} placeholder="Authorization=Bearer ..." /></Field><Field label={t("mcpConnectionTimeout")}><Input type="number" min="1" value={timeout} onChange={(e) => { setTimeout(e.target.value); dirty(); }} /></Field><div className="flex items-center justify-between rounded-xl bg-muted/60 px-3 py-3"><span className="text-sm">{t("mcpConnectionVerifyTls")}</span><Switch checked={verifyTls} onCheckedChange={(value) => { setVerifyTls(value); dirty(); }} /></div><p className="rounded-xl bg-muted px-3 py-2 text-sm text-muted-foreground">{t("mcpConnectionRemoteWarning")}</p></>}
29
+ {result?.accessible ? <p className="rounded-xl bg-emerald-500/10 px-3 py-2 text-sm text-emerald-800 dark:text-emerald-100">{t("mcpConnectionTestSuccess").replace("{count}", String(result.toolCount))}</p> : null}{error ? <p role="alert" className="rounded-xl bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p> : null}
30
+ </div><SheetFooter className="shrink-0 border-t border-border/60 bg-background px-5 py-4 sm:px-7"><Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={testing || saving}>{t("cancel")}</Button><Button type="button" variant="outline" onClick={() => void test()} disabled={testing || saving}>{testing ? t("mcpConnectionTesting") : t("mcpConnectionTest")}</Button><Button type="button" onClick={() => void save()} disabled={!result?.accessible || testing || saving}>{saving ? t("saving") : t("mcpConnectionSave")}</Button></SheetFooter></SheetContent></Sheet>;
31
+ }
32
+
33
+ function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { return <label className="block space-y-1.5"><span className="text-sm font-medium">{label}</span>{hint ? <span className="block text-xs text-muted-foreground">{hint}</span> : null}{children}</label>; }
34
+ function buildRequest(input: { transport: Transport; name: string; command: string; args: string; cwd: string; env: string; url: string; headers: string; timeout: string; verifyTls: boolean }): McpConnectionRequest | Error { try { const name = input.name.trim(); if (!name) throw new Error(t("mcpConnectionNameRequired")); if (input.transport === "stdio") { const command = input.command.trim(); if (!command) throw new Error(t("mcpConnectionCommandRequired")); return { name, definition: { enabled: true, transport: { type: "stdio", command, args: input.args.trim() ? input.args.trim().split(/\s+/) : [], cwd: input.cwd.trim(), env: pairs(input.env), stderr: "pipe" }, scope: { allAgents: true, agents: [] }, policy: { trust: "explicit", start: "eager" } } }; } const url = input.url.trim(); if (!url) throw new Error(t("mcpConnectionUrlRequired")); const timeoutMs = Number(input.timeout); if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error(t("mcpConnectionTimeoutInvalid")); const common = { url, headers: pairs(input.headers), timeoutMs: Math.trunc(timeoutMs), verifyTls: input.verifyTls }; return { name, definition: { enabled: true, transport: input.transport === "http" ? { type: "http", ...common } : { type: "sse", ...common, reconnect: { enabled: true, initialDelayMs: 1000, maxDelayMs: 30000 } }, scope: { allAgents: true, agents: [] }, policy: { trust: "explicit", start: "eager" } } }; } catch (error) { return error instanceof Error ? error : new Error(String(error)); } }
35
+ function pairs(value: string): Record<string, string> { return Object.fromEntries(value.split("\n").filter(Boolean).map((line) => { const index = line.indexOf("="); if (index <= 0) throw new Error(t("mcpConnectionPairsInvalid")); return [line.slice(0, index).trim(), line.slice(index + 1)]; })); }
@@ -9,7 +9,9 @@ import type {
9
9
  } from "@/shared/lib/api";
10
10
  import {
11
11
  fetchMcpMarketplaceContent,
12
+ createMcpConnection,
12
13
  doctorMcpMarketplaceItem,
14
+ testMcpConnection,
13
15
  } from "@/shared/lib/api";
14
16
  import { SettingsPage } from "@/shared/components/settings/settings-page";
15
17
  import {
@@ -19,6 +21,7 @@ import {
19
21
  } from "@/features/marketplace/components/marketplace-page-parts";
20
22
  import { buildLocaleFallbacks } from "@/features/marketplace/components/marketplace-localization";
21
23
  import { McpMarketplaceCard } from "@/features/marketplace/components/mcp/mcp-marketplace-card";
24
+ import { McpConnectionDialog } from "@/features/marketplace/components/mcp/mcp-connection-dialog";
22
25
  import {
23
26
  buildInstalledRecordLookup,
24
27
  findInstalledRecordForItem,
@@ -46,6 +49,7 @@ import { t } from "@/shared/lib/i18n";
46
49
  import { useInfiniteScrollLoader } from "@/shared/hooks/use-infinite-scroll-loader";
47
50
  import { cn } from "@/shared/lib/utils";
48
51
  import { Sparkles, PackageCheck } from "lucide-react";
52
+ import { Button } from "@/shared/components/ui/button";
49
53
 
50
54
  type ScopeType = "catalog" | "installed";
51
55
 
@@ -61,6 +65,7 @@ export function McpMarketplacePage() {
61
65
  const [doctorTarget, setDoctorTarget] = useState<string | null>(null);
62
66
  const [doctorResult, setDoctorResult] =
63
67
  useState<MarketplaceMcpDoctorResult | null>(null);
68
+ const [connectionOpen, setConnectionOpen] = useState(false);
64
69
  const { language } = useI18n();
65
70
  const docBrowser = useDocBrowser();
66
71
  const { confirm, ConfirmDialog } = useConfirmDialog();
@@ -110,6 +115,8 @@ export function McpMarketplacePage() {
110
115
  setDoctorResult(result);
111
116
  },
112
117
  });
118
+ const testConnectionMutation = useMutation({ mutationFn: testMcpConnection });
119
+ const createConnectionMutation = useMutation({ mutationFn: createMcpConnection });
113
120
 
114
121
  const installedRecordLookup = useMemo(() => {
115
122
  return buildInstalledRecordLookup(installedQuery.data?.records ?? []);
@@ -325,6 +332,9 @@ export function McpMarketplacePage() {
325
332
  : (installedQuery.data?.total ?? 0)}
326
333
  </span>
327
334
  </h3>
335
+ <Button type="button" size="sm" onClick={() => setConnectionOpen(true)}>
336
+ {t("mcpConnectionOpen")}
337
+ </Button>
328
338
  </div>
329
339
 
330
340
  <div
@@ -441,13 +451,21 @@ export function McpMarketplacePage() {
441
451
  </div>
442
452
  </section>
443
453
 
444
- <InstallDialog
454
+ <InstallDialog
445
455
  item={installingItem}
446
456
  open={Boolean(installingItem)}
447
457
  pending={installMutation.isPending}
448
458
  onOpenChange={(open) => !open && setInstallingItem(null)}
449
459
  onSubmit={handleInstall}
450
- />
460
+ />
461
+ <McpConnectionDialog
462
+ open={connectionOpen}
463
+ testing={testConnectionMutation.isPending}
464
+ saving={createConnectionMutation.isPending}
465
+ onOpenChange={setConnectionOpen}
466
+ onTest={(request) => testConnectionMutation.mutateAsync(request)}
467
+ onSave={async (request) => { await createConnectionMutation.mutateAsync(request); }}
468
+ />
451
469
  <DoctorDialog
452
470
  open={Boolean(doctorTarget)}
453
471
  targetName={doctorTarget}
@@ -100,6 +100,15 @@ describe('PwaShellThemeManager', () => {
100
100
  expect(document.documentElement.style.colorScheme).toBe('dark');
101
101
  });
102
102
 
103
+ it('applies island shell colors and light color scheme', () => {
104
+ pwaShellThemeManager.syncTheme('island');
105
+
106
+ const meta = document.querySelector('meta[name="theme-color"]');
107
+ expect(meta?.getAttribute('content')).toBe('#FBF8ED');
108
+ expect(document.body.style.backgroundColor).toBe('rgb(251, 248, 237)');
109
+ expect(document.documentElement.style.colorScheme).toBe('light');
110
+ });
111
+
103
112
  it('maps the legacy leaf theme to warm shell colors', () => {
104
113
  document.documentElement.setAttribute('data-theme', 'leaf');
105
114
 
@@ -15,6 +15,7 @@ const PWA_SHELL_THEME_COLORS: Record<UiTheme, string> = {
15
15
  graphite: '#F8F8F7',
16
16
  night: '#101318',
17
17
  charcoal: '#1C1C1C',
18
+ island: '#FBF8ED',
18
19
  probe: '#FEFCF6',
19
20
  };
20
21