@nextclaw/ui 0.15.26 → 0.15.28

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 (125) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/assets/{api-CK3Fro7z.js → api-a_GaOFc7.js} +4 -4
  3. package/dist/assets/{appearance-settings-page-zqwWO8Dy.js → appearance-settings-page-KEYKkxHu.js} +1 -1
  4. package/dist/assets/{channels-list-page-CVCZBlCL.js → channels-list-page-eXLXPgKT.js} +2 -2
  5. package/dist/assets/{chat-page-D4wehRTz.js → chat-page-jfImLdsq.js} +1 -1
  6. package/dist/assets/{config-split-page-CceNFvlY.js → config-split-page-ps1Aod3g.js} +1 -1
  7. package/dist/assets/{confirm-dialog-CWeNYWZY.js → confirm-dialog-DJSUNG33.js} +2 -2
  8. package/dist/assets/desktop-update-config-C20U71k1.js +1 -0
  9. package/dist/assets/{dist-D9xtGgdL.js → dist-BkTYNzm_.js} +1 -1
  10. package/dist/assets/{dist-AYxkFx2B.js → dist-DkR7_Dpl.js} +1 -1
  11. package/dist/assets/doc-browser-DahzJEWj.js +1 -0
  12. package/dist/assets/doc-browser-Q67TOGVS.js +1 -0
  13. package/dist/assets/{doc-browser-context-Cb7Z0j90.js → doc-browser-context-BQim8SqD.js} +1 -1
  14. package/dist/assets/form-actions-B2-bpFie.js +1 -0
  15. package/dist/assets/index-BP0Hd2jI.js +109 -0
  16. package/dist/assets/index-DSLK7zgR.css +1 -0
  17. package/dist/assets/loader-circle-DyXZU92l.js +1 -0
  18. package/dist/assets/mcp-marketplace-page-B0dBE5CV.js +1 -0
  19. package/dist/assets/mcp-marketplace-page-w8panjZG.js +9 -0
  20. package/dist/assets/{model-config-page-DfOLyQqR.js → model-config-page-CJCnaKOh.js} +1 -1
  21. package/dist/assets/{navigation-link-MiiyoIjH.js → navigation-link-mpHFXwRr.js} +2 -2
  22. package/dist/assets/plus-Dhb4KuMH.js +1 -0
  23. package/dist/assets/{provider-scoped-model-input-DXzjr5l0.js → provider-scoped-model-input-Dqu-ZoWG.js} +1 -1
  24. package/dist/assets/{providers-config-page-CaO8WfZH.js → providers-config-page-C6fRN2rJ.js} +1 -1
  25. package/dist/assets/react-BlXwvOLo.js +8 -0
  26. package/dist/assets/{refresh-cw-a2VVeeAF.js → refresh-cw-JFuyDj_P.js} +1 -1
  27. package/dist/assets/remote-Xyv2c_W9.js +1 -0
  28. package/dist/assets/rotate-cw-DjbbiIW_.js +1 -0
  29. package/dist/assets/{runtime-config-page-BuA4A02z.js → runtime-config-page-CtypMgLd.js} +1 -1
  30. package/dist/assets/{save-CGm23Yww.js → save-BL8P8CLX.js} +1 -1
  31. package/dist/assets/{search-CH2t--sH.js → search-bZL75Pgc.js} +1 -1
  32. package/dist/assets/{search-config-page-BAmYqoAz.js → search-config-page-DhSFjYj4.js} +1 -1
  33. package/dist/assets/{secrets-config-page-BlPOuql_.js → secrets-config-page-BLGcyb1D.js} +2 -2
  34. package/dist/assets/security-config-DmJw9QZA.js +1 -0
  35. package/dist/assets/{select-DnxxDpcV.js → select-rUZ8nIZu.js} +2 -2
  36. package/dist/assets/{settings-2-agnMYc8a.js → settings-2-BI4SCkxQ.js} +1 -1
  37. package/dist/assets/skeleton-CVmEPG43.js +1 -0
  38. package/dist/assets/{tag-chip-CPOc8b2N.js → tag-chip-BdI_yIOA.js} +1 -1
  39. package/dist/assets/x-BcEayhV6.js +1 -0
  40. package/dist/index.html +19 -19
  41. package/package.json +6 -6
  42. package/src/app/components/layout/app-layout.tsx +38 -2
  43. package/src/app/hooks/__tests__/use-app-event-consumers.test.ts +20 -0
  44. package/src/app/hooks/use-app-event-consumers.ts +21 -0
  45. package/src/app/presenters/app.presenter.ts +6 -0
  46. package/src/features/apps/components/__tests__/app-packages-panel.test.tsx +116 -14
  47. package/src/features/apps/components/__tests__/apps-panel.test.tsx +58 -0
  48. package/src/features/apps/components/app-artwork.tsx +49 -0
  49. package/src/features/apps/components/app-marketplace-catalog.tsx +332 -0
  50. package/src/features/apps/components/app-marketplace-cover.tsx +64 -0
  51. package/src/features/apps/components/app-marketplace-detail.tsx +161 -0
  52. package/src/features/apps/components/app-marketplace-dialog.tsx +107 -217
  53. package/src/features/apps/components/app-marketplace-operation.tsx +84 -0
  54. package/src/features/apps/components/app-package-card.tsx +201 -52
  55. package/src/features/apps/components/app-packages-panel.tsx +120 -42
  56. package/src/features/apps/components/apps-panel.tsx +28 -27
  57. package/src/features/apps/hooks/use-app-marketplace.test.ts +144 -0
  58. package/src/features/apps/hooks/use-app-marketplace.ts +55 -85
  59. package/src/features/apps/hooks/use-app-packages.ts +59 -4
  60. package/src/features/apps/index.ts +1 -0
  61. package/src/features/apps/managers/__tests__/app-package-operation-settlement.manager.test.ts +81 -0
  62. package/src/features/apps/managers/app-package-operation-settlement.manager.ts +80 -0
  63. package/src/features/apps/services/app-marketplace.service.ts +223 -0
  64. package/src/features/apps/types/app-marketplace.types.ts +75 -0
  65. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +64 -3
  66. package/src/features/chat/features/conversation/components/session-queued-input-rows.tsx +24 -14
  67. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +53 -0
  68. package/src/features/chat/features/conversation/hooks/__tests__/use-session-run-queue.test.tsx +18 -0
  69. package/src/features/chat/features/conversation/hooks/use-chat-composer-reference-intent.ts +4 -1
  70. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +126 -32
  71. package/src/features/chat/features/conversation/hooks/use-session-run-queue.ts +12 -2
  72. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +31 -0
  73. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +5 -0
  74. package/src/features/chat/features/input/utils/chat-inline-token-composer.utils.ts +14 -0
  75. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +25 -0
  76. package/src/features/chat/features/message/hooks/use-chat-inline-token-actions.ts +8 -1
  77. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +77 -20
  78. package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +56 -50
  79. package/src/features/chat/features/workspace/components/project-files/workspace-directory-tree.tsx +33 -11
  80. package/src/features/chat/features/workspace/components/project-files/workspace-project-tree-view.tsx +93 -0
  81. package/src/features/chat/features/workspace/stores/__tests__/workspace-project-tree.store.test.ts +38 -0
  82. package/src/features/chat/features/workspace/stores/workspace-project-tree.store.ts +160 -0
  83. package/src/features/chat/index.ts +1 -0
  84. package/src/features/chat/managers/__tests__/chat-composer-intent.manager.test.ts +29 -0
  85. package/src/features/chat/managers/chat-composer-intent.manager.ts +29 -2
  86. package/src/features/chat/presenters/chat.presenter.ts +2 -2
  87. package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +1 -1
  88. package/src/features/panel-apps/components/panel-apps-list.tsx +1 -6
  89. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +34 -0
  90. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +29 -0
  91. package/src/features/service-apps/components/service-apps-panel.tsx +3 -17
  92. package/src/platforms/desktop/components/desktop-app-shell.tsx +5 -0
  93. package/src/platforms/mobile/components/mobile-app-shell.tsx +9 -1
  94. package/src/shared/components/actions/__tests__/navigation-link.test.tsx +12 -0
  95. package/src/shared/components/actions/navigation-link.tsx +6 -3
  96. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +34 -0
  97. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +5 -0
  98. package/src/shared/components/doc-browser/doc-browser.tsx +11 -1
  99. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +13 -0
  100. package/src/shared/components/ui/tabs.tsx +24 -1
  101. package/src/shared/hooks/use-server-path-watch.ts +42 -0
  102. package/src/shared/lib/api/server-path/server-path.types.ts +10 -0
  103. package/src/shared/lib/api/types.ts +3 -0
  104. package/src/shared/lib/api/utils/server-path.utils.ts +10 -0
  105. package/src/shared/lib/i18n/locales/en-US/chat.json +1 -0
  106. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +49 -7
  107. package/src/shared/lib/i18n/locales/zh-CN/chat.json +1 -0
  108. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +49 -7
  109. package/vite.config.ts +32 -1
  110. package/dist/assets/desktop-update-config-Dirc3bpt.js +0 -1
  111. package/dist/assets/doc-browser-4Sud6dWw.js +0 -1
  112. package/dist/assets/doc-browser-D4x_yH5F.js +0 -1
  113. package/dist/assets/form-actions-B2q4jgjE.js +0 -1
  114. package/dist/assets/index-CdyTsSBu.css +0 -1
  115. package/dist/assets/index-Das_bw2h.js +0 -107
  116. package/dist/assets/loader-circle-CpmBdaon.js +0 -1
  117. package/dist/assets/mcp-marketplace-page-CdOY3Sop.js +0 -1
  118. package/dist/assets/mcp-marketplace-page-DvjrfqEt.js +0 -9
  119. package/dist/assets/plus-C8FkjpSZ.js +0 -1
  120. package/dist/assets/react-C8XFFvuz.js +0 -8
  121. package/dist/assets/remote-BJHXa4mB.js +0 -1
  122. package/dist/assets/rotate-cw-DJ7UF3F2.js +0 -1
  123. package/dist/assets/security-config-CbDiY2xr.js +0 -1
  124. package/dist/assets/skeleton-XpqeWL62.js +0 -1
  125. package/dist/assets/x-odTHAtLZ.js +0 -1
@@ -0,0 +1,20 @@
1
+ import type { QueryClient } from '@tanstack/react-query';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { invalidateChangedServerPath } from '@/app/hooks/use-app-event-consumers';
4
+
5
+ describe('server path realtime invalidation', () => {
6
+ it('invalidates only the browse query for the changed directory', () => {
7
+ const invalidateQueries = vi.fn();
8
+ invalidateChangedServerPath(
9
+ { invalidateQueries } as unknown as QueryClient,
10
+ '/Users/peiwang/Projects/nextbot/src',
11
+ );
12
+
13
+ const request = invalidateQueries.mock.calls[0]?.[0] as {
14
+ predicate: (query: { queryKey: readonly unknown[] }) => boolean;
15
+ };
16
+ expect(request.predicate({ queryKey: ['server-path-browse', '/Users/peiwang/Projects/nextbot/src', '', true] })).toBe(true);
17
+ expect(request.predicate({ queryKey: ['server-path-browse', '/Users/peiwang/Projects/nextbot', '', true] })).toBe(false);
18
+ expect(request.predicate({ queryKey: ['server-path-read', '/Users/peiwang/Projects/nextbot/src', ''] })).toBe(false);
19
+ });
20
+ });
@@ -30,6 +30,22 @@ function handleConfigUpdatedEvent(queryClient: QueryClient | undefined, path: st
30
30
  invalidateMarketplaceQueries(queryClient, path);
31
31
  }
32
32
 
33
+ function normalizeServerPath(path: string): string {
34
+ const normalized = path.trim().replace(/\\/g, '/').replace(/\/+$/g, '');
35
+ return /^[a-z]:/i.test(normalized) ? normalized.toLowerCase() : normalized;
36
+ }
37
+
38
+ export function invalidateChangedServerPath(queryClient: QueryClient | undefined, directoryPath: string): void {
39
+ if (!queryClient) return;
40
+ const changedDirectory = normalizeServerPath(directoryPath);
41
+ void queryClient.invalidateQueries({
42
+ predicate: (query) =>
43
+ query.queryKey[0] === 'server-path-browse' &&
44
+ typeof query.queryKey[1] === 'string' &&
45
+ normalizeServerPath(query.queryKey[1]) === changedDirectory,
46
+ });
47
+ }
48
+
33
49
  export function useAppEventConsumers(queryClient?: QueryClient) {
34
50
  const shouldResyncSessionsRef = useRef(false);
35
51
 
@@ -53,6 +69,10 @@ export function useAppEventConsumers(queryClient?: QueryClient) {
53
69
  const unsubscribeConfigUpdated = nextclawClient.eventBus.on(eventKeys.configUpdated, ({ path }) => {
54
70
  handleConfigUpdatedEvent(queryClient, path);
55
71
  });
72
+ const unsubscribeServerPathChanged = nextclawClient.eventBus.on(
73
+ eventKeys.serverPathChanged,
74
+ ({ directoryPath }) => invalidateChangedServerPath(queryClient, directoryPath),
75
+ );
56
76
  const unsubscribeRuntimeUpdate = nextclawClient.eventBus.on(eventKeys.runtimeUpdateSnapshot, (snapshot) => {
57
77
  runtimeUpdateManager.reportSnapshot(snapshot);
58
78
  });
@@ -74,6 +94,7 @@ export function useAppEventConsumers(queryClient?: QueryClient) {
74
94
  unsubscribeConnectionClose();
75
95
  unsubscribeConnectionError();
76
96
  unsubscribeConfigUpdated();
97
+ unsubscribeServerPathChanged();
77
98
  unsubscribeRuntimeUpdate();
78
99
  unsubscribeSessionRunStatus();
79
100
  unsubscribeSessionSummaryUpsert();
@@ -2,12 +2,14 @@ import { AccountManager } from '@/features/account';
2
2
  import { viewportLayoutManager } from '@/app/managers/viewport-layout.manager';
3
3
  import {
4
4
  ChatCompletionNotificationManager,
5
+ ChatComposerIntentManager,
5
6
  ChatDraftIntentManager,
6
7
  useChatThreadStore,
7
8
  } from '@/features/chat';
8
9
  import { AppNotificationManager } from '@/features/notifications';
9
10
  import { PanelAppBridgeManager } from '@/features/panel-apps';
10
11
  import { InboxManager } from '@/features/inbox';
12
+ import { AppPackageOperationSettlementManager } from '@/features/apps';
11
13
  import { RightPanelResourceRouteResolver } from '@/features/right-panel-resources';
12
14
  import { RemoteAccessManager } from '@/features/remote';
13
15
  import { ServiceActionAuthorizationManager } from '@/features/service-apps';
@@ -42,7 +44,11 @@ export class AppPresenter {
42
44
  this.rightPanelResourceRouteResolver,
43
45
  this.notifyRightPanelOpened,
44
46
  );
47
+ appPackageOperationSettlementManager = new AppPackageOperationSettlementManager(
48
+ this.docBrowserManager,
49
+ );
45
50
  sideDockManager = new SideDockManager(this.docBrowserManager);
51
+ chatComposerIntentManager = new ChatComposerIntentManager();
46
52
  chatDraftIntentManager = new ChatDraftIntentManager();
47
53
  serviceActionAuthorizationManager = new ServiceActionAuthorizationManager();
48
54
  panelAppBridgeManager = new PanelAppBridgeManager(this.serviceActionAuthorizationManager);
@@ -1,5 +1,6 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
+ import type { AppPackageOperationView } from '@nextclaw/client-sdk';
3
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
4
5
  import { AppPackagesPanel } from '@/features/apps/components/app-packages-panel';
5
6
 
@@ -9,7 +10,9 @@ const mocks = vi.hoisted(() => ({
9
10
  lifecycleMutate: vi.fn(),
10
11
  lifecycleReset: vi.fn(),
11
12
  onOpen: vi.fn(),
13
+ operations: [] as AppPackageOperationView[],
12
14
  recordOpened: vi.fn(),
15
+ refetchOperations: vi.fn(),
13
16
  refetchPackages: vi.fn(),
14
17
  refetchPanels: vi.fn(),
15
18
  requestAuthorization: vi.fn(async () => true),
@@ -17,6 +20,7 @@ const mocks = vi.hoisted(() => ({
17
20
 
18
21
  vi.mock('@/app/components/app-presenter-provider', () => ({
19
22
  useAppPresenter: () => ({
23
+ appPackageOperationSettlementManager: { settle: vi.fn() },
20
24
  serviceActionAuthorizationManager: {
21
25
  requestAuthorization: mocks.requestAuthorization,
22
26
  },
@@ -24,6 +28,14 @@ vi.mock('@/app/components/app-presenter-provider', () => ({
24
28
  }));
25
29
 
26
30
  vi.mock('@/features/apps/hooks/use-app-packages', () => ({
31
+ isAppPackageOperationActive: (status: string) => [
32
+ 'queued',
33
+ 'resolving',
34
+ 'downloading',
35
+ 'verifying',
36
+ 'installing',
37
+ 'finalizing',
38
+ ].includes(status),
27
39
  useAppPackageMutation: () => ({
28
40
  error: null,
29
41
  isError: false,
@@ -32,6 +44,11 @@ vi.mock('@/features/apps/hooks/use-app-packages', () => ({
32
44
  reset: mocks.lifecycleReset,
33
45
  variables: undefined,
34
46
  }),
47
+ useAppPackageOperationSettlement: () => undefined,
48
+ useAppPackageOperations: () => ({
49
+ data: { entries: mocks.operations },
50
+ refetch: mocks.refetchOperations,
51
+ }),
35
52
  useAppPackages: () => ({
36
53
  data: {
37
54
  entries: [{
@@ -101,22 +118,22 @@ vi.mock('@/features/apps/hooks/use-app-marketplace', () => ({
101
118
  webUrl: 'https://apps.nextclaw.io/apps/personal-organizer',
102
119
  },
103
120
  {
104
- id: 'app-starter-card',
105
- slug: 'starter-card',
106
- appId: 'nextclaw.starter-card',
107
- name: 'Starter Card',
108
- summary: 'A tiny starter app.',
109
- summaryI18n: { en: 'A tiny starter app.' },
110
- tags: ['starter'],
121
+ id: 'app-workspace-glance',
122
+ slug: 'workspace-glance',
123
+ appId: 'nextclaw.workspace-glance',
124
+ name: 'Workspace Glance',
125
+ summary: 'A local workspace overview.',
126
+ summaryI18n: { en: 'A local workspace overview.' },
127
+ tags: ['workspace', 'local'],
111
128
  latestVersion: '0.1.0',
112
129
  featured: false,
113
130
  publisher: { id: 'nextclaw', name: 'NextClaw' },
114
131
  install: {
115
132
  kind: 'registry',
116
- spec: 'nextclaw.starter-card',
133
+ spec: 'nextclaw.workspace-glance',
117
134
  registry: 'https://apps-registry.nextclaw.io/api/v1/apps/registry/',
118
135
  },
119
- webUrl: 'https://apps.nextclaw.io/apps/starter-card',
136
+ webUrl: 'https://apps.nextclaw.io/apps/workspace-glance',
120
137
  },
121
138
  ],
122
139
  },
@@ -124,6 +141,11 @@ vi.mock('@/features/apps/hooks/use-app-marketplace', () => ({
124
141
  isError: false,
125
142
  isLoading: false,
126
143
  }),
144
+ useAppMarketplaceDetail: () => ({
145
+ data: undefined,
146
+ error: null,
147
+ isLoading: false,
148
+ }),
127
149
  }));
128
150
 
129
151
  vi.mock('@/features/panel-apps/hooks/use-panel-apps', () => ({
@@ -164,8 +186,10 @@ describe('AppPackagesPanel', () => {
164
186
  mocks.lifecycleMutate.mockReset();
165
187
  mocks.lifecycleReset.mockReset();
166
188
  mocks.onOpen.mockReset();
189
+ mocks.operations = [];
167
190
  mocks.recordOpened.mockReset();
168
191
  mocks.refetchPackages.mockReset();
192
+ mocks.refetchOperations.mockReset();
169
193
  mocks.refetchPanels.mockReset();
170
194
  mocks.requestAuthorization.mockReset();
171
195
  mocks.requestAuthorization.mockResolvedValue(true);
@@ -211,10 +235,10 @@ describe('AppPackagesPanel', () => {
211
235
 
212
236
  render(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
213
237
 
214
- await user.click(screen.getByRole('button', { name: 'Browse apps' }));
238
+ await user.click(screen.getByRole('button', { name: 'Add apps' }));
215
239
 
216
- expect(screen.getByRole('heading', { name: 'Browse Mini APPs' })).toBeTruthy();
217
- expect(screen.getByText('Starter Card')).toBeTruthy();
240
+ expect(screen.getByRole('heading', { name: 'Add apps' })).toBeTruthy();
241
+ expect(screen.getByText('Workspace Glance')).toBeTruthy();
218
242
  expect(
219
243
  (screen.getByRole('button', { name: 'Installed' }) as HTMLButtonElement).disabled,
220
244
  ).toBe(true);
@@ -225,10 +249,88 @@ describe('AppPackagesPanel', () => {
225
249
  expect(mocks.lifecycleMutate).toHaveBeenCalledWith(
226
250
  {
227
251
  action: 'install',
228
- source: 'nextclaw.starter-card',
252
+ source: 'nextclaw.workspace-glance',
229
253
  registryUrl: 'https://apps-registry.nextclaw.io/api/v1/apps/registry/',
230
254
  },
231
- { onSuccess: expect.any(Function) },
255
+ { onSuccess: undefined },
256
+ );
257
+ });
258
+
259
+ it('allows a built-in app to be uninstalled while keeping its data by default', async () => {
260
+ const user = userEvent.setup();
261
+
262
+ render(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
263
+
264
+ await user.click(screen.getByRole('button', { name: 'More app actions' }));
265
+ await user.click(screen.getByRole('button', { name: 'Uninstall' }));
266
+ expect(screen.getByRole('heading', { name: 'Uninstall this app?' })).toBeTruthy();
267
+ expect(screen.getByRole('button', { name: /Keep personal data/ }).getAttribute('aria-pressed'))
268
+ .toBe('true');
269
+
270
+ await user.click(screen.getByRole('button', { name: 'Uninstall' }));
271
+ expect(mocks.lifecycleMutate).toHaveBeenCalledWith(
272
+ { action: 'uninstall', appId: 'nextclaw.personal-organizer', purgeData: false },
273
+ { onSuccess: undefined },
232
274
  );
233
275
  });
276
+
277
+ it('keeps an accepted install visible in the library and marketplace while it runs', async () => {
278
+ const user = userEvent.setup();
279
+ mocks.operations = [{
280
+ id: 'operation-1',
281
+ action: 'install',
282
+ appId: 'nextclaw.workspace-glance',
283
+ source: 'nextclaw.workspace-glance',
284
+ status: 'downloading',
285
+ completedSteps: 2,
286
+ totalSteps: 5,
287
+ createdAt: '2026-08-12T00:00:00.000Z',
288
+ updatedAt: '2026-08-12T00:00:01.000Z',
289
+ }];
290
+
291
+ render(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
292
+
293
+ expect(screen.getByRole('status').textContent).toContain('Downloading');
294
+ expect(screen.getByText('nextclaw.workspace-glance')).toBeTruthy();
295
+
296
+ await user.click(screen.getByRole('button', { name: 'Add apps' }));
297
+
298
+ expect(screen.getByRole('heading', { name: 'Add apps' })).toBeTruthy();
299
+ expect(screen.getAllByText('Downloading')).not.toHaveLength(0);
300
+ expect((screen.getByRole('button', { name: 'Downloading' }) as HTMLButtonElement).disabled)
301
+ .toBe(true);
302
+ });
303
+
304
+ it('clears an earlier failure after a newer operation succeeds', () => {
305
+ mocks.operations = [
306
+ {
307
+ id: 'operation-succeeded',
308
+ action: 'update',
309
+ appId: 'nextclaw.personal-organizer',
310
+ status: 'succeeded',
311
+ completedSteps: 5,
312
+ totalSteps: 5,
313
+ createdAt: '2026-08-13T11:27:49.090Z',
314
+ updatedAt: '2026-08-13T11:27:50.627Z',
315
+ completedAt: '2026-08-13T11:27:50.627Z',
316
+ },
317
+ {
318
+ id: 'operation-failed',
319
+ action: 'update',
320
+ appId: 'nextclaw.personal-organizer',
321
+ status: 'failed',
322
+ completedSteps: 1,
323
+ totalSteps: 5,
324
+ createdAt: '2026-08-13T11:08:08.080Z',
325
+ updatedAt: '2026-08-13T11:08:10.316Z',
326
+ completedAt: '2026-08-13T11:08:10.316Z',
327
+ error: 'name 必须是非空字符串。',
328
+ },
329
+ ];
330
+
331
+ render(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
332
+
333
+ expect(screen.queryByRole('alert')).toBeNull();
334
+ expect(screen.queryByText('name 必须是非空字符串。')).toBeNull();
335
+ });
234
336
  });
@@ -0,0 +1,58 @@
1
+ import { useState } from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { AppsPanel, type AppsPanelTab } from '@/features/apps/components/apps-panel';
6
+
7
+ vi.mock('@/shared/lib/i18n', () => ({ t: (key: string) => key }));
8
+ vi.mock('@/features/apps/components/app-packages-panel', () => ({
9
+ AppPackagesPanel: () => <div>packages-content</div>,
10
+ }));
11
+ vi.mock('@/features/panel-apps', () => ({
12
+ PanelAppsList: () => <div>panel-apps-content</div>,
13
+ }));
14
+ vi.mock('@/features/service-apps', () => ({
15
+ ServiceAppsPanel: () => <div>service-apps-content</div>,
16
+ }));
17
+
18
+ function AppsPanelHarness() {
19
+ const [activeTab, setActiveTab] = useState<AppsPanelTab>('apps');
20
+ return (
21
+ <AppsPanel
22
+ activeTab={activeTab}
23
+ onActiveTabChange={setActiveTab}
24
+ onOpenPanelApp={vi.fn()}
25
+ />
26
+ );
27
+ }
28
+
29
+ describe('AppsPanel', () => {
30
+ it('keeps one fixed-width navigation instance while switching app types', async () => {
31
+ const user = userEvent.setup();
32
+ render(<AppsPanelHarness />);
33
+
34
+ const navigation = screen.getByRole('tablist');
35
+ expect(navigation.className).toContain('w-full');
36
+ expect(navigation.className).toContain('max-w-[390px]');
37
+
38
+ await user.click(screen.getByRole('tab', { name: 'panelAppsTitle' }));
39
+ expect(screen.getByText('panel-apps-content')).toBeTruthy();
40
+ expect(screen.getByRole('tablist')).toBe(navigation);
41
+
42
+ await user.click(screen.getByRole('tab', { name: 'serviceAppsTitle' }));
43
+ expect(screen.getByText('service-apps-content')).toBeTruthy();
44
+ expect(screen.getByRole('tablist')).toBe(navigation);
45
+ });
46
+
47
+ it('supports arrow-key navigation through the shared tab primitive', async () => {
48
+ const user = userEvent.setup();
49
+ render(<AppsPanelHarness />);
50
+
51
+ const firstTab = screen.getByRole('tab', { name: 'appsTitle' });
52
+ firstTab.focus();
53
+ await user.keyboard('{ArrowRight}');
54
+
55
+ expect(document.activeElement).toBe(screen.getByRole('tab', { name: 'panelAppsTitle' }));
56
+ expect(screen.getByText('panel-apps-content')).toBeTruthy();
57
+ });
58
+ });
@@ -0,0 +1,49 @@
1
+ import { useState } from 'react';
2
+ import { cn } from '@/shared/lib/utils';
3
+
4
+ export function AppArtwork({
5
+ className,
6
+ icon,
7
+ name,
8
+ }: {
9
+ className?: string;
10
+ icon?: string;
11
+ name: string;
12
+ }) {
13
+ const [failedIcon, setFailedIcon] = useState<string>();
14
+ const imageIcon = isImageIcon(icon);
15
+ const imageFailed = failedIcon === icon;
16
+
17
+ return (
18
+ <span className={cn(
19
+ 'relative flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-[13px] bg-gradient-to-br from-violet-500/15 via-primary/10 to-emerald-500/15 text-sm font-semibold text-foreground ring-1 ring-black/[0.04] dark:ring-white/[0.06]',
20
+ className,
21
+ )}>
22
+ {imageIcon && !imageFailed ? (
23
+ <img
24
+ src={icon}
25
+ alt=""
26
+ className="h-full w-full object-cover"
27
+ onError={() => setFailedIcon(icon)}
28
+ />
29
+ ) : icon && !imageIcon ? (
30
+ <span className="text-base leading-none" aria-hidden="true">{icon}</span>
31
+ ) : (
32
+ <span aria-hidden="true">{readInitial(name)}</span>
33
+ )}
34
+ </span>
35
+ );
36
+ }
37
+
38
+ function isImageIcon(icon: string | undefined): icon is string {
39
+ return Boolean(icon) && (
40
+ icon!.startsWith('data:') ||
41
+ icon!.startsWith('/') ||
42
+ icon!.startsWith('http://') ||
43
+ icon!.startsWith('https://')
44
+ );
45
+ }
46
+
47
+ function readInitial(name: string): string {
48
+ return [...name.trim()][0]?.toLocaleUpperCase() ?? 'A';
49
+ }