@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
@@ -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,11 +1,13 @@
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';
5
5
  import { AppPackagesPanel } from '@/features/apps/components/app-packages-panel';
6
6
 
7
7
  const mocks = vi.hoisted(() => ({
8
+ appDataError: false,
8
9
  enabled: false,
10
+ includePackageStorageUsage: true,
9
11
  grantClient: vi.fn(),
10
12
  lifecycleMutate: vi.fn(),
11
13
  lifecycleReset: vi.fn(),
@@ -15,9 +17,30 @@ const mocks = vi.hoisted(() => ({
15
17
  refetchOperations: vi.fn(),
16
18
  refetchPackages: vi.fn(),
17
19
  refetchPanels: vi.fn(),
20
+ refetchAppData: vi.fn(),
21
+ deleteRetained: vi.fn(),
22
+ deleteRetainedReset: vi.fn(),
23
+ retainedEntries: [] as Array<Record<string, unknown>>,
18
24
  requestAuthorization: vi.fn(async () => true),
19
25
  }));
20
26
 
27
+ vi.mock('@/features/app-data/hooks/use-app-data', () => ({
28
+ useAppData: () => ({
29
+ data: { entries: mocks.retainedEntries, diagnostics: [] },
30
+ error: mocks.appDataError ? new Error('App data unavailable') : null,
31
+ isError: mocks.appDataError,
32
+ isLoading: false,
33
+ refetch: mocks.refetchAppData,
34
+ }),
35
+ useDeleteRetainedAppData: () => ({
36
+ error: null,
37
+ isError: false,
38
+ isPending: false,
39
+ mutate: mocks.deleteRetained,
40
+ reset: mocks.deleteRetainedReset,
41
+ }),
42
+ }));
43
+
21
44
  vi.mock('@/app/components/app-presenter-provider', () => ({
22
45
  useAppPresenter: () => ({
23
46
  appPackageOperationSettlementManager: { settle: vi.fn() },
@@ -97,15 +120,7 @@ vi.mock('@/features/apps/hooks/use-app-packages', () => ({
97
120
  primaryPanelId: 'nextclaw-personal-organizer-todos',
98
121
  runtimeProfile: 'native-process',
99
122
  storage: createStorageFixture(),
100
- storageUsage: {
101
- dataBytes: 128,
102
- configBytes: 0,
103
- stateBytes: 0,
104
- cacheBytes: 0,
105
- temporaryBytes: 0,
106
- logsBytes: 0,
107
- totalBytes: 128,
108
- },
123
+ storageUsage: mocks.includePackageStorageUsage ? createUsageFixture() : undefined,
109
124
  }],
110
125
  },
111
126
  error: null,
@@ -130,6 +145,18 @@ function createStorageFixture() {
130
145
  };
131
146
  }
132
147
 
148
+ function createUsageFixture() {
149
+ return {
150
+ dataBytes: 128,
151
+ configBytes: 3,
152
+ stateBytes: 2,
153
+ cacheBytes: 1,
154
+ temporaryBytes: 0,
155
+ logsBytes: 4,
156
+ totalBytes: 138,
157
+ };
158
+ }
159
+
133
160
  vi.mock('@/features/apps/hooks/use-app-marketplace', () => ({
134
161
  useAppMarketplace: () => ({
135
162
  data: {
@@ -217,7 +244,9 @@ vi.mock('@/features/panel-apps/hooks/use-panel-apps', () => ({
217
244
 
218
245
  describe('AppPackagesPanel', () => {
219
246
  beforeEach(() => {
247
+ mocks.appDataError = false;
220
248
  mocks.enabled = false;
249
+ mocks.includePackageStorageUsage = true;
221
250
  mocks.grantClient.mockReset();
222
251
  mocks.lifecycleMutate.mockReset();
223
252
  mocks.lifecycleReset.mockReset();
@@ -227,6 +256,10 @@ describe('AppPackagesPanel', () => {
227
256
  mocks.refetchPackages.mockReset();
228
257
  mocks.refetchOperations.mockReset();
229
258
  mocks.refetchPanels.mockReset();
259
+ mocks.refetchAppData.mockReset();
260
+ mocks.deleteRetained.mockReset();
261
+ mocks.deleteRetainedReset.mockReset();
262
+ mocks.retainedEntries = [];
230
263
  mocks.requestAuthorization.mockReset();
231
264
  mocks.requestAuthorization.mockResolvedValue(true);
232
265
  });
@@ -248,6 +281,30 @@ describe('AppPackagesPanel', () => {
248
281
  );
249
282
  });
250
283
 
284
+ it('scrolls and transfers focus to a package selected from Service Apps', async () => {
285
+ render(
286
+ <AppPackagesPanel
287
+ focusedPackageId="nextclaw.personal-organizer"
288
+ onOpenPanelApp={mocks.onOpen}
289
+ />,
290
+ );
291
+
292
+ const packageCard = screen.getByRole('region', { name: 'Personal Space' });
293
+ await waitFor(() => expect(document.activeElement).toBe(packageCard));
294
+ expect(packageCard.className).toContain('ring-2');
295
+ });
296
+
297
+ it('distinguishes an untouched app from an App Data query failure', () => {
298
+ mocks.includePackageStorageUsage = false;
299
+ const view = render(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
300
+ expect(screen.getByTitle('/tmp/data').textContent).toContain('No data yet');
301
+
302
+ mocks.appDataError = true;
303
+ view.rerender(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
304
+
305
+ expect(screen.getByTitle('/tmp/data').textContent).toContain('Size unavailable');
306
+ });
307
+
251
308
  it('opens an enabled package panel through the existing panel app chain', async () => {
252
309
  const user = userEvent.setup();
253
310
  mocks.enabled = true;
@@ -300,8 +357,13 @@ describe('AppPackagesPanel', () => {
300
357
  await user.click(screen.getByRole('button', { name: 'More app actions' }));
301
358
  await user.click(screen.getByRole('button', { name: 'Uninstall' }));
302
359
  expect(screen.getByRole('heading', { name: 'Uninstall this app?' })).toBeTruthy();
360
+ await waitFor(() => {
361
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Cancel' }));
362
+ });
303
363
  expect(screen.getByRole('button', { name: /Keep personal data/ }).getAttribute('aria-pressed'))
304
364
  .toBe('true');
365
+ expect(screen.getByText('Total app data')).toBeTruthy();
366
+ expect(screen.getByText('Managed instance path')).toBeTruthy();
305
367
 
306
368
  await user.click(screen.getByRole('button', { name: 'Uninstall' }));
307
369
  expect(mocks.lifecycleMutate).toHaveBeenCalledWith(
@@ -310,6 +372,48 @@ describe('AppPackagesPanel', () => {
310
372
  );
311
373
  });
312
374
 
375
+ it('shows retained data and requires a dedicated permanent-delete confirmation', async () => {
376
+ const user = userEvent.setup();
377
+ mocks.retainedEntries = [{
378
+ id: 'ad1.encoded',
379
+ appId: 'example.notes',
380
+ instanceId: 'default',
381
+ displayName: 'Notes',
382
+ source: 'package',
383
+ lifecycle: 'retained',
384
+ storage: createStorageFixture(),
385
+ usage: {
386
+ dataBytes: 128,
387
+ configBytes: 3,
388
+ stateBytes: 2,
389
+ cacheBytes: 1,
390
+ temporaryBytes: 0,
391
+ logsBytes: 4,
392
+ totalBytes: 138,
393
+ },
394
+ createdAt: '2026-08-14T00:00:00.000Z',
395
+ actions: { deleteRetainedData: true },
396
+ }];
397
+ const view = render(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
398
+
399
+ expect(screen.getByRole('heading', { name: 'Saved app data' })).toBeTruthy();
400
+ await user.click(screen.getByRole('button', { name: 'Delete data' }));
401
+ expect(screen.getByRole('heading', { name: 'Permanently delete this app data?' })).toBeTruthy();
402
+
403
+ await user.click(screen.getByRole('button', { name: 'Permanently delete data' }));
404
+ expect(mocks.deleteRetained).toHaveBeenCalledWith(
405
+ { dataId: 'ad1.encoded', confirmAppId: 'example.notes' },
406
+ { onSuccess: expect.any(Function) },
407
+ );
408
+ const onSuccess = mocks.deleteRetained.mock.calls[0]?.[1].onSuccess as () => void;
409
+ mocks.retainedEntries = [];
410
+ view.rerender(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
411
+ await act(async () => onSuccess());
412
+ await waitFor(() => {
413
+ expect(document.activeElement).toBe(screen.getByRole('heading', { name: 'Your apps' }));
414
+ });
415
+ });
416
+
313
417
  it('keeps an accepted install visible in the library and marketplace while it runs', async () => {
314
418
  const user = userEvent.setup();
315
419
  mocks.operations = [{
@@ -6,13 +6,19 @@ import { AppsPanel, type AppsPanelTab } from '@/features/apps/components/apps-pa
6
6
 
7
7
  vi.mock('@/shared/lib/i18n', () => ({ t: (key: string) => key }));
8
8
  vi.mock('@/features/apps/components/app-packages-panel', () => ({
9
- AppPackagesPanel: () => <div>packages-content</div>,
9
+ AppPackagesPanel: ({ focusedPackageId }: { focusedPackageId?: string }) => (
10
+ <div>packages-content:{focusedPackageId ?? 'none'}</div>
11
+ ),
10
12
  }));
11
13
  vi.mock('@/features/panel-apps', () => ({
12
14
  PanelAppsList: () => <div>panel-apps-content</div>,
13
15
  }));
14
16
  vi.mock('@/features/service-apps', () => ({
15
- ServiceAppsPanel: () => <div>service-apps-content</div>,
17
+ ServiceAppsPanel: ({ onManagePackage }: { onManagePackage: (packageId: string) => void }) => (
18
+ <button type="button" onClick={() => onManagePackage('nextclaw.personal-organizer')}>
19
+ service-apps-content
20
+ </button>
21
+ ),
16
22
  }));
17
23
 
18
24
  function AppsPanelHarness() {
@@ -55,4 +61,14 @@ describe('AppsPanel', () => {
55
61
  expect(document.activeElement).toBe(screen.getByRole('tab', { name: 'panelAppsTitle' }));
56
62
  expect(screen.getByText('panel-apps-content')).toBeTruthy();
57
63
  });
64
+
65
+ it('focuses the owning package when routing from a package-managed service', async () => {
66
+ const user = userEvent.setup();
67
+ render(<AppsPanelHarness />);
68
+
69
+ await user.click(screen.getByRole('tab', { name: 'serviceAppsTitle' }));
70
+ await user.click(screen.getByRole('button', { name: 'service-apps-content' }));
71
+
72
+ expect(screen.getByText('packages-content:nextclaw.personal-organizer')).toBeTruthy();
73
+ });
58
74
  });
@@ -1,11 +1,14 @@
1
- import { useState } from 'react';
2
- import type { AppPackageOperationView, AppPackageView } from '@nextclaw/client-sdk';
1
+ import { useRef, useState } from 'react';
2
+ import type {
3
+ AppDataEntry,
4
+ AppPackageOperationView,
5
+ AppPackageView,
6
+ } from '@nextclaw/client-sdk';
3
7
  import {
4
8
  AlertCircle,
5
9
  AppWindow,
6
10
  Bookmark,
7
11
  CalendarDays,
8
- Check,
9
12
  CheckSquare2,
10
13
  ChevronRight,
11
14
  MoreHorizontal,
@@ -21,6 +24,11 @@ import {
21
24
  type LucideIcon,
22
25
  } from 'lucide-react';
23
26
  import { AppArtwork } from '@/features/apps/components/app-artwork';
27
+ import {
28
+ AppDataRemovalChoice,
29
+ AppStorageUsageDetails,
30
+ formatBytes,
31
+ } from '@/features/app-data';
24
32
  import { isAppPackageOperationActive } from '@/features/apps/hooks/use-app-packages';
25
33
  import type { PanelAppEntryView } from '@/shared/lib/api';
26
34
  import { Button } from '@/shared/components/ui/button';
@@ -48,6 +56,9 @@ export function AppPackageCard({
48
56
  onUninstall,
49
57
  onUpdate,
50
58
  panelApps,
59
+ storageUsage,
60
+ storageUsageLoading,
61
+ storageUsageUnavailable,
51
62
  }: {
52
63
  appPackage: AppPackageView;
53
64
  isPending: boolean;
@@ -59,10 +70,15 @@ export function AppPackageCard({
59
70
  onUninstall: (purgeData: boolean) => void;
60
71
  onUpdate: () => void;
61
72
  panelApps: PanelAppEntryView[];
73
+ storageUsage?: AppDataEntry['usage'];
74
+ storageUsageLoading: boolean;
75
+ storageUsageUnavailable: boolean;
62
76
  }) {
63
77
  const [menuOpen, setMenuOpen] = useState(false);
64
78
  const [uninstallOpen, setUninstallOpen] = useState(false);
65
79
  const [purgeData, setPurgeData] = useState(false);
80
+ const skipMenuFocusRestoreRef = useRef(false);
81
+ const uninstallCancelRef = useRef<HTMLButtonElement>(null);
66
82
  const panelComponents = appPackage.components.filter((component) => component.kind === 'panel');
67
83
  const serviceCount = appPackage.components.length - panelComponents.length;
68
84
  const rollbackVersions = appPackage.installedVersions.filter(
@@ -75,6 +91,11 @@ export function AppPackageCard({
75
91
  );
76
92
  const operationActive = operation ? isAppPackageOperationActive(operation.status) : false;
77
93
  const pending = isPending || operationActive;
94
+ const storageUsageLabel = resolveStorageUsageLabel(
95
+ storageUsage,
96
+ storageUsageLoading,
97
+ storageUsageUnavailable,
98
+ );
78
99
 
79
100
  return (
80
101
  <Card surface="flat" hover={false} className="overflow-hidden border-border/60 shadow-[0_1px_2px_rgba(0,0,0,0.025)]">
@@ -126,7 +147,8 @@ export function AppPackageCard({
126
147
  >
127
148
  <HardDrive className="h-3 w-3 shrink-0" />
128
149
  <span className="shrink-0">
129
- {t('appPackagesDataUsage')} {formatBytes(appPackage.storageUsage.totalBytes)}
150
+ {t('appPackagesDataUsage')}{' '}
151
+ {storageUsageLabel}
130
152
  </span>
131
153
  <code className="min-w-0 truncate font-mono text-[10px]">
132
154
  {appPackage.storage.dataDirectory}
@@ -160,7 +182,16 @@ export function AppPackageCard({
160
182
  <MoreHorizontal className="h-4 w-4" />
161
183
  </button>
162
184
  </PopoverTrigger>
163
- <PopoverContent align="end" className="w-52 rounded-xl p-1.5">
185
+ <PopoverContent
186
+ align="end"
187
+ className="w-52 rounded-xl p-1.5"
188
+ onCloseAutoFocus={(event) => {
189
+ if (skipMenuFocusRestoreRef.current) {
190
+ event.preventDefault();
191
+ skipMenuFocusRestoreRef.current = false;
192
+ }
193
+ }}
194
+ >
164
195
  <AppPackageMenuItem
165
196
  icon={RefreshCw}
166
197
  label={t('appPackagesCheckUpdate')}
@@ -185,7 +216,9 @@ export function AppPackageCard({
185
216
  icon={Trash2}
186
217
  label={t('appPackagesUninstall')}
187
218
  onClick={() => {
219
+ skipMenuFocusRestoreRef.current = true;
188
220
  setMenuOpen(false);
221
+ setPurgeData(false);
189
222
  setUninstallOpen(true);
190
223
  }}
191
224
  />
@@ -239,19 +272,25 @@ export function AppPackageCard({
239
272
  ) : null}
240
273
 
241
274
  <Dialog open={uninstallOpen} onOpenChange={setUninstallOpen}>
242
- <DialogContent className="max-w-md [&>:last-child]:hidden">
275
+ <DialogContent
276
+ className="max-w-md [&>:last-child]:hidden"
277
+ onOpenAutoFocus={(event) => {
278
+ event.preventDefault();
279
+ uninstallCancelRef.current?.focus();
280
+ }}
281
+ >
243
282
  <DialogHeader>
244
283
  <DialogTitle>{t('appPackagesUninstallTitle')}</DialogTitle>
245
284
  <DialogDescription>{t('appPackagesUninstallDescription')}</DialogDescription>
246
285
  </DialogHeader>
247
286
  <div className="mt-4 grid gap-2">
248
- <DataChoice
287
+ <AppDataRemovalChoice
249
288
  checked={!purgeData}
250
289
  description={t('appPackagesKeepDataDescription')}
251
290
  label={t('appPackagesKeepData')}
252
291
  onClick={() => setPurgeData(false)}
253
292
  />
254
- <DataChoice
293
+ <AppDataRemovalChoice
255
294
  checked={purgeData}
256
295
  destructive
257
296
  description={t('appPackagesDeleteDataDescription')}
@@ -259,8 +298,22 @@ export function AppPackageCard({
259
298
  onClick={() => setPurgeData(true)}
260
299
  />
261
300
  </div>
301
+ <div className="mt-3">
302
+ <AppPackageStorageUsage
303
+ loading={storageUsageLoading}
304
+ storage={appPackage.storage}
305
+ usage={storageUsage}
306
+ unavailable={storageUsageUnavailable}
307
+ />
308
+ </div>
262
309
  <DialogFooter className="mt-5 gap-2 sm:gap-0">
263
- <Button type="button" variant="outline" onClick={() => setUninstallOpen(false)}>
310
+ <Button
311
+ ref={uninstallCancelRef}
312
+ autoFocus
313
+ type="button"
314
+ variant="outline"
315
+ onClick={() => setUninstallOpen(false)}
316
+ >
264
317
  {t('cancel')}
265
318
  </Button>
266
319
  <Button
@@ -280,6 +333,42 @@ export function AppPackageCard({
280
333
  );
281
334
  }
282
335
 
336
+ function AppPackageStorageUsage({
337
+ loading,
338
+ storage,
339
+ unavailable,
340
+ usage,
341
+ }: {
342
+ loading: boolean;
343
+ storage: AppPackageView['storage'];
344
+ unavailable: boolean;
345
+ usage?: AppDataEntry['usage'];
346
+ }) {
347
+ if (usage) {
348
+ return <AppStorageUsageDetails storage={storage} usage={usage} />;
349
+ }
350
+ return (
351
+ <div className="rounded-xl bg-muted/45 px-3 py-3 text-xs text-muted-foreground">
352
+ {loading ? (
353
+ <LoaderCircle className="mr-1.5 inline h-3.5 w-3.5 animate-spin" />
354
+ ) : null}
355
+ {t(loading
356
+ ? 'appPackagesLoading'
357
+ : unavailable ? 'appPackagesDataUsageUnavailable' : 'appPackagesNoDataYet')}
358
+ </div>
359
+ );
360
+ }
361
+
362
+ function resolveStorageUsageLabel(
363
+ usage: AppDataEntry['usage'] | undefined,
364
+ loading: boolean,
365
+ unavailable: boolean,
366
+ ): string {
367
+ if (usage) return formatBytes(usage.totalBytes);
368
+ if (loading) return t('appPackagesLoading');
369
+ return t(unavailable ? 'appPackagesDataUsageUnavailable' : 'appPackagesNoDataYet');
370
+ }
371
+
283
372
  function renderPrimaryActionLabel({
284
373
  enabled,
285
374
  isPending,
@@ -303,50 +392,6 @@ function renderPrimaryActionLabel({
303
392
  return enabled ? t('appPackagesDisable') : t('appPackagesEnable');
304
393
  }
305
394
 
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
395
  function AppPackageMenuItem({
351
396
  destructive = false,
352
397
  icon: Icon,