@nextclaw/ui 0.16.0 → 0.17.1

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 (73) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/assets/api-DJ6ZKWFY.js +13 -0
  3. package/dist/assets/{appearance-settings-page-CfwDf9ag.js → appearance-settings-page-B-juoJ6F.js} +1 -1
  4. package/dist/assets/{channels-list-page-Dyn83ZlS.js → channels-list-page-mVHYa7xW.js} +1 -1
  5. package/dist/assets/{chat-page-CL0IMvgF.js → chat-page-DsMYGNee.js} +1 -1
  6. package/dist/assets/{config-split-page-D4D4HFhq.js → config-split-page-BaqLcRxk.js} +1 -1
  7. package/dist/assets/{confirm-dialog-Cv6-9Nnj.js → confirm-dialog-DasFoaCT.js} +1 -1
  8. package/dist/assets/{desktop-update-config-Bckgulvm.js → desktop-update-config-CoEvKrpB.js} +1 -1
  9. package/dist/assets/{dist-BrRgjiq1.js → dist-C741xUwt.js} +1 -1
  10. package/dist/assets/{dist-Bh_tj3FN.js → dist-zOKNc7QQ.js} +1 -1
  11. package/dist/assets/doc-browser-BbirIYbj.js +1 -0
  12. package/dist/assets/{doc-browser-BlWjhmnM.js → doc-browser-Crqog5cn.js} +1 -1
  13. package/dist/assets/{doc-browser-context-NGYa66Xg.js → doc-browser-context-BT8NWQyM.js} +1 -1
  14. package/dist/assets/{form-actions-CeKgh4SQ.js → form-actions-BcukaMyR.js} +1 -1
  15. package/dist/assets/index-1f4Pv2q3.css +1 -0
  16. package/dist/assets/index-BYWLjYFQ.js +109 -0
  17. package/dist/assets/loader-circle-LVHz84Xj.js +1 -0
  18. package/dist/assets/mcp-marketplace-page-2SsrbaiQ.js +1 -0
  19. package/dist/assets/{mcp-marketplace-page-DfNmMqKs.js → mcp-marketplace-page-XgYLOAkK.js} +1 -1
  20. package/dist/assets/{model-config-page-DHzyDV5v.js → model-config-page-DL8ljJK-.js} +1 -1
  21. package/dist/assets/{navigation-link-CzGgQGi_.js → navigation-link-BoMNAKyw.js} +1 -1
  22. package/dist/assets/plus-BeGZHC3f.js +1 -0
  23. package/dist/assets/{provider-scoped-model-input-CjrHwzFb.js → provider-scoped-model-input-CB-vojwC.js} +1 -1
  24. package/dist/assets/{providers-config-page-ZPvJWHFN.js → providers-config-page-C80nti5H.js} +1 -1
  25. package/dist/assets/{react-BXRf96P-.js → react-DND3vEAM.js} +1 -1
  26. package/dist/assets/{refresh-cw-ChKuqgPR.js → refresh-cw-DZ_Y89ld.js} +1 -1
  27. package/dist/assets/remote-Codkr9SX.js +1 -0
  28. package/dist/assets/rotate-cw-CP3pQ4R8.js +1 -0
  29. package/dist/assets/{runtime-config-page-CGpPQD8K.js → runtime-config-page-Um1XDqXJ.js} +1 -1
  30. package/dist/assets/{save-BWGNAKV5.js → save-BgMfxs5u.js} +1 -1
  31. package/dist/assets/{search-DFcT_9wJ.js → search-C7ruoV7u.js} +1 -1
  32. package/dist/assets/{search-config-page-DBBzVAQZ.js → search-config-page-BTjfOZ6q.js} +1 -1
  33. package/dist/assets/{secrets-config-page-bnJ8sIB7.js → secrets-config-page-BFjiSz73.js} +1 -1
  34. package/dist/assets/security-config-Dx-wMb4q.js +1 -0
  35. package/dist/assets/{select-BnNrv8si.js → select-BMX2ooE-.js} +1 -1
  36. package/dist/assets/{settings-2-CFHrgKTX.js → settings-2-BNQF6hed.js} +1 -1
  37. package/dist/assets/skeleton-CWZ0IO2D.js +1 -0
  38. package/dist/assets/{tag-chip-L5w4xfS7.js → tag-chip-BL-UXlVO.js} +1 -1
  39. package/dist/assets/x-BSu1xwPK.js +1 -0
  40. package/dist/index.html +19 -19
  41. package/package.json +4 -4
  42. package/src/features/app-data/components/app-data-removal-choice.tsx +43 -0
  43. package/src/features/app-data/components/app-retained-data-section.tsx +179 -0
  44. package/src/features/app-data/components/app-storage-usage.tsx +59 -0
  45. package/src/features/app-data/hooks/use-app-data.ts +26 -0
  46. package/src/features/app-data/index.ts +8 -0
  47. package/src/features/apps/components/__tests__/app-packages-panel.test.tsx +114 -10
  48. package/src/features/apps/components/__tests__/apps-panel.test.tsx +18 -2
  49. package/src/features/apps/components/app-package-card.tsx +98 -53
  50. package/src/features/apps/components/app-packages-panel.tsx +78 -23
  51. package/src/features/apps/components/apps-panel.tsx +14 -3
  52. package/src/features/apps/hooks/use-app-packages.ts +7 -2
  53. package/src/features/apps/managers/__tests__/app-package-operation-settlement.manager.test.ts +5 -0
  54. package/src/features/apps/managers/app-package-operation-settlement.manager.ts +8 -2
  55. package/src/features/service-apps/components/__tests__/service-apps-panel.test.tsx +91 -6
  56. package/src/features/service-apps/components/service-app-delete-dialog.tsx +122 -0
  57. package/src/features/service-apps/components/service-app-list-item.tsx +50 -21
  58. package/src/features/service-apps/components/service-apps-panel.tsx +38 -2
  59. package/src/features/service-apps/hooks/use-service-apps.ts +4 -1
  60. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +26 -1
  61. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +26 -1
  62. package/dist/assets/api-Dket1cmq.js +0 -13
  63. package/dist/assets/doc-browser-Da9hDsoQ.js +0 -1
  64. package/dist/assets/index-Bz5yNMXA.js +0 -109
  65. package/dist/assets/index-DSLK7zgR.css +0 -1
  66. package/dist/assets/loader-circle-DLp2jNIf.js +0 -1
  67. package/dist/assets/mcp-marketplace-page-CIWbjXM8.js +0 -1
  68. package/dist/assets/plus-Dfj3tJ_m.js +0 -1
  69. package/dist/assets/remote-C2enAZbs.js +0 -1
  70. package/dist/assets/rotate-cw-BGkZpcc7.js +0 -1
  71. package/dist/assets/security-config-CfbXSpgX.js +0 -1
  72. package/dist/assets/skeleton-tfWhGuJT.js +0 -1
  73. package/dist/assets/x-BCSE3LCQ.js +0 -1
@@ -1,11 +1,12 @@
1
- import { useMemo, useState, type FormEvent } from 'react';
2
- import type { AppPackageOperationView, AppPackageView } from '@nextclaw/client-sdk';
1
+ import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
2
+ import type { AppDataEntry, AppPackageOperationView, AppPackageView } from '@nextclaw/client-sdk';
3
3
  import { LoaderCircle, PackagePlus, RefreshCw, ShieldCheck, Store } from 'lucide-react';
4
4
  import { AppArtwork } from '@/features/apps/components/app-artwork';
5
5
  import type { PanelAppEntryView } from '@/shared/lib/api';
6
6
  import { useAppPresenter } from '@/app/components/app-presenter-provider';
7
7
  import { AppMarketplaceDialog } from '@/features/apps/components/app-marketplace-dialog';
8
8
  import { AppPackageCard } from '@/features/apps/components/app-package-card';
9
+ import { AppRetainedDataSection, useAppData } from '@/features/app-data';
9
10
  import {
10
11
  useAppPackageMutation,
11
12
  useAppPackageOperationSettlement,
@@ -33,17 +34,21 @@ import { Skeleton } from '@/shared/components/ui/skeleton';
33
34
  import { t } from '@/shared/lib/i18n';
34
35
 
35
36
  export function AppPackagesPanel({
37
+ focusedPackageId,
36
38
  onOpenPanelApp,
37
39
  }: {
40
+ focusedPackageId?: string;
38
41
  onOpenPanelApp: (entry: PanelAppEntryView) => void;
39
42
  }) {
40
43
  const appPackages = useAppPackages();
44
+ const appData = useAppData();
41
45
  const appPackageOperations = useAppPackageOperations();
42
46
  const panelApps = usePanelApps();
43
47
  const lifecycle = useAppPackageMutation();
44
48
  const recordOpened = useRecordPanelAppOpened();
45
49
  const grantClient = useGrantPanelAppClient();
46
50
  const presenter = useAppPresenter();
51
+ const libraryTitleRef = useRef<HTMLHeadingElement>(null);
47
52
  const [installOpen, setInstallOpen] = useState(false);
48
53
  const [installSource, setInstallSource] = useState('');
49
54
  const [marketplaceOpen, setMarketplaceOpen] = useState(false);
@@ -53,11 +58,19 @@ export function AppPackagesPanel({
53
58
  [appPackageOperations.data?.entries],
54
59
  );
55
60
 
61
+ useEffect(() => {
62
+ if (!focusedPackageId || appPackages.isLoading) return;
63
+ const target = document.getElementById(`app-package-${focusedPackageId}`);
64
+ target?.scrollIntoView({ block: 'nearest' });
65
+ target?.focus({ preventScroll: true });
66
+ }, [appPackages.data, appPackages.isLoading, focusedPackageId]);
67
+
56
68
  useAppPackageOperationSettlement({
57
69
  isLoaded: Boolean(appPackageOperations.data),
58
70
  operations,
59
71
  refetchPackages: appPackages.refetch,
60
72
  refetchPanels: panelApps.refetch,
73
+ refetchAppData: appData.refetch,
61
74
  settlementManager: presenter.appPackageOperationSettlementManager,
62
75
  });
63
76
 
@@ -101,6 +114,7 @@ export function AppPackagesPanel({
101
114
  void appPackages.refetch();
102
115
  void appPackageOperations.refetch();
103
116
  void panelApps.refetch();
117
+ void appData.refetch();
104
118
  };
105
119
 
106
120
  return (
@@ -144,7 +158,13 @@ export function AppPackagesPanel({
144
158
  <div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
145
159
  <div className="mx-auto w-full max-w-3xl p-3 sm:p-5">
146
160
  <div className="mb-4 px-1">
147
- <h1 className="text-base font-semibold tracking-tight text-foreground">{t('appPackagesLibraryTitle')}</h1>
161
+ <h1
162
+ ref={libraryTitleRef}
163
+ tabIndex={-1}
164
+ className="text-base font-semibold tracking-tight text-foreground"
165
+ >
166
+ {t('appPackagesLibraryTitle')}
167
+ </h1>
148
168
  <p className="mt-1 text-xs leading-5 text-muted-foreground">
149
169
  {t('appPackagesLibraryDescription')}
150
170
  </p>
@@ -157,7 +177,11 @@ export function AppPackagesPanel({
157
177
  ) : null}
158
178
 
159
179
  <AppPackageLibrary
180
+ appDataEntries={appData.data?.entries ?? []}
181
+ appDataLoading={appData.isLoading}
182
+ appDataUnavailable={appData.isError}
160
183
  error={getPackageLoadError(appPackages.error, panelApps.error)}
184
+ focusedPackageId={focusedPackageId}
161
185
  isLoading={appPackages.isLoading || panelApps.isLoading}
162
186
  isPending={lifecycle.isPending}
163
187
  mutationAppId={'appId' in (lifecycle.variables ?? {})
@@ -170,6 +194,13 @@ export function AppPackagesPanel({
170
194
  onMutate={runMutation}
171
195
  onOpenPanelApp={(entry) => void openPanelApp(entry)}
172
196
  />
197
+ <AppRetainedDataSection
198
+ diagnostics={appData.data?.diagnostics ?? []}
199
+ entries={appData.data?.entries ?? []}
200
+ error={appData.error}
201
+ isLoading={appData.isLoading}
202
+ onDeletionFocusReturn={() => libraryTitleRef.current?.focus()}
203
+ />
173
204
  </div>
174
205
  </div>
175
206
 
@@ -232,7 +263,11 @@ export function AppPackagesPanel({
232
263
  }
233
264
 
234
265
  function AppPackageLibrary({
266
+ appDataEntries,
267
+ appDataLoading,
268
+ appDataUnavailable,
235
269
  error,
270
+ focusedPackageId,
236
271
  isLoading,
237
272
  isPending,
238
273
  mutationAppId,
@@ -243,7 +278,11 @@ function AppPackageLibrary({
243
278
  panelApps,
244
279
  operations,
245
280
  }: {
281
+ appDataEntries: AppDataEntry[];
282
+ appDataLoading: boolean;
283
+ appDataUnavailable: boolean;
246
284
  error?: string;
285
+ focusedPackageId?: string;
247
286
  isLoading: boolean;
248
287
  isPending: boolean;
249
288
  mutationAppId?: string;
@@ -310,27 +349,43 @@ function AppPackageLibrary({
310
349
  : undefined;
311
350
 
312
351
  return (
313
- <AppPackageCard
352
+ <section
314
353
  key={appPackage.id}
315
- appPackage={appPackage}
316
- panelApps={panelApps.filter((entry) => entry.packageId === appPackage.id)}
317
- isPending={isPending && mutationAppId === appPackage.id}
318
- operation={visibleOperation}
319
- onEnable={() => onMutate({ action: 'enable', appId: appPackage.id })}
320
- onDisable={() => onMutate({ action: 'disable', appId: appPackage.id })}
321
- onUpdate={() => onMutate({ action: 'update', appId: appPackage.id })}
322
- onRollback={(version) => onMutate({
323
- action: 'rollback',
324
- appId: appPackage.id,
325
- version,
326
- })}
327
- onUninstall={(purgeData) => onMutate({
328
- action: 'uninstall',
329
- appId: appPackage.id,
330
- purgeData,
331
- })}
332
- onOpenPanelApp={onOpenPanelApp}
333
- />
354
+ id={`app-package-${appPackage.id}`}
355
+ aria-label={appPackage.name}
356
+ tabIndex={-1}
357
+ className={focusedPackageId === appPackage.id
358
+ ? 'rounded-xl outline-none ring-2 ring-primary/35 ring-offset-2 ring-offset-background'
359
+ : 'rounded-xl outline-none'}
360
+ >
361
+ <AppPackageCard
362
+ appPackage={appPackage}
363
+ storageUsage={appDataEntries.find((entry) =>
364
+ entry.source === 'package' &&
365
+ entry.lifecycle === 'active' &&
366
+ entry.appId === appPackage.id &&
367
+ entry.instanceId === appPackage.instanceId)?.usage ?? appPackage.storageUsage}
368
+ storageUsageLoading={appDataLoading}
369
+ storageUsageUnavailable={appDataUnavailable}
370
+ panelApps={panelApps.filter((entry) => entry.packageId === appPackage.id)}
371
+ isPending={isPending && mutationAppId === appPackage.id}
372
+ operation={visibleOperation}
373
+ onEnable={() => onMutate({ action: 'enable', appId: appPackage.id })}
374
+ onDisable={() => onMutate({ action: 'disable', appId: appPackage.id })}
375
+ onUpdate={() => onMutate({ action: 'update', appId: appPackage.id })}
376
+ onRollback={(version) => onMutate({
377
+ action: 'rollback',
378
+ appId: appPackage.id,
379
+ version,
380
+ })}
381
+ onUninstall={(purgeData) => onMutate({
382
+ action: 'uninstall',
383
+ appId: appPackage.id,
384
+ purgeData,
385
+ })}
386
+ onOpenPanelApp={onOpenPanelApp}
387
+ />
388
+ </section>
334
389
  );
335
390
  })}
336
391
  </div>
@@ -1,3 +1,4 @@
1
+ import { useState } from 'react';
1
2
  import { AppWindow, Boxes, Server } from 'lucide-react';
2
3
  import type { PanelAppEntryView } from '@/shared/lib/api';
3
4
  import { AppPackagesPanel } from '@/features/apps/components/app-packages-panel';
@@ -17,10 +18,20 @@ export function AppsPanel({
17
18
  onActiveTabChange: (tab: AppsPanelTab) => void;
18
19
  onOpenPanelApp: (entry: PanelAppEntryView) => void;
19
20
  }) {
21
+ const [focusedPackageId, setFocusedPackageId] = useState<string>();
22
+ const changeTab = (tab: AppsPanelTab) => {
23
+ setFocusedPackageId(undefined);
24
+ onActiveTabChange(tab);
25
+ };
26
+ const managePackage = (packageId: string) => {
27
+ setFocusedPackageId(packageId);
28
+ onActiveTabChange('apps');
29
+ };
30
+
20
31
  return (
21
32
  <div className="flex h-full min-h-0 flex-col bg-card text-card-foreground">
22
33
  <nav className="shrink-0 border-b border-border/70 px-4 py-3" aria-label={t('appsTitle')}>
23
- <Tabs value={activeTab} onValueChange={(value) => onActiveTabChange(value as AppsPanelTab)}>
34
+ <Tabs value={activeTab} onValueChange={(value) => changeTab(value as AppsPanelTab)}>
24
35
  <TabsList className="grid h-auto w-full max-w-[390px] grid-cols-3 rounded-lg bg-muted/70 p-0.5">
25
36
  <TabsTrigger value="apps" className="min-w-0 gap-1.5 px-2 py-1.5 text-xs">
26
37
  <Boxes className="h-3.5 w-3.5 shrink-0" />
@@ -39,11 +50,11 @@ export function AppsPanel({
39
50
  </nav>
40
51
  <div className="min-h-0 flex-1">
41
52
  {activeTab === 'apps' ? (
42
- <AppPackagesPanel onOpenPanelApp={onOpenPanelApp} />
53
+ <AppPackagesPanel focusedPackageId={focusedPackageId} onOpenPanelApp={onOpenPanelApp} />
43
54
  ) : activeTab === 'panel-apps' ? (
44
55
  <PanelAppsList onOpenPanelApp={onOpenPanelApp} />
45
56
  ) : (
46
- <ServiceAppsPanel />
57
+ <ServiceAppsPanel onManagePackage={managePackage} />
47
58
  )}
48
59
  </div>
49
60
  </div>
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
3
3
  import type { AppPackageOperationStatus, AppPackageOperationView } from '@nextclaw/client-sdk';
4
4
  import { nextclawClient } from '@/shared/lib/api';
5
5
  import type { AppPackageOperationSettlementManager } from '@/features/apps/managers/app-package-operation-settlement.manager';
6
+ import { APP_DATA_QUERY_KEY } from '@/features/app-data';
6
7
 
7
8
  const APP_PACKAGES_QUERY_KEY = ['app-packages'] as const;
8
9
  const PANEL_APPS_QUERY_KEY = ['panel-apps'] as const;
@@ -30,7 +31,7 @@ export type AppPackageMutationInput =
30
31
  export function useAppPackages() {
31
32
  return useQuery({
32
33
  queryKey: APP_PACKAGES_QUERY_KEY,
33
- queryFn: () => nextclawClient.appPackages.list(),
34
+ queryFn: () => nextclawClient.appPackages.list({ includeStorageUsage: false }),
34
35
  staleTime: 0,
35
36
  });
36
37
  }
@@ -56,6 +57,7 @@ export function useAppPackageOperationSettlement({
56
57
  operations,
57
58
  refetchPackages,
58
59
  refetchPanels,
60
+ refetchAppData,
59
61
  settlementManager,
60
62
  }: {
61
63
  isLoaded: boolean;
@@ -64,6 +66,7 @@ export function useAppPackageOperationSettlement({
64
66
  refetchPanels: () => Promise<{
65
67
  data?: { entries: Array<{ id: string; packageId?: string }> };
66
68
  }>;
69
+ refetchAppData: () => Promise<unknown>;
67
70
  settlementManager: AppPackageOperationSettlementManager;
68
71
  }) {
69
72
  useEffect(() => {
@@ -72,8 +75,9 @@ export function useAppPackageOperationSettlement({
72
75
  operations,
73
76
  refetchPackages,
74
77
  refetchPanels,
78
+ refetchAppData,
75
79
  });
76
- }, [isLoaded, operations, refetchPackages, refetchPanels, settlementManager]);
80
+ }, [isLoaded, operations, refetchPackages, refetchPanels, refetchAppData, settlementManager]);
77
81
  }
78
82
 
79
83
  export function useAppPackageMutation() {
@@ -108,6 +112,7 @@ export function useAppPackageMutation() {
108
112
  queryClient.invalidateQueries({ queryKey: SERVICE_APPS_QUERY_KEY }),
109
113
  queryClient.invalidateQueries({ queryKey: SERVICE_ACTIONS_QUERY_KEY }),
110
114
  queryClient.invalidateQueries({ queryKey: APP_PACKAGE_OPERATIONS_QUERY_KEY }),
115
+ queryClient.invalidateQueries({ queryKey: APP_DATA_QUERY_KEY }),
111
116
  ]);
112
117
  },
113
118
  });
@@ -36,6 +36,7 @@ describe('AppPackageOperationSettlementManager', () => {
36
36
  it('ignores historical terminal operations and settles each new operation once', async () => {
37
37
  const reloadByDedupeKeys = vi.fn();
38
38
  const refetchPackages = vi.fn(async () => undefined);
39
+ const refetchAppData = vi.fn(async () => undefined);
39
40
  const refetchPanels = vi.fn(async () => ({
40
41
  data: {
41
42
  entries: [
@@ -54,6 +55,7 @@ describe('AppPackageOperationSettlementManager', () => {
54
55
  operations: [historical],
55
56
  refetchPackages,
56
57
  refetchPanels,
58
+ refetchAppData,
57
59
  });
58
60
  expect(mocks.success).not.toHaveBeenCalled();
59
61
 
@@ -63,17 +65,20 @@ describe('AppPackageOperationSettlementManager', () => {
63
65
  operations: [completed, historical],
64
66
  refetchPackages,
65
67
  refetchPanels,
68
+ refetchAppData,
66
69
  });
67
70
  manager.settle({
68
71
  isLoaded: true,
69
72
  operations: [completed, historical],
70
73
  refetchPackages,
71
74
  refetchPanels,
75
+ refetchAppData,
72
76
  });
73
77
 
74
78
  await vi.waitFor(() => {
75
79
  expect(refetchPackages).toHaveBeenCalledTimes(1);
76
80
  expect(refetchPanels).toHaveBeenCalledTimes(1);
81
+ expect(refetchAppData).toHaveBeenCalledTimes(1);
77
82
  expect(reloadByDedupeKeys).toHaveBeenCalledWith(['panel-app:todos']);
78
83
  });
79
84
  expect(mocks.success).toHaveBeenCalledTimes(1);
@@ -19,11 +19,13 @@ export class AppPackageOperationSettlementManager {
19
19
  operations,
20
20
  refetchPackages,
21
21
  refetchPanels,
22
+ refetchAppData,
22
23
  }: {
23
24
  isLoaded: boolean;
24
25
  operations: AppPackageOperationView[];
25
26
  refetchPackages: () => Promise<unknown>;
26
27
  refetchPanels: RefetchPanels;
28
+ refetchAppData: () => Promise<unknown>;
27
29
  }): void => {
28
30
  if (!isLoaded) return;
29
31
  const terminal = operations.filter(
@@ -37,7 +39,7 @@ export class AppPackageOperationSettlementManager {
37
39
  terminal.forEach((operation) => {
38
40
  if (this.knownTerminalOperationIds.has(operation.id)) return;
39
41
  this.knownTerminalOperationIds.add(operation.id);
40
- this.settleOperation(operation, refetchPackages, refetchPanels);
42
+ this.settleOperation(operation, refetchPackages, refetchPanels, refetchAppData);
41
43
  });
42
44
  };
43
45
 
@@ -45,10 +47,13 @@ export class AppPackageOperationSettlementManager {
45
47
  operation: AppPackageOperationView,
46
48
  refetchPackages: () => Promise<unknown>,
47
49
  refetchPanels: RefetchPanels,
50
+ refetchAppData: () => Promise<unknown>,
48
51
  ): void => {
49
52
  if (operation.status === 'succeeded') {
50
53
  toast.success(operationSuccessLabel(operation));
51
- void Promise.all([refetchPackages(), refetchPanels()]).then(([, panelsResult]) => {
54
+ void refetchPackages();
55
+ void refetchAppData();
56
+ void refetchPanels().then((panelsResult) => {
52
57
  if (operation.action !== 'rollback' && operation.action !== 'update') return;
53
58
  this.docBrowserManager.reloadByDedupeKeys(
54
59
  panelsResult.data?.entries
@@ -61,6 +66,7 @@ export class AppPackageOperationSettlementManager {
61
66
  if (operation.status === 'failed' || operation.status === 'interrupted') {
62
67
  void refetchPackages();
63
68
  void refetchPanels();
69
+ void refetchAppData();
64
70
  toast.error(operation.error ?? t('appPackagesActionFailed'));
65
71
  }
66
72
  };
@@ -8,13 +8,59 @@ const discoverServiceAppActionsMutateAsync = vi.fn(async () => ({ actions: [] })
8
8
  const refetchServiceActions = vi.fn();
9
9
  const refetchServiceActionGrants = vi.fn();
10
10
  const refetchServiceApps = vi.fn();
11
+ const refetchAppData = vi.fn();
11
12
  const restartServiceAppMutate = vi.fn();
12
13
  const revokeServiceActionGrantMutate = vi.fn();
14
+ const resetDeleteServiceApp = vi.fn();
15
+
16
+ vi.mock('@/features/app-data/hooks/use-app-data', () => ({
17
+ useAppData: () => ({
18
+ data: {
19
+ entries: [{
20
+ id: 'ad1.workspace-notes',
21
+ appId: 'notes',
22
+ instanceId: 'default',
23
+ displayName: 'Notes',
24
+ source: 'workspace-service',
25
+ lifecycle: 'active',
26
+ storage: {
27
+ layout: 'instance-v1',
28
+ layoutVersion: 1,
29
+ instanceId: 'default',
30
+ instanceDirectory: '/workspace/.nextclaw/app-instances/notes/default',
31
+ dataDirectory: '/workspace/.nextclaw/app-instances/notes/default/data',
32
+ configDirectory: '/workspace/.nextclaw/app-instances/notes/default/config',
33
+ stateDirectory: '/workspace/.nextclaw/app-instances/notes/default/state',
34
+ cacheDirectory: '/workspace/.nextclaw/app-instances/notes/default/cache',
35
+ temporaryDirectory: '/workspace/.nextclaw/app-instances/notes/default/tmp',
36
+ logsDirectory: '/workspace/.nextclaw/app-instances/notes/default/logs',
37
+ },
38
+ usage: {
39
+ dataBytes: 12,
40
+ configBytes: 0,
41
+ stateBytes: 0,
42
+ cacheBytes: 0,
43
+ temporaryBytes: 0,
44
+ logsBytes: 0,
45
+ totalBytes: 12,
46
+ },
47
+ createdAt: '2026-08-14T00:00:00.000Z',
48
+ actions: { deleteRetainedData: false },
49
+ }],
50
+ diagnostics: [],
51
+ },
52
+ error: null,
53
+ isLoading: false,
54
+ refetch: refetchAppData,
55
+ }),
56
+ }));
13
57
 
14
58
  vi.mock('@/features/service-apps/hooks/use-service-apps', () => ({
15
59
  useDeleteServiceApp: () => ({
60
+ error: null,
16
61
  isPending: false,
17
62
  mutate: deleteServiceAppMutate,
63
+ reset: resetDeleteServiceApp,
18
64
  }),
19
65
  useDiscoverServiceAppActions: () => ({
20
66
  isPending: false,
@@ -49,9 +95,24 @@ vi.mock('@/features/service-apps/hooks/use-service-apps', () => ({
49
95
  id: 'notes',
50
96
  manifestPath: '/workspace/service-apps/notes/service-app.json',
51
97
  protocol: 'mcp',
98
+ sourceKind: 'workspace',
52
99
  status: 'idle',
53
100
  title: 'Notes',
101
+ }, {
102
+ args: ['server.mjs'],
103
+ command: 'node',
104
+ cwd: '/apps/personal-organizer/service',
105
+ dirPath: '/apps/personal-organizer/service',
106
+ enabled: true,
107
+ id: 'organizer-service',
108
+ manifestPath: '/apps/personal-organizer/service/service-app.json',
109
+ packageId: 'nextclaw.personal-organizer',
110
+ protocol: 'mcp',
111
+ sourceKind: 'package',
112
+ status: 'idle',
113
+ title: 'Organizer Service',
54
114
  }],
115
+ diagnostics: [],
55
116
  serviceAppsPath: '/workspace/service-apps',
56
117
  workspacePath: '/workspace',
57
118
  },
@@ -65,11 +126,13 @@ describe('ServiceAppsPanel', () => {
65
126
  it('keeps service actions progressive and deletes through a confirm dialog', async () => {
66
127
  const user = userEvent.setup();
67
128
 
68
- render(<ServiceAppsPanel />);
129
+ render(<ServiceAppsPanel onManagePackage={vi.fn()} />);
69
130
 
70
- expect(screen.getByText('Not connected')).toBeTruthy();
131
+ expect(screen.getAllByText('Not connected')).toHaveLength(2);
71
132
  expect(screen.queryByText('idle')).toBeNull();
72
- const discoverButton = screen.getByRole('button', { name: 'Connect and discover actions' });
133
+ const discoverButton = screen.getAllByRole('button', {
134
+ name: 'Connect and discover actions',
135
+ })[0]!;
73
136
  expect(discoverButton).toBeTruthy();
74
137
  await user.hover(discoverButton);
75
138
  await waitFor(() => {
@@ -79,11 +142,33 @@ describe('ServiceAppsPanel', () => {
79
142
  await user.click(discoverButton);
80
143
  expect(discoverServiceAppActionsMutateAsync).toHaveBeenCalledWith('notes');
81
144
 
82
- fireEvent.click(screen.getByRole('button', { name: 'More actions' }));
145
+ fireEvent.click(screen.getAllByRole('button', { name: 'More actions' })[0]!);
83
146
  expect((screen.getByRole('button', { name: 'Disconnect runtime' }) as HTMLButtonElement).disabled).toBe(true);
84
147
  fireEvent.click(screen.getByRole('button', { name: 'Delete service app' }));
85
- fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
148
+ await waitFor(() => {
149
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Cancel' }));
150
+ });
151
+ expect(resetDeleteServiceApp).toHaveBeenCalled();
152
+ expect(screen.getByText('Total app data')).toBeTruthy();
153
+ expect(screen.getByRole('button', { name: /Keep personal data/ }).getAttribute('aria-pressed'))
154
+ .toBe('true');
155
+ const deleteButtons = screen.getAllByRole('button', { name: 'Delete service app' });
156
+ fireEvent.click(deleteButtons.at(-1)!);
157
+
158
+ expect(deleteServiceAppMutate).toHaveBeenCalledWith(
159
+ { appId: 'notes', purgeData: false },
160
+ { onSuccess: expect.any(Function) },
161
+ );
162
+ });
163
+
164
+ it('routes package-managed services back to Apps without offering standalone deletion', () => {
165
+ const onManagePackage = vi.fn();
166
+ render(<ServiceAppsPanel onManagePackage={onManagePackage} />);
167
+
168
+ fireEvent.click(screen.getAllByRole('button', { name: 'More actions' })[1]!);
169
+ expect(screen.queryByRole('button', { name: 'Delete service app' })).toBeNull();
170
+ fireEvent.click(screen.getByRole('button', { name: 'Manage in Apps' }));
86
171
 
87
- expect(deleteServiceAppMutate).toHaveBeenCalledWith('notes');
172
+ expect(onManagePackage).toHaveBeenCalledWith('nextclaw.personal-organizer');
88
173
  });
89
174
  });
@@ -0,0 +1,122 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { AppDataEntry } from "@nextclaw/client-sdk";
3
+ import { LoaderCircle } from "lucide-react";
4
+ import { AppDataRemovalChoice, AppStorageUsageDetails } from "@/features/app-data";
5
+ import { Button } from "@/shared/components/ui/button";
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from "@/shared/components/ui/dialog";
14
+ import { t } from "@/shared/lib/i18n";
15
+
16
+ export function ServiceAppDeleteDialog({
17
+ appId,
18
+ appTitle,
19
+ dataEntry,
20
+ dataLoading,
21
+ deleteError,
22
+ deletePending,
23
+ open,
24
+ purgeData,
25
+ onDelete,
26
+ onOpenChange,
27
+ onPurgeDataChange,
28
+ }: {
29
+ appId: string;
30
+ appTitle: string;
31
+ dataEntry?: AppDataEntry;
32
+ dataLoading: boolean;
33
+ deleteError: unknown;
34
+ deletePending: boolean;
35
+ open: boolean;
36
+ purgeData: boolean;
37
+ onDelete: (appId: string, purgeData: boolean, onSuccess: () => void) => void;
38
+ onOpenChange: (open: boolean) => void;
39
+ onPurgeDataChange: (purgeData: boolean) => void;
40
+ }) {
41
+ const errorRef = useRef<HTMLParagraphElement>(null);
42
+
43
+ useEffect(() => {
44
+ if (deleteError) errorRef.current?.focus();
45
+ }, [deleteError]);
46
+
47
+ const close = () => onOpenChange(false);
48
+ return (
49
+ <Dialog
50
+ open={open}
51
+ onOpenChange={(nextOpen) => !deletePending && onOpenChange(nextOpen)}
52
+ >
53
+ <DialogContent
54
+ className="max-w-md [&>:last-child]:hidden"
55
+ onEscapeKeyDown={(event) => deletePending && event.preventDefault()}
56
+ onInteractOutside={(event) => deletePending && event.preventDefault()}
57
+ >
58
+ <DialogHeader>
59
+ <DialogTitle>{t("serviceAppsDeleteConfirmTitle")}</DialogTitle>
60
+ <DialogDescription>
61
+ {t("serviceAppsDeleteConfirmDescription")} {appTitle}
62
+ </DialogDescription>
63
+ </DialogHeader>
64
+ <div className="mt-4 grid gap-2">
65
+ <AppDataRemovalChoice
66
+ checked={!purgeData}
67
+ description={t("serviceAppsKeepDataDescription")}
68
+ label={t("appPackagesKeepData")}
69
+ onClick={() => onPurgeDataChange(false)}
70
+ />
71
+ <AppDataRemovalChoice
72
+ checked={purgeData}
73
+ destructive
74
+ disabled={!dataEntry || dataLoading}
75
+ description={t("serviceAppsDeleteDataDescription")}
76
+ label={t("appPackagesDeleteData")}
77
+ onClick={() => onPurgeDataChange(true)}
78
+ />
79
+ </div>
80
+ {dataEntry ? (
81
+ <div className="mt-3">
82
+ <AppStorageUsageDetails storage={dataEntry.storage} usage={dataEntry.usage} />
83
+ </div>
84
+ ) : (
85
+ <p role="status" className="mt-3 text-xs text-muted-foreground">
86
+ {dataLoading ? t("appPackagesLoading") : t("serviceAppsDataUnavailable")}
87
+ </p>
88
+ )}
89
+ {deleteError ? (
90
+ <p
91
+ ref={errorRef}
92
+ role="alert"
93
+ tabIndex={-1}
94
+ className="mt-3 text-xs text-destructive"
95
+ >
96
+ {deleteError instanceof Error ? deleteError.message : t("appPackagesActionFailed")}
97
+ </p>
98
+ ) : null}
99
+ <DialogFooter className="mt-5 gap-2 sm:gap-0">
100
+ <Button
101
+ autoFocus
102
+ type="button"
103
+ variant="outline"
104
+ disabled={deletePending}
105
+ onClick={close}
106
+ >
107
+ {t("cancel")}
108
+ </Button>
109
+ <Button
110
+ type="button"
111
+ variant={purgeData ? "destructive" : "default"}
112
+ disabled={deletePending || (purgeData && !dataEntry)}
113
+ onClick={() => onDelete(appId, purgeData, close)}
114
+ >
115
+ {deletePending ? <LoaderCircle className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
116
+ {purgeData ? t("appDataDeletePermanently") : t("serviceAppsDelete")}
117
+ </Button>
118
+ </DialogFooter>
119
+ </DialogContent>
120
+ </Dialog>
121
+ );
122
+ }