@nextclaw/ui 0.12.9 → 0.12.10

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 (178) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/assets/ChannelsList-M9FTK1Ak.js +8 -0
  3. package/dist/assets/DocBrowser-CH7-GxlL.js +1 -0
  4. package/dist/assets/{DocBrowser-6ReNjvzF.js → DocBrowser-DMfr0Oow.js} +1 -1
  5. package/dist/assets/{DocBrowserContext-B6SpA7Qs.js → DocBrowserContext-BXydqby-.js} +1 -1
  6. package/dist/assets/{LogoBadge-ByNLYg65.js → LogoBadge-hO7tY7hE.js} +1 -1
  7. package/dist/assets/ModelConfig-CNIgLf0e.js +1 -0
  8. package/dist/assets/{ProviderScopedModelInput-Da7khnBA.js → ProviderScopedModelInput-B3HWP4oz.js} +1 -1
  9. package/dist/assets/ProvidersList-CHjMnRhX.js +1 -0
  10. package/dist/assets/RuntimeConfig-psp8nMSG.js +1 -0
  11. package/dist/assets/SearchConfig-CSoKip1f.js +1 -0
  12. package/dist/assets/{SecretsConfig-D281Rotl.js → SecretsConfig-MEt6MjuD.js} +2 -2
  13. package/dist/assets/SessionsConfig-DifCiXwR.js +2 -0
  14. package/dist/assets/{app-query-client-VnFElj4E.js → app-query-client-9jNewezV.js} +1 -1
  15. package/dist/assets/{book-open-BdcxxoQu.js → book-open-DzdUViDm.js} +1 -1
  16. package/dist/assets/chat-page-CLp0UV0Y.js +58 -0
  17. package/dist/assets/chat-session-display-DsYHx0RZ.js +1 -0
  18. package/dist/assets/{chunk-JZWAC4HX-DK5HPmIK.js → chunk-JZWAC4HX-C5dEc8hV.js} +1 -1
  19. package/dist/assets/{client-_i4MU2bB.js → client-C-8fH7-c.js} +1 -1
  20. package/dist/assets/{config-DtIQwrHF.js → config-CBScxsdV.js} +1 -1
  21. package/dist/assets/config-split-page-BUout_Ak.js +1 -0
  22. package/dist/assets/{createLucideIcon-BSeTgkZW.js → createLucideIcon-dy5ie7Ox.js} +1 -1
  23. package/dist/assets/desktop-update-config-2BS6BMkW.js +1 -0
  24. package/dist/assets/{dist-ccBFUi-o.js → dist-BruyLa92.js} +1 -1
  25. package/dist/assets/{dist-6TrrnPCR.js → dist-Cy7_j6hA.js} +1 -1
  26. package/dist/assets/{download-BhDxnyvU.js → download-BD0ETkB-.js} +1 -1
  27. package/dist/assets/{external-link-BgErLCNT.js → external-link-kZSAO8nT.js} +1 -1
  28. package/dist/assets/{hash-Bl7dr_UG.js → hash-BHJC2Ovu.js} +1 -1
  29. package/dist/assets/{i18n-eDHeDY0n.js → i18n-CpTZLchQ.js} +1 -1
  30. package/dist/assets/index-mW8W2FUu.css +1 -0
  31. package/dist/assets/index-zDZfXoI4.js +6 -0
  32. package/dist/assets/{infiniteQueryBehavior-ZDS92Qpp.js → infiniteQueryBehavior-CyER9hv0.js} +1 -1
  33. package/dist/assets/loader-circle-Bc2gCU33.js +1 -0
  34. package/dist/assets/{logos-x89HbrZ4.js → logos-B7gRObP8.js} +1 -1
  35. package/dist/assets/marketplace-page-3qVMnF3d.js +1 -0
  36. package/dist/assets/marketplace-page-BhFIeQzI.js +49 -0
  37. package/dist/assets/mcp-marketplace-page-DYfteJ1D.js +40 -0
  38. package/dist/assets/{page-layout-vZnghcFy.js → page-layout-0UcO9H9Z.js} +1 -1
  39. package/dist/assets/play-CKDjSQFL.js +1 -0
  40. package/dist/assets/plus-CG0QrVY_.js +1 -0
  41. package/dist/assets/{refresh-ccw-DT98i__E.js → refresh-ccw-COVhNHtN.js} +1 -1
  42. package/dist/assets/{refresh-cw-C47QSEwg.js → refresh-cw-Bcv40SXy.js} +1 -1
  43. package/dist/assets/remote-access-page-CWHG-sug.js +1 -0
  44. package/dist/assets/{rotate-cw-JtFzpNn6.js → rotate-cw-oHMKJMC8.js} +1 -1
  45. package/dist/assets/{save-3S6-H3Xw.js → save-EqJPOF0G.js} +1 -1
  46. package/dist/assets/search-BCAlB8nz.js +1 -0
  47. package/dist/assets/security-config-Slh0Mayz.js +1 -0
  48. package/dist/assets/select-CVz0t7MF.js +41 -0
  49. package/dist/assets/setting-row-CbVHAuQt.js +1 -0
  50. package/dist/assets/skeleton-D5rdKvzy.js +1 -0
  51. package/dist/assets/{status-dot-vbanNPFU.js → status-dot-DpPtVzQT.js} +1 -1
  52. package/dist/assets/{switch-BsLtHOH-.js → switch-CM29eCAR.js} +1 -1
  53. package/dist/assets/{tabs-custom-D3HYMt6k.js → tabs-custom-YcZUWn3o.js} +1 -1
  54. package/dist/assets/tag-chip-DMXdnLcj.js +1 -0
  55. package/dist/assets/{trash-2-G48scll7.js → trash-2-mJT6oWa2.js} +1 -1
  56. package/dist/assets/{use-infinite-scroll-loader-DkNhD-42.js → use-infinite-scroll-loader-DJ1L81Dz.js} +1 -1
  57. package/dist/assets/{useConfirmDialog-BkvTN-vd.js → useConfirmDialog-BsVuqu1x.js} +1 -1
  58. package/dist/assets/{useMutation-CBWjE2uj.js → useMutation-CNcz2fgt.js} +1 -1
  59. package/dist/assets/x-Czwxm82I.js +1 -0
  60. package/dist/index.html +22 -22
  61. package/dist/runtime-icons/claude.ico +0 -0
  62. package/dist/runtime-icons/codex-openai.svg +6 -0
  63. package/dist/runtime-icons/hermes-agent.png +0 -0
  64. package/package.json +6 -6
  65. package/public/runtime-icons/claude.ico +0 -0
  66. package/public/runtime-icons/codex-openai.svg +6 -0
  67. package/public/runtime-icons/hermes-agent.png +0 -0
  68. package/src/account/components/account-panel.tsx +217 -97
  69. package/src/account/managers/account.manager.ts +3 -2
  70. package/src/api/chat-session-type.types.ts +7 -0
  71. package/src/api/runtime-control.types.ts +8 -0
  72. package/src/api/types.ts +8 -0
  73. package/src/app.tsx +221 -57
  74. package/src/components/agents/agent-dialogs.tsx +499 -0
  75. package/src/components/agents/agents-page.test.tsx +238 -0
  76. package/src/components/agents/agents-page.tsx +435 -0
  77. package/src/components/chat/ChatSidebar.tsx +11 -35
  78. package/src/components/chat/chat-conversation-panel.test.tsx +20 -0
  79. package/src/components/chat/chat-conversation-panel.tsx +83 -13
  80. package/src/components/chat/chat-page-shell.tsx +19 -13
  81. package/src/components/chat/chat-session-type-option-item.test.tsx +46 -0
  82. package/src/components/chat/chat-session-type-option-item.tsx +68 -0
  83. package/src/components/chat/chat-session-workspace-file-preview.test.tsx +87 -0
  84. package/src/components/chat/chat-session-workspace-file-preview.tsx +14 -43
  85. package/src/components/chat/chat-session-workspace-panel-nav.tsx +8 -2
  86. package/src/components/chat/chat-sidebar-project-groups.tsx +11 -36
  87. package/src/components/chat/ncp/__tests__/ncp-session-adapter.cancelled-tool.test.ts +77 -0
  88. package/src/components/chat/ncp/ncp-chat-page.tsx +2 -0
  89. package/src/components/chat/ncp/ncp-session-adapter.test.ts +1 -0
  90. package/src/components/chat/ncp/ncp-session-adapter.ts +3 -0
  91. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +10 -4
  92. package/src/components/chat/stores/chat-input.store.ts +2 -1
  93. package/src/components/chat/stores/chat-thread.store.ts +3 -1
  94. package/src/components/chat/useChatSessionTypeState.ts +10 -1
  95. package/src/components/chat/workspace/chat-session-workspace-file-breadcrumbs.tsx +86 -0
  96. package/src/components/common/BrandHeader.tsx +3 -1
  97. package/src/components/common/session-context-icon.tsx +15 -2
  98. package/src/components/common/{TagInput.tsx → tag-input.tsx} +25 -17
  99. package/src/components/config/ChannelForm.test.tsx +89 -3
  100. package/src/components/config/ChannelForm.tsx +157 -188
  101. package/src/components/config/ChannelsList.test.tsx +163 -119
  102. package/src/components/config/ChannelsList.tsx +90 -101
  103. package/src/components/config/ProviderForm.tsx +108 -146
  104. package/src/components/config/ProvidersList.tsx +100 -123
  105. package/src/components/config/SearchConfig.tsx +423 -393
  106. package/src/components/config/channel-form-fields-section.tsx +70 -37
  107. package/src/components/config/config-split-page.tsx +109 -0
  108. package/src/components/config/provider-enabled-field.tsx +17 -10
  109. package/src/components/config/runtime-control-card.test.tsx +56 -0
  110. package/src/components/config/runtime-control-card.tsx +25 -0
  111. package/src/components/config/runtime-presence-card.tsx +93 -79
  112. package/src/components/layout/AppLayout.tsx +25 -37
  113. package/src/components/layout/app-layout.test.tsx +46 -14
  114. package/src/components/layout/runtime-status-entry.test.tsx +157 -0
  115. package/src/components/layout/runtime-status-entry.tsx +143 -0
  116. package/src/components/marketplace/marketplace-detail-doc.ts +93 -0
  117. package/src/components/marketplace/marketplace-list-card.tsx +288 -0
  118. package/src/components/marketplace/marketplace-page-data.ts +129 -0
  119. package/src/components/marketplace/marketplace-page.test.tsx +339 -0
  120. package/src/components/marketplace/marketplace-page.tsx +596 -0
  121. package/src/components/marketplace/mcp/mcp-marketplace-card.tsx +128 -0
  122. package/src/components/marketplace/mcp/mcp-marketplace-dialogs.tsx +191 -0
  123. package/src/components/marketplace/mcp/mcp-marketplace-doc.ts +152 -0
  124. package/src/components/marketplace/mcp/mcp-marketplace-page.test.tsx +223 -0
  125. package/src/components/marketplace/mcp/mcp-marketplace-page.tsx +414 -0
  126. package/src/components/remote/remote-access-page.test.tsx +105 -0
  127. package/src/components/remote/remote-access-page.tsx +248 -0
  128. package/src/components/ui/notice-card.tsx +129 -0
  129. package/src/components/ui/setting-row.tsx +51 -0
  130. package/src/components/ui/tag-chip.tsx +39 -0
  131. package/src/components/ui/textarea.tsx +19 -0
  132. package/src/hooks/useConfig.ts +2 -1
  133. package/src/index.css +24 -0
  134. package/src/lib/app-resource-uri.test.ts +20 -0
  135. package/src/lib/app-resource-uri.ts +29 -0
  136. package/src/lib/i18n.remote.ts +1 -1
  137. package/src/lib/i18n.runtime-control.ts +31 -0
  138. package/src/lib/i18n.ts +5 -8
  139. package/src/lib/session-context.utils.test.ts +71 -0
  140. package/src/lib/session-context.utils.ts +28 -3
  141. package/src/lib/session-project/workspace-file-breadcrumb.test.ts +83 -0
  142. package/src/lib/session-project/workspace-file-breadcrumb.ts +188 -0
  143. package/dist/assets/ChannelsList-Ita2Zm1_.js +0 -8
  144. package/dist/assets/DocBrowser-BNwbPHf4.js +0 -1
  145. package/dist/assets/MarketplacePage-CjX2MWww.js +0 -1
  146. package/dist/assets/MarketplacePage-D0sDlYX4.js +0 -49
  147. package/dist/assets/McpMarketplacePage-BGKJm1sJ.js +0 -40
  148. package/dist/assets/ModelConfig-BzZenCH-.js +0 -1
  149. package/dist/assets/ProvidersList-BbVzRxjY.js +0 -1
  150. package/dist/assets/RemoteAccessPage-BaDH_X1Q.js +0 -1
  151. package/dist/assets/RuntimeConfig-F_XKGgLm.js +0 -1
  152. package/dist/assets/SearchConfig-BGkzXQP-.js +0 -1
  153. package/dist/assets/SessionsConfig-ChHQ7M5c.js +0 -2
  154. package/dist/assets/chat-page-Doe0yTtB.js +0 -58
  155. package/dist/assets/chat-session-display-cw78aiI_.js +0 -1
  156. package/dist/assets/config-layout-CHs0mAaR.js +0 -1
  157. package/dist/assets/desktop-update-config-Dpcf4BKG.js +0 -1
  158. package/dist/assets/index-CF9xve0E.js +0 -6
  159. package/dist/assets/index-FgA52VBt.css +0 -1
  160. package/dist/assets/loader-circle-ACM1s51e.js +0 -1
  161. package/dist/assets/play-CFUwCA2E.js +0 -1
  162. package/dist/assets/plus-rYsv72JG.js +0 -1
  163. package/dist/assets/popover-Bg1VoTZ6.js +0 -1
  164. package/dist/assets/search-3kFR_zh9.js +0 -1
  165. package/dist/assets/security-config-BWaiARNk.js +0 -1
  166. package/dist/assets/select-DJ2MUjBB.js +0 -41
  167. package/dist/assets/skeleton-ByQepn0M.js +0 -1
  168. package/dist/assets/x-ByDbItbq.js +0 -1
  169. package/src/components/agents/AgentDialogs.tsx +0 -400
  170. package/src/components/agents/AgentsPage.test.tsx +0 -217
  171. package/src/components/agents/AgentsPage.tsx +0 -352
  172. package/src/components/config/config-layout.ts +0 -10
  173. package/src/components/marketplace/MarketplacePage.test.tsx +0 -322
  174. package/src/components/marketplace/MarketplacePage.tsx +0 -827
  175. package/src/components/marketplace/mcp/McpMarketplacePage.test.tsx +0 -208
  176. package/src/components/marketplace/mcp/McpMarketplacePage.tsx +0 -580
  177. package/src/components/remote/RemoteAccessPage.test.tsx +0 -103
  178. package/src/components/remote/RemoteAccessPage.tsx +0 -144
@@ -0,0 +1,128 @@
1
+ import type {
2
+ MarketplaceInstalledRecord,
3
+ MarketplaceItemSummary,
4
+ } from "@/api/types";
5
+ import { Button } from "@/components/ui/button";
6
+ import { TagChip } from "@/components/ui/tag-chip";
7
+ import { t } from "@/lib/i18n";
8
+ import { readSummary, readTransportLabel } from "./mcp-marketplace-doc";
9
+
10
+ export function McpMarketplaceCard(props: {
11
+ item?: MarketplaceItemSummary;
12
+ record?: MarketplaceInstalledRecord;
13
+ localeFallbacks: string[];
14
+ onOpen: () => void;
15
+ onInstall?: () => void;
16
+ onToggle?: () => void;
17
+ onDoctor?: () => void;
18
+ onRemove?: () => void;
19
+ }) {
20
+ const {
21
+ item,
22
+ record,
23
+ localeFallbacks,
24
+ onOpen,
25
+ onInstall,
26
+ onToggle,
27
+ onDoctor,
28
+ onRemove,
29
+ } = props;
30
+ const installed = record;
31
+ const name = item?.name ?? record?.label ?? record?.id ?? "MCP";
32
+ const summary = readSummary(localeFallbacks, item, record);
33
+ const transport = readTransportLabel(item, record);
34
+ const status = installed
35
+ ? installed.enabled === false
36
+ ? t("marketplaceDisable")
37
+ : t("statusReady")
38
+ : null;
39
+
40
+ return (
41
+ <article
42
+ onClick={onOpen}
43
+ className="cursor-pointer rounded-2xl border border-gray-200/70 bg-white p-4 shadow-sm transition hover:border-blue-300 hover:shadow-md"
44
+ >
45
+ <div className="flex items-start justify-between gap-3">
46
+ <div className="min-w-0">
47
+ <div className="text-sm font-semibold text-gray-900">{name}</div>
48
+ <div className="mt-1 text-xs text-gray-500">{transport}</div>
49
+ <div className="mt-2 line-clamp-2 text-sm text-gray-600">
50
+ {summary}
51
+ </div>
52
+ <div className="mt-3 flex flex-wrap gap-2">
53
+ {(item?.tags ?? []).map((tag) => (
54
+ <TagChip key={tag}>{tag}</TagChip>
55
+ ))}
56
+ {status ? <TagChip tone="success">{status}</TagChip> : null}
57
+ </div>
58
+ </div>
59
+
60
+ <div className="flex shrink-0 flex-col gap-2">
61
+ {!installed && item && onInstall ? (
62
+ <Button
63
+ type="button"
64
+ size="sm"
65
+ variant="primary"
66
+ className="rounded-xl"
67
+ onClick={(event) => {
68
+ event.stopPropagation();
69
+ onInstall();
70
+ }}
71
+ >
72
+ {t("marketplaceInstall")}
73
+ </Button>
74
+ ) : null}
75
+
76
+ {installed ? (
77
+ <>
78
+ {onToggle ? (
79
+ <Button
80
+ type="button"
81
+ size="sm"
82
+ variant="outline"
83
+ className="rounded-xl border-gray-200 text-gray-700"
84
+ onClick={(event) => {
85
+ event.stopPropagation();
86
+ onToggle();
87
+ }}
88
+ >
89
+ {installed.enabled === false
90
+ ? t("marketplaceEnable")
91
+ : t("marketplaceDisable")}
92
+ </Button>
93
+ ) : null}
94
+ {onDoctor ? (
95
+ <Button
96
+ type="button"
97
+ size="sm"
98
+ variant="outline"
99
+ className="rounded-xl border-blue-200 text-blue-700 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-700"
100
+ onClick={(event) => {
101
+ event.stopPropagation();
102
+ onDoctor();
103
+ }}
104
+ >
105
+ {t("marketplaceMcpDoctor")}
106
+ </Button>
107
+ ) : null}
108
+ {onRemove ? (
109
+ <Button
110
+ type="button"
111
+ size="sm"
112
+ variant="outline"
113
+ className="rounded-xl border-rose-200 text-rose-600 hover:border-rose-300 hover:bg-rose-50 hover:text-rose-600"
114
+ onClick={(event) => {
115
+ event.stopPropagation();
116
+ onRemove();
117
+ }}
118
+ >
119
+ {t("marketplaceMcpRemove")}
120
+ </Button>
121
+ ) : null}
122
+ </>
123
+ ) : null}
124
+ </div>
125
+ </div>
126
+ </article>
127
+ );
128
+ }
@@ -0,0 +1,191 @@
1
+ import { useState } from "react";
2
+ import type {
3
+ MarketplaceItemSummary,
4
+ MarketplaceMcpDoctorResult,
5
+ MarketplaceMcpInstallSpec,
6
+ } from "@/api/types";
7
+ import { Button } from "@/components/ui/button";
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ } from "@/components/ui/dialog";
16
+ import { Input } from "@/components/ui/input";
17
+ import { NoticeCard } from "@/components/ui/notice-card";
18
+ import { Switch } from "@/components/ui/switch";
19
+ import { t } from "@/lib/i18n";
20
+
21
+ export function InstallDialog(props: {
22
+ item: MarketplaceItemSummary | null;
23
+ open: boolean;
24
+ pending: boolean;
25
+ onOpenChange: (open: boolean) => void;
26
+ onSubmit: (payload: {
27
+ name: string;
28
+ allAgents: boolean;
29
+ inputs: Record<string, string>;
30
+ }) => Promise<void>;
31
+ }) {
32
+ const { item, open, pending, onOpenChange, onSubmit } = props;
33
+
34
+ return (
35
+ <Dialog open={open} onOpenChange={onOpenChange}>
36
+ {item ? (
37
+ <InstallDialogContent
38
+ key={`${item.slug}:${open ? "open" : "closed"}`}
39
+ item={item}
40
+ pending={pending}
41
+ onOpenChange={onOpenChange}
42
+ onSubmit={onSubmit}
43
+ />
44
+ ) : null}
45
+ </Dialog>
46
+ );
47
+ }
48
+
49
+ function InstallDialogContent(props: {
50
+ item: MarketplaceItemSummary;
51
+ pending: boolean;
52
+ onOpenChange: (open: boolean) => void;
53
+ onSubmit: (payload: {
54
+ name: string;
55
+ allAgents: boolean;
56
+ inputs: Record<string, string>;
57
+ }) => Promise<void>;
58
+ }) {
59
+ const { item, pending, onOpenChange, onSubmit } = props;
60
+ const template = item.install as MarketplaceMcpInstallSpec | undefined;
61
+ const [name, setName] = useState(template?.defaultName ?? "");
62
+ const [allAgents, setAllAgents] = useState(true);
63
+ const [inputs, setInputs] = useState<Record<string, string>>(
64
+ Object.fromEntries(
65
+ (template?.inputs ?? []).map((field) => [
66
+ field.id,
67
+ field.defaultValue ?? "",
68
+ ]),
69
+ ),
70
+ );
71
+
72
+ return (
73
+ <DialogContent>
74
+ <DialogHeader>
75
+ <DialogTitle>{t("marketplaceMcpInstallDialogTitle")}</DialogTitle>
76
+ <DialogDescription>{item.name}</DialogDescription>
77
+ </DialogHeader>
78
+
79
+ <div className="space-y-4">
80
+ <div className="space-y-2">
81
+ <div className="text-sm font-medium text-gray-800">
82
+ {t("marketplaceMcpServerName")}
83
+ </div>
84
+ <Input
85
+ value={name}
86
+ onChange={(event) => setName(event.target.value)}
87
+ placeholder={template?.defaultName ?? "mcp-server"}
88
+ />
89
+ </div>
90
+
91
+ <div className="flex items-center justify-between rounded-xl border border-gray-200 px-3 py-3">
92
+ <div>
93
+ <div className="text-sm font-medium text-gray-900">
94
+ {t("marketplaceMcpAllAgents")}
95
+ </div>
96
+ <div className="text-xs text-gray-500">
97
+ {t("marketplaceMcpAllAgentsDescription")}
98
+ </div>
99
+ </div>
100
+ <Switch checked={allAgents} onCheckedChange={setAllAgents} />
101
+ </div>
102
+
103
+ {(template?.inputs ?? []).map((field) => (
104
+ <div key={field.id} className="space-y-2">
105
+ <div className="text-sm font-medium text-gray-800">
106
+ {field.label}
107
+ </div>
108
+ {field.description ? (
109
+ <div className="text-xs text-gray-500">{field.description}</div>
110
+ ) : null}
111
+ <Input
112
+ type={field.secret ? "password" : "text"}
113
+ value={inputs[field.id] ?? ""}
114
+ onChange={(event) =>
115
+ setInputs((current) => ({
116
+ ...current,
117
+ [field.id]: event.target.value,
118
+ }))
119
+ }
120
+ placeholder={field.defaultValue ?? ""}
121
+ />
122
+ </div>
123
+ ))}
124
+ </div>
125
+
126
+ <DialogFooter>
127
+ <Button
128
+ variant="outline"
129
+ onClick={() => onOpenChange(false)}
130
+ disabled={pending}
131
+ >
132
+ {t("cancel")}
133
+ </Button>
134
+ <Button
135
+ onClick={() => void onSubmit({ name, allAgents, inputs })}
136
+ disabled={pending || !name.trim()}
137
+ >
138
+ {pending ? t("marketplaceInstalling") : t("marketplaceInstall")}
139
+ </Button>
140
+ </DialogFooter>
141
+ </DialogContent>
142
+ );
143
+ }
144
+
145
+ export function DoctorDialog(props: {
146
+ result: MarketplaceMcpDoctorResult | null;
147
+ targetName: string | null;
148
+ open: boolean;
149
+ pending: boolean;
150
+ onOpenChange: (open: boolean) => void;
151
+ }) {
152
+ const { result, targetName, open, pending, onOpenChange } = props;
153
+
154
+ return (
155
+ <Dialog open={open} onOpenChange={onOpenChange}>
156
+ <DialogContent>
157
+ <DialogHeader>
158
+ <DialogTitle>{t("marketplaceMcpDoctorTitle")}</DialogTitle>
159
+ <DialogDescription>{targetName ?? "-"}</DialogDescription>
160
+ </DialogHeader>
161
+ {pending ? (
162
+ <div className="text-sm text-gray-500">{t("loading")}</div>
163
+ ) : null}
164
+ {!pending && result ? (
165
+ <div className="space-y-3 text-sm text-gray-700">
166
+ <div>
167
+ {t("marketplaceMcpDoctorAccessible")}:{" "}
168
+ {result.accessible
169
+ ? t("statusReady")
170
+ : t("marketplaceOperationFailed")}
171
+ </div>
172
+ <div>
173
+ {t("marketplaceMcpDoctorTransport")}:{" "}
174
+ {result.transport.toUpperCase()}
175
+ </div>
176
+ <div>
177
+ {t("marketplaceMcpDoctorTools")}: {result.toolCount}
178
+ </div>
179
+ {result.error ? (
180
+ <NoticeCard
181
+ tone="danger"
182
+ description={result.error}
183
+ className="rounded-lg"
184
+ />
185
+ ) : null}
186
+ </div>
187
+ ) : null}
188
+ </DialogContent>
189
+ </Dialog>
190
+ );
191
+ }
@@ -0,0 +1,152 @@
1
+ import type {
2
+ MarketplaceInstalledRecord,
3
+ MarketplaceItemSummary,
4
+ MarketplaceMcpInstallSpec,
5
+ } from "@/api/types";
6
+ import {
7
+ pickInstalledRecordDescription,
8
+ pickLocalizedText,
9
+ } from "@/components/marketplace/marketplace-localization";
10
+ import { t } from "@/lib/i18n";
11
+
12
+ function normalizeMarketplaceKey(value: string | undefined): string {
13
+ return (value ?? "").trim().toLowerCase();
14
+ }
15
+
16
+ export function buildInstalledRecordLookup(
17
+ records: MarketplaceInstalledRecord[],
18
+ ): Map<string, MarketplaceInstalledRecord> {
19
+ const lookup = new Map<string, MarketplaceInstalledRecord>();
20
+
21
+ for (const record of records) {
22
+ const candidates = [
23
+ record.catalogSlug,
24
+ record.spec,
25
+ record.id,
26
+ record.label,
27
+ ];
28
+ for (const candidate of candidates) {
29
+ const normalized = normalizeMarketplaceKey(candidate);
30
+ if (!normalized || lookup.has(normalized)) {
31
+ continue;
32
+ }
33
+ lookup.set(normalized, record);
34
+ }
35
+ }
36
+
37
+ return lookup;
38
+ }
39
+
40
+ export function findInstalledRecordForItem(
41
+ item: MarketplaceItemSummary,
42
+ installedRecordLookup: Map<string, MarketplaceInstalledRecord>,
43
+ ): MarketplaceInstalledRecord | undefined {
44
+ const candidates = [item.slug, item.install.spec, item.id, item.name];
45
+ for (const candidate of candidates) {
46
+ const normalized = normalizeMarketplaceKey(candidate);
47
+ if (!normalized) {
48
+ continue;
49
+ }
50
+ const record = installedRecordLookup.get(normalized);
51
+ if (record) {
52
+ return record;
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ function escape(value: string) {
59
+ return value
60
+ .replace(/&/g, "&amp;")
61
+ .replace(/</g, "&lt;")
62
+ .replace(/>/g, "&gt;")
63
+ .replace(/"/g, "&quot;")
64
+ .replace(/'/g, "&#39;");
65
+ }
66
+
67
+ export function buildDocDataUrl(
68
+ title: string,
69
+ metadata: string,
70
+ content: string,
71
+ sourceUrl?: string,
72
+ summary?: string,
73
+ ): string {
74
+ const html = `<!doctype html>
75
+ <html>
76
+ <head>
77
+ <meta charset="utf-8" />
78
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
79
+ <title>${escape(title)}</title>
80
+ <style>
81
+ body { margin: 0; background: #f8fafc; color: #0f172a; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
82
+ .wrap { max-width: 980px; margin: 0 auto; padding: 28px 20px 40px; }
83
+ .hero { border: 1px solid #dbeafe; border-radius: 16px; background: linear-gradient(180deg, #eff6ff, #ffffff); padding: 20px; }
84
+ .hero h1 { margin: 0; font-size: 26px; }
85
+ .grid { display: grid; grid-template-columns: 280px 1fr; gap: 14px; margin-top: 16px; }
86
+ .card { border: 1px solid #e2e8f0; background: #fff; border-radius: 14px; overflow: hidden; }
87
+ .card h2 { margin: 0; padding: 12px 14px; font-size: 13px; font-weight: 700; color: #1d4ed8; border-bottom: 1px solid #e2e8f0; background: #f8fafc; }
88
+ .body { padding: 12px 14px; }
89
+ pre { margin: 0; white-space: pre-wrap; line-height: 1.7; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
90
+ a { color: #2563eb; text-decoration: none; }
91
+ @media (max-width: 860px) { .grid { grid-template-columns: 1fr; } }
92
+ </style>
93
+ </head>
94
+ <body>
95
+ <main class="wrap">
96
+ <section class="hero">
97
+ <h1>${escape(title)}</h1>
98
+ ${summary ? `<p>${escape(summary)}</p>` : ""}
99
+ ${sourceUrl ? `<p><a href="${escape(sourceUrl)}" target="_blank" rel="noopener noreferrer">${escape(sourceUrl)}</a></p>` : ""}
100
+ </section>
101
+ <section class="grid">
102
+ <article class="card">
103
+ <h2>Metadata</h2>
104
+ <div class="body"><pre>${escape(metadata)}</pre></div>
105
+ </article>
106
+ <article class="card">
107
+ <h2>Content</h2>
108
+ <div class="body"><pre>${escape(content)}</pre></div>
109
+ </article>
110
+ </section>
111
+ </main>
112
+ </body>
113
+ </html>`;
114
+
115
+ return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
116
+ }
117
+
118
+ export function readSummary(
119
+ localeFallbacks: string[],
120
+ item?: MarketplaceItemSummary,
121
+ record?: MarketplaceInstalledRecord,
122
+ ): string {
123
+ const localizedSummary = pickLocalizedText(
124
+ item?.summaryI18n,
125
+ item?.summary,
126
+ localeFallbacks,
127
+ );
128
+ if (localizedSummary) {
129
+ return localizedSummary;
130
+ }
131
+
132
+ const localizedRecordDescription = pickInstalledRecordDescription(
133
+ record,
134
+ localeFallbacks,
135
+ );
136
+ return localizedRecordDescription || t("marketplaceInstalledLocalSummary");
137
+ }
138
+
139
+ export function readTransportLabel(
140
+ item?: MarketplaceItemSummary,
141
+ record?: MarketplaceInstalledRecord,
142
+ ): string {
143
+ if (record?.transport) {
144
+ return record.transport.toUpperCase();
145
+ }
146
+ const install = item?.install as MarketplaceMcpInstallSpec | undefined;
147
+ return (
148
+ (install?.transportTypes ?? [])
149
+ .map((entry) => entry.toUpperCase())
150
+ .join(" / ") || "MCP"
151
+ );
152
+ }
@@ -0,0 +1,223 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, beforeEach, expect, it, vi } from "vitest";
3
+ import { McpMarketplacePage } from "@/components/marketplace/mcp/mcp-marketplace-page";
4
+ import type {
5
+ MarketplaceInstalledView,
6
+ MarketplaceListView,
7
+ } from "@/api/types";
8
+
9
+ type ItemsQueryState = {
10
+ data?: MarketplaceListView;
11
+ isLoading: boolean;
12
+ isFetching: boolean;
13
+ isError: boolean;
14
+ error: Error | null;
15
+ };
16
+
17
+ type InstalledQueryState = {
18
+ data?: MarketplaceInstalledView;
19
+ isLoading: boolean;
20
+ isFetching: boolean;
21
+ isError: boolean;
22
+ error: Error | null;
23
+ };
24
+
25
+ const mocks = vi.hoisted(() => ({
26
+ itemsQuery: null as unknown as ItemsQueryState,
27
+ installedQuery: null as unknown as InstalledQueryState,
28
+ installMutation: {
29
+ mutateAsync: vi.fn(),
30
+ isPending: false,
31
+ },
32
+ manageMutation: {
33
+ mutateAsync: vi.fn(),
34
+ isPending: false,
35
+ },
36
+ doctorMutation: {
37
+ mutateAsync: vi.fn(),
38
+ isPending: false,
39
+ },
40
+ confirm: vi.fn(),
41
+ docOpen: vi.fn(),
42
+ }));
43
+
44
+ vi.mock("@tanstack/react-query", () => ({
45
+ useMutation: () => mocks.doctorMutation,
46
+ }));
47
+
48
+ vi.mock("@/components/providers/I18nProvider", () => ({
49
+ useI18n: () => ({
50
+ language: "zh",
51
+ setLanguage: vi.fn(),
52
+ toggleLanguage: vi.fn(),
53
+ t: (key: string) => key,
54
+ }),
55
+ }));
56
+
57
+ vi.mock("@/components/doc-browser", () => ({
58
+ useDocBrowser: () => ({
59
+ open: mocks.docOpen,
60
+ }),
61
+ }));
62
+
63
+ vi.mock("@/hooks/useConfirmDialog", () => ({
64
+ useConfirmDialog: () => ({
65
+ confirm: mocks.confirm,
66
+ ConfirmDialog: () => null,
67
+ }),
68
+ }));
69
+
70
+ vi.mock("@/hooks/useMcpMarketplace", () => ({
71
+ useMcpMarketplaceItems: () => mocks.itemsQuery,
72
+ useMcpMarketplaceInstalled: () => mocks.installedQuery,
73
+ useInstallMcpMarketplaceItem: () => mocks.installMutation,
74
+ useManageMcpMarketplaceItem: () => mocks.manageMutation,
75
+ }));
76
+
77
+ function createItemsQuery(
78
+ overrides: Partial<ItemsQueryState> = {},
79
+ ): ItemsQueryState {
80
+ return {
81
+ data: undefined,
82
+ isLoading: false,
83
+ isFetching: false,
84
+ isError: false,
85
+ error: null,
86
+ ...overrides,
87
+ };
88
+ }
89
+
90
+ function createInstalledQuery(
91
+ overrides: Partial<InstalledQueryState> = {},
92
+ ): InstalledQueryState {
93
+ return {
94
+ data: {
95
+ type: "mcp",
96
+ total: 0,
97
+ specs: [],
98
+ records: [],
99
+ },
100
+ isLoading: false,
101
+ isFetching: false,
102
+ isError: false,
103
+ error: null,
104
+ ...overrides,
105
+ };
106
+ }
107
+
108
+ describe("McpMarketplacePage", () => {
109
+ beforeEach(() => {
110
+ mocks.installMutation.mutateAsync.mockReset();
111
+ mocks.manageMutation.mutateAsync.mockReset();
112
+ mocks.doctorMutation.mutateAsync.mockReset();
113
+ mocks.confirm.mockReset();
114
+ mocks.docOpen.mockReset();
115
+ mocks.itemsQuery = createItemsQuery();
116
+ mocks.installedQuery = createInstalledQuery();
117
+ });
118
+
119
+ it("prefers localized summary copy for the active language", () => {
120
+ mocks.itemsQuery = createItemsQuery({
121
+ data: {
122
+ total: 1,
123
+ page: 1,
124
+ pageSize: 12,
125
+ totalPages: 1,
126
+ sort: "relevance",
127
+ items: [
128
+ {
129
+ id: "mcp-chrome-devtools",
130
+ slug: "chrome-devtools",
131
+ type: "mcp",
132
+ name: "Chrome DevTools MCP",
133
+ summary:
134
+ "Connect MCP clients to Chrome DevTools for browser inspection and automation.",
135
+ summaryI18n: {
136
+ en: "Connect MCP clients to Chrome DevTools for browser inspection and automation.",
137
+ zh: "把 MCP 客户端接入 Chrome DevTools,用于浏览器检查与自动化。",
138
+ },
139
+ tags: ["mcp", "browser"],
140
+ author: "Chrome DevTools",
141
+ install: {
142
+ kind: "template",
143
+ spec: "chrome-devtools",
144
+ command:
145
+ "nextclaw mcp add chrome-devtools -- npx -y chrome-devtools-mcp@latest",
146
+ },
147
+ updatedAt: "2026-03-19T00:00:00.000Z",
148
+ },
149
+ ],
150
+ },
151
+ });
152
+
153
+ render(<McpMarketplacePage />);
154
+
155
+ expect(
156
+ screen.getByText(
157
+ "把 MCP 客户端接入 Chrome DevTools,用于浏览器检查与自动化。",
158
+ ),
159
+ ).toBeTruthy();
160
+ });
161
+
162
+ it("hides install button when an installed record matches by spec without catalog slug", () => {
163
+ mocks.itemsQuery = createItemsQuery({
164
+ data: {
165
+ total: 1,
166
+ page: 1,
167
+ pageSize: 12,
168
+ totalPages: 1,
169
+ sort: "relevance",
170
+ items: [
171
+ {
172
+ id: "mcp-chrome-devtools",
173
+ slug: "chrome-devtools",
174
+ type: "mcp",
175
+ name: "Chrome DevTools MCP",
176
+ summary:
177
+ "Connect MCP clients to Chrome DevTools for browser inspection and automation.",
178
+ summaryI18n: {
179
+ en: "Connect MCP clients to Chrome DevTools for browser inspection and automation.",
180
+ zh: "把 MCP 客户端接入 Chrome DevTools,用于浏览器检查与自动化。",
181
+ },
182
+ tags: ["mcp", "browser"],
183
+ author: "Chrome DevTools",
184
+ install: {
185
+ kind: "template",
186
+ spec: "chrome-devtools",
187
+ command:
188
+ "nextclaw mcp add chrome-devtools -- npx -y chrome-devtools-mcp@latest",
189
+ },
190
+ updatedAt: "2026-03-19T00:00:00.000Z",
191
+ },
192
+ ],
193
+ },
194
+ });
195
+ mocks.installedQuery = createInstalledQuery({
196
+ data: {
197
+ type: "mcp",
198
+ total: 1,
199
+ specs: ["chrome-devtools"],
200
+ records: [
201
+ {
202
+ type: "mcp",
203
+ id: "chrome-devtools",
204
+ spec: "chrome-devtools",
205
+ label: "Chrome DevTools MCP",
206
+ enabled: true,
207
+ runtimeStatus: "enabled",
208
+ transport: "stdio",
209
+ scope: {
210
+ allAgents: true,
211
+ agents: [],
212
+ },
213
+ },
214
+ ],
215
+ },
216
+ });
217
+
218
+ render(<McpMarketplacePage />);
219
+
220
+ expect(screen.queryByText("Install")).toBeNull();
221
+ expect(screen.getByText("Disable")).toBeTruthy();
222
+ });
223
+ });