@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.
- package/CHANGELOG.md +45 -0
- package/dist/assets/{api-iMkU_o5P.js → api-m33O1Kb0.js} +6 -6
- package/dist/assets/{appearance-settings-page-CU5k4b-M.js → appearance-settings-page-x4Vjnfmz.js} +1 -1
- package/dist/assets/{book-open-nD8gdMhA.js → book-open-BHiasMCg.js} +1 -1
- package/dist/assets/{channels-list-page-Drlt62kY.js → channels-list-page-DOxJZeXq.js} +2 -2
- package/dist/assets/{config-split-page-BtJZp1E5.js → config-split-page-DBnMEP1o.js} +1 -1
- package/dist/assets/{confirm-dialog-k2-Jq_x3.js → confirm-dialog-f5NKLh5I.js} +2 -2
- package/dist/assets/{createLucideIcon-DxlwKLr-.js → createLucideIcon-CHALGA42.js} +1 -1
- package/dist/assets/desktop-capabilities-page-DixayAe6.js +1 -0
- package/dist/assets/desktop-update-config-ChhfeOHj.js +1 -0
- package/dist/assets/{dist-Cy_e4kNb.js → dist-_UMsWSY0.js} +1 -1
- package/dist/assets/{dist-m08DUswB.js → dist-sD6V4nHX.js} +1 -1
- package/dist/assets/doc-browser-Bq7LJsQY.js +1 -0
- package/dist/assets/doc-browser-BuDgpY2e.js +1 -0
- package/dist/assets/{doc-browser-context-jiy-O4F8.js → doc-browser-context-Capim3og.js} +1 -1
- package/dist/assets/{extensions-CIb17__o.js → extensions-Bdclr3Wr.js} +1 -1
- package/dist/assets/{form-actions-DGf5zUg_.js → form-actions-CH0kWhEt.js} +1 -1
- package/dist/assets/index-BjxnLYE3.js +109 -0
- package/dist/assets/index-CBmwJjwE.css +1 -0
- package/dist/assets/loader-circle-CH_0iPLy.js +1 -0
- package/dist/assets/mcp-marketplace-page-BUsxLVB5.js +10 -0
- package/dist/assets/mcp-marketplace-page-GPRV3fB0.js +1 -0
- package/dist/assets/model-config-page-CIevwqp4.js +1 -0
- package/dist/assets/{navigation-link-DAfwFCj4.js → navigation-link-CAUiM3zS.js} +1 -1
- package/dist/assets/plus-B3obD55V.js +1 -0
- package/dist/assets/privacy-settings-page-CNKfZsYH.js +1 -0
- package/dist/assets/providers-config-page-DAah2NIS.js +1 -0
- package/dist/assets/react-Co6JiC1n.js +8 -0
- package/dist/assets/{refresh-cw-D_Eibznw.js → refresh-cw-BG_LX3SS.js} +1 -1
- package/dist/assets/remote-DuscgfvR.js +1 -0
- package/dist/assets/{rotate-cw-QQjmoWuq.js → rotate-cw-Cz7I_m7O.js} +1 -1
- package/dist/assets/runtime-config-page-Cl1RpmSC.js +1 -0
- package/dist/assets/{save-W2xthJJ2.js → save-BiHnb8ps.js} +1 -1
- package/dist/assets/{search-Vih1R2BD.js → search-C47TyRg0.js} +1 -1
- package/dist/assets/{search-config-page-CtBcL2uc.js → search-config-page-BAIzd6gN.js} +1 -1
- package/dist/assets/{secrets-config-page-Q6XfIj0h.js → secrets-config-page-Ddi-n43R.js} +2 -2
- package/dist/assets/security-config-C8MHnE1r.js +1 -0
- package/dist/assets/{select-lNdozU4o.js → select-BzQKfW1e.js} +2 -2
- package/dist/assets/skeleton-BXFAE9SE.js +1 -0
- package/dist/assets/{tag-chip-gdaU9bE7.js → tag-chip-C3K814HZ.js} +1 -1
- package/dist/assets/x-C1r9IXet.js +1 -0
- package/dist/index.html +29 -21
- package/dist/themes/island/island-atmosphere.webp +0 -0
- package/index.html +8 -0
- package/package.json +8 -8
- package/public/themes/island/island-atmosphere.webp +0 -0
- package/src/app/components/layout/header.tsx +4 -1
- package/src/app/components/layout/page-layout.tsx +4 -1
- package/src/app/components/layout/sidebar.tsx +6 -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/app/styles/island-theme.css +542 -0
- package/src/features/chat/components/conversation/__tests__/chat-conversation-header.test.tsx +1 -0
- package/src/features/chat/components/conversation/chat-conversation-header.tsx +5 -1
- package/src/features/chat/components/conversation/chat-conversation-panel.tsx +4 -1
- package/src/features/chat/components/layout/__tests__/chat-sidebar-toolbar.test.tsx +10 -0
- package/src/features/chat/components/layout/chat-page-shell.tsx +4 -1
- package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +2 -0
- package/src/features/chat/components/layout/chat-sidebar.tsx +2 -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/features/workspace/components/__tests__/chat-session-workspace-panel.test.tsx +1 -0
- package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +1 -0
- package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +1 -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/features/pwa/managers/__tests__/pwa-shell-theme.manager.test.ts +9 -0
- package/src/features/pwa/managers/pwa-shell-theme.manager.ts +1 -0
- package/src/features/service-apps/components/__tests__/service-apps-panel.test.tsx +35 -1
- package/src/features/service-apps/components/service-action-row.tsx +130 -0
- package/src/features/service-apps/components/service-app-list-item.tsx +11 -66
- package/src/features/service-apps/components/service-apps-panel.tsx +12 -0
- package/src/features/service-apps/hooks/use-service-apps.ts +20 -1
- package/src/features/settings/pages/__tests__/appearance-settings-page.test.tsx +2 -2
- package/src/features/side-dock/components/side-dock.tsx +1 -0
- package/src/index.css +1 -0
- package/src/platforms/desktop/components/__tests__/desktop-app-shell.test.tsx +8 -0
- package/src/platforms/desktop/components/desktop-app-shell.tsx +56 -2
- package/src/platforms/desktop/components/desktop-window-chrome.tsx +1 -0
- package/src/platforms/mobile/components/mobile-settings-shell.tsx +5 -1
- package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +3 -0
- package/src/shared/components/doc-browser/doc-browser.tsx +2 -0
- 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/components/ui/context-menu/context-menu.tsx +1 -0
- package/src/shared/components/ui/dialog.tsx +2 -0
- package/src/shared/components/ui/overlays/sheet.tsx +2 -0
- package/src/shared/components/ui/popover.tsx +1 -0
- package/src/shared/components/ui/select.tsx +1 -0
- package/src/shared/components/ui/tooltip.tsx +1 -0
- 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 +44 -0
- package/src/shared/lib/i18n/locales/en-US/doc-browser.json +1 -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 +44 -0
- package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +1 -0
- package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +29 -1
- package/src/shared/lib/theme/index.test.ts +9 -0
- package/src/shared/lib/theme/index.ts +2 -0
- package/dist/assets/desktop-update-config-BiZmy7FC.js +0 -1
- package/dist/assets/doc-browser-CPsPHh12.js +0 -1
- package/dist/assets/doc-browser-npOvWtgf.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,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
|
-
|
|
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
|
|