@nextclaw/ui 0.15.29 → 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 (71) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/assets/api-CVjdSvJk.js +13 -0
  3. package/dist/assets/{appearance-settings-page-Bpd2RhK5.js → appearance-settings-page-BOorfaUx.js} +1 -1
  4. package/dist/assets/{channels-list-page-CTblam4c.js → channels-list-page-DuZJvYB6.js} +1 -1
  5. package/dist/assets/{chat-page-Bg3_X8G9.js → chat-page-8PTMgI0t.js} +1 -1
  6. package/dist/assets/{config-split-page-ps1Aod3g.js → config-split-page-UuhsVcQD.js} +1 -1
  7. package/dist/assets/{confirm-dialog-DJSUNG33.js → confirm-dialog-Bnosa8UF.js} +1 -1
  8. package/dist/assets/{desktop-update-config-D9AyIhJZ.js → desktop-update-config-CM4lsX4R.js} +1 -1
  9. package/dist/assets/{dist-DkR7_Dpl.js → dist-D17RZAsz.js} +1 -1
  10. package/dist/assets/{dist-BkTYNzm_.js → dist-_zZ8P38T.js} +1 -1
  11. package/dist/assets/{doc-browser-Q67TOGVS.js → doc-browser-CiLvybSC.js} +1 -1
  12. package/dist/assets/{doc-browser-context-BQim8SqD.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-B2-bpFie.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-w8panjZG.js → mcp-marketplace-page-DN_fqcLd.js} +1 -1
  20. package/dist/assets/{model-config-page-B8Aubkwx.js → model-config-page-BxaeCMbJ.js} +1 -1
  21. package/dist/assets/{navigation-link-mpHFXwRr.js → navigation-link-k9rsmtC4.js} +1 -1
  22. package/dist/assets/plus-Dc760kHj.js +1 -0
  23. package/dist/assets/{provider-scoped-model-input-BRnIH-5O.js → provider-scoped-model-input-DLSAB6a5.js} +1 -1
  24. package/dist/assets/{providers-config-page-0r9AZNNZ.js → providers-config-page-DM5N0aeS.js} +1 -1
  25. package/dist/assets/{react-BlXwvOLo.js → react-BgRLh3ee.js} +1 -1
  26. package/dist/assets/{refresh-cw-JFuyDj_P.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-BgeYpvMK.js → runtime-config-page-WNzZyjMW.js} +1 -1
  30. package/dist/assets/{save-BL8P8CLX.js → save-DbR4SGk8.js} +1 -1
  31. package/dist/assets/{search-bZL75Pgc.js → search-M9lrb8id.js} +1 -1
  32. package/dist/assets/{search-config-page-CzNzLyd0.js → search-config-page-DmsID8FQ.js} +1 -1
  33. package/dist/assets/{secrets-config-page-C4k3ik0v.js → secrets-config-page-C10OwI0b.js} +1 -1
  34. package/dist/assets/security-config-CvU5JxDT.js +1 -0
  35. package/dist/assets/{select-rUZ8nIZu.js → select-D_YBzMdv.js} +1 -1
  36. package/dist/assets/{settings-2-BI4SCkxQ.js → settings-2-zy40UwTo.js} +1 -1
  37. package/dist/assets/skeleton-BmAtzjDM.js +1 -0
  38. package/dist/assets/{tag-chip-BdI_yIOA.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 +5 -5
  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 +108 -1
  48. package/src/features/apps/components/app-marketplace-detail.tsx +19 -2
  49. package/src/features/apps/components/app-package-card.tsx +70 -44
  50. package/src/features/apps/components/app-packages-panel.tsx +19 -2
  51. package/src/features/apps/hooks/use-app-packages.ts +2 -0
  52. package/src/features/apps/types/app-marketplace.types.ts +3 -2
  53. package/src/features/service-apps/components/__tests__/service-apps-panel.test.tsx +59 -2
  54. package/src/features/service-apps/components/service-app-delete-dialog.tsx +122 -0
  55. package/src/features/service-apps/components/service-app-list-item.tsx +24 -9
  56. package/src/features/service-apps/components/service-apps-panel.tsx +15 -1
  57. package/src/features/service-apps/hooks/use-service-apps.ts +4 -1
  58. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +29 -1
  59. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +29 -1
  60. package/dist/assets/api-a_GaOFc7.js +0 -13
  61. package/dist/assets/doc-browser-DahzJEWj.js +0 -1
  62. package/dist/assets/index-DSLK7zgR.css +0 -1
  63. package/dist/assets/index-VQAVqwG8.js +0 -109
  64. package/dist/assets/loader-circle-DyXZU92l.js +0 -1
  65. package/dist/assets/mcp-marketplace-page-B0dBE5CV.js +0 -1
  66. package/dist/assets/plus-Dhb4KuMH.js +0 -1
  67. package/dist/assets/remote-DQzE55WW.js +0 -1
  68. package/dist/assets/rotate-cw-DjbbiIW_.js +0 -1
  69. package/dist/assets/security-config-DmJw9QZA.js +0 -1
  70. package/dist/assets/skeleton-CVmEPG43.js +0 -1
  71. package/dist/assets/x-BcEayhV6.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() },
@@ -65,6 +85,10 @@ vi.mock('@/features/apps/hooks/use-app-packages', () => ({
65
85
  packageId: 'nextclaw.personal-organizer',
66
86
  packageVersion: '0.1.0',
67
87
  sourcePath: '/tmp/app/todos.panel',
88
+ instanceId: 'default',
89
+ isolation: 'full-user',
90
+ runtimeProfile: 'native-process',
91
+ storage: createStorageFixture(),
68
92
  title: 'Todos',
69
93
  },
70
94
  {
@@ -75,6 +99,10 @@ vi.mock('@/features/apps/hooks/use-app-packages', () => ({
75
99
  packageId: 'nextclaw.personal-organizer',
76
100
  packageVersion: '0.1.0',
77
101
  sourcePath: '/tmp/app/data',
102
+ instanceId: 'default',
103
+ isolation: 'full-user',
104
+ runtimeProfile: 'native-process',
105
+ storage: createStorageFixture(),
78
106
  title: 'Personal data',
79
107
  },
80
108
  ],
@@ -83,8 +111,21 @@ vi.mock('@/features/apps/hooks/use-app-packages', () => ({
83
111
  enabled: mocks.enabled,
84
112
  id: 'nextclaw.personal-organizer',
85
113
  installedVersions: ['0.1.0'],
114
+ instanceId: 'default',
115
+ isolation: 'full-user',
86
116
  name: 'Personal Space',
87
117
  primaryPanelId: 'nextclaw-personal-organizer-todos',
118
+ runtimeProfile: 'native-process',
119
+ storage: createStorageFixture(),
120
+ storageUsage: {
121
+ dataBytes: 128,
122
+ configBytes: 0,
123
+ stateBytes: 0,
124
+ cacheBytes: 0,
125
+ temporaryBytes: 0,
126
+ logsBytes: 0,
127
+ totalBytes: 128,
128
+ },
88
129
  }],
89
130
  },
90
131
  error: null,
@@ -94,6 +135,21 @@ vi.mock('@/features/apps/hooks/use-app-packages', () => ({
94
135
  }),
95
136
  }));
96
137
 
138
+ function createStorageFixture() {
139
+ return {
140
+ layout: 'instance-v1' as const,
141
+ layoutVersion: 1 as const,
142
+ instanceId: 'default',
143
+ instanceDirectory: '/tmp/instance',
144
+ dataDirectory: '/tmp/data',
145
+ configDirectory: '/tmp/config',
146
+ stateDirectory: '/tmp/state',
147
+ cacheDirectory: '/tmp/cache',
148
+ temporaryDirectory: '/tmp/tmp',
149
+ logsDirectory: '/tmp/logs',
150
+ };
151
+ }
152
+
97
153
  vi.mock('@/features/apps/hooks/use-app-marketplace', () => ({
98
154
  useAppMarketplace: () => ({
99
155
  data: {
@@ -191,6 +247,10 @@ describe('AppPackagesPanel', () => {
191
247
  mocks.refetchPackages.mockReset();
192
248
  mocks.refetchOperations.mockReset();
193
249
  mocks.refetchPanels.mockReset();
250
+ mocks.refetchAppData.mockReset();
251
+ mocks.deleteRetained.mockReset();
252
+ mocks.deleteRetainedReset.mockReset();
253
+ mocks.retainedEntries = [];
194
254
  mocks.requestAuthorization.mockReset();
195
255
  mocks.requestAuthorization.mockResolvedValue(true);
196
256
  });
@@ -264,8 +324,13 @@ describe('AppPackagesPanel', () => {
264
324
  await user.click(screen.getByRole('button', { name: 'More app actions' }));
265
325
  await user.click(screen.getByRole('button', { name: 'Uninstall' }));
266
326
  expect(screen.getByRole('heading', { name: 'Uninstall this app?' })).toBeTruthy();
327
+ await waitFor(() => {
328
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Cancel' }));
329
+ });
267
330
  expect(screen.getByRole('button', { name: /Keep personal data/ }).getAttribute('aria-pressed'))
268
331
  .toBe('true');
332
+ expect(screen.getByText('Total app data')).toBeTruthy();
333
+ expect(screen.getByText('Managed instance path')).toBeTruthy();
269
334
 
270
335
  await user.click(screen.getByRole('button', { name: 'Uninstall' }));
271
336
  expect(mocks.lifecycleMutate).toHaveBeenCalledWith(
@@ -274,6 +339,48 @@ describe('AppPackagesPanel', () => {
274
339
  );
275
340
  });
276
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
+
277
384
  it('keeps an accepted install visible in the library and marketplace while it runs', async () => {
278
385
  const user = userEvent.setup();
279
386
  mocks.operations = [{
@@ -1,5 +1,5 @@
1
1
  import type { AppPackageOperationView, AppPackageView } from '@nextclaw/client-sdk';
2
- import { ExternalLink, FileText, Server, ShieldCheck, type LucideIcon } from 'lucide-react';
2
+ import { ExternalLink, FileText, Server, ShieldAlert, ShieldCheck, type LucideIcon } from 'lucide-react';
3
3
  import { AppArtwork } from '@/features/apps/components/app-artwork';
4
4
  import { AppMarketplaceCover } from '@/features/apps/components/app-marketplace-cover';
5
5
  import {
@@ -46,6 +46,8 @@ export function MarketplaceDetail({
46
46
  const components = detail.manifest.components ?? [];
47
47
  const panelCount = components.filter((entry) => entry.kind === 'panel').length;
48
48
  const serviceCount = components.filter((entry) => entry.kind === 'service').length;
49
+ const runtimeProfile = detail.manifest.runtime?.profile
50
+ ?? (serviceCount > 0 ? 'native-process' : 'panel-only');
49
51
  const canUpdate = Boolean(
50
52
  installedPackage && installedPackage.activeVersion !== detail.latestVersion,
51
53
  );
@@ -97,6 +99,15 @@ export function MarketplaceDetail({
97
99
  value={detail.manifest.engines.nextclaw}
98
100
  />
99
101
  ) : null}
102
+ <DetailFact
103
+ icon={runtimeProfile === 'native-process' ? ShieldAlert : ShieldCheck}
104
+ label={t('appPackagesIsolationLabel')}
105
+ value={runtimeProfile === 'native-process'
106
+ ? t('appPackagesIsolationFullUser')
107
+ : runtimeProfile === 'wasi'
108
+ ? t('appPackagesIsolationMediated')
109
+ : t('appPackagesIsolationPanel')}
110
+ />
100
111
  </div>
101
112
 
102
113
  <section className="mt-7 border-t border-border/60 pt-6">
@@ -154,7 +165,13 @@ function readPermissionRows(detail: AppMarketplaceDetailView): string[] {
154
165
  if (detail.permissions.capabilities?.hostBridge) {
155
166
  rows.push(t('appPackagesHostBridgeAccess'));
156
167
  }
157
- if (detail.permissions.storage?.namespace) {
168
+ if (detail.permissions.capabilities?.nativeProcess) {
169
+ rows.push(t('appPackagesNativeProcessAccess'));
170
+ }
171
+ if (detail.permissions.storage === true || (
172
+ typeof detail.permissions.storage === 'object' &&
173
+ detail.permissions.storage?.namespace
174
+ )) {
158
175
  rows.push(t('appPackagesLocalStorageAccess'));
159
176
  }
160
177
  return rows.length > 0 ? rows : [t('appPackagesNoExtraAccess')];
@@ -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,
@@ -15,9 +14,17 @@ import {
15
14
  Server,
16
15
  Trash2,
17
16
  LoaderCircle,
17
+ HardDrive,
18
+ ShieldAlert,
19
+ ShieldCheck,
18
20
  type LucideIcon,
19
21
  } from 'lucide-react';
20
22
  import { AppArtwork } from '@/features/apps/components/app-artwork';
23
+ import {
24
+ AppDataRemovalChoice,
25
+ AppStorageUsageDetails,
26
+ formatBytes,
27
+ } from '@/features/app-data';
21
28
  import { isAppPackageOperationActive } from '@/features/apps/hooks/use-app-packages';
22
29
  import type { PanelAppEntryView } from '@/shared/lib/api';
23
30
  import { Button } from '@/shared/components/ui/button';
@@ -60,6 +67,8 @@ export function AppPackageCard({
60
67
  const [menuOpen, setMenuOpen] = useState(false);
61
68
  const [uninstallOpen, setUninstallOpen] = useState(false);
62
69
  const [purgeData, setPurgeData] = useState(false);
70
+ const skipMenuFocusRestoreRef = useRef(false);
71
+ const uninstallCancelRef = useRef<HTMLButtonElement>(null);
63
72
  const panelComponents = appPackage.components.filter((component) => component.kind === 'panel');
64
73
  const serviceCount = appPackage.components.length - panelComponents.length;
65
74
  const rollbackVersions = appPackage.installedVersions.filter(
@@ -103,6 +112,31 @@ export function AppPackageCard({
103
112
  {t('appPackagesLocalService')}
104
113
  </span>
105
114
  ) : null}
115
+ <span className={cn(
116
+ 'inline-flex items-center gap-1',
117
+ appPackage.isolation === 'full-user' && 'text-amber-700 dark:text-amber-300',
118
+ )}>
119
+ {appPackage.isolation === 'full-user'
120
+ ? <ShieldAlert className="h-3 w-3" />
121
+ : <ShieldCheck className="h-3 w-3" />}
122
+ {appPackage.isolation === 'full-user'
123
+ ? t('appPackagesIsolationFullUser')
124
+ : appPackage.isolation === 'host-mediated'
125
+ ? t('appPackagesIsolationMediated')
126
+ : t('appPackagesIsolationPanel')}
127
+ </span>
128
+ </div>
129
+ <div
130
+ className="mt-2 flex min-w-0 items-center gap-1.5 rounded-md bg-muted/35 px-2 py-1 text-[10px] text-muted-foreground"
131
+ title={appPackage.storage.dataDirectory}
132
+ >
133
+ <HardDrive className="h-3 w-3 shrink-0" />
134
+ <span className="shrink-0">
135
+ {t('appPackagesDataUsage')} {formatBytes(appPackage.storageUsage.totalBytes)}
136
+ </span>
137
+ <code className="min-w-0 truncate font-mono text-[10px]">
138
+ {appPackage.storage.dataDirectory}
139
+ </code>
106
140
  </div>
107
141
  </div>
108
142
  <div className="flex shrink-0 items-center gap-1">
@@ -132,7 +166,16 @@ export function AppPackageCard({
132
166
  <MoreHorizontal className="h-4 w-4" />
133
167
  </button>
134
168
  </PopoverTrigger>
135
- <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
+ >
136
179
  <AppPackageMenuItem
137
180
  icon={RefreshCw}
138
181
  label={t('appPackagesCheckUpdate')}
@@ -157,7 +200,9 @@ export function AppPackageCard({
157
200
  icon={Trash2}
158
201
  label={t('appPackagesUninstall')}
159
202
  onClick={() => {
203
+ skipMenuFocusRestoreRef.current = true;
160
204
  setMenuOpen(false);
205
+ setPurgeData(false);
161
206
  setUninstallOpen(true);
162
207
  }}
163
208
  />
@@ -211,19 +256,25 @@ export function AppPackageCard({
211
256
  ) : null}
212
257
 
213
258
  <Dialog open={uninstallOpen} onOpenChange={setUninstallOpen}>
214
- <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
+ >
215
266
  <DialogHeader>
216
267
  <DialogTitle>{t('appPackagesUninstallTitle')}</DialogTitle>
217
268
  <DialogDescription>{t('appPackagesUninstallDescription')}</DialogDescription>
218
269
  </DialogHeader>
219
270
  <div className="mt-4 grid gap-2">
220
- <DataChoice
271
+ <AppDataRemovalChoice
221
272
  checked={!purgeData}
222
273
  description={t('appPackagesKeepDataDescription')}
223
274
  label={t('appPackagesKeepData')}
224
275
  onClick={() => setPurgeData(false)}
225
276
  />
226
- <DataChoice
277
+ <AppDataRemovalChoice
227
278
  checked={purgeData}
228
279
  destructive
229
280
  description={t('appPackagesDeleteDataDescription')}
@@ -231,8 +282,20 @@ export function AppPackageCard({
231
282
  onClick={() => setPurgeData(true)}
232
283
  />
233
284
  </div>
285
+ <div className="mt-3">
286
+ <AppStorageUsageDetails
287
+ storage={appPackage.storage}
288
+ usage={appPackage.storageUsage}
289
+ />
290
+ </div>
234
291
  <DialogFooter className="mt-5 gap-2 sm:gap-0">
235
- <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
+ >
236
299
  {t('cancel')}
237
300
  </Button>
238
301
  <Button
@@ -275,43 +338,6 @@ function renderPrimaryActionLabel({
275
338
  return enabled ? t('appPackagesDisable') : t('appPackagesEnable');
276
339
  }
277
340
 
278
- function DataChoice({
279
- checked,
280
- description,
281
- destructive = false,
282
- label,
283
- onClick,
284
- }: {
285
- checked: boolean;
286
- description: string;
287
- destructive?: boolean;
288
- label: string;
289
- onClick: () => void;
290
- }) {
291
- return (
292
- <button
293
- type="button"
294
- aria-pressed={checked}
295
- onClick={onClick}
296
- className={cn(
297
- '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',
298
- checked ? 'border-foreground/25 bg-muted/55' : 'border-border/60 hover:bg-muted/35',
299
- )}
300
- >
301
- <span className={cn(
302
- 'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border',
303
- checked ? 'border-foreground bg-foreground text-background' : 'border-border',
304
- )}>
305
- {checked ? <Check className="h-2.5 w-2.5" /> : null}
306
- </span>
307
- <span>
308
- <span className={cn('block text-sm font-medium', destructive ? 'text-destructive' : 'text-foreground')}>{label}</span>
309
- <span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{description}</span>
310
- </span>
311
- </button>
312
- );
313
- }
314
-
315
341
  function AppPackageMenuItem({
316
342
  destructive = false,
317
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
  });
@@ -48,6 +48,7 @@ export type AppMarketplaceDetailView = AppMarketplaceItemView & {
48
48
  schemaVersion: 1 | 2;
49
49
  icon?: string;
50
50
  engines?: { nextclaw?: string };
51
+ runtime?: { profile: 'panel-only' | 'wasi' | 'native-process' };
51
52
  components?: Array<{
52
53
  kind: 'panel' | 'service';
53
54
  path: string;
@@ -60,8 +61,8 @@ export type AppMarketplaceDetailView = AppMarketplaceItemView & {
60
61
  description?: string;
61
62
  }>;
62
63
  allowedDomains?: string[];
63
- storage?: { namespace?: string };
64
- capabilities?: { hostBridge?: boolean };
64
+ storage?: boolean | { namespace?: string };
65
+ capabilities?: { hostBridge?: boolean; nativeProcess?: boolean };
65
66
  };
66
67
  };
67
68