@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
@@ -0,0 +1,441 @@
1
+ import type { ReactNode } from 'react';
2
+ import { AlertCircle, FileText } from 'lucide-react';
3
+ import type { DocBrowserCustomTabRenderers } from '@/shared/components/doc-browser/doc-browser-renderer.types';
4
+ import type {
5
+ DocBrowserContextValue,
6
+ DocBrowserRouteTarget,
7
+ } from '@/shared/components/doc-browser/types/doc-browser.types';
8
+ import { Skeleton } from '@/shared/components/ui/skeleton';
9
+ import { t } from '@/shared/lib/i18n';
10
+ import { cn } from '@/shared/lib/utils';
11
+ import {
12
+ setMarketplaceDetailDocEntry,
13
+ useMarketplaceDetailDocStore,
14
+ type MarketplaceDetailDocEntry,
15
+ } from '@/features/marketplace/stores/marketplace-detail-doc.store';
16
+
17
+ export const MARKETPLACE_DETAIL_TAB_KIND = 'marketplace-detail';
18
+
19
+ type MetadataEntry = {
20
+ key: string;
21
+ value: string;
22
+ };
23
+
24
+ const DETAIL_URL_PREFIX = 'nextclaw://marketplace-detail';
25
+
26
+ export function createMarketplaceDetailDocId(scope: string, key: string): string {
27
+ return `${scope}:${key}`;
28
+ }
29
+
30
+ export function createMarketplaceDetailDocUrl(detailId: string): string {
31
+ return `${DETAIL_URL_PREFIX}/${encodeURIComponent(detailId)}`;
32
+ }
33
+
34
+ function readMarketplaceDetailDocId(url: string): string | null {
35
+ try {
36
+ const parsed = new URL(url);
37
+ if (parsed.protocol !== 'nextclaw:' || parsed.hostname !== 'marketplace-detail') {
38
+ return null;
39
+ }
40
+ const rawId = parsed.pathname.replace(/^\//, '');
41
+ return rawId ? decodeURIComponent(rawId) : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function createMarketplaceDetailDocTarget(
48
+ entry: MarketplaceDetailDocEntry,
49
+ ): DocBrowserRouteTarget {
50
+ const url = createMarketplaceDetailDocUrl(entry.id);
51
+ return {
52
+ dedupeKey: `marketplace-detail:${entry.id}`,
53
+ historyPolicy: 'managed',
54
+ kind: MARKETPLACE_DETAIL_TAB_KIND,
55
+ resourceUri: url,
56
+ title: entry.title,
57
+ url,
58
+ };
59
+ }
60
+
61
+ export function openMarketplaceDetailDoc(
62
+ docBrowser: Pick<DocBrowserContextValue, 'openTarget'>,
63
+ entry: MarketplaceDetailDocEntry,
64
+ options?: { activate?: boolean },
65
+ ): void {
66
+ setMarketplaceDetailDocEntry(entry);
67
+ docBrowser.openTarget(createMarketplaceDetailDocTarget(entry), {
68
+ activate: options?.activate,
69
+ dedupeKey: `marketplace-detail:${entry.id}`,
70
+ title: entry.title,
71
+ });
72
+ }
73
+
74
+ function stripYamlFrontmatter(text: string): string {
75
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
76
+ if (lines[0]?.trim() !== '---') {
77
+ return text;
78
+ }
79
+ const closingIndex = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
80
+ return closingIndex > 0 ? lines.slice(closingIndex + 1).join('\n').trim() : text;
81
+ }
82
+
83
+ function renderInlineMarkdown(text: string): ReactNode[] {
84
+ const nodes: ReactNode[] = [];
85
+ const tokenPattern = /(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\(https?:\/\/[^)\s]+\))/g;
86
+ let cursor = 0;
87
+ let index = 0;
88
+ for (const match of text.matchAll(tokenPattern)) {
89
+ if (match.index === undefined) {
90
+ continue;
91
+ }
92
+ if (match.index > cursor) {
93
+ nodes.push(text.slice(cursor, match.index));
94
+ }
95
+ const token = match[0];
96
+ const key = `${index}-${match.index}`;
97
+ if (token.startsWith('`')) {
98
+ nodes.push(
99
+ <code key={key} className="rounded border border-border bg-muted px-1 py-0.5 font-mono text-[0.88em] text-foreground">
100
+ {token.slice(1, -1)}
101
+ </code>,
102
+ );
103
+ } else if (token.startsWith('**')) {
104
+ nodes.push(<strong key={key}>{token.slice(2, -2)}</strong>);
105
+ } else {
106
+ const linkMatch = token.match(/^\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)$/);
107
+ nodes.push(
108
+ linkMatch ? (
109
+ <a key={key} href={linkMatch[2]} target="_blank" rel="noopener noreferrer">
110
+ {linkMatch[1]}
111
+ </a>
112
+ ) : token,
113
+ );
114
+ }
115
+ cursor = match.index + token.length;
116
+ index += 1;
117
+ }
118
+ if (cursor < text.length) {
119
+ nodes.push(text.slice(cursor));
120
+ }
121
+ return nodes;
122
+ }
123
+
124
+ function isMarkdownBlockStart(line: string): boolean {
125
+ return /^(#{1,4})\s+/.test(line)
126
+ || /^([-*])\s+/.test(line)
127
+ || /^\d+\.\s+/.test(line)
128
+ || /^>\s?/.test(line)
129
+ || /^```/.test(line);
130
+ }
131
+
132
+ function renderCodeBlock(lines: string[], index: number, fenceMatch: RegExpMatchArray) {
133
+ const codeLines: string[] = [];
134
+ let nextIndex = index + 1;
135
+ while (nextIndex < lines.length && !lines[nextIndex]?.trim().startsWith('```')) {
136
+ codeLines.push(lines[nextIndex] ?? '');
137
+ nextIndex += 1;
138
+ }
139
+ const language = fenceMatch[1] ?? '';
140
+ return {
141
+ node: (
142
+ <div key={index} className="relative my-3 overflow-hidden rounded-lg border border-border bg-muted/70">
143
+ {language ? (
144
+ <span className="absolute right-3 top-2 font-mono text-[10px] text-muted-foreground">
145
+ {language}
146
+ </span>
147
+ ) : null}
148
+ <pre className="m-0 overflow-x-auto p-3 text-xs leading-6 text-foreground">
149
+ <code>{codeLines.join('\n')}</code>
150
+ </pre>
151
+ </div>
152
+ ),
153
+ nextIndex: nextIndex + 1,
154
+ };
155
+ }
156
+
157
+ function renderListBlock(lines: string[], index: number, ordered: boolean) {
158
+ const items: string[] = [];
159
+ let nextIndex = index;
160
+ const matcher = ordered ? /^\d+\.\s+/ : /^[-*]\s+/;
161
+ while (nextIndex < lines.length && matcher.test(lines[nextIndex]?.trim() ?? '')) {
162
+ items.push((lines[nextIndex] ?? '').trim().replace(matcher, ''));
163
+ nextIndex += 1;
164
+ }
165
+ const Tag = ordered ? 'ol' : 'ul';
166
+ return {
167
+ node: (
168
+ <Tag key={index} className={cn('my-2 pl-5', ordered ? 'list-decimal' : 'list-disc')}>
169
+ {items.map((item, itemIndex) => (
170
+ <li key={`${index}-${itemIndex}`} className="my-1">
171
+ {renderInlineMarkdown(item)}
172
+ </li>
173
+ ))}
174
+ </Tag>
175
+ ),
176
+ nextIndex,
177
+ };
178
+ }
179
+
180
+ function renderHeadingNode(level: number, key: number, children: ReactNode[]) {
181
+ const className = "mb-2 mt-5 font-semibold leading-tight text-foreground";
182
+ if (level === 1) {
183
+ return <h1 key={key} className={className}>{children}</h1>;
184
+ }
185
+ if (level === 2) {
186
+ return <h2 key={key} className={className}>{children}</h2>;
187
+ }
188
+ if (level === 3) {
189
+ return <h3 key={key} className={className}>{children}</h3>;
190
+ }
191
+ return <h4 key={key} className={className}>{children}</h4>;
192
+ }
193
+
194
+ function renderMarkdownBlock(lines: string[], index: number) {
195
+ const trimmed = lines[index]?.trim() ?? '';
196
+ const fenceMatch = trimmed.match(/^```(\S*)/);
197
+ if (fenceMatch) {
198
+ return renderCodeBlock(lines, index, fenceMatch);
199
+ }
200
+ const headingMatch = trimmed.match(/^(#{1,4})\s+(.+)$/);
201
+ if (headingMatch) {
202
+ const level = Math.min(4, headingMatch[1]?.length ?? 2);
203
+ return {
204
+ node: renderHeadingNode(level, index, renderInlineMarkdown(headingMatch[2] ?? '')),
205
+ nextIndex: index + 1,
206
+ };
207
+ }
208
+ if (/^[-*]\s+/.test(trimmed)) {
209
+ return renderListBlock(lines, index, false);
210
+ }
211
+ if (/^\d+\.\s+/.test(trimmed)) {
212
+ return renderListBlock(lines, index, true);
213
+ }
214
+ if (/^>\s?/.test(trimmed)) {
215
+ const quotes: string[] = [];
216
+ let nextIndex = index;
217
+ while (nextIndex < lines.length && /^>\s?/.test(lines[nextIndex]?.trim() ?? '')) {
218
+ quotes.push((lines[nextIndex] ?? '').trim().replace(/^>\s?/, ''));
219
+ nextIndex += 1;
220
+ }
221
+ return {
222
+ node: (
223
+ <blockquote key={index} className="my-3 rounded-lg border-l-4 border-primary/50 bg-muted/60 px-3 py-2 text-muted-foreground">
224
+ {renderInlineMarkdown(quotes.join(' '))}
225
+ </blockquote>
226
+ ),
227
+ nextIndex,
228
+ };
229
+ }
230
+
231
+ const paragraphLines: string[] = [];
232
+ let nextIndex = index;
233
+ while (nextIndex < lines.length) {
234
+ const paragraphLine = lines[nextIndex]?.trim() ?? '';
235
+ if (!paragraphLine || isMarkdownBlockStart(paragraphLine)) {
236
+ break;
237
+ }
238
+ paragraphLines.push(paragraphLine);
239
+ nextIndex += 1;
240
+ }
241
+ return {
242
+ node: <p key={index}>{renderInlineMarkdown(paragraphLines.join(' '))}</p>,
243
+ nextIndex,
244
+ };
245
+ }
246
+
247
+ function renderMarkdown(markdown: string): ReactNode[] {
248
+ const lines = stripYamlFrontmatter(markdown).replace(/\r\n/g, '\n').split('\n');
249
+ const blocks: ReactNode[] = [];
250
+ let index = 0;
251
+ while (index < lines.length) {
252
+ if (!(lines[index] ?? '').trim()) {
253
+ index += 1;
254
+ continue;
255
+ }
256
+ const result = renderMarkdownBlock(lines, index);
257
+ blocks.push(result.node);
258
+ index = result.nextIndex;
259
+ }
260
+ return blocks;
261
+ }
262
+
263
+ function stringifyMetadataValue(value: unknown): string {
264
+ if (Array.isArray(value)) {
265
+ const values = value
266
+ .map((entry) => stringifyMetadataValue(entry))
267
+ .filter((entry) => entry.trim().length > 0);
268
+ return values.join(', ');
269
+ }
270
+ if (value && typeof value === 'object') {
271
+ return '';
272
+ }
273
+ return String(value ?? '');
274
+ }
275
+
276
+ function readJsonMetadata(raw: string): MetadataEntry[] {
277
+ try {
278
+ const parsed = JSON.parse(raw) as unknown;
279
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
280
+ return [];
281
+ }
282
+ return Object.entries(parsed)
283
+ .map(([key, value]) => ({ key, value: stringifyMetadataValue(value) }))
284
+ .filter((entry) => entry.value.trim().length > 0);
285
+ } catch {
286
+ return [];
287
+ }
288
+ }
289
+
290
+ function readYamlLikeMetadata(raw: string): MetadataEntry[] {
291
+ return raw
292
+ .replace(/\r\n/g, '\n')
293
+ .split('\n')
294
+ .map((line) => line.match(/^([A-Za-z0-9_.-]+):\s*(.+)$/))
295
+ .filter((match): match is RegExpMatchArray => Boolean(match))
296
+ .map((match) => ({
297
+ key: match[1] ?? '',
298
+ value: match[2]?.replace(/^["']|["']$/g, '') ?? '',
299
+ }))
300
+ .filter((entry) => entry.key && entry.value.trim().length > 0);
301
+ }
302
+
303
+ function readMetadata(raw: string): MetadataEntry[] {
304
+ const jsonEntries = readJsonMetadata(raw);
305
+ return jsonEntries.length > 0 ? jsonEntries : readYamlLikeMetadata(raw);
306
+ }
307
+
308
+ function MarketplaceDetailSkeleton() {
309
+ return (
310
+ <div className="space-y-4 p-5">
311
+ <section className="rounded-xl border border-border bg-card p-5">
312
+ <Skeleton className="h-7 w-1/2" />
313
+ <Skeleton className="mt-3 h-4 w-3/4" />
314
+ <Skeleton className="mt-4 h-4 w-full" />
315
+ <Skeleton className="mt-3 h-4 w-2/3" />
316
+ </section>
317
+ <section className="grid gap-4 lg:grid-cols-[minmax(220px,0.42fr)_minmax(0,1fr)]">
318
+ <Skeleton className="h-56 rounded-xl" />
319
+ <Skeleton className="h-56 rounded-xl" />
320
+ </section>
321
+ </div>
322
+ );
323
+ }
324
+
325
+ function MetadataView({ raw }: { raw: string }) {
326
+ const entries = readMetadata(raw);
327
+ if (entries.length === 0) {
328
+ return <pre className="m-0 whitespace-pre-wrap break-words font-mono text-xs leading-6">{raw}</pre>;
329
+ }
330
+ return (
331
+ <dl className="m-0">
332
+ {entries.map((entry) => (
333
+ <div key={entry.key} className="grid grid-cols-[minmax(104px,0.34fr)_minmax(0,1fr)] gap-3 border-b border-border/70 py-2 last:border-b-0">
334
+ <dt className="break-words font-semibold text-primary">{entry.key}</dt>
335
+ <dd className="m-0 break-words text-card-foreground">{entry.value}</dd>
336
+ </div>
337
+ ))}
338
+ </dl>
339
+ );
340
+ }
341
+
342
+ function DetailSection({ title, children }: { title: string; children: ReactNode }) {
343
+ return (
344
+ <article className="overflow-hidden rounded-xl border border-border bg-card shadow-sm">
345
+ <h2 className="border-b border-border bg-muted/40 px-4 py-3 text-sm font-semibold text-card-foreground">
346
+ {title}
347
+ </h2>
348
+ <div className="p-4 text-sm leading-7 text-card-foreground">{children}</div>
349
+ </article>
350
+ );
351
+ }
352
+
353
+ function MarketplaceDetailUnavailable() {
354
+ return (
355
+ <div className="flex h-full items-center justify-center bg-background p-6 text-center text-muted-foreground">
356
+ <div className="max-w-sm">
357
+ <AlertCircle className="mx-auto mb-3 h-7 w-7 text-muted-foreground" />
358
+ <h2 className="text-base font-semibold text-foreground">{t('marketplaceDetailUnavailableTitle')}</h2>
359
+ <p className="mt-2 text-sm leading-6">{t('marketplaceDetailUnavailableDescription')}</p>
360
+ </div>
361
+ </div>
362
+ );
363
+ }
364
+
365
+ function MarketplaceDetailDocContent({ entry }: { entry: MarketplaceDetailDocEntry }) {
366
+ const metadata = entry.metadataRaw?.trim() || '-';
367
+ const content = entry.contentRaw?.trim() || '-';
368
+ const summary = entry.summary?.trim();
369
+ const description = entry.description?.trim();
370
+ const shouldShowDescription = Boolean(description) && description !== summary;
371
+
372
+ return (
373
+ <main className="h-full overflow-auto bg-background text-foreground custom-scrollbar">
374
+ <div className="mx-auto max-w-[940px] space-y-4 p-5 pb-9">
375
+ <section className="rounded-xl border border-border bg-card p-5 shadow-sm">
376
+ <div className="flex items-start gap-3">
377
+ <div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-primary">
378
+ <FileText className="h-4 w-4" />
379
+ </div>
380
+ <div className="min-w-0">
381
+ <h1 className="break-words text-2xl font-semibold leading-tight text-foreground">{entry.title}</h1>
382
+ <p className="mt-2 break-words text-xs text-muted-foreground">
383
+ {entry.typeLabel} · {entry.spec}
384
+ {entry.author ? ` · ${entry.author}` : ''}
385
+ </p>
386
+ </div>
387
+ </div>
388
+ {summary ? <p className="mt-4 text-sm leading-7 text-card-foreground">{summary}</p> : null}
389
+ {shouldShowDescription ? <p className="mt-3 text-sm leading-7 text-card-foreground">{description}</p> : null}
390
+ {entry.tags && entry.tags.length > 0 ? (
391
+ <div className="mt-4 flex flex-wrap gap-2">
392
+ {entry.tags.map((tag) => (
393
+ <span key={tag} className="rounded-full border border-border bg-muted px-3 py-1 text-xs text-muted-foreground">
394
+ {tag}
395
+ </span>
396
+ ))}
397
+ </div>
398
+ ) : null}
399
+ {entry.sourceUrl ? (
400
+ <p className="mt-4 break-all text-xs text-muted-foreground">
401
+ {entry.sourceLabel ?? t('marketplaceDetailSource')}: {' '}
402
+ <a href={entry.sourceUrl} target="_blank" rel="noopener noreferrer" className="font-medium text-primary hover:underline">
403
+ {entry.sourceUrl}
404
+ </a>
405
+ </p>
406
+ ) : null}
407
+ </section>
408
+ <section className="grid grid-cols-[repeat(auto-fit,minmax(min(300px,100%),1fr))] gap-4">
409
+ <DetailSection title={t('marketplaceDetailMetadata')}>
410
+ <MetadataView raw={metadata} />
411
+ </DetailSection>
412
+ <DetailSection title={t('marketplaceDetailContent')}>
413
+ <div className="marketplace-detail-markdown space-y-3 text-sm leading-7 text-card-foreground [&_a]:font-medium [&_a]:text-primary [&_a:hover]:underline">
414
+ {renderMarkdown(content)}
415
+ </div>
416
+ </DetailSection>
417
+ </section>
418
+ </div>
419
+ </main>
420
+ );
421
+ }
422
+
423
+ function MarketplaceDetailDoc({ currentUrl }: { currentUrl: string }) {
424
+ const detailId = readMarketplaceDetailDocId(currentUrl);
425
+ const entry = useMarketplaceDetailDocStore((state) => (detailId ? state.entries[detailId] : undefined));
426
+ if (!detailId || !entry) {
427
+ return <MarketplaceDetailUnavailable />;
428
+ }
429
+ if (entry.status === 'loading') {
430
+ return <MarketplaceDetailSkeleton />;
431
+ }
432
+ return <MarketplaceDetailDocContent entry={entry} />;
433
+ }
434
+
435
+ export const MARKETPLACE_DETAIL_DOC_BROWSER_RENDERERS: DocBrowserCustomTabRenderers = {
436
+ [MARKETPLACE_DETAIL_TAB_KIND]: {
437
+ getTitle: (tab) => tab.title,
438
+ renderContent: ({ currentUrl }) => <MarketplaceDetailDoc currentUrl={currentUrl} />,
439
+ renderIcon: () => <FileText className="h-4 w-4 text-primary" />,
440
+ },
441
+ };
@@ -39,7 +39,7 @@ const mocks = vi.hoisted(() => ({
39
39
  isPending: false,
40
40
  },
41
41
  confirm: vi.fn(),
42
- docOpen: vi.fn(),
42
+ docOpenTarget: vi.fn(),
43
43
  }));
44
44
 
45
45
  vi.mock("@tanstack/react-query", async (importOriginal) => {
@@ -61,7 +61,7 @@ vi.mock("@/app/components/i18n-provider", () => ({
61
61
 
62
62
  vi.mock("@/shared/components/doc-browser", () => ({
63
63
  useDocBrowser: () => ({
64
- open: mocks.docOpen,
64
+ openTarget: mocks.docOpenTarget,
65
65
  }),
66
66
  }));
67
67
 
@@ -116,7 +116,7 @@ describe("McpMarketplacePage", () => {
116
116
  mocks.manageMutation.mutateAsync.mockReset();
117
117
  mocks.doctorMutation.mutateAsync.mockReset();
118
118
  mocks.confirm.mockReset();
119
- mocks.docOpen.mockReset();
119
+ mocks.docOpenTarget.mockReset();
120
120
  mocks.itemsQuery = createItemsQuery();
121
121
  mocks.installedQuery = createInstalledQuery();
122
122
  });
@@ -55,66 +55,6 @@ export function findInstalledRecordForItem(
55
55
  return undefined;
56
56
  }
57
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
58
  export function readSummary(
119
59
  localeFallbacks: string[],
120
60
  item?: MarketplaceItemSummary,
@@ -20,11 +20,14 @@ import {
20
20
  import { buildLocaleFallbacks } from "@/features/marketplace/components/marketplace-localization";
21
21
  import { McpMarketplaceCard } from "@/features/marketplace/components/mcp/mcp-marketplace-card";
22
22
  import {
23
- buildDocDataUrl,
24
23
  buildInstalledRecordLookup,
25
24
  findInstalledRecordForItem,
26
25
  readSummary,
27
26
  } from "@/features/marketplace/components/mcp/mcp-marketplace-doc";
27
+ import {
28
+ createMarketplaceDetailDocId,
29
+ openMarketplaceDetailDoc,
30
+ } from "@/features/marketplace/components/marketplace-detail-doc";
28
31
  import {
29
32
  DoctorDialog,
30
33
  InstallDialog,
@@ -133,45 +136,77 @@ export function McpMarketplacePage() {
133
136
  record?: MarketplaceInstalledRecord,
134
137
  ) => {
135
138
  const title = item?.name ?? record?.label ?? record?.id ?? "MCP";
136
- const dedupeKey = item
137
- ? `marketplace:mcp:${item.slug}`
138
- : `marketplace:mcp:${record?.id ?? record?.spec ?? title}`;
139
- const openOptions = { title, kind: "content" as const, dedupeKey };
139
+ const detailId = createMarketplaceDetailDocId(
140
+ item ? "mcp" : "mcp-local",
141
+ item?.slug ?? record?.id ?? record?.spec ?? title,
142
+ );
140
143
  const summary = readSummary(localeFallbacks, item, record);
141
144
  if (!item) {
142
- const url = buildDocDataUrl(
145
+ openMarketplaceDetailDoc(docBrowser, {
146
+ id: detailId,
143
147
  title,
144
- JSON.stringify(record ?? {}, null, 2),
145
- t("marketplaceInstalledLocalSummary"),
146
- record?.docsUrl,
148
+ typeLabel: t("marketplaceTypeMcp"),
149
+ spec: record?.spec ?? "-",
150
+ status: "ready",
147
151
  summary,
148
- );
149
- docBrowser.open(url, openOptions);
152
+ metadataRaw: JSON.stringify(record ?? {}, null, 2),
153
+ contentRaw: t("marketplaceInstalledLocalSummary"),
154
+ sourceUrl: record?.docsUrl,
155
+ sourceLabel: t("marketplaceDetailSource"),
156
+ });
150
157
  return;
151
158
  }
159
+
160
+ openMarketplaceDetailDoc(docBrowser, {
161
+ id: detailId,
162
+ title,
163
+ typeLabel: t("marketplaceTypeMcp"),
164
+ spec: item.install.spec,
165
+ status: "loading",
166
+ summary,
167
+ tags: item.tags,
168
+ author: item.author,
169
+ });
170
+
152
171
  try {
153
172
  const content = await fetchMcpMarketplaceContent(item.slug);
154
- const url = buildDocDataUrl(
155
- title,
156
- content.metadataRaw || JSON.stringify(item, null, 2),
157
- content.bodyRaw || content.raw,
158
- content.sourceUrl,
159
- summary,
173
+ openMarketplaceDetailDoc(
174
+ docBrowser,
175
+ {
176
+ id: detailId,
177
+ title,
178
+ typeLabel: t("marketplaceTypeMcp"),
179
+ spec: item.install.spec,
180
+ status: "ready",
181
+ summary,
182
+ metadataRaw: content.metadataRaw || JSON.stringify(item, null, 2),
183
+ contentRaw: content.bodyRaw || content.raw,
184
+ sourceUrl: content.sourceUrl,
185
+ sourceLabel: t("marketplaceDetailSource"),
186
+ tags: item.tags,
187
+ author: item.author,
188
+ },
189
+ { activate: false },
160
190
  );
161
- docBrowser.open(url, openOptions);
162
191
  } catch (error) {
163
- const url = buildDocDataUrl(
164
- title,
165
- JSON.stringify(
166
- { error: error instanceof Error ? error.message : String(error) },
167
- null,
168
- 2,
169
- ),
170
- summary,
171
- undefined,
172
- summary,
192
+ openMarketplaceDetailDoc(
193
+ docBrowser,
194
+ {
195
+ id: detailId,
196
+ title,
197
+ typeLabel: t("marketplaceTypeMcp"),
198
+ spec: item.install.spec,
199
+ status: "error",
200
+ summary,
201
+ metadataRaw: JSON.stringify(
202
+ { error: error instanceof Error ? error.message : String(error) },
203
+ null,
204
+ 2,
205
+ ),
206
+ contentRaw: summary,
207
+ },
208
+ { activate: false },
173
209
  );
174
- docBrowser.open(url, openOptions);
175
210
  }
176
211
  };
177
212