@nextclaw/ui 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/assets/api-CVjdSvJk.js +13 -0
  3. package/dist/assets/{appearance-settings-page-CfwDf9ag.js → appearance-settings-page-BOorfaUx.js} +1 -1
  4. package/dist/assets/{channels-list-page-Dyn83ZlS.js → channels-list-page-DuZJvYB6.js} +1 -1
  5. package/dist/assets/{chat-page-CL0IMvgF.js → chat-page-8PTMgI0t.js} +1 -1
  6. package/dist/assets/{config-split-page-D4D4HFhq.js → config-split-page-UuhsVcQD.js} +1 -1
  7. package/dist/assets/{confirm-dialog-Cv6-9Nnj.js → confirm-dialog-Bnosa8UF.js} +1 -1
  8. package/dist/assets/{desktop-update-config-Bckgulvm.js → desktop-update-config-CM4lsX4R.js} +1 -1
  9. package/dist/assets/{dist-BrRgjiq1.js → dist-D17RZAsz.js} +1 -1
  10. package/dist/assets/{dist-Bh_tj3FN.js → dist-_zZ8P38T.js} +1 -1
  11. package/dist/assets/{doc-browser-BlWjhmnM.js → doc-browser-CiLvybSC.js} +1 -1
  12. package/dist/assets/{doc-browser-context-NGYa66Xg.js → doc-browser-context-CerkiK_N.js} +1 -1
  13. package/dist/assets/doc-browser-z7vdH3Ez.js +1 -0
  14. package/dist/assets/{form-actions-CeKgh4SQ.js → form-actions-Cmv23dN4.js} +1 -1
  15. package/dist/assets/index-CWdgBpSc.css +1 -0
  16. package/dist/assets/index-DlL8uKXu.js +109 -0
  17. package/dist/assets/loader-circle-vUsFtTfb.js +1 -0
  18. package/dist/assets/mcp-marketplace-page-BV8WB3_L.js +1 -0
  19. package/dist/assets/{mcp-marketplace-page-DfNmMqKs.js → mcp-marketplace-page-DN_fqcLd.js} +1 -1
  20. package/dist/assets/{model-config-page-DHzyDV5v.js → model-config-page-BxaeCMbJ.js} +1 -1
  21. package/dist/assets/{navigation-link-CzGgQGi_.js → navigation-link-k9rsmtC4.js} +1 -1
  22. package/dist/assets/plus-Dc760kHj.js +1 -0
  23. package/dist/assets/{provider-scoped-model-input-CjrHwzFb.js → provider-scoped-model-input-DLSAB6a5.js} +1 -1
  24. package/dist/assets/{providers-config-page-ZPvJWHFN.js → providers-config-page-DM5N0aeS.js} +1 -1
  25. package/dist/assets/{react-BXRf96P-.js → react-BgRLh3ee.js} +1 -1
  26. package/dist/assets/{refresh-cw-ChKuqgPR.js → refresh-cw-axvJLmRG.js} +1 -1
  27. package/dist/assets/remote-Cj1GBMaN.js +1 -0
  28. package/dist/assets/rotate-cw-CFByP2O8.js +1 -0
  29. package/dist/assets/{runtime-config-page-CGpPQD8K.js → runtime-config-page-WNzZyjMW.js} +1 -1
  30. package/dist/assets/{save-BWGNAKV5.js → save-DbR4SGk8.js} +1 -1
  31. package/dist/assets/{search-DFcT_9wJ.js → search-M9lrb8id.js} +1 -1
  32. package/dist/assets/{search-config-page-DBBzVAQZ.js → search-config-page-DmsID8FQ.js} +1 -1
  33. package/dist/assets/{secrets-config-page-bnJ8sIB7.js → secrets-config-page-C10OwI0b.js} +1 -1
  34. package/dist/assets/security-config-CvU5JxDT.js +1 -0
  35. package/dist/assets/{select-BnNrv8si.js → select-D_YBzMdv.js} +1 -1
  36. package/dist/assets/{settings-2-CFHrgKTX.js → settings-2-zy40UwTo.js} +1 -1
  37. package/dist/assets/skeleton-BmAtzjDM.js +1 -0
  38. package/dist/assets/{tag-chip-L5w4xfS7.js → tag-chip-D7QRdcph.js} +1 -1
  39. package/dist/assets/x-D9Y9hf_r.js +1 -0
  40. package/dist/index.html +19 -19
  41. package/package.json +6 -6
  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 +72 -1
  48. package/src/features/apps/components/app-package-card.tsx +42 -51
  49. package/src/features/apps/components/app-packages-panel.tsx +19 -2
  50. package/src/features/apps/hooks/use-app-packages.ts +2 -0
  51. package/src/features/service-apps/components/__tests__/service-apps-panel.test.tsx +59 -2
  52. package/src/features/service-apps/components/service-app-delete-dialog.tsx +122 -0
  53. package/src/features/service-apps/components/service-app-list-item.tsx +24 -9
  54. package/src/features/service-apps/components/service-apps-panel.tsx +15 -1
  55. package/src/features/service-apps/hooks/use-service-apps.ts +4 -1
  56. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +23 -1
  57. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +23 -1
  58. package/dist/assets/api-Dket1cmq.js +0 -13
  59. package/dist/assets/doc-browser-Da9hDsoQ.js +0 -1
  60. package/dist/assets/index-Bz5yNMXA.js +0 -109
  61. package/dist/assets/index-DSLK7zgR.css +0 -1
  62. package/dist/assets/loader-circle-DLp2jNIf.js +0 -1
  63. package/dist/assets/mcp-marketplace-page-CIWbjXM8.js +0 -1
  64. package/dist/assets/plus-Dfj3tJ_m.js +0 -1
  65. package/dist/assets/remote-C2enAZbs.js +0 -1
  66. package/dist/assets/rotate-cw-BGkZpcc7.js +0 -1
  67. package/dist/assets/security-config-CfbXSpgX.js +0 -1
  68. package/dist/assets/skeleton-tfWhGuJT.js +0 -1
  69. package/dist/assets/x-BCSE3LCQ.js +0 -1
@@ -0,0 +1,59 @@
1
+ import type { AppDataEntry } from '@nextclaw/client-sdk';
2
+ import { HardDrive } from 'lucide-react';
3
+ import { t } from '@/shared/lib/i18n';
4
+
5
+ type Storage = AppDataEntry['storage'];
6
+ type Usage = AppDataEntry['usage'];
7
+
8
+ export function AppStorageUsageDetails({
9
+ storage,
10
+ usage,
11
+ }: {
12
+ storage: Storage;
13
+ usage: Usage;
14
+ }) {
15
+ const rows = [
16
+ [t('appDataClassData'), usage.dataBytes],
17
+ [t('appDataClassConfig'), usage.configBytes],
18
+ [t('appDataClassState'), usage.stateBytes],
19
+ [t('appDataClassCache'), usage.cacheBytes],
20
+ [t('appDataClassTemporary'), usage.temporaryBytes],
21
+ [t('appDataClassLogs'), usage.logsBytes],
22
+ ] as const;
23
+ return (
24
+ <div className="rounded-xl bg-muted/45 px-3 py-2.5">
25
+ <div className="flex items-center justify-between gap-3 text-xs">
26
+ <span className="inline-flex items-center gap-1.5 font-medium text-foreground">
27
+ <HardDrive className="h-3.5 w-3.5" />
28
+ {t('appDataStorageTotal')}
29
+ </span>
30
+ <span className="tabular-nums text-foreground">{formatBytes(usage.totalBytes)}</span>
31
+ </div>
32
+ <dl className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-[11px] sm:grid-cols-3">
33
+ {rows.map(([label, bytes]) => (
34
+ <div key={label} className="flex min-w-0 items-center justify-between gap-2">
35
+ <dt className="truncate text-muted-foreground">{label}</dt>
36
+ <dd className="shrink-0 tabular-nums text-foreground/80">{formatBytes(bytes)}</dd>
37
+ </div>
38
+ ))}
39
+ </dl>
40
+ <div className="mt-2 border-t border-border/50 pt-2">
41
+ <div className="text-[10px] text-muted-foreground">{t('appDataStoragePath')}</div>
42
+ <code
43
+ aria-label={t('appDataStoragePath')}
44
+ className="mt-0.5 block select-all break-all font-mono text-[10px] leading-4 text-foreground/75"
45
+ tabIndex={0}
46
+ >
47
+ {storage.instanceDirectory}
48
+ </code>
49
+ </div>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ export function formatBytes(value: number): string {
55
+ if (value < 1024) return `${value} B`;
56
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
57
+ if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`;
58
+ return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`;
59
+ }
@@ -0,0 +1,26 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { toast } from 'sonner';
3
+ import { nextclawClient } from '@/shared/lib/api';
4
+ import { t } from '@/shared/lib/i18n';
5
+
6
+ export const APP_DATA_QUERY_KEY = ['app-data'] as const;
7
+
8
+ export function useAppData() {
9
+ return useQuery({
10
+ queryKey: APP_DATA_QUERY_KEY,
11
+ queryFn: () => nextclawClient.appData.list(),
12
+ staleTime: 0,
13
+ });
14
+ }
15
+
16
+ export function useDeleteRetainedAppData() {
17
+ const queryClient = useQueryClient();
18
+ return useMutation({
19
+ mutationFn: ({ dataId, confirmAppId }: { dataId: string; confirmAppId: string }) =>
20
+ nextclawClient.appData.deleteRetained(dataId, confirmAppId),
21
+ onSuccess: async () => {
22
+ await queryClient.invalidateQueries({ queryKey: APP_DATA_QUERY_KEY });
23
+ toast.success(t('appDataDeleteCompleted'));
24
+ },
25
+ });
26
+ }
@@ -0,0 +1,8 @@
1
+ export { AppDataRemovalChoice } from './components/app-data-removal-choice';
2
+ export { AppRetainedDataSection } from './components/app-retained-data-section';
3
+ export { AppStorageUsageDetails, formatBytes } from './components/app-storage-usage';
4
+ export {
5
+ APP_DATA_QUERY_KEY,
6
+ useAppData,
7
+ useDeleteRetainedAppData,
8
+ } from './hooks/use-app-data';
@@ -1,4 +1,4 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { act, render, screen, waitFor } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import type { AppPackageOperationView } from '@nextclaw/client-sdk';
4
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -15,9 +15,29 @@ const mocks = vi.hoisted(() => ({
15
15
  refetchOperations: vi.fn(),
16
16
  refetchPackages: vi.fn(),
17
17
  refetchPanels: vi.fn(),
18
+ refetchAppData: vi.fn(),
19
+ deleteRetained: vi.fn(),
20
+ deleteRetainedReset: vi.fn(),
21
+ retainedEntries: [] as Array<Record<string, unknown>>,
18
22
  requestAuthorization: vi.fn(async () => true),
19
23
  }));
20
24
 
25
+ vi.mock('@/features/app-data/hooks/use-app-data', () => ({
26
+ useAppData: () => ({
27
+ data: { entries: mocks.retainedEntries, diagnostics: [] },
28
+ error: null,
29
+ isLoading: false,
30
+ refetch: mocks.refetchAppData,
31
+ }),
32
+ useDeleteRetainedAppData: () => ({
33
+ error: null,
34
+ isError: false,
35
+ isPending: false,
36
+ mutate: mocks.deleteRetained,
37
+ reset: mocks.deleteRetainedReset,
38
+ }),
39
+ }));
40
+
21
41
  vi.mock('@/app/components/app-presenter-provider', () => ({
22
42
  useAppPresenter: () => ({
23
43
  appPackageOperationSettlementManager: { settle: vi.fn() },
@@ -227,6 +247,10 @@ describe('AppPackagesPanel', () => {
227
247
  mocks.refetchPackages.mockReset();
228
248
  mocks.refetchOperations.mockReset();
229
249
  mocks.refetchPanels.mockReset();
250
+ mocks.refetchAppData.mockReset();
251
+ mocks.deleteRetained.mockReset();
252
+ mocks.deleteRetainedReset.mockReset();
253
+ mocks.retainedEntries = [];
230
254
  mocks.requestAuthorization.mockReset();
231
255
  mocks.requestAuthorization.mockResolvedValue(true);
232
256
  });
@@ -300,8 +324,13 @@ describe('AppPackagesPanel', () => {
300
324
  await user.click(screen.getByRole('button', { name: 'More app actions' }));
301
325
  await user.click(screen.getByRole('button', { name: 'Uninstall' }));
302
326
  expect(screen.getByRole('heading', { name: 'Uninstall this app?' })).toBeTruthy();
327
+ await waitFor(() => {
328
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Cancel' }));
329
+ });
303
330
  expect(screen.getByRole('button', { name: /Keep personal data/ }).getAttribute('aria-pressed'))
304
331
  .toBe('true');
332
+ expect(screen.getByText('Total app data')).toBeTruthy();
333
+ expect(screen.getByText('Managed instance path')).toBeTruthy();
305
334
 
306
335
  await user.click(screen.getByRole('button', { name: 'Uninstall' }));
307
336
  expect(mocks.lifecycleMutate).toHaveBeenCalledWith(
@@ -310,6 +339,48 @@ describe('AppPackagesPanel', () => {
310
339
  );
311
340
  });
312
341
 
342
+ it('shows retained data and requires a dedicated permanent-delete confirmation', async () => {
343
+ const user = userEvent.setup();
344
+ mocks.retainedEntries = [{
345
+ id: 'ad1.encoded',
346
+ appId: 'example.notes',
347
+ instanceId: 'default',
348
+ displayName: 'Notes',
349
+ source: 'package',
350
+ lifecycle: 'retained',
351
+ storage: createStorageFixture(),
352
+ usage: {
353
+ dataBytes: 128,
354
+ configBytes: 3,
355
+ stateBytes: 2,
356
+ cacheBytes: 1,
357
+ temporaryBytes: 0,
358
+ logsBytes: 4,
359
+ totalBytes: 138,
360
+ },
361
+ createdAt: '2026-08-14T00:00:00.000Z',
362
+ actions: { deleteRetainedData: true },
363
+ }];
364
+ const view = render(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
365
+
366
+ expect(screen.getByRole('heading', { name: 'Saved app data' })).toBeTruthy();
367
+ await user.click(screen.getByRole('button', { name: 'Delete data' }));
368
+ expect(screen.getByRole('heading', { name: 'Permanently delete this app data?' })).toBeTruthy();
369
+
370
+ await user.click(screen.getByRole('button', { name: 'Permanently delete data' }));
371
+ expect(mocks.deleteRetained).toHaveBeenCalledWith(
372
+ { dataId: 'ad1.encoded', confirmAppId: 'example.notes' },
373
+ { onSuccess: expect.any(Function) },
374
+ );
375
+ const onSuccess = mocks.deleteRetained.mock.calls[0]?.[1].onSuccess as () => void;
376
+ mocks.retainedEntries = [];
377
+ view.rerender(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
378
+ await act(async () => onSuccess());
379
+ await waitFor(() => {
380
+ expect(document.activeElement).toBe(screen.getByRole('heading', { name: 'Your apps' }));
381
+ });
382
+ });
383
+
313
384
  it('keeps an accepted install visible in the library and marketplace while it runs', async () => {
314
385
  const user = userEvent.setup();
315
386
  mocks.operations = [{
@@ -1,11 +1,10 @@
1
- import { useState } from 'react';
1
+ import { useRef, useState } from 'react';
2
2
  import type { AppPackageOperationView, AppPackageView } from '@nextclaw/client-sdk';
3
3
  import {
4
4
  AlertCircle,
5
5
  AppWindow,
6
6
  Bookmark,
7
7
  CalendarDays,
8
- Check,
9
8
  CheckSquare2,
10
9
  ChevronRight,
11
10
  MoreHorizontal,
@@ -21,6 +20,11 @@ import {
21
20
  type LucideIcon,
22
21
  } from 'lucide-react';
23
22
  import { AppArtwork } from '@/features/apps/components/app-artwork';
23
+ import {
24
+ AppDataRemovalChoice,
25
+ AppStorageUsageDetails,
26
+ formatBytes,
27
+ } from '@/features/app-data';
24
28
  import { isAppPackageOperationActive } from '@/features/apps/hooks/use-app-packages';
25
29
  import type { PanelAppEntryView } from '@/shared/lib/api';
26
30
  import { Button } from '@/shared/components/ui/button';
@@ -63,6 +67,8 @@ export function AppPackageCard({
63
67
  const [menuOpen, setMenuOpen] = useState(false);
64
68
  const [uninstallOpen, setUninstallOpen] = useState(false);
65
69
  const [purgeData, setPurgeData] = useState(false);
70
+ const skipMenuFocusRestoreRef = useRef(false);
71
+ const uninstallCancelRef = useRef<HTMLButtonElement>(null);
66
72
  const panelComponents = appPackage.components.filter((component) => component.kind === 'panel');
67
73
  const serviceCount = appPackage.components.length - panelComponents.length;
68
74
  const rollbackVersions = appPackage.installedVersions.filter(
@@ -160,7 +166,16 @@ export function AppPackageCard({
160
166
  <MoreHorizontal className="h-4 w-4" />
161
167
  </button>
162
168
  </PopoverTrigger>
163
- <PopoverContent align="end" className="w-52 rounded-xl p-1.5">
169
+ <PopoverContent
170
+ align="end"
171
+ className="w-52 rounded-xl p-1.5"
172
+ onCloseAutoFocus={(event) => {
173
+ if (skipMenuFocusRestoreRef.current) {
174
+ event.preventDefault();
175
+ skipMenuFocusRestoreRef.current = false;
176
+ }
177
+ }}
178
+ >
164
179
  <AppPackageMenuItem
165
180
  icon={RefreshCw}
166
181
  label={t('appPackagesCheckUpdate')}
@@ -185,7 +200,9 @@ export function AppPackageCard({
185
200
  icon={Trash2}
186
201
  label={t('appPackagesUninstall')}
187
202
  onClick={() => {
203
+ skipMenuFocusRestoreRef.current = true;
188
204
  setMenuOpen(false);
205
+ setPurgeData(false);
189
206
  setUninstallOpen(true);
190
207
  }}
191
208
  />
@@ -239,19 +256,25 @@ export function AppPackageCard({
239
256
  ) : null}
240
257
 
241
258
  <Dialog open={uninstallOpen} onOpenChange={setUninstallOpen}>
242
- <DialogContent className="max-w-md [&>:last-child]:hidden">
259
+ <DialogContent
260
+ className="max-w-md [&>:last-child]:hidden"
261
+ onOpenAutoFocus={(event) => {
262
+ event.preventDefault();
263
+ uninstallCancelRef.current?.focus();
264
+ }}
265
+ >
243
266
  <DialogHeader>
244
267
  <DialogTitle>{t('appPackagesUninstallTitle')}</DialogTitle>
245
268
  <DialogDescription>{t('appPackagesUninstallDescription')}</DialogDescription>
246
269
  </DialogHeader>
247
270
  <div className="mt-4 grid gap-2">
248
- <DataChoice
271
+ <AppDataRemovalChoice
249
272
  checked={!purgeData}
250
273
  description={t('appPackagesKeepDataDescription')}
251
274
  label={t('appPackagesKeepData')}
252
275
  onClick={() => setPurgeData(false)}
253
276
  />
254
- <DataChoice
277
+ <AppDataRemovalChoice
255
278
  checked={purgeData}
256
279
  destructive
257
280
  description={t('appPackagesDeleteDataDescription')}
@@ -259,8 +282,20 @@ export function AppPackageCard({
259
282
  onClick={() => setPurgeData(true)}
260
283
  />
261
284
  </div>
285
+ <div className="mt-3">
286
+ <AppStorageUsageDetails
287
+ storage={appPackage.storage}
288
+ usage={appPackage.storageUsage}
289
+ />
290
+ </div>
262
291
  <DialogFooter className="mt-5 gap-2 sm:gap-0">
263
- <Button type="button" variant="outline" onClick={() => setUninstallOpen(false)}>
292
+ <Button
293
+ ref={uninstallCancelRef}
294
+ autoFocus
295
+ type="button"
296
+ variant="outline"
297
+ onClick={() => setUninstallOpen(false)}
298
+ >
264
299
  {t('cancel')}
265
300
  </Button>
266
301
  <Button
@@ -303,50 +338,6 @@ function renderPrimaryActionLabel({
303
338
  return enabled ? t('appPackagesDisable') : t('appPackagesEnable');
304
339
  }
305
340
 
306
- function formatBytes(value: number): string {
307
- if (value < 1024) return `${value} B`;
308
- if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
309
- if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`;
310
- return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`;
311
- }
312
-
313
- function DataChoice({
314
- checked,
315
- description,
316
- destructive = false,
317
- label,
318
- onClick,
319
- }: {
320
- checked: boolean;
321
- description: string;
322
- destructive?: boolean;
323
- label: string;
324
- onClick: () => void;
325
- }) {
326
- return (
327
- <button
328
- type="button"
329
- aria-pressed={checked}
330
- onClick={onClick}
331
- className={cn(
332
- 'flex items-start gap-3 rounded-xl border px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border',
333
- checked ? 'border-foreground/25 bg-muted/55' : 'border-border/60 hover:bg-muted/35',
334
- )}
335
- >
336
- <span className={cn(
337
- 'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border',
338
- checked ? 'border-foreground bg-foreground text-background' : 'border-border',
339
- )}>
340
- {checked ? <Check className="h-2.5 w-2.5" /> : null}
341
- </span>
342
- <span>
343
- <span className={cn('block text-sm font-medium', destructive ? 'text-destructive' : 'text-foreground')}>{label}</span>
344
- <span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{description}</span>
345
- </span>
346
- </button>
347
- );
348
- }
349
-
350
341
  function AppPackageMenuItem({
351
342
  destructive = false,
352
343
  icon: Icon,
@@ -1,4 +1,4 @@
1
- import { useMemo, useState, type FormEvent } from 'react';
1
+ import { useMemo, useRef, useState, type FormEvent } from 'react';
2
2
  import type { 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';
@@ -6,6 +6,7 @@ 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,
@@ -38,12 +39,14 @@ export function AppPackagesPanel({
38
39
  onOpenPanelApp: (entry: PanelAppEntryView) => void;
39
40
  }) {
40
41
  const appPackages = useAppPackages();
42
+ const appData = useAppData();
41
43
  const appPackageOperations = useAppPackageOperations();
42
44
  const panelApps = usePanelApps();
43
45
  const lifecycle = useAppPackageMutation();
44
46
  const recordOpened = useRecordPanelAppOpened();
45
47
  const grantClient = useGrantPanelAppClient();
46
48
  const presenter = useAppPresenter();
49
+ const libraryTitleRef = useRef<HTMLHeadingElement>(null);
47
50
  const [installOpen, setInstallOpen] = useState(false);
48
51
  const [installSource, setInstallSource] = useState('');
49
52
  const [marketplaceOpen, setMarketplaceOpen] = useState(false);
@@ -101,6 +104,7 @@ export function AppPackagesPanel({
101
104
  void appPackages.refetch();
102
105
  void appPackageOperations.refetch();
103
106
  void panelApps.refetch();
107
+ void appData.refetch();
104
108
  };
105
109
 
106
110
  return (
@@ -144,7 +148,13 @@ export function AppPackagesPanel({
144
148
  <div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
145
149
  <div className="mx-auto w-full max-w-3xl p-3 sm:p-5">
146
150
  <div className="mb-4 px-1">
147
- <h1 className="text-base font-semibold tracking-tight text-foreground">{t('appPackagesLibraryTitle')}</h1>
151
+ <h1
152
+ ref={libraryTitleRef}
153
+ tabIndex={-1}
154
+ className="text-base font-semibold tracking-tight text-foreground"
155
+ >
156
+ {t('appPackagesLibraryTitle')}
157
+ </h1>
148
158
  <p className="mt-1 text-xs leading-5 text-muted-foreground">
149
159
  {t('appPackagesLibraryDescription')}
150
160
  </p>
@@ -170,6 +180,13 @@ export function AppPackagesPanel({
170
180
  onMutate={runMutation}
171
181
  onOpenPanelApp={(entry) => void openPanelApp(entry)}
172
182
  />
183
+ <AppRetainedDataSection
184
+ diagnostics={appData.data?.diagnostics ?? []}
185
+ entries={appData.data?.entries ?? []}
186
+ error={appData.error}
187
+ isLoading={appData.isLoading}
188
+ onDeletionFocusReturn={() => libraryTitleRef.current?.focus()}
189
+ />
173
190
  </div>
174
191
  </div>
175
192
 
@@ -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;
@@ -108,6 +109,7 @@ export function useAppPackageMutation() {
108
109
  queryClient.invalidateQueries({ queryKey: SERVICE_APPS_QUERY_KEY }),
109
110
  queryClient.invalidateQueries({ queryKey: SERVICE_ACTIONS_QUERY_KEY }),
110
111
  queryClient.invalidateQueries({ queryKey: APP_PACKAGE_OPERATIONS_QUERY_KEY }),
112
+ queryClient.invalidateQueries({ queryKey: APP_DATA_QUERY_KEY }),
111
113
  ]);
112
114
  },
113
115
  });
@@ -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,
@@ -82,8 +128,19 @@ describe('ServiceAppsPanel', () => {
82
128
  fireEvent.click(screen.getByRole('button', { name: 'More actions' }));
83
129
  expect((screen.getByRole('button', { name: 'Disconnect runtime' }) as HTMLButtonElement).disabled).toBe(true);
84
130
  fireEvent.click(screen.getByRole('button', { name: 'Delete service app' }));
85
- fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
131
+ await waitFor(() => {
132
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Cancel' }));
133
+ });
134
+ expect(resetDeleteServiceApp).toHaveBeenCalled();
135
+ expect(screen.getByText('Total app data')).toBeTruthy();
136
+ expect(screen.getByRole('button', { name: /Keep personal data/ }).getAttribute('aria-pressed'))
137
+ .toBe('true');
138
+ const deleteButtons = screen.getAllByRole('button', { name: 'Delete service app' });
139
+ fireEvent.click(deleteButtons.at(-1)!);
86
140
 
87
- expect(deleteServiceAppMutate).toHaveBeenCalledWith('notes');
141
+ expect(deleteServiceAppMutate).toHaveBeenCalledWith(
142
+ { appId: 'notes', purgeData: false },
143
+ { onSuccess: expect.any(Function) },
144
+ );
88
145
  });
89
146
  });
@@ -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
+ }