@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
@@ -5,15 +5,26 @@ import type {
5
5
  ServiceAppRecordView,
6
6
  } from '@nextclaw/client-sdk';
7
7
  import {
8
+ AlertTriangle,
9
+ CheckCircle2,
10
+ CircleDashed,
11
+ LoaderCircle,
12
+ MoreVertical,
13
+ PauseCircle,
14
+ Radar,
8
15
  RefreshCw,
9
- RotateCw,
10
- Search,
11
16
  Server,
12
17
  ShieldCheck,
13
18
  Trash2,
19
+ Unplug,
20
+ type LucideIcon,
14
21
  Wrench,
15
22
  } from 'lucide-react';
23
+ import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog';
24
+ import { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
25
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/shared/components/ui/tooltip';
16
26
  import {
27
+ useDeleteServiceApp,
17
28
  useDiscoverServiceAppActions,
18
29
  useRestartServiceApp,
19
30
  useRevokeServiceActionGrant,
@@ -22,8 +33,10 @@ import {
22
33
  useServiceApps,
23
34
  } from '@/features/service-apps/hooks/use-service-apps';
24
35
  import { t } from '@/shared/lib/i18n';
36
+ import { cn } from '@/shared/lib/utils';
25
37
 
26
38
  type ServiceActionView = ServiceActionListView['actions'][number];
39
+ type ServiceAppStatus = ServiceAppRecordView['status'];
27
40
 
28
41
  export function ServiceAppsPanel({
29
42
  headerContent,
@@ -33,6 +46,7 @@ export function ServiceAppsPanel({
33
46
  const serviceApps = useServiceApps();
34
47
  const serviceActions = useServiceActions();
35
48
  const serviceActionGrants = useServiceActionGrants();
49
+ const deleteServiceApp = useDeleteServiceApp();
36
50
  const restartServiceApp = useRestartServiceApp();
37
51
  const discoverServiceAppActions = useDiscoverServiceAppActions();
38
52
  const revokeServiceActionGrant = useRevokeServiceActionGrant();
@@ -86,15 +100,13 @@ export function ServiceAppsPanel({
86
100
  </>
87
101
  )}
88
102
  </div>
89
- <button
90
- type="button"
91
- onClick={refetch}
92
- className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800"
93
- title={t('serviceAppsRefresh')}
94
- aria-label={t('serviceAppsRefresh')}
95
- >
96
- <RefreshCw className="h-3.5 w-3.5" />
97
- </button>
103
+ <TooltipProvider delayDuration={250}>
104
+ <ServiceAppIconButton
105
+ icon={RefreshCw}
106
+ label={t('serviceAppsRefresh')}
107
+ onClick={refetch}
108
+ />
109
+ </TooltipProvider>
98
110
  </div>
99
111
 
100
112
  {apps.length === 0 ? (
@@ -110,8 +122,10 @@ export function ServiceAppsPanel({
110
122
  app={app}
111
123
  actions={discoveredActionsByApp[app.id] ?? actions.filter((action) => action.appId === app.id)}
112
124
  grants={grants}
125
+ deletePending={deleteServiceApp.isPending}
113
126
  isDiscovering={discoverServiceAppActions.isPending}
114
127
  onDiscover={discover}
128
+ onDelete={(appId) => void deleteServiceApp.mutate(appId)}
115
129
  onRestart={(appId) => void restartServiceApp.mutate(appId)}
116
130
  onRevoke={(grant) => void revokeServiceActionGrant.mutate({
117
131
  actionId: grant.actionId,
@@ -130,68 +144,244 @@ function ServiceAppCard({
130
144
  app,
131
145
  actions,
132
146
  grants,
147
+ deletePending,
133
148
  isDiscovering,
134
149
  onDiscover,
150
+ onDelete,
135
151
  onRestart,
136
152
  onRevoke,
137
153
  }: {
138
154
  app: ServiceAppRecordView;
139
155
  actions: ServiceActionView[];
140
156
  grants: ServiceActionGrantView[];
157
+ deletePending: boolean;
141
158
  isDiscovering: boolean;
142
159
  onDiscover: (appId: string) => void;
160
+ onDelete: (appId: string) => void;
143
161
  onRestart: (appId: string) => void;
144
162
  onRevoke: (grant: ServiceActionGrantView) => void;
145
163
  }) {
164
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
165
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
166
+ const canConnectAndDiscover = app.status !== 'starting' && app.status !== 'stopped';
167
+ const canDisconnectRuntime = app.status === 'running' || app.status === 'failed';
168
+ const openDeleteDialog = () => {
169
+ setIsMenuOpen(false);
170
+ setIsDeleteDialogOpen(true);
171
+ };
172
+
146
173
  return (
147
- <section className="rounded-lg border border-gray-200 bg-white p-3">
148
- <div className="flex items-start justify-between gap-3">
149
- <div className="min-w-0">
150
- <div className="flex items-center gap-2">
151
- <div className="truncate text-sm font-semibold text-gray-900">{app.title}</div>
152
- <span className="rounded bg-gray-100 px-1.5 py-0.5 text-[11px] text-gray-600">{app.status}</span>
174
+ <TooltipProvider delayDuration={250}>
175
+ <section className="rounded-lg border border-gray-200 bg-white p-3">
176
+ <div className="flex items-start justify-between gap-3">
177
+ <div className="min-w-0">
178
+ <div className="flex items-center gap-2">
179
+ <div className="truncate text-sm font-semibold text-gray-900">{app.title}</div>
180
+ <ServiceAppStatusBadge status={app.status} />
181
+ </div>
182
+ {app.description ? (
183
+ <div className="mt-1 line-clamp-2 text-xs text-gray-500">{app.description}</div>
184
+ ) : null}
185
+ {app.lastError ? (
186
+ <div className="mt-2 rounded bg-rose-50 px-2 py-1 text-xs text-rose-700">{app.lastError}</div>
187
+ ) : null}
188
+ </div>
189
+ <div className="flex shrink-0 items-center gap-1">
190
+ <ServiceAppIconButton
191
+ disabled={isDiscovering || !canConnectAndDiscover}
192
+ icon={Radar}
193
+ label={t('serviceAppsDiscoverActions')}
194
+ tooltip={t('serviceAppsDiscoverActionsHint')}
195
+ onClick={() => onDiscover(app.id)}
196
+ />
197
+ <ServiceAppIconButton
198
+ disabled={!canDisconnectRuntime}
199
+ icon={Unplug}
200
+ label={t('serviceAppsDisconnectRuntime')}
201
+ tooltip={t('serviceAppsDisconnectRuntimeHint')}
202
+ onClick={() => onRestart(app.id)}
203
+ />
204
+ <Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
205
+ <Tooltip>
206
+ <TooltipTrigger asChild>
207
+ <PopoverTrigger asChild>
208
+ <button
209
+ type="button"
210
+ className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50"
211
+ aria-label={t('serviceAppsMoreActions')}
212
+ disabled={deletePending}
213
+ >
214
+ <MoreVertical className="h-3.5 w-3.5" />
215
+ </button>
216
+ </PopoverTrigger>
217
+ </TooltipTrigger>
218
+ <TooltipContent side="bottom" className="text-xs">{t('serviceAppsMoreActions')}</TooltipContent>
219
+ </Tooltip>
220
+ <PopoverContent align="end" className="w-48 rounded-xl p-1.5">
221
+ <ServiceAppMenuItem
222
+ destructive
223
+ disabled={deletePending}
224
+ icon={Trash2}
225
+ label={t('serviceAppsDelete')}
226
+ onClick={openDeleteDialog}
227
+ />
228
+ </PopoverContent>
229
+ </Popover>
153
230
  </div>
154
- {app.description ? (
155
- <div className="mt-1 line-clamp-2 text-xs text-gray-500">{app.description}</div>
156
- ) : null}
157
- {app.lastError ? (
158
- <div className="mt-2 rounded bg-rose-50 px-2 py-1 text-xs text-rose-700">{app.lastError}</div>
159
- ) : null}
160
231
  </div>
161
- <div className="flex shrink-0 items-center gap-1">
162
- <button
163
- type="button"
164
- onClick={() => onDiscover(app.id)}
165
- disabled={isDiscovering}
166
- className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50"
167
- title={t('serviceAppsDiscoverActions')}
168
- aria-label={t('serviceAppsDiscoverActions')}
169
- >
170
- <Search className="h-3.5 w-3.5" />
171
- </button>
172
- <button
173
- type="button"
174
- onClick={() => onRestart(app.id)}
175
- className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800"
176
- title={t('serviceAppsRestart')}
177
- aria-label={t('serviceAppsRestart')}
178
- >
179
- <RotateCw className="h-3.5 w-3.5" />
180
- </button>
232
+ <ServiceAppDiagnostics app={app} />
233
+ <div className="mt-3 space-y-1">
234
+ {actions.map((action) => (
235
+ <ServiceActionRow
236
+ key={action.id}
237
+ action={action}
238
+ grants={grants.filter((grant) => grant.actionId === action.id)}
239
+ onRevoke={onRevoke}
240
+ />
241
+ ))}
181
242
  </div>
182
- </div>
183
- <ServiceAppDiagnostics app={app} />
184
- <div className="mt-3 space-y-1">
185
- {actions.map((action) => (
186
- <ServiceActionRow
187
- key={action.id}
188
- action={action}
189
- grants={grants.filter((grant) => grant.actionId === action.id)}
190
- onRevoke={onRevoke}
191
- />
192
- ))}
193
- </div>
194
- </section>
243
+ <ConfirmDialog
244
+ open={isDeleteDialogOpen}
245
+ onOpenChange={setIsDeleteDialogOpen}
246
+ title={t('serviceAppsDeleteConfirmTitle')}
247
+ description={`${t('serviceAppsDeleteConfirmDescription')} ${app.title}`}
248
+ confirmLabel={t('delete')}
249
+ variant="destructive"
250
+ onConfirm={() => onDelete(app.id)}
251
+ onCancel={() => undefined}
252
+ />
253
+ </section>
254
+ </TooltipProvider>
255
+ );
256
+ }
257
+
258
+ function ServiceAppStatusBadge({ status }: { status: ServiceAppStatus }) {
259
+ const view = getServiceAppStatusView(status);
260
+ const Icon = view.icon;
261
+ return (
262
+ <Tooltip>
263
+ <TooltipTrigger asChild>
264
+ <span
265
+ className={cn(
266
+ 'inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-medium',
267
+ view.className,
268
+ )}
269
+ aria-label={view.label}
270
+ >
271
+ <Icon className={cn('h-3 w-3', view.iconClassName)} />
272
+ <span>{view.label}</span>
273
+ </span>
274
+ </TooltipTrigger>
275
+ <TooltipContent side="bottom" className="text-xs">{view.tooltip}</TooltipContent>
276
+ </Tooltip>
277
+ );
278
+ }
279
+
280
+ function getServiceAppStatusView(status: ServiceAppStatus): {
281
+ className: string;
282
+ icon: LucideIcon;
283
+ iconClassName?: string;
284
+ label: string;
285
+ tooltip: string;
286
+ } {
287
+ switch (status) {
288
+ case 'running':
289
+ return {
290
+ className: 'bg-emerald-50 text-emerald-700',
291
+ icon: CheckCircle2,
292
+ label: t('serviceAppsStatus_running'),
293
+ tooltip: t('serviceAppsStatusHint_running'),
294
+ };
295
+ case 'starting':
296
+ return {
297
+ className: 'bg-blue-50 text-blue-700',
298
+ icon: LoaderCircle,
299
+ iconClassName: 'animate-spin',
300
+ label: t('serviceAppsStatus_starting'),
301
+ tooltip: t('serviceAppsStatusHint_starting'),
302
+ };
303
+ case 'failed':
304
+ return {
305
+ className: 'bg-rose-50 text-rose-700',
306
+ icon: AlertTriangle,
307
+ label: t('serviceAppsStatus_failed'),
308
+ tooltip: t('serviceAppsStatusHint_failed'),
309
+ };
310
+ case 'stopped':
311
+ return {
312
+ className: 'bg-gray-100 text-gray-600',
313
+ icon: PauseCircle,
314
+ label: t('serviceAppsStatus_stopped'),
315
+ tooltip: t('serviceAppsStatusHint_stopped'),
316
+ };
317
+ case 'idle':
318
+ default:
319
+ return {
320
+ className: 'bg-slate-100 text-slate-600',
321
+ icon: CircleDashed,
322
+ label: t('serviceAppsStatus_idle'),
323
+ tooltip: t('serviceAppsStatusHint_idle'),
324
+ };
325
+ }
326
+ }
327
+
328
+ function ServiceAppIconButton({
329
+ disabled = false,
330
+ icon: Icon,
331
+ label,
332
+ tooltip = label,
333
+ onClick,
334
+ }: {
335
+ disabled?: boolean;
336
+ icon: LucideIcon;
337
+ label: string;
338
+ tooltip?: string;
339
+ onClick: () => void;
340
+ }) {
341
+ return (
342
+ <Tooltip>
343
+ <TooltipTrigger asChild>
344
+ <button
345
+ type="button"
346
+ onClick={onClick}
347
+ disabled={disabled}
348
+ className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800 disabled:cursor-not-allowed disabled:text-gray-300 disabled:opacity-60 disabled:hover:bg-transparent"
349
+ aria-label={label}
350
+ >
351
+ <Icon className="h-3.5 w-3.5" />
352
+ </button>
353
+ </TooltipTrigger>
354
+ <TooltipContent side="bottom" className="max-w-56 text-xs">{tooltip}</TooltipContent>
355
+ </Tooltip>
356
+ );
357
+ }
358
+
359
+ function ServiceAppMenuItem({
360
+ destructive = false,
361
+ disabled = false,
362
+ icon: Icon,
363
+ label,
364
+ onClick,
365
+ }: {
366
+ destructive?: boolean;
367
+ disabled?: boolean;
368
+ icon: LucideIcon;
369
+ label: string;
370
+ onClick: () => void;
371
+ }) {
372
+ return (
373
+ <button
374
+ type="button"
375
+ className={cn(
376
+ 'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
377
+ destructive ? 'text-destructive hover:bg-destructive/10' : 'text-gray-700 hover:bg-gray-100',
378
+ )}
379
+ disabled={disabled}
380
+ onClick={onClick}
381
+ >
382
+ <Icon className="h-4 w-4 shrink-0" />
383
+ <span>{label}</span>
384
+ </button>
195
385
  );
196
386
  }
197
387
 
@@ -39,6 +39,20 @@ export function useRestartServiceApp() {
39
39
  });
40
40
  }
41
41
 
42
+ export function useDeleteServiceApp() {
43
+ const queryClient = useQueryClient();
44
+ return useMutation({
45
+ mutationFn: (appId: string) => nextclawClient.serviceApps.deleteServiceApp(appId),
46
+ onSuccess: async () => {
47
+ await Promise.all([
48
+ queryClient.invalidateQueries({ queryKey: SERVICE_APPS_QUERY_KEY }),
49
+ queryClient.invalidateQueries({ queryKey: SERVICE_ACTIONS_QUERY_KEY }),
50
+ queryClient.invalidateQueries({ queryKey: SERVICE_ACTION_GRANTS_QUERY_KEY }),
51
+ ]);
52
+ },
53
+ });
54
+ }
55
+
42
56
  export function useDiscoverServiceAppActions() {
43
57
  const queryClient = useQueryClient();
44
58
  return useMutation({
@@ -1,6 +1,6 @@
1
1
  import { fireEvent, render, screen } from '@testing-library/react';
2
2
  import { beforeEach, describe, expect, it, vi } from 'vitest';
3
- import { SIDE_DOCK_BUILT_IN_ITEMS } from '@/features/side-dock/configs/side-dock-built-in-items.config';
3
+ import { getSideDockBuiltInItems } from '@/features/side-dock/configs/side-dock-built-in-items.config';
4
4
  import { SideDock } from '@/features/side-dock/components/side-dock';
5
5
  import { useSideDockStore } from '@/features/side-dock/stores/side-dock.store';
6
6
  import type { SideDockManager } from '@/features/side-dock/managers/side-dock.manager';
@@ -28,7 +28,8 @@ describe('SideDock', () => {
28
28
  );
29
29
 
30
30
  expect(screen.getByTestId('side-dock')).toBeTruthy();
31
- SIDE_DOCK_BUILT_IN_ITEMS.forEach((item) => {
31
+ const builtInItems = getSideDockBuiltInItems();
32
+ builtInItems.forEach((item) => {
32
33
  expect(document.querySelector(`[data-side-dock-item-id="${item.id}"]`)).toBeTruthy();
33
34
  });
34
35
 
@@ -36,7 +37,7 @@ describe('SideDock', () => {
36
37
  expect(appsButton).toBeTruthy();
37
38
  fireEvent.click(appsButton as Element);
38
39
 
39
- expect(openItem).toHaveBeenCalledWith(SIDE_DOCK_BUILT_IN_ITEMS[0]);
40
+ expect(openItem).toHaveBeenCalledWith(builtInItems[0]);
40
41
  });
41
42
 
42
43
  it('does not highlight the default docs tab while DocBrowser is closed', () => {
@@ -55,4 +56,58 @@ describe('SideDock', () => {
55
56
  expect(docsButton?.className).toContain('bg-transparent');
56
57
  expect(docsButton?.className).not.toContain('shadow-sm');
57
58
  });
59
+
60
+ it('renders removable pinned entries and calls manager unpin by item id', () => {
61
+ const manager = {
62
+ openItem: vi.fn(),
63
+ unpinItem: vi.fn(),
64
+ } as unknown as SideDockManager;
65
+ const docBrowserManager = new DocBrowserManager();
66
+ useSideDockStore.getState().setPinnedItems([
67
+ {
68
+ createdAt: '2026-06-02T00:00:00.000Z',
69
+ icon: { type: 'builtin', name: 'docs' },
70
+ id: 'pinned-docs-custom',
71
+ label: 'Custom Docs',
72
+ target: { type: 'right-panel-resource', uri: 'nextclaw://docs/custom' },
73
+ },
74
+ ]);
75
+
76
+ render(
77
+ <DocBrowserProvider manager={docBrowserManager}>
78
+ <SideDock manager={manager} />
79
+ </DocBrowserProvider>,
80
+ );
81
+
82
+ expect(document.querySelector('[data-side-dock-item-id="pinned-docs-custom"]')).toBeTruthy();
83
+
84
+ fireEvent.click(screen.getByTitle('Remove shortcut'));
85
+
86
+ expect(manager.unpinItem).toHaveBeenCalledWith('pinned-docs-custom');
87
+ });
88
+
89
+ it('renders text icons for pinned entries', () => {
90
+ const manager = {
91
+ openItem: vi.fn(),
92
+ unpinItem: vi.fn(),
93
+ } as unknown as SideDockManager;
94
+ const docBrowserManager = new DocBrowserManager();
95
+ useSideDockStore.getState().setPinnedItems([
96
+ {
97
+ createdAt: '2026-06-02T00:00:00.000Z',
98
+ icon: { type: 'text', value: 'D' },
99
+ id: 'pinned-panel-app-demo',
100
+ label: 'Demo App',
101
+ target: { type: 'right-panel-resource', uri: 'nextclaw://panel-app/demo' },
102
+ },
103
+ ]);
104
+
105
+ render(
106
+ <DocBrowserProvider manager={docBrowserManager}>
107
+ <SideDock manager={manager} />
108
+ </DocBrowserProvider>,
109
+ );
110
+
111
+ expect(screen.getByText('D')).toBeTruthy();
112
+ });
58
113
  });
@@ -1,7 +1,7 @@
1
- import { AppWindow, BookOpen, Boxes, Plus } from 'lucide-react';
1
+ import { AppWindow, BookOpen, Boxes, Plus, X } from 'lucide-react';
2
2
  import type { LucideIcon } from 'lucide-react';
3
3
  import type { SideDockManager } from '@/features/side-dock/managers/side-dock.manager';
4
- import { SIDE_DOCK_BUILT_IN_ITEMS } from '@/features/side-dock/configs/side-dock-built-in-items.config';
4
+ import { getSideDockBuiltInItems } from '@/features/side-dock/configs/side-dock-built-in-items.config';
5
5
  import { useSideDockStore } from '@/features/side-dock/stores/side-dock.store';
6
6
  import type {
7
7
  SideDockIconName,
@@ -30,6 +30,7 @@ const SIDE_DOCK_ICON_COMPONENTS: Record<SideDockIconName, LucideIcon> = {
30
30
  apps: Boxes,
31
31
  docs: BookOpen,
32
32
  'new-tab': Plus,
33
+ 'panel-app': AppWindow,
33
34
  'service-apps': AppWindow,
34
35
  };
35
36
 
@@ -37,6 +38,13 @@ function SideDockItemIconView({ icon }: { icon: SideDockItemIcon }) {
37
38
  if (icon.type === 'url') {
38
39
  return <img src={icon.url} alt="" className="h-5 w-5 rounded object-cover" />;
39
40
  }
41
+ if (icon.type === 'text') {
42
+ return (
43
+ <span className="max-w-7 truncate text-center text-[13px] font-semibold leading-none" aria-hidden="true">
44
+ {icon.value}
45
+ </span>
46
+ );
47
+ }
40
48
 
41
49
  const Icon = SIDE_DOCK_ICON_COMPONENTS[icon.name];
42
50
  return <Icon className="h-5 w-5" aria-hidden="true" />;
@@ -46,56 +54,100 @@ function SideDockButton({
46
54
  active,
47
55
  item,
48
56
  onOpen,
57
+ onUnpin,
49
58
  }: {
50
59
  active: boolean;
51
60
  item: SideDockItem;
52
61
  onOpen: (item: SideDockItem) => void;
62
+ onUnpin: (item: SideDockItem) => void;
53
63
  }) {
54
- const label = t(item.labelKey);
64
+ const { label } = item;
55
65
 
56
66
  return (
57
67
  <Tooltip>
58
- <TooltipTrigger asChild>
59
- <button
60
- type="button"
61
- aria-label={label}
62
- title={label}
63
- data-side-dock-item-id={item.id}
64
- onClick={() => onOpen(item)}
65
- className={cn(
66
- 'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors',
67
- 'hover:bg-primary/10 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
68
- active
69
- ? 'bg-primary/10 text-primary'
70
- : 'bg-transparent',
71
- )}
72
- >
73
- <SideDockItemIconView icon={item.icon} />
74
- </button>
75
- </TooltipTrigger>
68
+ <div className="group relative">
69
+ <TooltipTrigger asChild>
70
+ <button
71
+ type="button"
72
+ aria-label={label}
73
+ title={label}
74
+ data-side-dock-item-id={item.id}
75
+ onClick={() => onOpen(item)}
76
+ className={cn(
77
+ 'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors',
78
+ 'hover:bg-primary/10 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
79
+ active
80
+ ? 'bg-primary/10 text-primary'
81
+ : 'bg-transparent',
82
+ )}
83
+ >
84
+ <SideDockItemIconView icon={item.icon} />
85
+ </button>
86
+ </TooltipTrigger>
87
+ {item.removable ? (
88
+ <button
89
+ type="button"
90
+ aria-label={t('sideDockUnpinItem')}
91
+ title={t('sideDockUnpinItem')}
92
+ onClick={(event) => {
93
+ event.stopPropagation();
94
+ onUnpin(item);
95
+ }}
96
+ className="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-background text-muted-foreground shadow-sm ring-1 ring-border/70 transition-colors hover:bg-muted hover:text-foreground group-hover:flex focus-visible:flex focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
97
+ >
98
+ <X className="h-3 w-3" aria-hidden="true" />
99
+ </button>
100
+ ) : null}
101
+ </div>
76
102
  <TooltipContent side="left">{label}</TooltipContent>
77
103
  </Tooltip>
78
104
  );
79
105
  }
80
106
 
107
+ function normalizeSideDockUri(uri?: string): string {
108
+ return uri?.trim() ?? '';
109
+ }
110
+
81
111
  function isSideDockItemActive(
82
112
  item: SideDockItem,
83
113
  isDocBrowserOpen: boolean,
84
114
  currentTab?: DocBrowserTab,
115
+ hasExactActiveItem = false,
85
116
  ): boolean {
86
117
  if (!isDocBrowserOpen || !currentTab) {
87
118
  return false;
88
119
  }
89
- if (currentTab.currentUrl === item.target.uri) {
120
+ const itemUri = normalizeSideDockUri(item.target.uri);
121
+ const currentResourceUri = normalizeSideDockUri(currentTab.resourceUri);
122
+ if (
123
+ (currentResourceUri && currentResourceUri === itemUri)
124
+ || normalizeSideDockUri(currentTab.currentUrl) === itemUri
125
+ ) {
90
126
  return true;
91
127
  }
128
+ if (hasExactActiveItem) {
129
+ return false;
130
+ }
92
131
  return item.id === 'docs' && currentTab.kind === 'docs';
93
132
  }
94
133
 
134
+ function hasExactActiveSideDockItem(items: SideDockItem[], currentTab?: DocBrowserTab): boolean {
135
+ if (!currentTab) {
136
+ return false;
137
+ }
138
+ const currentResourceUri = normalizeSideDockUri(currentTab.resourceUri);
139
+ const currentUrl = normalizeSideDockUri(currentTab.currentUrl);
140
+ return items.some((item) => {
141
+ const itemUri = normalizeSideDockUri(item.target.uri);
142
+ return itemUri === currentResourceUri || itemUri === currentUrl;
143
+ });
144
+ }
145
+
95
146
  export function SideDock({ manager }: SideDockProps) {
96
147
  const pinnedItems = useSideDockStore((state) => state.pinnedItems);
97
148
  const { currentTab, isOpen } = useDocBrowser();
98
- const items = mergeSideDockItems(SIDE_DOCK_BUILT_IN_ITEMS, pinnedItems);
149
+ const items = mergeSideDockItems(getSideDockBuiltInItems(), pinnedItems);
150
+ const hasExactActiveItem = hasExactActiveSideDockItem(items, currentTab);
99
151
 
100
152
  return (
101
153
  <TooltipProvider delayDuration={250}>
@@ -106,9 +158,10 @@ export function SideDock({ manager }: SideDockProps) {
106
158
  {items.map((item) => (
107
159
  <SideDockButton
108
160
  key={item.id}
109
- active={isSideDockItemActive(item, isOpen, currentTab)}
161
+ active={isSideDockItemActive(item, isOpen, currentTab, hasExactActiveItem)}
110
162
  item={item}
111
163
  onOpen={manager.openItem}
164
+ onUnpin={(dockItem) => manager.unpinItem(dockItem.id)}
112
165
  />
113
166
  ))}
114
167
  </aside>