@nextclaw/ui 0.13.4 → 0.13.6

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 (142) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/assets/api-BCDb13Uv.js +8 -0
  3. package/dist/assets/app-presenter-provider-C-QMjK7m.js +3 -0
  4. package/dist/assets/arrow-left-rIXFA2Zy.js +1 -0
  5. package/dist/assets/channels-list-page-DnqwsKGe.js +8 -0
  6. package/dist/assets/chat-page-CsQx20xL.js +105 -0
  7. package/dist/assets/config-split-page-BwhGqmUS.js +1 -0
  8. package/dist/assets/confirm-dialog-DBkKcQ1q.js +5 -0
  9. package/dist/assets/desktop-update-config-BE9KjiXi.js +1 -0
  10. package/dist/assets/{dist-CpwDZDtv.js → dist-BI0bX8jv.js} +1 -1
  11. package/dist/assets/dist-C8JRt-tf.js +1 -0
  12. package/dist/assets/doc-browser-context-VHUOIUXL.js +1 -0
  13. package/dist/assets/doc-browser-eqdNEerh.js +1 -0
  14. package/dist/assets/doc-browser-lpF_pyx4.js +1 -0
  15. package/dist/assets/ellipsis-C9VB2CxT.js +1 -0
  16. package/dist/assets/es2015-BCw3Q7nY.js +41 -0
  17. package/dist/assets/external-link-Bp7_4s9t.js +1 -0
  18. package/dist/assets/index-BCq7kbC8.css +1 -0
  19. package/dist/assets/index-DTdPDqto.js +101 -0
  20. package/dist/assets/key-round-Ccy6oND3.js +1 -0
  21. package/dist/assets/loader-circle-OpH4EErP.js +1 -0
  22. package/dist/assets/{mcp-marketplace-page-Dvi8mCBd.js → mcp-marketplace-page-B-GxKOqS.js} +2 -2
  23. package/dist/assets/mcp-marketplace-page-Dm-0-w51.js +1 -0
  24. package/dist/assets/model-config-BXPnV_7n.js +1 -0
  25. package/dist/assets/notice-card-Dsbkl7u7.js +1 -0
  26. package/dist/assets/play-CvgYhNbo.js +1 -0
  27. package/dist/assets/plus-DHcP-K1U.js +1 -0
  28. package/dist/assets/popover-BTLZE5Iz.js +1 -0
  29. package/dist/assets/provider-models-C_yOh6DE.js +1 -0
  30. package/dist/assets/provider-scoped-model-input-DKI9KN7N.js +1 -0
  31. package/dist/assets/providers-list-Bko6B7dE.js +1 -0
  32. package/dist/assets/react-BqS8oMyM.js +8 -0
  33. package/dist/assets/refresh-cw-BIaj7txb.js +1 -0
  34. package/dist/assets/remote-DHn_b8QU.js +1 -0
  35. package/dist/assets/rotate-cw-DMqSkRJQ.js +1 -0
  36. package/dist/assets/runtime-config-page-DX_wSK1J.js +1 -0
  37. package/dist/assets/save-BEoORten.js +1 -0
  38. package/dist/assets/search-COFh_SFI.js +1 -0
  39. package/dist/assets/{search-config-G2O1fcRb.js → search-config-DA5By-tE.js} +1 -1
  40. package/dist/assets/{secrets-config-TenzxYbE.js → secrets-config-eDxG7iN4.js} +2 -2
  41. package/dist/assets/{status-dot-BSMG7ZaC.js → status-dot-oWBjTovo.js} +1 -1
  42. package/dist/assets/{tabs-D1CjpWNX.js → tabs-CGaY0BVO.js} +1 -1
  43. package/dist/assets/{tabs-custom-CRSN1ekg.js → tabs-custom-D2fPYb4G.js} +1 -1
  44. package/dist/assets/{tag-chip-DEashiw6.js → tag-chip-TVkgofS1.js} +1 -1
  45. package/dist/assets/tooltip-BwHPKBv9.js +1 -0
  46. package/dist/assets/trash-2-D3psAGrz.js +1 -0
  47. package/dist/assets/x-CHAxzyp2.js +1 -0
  48. package/dist/index.html +25 -23
  49. package/dist/logos/mimo.svg +9 -0
  50. package/package.json +8 -8
  51. package/public/logos/mimo.svg +9 -0
  52. package/src/app/components/layout/app-layout.tsx +10 -2
  53. package/src/features/agents/components/agents-page.tsx +6 -4
  54. package/src/features/chat/components/chat-welcome.test.tsx +15 -0
  55. package/src/features/chat/components/chat-welcome.tsx +4 -7
  56. package/src/features/chat/hooks/use-ncp-chat-page-data.ts +17 -10
  57. package/src/features/marketplace/components/marketplace-list-card.tsx +22 -1
  58. package/src/features/marketplace/components/marketplace-page.tsx +3 -1
  59. package/src/features/marketplace/hooks/use-marketplace.ts +3 -1
  60. package/src/features/marketplace/utils/marketplace-installed-cache.utils.ts +8 -0
  61. package/src/features/right-panel-resources/configs/right-panel-resource-routes.config.ts +70 -6
  62. package/src/features/right-panel-resources/utils/right-panel-resource-route-resolver.utils.test.ts +25 -1
  63. package/src/features/right-panel-resources/utils/right-panel-resource-route-resolver.utils.ts +1 -1
  64. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.test.ts +42 -0
  65. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +22 -0
  66. package/src/features/service-apps/components/service-apps-panel.test.tsx +88 -0
  67. package/src/features/service-apps/components/service-apps-panel.tsx +246 -56
  68. package/src/features/service-apps/hooks/use-service-apps.ts +14 -0
  69. package/src/features/side-dock/components/side-dock.test.tsx +58 -3
  70. package/src/features/side-dock/components/side-dock.tsx +77 -24
  71. package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +37 -34
  72. package/src/features/side-dock/index.ts +2 -0
  73. package/src/features/side-dock/managers/side-dock.manager.test.ts +84 -3
  74. package/src/features/side-dock/managers/side-dock.manager.ts +135 -12
  75. package/src/features/side-dock/types/side-dock.types.ts +11 -4
  76. package/src/features/side-dock/utils/side-dock-item.utils.test.ts +8 -8
  77. package/src/features/side-dock/utils/side-dock-item.utils.ts +13 -5
  78. package/src/platforms/desktop/components/desktop-app-shell.tsx +5 -2
  79. package/src/platforms/mobile/components/mobile-app-shell.tsx +4 -1
  80. package/src/shared/components/config/provider-form-support.test.ts +21 -0
  81. package/src/shared/components/config/provider-form-support.ts +38 -78
  82. package/src/shared/components/config/provider-form.tsx +54 -67
  83. package/src/shared/components/config/providers-list.tsx +211 -73
  84. package/src/shared/components/doc-browser/doc-browser-context.test.tsx +73 -0
  85. package/src/shared/components/doc-browser/doc-browser-context.tsx +3 -0
  86. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +184 -98
  87. package/src/shared/components/doc-browser/doc-browser.test.tsx +168 -43
  88. package/src/shared/components/doc-browser/doc-browser.tsx +38 -7
  89. package/src/shared/components/doc-browser/index.ts +1 -0
  90. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +30 -6
  91. package/src/shared/components/doc-browser/stores/doc-browser.store.ts +29 -0
  92. package/src/shared/components/doc-browser/types/doc-browser.types.ts +23 -0
  93. package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +7 -0
  94. package/src/shared/components/model-config.tsx +5 -4
  95. package/src/shared/components/provider-auth-section.tsx +2 -2
  96. package/src/shared/components/ui/switch.tsx +5 -2
  97. package/src/shared/hooks/use-config.ts +27 -6
  98. package/src/shared/lib/api/marketplace.types.ts +193 -0
  99. package/src/shared/lib/api/types.ts +23 -200
  100. package/src/shared/lib/api/utils/config.utils.ts +17 -7
  101. package/src/shared/lib/i18n/index.ts +10 -12
  102. package/src/shared/lib/i18n/{marketplace.ts → marketplace-labels.utils.ts} +3 -0
  103. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +46 -2
  104. package/src/shared/lib/provider-models/index.test.ts +93 -15
  105. package/src/shared/lib/provider-models/index.ts +35 -20
  106. package/src/shared/lib/transport/request-raw-api-response.utils.ts +1 -1
  107. package/dist/assets/api-DruAcori.js +0 -15
  108. package/dist/assets/app-presenter-provider-DIcnNB9s.js +0 -3
  109. package/dist/assets/book-open-CRotBOF2.js +0 -1
  110. package/dist/assets/boxes-BvA-I63l.js +0 -1
  111. package/dist/assets/channels-list-page-wK3l7p5R.js +0 -8
  112. package/dist/assets/chat-page-DgehvdJT.js +0 -105
  113. package/dist/assets/config-split-page-BnTO1mAM.js +0 -1
  114. package/dist/assets/confirm-dialog-CGrKHbAW.js +0 -5
  115. package/dist/assets/createLucideIcon-Br1EFMEr.js +0 -1
  116. package/dist/assets/desktop-update-config-CehWxSSg.js +0 -1
  117. package/dist/assets/dist-rzakR_bu.js +0 -41
  118. package/dist/assets/doc-browser-BgxmVsTx.js +0 -1
  119. package/dist/assets/doc-browser-CZn5-Qbu.js +0 -1
  120. package/dist/assets/doc-browser-context-B8gk7HMr.js +0 -1
  121. package/dist/assets/external-link-DHjFdCpy.js +0 -1
  122. package/dist/assets/index-DORmh0Cy.css +0 -1
  123. package/dist/assets/index-DcFpvnge.js +0 -101
  124. package/dist/assets/key-round-D41Oe35x.js +0 -1
  125. package/dist/assets/mcp-marketplace-page-DBLhJWOJ.js +0 -1
  126. package/dist/assets/model-config-B-EJXGtO.js +0 -1
  127. package/dist/assets/play-C-0vo7hi.js +0 -1
  128. package/dist/assets/plus-Cr_akLp2.js +0 -1
  129. package/dist/assets/popover-YieElO8c.js +0 -1
  130. package/dist/assets/provider-scoped-model-input-BPRNGqYn.js +0 -1
  131. package/dist/assets/providers-list-CtBpL1ZY.js +0 -1
  132. package/dist/assets/react-B2X_ph0a.js +0 -1
  133. package/dist/assets/remote-BGHbHoHf.js +0 -1
  134. package/dist/assets/rotate-cw-jueVCNJQ.js +0 -1
  135. package/dist/assets/runtime-config-page-C1Hv7mad.js +0 -1
  136. package/dist/assets/save-aYdSdK97.js +0 -1
  137. package/dist/assets/search-CdhgFNwf.js +0 -1
  138. package/dist/assets/setting-row-BNu0qhOM.js +0 -1
  139. package/dist/assets/trash-2-DSWTsARW.js +0 -1
  140. package/dist/assets/x-D1v_vb54.js +0 -1
  141. package/src/shared/components/provider-enabled-field.tsx +0 -23
  142. /package/dist/assets/{config-hints-A7G_jMTU.js → config-hints-Ceiol9x4.js} +0 -0
@@ -19,6 +19,7 @@ import { cn } from "@/shared/lib/utils";
19
19
  import {
20
20
  CheckCircle2,
21
21
  Download,
22
+ RefreshCw,
22
23
  Trash2,
23
24
  } from "lucide-react";
24
25
 
@@ -35,6 +36,7 @@ type MarketplaceListCardActionProps = {
35
36
  record?: MarketplaceInstalledRecord;
36
37
  isInstalling: boolean;
37
38
  isDisabled: boolean;
39
+ canUpdate: boolean;
38
40
  canUninstall: boolean;
39
41
  busyAction?: MarketplaceManageAction;
40
42
  busyForRecord: boolean;
@@ -96,6 +98,7 @@ function MarketplaceListCardActions(props: MarketplaceListCardActionProps) {
96
98
  record,
97
99
  isInstalling,
98
100
  isDisabled,
101
+ canUpdate,
99
102
  canUninstall,
100
103
  busyAction,
101
104
  busyForRecord,
@@ -103,7 +106,7 @@ function MarketplaceListCardActions(props: MarketplaceListCardActionProps) {
103
106
  onInstall,
104
107
  onManage,
105
108
  } = props;
106
- const hasActions = Boolean((item && !record) || (record && canUninstall));
109
+ const hasActions = Boolean((item && !record) || (record && (canUpdate || canUninstall)));
107
110
 
108
111
  return (
109
112
  <div
@@ -148,6 +151,22 @@ function MarketplaceListCardActions(props: MarketplaceListCardActionProps) {
148
151
  </button>
149
152
  )}
150
153
 
154
+ {record && canUpdate && (
155
+ <button
156
+ disabled={busyForRecord}
157
+ onClick={(event) => {
158
+ event.stopPropagation();
159
+ onManage("update", record);
160
+ }}
161
+ className="inline-flex h-8 items-center gap-1.5 whitespace-nowrap rounded-xl border border-blue-200/80 bg-white px-3 text-xs font-medium text-blue-600 transition-colors hover:border-blue-300 hover:bg-blue-50 disabled:opacity-50"
162
+ >
163
+ <RefreshCw className="h-3.5 w-3.5" />
164
+ {busyAction === "update"
165
+ ? t("marketplaceUpdating")
166
+ : t("marketplaceUpdate")}
167
+ </button>
168
+ )}
169
+
151
170
  {record && canUninstall && (
152
171
  <button
153
172
  disabled={busyForRecord}
@@ -241,6 +260,7 @@ export function MarketplaceListCard(props: {
241
260
  : undefined;
242
261
  const busyForRecord = Boolean(busyAction);
243
262
  const canUninstall = record?.type === "skill" && record.source === "workspace";
263
+ const canUpdate = record?.type === "skill" && record.origin === "marketplace";
244
264
  const isDisabled = record
245
265
  ? record.enabled === false || record.runtimeStatus === "disabled"
246
266
  : false;
@@ -269,6 +289,7 @@ export function MarketplaceListCard(props: {
269
289
  record={record}
270
290
  isInstalling={isInstalling}
271
291
  isDisabled={isDisabled}
292
+ canUpdate={canUpdate}
272
293
  canUninstall={canUninstall}
273
294
  busyAction={busyAction}
274
295
  busyForRecord={busyForRecord}
@@ -282,7 +282,9 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
282
282
  action: MarketplaceManageAction,
283
283
  record: MarketplaceInstalledRecord,
284
284
  ) => {
285
- const targetId = record.id || record.spec;
285
+ const targetId = action === "update"
286
+ ? record.catalogSlug || record.id || record.spec
287
+ : record.id || record.spec;
286
288
  if (!targetId) {
287
289
  return;
288
290
  }
@@ -178,7 +178,9 @@ export function useManageMarketplaceItem() {
178
178
  ? t('marketplaceEnableSuccess')
179
179
  : result.action === 'disable'
180
180
  ? t('marketplaceDisableSuccess')
181
- : t('marketplaceUninstallSuccess');
181
+ : result.action === 'update'
182
+ ? t('marketplaceUpdateSuccess')
183
+ : t('marketplaceUninstallSuccess');
182
184
  toast.success(result.message || fallback);
183
185
  },
184
186
  onError: (error: Error) => {
@@ -133,6 +133,14 @@ export function applyManageResultToInstalledView(params: {
133
133
  };
134
134
  }
135
135
 
136
+ if (params.result.action === 'update') {
137
+ return {
138
+ ...record,
139
+ installedAt: new Date().toISOString(),
140
+ origin: record.origin ?? 'marketplace'
141
+ };
142
+ }
143
+
136
144
  return {
137
145
  ...record,
138
146
  enabled: true,
@@ -1,5 +1,5 @@
1
1
  import { t } from '@/shared/lib/i18n';
2
- import type { ParsedResourceUri, ResourceUriRouteDefinition } from '@/shared/lib/resource-uri';
2
+ import { parseResourceUri, type ParsedResourceUri, type ResourceUriRouteDefinition } from '@/shared/lib/resource-uri';
3
3
  import {
4
4
  getDefaultDocsUrl,
5
5
  inferTabTitle,
@@ -32,9 +32,67 @@ function resolveNextclawDocsUrl(uri: ParsedResourceUri): string {
32
32
  return normalizeUrlByKind(rawPath, 'docs');
33
33
  }
34
34
 
35
+ function createDocsResourceUriFromSegments(pathSegments: string[]): string {
36
+ const path = pathSegments
37
+ .filter((segment) => segment.trim().length > 0)
38
+ .map((segment) => encodeURIComponent(decodeURIComponent(segment)))
39
+ .join('/');
40
+ return path ? `nextclaw://docs/${path}` : 'nextclaw://docs';
41
+ }
42
+
43
+ function createDocsResourceUriFromUrl(url: string): string {
44
+ const normalized = normalizeDocUrl(url);
45
+ const pathname = normalized.startsWith('/')
46
+ ? normalized
47
+ : (() => {
48
+ try {
49
+ return new URL(url).pathname;
50
+ } catch {
51
+ return '';
52
+ }
53
+ })();
54
+ if (!pathname) {
55
+ return 'nextclaw://docs';
56
+ }
57
+ return createDocsResourceUriFromSegments(pathname.split('/'));
58
+ }
59
+
60
+ function getPanelAppId(uri: ParsedResourceUri): string {
61
+ if (uri.scheme === 'nextclaw' && uri.authority === 'panel-app') {
62
+ return uri.pathSegments[0] ?? '';
63
+ }
64
+ const [apiSegment, collectionSegment, appId, contentSegment] = uri.pathSegments;
65
+ return apiSegment === 'api'
66
+ && collectionSegment === 'panel-apps'
67
+ && appId !== undefined
68
+ && appId.length > 0
69
+ && contentSegment === 'content'
70
+ ? appId
71
+ : '';
72
+ }
73
+
74
+ function isPanelAppContentUri(uri: ParsedResourceUri): boolean {
75
+ return getPanelAppId(uri).length > 0;
76
+ }
77
+
35
78
  function resolvePanelAppPlaceholderUrl(uri: ParsedResourceUri): string {
36
- const appId = uri.pathSegments[0] ?? '';
37
- return appId ? `nextclaw://panel-app/${encodeURIComponent(appId)}` : 'nextclaw://panel-app';
79
+ const appId = getPanelAppId(uri);
80
+ return appId ? `/api/panel-apps/${encodeURIComponent(decodeURIComponent(appId))}/content` : 'nextclaw://panel-app';
81
+ }
82
+
83
+ function createPanelAppResourceUri(uri: ParsedResourceUri): string {
84
+ const appId = getPanelAppId(uri);
85
+ return appId ? `nextclaw://panel-app/${encodeURIComponent(decodeURIComponent(appId))}` : 'nextclaw://panel-app';
86
+ }
87
+
88
+ function arePanelAppUrlsEquivalent(left: string, right: string): boolean {
89
+ const leftUri = parseResourceUri(left);
90
+ const rightUri = parseResourceUri(right);
91
+ const leftAppId = getPanelAppId(leftUri);
92
+ const rightAppId = getPanelAppId(rightUri);
93
+ return leftAppId.length > 0 && rightAppId.length > 0
94
+ ? decodeURIComponent(leftAppId) === decodeURIComponent(rightAppId)
95
+ : left === right;
38
96
  }
39
97
 
40
98
  export const RIGHT_PANEL_RESOURCE_ROUTE_DEFINITIONS: RightPanelResourceRouteDefinition[] = [
@@ -46,6 +104,7 @@ export const RIGHT_PANEL_RESOURCE_ROUTE_DEFINITIONS: RightPanelResourceRouteDefi
46
104
  resolve: () => ({
47
105
  historyPolicy: 'managed',
48
106
  kind: RIGHT_PANEL_HOME_TAB_KIND,
107
+ resourceUri: RIGHT_PANEL_HOME_URL,
49
108
  title: t('docBrowserHomeTitle'),
50
109
  url: RIGHT_PANEL_HOME_URL,
51
110
  }),
@@ -61,6 +120,7 @@ export const RIGHT_PANEL_RESOURCE_ROUTE_DEFINITIONS: RightPanelResourceRouteDefi
61
120
  return {
62
121
  historyPolicy: 'managed',
63
122
  kind: 'docs',
123
+ resourceUri: createDocsResourceUriFromUrl(url),
64
124
  title: t('docBrowserHelp'),
65
125
  url,
66
126
  };
@@ -77,6 +137,7 @@ export const RIGHT_PANEL_RESOURCE_ROUTE_DEFINITIONS: RightPanelResourceRouteDefi
77
137
  return {
78
138
  historyPolicy: 'managed',
79
139
  kind: 'docs',
140
+ resourceUri: createDocsResourceUriFromSegments(uri.pathSegments),
80
141
  title: t('docBrowserHelp'),
81
142
  url,
82
143
  };
@@ -94,6 +155,7 @@ export const RIGHT_PANEL_RESOURCE_ROUTE_DEFINITIONS: RightPanelResourceRouteDefi
94
155
  dedupeKey: 'apps',
95
156
  historyPolicy: 'managed',
96
157
  kind: RIGHT_PANEL_APPS_TAB_KIND,
158
+ resourceUri: url,
97
159
  title: url === RIGHT_PANEL_APPS_URL ? t('appsTitle') : t('serviceAppsTitle'),
98
160
  url,
99
161
  };
@@ -104,19 +166,20 @@ export const RIGHT_PANEL_RESOURCE_ROUTE_DEFINITIONS: RightPanelResourceRouteDefi
104
166
  defaultUrl: () => 'nextclaw://panel-app',
105
167
  id: 'panel-app',
106
168
  kind: RIGHT_PANEL_PANEL_APP_TAB_KIND,
107
- match: (uri) => uri.scheme === 'nextclaw' && uri.authority === 'panel-app',
169
+ match: (uri) => (uri.scheme === 'nextclaw' && uri.authority === 'panel-app') || isPanelAppContentUri(uri),
108
170
  resolve: (uri) => {
109
171
  const url = resolvePanelAppPlaceholderUrl(uri);
110
- const appId = uri.pathSegments[0] ?? '';
172
+ const appId = getPanelAppId(uri);
111
173
  return {
112
174
  dedupeKey: appId ? `panel-app:${decodeURIComponent(appId)}` : undefined,
113
175
  historyPolicy: 'managed',
114
176
  kind: RIGHT_PANEL_PANEL_APP_TAB_KIND,
177
+ resourceUri: createPanelAppResourceUri(uri),
115
178
  title: t('panelAppsTitle'),
116
179
  url,
117
180
  };
118
181
  },
119
- areEquivalent: (left, right) => left === right,
182
+ areEquivalent: arePanelAppUrlsEquivalent,
120
183
  },
121
184
  {
122
185
  defaultUrl: () => 'about:blank',
@@ -126,6 +189,7 @@ export const RIGHT_PANEL_RESOURCE_ROUTE_DEFINITIONS: RightPanelResourceRouteDefi
126
189
  resolve: (uri) => ({
127
190
  historyPolicy: 'managed',
128
191
  kind: 'content',
192
+ resourceUri: uri.raw || 'about:blank',
129
193
  title: inferTabTitle(uri.raw, 'content', 'Detail'),
130
194
  url: uri.raw || 'about:blank',
131
195
  }),
@@ -8,11 +8,13 @@ describe('RightPanelResourceRouteResolver', () => {
8
8
  expect(resolver.resolve('nextclaw://apps')).toMatchObject({
9
9
  dedupeKey: 'apps',
10
10
  kind: 'apps',
11
+ resourceUri: 'nextclaw://apps',
11
12
  url: 'nextclaw://apps',
12
13
  });
13
14
  expect(resolver.resolve('nextclaw://apps?tab=service-apps')).toMatchObject({
14
15
  dedupeKey: 'apps',
15
16
  kind: 'apps',
17
+ resourceUri: 'nextclaw://apps?tab=service-apps',
16
18
  url: 'nextclaw://apps?tab=service-apps',
17
19
  });
18
20
  });
@@ -22,6 +24,7 @@ describe('RightPanelResourceRouteResolver', () => {
22
24
  const target = resolver.resolve('nextclaw://docs/guide/getting-started');
23
25
 
24
26
  expect(target.kind).toBe('docs');
27
+ expect(target.resourceUri).toBe('nextclaw://docs/guide/getting-started');
25
28
  expect(target.title).toBe('Help Docs');
26
29
  expect(target.url).toContain('/guide/getting-started');
27
30
  });
@@ -32,7 +35,28 @@ describe('RightPanelResourceRouteResolver', () => {
32
35
  expect(resolver.resolve('nextclaw://panel-app/timer')).toMatchObject({
33
36
  dedupeKey: 'panel-app:timer',
34
37
  kind: 'panel-app',
35
- url: 'nextclaw://panel-app/timer',
38
+ resourceUri: 'nextclaw://panel-app/timer',
39
+ url: '/api/panel-apps/timer/content',
36
40
  });
37
41
  });
42
+
43
+ it('keeps panel app content URLs idempotent under panel app kind', () => {
44
+ const resolver = new RightPanelResourceRouteResolver();
45
+
46
+ expect(resolver.resolveOpenTarget({
47
+ kind: 'panel-app',
48
+ url: '/api/panel-apps/timer/content',
49
+ })).toMatchObject({
50
+ dedupeKey: 'panel-app:timer',
51
+ kind: 'panel-app',
52
+ resourceUri: 'nextclaw://panel-app/timer',
53
+ url: '/api/panel-apps/timer/content',
54
+ });
55
+ expect(resolver.areUrlsEquivalent(
56
+ 'nextclaw://panel-app/timer',
57
+ '/api/panel-apps/timer/content',
58
+ 'panel-app',
59
+ 'panel-app',
60
+ )).toBe(true);
61
+ });
38
62
  });
@@ -21,7 +21,7 @@ function inferFallbackKind(url?: string): DocBrowserTabKind {
21
21
  export class RightPanelResourceRouteResolver implements DocBrowserRouteResolver {
22
22
  private readonly resourceUriResolver = new ResourceUriResolver<RightPanelResourceTarget>(
23
23
  RIGHT_PANEL_RESOURCE_ROUTE_DEFINITIONS_FOR_RESOURCE_URI,
24
- { getNormalizedUri: (target) => target.url },
24
+ { getNormalizedUri: (target) => target.resourceUri ?? target.url },
25
25
  );
26
26
 
27
27
  resolve = (uri: string): RightPanelResourceTarget => this.resourceUriResolver.resolve(uri);
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { PanelAppEntryView } from '@/shared/lib/api';
3
+ import { createPanelAppRightPanelResourceTarget } from '@/features/right-panel-resources/utils/right-panel-resource-uri.utils';
4
+
5
+ function createPanelAppEntry(overrides: Partial<PanelAppEntryView> = {}): PanelAppEntryView {
6
+ return {
7
+ contentPath: '/api/panel-apps/demo/content',
8
+ createdAt: '2026-06-02T00:00:00.000Z',
9
+ favorite: false,
10
+ fileName: 'demo.html',
11
+ id: 'demo',
12
+ openCount: 0,
13
+ sizeBytes: 1024,
14
+ title: 'Demo App',
15
+ updatedAt: '2026-06-02T00:00:00.000Z',
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe('right-panel-resource uri utils', () => {
21
+ it('keeps panel app image icons as dock image icons', () => {
22
+ const target = createPanelAppRightPanelResourceTarget(createPanelAppEntry({
23
+ icon: '/api/panel-apps/demo/assets/icon.png',
24
+ }));
25
+
26
+ expect(target.dockIcon).toEqual({
27
+ type: 'url',
28
+ url: '/api/panel-apps/demo/assets/icon.png',
29
+ });
30
+ });
31
+
32
+ it('keeps panel app text icons as dock text icons', () => {
33
+ const target = createPanelAppRightPanelResourceTarget(createPanelAppEntry({
34
+ icon: '🧭',
35
+ }));
36
+
37
+ expect(target.dockIcon).toEqual({
38
+ type: 'text',
39
+ value: '🧭',
40
+ });
41
+ });
42
+ });
@@ -1,4 +1,5 @@
1
1
  import type { PanelAppEntryView } from '@/shared/lib/api';
2
+ import type { DocBrowserDockIcon } from '@/shared/components/doc-browser';
2
3
  import type { RightPanelResourceTarget } from '@/features/right-panel-resources/types/right-panel-resource.types';
3
4
 
4
5
  export const RIGHT_PANEL_HOME_TAB_KIND = 'home';
@@ -37,11 +38,32 @@ export function createPanelAppResourceUri(appId: string): string {
37
38
  return `nextclaw://panel-app/${encodeURIComponent(appId)}`;
38
39
  }
39
40
 
41
+ function isPanelAppImageIcon(icon: string): boolean {
42
+ return (
43
+ icon.startsWith('data:image/') ||
44
+ icon.startsWith('http://') ||
45
+ icon.startsWith('https://') ||
46
+ icon.startsWith('/')
47
+ );
48
+ }
49
+
50
+ function createPanelAppDockIcon(entry: PanelAppEntryView): DocBrowserDockIcon | undefined {
51
+ const icon = entry.icon?.trim();
52
+ if (!icon) {
53
+ return undefined;
54
+ }
55
+ return isPanelAppImageIcon(icon)
56
+ ? { type: 'url', url: icon }
57
+ : { type: 'text', value: icon };
58
+ }
59
+
40
60
  export function createPanelAppRightPanelResourceTarget(entry: PanelAppEntryView): RightPanelResourceTarget {
41
61
  return {
42
62
  dedupeKey: `panel-app:${entry.id}`,
63
+ dockIcon: createPanelAppDockIcon(entry),
43
64
  historyPolicy: 'managed',
44
65
  kind: RIGHT_PANEL_PANEL_APP_TAB_KIND,
66
+ resourceUri: createPanelAppResourceUri(entry.id),
45
67
  title: entry.title,
46
68
  url: entry.contentPath,
47
69
  };
@@ -0,0 +1,88 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { ServiceAppsPanel } from '@/features/service-apps/components/service-apps-panel';
5
+
6
+ const deleteServiceAppMutate = vi.fn();
7
+ const discoverServiceAppActionsMutateAsync = vi.fn(async () => ({ actions: [] }));
8
+ const refetchServiceActions = vi.fn();
9
+ const refetchServiceActionGrants = vi.fn();
10
+ const refetchServiceApps = vi.fn();
11
+ const restartServiceAppMutate = vi.fn();
12
+ const revokeServiceActionGrantMutate = vi.fn();
13
+
14
+ vi.mock('@/features/service-apps/hooks/use-service-apps', () => ({
15
+ useDeleteServiceApp: () => ({
16
+ isPending: false,
17
+ mutate: deleteServiceAppMutate,
18
+ }),
19
+ useDiscoverServiceAppActions: () => ({
20
+ isPending: false,
21
+ mutateAsync: discoverServiceAppActionsMutateAsync,
22
+ }),
23
+ useRestartServiceApp: () => ({
24
+ mutate: restartServiceAppMutate,
25
+ }),
26
+ useRevokeServiceActionGrant: () => ({
27
+ mutate: revokeServiceActionGrantMutate,
28
+ }),
29
+ useServiceActionGrants: () => ({
30
+ data: { grants: [] },
31
+ isError: false,
32
+ isLoading: false,
33
+ refetch: refetchServiceActionGrants,
34
+ }),
35
+ useServiceActions: () => ({
36
+ data: { actions: [] },
37
+ isError: false,
38
+ isLoading: false,
39
+ refetch: refetchServiceActions,
40
+ }),
41
+ useServiceApps: () => ({
42
+ data: {
43
+ entries: [{
44
+ args: ['server.mjs'],
45
+ command: 'node',
46
+ cwd: '/workspace/service-apps/notes',
47
+ dirPath: '/workspace/service-apps/notes',
48
+ enabled: true,
49
+ id: 'notes',
50
+ manifestPath: '/workspace/service-apps/notes/service-app.json',
51
+ protocol: 'mcp',
52
+ status: 'idle',
53
+ title: 'Notes',
54
+ }],
55
+ serviceAppsPath: '/workspace/service-apps',
56
+ workspacePath: '/workspace',
57
+ },
58
+ isError: false,
59
+ isLoading: false,
60
+ refetch: refetchServiceApps,
61
+ }),
62
+ }));
63
+
64
+ describe('ServiceAppsPanel', () => {
65
+ it('labels service app icon actions and deletes through a confirm dialog', async () => {
66
+ const user = userEvent.setup();
67
+
68
+ render(<ServiceAppsPanel />);
69
+
70
+ expect(screen.getByText('Not connected')).toBeTruthy();
71
+ expect(screen.queryByText('idle')).toBeNull();
72
+ const discoverButton = screen.getByRole('button', { name: 'Connect and discover actions' });
73
+ expect(discoverButton).toBeTruthy();
74
+ const disconnectButton = screen.getByRole('button', { name: 'Disconnect runtime' });
75
+ expect(disconnectButton).toBeTruthy();
76
+ expect((disconnectButton as HTMLButtonElement).disabled).toBe(true);
77
+ await user.hover(discoverButton);
78
+ await waitFor(() => {
79
+ expect(screen.queryAllByText('Connect to the service app runtime and discover its available actions.').length).toBeGreaterThan(0);
80
+ });
81
+
82
+ fireEvent.click(screen.getByRole('button', { name: 'More actions' }));
83
+ fireEvent.click(screen.getByRole('button', { name: 'Delete service app' }));
84
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
85
+
86
+ expect(deleteServiceAppMutate).toHaveBeenCalledWith('notes');
87
+ });
88
+ });