@nextclaw/ui 0.15.26 → 0.15.27

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 +16 -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-DlGXLgff.js} +1 -1
  4. package/dist/assets/{channels-list-page-CVCZBlCL.js → channels-list-page-BEEk4Uc9.js} +2 -2
  5. package/dist/assets/{chat-page-D4wehRTz.js → chat-page-DUQXXqQm.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--zagjHic.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-Ba_o5fu9.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-rrhFaque.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-CFsfWEt9.js} +1 -1
  24. package/dist/assets/{providers-config-page-CaO8WfZH.js → providers-config-page-D-TYFZsF.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-BwVv_Qq-.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-Br9j9Vxe.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-B3fvzMd1.js} +1 -1
  33. package/dist/assets/{secrets-config-page-BlPOuql_.js → secrets-config-page-ClwTxtXd.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 +7 -7
  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 +92 -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 +98 -26
  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);
@@ -9,7 +9,19 @@ const mocks = vi.hoisted(() => ({
9
9
  lifecycleMutate: vi.fn(),
10
10
  lifecycleReset: vi.fn(),
11
11
  onOpen: vi.fn(),
12
+ operations: [] as Array<{
13
+ id: string;
14
+ action: 'install';
15
+ appId: string;
16
+ source: string;
17
+ status: 'downloading';
18
+ completedSteps: number;
19
+ totalSteps: number;
20
+ createdAt: string;
21
+ updatedAt: string;
22
+ }>,
12
23
  recordOpened: vi.fn(),
24
+ refetchOperations: vi.fn(),
13
25
  refetchPackages: vi.fn(),
14
26
  refetchPanels: vi.fn(),
15
27
  requestAuthorization: vi.fn(async () => true),
@@ -17,6 +29,7 @@ const mocks = vi.hoisted(() => ({
17
29
 
18
30
  vi.mock('@/app/components/app-presenter-provider', () => ({
19
31
  useAppPresenter: () => ({
32
+ appPackageOperationSettlementManager: { settle: vi.fn() },
20
33
  serviceActionAuthorizationManager: {
21
34
  requestAuthorization: mocks.requestAuthorization,
22
35
  },
@@ -24,6 +37,14 @@ vi.mock('@/app/components/app-presenter-provider', () => ({
24
37
  }));
25
38
 
26
39
  vi.mock('@/features/apps/hooks/use-app-packages', () => ({
40
+ isAppPackageOperationActive: (status: string) => [
41
+ 'queued',
42
+ 'resolving',
43
+ 'downloading',
44
+ 'verifying',
45
+ 'installing',
46
+ 'finalizing',
47
+ ].includes(status),
27
48
  useAppPackageMutation: () => ({
28
49
  error: null,
29
50
  isError: false,
@@ -32,6 +53,11 @@ vi.mock('@/features/apps/hooks/use-app-packages', () => ({
32
53
  reset: mocks.lifecycleReset,
33
54
  variables: undefined,
34
55
  }),
56
+ useAppPackageOperationSettlement: () => undefined,
57
+ useAppPackageOperations: () => ({
58
+ data: { entries: mocks.operations },
59
+ refetch: mocks.refetchOperations,
60
+ }),
35
61
  useAppPackages: () => ({
36
62
  data: {
37
63
  entries: [{
@@ -101,22 +127,22 @@ vi.mock('@/features/apps/hooks/use-app-marketplace', () => ({
101
127
  webUrl: 'https://apps.nextclaw.io/apps/personal-organizer',
102
128
  },
103
129
  {
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'],
130
+ id: 'app-workspace-glance',
131
+ slug: 'workspace-glance',
132
+ appId: 'nextclaw.workspace-glance',
133
+ name: 'Workspace Glance',
134
+ summary: 'A local workspace overview.',
135
+ summaryI18n: { en: 'A local workspace overview.' },
136
+ tags: ['workspace', 'local'],
111
137
  latestVersion: '0.1.0',
112
138
  featured: false,
113
139
  publisher: { id: 'nextclaw', name: 'NextClaw' },
114
140
  install: {
115
141
  kind: 'registry',
116
- spec: 'nextclaw.starter-card',
142
+ spec: 'nextclaw.workspace-glance',
117
143
  registry: 'https://apps-registry.nextclaw.io/api/v1/apps/registry/',
118
144
  },
119
- webUrl: 'https://apps.nextclaw.io/apps/starter-card',
145
+ webUrl: 'https://apps.nextclaw.io/apps/workspace-glance',
120
146
  },
121
147
  ],
122
148
  },
@@ -124,6 +150,11 @@ vi.mock('@/features/apps/hooks/use-app-marketplace', () => ({
124
150
  isError: false,
125
151
  isLoading: false,
126
152
  }),
153
+ useAppMarketplaceDetail: () => ({
154
+ data: undefined,
155
+ error: null,
156
+ isLoading: false,
157
+ }),
127
158
  }));
128
159
 
129
160
  vi.mock('@/features/panel-apps/hooks/use-panel-apps', () => ({
@@ -164,8 +195,10 @@ describe('AppPackagesPanel', () => {
164
195
  mocks.lifecycleMutate.mockReset();
165
196
  mocks.lifecycleReset.mockReset();
166
197
  mocks.onOpen.mockReset();
198
+ mocks.operations = [];
167
199
  mocks.recordOpened.mockReset();
168
200
  mocks.refetchPackages.mockReset();
201
+ mocks.refetchOperations.mockReset();
169
202
  mocks.refetchPanels.mockReset();
170
203
  mocks.requestAuthorization.mockReset();
171
204
  mocks.requestAuthorization.mockResolvedValue(true);
@@ -211,10 +244,10 @@ describe('AppPackagesPanel', () => {
211
244
 
212
245
  render(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
213
246
 
214
- await user.click(screen.getByRole('button', { name: 'Browse apps' }));
247
+ await user.click(screen.getByRole('button', { name: 'Add apps' }));
215
248
 
216
- expect(screen.getByRole('heading', { name: 'Browse Mini APPs' })).toBeTruthy();
217
- expect(screen.getByText('Starter Card')).toBeTruthy();
249
+ expect(screen.getByRole('heading', { name: 'Add apps' })).toBeTruthy();
250
+ expect(screen.getByText('Workspace Glance')).toBeTruthy();
218
251
  expect(
219
252
  (screen.getByRole('button', { name: 'Installed' }) as HTMLButtonElement).disabled,
220
253
  ).toBe(true);
@@ -225,10 +258,55 @@ describe('AppPackagesPanel', () => {
225
258
  expect(mocks.lifecycleMutate).toHaveBeenCalledWith(
226
259
  {
227
260
  action: 'install',
228
- source: 'nextclaw.starter-card',
261
+ source: 'nextclaw.workspace-glance',
229
262
  registryUrl: 'https://apps-registry.nextclaw.io/api/v1/apps/registry/',
230
263
  },
231
- { onSuccess: expect.any(Function) },
264
+ { onSuccess: undefined },
232
265
  );
233
266
  });
267
+
268
+ it('allows a built-in app to be uninstalled while keeping its data by default', async () => {
269
+ const user = userEvent.setup();
270
+
271
+ render(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
272
+
273
+ await user.click(screen.getByRole('button', { name: 'More app actions' }));
274
+ await user.click(screen.getByRole('button', { name: 'Uninstall' }));
275
+ expect(screen.getByRole('heading', { name: 'Uninstall this app?' })).toBeTruthy();
276
+ expect(screen.getByRole('button', { name: /Keep personal data/ }).getAttribute('aria-pressed'))
277
+ .toBe('true');
278
+
279
+ await user.click(screen.getByRole('button', { name: 'Uninstall' }));
280
+ expect(mocks.lifecycleMutate).toHaveBeenCalledWith(
281
+ { action: 'uninstall', appId: 'nextclaw.personal-organizer', purgeData: false },
282
+ { onSuccess: undefined },
283
+ );
284
+ });
285
+
286
+ it('keeps an accepted install visible in the library and marketplace while it runs', async () => {
287
+ const user = userEvent.setup();
288
+ mocks.operations = [{
289
+ id: 'operation-1',
290
+ action: 'install',
291
+ appId: 'nextclaw.workspace-glance',
292
+ source: 'nextclaw.workspace-glance',
293
+ status: 'downloading',
294
+ completedSteps: 2,
295
+ totalSteps: 5,
296
+ createdAt: '2026-08-12T00:00:00.000Z',
297
+ updatedAt: '2026-08-12T00:00:01.000Z',
298
+ }];
299
+
300
+ render(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
301
+
302
+ expect(screen.getByRole('status').textContent).toContain('Downloading');
303
+ expect(screen.getByText('nextclaw.workspace-glance')).toBeTruthy();
304
+
305
+ await user.click(screen.getByRole('button', { name: 'Add apps' }));
306
+
307
+ expect(screen.getByRole('heading', { name: 'Add apps' })).toBeTruthy();
308
+ expect(screen.getAllByText('Downloading')).not.toHaveLength(0);
309
+ expect((screen.getByRole('button', { name: 'Downloading' }) as HTMLButtonElement).disabled)
310
+ .toBe(true);
311
+ });
234
312
  });
@@ -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
+ }