@nextclaw/ui 0.13.3 → 0.13.5

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 (132) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/assets/api-BAyyGaoz.js +15 -0
  3. package/dist/assets/app-presenter-provider-DqHHfC1K.js +3 -0
  4. package/dist/assets/arrow-left-Bn-3Pj3p.js +1 -0
  5. package/dist/assets/{boxes-BvA-I63l.js → boxes-ClVuotcz.js} +1 -1
  6. package/dist/assets/channels-list-page-BnGxv6ma.js +8 -0
  7. package/dist/assets/chat-page-DInyWLb4.js +105 -0
  8. package/dist/assets/config-split-page-CWN2YG9U.js +1 -0
  9. package/dist/assets/confirm-dialog-CVbgKuHp.js +5 -0
  10. package/dist/assets/{createLucideIcon-Br1EFMEr.js → createLucideIcon-CjPUPAAK.js} +1 -1
  11. package/dist/assets/desktop-update-config-abmYzHSy.js +1 -0
  12. package/dist/assets/{dist-rzakR_bu.js → dist-hkrb6Ynt.js} +1 -1
  13. package/dist/assets/doc-browser-BlrpUKVJ.js +1 -0
  14. package/dist/assets/doc-browser-context-Cb1sEUT0.js +1 -0
  15. package/dist/assets/doc-browser-kJ2ClG0H.js +1 -0
  16. package/dist/assets/ellipsis-CdkxFkVZ.js +1 -0
  17. package/dist/assets/{external-link-DHjFdCpy.js → external-link-CT9_qFwN.js} +1 -1
  18. package/dist/assets/index-BGXTR0F_.css +1 -0
  19. package/dist/assets/index-Ccbswzt9.js +101 -0
  20. package/dist/assets/{key-round-D41Oe35x.js → key-round-xJfR4iRe.js} +1 -1
  21. package/dist/assets/{book-open-CRotBOF2.js → loader-circle-BI6HJDg5.js} +1 -1
  22. package/dist/assets/{mcp-marketplace-page-Dvi8mCBd.js → mcp-marketplace-page-CmUKUeno.js} +2 -2
  23. package/dist/assets/mcp-marketplace-page-DH11bSVx.js +1 -0
  24. package/dist/assets/model-config-Dbo3zWDl.js +1 -0
  25. package/dist/assets/notice-card-CFTFtJky.js +1 -0
  26. package/dist/assets/play-DyBWW2j4.js +1 -0
  27. package/dist/assets/plus-BlnBmcKE.js +1 -0
  28. package/dist/assets/popover-DPqa_Mfb.js +1 -0
  29. package/dist/assets/provider-models-CXUgDxGR.js +1 -0
  30. package/dist/assets/provider-scoped-model-input-C6xL7zSJ.js +1 -0
  31. package/dist/assets/providers-list-DOj_7GL8.js +1 -0
  32. package/dist/assets/react-oAHNoVya.js +1 -0
  33. package/dist/assets/refresh-cw-o8NSmIYV.js +1 -0
  34. package/dist/assets/remote-fCE5fLsj.js +1 -0
  35. package/dist/assets/rotate-cw-DA8EqNAH.js +1 -0
  36. package/dist/assets/runtime-config-page-Dg8aEgWx.js +1 -0
  37. package/dist/assets/{save-aYdSdK97.js → save-D4-g0EAq.js} +1 -1
  38. package/dist/assets/search-G2cMn3M5.js +1 -0
  39. package/dist/assets/{search-config-G2O1fcRb.js → search-config-DAibVFGR.js} +1 -1
  40. package/dist/assets/{secrets-config-TenzxYbE.js → secrets-config-CoJzy13_.js} +2 -2
  41. package/dist/assets/{status-dot-BSMG7ZaC.js → status-dot-D8xx5ZR8.js} +1 -1
  42. package/dist/assets/{tabs-D1CjpWNX.js → tabs-DxmND60Q.js} +1 -1
  43. package/dist/assets/{tabs-custom-CRSN1ekg.js → tabs-custom-Cjo0bHw8.js} +1 -1
  44. package/dist/assets/{tag-chip-DEashiw6.js → tag-chip-DnNWFRMu.js} +1 -1
  45. package/dist/assets/{trash-2-DSWTsARW.js → trash-2-C9tc-9sQ.js} +1 -1
  46. package/dist/assets/x-DmLl2yG6.js +1 -0
  47. package/dist/index.html +24 -23
  48. package/dist/logos/mimo.svg +9 -0
  49. package/package.json +8 -8
  50. package/public/logos/mimo.svg +9 -0
  51. package/src/app/components/layout/app-layout.tsx +10 -2
  52. package/src/features/agents/components/agents-page.tsx +6 -4
  53. package/src/features/chat/hooks/use-ncp-chat-page-data.ts +17 -10
  54. package/src/features/marketplace/components/marketplace-list-card.tsx +22 -1
  55. package/src/features/marketplace/components/marketplace-page.tsx +3 -1
  56. package/src/features/marketplace/hooks/use-marketplace.ts +3 -1
  57. package/src/features/marketplace/utils/marketplace-installed-cache.utils.ts +8 -0
  58. package/src/features/right-panel-resources/configs/right-panel-resource-routes.config.ts +70 -6
  59. package/src/features/right-panel-resources/utils/right-panel-resource-route-resolver.utils.test.ts +25 -1
  60. package/src/features/right-panel-resources/utils/right-panel-resource-route-resolver.utils.ts +1 -1
  61. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.test.ts +42 -0
  62. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +22 -0
  63. package/src/features/service-apps/components/service-apps-panel.test.tsx +88 -0
  64. package/src/features/service-apps/components/service-apps-panel.tsx +246 -56
  65. package/src/features/service-apps/hooks/use-service-apps.ts +14 -0
  66. package/src/features/side-dock/components/side-dock.test.tsx +58 -3
  67. package/src/features/side-dock/components/side-dock.tsx +77 -24
  68. package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +37 -34
  69. package/src/features/side-dock/index.ts +2 -0
  70. package/src/features/side-dock/managers/side-dock.manager.test.ts +84 -3
  71. package/src/features/side-dock/managers/side-dock.manager.ts +135 -12
  72. package/src/features/side-dock/types/side-dock.types.ts +11 -4
  73. package/src/features/side-dock/utils/side-dock-item.utils.test.ts +8 -8
  74. package/src/features/side-dock/utils/side-dock-item.utils.ts +13 -5
  75. package/src/platforms/desktop/components/desktop-app-shell.tsx +5 -2
  76. package/src/platforms/mobile/components/mobile-app-shell.tsx +4 -1
  77. package/src/shared/components/config/provider-form-support.test.ts +21 -0
  78. package/src/shared/components/config/provider-form-support.ts +38 -78
  79. package/src/shared/components/config/provider-form.tsx +54 -67
  80. package/src/shared/components/config/providers-list.tsx +211 -73
  81. package/src/shared/components/doc-browser/doc-browser-context.test.tsx +73 -0
  82. package/src/shared/components/doc-browser/doc-browser-context.tsx +3 -0
  83. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +25 -2
  84. package/src/shared/components/doc-browser/doc-browser.test.tsx +42 -0
  85. package/src/shared/components/doc-browser/doc-browser.tsx +38 -7
  86. package/src/shared/components/doc-browser/index.ts +1 -0
  87. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +30 -6
  88. package/src/shared/components/doc-browser/stores/doc-browser.store.ts +29 -0
  89. package/src/shared/components/doc-browser/types/doc-browser.types.ts +23 -0
  90. package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +7 -0
  91. package/src/shared/components/model-config.tsx +5 -4
  92. package/src/shared/components/provider-auth-section.tsx +2 -2
  93. package/src/shared/components/ui/switch.tsx +5 -2
  94. package/src/shared/hooks/use-config.ts +27 -6
  95. package/src/shared/lib/api/marketplace.types.ts +193 -0
  96. package/src/shared/lib/api/types.ts +23 -200
  97. package/src/shared/lib/api/utils/config.utils.ts +17 -7
  98. package/src/shared/lib/i18n/index.ts +10 -12
  99. package/src/shared/lib/i18n/{marketplace.ts → marketplace-labels.utils.ts} +3 -0
  100. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +46 -2
  101. package/src/shared/lib/provider-models/index.test.ts +93 -15
  102. package/src/shared/lib/provider-models/index.ts +35 -20
  103. package/src/shared/lib/transport/request-raw-api-response.utils.ts +1 -1
  104. package/dist/assets/api-DruAcori.js +0 -15
  105. package/dist/assets/app-presenter-provider-DIcnNB9s.js +0 -3
  106. package/dist/assets/channels-list-page-wK3l7p5R.js +0 -8
  107. package/dist/assets/chat-page-DgehvdJT.js +0 -105
  108. package/dist/assets/config-split-page-BnTO1mAM.js +0 -1
  109. package/dist/assets/confirm-dialog-CGrKHbAW.js +0 -5
  110. package/dist/assets/desktop-update-config-CehWxSSg.js +0 -1
  111. package/dist/assets/dist-CpwDZDtv.js +0 -1
  112. package/dist/assets/doc-browser-BgxmVsTx.js +0 -1
  113. package/dist/assets/doc-browser-CZn5-Qbu.js +0 -1
  114. package/dist/assets/doc-browser-context-B8gk7HMr.js +0 -1
  115. package/dist/assets/index-DORmh0Cy.css +0 -1
  116. package/dist/assets/index-DcFpvnge.js +0 -101
  117. package/dist/assets/mcp-marketplace-page-DBLhJWOJ.js +0 -1
  118. package/dist/assets/model-config-B-EJXGtO.js +0 -1
  119. package/dist/assets/play-C-0vo7hi.js +0 -1
  120. package/dist/assets/plus-Cr_akLp2.js +0 -1
  121. package/dist/assets/popover-YieElO8c.js +0 -1
  122. package/dist/assets/provider-scoped-model-input-BPRNGqYn.js +0 -1
  123. package/dist/assets/providers-list-CtBpL1ZY.js +0 -1
  124. package/dist/assets/react-B2X_ph0a.js +0 -1
  125. package/dist/assets/remote-BGHbHoHf.js +0 -1
  126. package/dist/assets/rotate-cw-jueVCNJQ.js +0 -1
  127. package/dist/assets/runtime-config-page-C1Hv7mad.js +0 -1
  128. package/dist/assets/search-CdhgFNwf.js +0 -1
  129. package/dist/assets/setting-row-BNu0qhOM.js +0 -1
  130. package/dist/assets/x-D1v_vb54.js +0 -1
  131. package/src/shared/components/provider-enabled-field.tsx +0 -23
  132. /package/dist/assets/{config-hints-A7G_jMTU.js → config-hints-TmNgpFHv.js} +0 -0
@@ -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
+ });
@@ -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
  });