@nextclaw/ui 0.14.4 → 0.15.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 (194) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/assets/api-DLtsiBKm.js +13 -0
  3. package/dist/assets/appearance-settings-page-CtkeYXux.js +1 -0
  4. package/dist/assets/book-open-BQwWBDKb.js +1 -0
  5. package/dist/assets/channels-list-page-Cv8o9Hxg.js +8 -0
  6. package/dist/assets/chat-page-BtA7H1Ea.js +1 -0
  7. package/dist/assets/{config-split-page-CRhlZ_pH.js → config-split-page-EidRBpP2.js} +1 -1
  8. package/dist/assets/confirm-dialog-iIJFFp2T.js +5 -0
  9. package/dist/assets/desktop-update-config-C6n_TB1r.js +1 -0
  10. package/dist/assets/{dist-CikkhOlq.js → dist-CBxWOazz.js} +1 -1
  11. package/dist/assets/{dist-PVA_xL1D.js → dist-D2eZC3OR.js} +1 -1
  12. package/dist/assets/doc-browser-B76XOXmW.js +1 -0
  13. package/dist/assets/doc-browser-DBnj9UMB.js +1 -0
  14. package/dist/assets/{doc-browser-context-MTw4EvKd.js → doc-browser-context-Sv-044Cl.js} +1 -1
  15. package/dist/assets/{ellipsis-y6vDtq4M.js → ellipsis-2uzhypw5.js} +1 -1
  16. package/dist/assets/es2015-DhQo_oS6.js +41 -0
  17. package/dist/assets/{external-link-DVJIQf_G.js → external-link-CM5dnUI6.js} +1 -1
  18. package/dist/assets/host-capabilities-9bB_998Z.js +1 -0
  19. package/dist/assets/i18n-provider-di1waTGX.js +1 -0
  20. package/dist/assets/index-CBRP-7-a.css +1 -0
  21. package/dist/assets/index-Z1fYpbNd.js +104 -0
  22. package/dist/assets/loader-circle-BjmN5DeJ.js +1 -0
  23. package/dist/assets/mcp-marketplace-page-C_MVQUbT.js +9 -0
  24. package/dist/assets/mcp-marketplace-page-CoZIiD6G.js +1 -0
  25. package/dist/assets/model-config-page-Cdr9P6TQ.js +1 -0
  26. package/dist/assets/plus-3ztRZD_l.js +1 -0
  27. package/dist/assets/provider-scoped-model-input-g1WkGiJ8.js +1 -0
  28. package/dist/assets/providers-config-page-CWlO7J4A.js +1 -0
  29. package/dist/assets/{react-VNqT5Zp7.js → react-DjpzoZXB.js} +1 -1
  30. package/dist/assets/refresh-cw-DaTv3zxE.js +1 -0
  31. package/dist/assets/remote-CKWZfVVB.js +1 -0
  32. package/dist/assets/rotate-cw-CHcaRUDz.js +1 -0
  33. package/dist/assets/runtime-config-page-CfTqh1nv.js +1 -0
  34. package/dist/assets/{save-BOJ477H-.js → save-BZof1uie.js} +1 -1
  35. package/dist/assets/{search-BEti5beA.js → search-MxAarS_g.js} +1 -1
  36. package/dist/assets/search-config-page-Dm8daYny.js +1 -0
  37. package/dist/assets/{secrets-config-page--IzrIOid.js → secrets-config-page-D8AbcR05.js} +2 -2
  38. package/dist/assets/security-config-Ds0LsJmo.js +1 -0
  39. package/dist/assets/select-D7GcNP0z.js +1 -0
  40. package/dist/assets/setting-row-CuozlnJd.js +1 -0
  41. package/dist/assets/side-dock-BcLau5S_.js +3 -0
  42. package/dist/assets/skeleton-shfmXuYp.js +1 -0
  43. package/dist/assets/switch-C1BWMU5c.js +1 -0
  44. package/dist/assets/tag-chip-DCUrQUCP.js +1 -0
  45. package/dist/assets/tooltip-DfAHHA76.js +1 -0
  46. package/dist/assets/use-config-DAdbT5QJ.js +1 -0
  47. package/dist/assets/use-confirm-dialog-eWcJ6V-s.js +1 -0
  48. package/dist/assets/x-DqL1mYLC.js +1 -0
  49. package/dist/index.html +53 -17
  50. package/index.html +25 -2
  51. package/package.json +9 -9
  52. package/src/app/components/layout/__tests__/app-layout.test.tsx +27 -0
  53. package/src/app/components/layout/__tests__/settings-entry-page.test.tsx +9 -1
  54. package/src/app/components/layout/__tests__/sidebar.layout.test.tsx +6 -3
  55. package/src/app/components/layout/app-layout.tsx +10 -3
  56. package/src/app/components/layout/sidebar-items.tsx +8 -8
  57. package/src/app/components/layout/sidebar-rail.styles.ts +4 -2
  58. package/src/app/components/layout/sidebar.tsx +21 -28
  59. package/src/app/configs/app-navigation.config.ts +11 -5
  60. package/src/app/index.tsx +8 -0
  61. package/src/app/managers/__tests__/viewport-layout.manager.test.ts +59 -0
  62. package/src/app/managers/viewport-layout.manager.ts +39 -0
  63. package/src/app/presenters/app.presenter.ts +25 -2
  64. package/src/app/styles/design-system.css +91 -4
  65. package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +86 -0
  66. package/src/features/chat/components/conversation/chat-conversation-content.tsx +47 -17
  67. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +7 -0
  68. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +9 -5
  69. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +5 -5
  70. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +51 -2
  71. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +170 -1
  72. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +23 -1
  73. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +24 -7
  74. package/src/features/chat/features/conversation/components/session-queued-input-rows.tsx +96 -0
  75. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +181 -0
  76. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +351 -89
  77. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +29 -4
  78. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +28 -3
  79. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +108 -8
  80. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +50 -14
  81. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +77 -6
  82. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +21 -12
  83. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +166 -0
  84. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +19 -11
  85. package/src/features/chat/features/message/components/chat-message-list.container.tsx +38 -64
  86. package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +59 -0
  87. package/src/features/chat/features/message/utils/__tests__/chat-message-show-content-tool-card.utils.test.ts +56 -1
  88. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +0 -2
  89. package/src/features/chat/features/message/utils/chat-message-process-summary.utils.ts +80 -0
  90. package/src/features/chat/features/message/utils/chat-message-show-content-tool-card.utils.ts +12 -3
  91. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +60 -0
  92. package/src/features/chat/features/message/utils/chat-message.utils.ts +3 -0
  93. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +39 -0
  94. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +2 -2
  95. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +1 -1
  96. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-header-actions.test.tsx +3 -1
  97. package/src/features/chat/features/session/components/session-header/chat-session-metadata-dialog.tsx +1 -1
  98. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +270 -115
  99. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumb-browser.tsx +121 -0
  100. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +75 -20
  101. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +70 -5
  102. package/src/features/chat/features/workspace/utils/chat-workspace-file-tab-persistence.utils.ts +83 -0
  103. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +79 -1
  104. package/src/features/chat/managers/__tests__/chat-ui.manager.test.ts +2 -0
  105. package/src/features/chat/managers/chat-thread.manager.ts +9 -3
  106. package/src/features/chat/managers/chat-ui.manager.ts +5 -1
  107. package/src/features/chat/presenters/chat.presenter.ts +2 -1
  108. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +4 -0
  109. package/src/features/chat/stores/chat-thread.store.ts +6 -64
  110. package/src/features/chat/types/chat-message.types.ts +4 -0
  111. package/src/features/marketplace/components/__tests__/marketplace-page-detail.test.tsx +33 -26
  112. package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +1 -0
  113. package/src/features/marketplace/components/curated-shelves/__tests__/marketplace-curated-scene-route.test.tsx +1 -0
  114. package/src/features/marketplace/components/detail-doc/__tests__/marketplace-detail-doc.test.tsx +115 -0
  115. package/src/features/marketplace/components/marketplace-detail-doc.tsx +441 -0
  116. package/src/features/marketplace/components/mcp/__tests__/mcp-marketplace-page.test.tsx +3 -3
  117. package/src/features/marketplace/components/mcp/mcp-marketplace-doc.ts +0 -60
  118. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +64 -29
  119. package/src/features/marketplace/hooks/use-marketplace-item-detail.ts +42 -31
  120. package/src/features/marketplace/index.ts +3 -0
  121. package/src/features/marketplace/stores/marketplace-detail-doc.store.ts +38 -0
  122. package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +32 -2
  123. package/src/features/pwa/managers/__tests__/pwa-shell-theme.manager.test.ts +11 -0
  124. package/src/features/pwa/managers/pwa-shell-theme.manager.ts +8 -3
  125. package/src/features/settings/pages/__tests__/appearance-settings-page.test.tsx +25 -0
  126. package/src/features/settings/pages/appearance-settings-page.tsx +40 -0
  127. package/src/features/side-dock/components/__tests__/side-dock.test.tsx +44 -2
  128. package/src/features/side-dock/components/side-dock.tsx +100 -13
  129. package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +10 -0
  130. package/src/features/side-dock/managers/__tests__/side-dock.manager.test.ts +32 -2
  131. package/src/features/side-dock/managers/side-dock.manager.ts +17 -7
  132. package/src/features/side-dock/stores/side-dock.store.ts +9 -0
  133. package/src/features/side-dock/types/side-dock.types.ts +10 -3
  134. package/src/features/side-dock/utils/side-dock-item.utils.ts +15 -2
  135. package/src/index.css +501 -27
  136. package/src/platforms/desktop/components/desktop-window-chrome.tsx +1 -1
  137. package/src/shared/components/doc-browser/__tests__/doc-browser-context.test.tsx +30 -1
  138. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +206 -38
  139. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +34 -11
  140. package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +1 -1
  141. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +23 -5
  142. package/src/shared/components/doc-browser/doc-browser.tsx +128 -75
  143. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +9 -1
  144. package/src/shared/components/doc-browser/utils/doc-browser-floating-panel.utils.ts +83 -0
  145. package/src/shared/components/ui/__tests__/dialog.test.tsx +27 -0
  146. package/src/shared/components/ui/actions/icon-action-button.tsx +14 -2
  147. package/src/shared/components/ui/dialog.tsx +2 -2
  148. package/src/shared/components/ui/popover.tsx +1 -1
  149. package/src/shared/components/ui/select.tsx +1 -1
  150. package/src/shared/components/ui/switch.tsx +4 -3
  151. package/src/shared/components/ui/tab-strip/__tests__/compact-tab-strip.test.tsx +54 -0
  152. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +4 -5
  153. package/src/shared/components/ui/tooltip.tsx +1 -1
  154. package/src/shared/lib/api/utils/server-path.utils.ts +27 -1
  155. package/src/shared/lib/i18n/locales/en-US/chat.json +15 -6
  156. package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
  157. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +11 -0
  158. package/src/shared/lib/i18n/locales/en-US/marketplace.json +5 -0
  159. package/src/shared/lib/i18n/locales/zh-CN/chat.json +15 -6
  160. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
  161. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +11 -0
  162. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +5 -0
  163. package/src/shared/lib/session-project/__tests__/workspace-file-breadcrumb.test.ts +14 -0
  164. package/src/shared/lib/session-project/index.ts +1 -1
  165. package/src/shared/lib/session-project/{workspace-file-breadcrumb.ts → workspace-file-breadcrumb.utils.ts} +62 -8
  166. package/src/shared/lib/theme/index.ts +51 -13
  167. package/dist/assets/api-BP_8IaxB.js +0 -13
  168. package/dist/assets/channels-list-page-DEh5Jjqx.js +0 -8
  169. package/dist/assets/chat-page-BRB0N5WW.js +0 -105
  170. package/dist/assets/confirm-dialog-DsivFJ4r.js +0 -5
  171. package/dist/assets/desktop-update-config-B0w9p2ep.js +0 -1
  172. package/dist/assets/doc-browser-BoNJ7-tE.js +0 -1
  173. package/dist/assets/doc-browser-CdJ_d4ik.js +0 -1
  174. package/dist/assets/index-BiE-K8-q.css +0 -1
  175. package/dist/assets/index-D9BAE_L_.js +0 -105
  176. package/dist/assets/mcp-marketplace-page-B1SZNY5m.js +0 -40
  177. package/dist/assets/mcp-marketplace-page-Cq_Tw42T.js +0 -1
  178. package/dist/assets/model-config-page-DotaTUHb.js +0 -1
  179. package/dist/assets/plus-BbZqquJe.js +0 -1
  180. package/dist/assets/provider-scoped-model-input-DsFRuWJH.js +0 -1
  181. package/dist/assets/providers-config-page-DW4tQSLN.js +0 -1
  182. package/dist/assets/remote-B8TiUfwW.js +0 -1
  183. package/dist/assets/rotate-cw-DjHDTkCv.js +0 -1
  184. package/dist/assets/runtime-config-page-S6ErRHR8.js +0 -1
  185. package/dist/assets/search-config-page-DGj5am03.js +0 -1
  186. package/dist/assets/select-hoj8dK4K.js +0 -41
  187. package/dist/assets/tag-chip-T8-h-die.js +0 -1
  188. package/dist/assets/use-config-CjRV2hwL.js +0 -1
  189. package/dist/assets/x-D9PXs_xP.js +0 -1
  190. package/src/features/marketplace/components/detail-doc/__tests__/marketplace-detail-doc.test.ts +0 -40
  191. package/src/features/marketplace/components/detail-doc/marketplace-detail-doc-renderer.ts +0 -201
  192. package/src/features/marketplace/components/marketplace-detail-doc.ts +0 -142
  193. /package/dist/assets/{config-hints-BkYBc7z0.js → config-hints-fGnUjDe9.js} +0 -0
  194. /package/dist/assets/{middleware-CkFT3R4v.js → middleware-BILWbJE4.js} +0 -0
@@ -1,11 +1,15 @@
1
1
  import { create } from 'zustand';
2
2
  import { createJSONStorage, persist } from 'zustand/middleware';
3
3
  import type { NcpMessage } from '@nextclaw/ncp';
4
- import type { ChatFileOperationLineViewModel } from '@nextclaw/agent-chat-ui';
4
+ import type { ChatFileOperationLineViewModel, ChatFilePreviewViewer } from '@nextclaw/agent-chat-ui';
5
5
  import type {
6
6
  SessionContextWindowView,
7
7
  SessionTypeIconView
8
8
  } from '@/shared/lib/api';
9
+ import {
10
+ normalizePersistedWorkspaceFileTab,
11
+ toPersistedWorkspaceFileTab,
12
+ } from '@/features/chat/features/workspace/utils/chat-workspace-file-tab-persistence.utils';
9
13
 
10
14
  export type ChatChildSessionTab = {
11
15
  sessionKey: string;
@@ -25,6 +29,7 @@ export type ChatWorkspaceFileTab = {
25
29
  path: string;
26
30
  label?: string | null;
27
31
  viewMode: 'preview' | 'diff';
32
+ previewViewer?: ChatFilePreviewViewer | null;
28
33
  line?: number | null;
29
34
  column?: number | null;
30
35
  rawText?: string | null;
@@ -146,12 +151,6 @@ function normalizeOptionalString(value: unknown): string | null {
146
151
  : null;
147
152
  }
148
153
 
149
- function normalizeOptionalNumber(value: unknown): number | null {
150
- return typeof value === 'number' && Number.isFinite(value)
151
- ? value
152
- : null;
153
- }
154
-
155
154
  function normalizeHistoryIndex(value: unknown, maxIndex: number): number {
156
155
  return typeof value === 'number' && Number.isInteger(value)
157
156
  ? Math.min(Math.max(0, value), maxIndex)
@@ -210,63 +209,6 @@ function createWorkspaceNavigationEntryFromSnapshot({
210
209
  return null;
211
210
  }
212
211
 
213
- function normalizeOptionalText(value: unknown): string | null {
214
- return typeof value === 'string' ? value : null;
215
- }
216
-
217
- function normalizePersistedWorkspaceFileTab(
218
- value: unknown,
219
- ): ChatWorkspaceFileTab | null {
220
- if (
221
- !isRecord(value)
222
- || typeof value.key !== 'string'
223
- || typeof value.path !== 'string'
224
- || (value.viewMode !== 'preview' && value.viewMode !== 'diff')
225
- ) {
226
- return null;
227
- }
228
- const key = value.key.trim();
229
- const path = value.path.trim();
230
- if (!key || !path) {
231
- return null;
232
- }
233
- return {
234
- key,
235
- parentSessionKey: normalizeOptionalString(value.parentSessionKey),
236
- path,
237
- label: normalizeOptionalString(value.label),
238
- viewMode: value.viewMode,
239
- line: normalizeOptionalNumber(value.line),
240
- column: normalizeOptionalNumber(value.column),
241
- rawText: normalizeOptionalText(value.rawText),
242
- beforeText: normalizeOptionalText(value.beforeText),
243
- afterText: normalizeOptionalText(value.afterText),
244
- patchText: normalizeOptionalText(value.patchText),
245
- oldStartLine: normalizeOptionalNumber(value.oldStartLine),
246
- newStartLine: normalizeOptionalNumber(value.newStartLine),
247
- };
248
- }
249
-
250
- function toPersistedWorkspaceFileTab(
251
- tab: ChatWorkspaceFileTab,
252
- ): ChatWorkspaceFileTab {
253
- return {
254
- key: tab.key,
255
- parentSessionKey: tab.parentSessionKey,
256
- path: tab.path,
257
- label: tab.label,
258
- viewMode: tab.viewMode,
259
- line: tab.line,
260
- column: tab.column,
261
- rawText: tab.rawText,
262
- beforeText: tab.beforeText,
263
- afterText: tab.afterText,
264
- patchText: tab.patchText,
265
- oldStartLine: tab.oldStartLine,
266
- newStartLine: tab.newStartLine,
267
- };
268
- }
269
-
270
212
  function normalizePersistedWorkspaceSnapshot(
271
213
  value: unknown,
272
214
  ): PersistedChatWorkspaceSnapshot | null {
@@ -23,6 +23,10 @@ export type ChatMessageAdapterTexts = {
23
23
  unknownPartLabel: string;
24
24
  };
25
25
 
26
+ export type ChatMessageProcessSummarySource = {
27
+ label: string;
28
+ };
29
+
26
30
  export type ChatMessagePartSource =
27
31
  | {
28
32
  type: "text";
@@ -1,6 +1,7 @@
1
1
  import { render, screen, waitFor } from "@testing-library/react";
2
2
  import userEvent from "@testing-library/user-event";
3
3
  import { MarketplacePage } from "@/features/marketplace";
4
+ import { useMarketplaceDetailDocStore } from "@/features/marketplace/stores/marketplace-detail-doc.store";
4
5
  import type { MarketplaceInstalledView, MarketplaceItemSummary, MarketplaceListView } from "@/shared/lib/api";
5
6
 
6
7
  type ItemsQueryState = {
@@ -21,7 +22,7 @@ type InstalledQueryState = {
21
22
 
22
23
  const mocks = vi.hoisted(() => ({
23
24
  navigate: vi.fn(),
24
- docOpen: vi.fn(),
25
+ docOpenTarget: vi.fn(),
25
26
  itemsQuery: null as unknown as ItemsQueryState,
26
27
  installedQuery: null as unknown as InstalledQueryState,
27
28
  fetchMarketplaceSkillContent: vi.fn(),
@@ -38,7 +39,7 @@ vi.mock("react-router-dom", async () => {
38
39
 
39
40
  vi.mock("@/shared/components/doc-browser", () => ({
40
41
  useDocBrowser: () => ({
41
- open: mocks.docOpen,
42
+ openTarget: mocks.docOpenTarget,
42
43
  }),
43
44
  }));
44
45
 
@@ -137,7 +138,7 @@ function createInstalledQuery(): InstalledQueryState {
137
138
  describe("MarketplacePage detail loading", () => {
138
139
  beforeEach(() => {
139
140
  mocks.navigate.mockReset();
140
- mocks.docOpen.mockReset();
141
+ mocks.docOpenTarget.mockReset();
141
142
  mocks.fetchMarketplaceSkillContent.mockReset();
142
143
  mocks.itemsQuery = createItemsQuery([createMarketplaceItem()]);
143
144
  mocks.installedQuery = createInstalledQuery();
@@ -165,13 +166,15 @@ describe("MarketplacePage detail loading", () => {
165
166
 
166
167
  await user.click(screen.getByText("Web Search"));
167
168
 
168
- expect(mocks.docOpen).toHaveBeenCalledTimes(1);
169
- const loadingDetailHtml = decodeURIComponent(String(mocks.docOpen.mock.calls[0]?.[0]));
170
- expect(loadingDetailHtml).toContain('aria-busy="true"');
171
- expect(loadingDetailHtml).not.toContain("Loading");
172
- expect(mocks.docOpen.mock.calls[0]?.[1]).toMatchObject({
173
- dedupeKey: "marketplace:skill:web-search",
174
- kind: "content",
169
+ expect(mocks.docOpenTarget).toHaveBeenCalledTimes(1);
170
+ expect(mocks.docOpenTarget.mock.calls[0]?.[0]).toMatchObject({
171
+ dedupeKey: "marketplace-detail:skill:web-search",
172
+ kind: "marketplace-detail",
173
+ title: "Web Search",
174
+ url: "nextclaw://marketplace-detail/skill%3Aweb-search",
175
+ });
176
+ expect(useMarketplaceDetailDocStore.getState().entries["skill:web-search"]).toMatchObject({
177
+ status: "loading",
175
178
  title: "Web Search",
176
179
  });
177
180
 
@@ -184,11 +187,15 @@ describe("MarketplacePage detail loading", () => {
184
187
  });
185
188
 
186
189
  await waitFor(() => {
187
- expect(mocks.docOpen).toHaveBeenCalledTimes(2);
190
+ expect(mocks.docOpenTarget).toHaveBeenCalledTimes(2);
188
191
  });
189
- expect(mocks.docOpen.mock.calls[1]?.[1]).toMatchObject({
192
+ expect(mocks.docOpenTarget.mock.calls[1]?.[1]).toMatchObject({
190
193
  activate: false,
191
- dedupeKey: "marketplace:skill:web-search",
194
+ dedupeKey: "marketplace-detail:skill:web-search",
195
+ });
196
+ expect(useMarketplaceDetailDocStore.getState().entries["skill:web-search"]).toMatchObject({
197
+ contentRaw: "Use this skill to search the web.",
198
+ status: "ready",
192
199
  });
193
200
  });
194
201
 
@@ -259,20 +266,20 @@ describe("MarketplacePage detail loading", () => {
259
266
  });
260
267
 
261
268
  await waitFor(() => {
262
- expect(mocks.docOpen).toHaveBeenCalledTimes(4);
269
+ expect(mocks.docOpenTarget).toHaveBeenCalledTimes(4);
263
270
  });
264
271
 
265
- const webSearchCalls = mocks.docOpen.mock.calls.filter(
266
- (call) => call[1]?.dedupeKey === "marketplace:skill:web-search",
272
+ const webSearchCalls = mocks.docOpenTarget.mock.calls.filter(
273
+ (call) => call[0]?.dedupeKey === "marketplace-detail:skill:web-search",
267
274
  );
268
- const browserCalls = mocks.docOpen.mock.calls.filter(
269
- (call) => call[1]?.dedupeKey === "marketplace:skill:bb-browser",
275
+ const browserCalls = mocks.docOpenTarget.mock.calls.filter(
276
+ (call) => call[0]?.dedupeKey === "marketplace-detail:skill:bb-browser",
270
277
  );
271
278
 
272
279
  expect(webSearchCalls).toHaveLength(2);
273
280
  expect(browserCalls).toHaveLength(2);
274
- expect(String(webSearchCalls[1]?.[0])).toContain("Web%20Search");
275
- expect(String(browserCalls[1]?.[0])).toContain("BB%20Browser");
281
+ expect(useMarketplaceDetailDocStore.getState().entries["skill:web-search"]?.contentRaw).toContain("search the web");
282
+ expect(useMarketplaceDetailDocStore.getState().entries["skill:bb-browser"]?.contentRaw).toContain("browser workflow");
276
283
  });
277
284
 
278
285
  it("ignores stale responses for the same skill detail key", async () => {
@@ -327,16 +334,16 @@ describe("MarketplacePage detail loading", () => {
327
334
  });
328
335
 
329
336
  await waitFor(() => {
330
- expect(mocks.docOpen).toHaveBeenCalledTimes(3);
337
+ expect(mocks.docOpenTarget).toHaveBeenCalledTimes(3);
331
338
  });
332
339
 
333
- expect(String(mocks.docOpen.mock.calls[2]?.[0])).toContain(
334
- "Latest%20content",
340
+ expect(useMarketplaceDetailDocStore.getState().entries["skill:web-search"]?.contentRaw).toContain(
341
+ "Latest content",
335
342
  );
336
- expect(mocks.docOpen.mock.calls[2]?.[1]).toMatchObject({
343
+ expect(mocks.docOpenTarget.mock.calls[2]?.[1]).toMatchObject({
337
344
  activate: false,
338
- dedupeKey: "marketplace:skill:web-search",
345
+ dedupeKey: "marketplace-detail:skill:web-search",
339
346
  });
340
- expect(String(mocks.docOpen.mock.calls[2]?.[0])).not.toContain("Stale");
347
+ expect(useMarketplaceDetailDocStore.getState().entries["skill:web-search"]?.contentRaw).not.toContain("Stale");
341
348
  });
342
349
  });
@@ -57,6 +57,7 @@ vi.mock("react-router-dom", async () => {
57
57
  vi.mock("@/shared/components/doc-browser", () => ({
58
58
  useDocBrowser: () => ({
59
59
  open: mocks.docOpen,
60
+ openTarget: mocks.docOpen,
60
61
  }),
61
62
  }));
62
63
 
@@ -53,6 +53,7 @@ vi.mock("react-router-dom", async () => {
53
53
  vi.mock("@/shared/components/doc-browser", () => ({
54
54
  useDocBrowser: () => ({
55
55
  open: mocks.docOpen,
56
+ openTarget: mocks.docOpen,
56
57
  }),
57
58
  }));
58
59
 
@@ -0,0 +1,115 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import {
3
+ createMarketplaceDetailDocId,
4
+ createMarketplaceDetailDocUrl,
5
+ MARKETPLACE_DETAIL_DOC_BROWSER_RENDERERS,
6
+ MARKETPLACE_DETAIL_TAB_KIND,
7
+ } from "@/features/marketplace/components/marketplace-detail-doc";
8
+ import { setMarketplaceDetailDocEntry } from "@/features/marketplace/stores/marketplace-detail-doc.store";
9
+
10
+ function renderDetail(detailId: string) {
11
+ const renderer = MARKETPLACE_DETAIL_DOC_BROWSER_RENDERERS[MARKETPLACE_DETAIL_TAB_KIND];
12
+ return render(
13
+ <>
14
+ {renderer?.renderContent?.({
15
+ currentUrl: createMarketplaceDetailDocUrl(detailId),
16
+ open: vi.fn(),
17
+ openTarget: vi.fn(),
18
+ refreshIframe: vi.fn(),
19
+ tab: {
20
+ id: "tab-1",
21
+ kind: MARKETPLACE_DETAIL_TAB_KIND,
22
+ title: "Weather Skill",
23
+ currentUrl: createMarketplaceDetailDocUrl(detailId),
24
+ history: [createMarketplaceDetailDocUrl(detailId)],
25
+ historyIndex: 0,
26
+ navVersion: 0,
27
+ },
28
+ })}
29
+ </>,
30
+ );
31
+ }
32
+
33
+ describe("Marketplace detail doc renderer", () => {
34
+ it("renders metadata and markdown content as React content", () => {
35
+ const detailId = createMarketplaceDetailDocId("skill", "weather");
36
+ setMarketplaceDetailDocEntry({
37
+ id: detailId,
38
+ title: "Weather Skill",
39
+ typeLabel: "Skill",
40
+ spec: "@nextclaw/weather",
41
+ status: "ready",
42
+ metadataRaw: "name: weather\ndescription: Local weather skill",
43
+ contentRaw: "# Weather Skill\n\nUse **weather** with `city`.\n\n- Local forecast\n- Severe alerts",
44
+ });
45
+
46
+ renderDetail(detailId);
47
+
48
+ expect(screen.getAllByRole("heading", { name: "Weather Skill", level: 1 })).toHaveLength(2);
49
+ expect(screen.getByText("name")).toBeTruthy();
50
+ expect(screen.getAllByText("weather")).toHaveLength(2);
51
+ expect(screen.getByText("description")).toBeTruthy();
52
+ expect(screen.getByText("city")).toBeTruthy();
53
+ expect(screen.getByText("Local forecast")).toBeTruthy();
54
+ expect(screen.getByText("Severe alerts")).toBeTruthy();
55
+ });
56
+
57
+ it("treats unsafe HTML as text, skips nested metadata, and preserves safe links", () => {
58
+ const detailId = createMarketplaceDetailDocId("skill", "unsafe");
59
+ setMarketplaceDetailDocEntry({
60
+ id: detailId,
61
+ title: "Unsafe Skill",
62
+ typeLabel: "Skill",
63
+ spec: "@nextclaw/unsafe",
64
+ status: "ready",
65
+ metadataRaw: '{"name":"unsafe","nested":{"script":"<script>alert(1)</script>"}}',
66
+ contentRaw: "[safe](https://example.com) <script>alert(1)</script>",
67
+ });
68
+
69
+ const { container } = renderDetail(detailId);
70
+
71
+ expect(screen.queryByText("nested")).toBeNull();
72
+ expect(screen.queryByText('{"script":"<script>alert(1)</script>"}')).toBeNull();
73
+ expect(screen.getByRole("link", { name: "safe" }).getAttribute("href")).toBe("https://example.com");
74
+ expect(container.querySelector("script")).toBeNull();
75
+ expect(screen.getAllByText(/<script>alert\(1\)<\/script>/)).toHaveLength(1);
76
+ });
77
+
78
+ it("renders loading and missing states inside the React panel", () => {
79
+ const loadingId = createMarketplaceDetailDocId("skill", "loading");
80
+ setMarketplaceDetailDocEntry({
81
+ id: loadingId,
82
+ title: "Loading Skill",
83
+ typeLabel: "Skill",
84
+ spec: "@nextclaw/loading",
85
+ status: "loading",
86
+ });
87
+
88
+ const { rerender } = renderDetail(loadingId);
89
+ expect(document.querySelector(".animate-pulse")).toBeTruthy();
90
+
91
+ const missingId = createMarketplaceDetailDocId("skill", "missing");
92
+ const renderer = MARKETPLACE_DETAIL_DOC_BROWSER_RENDERERS[MARKETPLACE_DETAIL_TAB_KIND];
93
+ rerender(
94
+ <>
95
+ {renderer?.renderContent?.({
96
+ currentUrl: createMarketplaceDetailDocUrl(missingId),
97
+ open: vi.fn(),
98
+ openTarget: vi.fn(),
99
+ refreshIframe: vi.fn(),
100
+ tab: {
101
+ id: "tab-1",
102
+ kind: MARKETPLACE_DETAIL_TAB_KIND,
103
+ title: "Missing",
104
+ currentUrl: createMarketplaceDetailDocUrl(missingId),
105
+ history: [createMarketplaceDetailDocUrl(missingId)],
106
+ historyIndex: 0,
107
+ navVersion: 0,
108
+ },
109
+ })}
110
+ </>,
111
+ );
112
+
113
+ expect(screen.getByText("Detail unavailable")).toBeTruthy();
114
+ });
115
+ });